mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-29 08:39:03 +00:00
Compare commits
103 Commits
0.6.8
...
eb27f19196
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
b3eab6ae26 | ||
|
|
a5d10b3539 | ||
|
|
1c901a9dda | ||
|
|
1e94333d8f | ||
|
|
4006dd128e | ||
|
|
7d9744a40e | ||
|
|
8654471042 | ||
|
|
e9711339bd | ||
|
|
0c030efef2 | ||
|
|
e4e04ea34e | ||
|
|
f4bf549b99 | ||
|
|
8cc4f567f8 | ||
|
|
a939b841f3 | ||
|
|
caa43e8eb0 | ||
|
|
0a8750ea36 | ||
|
|
2d6800eff8 | ||
|
|
544527f282 | ||
|
|
91020e32ea | ||
|
|
5a823d62f0 | ||
|
|
a00d6ce2ce | ||
|
|
f47644e8c6 | ||
|
|
c0ee21d6fa | ||
|
|
7dd33084f5 | ||
|
|
ca41b7af0a | ||
|
|
aa97f36819 | ||
|
|
d9c50d1298 | ||
|
|
61fafecb78 | ||
|
|
13e92ccd34 | ||
|
|
433ba3faf9 | ||
|
|
da6c7bbf3e | ||
|
|
90e58734ad | ||
|
|
03e16a9b54 | ||
|
|
53e08e8e4b | ||
|
|
84fe79b37b | ||
|
|
1a6578100a | ||
|
|
3270dc4585 | ||
|
|
8fabaf7b86 | ||
|
|
8006e5e1f6 | ||
|
|
5e0b8eb2a4 | ||
|
|
f2e05484dc | ||
|
|
37949a4e1f | ||
|
|
abf39abe74 | ||
|
|
957099786c | ||
|
|
2191fe9066 | ||
|
|
bc947183e3 | ||
|
|
16afa1ed8a | ||
|
|
541cb262ee | ||
|
|
eaaea6e459 | ||
|
|
34501f5f0d | ||
|
|
dcac53d181 | ||
|
|
cb2e70e42d | ||
|
|
417b77539f | ||
|
|
947342e196 | ||
|
|
c5fd26cb3e | ||
|
|
6c8b8ed3cc | ||
|
|
abacfd78c8 | ||
|
|
26487cff89 | ||
|
|
46f3f5c498 | ||
|
|
6bce7eb6be | ||
|
|
155c30559f | ||
|
|
ec8ef8346a | ||
|
|
de3da84dff | ||
|
|
47164e8546 | ||
|
|
34e793c75c | ||
|
|
4018b263f2 | ||
|
|
f0fee4fd82 | ||
|
|
91f53b35b1 | ||
|
|
73d6bd32db | ||
|
|
6b2c19778b | ||
|
|
355fa5cfb6 | ||
|
|
7cf471402b | ||
|
|
7480094419 | ||
|
|
d69bd5d115 | ||
|
|
ca29d527c9 |
22
.claude/settings.local.json
Normal file
22
.claude/settings.local.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(uv sync:*)",
|
||||
"Bash(uv run pytest:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(ruff check:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(mypy:*)",
|
||||
"WebFetch(domain:localhost)",
|
||||
"Bash(npm create:*)",
|
||||
"Bash(npm install)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npx tailwindcss init:*)",
|
||||
"Bash(./node_modules/.bin/tailwindcss:*)",
|
||||
"Bash(npm run build:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
.git/
|
||||
data/
|
||||
docker-compose.dev.yml
|
||||
frontend/node_modules/
|
||||
.venv/
|
||||
|
||||
55
.github/workflows/ci.yml
vendored
Normal file
55
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main", "dev" ]
|
||||
pull_request:
|
||||
branches: [ "main", "dev" ]
|
||||
|
||||
jobs:
|
||||
test-python:
|
||||
name: Test Python
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
|
||||
- name: Create config directory for tests
|
||||
run: |
|
||||
mkdir -p ~/.config/leggen
|
||||
cp config.example.toml ~/.config/leggen/config.toml
|
||||
|
||||
- name: Run Python tests
|
||||
run: uv run pytest
|
||||
|
||||
test-frontend:
|
||||
name: Test Frontend
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Run lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Run build
|
||||
run: npm run build
|
||||
119
.github/workflows/release.yml
vendored
119
.github/workflows/release.yml
vendored
@@ -6,27 +6,43 @@ on:
|
||||
- "**"
|
||||
|
||||
jobs:
|
||||
publish-pypi:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: "Set up Python"
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
|
||||
- name: Build Package
|
||||
run: uv build
|
||||
- name: Store the distribution packages
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
||||
publish-to-pypi:
|
||||
name: Publish Python distribution to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # IMPORTANT: mandatory for trusted publishing
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download all the dists
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
- name: Publish package
|
||||
run: uv publish
|
||||
|
||||
push-docker:
|
||||
push-docker-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -46,10 +62,12 @@ jobs:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
- name: Docker meta backend
|
||||
id: meta-backend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
flavor: |
|
||||
latest=false
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
elisiariocouto/leggen
|
||||
@@ -59,11 +77,90 @@ jobs:
|
||||
type=ref,event=tag
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
- name: Build and push
|
||||
type=raw,value=latest
|
||||
- name: Build and push backend
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta-backend.outputs.tags }}
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
|
||||
push-docker-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: elisiariocouto
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Docker meta frontend
|
||||
id: meta-frontend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
flavor: |
|
||||
latest=false
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
elisiariocouto/leggen
|
||||
ghcr.io/elisiariocouto/leggen
|
||||
# generate Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
type=ref,event=tag,suffix=-frontend
|
||||
type=semver,pattern={{version}},suffix=-frontend
|
||||
type=semver,pattern={{major}}.{{minor}},suffix=-frontend
|
||||
type=raw,value=latest-frontend
|
||||
- name: Build and push frontend
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./frontend
|
||||
file: ./frontend/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta-frontend.outputs.tags }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
|
||||
create-github-release:
|
||||
name: Create GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build, publish-to-pypi, push-docker-backend, push-docker-frontend]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install git-cliff
|
||||
run: |
|
||||
wget -qO- https://github.com/orhun/git-cliff/releases/latest/download/git-cliff-2.10.0-x86_64-unknown-linux-gnu.tar.gz | tar xz
|
||||
sudo mv git-cliff-*/git-cliff /usr/local/bin/
|
||||
|
||||
- name: Generate release notes
|
||||
id: release_notes
|
||||
run: |
|
||||
echo "notes<<EOF" >> $GITHUB_OUTPUT
|
||||
git-cliff --current >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: Release ${{ github.ref_name }}
|
||||
body: ${{ steps.release_notes.outputs.notes }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -14,7 +14,6 @@ dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
@@ -163,3 +162,5 @@ docker-compose.dev.yml
|
||||
nocodb/
|
||||
sql/
|
||||
leggen.db
|
||||
*.db
|
||||
config.toml
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
repos:
|
||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||
rev: "v0.9.1"
|
||||
rev: "v0.13.0"
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-check
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
exclude: ".*\\.md$"
|
||||
- id: end-of-file-fixer
|
||||
- id: check-added-large-files
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: mypy
|
||||
name: Static type check with mypy
|
||||
entry: uv run mypy leggen --check-untyped-defs
|
||||
files: "^leggen/.*"
|
||||
language: "system"
|
||||
types: ["python"]
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
|
||||
106
AGENTS.md
Normal file
106
AGENTS.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# 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
|
||||
|
||||
### Frontend (React/TypeScript)
|
||||
- **Dev server**: `cd frontend && npm run dev`
|
||||
- **Build**: `cd frontend && npm run build`
|
||||
- **Lint**: `cd frontend && npm run lint`
|
||||
|
||||
### Backend (Python)
|
||||
- **Lint**: `uv run ruff check .`
|
||||
- **Format**: `uv run ruff format .`
|
||||
- **Type check**: `uv run mypy leggen --check-untyped-defs`
|
||||
- **All checks**: `uv run pre-commit run --all-files`
|
||||
- **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 tests by marker**: `uv run pytest -m "api"` or `uv run pytest -m "unit"`
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Python
|
||||
- **Imports**: Standard library → Third-party → Local (blank lines between groups)
|
||||
- **Naming**: snake_case for variables/functions, PascalCase for classes
|
||||
- **Types**: Use type hints for all function parameters and return values
|
||||
- **Error handling**: Use specific exceptions, loguru for logging
|
||||
- **Path handling**: Use `pathlib.Path` instead of `os.path`
|
||||
- **CLI**: Use Click framework with proper option decorators
|
||||
|
||||
### TypeScript/React
|
||||
- **Imports**: React hooks first, then third-party, then local components/types
|
||||
- **Naming**: PascalCase for components, camelCase for variables/functions
|
||||
- **Types**: Use `import type` for type-only imports, define interfaces/types
|
||||
- **Styling**: Tailwind CSS with `clsx` utility for conditional classes
|
||||
- **Icons**: lucide-react with consistent naming
|
||||
- **Data fetching**: @tanstack/react-query with proper error handling
|
||||
- **Components**: Functional components with hooks, proper TypeScript typing
|
||||
|
||||
### General
|
||||
- **Formatting**: ruff for Python, ESLint for TypeScript
|
||||
- **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
|
||||
- **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`
|
||||
383
CHANGELOG.md
383
CHANGELOG.md
@@ -1,4 +1,387 @@
|
||||
|
||||
## 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)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **core:** Handle permission errors gracefully in database path creation. ([4006dd12](https://github.com/elisiariocouto/leggen/commit/4006dd128e0896b338cb93fad60a1eca90c1873d))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1e94333d](https://github.com/elisiariocouto/leggen/commit/1e94333d8f0275542ae7fd6e49fb8b7f03ad3d11))
|
||||
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1c901a9d](https://github.com/elisiariocouto/leggen/commit/1c901a9ddab0f6515dce56df8cce74518805a6bb))
|
||||
- Remove config.toml file - should be created when needed ([a5d10b35](https://github.com/elisiariocouto/leggen/commit/a5d10b3539e7cfc649b0fee05b12c4a03681e135))
|
||||
|
||||
|
||||
### Refactor
|
||||
|
||||
- **core:** Integrate directory creation with database path retrieval and remove backup file. ([7d9744a4](https://github.com/elisiariocouto/leggen/commit/7d9744a40e7898e5bbe52e2e9f54317aa5c1cdd6))
|
||||
|
||||
|
||||
|
||||
## 2025.9.9 (2025/09/11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **core:** Handle permission errors gracefully in database path creation. ([4006dd12](https://github.com/elisiariocouto/leggen/commit/4006dd128e0896b338cb93fad60a1eca90c1873d))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1e94333d](https://github.com/elisiariocouto/leggen/commit/1e94333d8f0275542ae7fd6e49fb8b7f03ad3d11))
|
||||
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1c901a9d](https://github.com/elisiariocouto/leggen/commit/1c901a9ddab0f6515dce56df8cce74518805a6bb))
|
||||
- Remove config.toml file - should be created when needed ([a5d10b35](https://github.com/elisiariocouto/leggen/commit/a5d10b3539e7cfc649b0fee05b12c4a03681e135))
|
||||
|
||||
|
||||
### Refactor
|
||||
|
||||
- **core:** Integrate directory creation with database path retrieval and remove backup file. ([7d9744a4](https://github.com/elisiariocouto/leggen/commit/7d9744a40e7898e5bbe52e2e9f54317aa5c1cdd6))
|
||||
|
||||
|
||||
|
||||
## 2025.9.8 (2025/09/11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Change branch name from develop to dev in CI workflow ([f4bf549b](https://github.com/elisiariocouto/leggen/commit/f4bf549b99197d70104abf5731ab1ccb67cc9a69))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- Update CI workflow to use Node.js 20 instead of 18 ([e4e04ea3](https://github.com/elisiariocouto/leggen/commit/e4e04ea34ea568c08292562243b6e6c08234d918))
|
||||
|
||||
|
||||
|
||||
## 2025.9.8 (2025/09/11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Change branch name from develop to dev in CI workflow ([f4bf549b](https://github.com/elisiariocouto/leggen/commit/f4bf549b99197d70104abf5731ab1ccb67cc9a69))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- Update CI workflow to use Node.js 20 instead of 18 ([e4e04ea3](https://github.com/elisiariocouto/leggen/commit/e4e04ea34ea568c08292562243b6e6c08234d918))
|
||||
|
||||
|
||||
|
||||
## 2025.9.7 (2025/09/11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Simplify notification settings and fix notification test on dashboard. ([91020e32](https://github.com/elisiariocouto/leggen/commit/91020e32ea836ee8af4aeaf5d49525c24b566aed))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **frontend:** Implement TanStack Table for transactions view ([544527f2](https://github.com/elisiariocouto/leggen/commit/544527f28284fb9644bec6e721fa5da8ce10739f))
|
||||
- Improve transactions API pagination and search ([2d6800ef](https://github.com/elisiariocouto/leggen/commit/2d6800eff8e484d3d175225f94d854706584a773))
|
||||
|
||||
|
||||
|
||||
## 2025.9.7 (2025/09/11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Simplify notification settings and fix notification test on dashboard. ([91020e32](https://github.com/elisiariocouto/leggen/commit/91020e32ea836ee8af4aeaf5d49525c24b566aed))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **frontend:** Implement TanStack Table for transactions view ([544527f2](https://github.com/elisiariocouto/leggen/commit/544527f28284fb9644bec6e721fa5da8ce10739f))
|
||||
- Improve transactions API pagination and search ([2d6800ef](https://github.com/elisiariocouto/leggen/commit/2d6800eff8e484d3d175225f94d854706584a773))
|
||||
|
||||
|
||||
|
||||
## 2025.9.6 (2025/09/10)
|
||||
|
||||
### Features
|
||||
|
||||
- **db:** Migrate transactions table to composite primary key ([a00d6ce2](https://github.com/elisiariocouto/leggen/commit/a00d6ce2ce2c4a070e9fae56c0cea58b3aab6cec))
|
||||
|
||||
|
||||
|
||||
## 2025.9.6 (2025/09/10)
|
||||
|
||||
### Features
|
||||
|
||||
- **db:** Migrate transactions table to composite primary key ([a00d6ce2](https://github.com/elisiariocouto/leggen/commit/a00d6ce2ce2c4a070e9fae56c0cea58b3aab6cec))
|
||||
|
||||
|
||||
|
||||
## 2025.9.5 (2025/09/10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Correct composite key migration check ([c0ee21d6](https://github.com/elisiariocouto/leggen/commit/c0ee21d6fa8d5d61c029bd9334a7674fce99f729))
|
||||
|
||||
|
||||
|
||||
## 2025.9.5 (2025/09/10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Correct composite key migration check ([c0ee21d6](https://github.com/elisiariocouto/leggen/commit/c0ee21d6fa8d5d61c029bd9334a7674fce99f729))
|
||||
|
||||
|
||||
|
||||
## 2025.9.4 (2025/09/10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **api:** Resolve duplicate transactions with composite key migration ([13e92ccd](https://github.com/elisiariocouto/leggen/commit/13e92ccd3497bacf3b8639f6332cd3f4b682bd0a))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **api:** Add currency extraction and account name updates ([d9c50d12](https://github.com/elisiariocouto/leggen/commit/d9c50d129825529e0fb6477e5b62c0f990523bca))
|
||||
- **frontend:** Adapt to composite key transaction structure ([61fafecb](https://github.com/elisiariocouto/leggen/commit/61fafecb780a877a69ecca27ea95a1494669b70d))
|
||||
- **frontend:** Add account name editing functionality ([aa97f368](https://github.com/elisiariocouto/leggen/commit/aa97f36819f15f1afc34f45642abdc6e2ce6c883))
|
||||
- **frontend:** Implement TanStack Router with mobile sidebar ([ca41b7af](https://github.com/elisiariocouto/leggen/commit/ca41b7af0a5e50e0350857a4ace7979b7b29eab2))
|
||||
- **web:** Add modal to view raw transaction. ([433ba3fa](https://github.com/elisiariocouto/leggen/commit/433ba3faf9937613786e66e9ee13152f96d00c43))
|
||||
|
||||
|
||||
|
||||
## 2025.9.4 (2025/09/10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **api:** Resolve duplicate transactions with composite key migration ([13e92ccd](https://github.com/elisiariocouto/leggen/commit/13e92ccd3497bacf3b8639f6332cd3f4b682bd0a))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **api:** Add currency extraction and account name updates ([d9c50d12](https://github.com/elisiariocouto/leggen/commit/d9c50d129825529e0fb6477e5b62c0f990523bca))
|
||||
- **frontend:** Adapt to composite key transaction structure ([61fafecb](https://github.com/elisiariocouto/leggen/commit/61fafecb780a877a69ecca27ea95a1494669b70d))
|
||||
- **frontend:** Add account name editing functionality ([aa97f368](https://github.com/elisiariocouto/leggen/commit/aa97f36819f15f1afc34f45642abdc6e2ce6c883))
|
||||
- **frontend:** Implement TanStack Router with mobile sidebar ([ca41b7af](https://github.com/elisiariocouto/leggen/commit/ca41b7af0a5e50e0350857a4ace7979b7b29eab2))
|
||||
- **web:** Add modal to view raw transaction. ([433ba3fa](https://github.com/elisiariocouto/leggen/commit/433ba3faf9937613786e66e9ee13152f96d00c43))
|
||||
|
||||
|
||||
|
||||
## 2025.9.4 (2025/09/10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **api:** Resolve duplicate transactions with composite key migration ([13e92ccd](https://github.com/elisiariocouto/leggen/commit/13e92ccd3497bacf3b8639f6332cd3f4b682bd0a))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **api:** Add currency extraction and account name updates ([d9c50d12](https://github.com/elisiariocouto/leggen/commit/d9c50d129825529e0fb6477e5b62c0f990523bca))
|
||||
- **frontend:** Adapt to composite key transaction structure ([61fafecb](https://github.com/elisiariocouto/leggen/commit/61fafecb780a877a69ecca27ea95a1494669b70d))
|
||||
- **frontend:** Add account name editing functionality ([aa97f368](https://github.com/elisiariocouto/leggen/commit/aa97f36819f15f1afc34f45642abdc6e2ce6c883))
|
||||
- **frontend:** Implement TanStack Router with mobile sidebar ([ca41b7af](https://github.com/elisiariocouto/leggen/commit/ca41b7af0a5e50e0350857a4ace7979b7b29eab2))
|
||||
- **web:** Add modal to view raw transaction. ([433ba3fa](https://github.com/elisiariocouto/leggen/commit/433ba3faf9937613786e66e9ee13152f96d00c43))
|
||||
|
||||
|
||||
|
||||
## 2025.9.3 (2025/09/10)
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- **ci:** Fix GitHub Actions syntax. ([90e58734](https://github.com/elisiariocouto/leggen/commit/90e58734adb9638efd695719321874658529561d))
|
||||
|
||||
|
||||
|
||||
## 2025.9.3 (2025/09/10)
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- **ci:** Fix GitHub Actions syntax. ([90e58734](https://github.com/elisiariocouto/leggen/commit/90e58734adb9638efd695719321874658529561d))
|
||||
|
||||
|
||||
|
||||
## 2025.9.2 (2025/09/10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **ci:** Prevent duplicate Docker tags in GitHub Actions ([53e08e8e](https://github.com/elisiariocouto/leggen/commit/53e08e8e4b909b4895b5a447cfbce515893d31a5))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **docker:** Add Docker containerization for React frontend ([84fe79b3](https://github.com/elisiariocouto/leggen/commit/84fe79b37b4f154fa0758f8d037cdba0d166dd3b))
|
||||
|
||||
|
||||
|
||||
## 2025.9.2 (2025/09/10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **ci:** Prevent duplicate Docker tags in GitHub Actions ([53e08e8e](https://github.com/elisiariocouto/leggen/commit/53e08e8e4b909b4895b5a447cfbce515893d31a5))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **docker:** Add Docker containerization for React frontend ([84fe79b3](https://github.com/elisiariocouto/leggen/commit/84fe79b37b4f154fa0758f8d037cdba0d166dd3b))
|
||||
|
||||
|
||||
|
||||
## 2025.9.1 (2025/09/09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Handle duplicate transactionId values in migration ([8fabaf7b](https://github.com/elisiariocouto/leggen/commit/8fabaf7b86fde921c61266568ecb0403d3102671))
|
||||
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Improve AGENTS.md. ([3270dc45](https://github.com/elisiariocouto/leggen/commit/3270dc4585e6b33d55aef0deecd849753d36fa74))
|
||||
|
||||
|
||||
### Refactor
|
||||
|
||||
- Remove unused hide_missing_ids functionality ([8006e5e1](https://github.com/elisiariocouto/leggen/commit/8006e5e1f6373aae39d3c38068d694e142bc85a5))
|
||||
|
||||
|
||||
|
||||
## 2025.9.1 (2025/09/09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Handle duplicate transactionId values in migration ([8fabaf7b](https://github.com/elisiariocouto/leggen/commit/8fabaf7b86fde921c61266568ecb0403d3102671))
|
||||
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Improve AGENTS.md. ([3270dc45](https://github.com/elisiariocouto/leggen/commit/3270dc4585e6b33d55aef0deecd849753d36fa74))
|
||||
|
||||
|
||||
### Refactor
|
||||
|
||||
- Remove unused hide_missing_ids functionality ([8006e5e1](https://github.com/elisiariocouto/leggen/commit/8006e5e1f6373aae39d3c38068d694e142bc85a5))
|
||||
|
||||
|
||||
|
||||
## 2025.9.0 (2025/09/09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli:** Show transactions without internal ID when using --full. ([46f3f5c4](https://github.com/elisiariocouto/leggen/commit/46f3f5c4984224c3f4b421e1a06dcf44d4f211e0))
|
||||
- Do not install development dependencies. ([73d6bd32](https://github.com/elisiariocouto/leggen/commit/73d6bd32dbc59608ef1472dc65d9e18450f00896))
|
||||
- Implement proper GoCardless authentication and add dev features ([f0fee4fd](https://github.com/elisiariocouto/leggen/commit/f0fee4fd82e1c788614d73fcd0075f5e16976650))
|
||||
- Make internal transcation ID optional. ([6bce7eb6](https://github.com/elisiariocouto/leggen/commit/6bce7eb6be5f9a5286eb27e777fbf83a6b1c5f8d))
|
||||
- Resolve 404 balances endpoint and currency formatting errors ([417b7753](https://github.com/elisiariocouto/leggen/commit/417b77539fc275493d55efb29f92abcea666b210))
|
||||
- Merge account details into balance data to prevent unknown/N/A values ([eaaea6e4](https://github.com/elisiariocouto/leggen/commit/eaaea6e4598e9c81997573e19f4ef1c58ebe320f))
|
||||
- Use account status for balance records instead of hardcoded 'active' ([541cb262](https://github.com/elisiariocouto/leggen/commit/541cb262ee5783eedf2b154c148c28ec89845da5))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
- Update README for new web architecture ([4018b263](https://github.com/elisiariocouto/leggen/commit/4018b263f27c2b59af31428d7a0878280a291c85))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- Transform to web architecture with FastAPI backend ([91f53b35](https://github.com/elisiariocouto/leggen/commit/91f53b35b18740869ee9cebfac394db2e12db099))
|
||||
- Add comprehensive test suite with 46 passing tests ([34e793c7](https://github.com/elisiariocouto/leggen/commit/34e793c75c8df1e57ea240b92ccf0843a80c2a14))
|
||||
- Add mypy to pre-commit. ([ec8ef834](https://github.com/elisiariocouto/leggen/commit/ec8ef8346add878f3ff4e8ed928b952d9b5dd584))
|
||||
- Implement database-first architecture to minimize GoCardless API calls ([155c3055](https://github.com/elisiariocouto/leggen/commit/155c30559f4cacd76ef01e50ec29ee436d3f9d56))
|
||||
- Implement dynamic API connection status ([cb2e70e4](https://github.com/elisiariocouto/leggen/commit/cb2e70e42d1122e9c2e5420b095aeb1e55454c24))
|
||||
- Add automatic balance timestamp migration mechanism ([34501f5f](https://github.com/elisiariocouto/leggen/commit/34501f5f0d3b3dff68364b60be77bfb99071b269))
|
||||
- Improve notification filters configuration format ([2191fe90](https://github.com/elisiariocouto/leggen/commit/2191fe906659f4fd22c25b6cb9fbb95c03472f00))
|
||||
- Add notifications view and update branding ([abf39abe](https://github.com/elisiariocouto/leggen/commit/abf39abe74b75d8cb980109fbcbdd940066cc90b))
|
||||
- Make API URL configurable and improve code quality ([37949a4e](https://github.com/elisiariocouto/leggen/commit/37949a4e1f25a2656f6abef75ba942f7b205c130))
|
||||
- Change versioning scheme to calver. ([f2e05484](https://github.com/elisiariocouto/leggen/commit/f2e05484dc688409b6db6bd16858b066d3a16976))
|
||||
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Implement code review suggestions and format code. ([de3da84d](https://github.com/elisiariocouto/leggen/commit/de3da84dffd83e0b232cf76836935a66eb704aee))
|
||||
|
||||
|
||||
### Refactor
|
||||
|
||||
- Remove MongoDB support, simplify to SQLite-only architecture ([47164e85](https://github.com/elisiariocouto/leggen/commit/47164e854600dfcac482449769b1d2e55c842570))
|
||||
- Remove unused amount_threshold and keywords from notification filters ([95709978](https://github.com/elisiariocouto/leggen/commit/957099786cb0e48c9ffbda11b3172ec9fae9ac37))
|
||||
|
||||
|
||||
|
||||
## 2025.9.0 (2025/09/09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli:** Show transactions without internal ID when using --full. ([46f3f5c4](https://github.com/elisiariocouto/leggen/commit/46f3f5c4984224c3f4b421e1a06dcf44d4f211e0))
|
||||
- Do not install development dependencies. ([73d6bd32](https://github.com/elisiariocouto/leggen/commit/73d6bd32dbc59608ef1472dc65d9e18450f00896))
|
||||
- Implement proper GoCardless authentication and add dev features ([f0fee4fd](https://github.com/elisiariocouto/leggen/commit/f0fee4fd82e1c788614d73fcd0075f5e16976650))
|
||||
- Make internal transcation ID optional. ([6bce7eb6](https://github.com/elisiariocouto/leggen/commit/6bce7eb6be5f9a5286eb27e777fbf83a6b1c5f8d))
|
||||
- Resolve 404 balances endpoint and currency formatting errors ([417b7753](https://github.com/elisiariocouto/leggen/commit/417b77539fc275493d55efb29f92abcea666b210))
|
||||
- Merge account details into balance data to prevent unknown/N/A values ([eaaea6e4](https://github.com/elisiariocouto/leggen/commit/eaaea6e4598e9c81997573e19f4ef1c58ebe320f))
|
||||
- Use account status for balance records instead of hardcoded 'active' ([541cb262](https://github.com/elisiariocouto/leggen/commit/541cb262ee5783eedf2b154c148c28ec89845da5))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
- Update README for new web architecture ([4018b263](https://github.com/elisiariocouto/leggen/commit/4018b263f27c2b59af31428d7a0878280a291c85))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- Transform to web architecture with FastAPI backend ([91f53b35](https://github.com/elisiariocouto/leggen/commit/91f53b35b18740869ee9cebfac394db2e12db099))
|
||||
- Add comprehensive test suite with 46 passing tests ([34e793c7](https://github.com/elisiariocouto/leggen/commit/34e793c75c8df1e57ea240b92ccf0843a80c2a14))
|
||||
- Add mypy to pre-commit. ([ec8ef834](https://github.com/elisiariocouto/leggen/commit/ec8ef8346add878f3ff4e8ed928b952d9b5dd584))
|
||||
- Implement database-first architecture to minimize GoCardless API calls ([155c3055](https://github.com/elisiariocouto/leggen/commit/155c30559f4cacd76ef01e50ec29ee436d3f9d56))
|
||||
- Implement dynamic API connection status ([cb2e70e4](https://github.com/elisiariocouto/leggen/commit/cb2e70e42d1122e9c2e5420b095aeb1e55454c24))
|
||||
- Add automatic balance timestamp migration mechanism ([34501f5f](https://github.com/elisiariocouto/leggen/commit/34501f5f0d3b3dff68364b60be77bfb99071b269))
|
||||
- Improve notification filters configuration format ([2191fe90](https://github.com/elisiariocouto/leggen/commit/2191fe906659f4fd22c25b6cb9fbb95c03472f00))
|
||||
- Add notifications view and update branding ([abf39abe](https://github.com/elisiariocouto/leggen/commit/abf39abe74b75d8cb980109fbcbdd940066cc90b))
|
||||
- Make API URL configurable and improve code quality ([37949a4e](https://github.com/elisiariocouto/leggen/commit/37949a4e1f25a2656f6abef75ba942f7b205c130))
|
||||
- Change versioning scheme to calver. ([f2e05484](https://github.com/elisiariocouto/leggen/commit/f2e05484dc688409b6db6bd16858b066d3a16976))
|
||||
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Implement code review suggestions and format code. ([de3da84d](https://github.com/elisiariocouto/leggen/commit/de3da84dffd83e0b232cf76836935a66eb704aee))
|
||||
|
||||
|
||||
### Refactor
|
||||
|
||||
- Remove MongoDB support, simplify to SQLite-only architecture ([47164e85](https://github.com/elisiariocouto/leggen/commit/47164e854600dfcac482449769b1d2e55c842570))
|
||||
- Remove unused amount_threshold and keywords from notification filters ([95709978](https://github.com/elisiariocouto/leggen/commit/957099786cb0e48c9ffbda11b3172ec9fae9ac37))
|
||||
|
||||
|
||||
|
||||
## 0.6.11 (2025/02/23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add workdir to dockerfile last stage. ([355fa5cf](https://github.com/elisiariocouto/leggen/commit/355fa5cfb6ccc4ca225d921cdc2ad77d6bb9b2e6))
|
||||
|
||||
|
||||
|
||||
## 0.6.10 (2025/01/14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **ci:** Install uv before publishing. ([74800944](https://github.com/elisiariocouto/leggen/commit/7480094419697a46515a88a635d4e73820b0d283))
|
||||
|
||||
|
||||
|
||||
## 0.6.9 (2025/01/14)
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Setup PyPI Trusted Publishing. ([ca29d527](https://github.com/elisiariocouto/leggen/commit/ca29d527c9e5f9391dfcad6601ad9c585b511b47))
|
||||
|
||||
|
||||
|
||||
## 0.6.8 (2025/01/13)
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
20
Dockerfile
20
Dockerfile
@@ -6,24 +6,28 @@ WORKDIR /app
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --frozen --no-install-project --no-editable
|
||||
uv sync --locked --no-install-project --no-editable
|
||||
|
||||
COPY . /app
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-editable
|
||||
uv sync --locked --no-editable --no-group dev
|
||||
|
||||
FROM python:3.13-alpine
|
||||
|
||||
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.licenses="MIT"
|
||||
LABEL org.opencontainers.image.title="leggen"
|
||||
LABEL org.opencontainers.image.description="An Open Banking CLI"
|
||||
LABEL org.opencontainers.image.title="Leggen API"
|
||||
LABEL org.opencontainers.image.description="Open Banking API for Leggen"
|
||||
LABEL org.opencontainers.image.url="https://github.com/elisiariocouto/leggen"
|
||||
|
||||
# Copy the environment, but not the source code
|
||||
COPY --from=builder --chown=app:app /app/.venv /app/.venv
|
||||
WORKDIR /app
|
||||
|
||||
# Run the application
|
||||
ENTRYPOINT ["/app/.venv/bin/leggen"]
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
|
||||
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
|
||||
|
||||
CMD ["/app/.venv/bin/leggen", "server"]
|
||||
|
||||
381
README.md
381
README.md
@@ -1,45 +1,121 @@
|
||||
# 💲 leggen
|
||||
|
||||
An Open Banking CLI.
|
||||
An Open Banking CLI and API service for managing bank connections and transactions.
|
||||
|
||||
This tool aims to provide a simple way 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 a simple CLI tool to connect to banks and list transactions can be very useful for developers and companies that need to access bank data.
|
||||
|
||||
Having your bank data in a database, gives you the power to backup, analyze and create reports with your data.
|
||||
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
|
||||
|
||||
### 🔌 API & Backend
|
||||
- [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
|
||||
- [APScheduler](https://apscheduler.readthedocs.io/): Background job scheduling with configurable cron
|
||||
|
||||
### 📦 Storage
|
||||
- [SQLite](https://www.sqlite.org): for storing transactions, simple and easy to use
|
||||
- [MongoDB](https://www.mongodb.com/docs/): alternative store for transactions, good balance between performance and query capabilities
|
||||
|
||||
### ⏰ Scheduling
|
||||
- [Ofelia](https://github.com/mcuadros/ofelia): for scheduling regular syncs with the database when using Docker
|
||||
|
||||
### 📊 Visualization
|
||||
- [NocoDB](https://github.com/nocodb/nocodb): for visualizing and querying transactions, a simple and easy to use interface for SQLite
|
||||
### Frontend
|
||||
- [React](https://reactjs.org/): Modern web interface with TypeScript
|
||||
- [Vite](https://vitejs.dev/): Fast build tool and development server
|
||||
- [Tailwind CSS](https://tailwindcss.com/): Utility-first CSS framework
|
||||
- [TanStack Query](https://tanstack.com/query): Powerful data synchronization for React
|
||||
|
||||
## ✨ Features
|
||||
- Connect to banks using GoCardless Open Banking API
|
||||
- List all connected banks and their statuses
|
||||
- List balances of all connected accounts
|
||||
- List transactions for all connected accounts
|
||||
- Sync all transactions with a SQLite and/or MongoDB database
|
||||
- Visualize and query transactions using NocoDB
|
||||
- Schedule regular syncs with the database using Ofelia
|
||||
- Send notifications to Discord and/or Telegram when transactions match certain filters
|
||||
|
||||
## 🚀 Installation and Configuration
|
||||
### 🎯 Core Banking Features
|
||||
- Connect to banks using GoCardless Open Banking API (30+ EU countries)
|
||||
- List all connected banks and their connection statuses
|
||||
- View balances of all connected accounts
|
||||
- List and filter transactions across all accounts
|
||||
- Support for both booked and pending transactions
|
||||
|
||||
In order to use `leggen`, you need to create a GoCardless account. GoCardless is a service that provides access to Open Banking APIs. You can create an account at https://gocardless.com/bank-account-data/.
|
||||
### 🔄 Data Management
|
||||
- Sync all transactions with SQLite database
|
||||
- Background sync scheduling with configurable cron expressions
|
||||
- Automatic transaction deduplication and status tracking
|
||||
- Real-time sync status monitoring
|
||||
|
||||
After creating an account and getting your API keys, the best way is to use the [compose file](compose.yml). Open the file and adapt it to your needs.
|
||||
### 📡 API & Integration
|
||||
- **REST API**: Complete FastAPI backend with comprehensive endpoints
|
||||
- **CLI Interface**: Enhanced command-line tools with new options
|
||||
|
||||
### Example Configuration
|
||||
### 🔔 Notifications & Monitoring
|
||||
- Discord and Telegram notifications for filtered transactions
|
||||
- Configurable transaction filters (case-sensitive/insensitive)
|
||||
- Account expiry notifications and status alerts
|
||||
- Comprehensive logging and error handling
|
||||
|
||||
Create a configuration file at with the following content:
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
1. Create a GoCardless account at [https://gocardless.com/bank-account-data/](https://gocardless.com/bank-account-data/)
|
||||
2. Get your API credentials (key and secret)
|
||||
|
||||
### Installation Options
|
||||
|
||||
#### Option 1: Docker Compose (Recommended)
|
||||
The easiest way to get started is with Docker Compose, which includes both the React frontend and FastAPI backend:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/elisiariocouto/leggen.git
|
||||
cd leggen
|
||||
|
||||
# Create your configuration
|
||||
mkdir -p data && cp config.example.toml data/config.toml
|
||||
# Edit data/config.toml with your GoCardless credentials
|
||||
|
||||
# Start all services (frontend + backend)
|
||||
docker compose up -d
|
||||
|
||||
# Access the web interface at http://localhost:3000
|
||||
# API is available at http://localhost:8000
|
||||
```
|
||||
|
||||
#### Production Deployment
|
||||
|
||||
For production deployment using published Docker images:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/elisiariocouto/leggen.git
|
||||
cd leggen
|
||||
|
||||
# Create your configuration
|
||||
mkdir -p data && cp config.example.toml data/config.toml
|
||||
# Edit data/config.toml with your GoCardless credentials
|
||||
|
||||
# Start production services
|
||||
docker compose up -d
|
||||
|
||||
# Access the web interface at http://localhost:3000
|
||||
# API is available at http://localhost:8000
|
||||
```
|
||||
|
||||
### Development vs Production
|
||||
|
||||
- **Development**: Use `docker compose -f compose.dev.yml up -d` (builds from source)
|
||||
- **Production**: Use `docker compose up -d` (uses published images)
|
||||
|
||||
#### Option 2: Local Development
|
||||
For development or local installation:
|
||||
|
||||
```bash
|
||||
# Install with uv (recommended) or pip
|
||||
uv sync # or pip install -e .
|
||||
|
||||
# Start the API service
|
||||
uv run leggen server --reload # Development mode with auto-reload
|
||||
|
||||
# Use the CLI (in another terminal)
|
||||
uv run leggen --help
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Create a configuration file at `./data/config.toml` (for Docker) or `~/.config/leggen/config.toml` (for local development):
|
||||
|
||||
```toml
|
||||
[gocardless]
|
||||
@@ -49,70 +125,241 @@ url = "https://bankaccountdata.gocardless.com/api/v2"
|
||||
|
||||
[database]
|
||||
sqlite = true
|
||||
mongodb = true
|
||||
|
||||
[database.mongodb]
|
||||
uri = "mongodb://localhost:27017"
|
||||
# Optional: Background sync scheduling
|
||||
[scheduler.sync]
|
||||
enabled = true
|
||||
hour = 3 # 3 AM
|
||||
minute = 0
|
||||
# cron = "0 3 * * *" # Alternative: use cron expression
|
||||
|
||||
# Optional: Discord notifications
|
||||
[notifications.discord]
|
||||
webhook = "https://discord.com/api/webhooks/..."
|
||||
enabled = true
|
||||
|
||||
# Optional: Telegram notifications
|
||||
[notifications.telegram]
|
||||
# See gist for telegram instructions
|
||||
# https://gist.github.com/nafiesl/4ad622f344cd1dc3bb1ecbe468ff9f8a
|
||||
token = "12345:abcdefghijklmnopqrstuvxwyz"
|
||||
chat-id = 12345
|
||||
token = "your-bot-token"
|
||||
chat_id = 12345
|
||||
enabled = true
|
||||
|
||||
[filters.case-insensitive]
|
||||
filter1 = "company-name"
|
||||
# Optional: Transaction filters for notifications
|
||||
[filters]
|
||||
case-insensitive = ["salary", "utility"]
|
||||
case-sensitive = ["SpecificStore"]
|
||||
```
|
||||
|
||||
### Running Leggen with Docker
|
||||
## 📖 Usage
|
||||
|
||||
After adapting the compose file, run the following command:
|
||||
### API Service (`leggen server`)
|
||||
|
||||
Start the FastAPI backend service:
|
||||
|
||||
```bash
|
||||
$ docker compose up -d
|
||||
# Production mode
|
||||
leggen server
|
||||
|
||||
# Development mode with auto-reload
|
||||
leggen server --reload
|
||||
|
||||
# Custom host and port
|
||||
leggen server --host 127.0.0.1 --port 8080
|
||||
```
|
||||
|
||||
The leggen container will exit, this is expected since you didn't connect any bank accounts yet.
|
||||
**API Documentation**: Visit `http://localhost:8000/docs` for interactive API documentation.
|
||||
|
||||
Run the following command and follow the instructions:
|
||||
### CLI Commands (`leggen`)
|
||||
|
||||
#### Basic Commands
|
||||
```bash
|
||||
# Check connection status
|
||||
leggen status
|
||||
|
||||
# Connect to a new bank
|
||||
leggen bank add
|
||||
|
||||
# View account balances
|
||||
leggen balances
|
||||
|
||||
# List recent transactions
|
||||
leggen transactions --limit 20
|
||||
|
||||
# View detailed transactions
|
||||
leggen transactions --full
|
||||
```
|
||||
|
||||
#### Sync Operations
|
||||
```bash
|
||||
# Start background sync
|
||||
leggen sync
|
||||
|
||||
# Synchronous sync (wait for completion)
|
||||
leggen sync --wait
|
||||
|
||||
# Force sync (override running sync)
|
||||
leggen sync --force --wait
|
||||
```
|
||||
|
||||
#### API Integration
|
||||
```bash
|
||||
# Use custom API URL
|
||||
leggen --api-url http://localhost:8080 status
|
||||
|
||||
# Set via environment variable
|
||||
export LEGGEN_API_URL=http://localhost:8080
|
||||
leggen status
|
||||
```
|
||||
|
||||
### Docker Usage
|
||||
|
||||
#### Development (build from source)
|
||||
```bash
|
||||
# Start development services
|
||||
docker compose -f compose.dev.yml up -d
|
||||
|
||||
# View service status
|
||||
docker compose -f compose.dev.yml ps
|
||||
|
||||
# Check logs
|
||||
docker compose -f compose.dev.yml logs frontend
|
||||
docker compose -f compose.dev.yml logs leggen-server
|
||||
|
||||
# Stop development services
|
||||
docker compose -f compose.dev.yml down
|
||||
```
|
||||
|
||||
#### Production (use published images)
|
||||
```bash
|
||||
# Start production services
|
||||
docker compose up -d
|
||||
|
||||
# View service status
|
||||
docker compose ps
|
||||
|
||||
# Check logs
|
||||
docker compose logs frontend
|
||||
docker compose logs leggen-server
|
||||
|
||||
# Access the web interface at http://localhost:3000
|
||||
# API documentation at http://localhost:8000/docs
|
||||
|
||||
# Stop production services
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## 🔌 API Endpoints
|
||||
|
||||
The FastAPI backend provides comprehensive REST endpoints:
|
||||
|
||||
### Banks & Connections
|
||||
- `GET /api/v1/banks/institutions?country=PT` - List available banks
|
||||
- `POST /api/v1/banks/connect` - Create bank connection
|
||||
- `GET /api/v1/banks/status` - Connection status
|
||||
- `GET /api/v1/banks/countries` - Supported countries
|
||||
|
||||
### Accounts & Balances
|
||||
- `GET /api/v1/accounts` - List all accounts
|
||||
- `GET /api/v1/accounts/{id}` - Account details
|
||||
- `GET /api/v1/accounts/{id}/balances` - Account balances
|
||||
- `GET /api/v1/accounts/{id}/transactions` - Account transactions
|
||||
|
||||
### Transactions
|
||||
- `GET /api/v1/transactions` - All transactions with filtering
|
||||
- `GET /api/v1/transactions/stats` - Transaction statistics
|
||||
|
||||
### Sync & Scheduling
|
||||
- `POST /api/v1/sync` - Trigger background sync
|
||||
- `POST /api/v1/sync/now` - Synchronous sync
|
||||
- `GET /api/v1/sync/status` - Sync status
|
||||
- `GET/PUT /api/v1/sync/scheduler` - Scheduler configuration
|
||||
|
||||
### Notifications
|
||||
- `GET/PUT /api/v1/notifications/settings` - Manage notifications
|
||||
- `POST /api/v1/notifications/test` - Test notifications
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Local Development Setup
|
||||
```bash
|
||||
# Clone and setup
|
||||
git clone https://github.com/elisiariocouto/leggen.git
|
||||
cd leggen
|
||||
|
||||
# Install dependencies
|
||||
uv sync
|
||||
|
||||
# Start API service with auto-reload
|
||||
uv run leggen server --reload
|
||||
|
||||
# Use CLI commands
|
||||
uv run leggen status
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
Run the comprehensive test suite with:
|
||||
|
||||
```bash
|
||||
$ docker compose run leggen bank add
|
||||
# Run all tests
|
||||
uv run pytest
|
||||
|
||||
# Run unit tests only
|
||||
uv run pytest tests/unit/
|
||||
|
||||
# Run with verbose output
|
||||
uv run pytest tests/unit/ -v
|
||||
|
||||
# Run specific test files
|
||||
uv run pytest tests/unit/test_config.py -v
|
||||
uv run pytest tests/unit/test_scheduler.py -v
|
||||
uv run pytest tests/unit/test_api_banks.py -v
|
||||
|
||||
# Run tests by markers
|
||||
uv run pytest -m unit # Unit tests
|
||||
uv run pytest -m api # API endpoint tests
|
||||
uv run pytest -m cli # CLI tests
|
||||
```
|
||||
|
||||
To sync all transactions with the database, run the following command:
|
||||
The test suite includes:
|
||||
- **Configuration management tests** - TOML config loading/saving
|
||||
- **API endpoint tests** - FastAPI route testing with mocked dependencies
|
||||
- **CLI API client tests** - HTTP client integration testing
|
||||
- **Background scheduler tests** - APScheduler job management
|
||||
- **Mock data and fixtures** - Realistic test data for banks, accounts, transactions
|
||||
|
||||
```bash
|
||||
$ docker compose run leggen sync
|
||||
### Code Structure
|
||||
```
|
||||
leggen/ # CLI application
|
||||
├── commands/ # CLI command implementations
|
||||
├── utils/ # Shared utilities
|
||||
├── api/ # FastAPI API routes and models
|
||||
├── services/ # Business logic
|
||||
├── background/ # Background job scheduler
|
||||
└── api_client.py # API client for server communication
|
||||
|
||||
tests/ # Test suite
|
||||
├── conftest.py # Shared test fixtures
|
||||
└── unit/ # Unit tests
|
||||
├── test_config.py # Configuration tests
|
||||
├── test_scheduler.py # Background scheduler tests
|
||||
├── test_api_banks.py # Banks API tests
|
||||
├── test_api_accounts.py # Accounts API tests
|
||||
└── test_api_client.py # CLI API client tests
|
||||
```
|
||||
|
||||
## 👩🏫 Usage
|
||||
### Contributing
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes with tests
|
||||
4. Submit a pull request
|
||||
|
||||
```
|
||||
$ leggen --help
|
||||
Usage: leggen [OPTIONS] COMMAND [ARGS]...
|
||||
The repository uses GitHub Actions for CI/CD:
|
||||
- **CI**: Runs Python tests (`uv run pytest`) and frontend linting/build on every push
|
||||
- **Release**: Creates GitHub releases with changelog when tags are pushed
|
||||
|
||||
Leggen: An Open Banking CLI
|
||||
|
||||
Options:
|
||||
--version Show the version and exit.
|
||||
-c, --config FILE Path to TOML configuration file
|
||||
[env var: LEGGEN_CONFIG_FILE;
|
||||
default: ~/.config/leggen/config.toml]
|
||||
-h, --help Show this message and exit.
|
||||
|
||||
Command Groups:
|
||||
bank Manage banks connections
|
||||
|
||||
Commands:
|
||||
balances List balances of all connected accounts
|
||||
status List all connected banks and their status
|
||||
sync Sync all transactions with database
|
||||
transactions List transactions
|
||||
```
|
||||
|
||||
## ⚠️ Caveats
|
||||
- This project is still in early development, breaking changes may occur.
|
||||
## ⚠️ Notes
|
||||
- This project is in active development
|
||||
- GoCardless API rate limits apply
|
||||
- Some banks may require additional authorization steps
|
||||
- Docker images are automatically built and published on releases
|
||||
|
||||
25
compose.dev.yml
Normal file
25
compose.dev.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
# React frontend service
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
restart: "unless-stopped"
|
||||
ports:
|
||||
- "127.0.0.1:3000:80"
|
||||
environment:
|
||||
- API_BACKEND_URL=${API_BACKEND_URL:-http://leggen-server:8000}
|
||||
depends_on:
|
||||
leggen-server:
|
||||
condition: service_healthy
|
||||
|
||||
# FastAPI backend service
|
||||
leggen-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: "unless-stopped"
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
volumes:
|
||||
- "./data:/root/.config/leggen"
|
||||
64
compose.yml
64
compose.yml
@@ -1,59 +1,19 @@
|
||||
services:
|
||||
# Defaults to `sync` command.
|
||||
leggen:
|
||||
image: elisiariocouto/leggen:latest
|
||||
command: sync
|
||||
restart: "no"
|
||||
volumes:
|
||||
- "./leggen:/root/.config/leggen" # Default configuration file should be in this directory, named `config.toml`
|
||||
- "./db:/app"
|
||||
|
||||
nocodb:
|
||||
image: nocodb/nocodb:latest
|
||||
# React frontend service
|
||||
frontend:
|
||||
image: ghcr.io/elisiariocouto/leggen:latest-frontend
|
||||
restart: "unless-stopped"
|
||||
volumes:
|
||||
- "./nocodb:/usr/app/data/"
|
||||
- "./db:/usr/leggen:ro"
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "127.0.0.1:3000:80"
|
||||
depends_on:
|
||||
- leggen
|
||||
leggen-server:
|
||||
condition: service_healthy
|
||||
|
||||
# Recommended: Run `leggen sync` every day.
|
||||
ofelia:
|
||||
image: mcuadros/ofelia:latest
|
||||
# FastAPI backend service
|
||||
leggen-server:
|
||||
image: ghcr.io/elisiariocouto/leggen:latest
|
||||
restart: "unless-stopped"
|
||||
depends_on:
|
||||
- leggen
|
||||
command: daemon --docker -f label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
labels:
|
||||
ofelia.job-run.leggen-sync.schedule: "0 0 3 * * *"
|
||||
ofelia.job-run.leggen-sync.container: ${COMPOSE_PROJECT_NAME}-leggen-1
|
||||
|
||||
# Optional: If you want to have a mongodb, uncomment the following lines
|
||||
# mongo:
|
||||
# image: mongo:7
|
||||
# restart: "unless-stopped"
|
||||
# # If you want to expose the mongodb port to the host, uncomment the following lines
|
||||
# # ports:
|
||||
# # - 127.0.0.1:27017:27017
|
||||
# volumes:
|
||||
# - "./data:/data/db"
|
||||
# environment:
|
||||
# MONGO_INITDB_ROOT_USERNAME: "leggen"
|
||||
# MONGO_INITDB_ROOT_PASSWORD: "changeme"
|
||||
|
||||
# Optional: If you want to have an admin interface for your mongodb, uncomment the following lines
|
||||
# mongo-express:
|
||||
# image: mongo-express
|
||||
# restart: "unless-stopped"
|
||||
# # By default, we are exposing the mongo-express port to the host
|
||||
# ports:
|
||||
# - 127.0.0.1:8081:8081
|
||||
# environment:
|
||||
# ME_CONFIG_MONGODB_URL: "mongodb://leggen:changeme@mongo:27017/"
|
||||
# ME_CONFIG_BASICAUTH_USERNAME: ""
|
||||
# depends_on:
|
||||
# - mongo
|
||||
- "./data:/root/.config/leggen" # Configuration and database directory
|
||||
|
||||
30
config.example.toml
Normal file
30
config.example.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[gocardless]
|
||||
key = "your-api-key"
|
||||
secret = "your-secret-key"
|
||||
url = "https://bankaccountdata.gocardless.com/api/v2"
|
||||
|
||||
[database]
|
||||
sqlite = true
|
||||
|
||||
# Optional: Background sync scheduling
|
||||
[scheduler.sync]
|
||||
enabled = true
|
||||
hour = 3 # 3 AM
|
||||
minute = 0
|
||||
# cron = "0 3 * * *" # Alternative: use cron expression
|
||||
|
||||
# Optional: Discord notifications
|
||||
[notifications.discord]
|
||||
webhook = "https://discord.com/api/webhooks/..."
|
||||
enabled = true
|
||||
|
||||
# Optional: Telegram notifications
|
||||
[notifications.telegram]
|
||||
api-key = "your-bot-token"
|
||||
chat-id = 12345
|
||||
enabled = true
|
||||
|
||||
# Optional: Transaction filters for notifications
|
||||
[filters]
|
||||
case-insensitive = ["salary", "utility"]
|
||||
case-sensitive = ["SpecificStore"]
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
34
frontend/Dockerfile
Normal file
34
frontend/Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm i
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy server configuration template
|
||||
COPY default.conf.template /etc/nginx/templates/default.conf.template
|
||||
|
||||
# Set default API backend URL (can be overridden at runtime)
|
||||
ENV API_BACKEND_URL=http://leggen-server:8000
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
124
frontend/README.md
Normal file
124
frontend/README.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Leggen Frontend
|
||||
|
||||
A modern React dashboard for the Leggen Open Banking CLI tool. This frontend provides a user-friendly interface to view bank accounts, transactions, and balances.
|
||||
|
||||
## Features
|
||||
|
||||
- **Modern Dashboard**: Clean, responsive interface built with React and TypeScript
|
||||
- **Bank Accounts Overview**: View all connected bank accounts with real-time balances
|
||||
- **Transaction Management**: Browse, search, and filter transactions across all accounts
|
||||
- **Responsive Design**: Works seamlessly on desktop, tablet, and mobile devices
|
||||
- **Real-time Data**: Powered by React Query for efficient data fetching and caching
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+ and npm
|
||||
- Leggen API server running (configurable via environment variables)
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Install dependencies:**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Start the development server:**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. **Open your browser to:**
|
||||
```
|
||||
http://localhost:5173
|
||||
```
|
||||
|
||||
## Available Scripts
|
||||
|
||||
- `npm run dev` - Start development server
|
||||
- `npm run build` - Build for production
|
||||
- `npm run preview` - Preview production build
|
||||
- `npm run lint` - Run ESLint
|
||||
|
||||
## Architecture
|
||||
|
||||
### Key Technologies
|
||||
|
||||
- **React 18** - Modern React with hooks and concurrent features
|
||||
- **TypeScript** - Type-safe JavaScript development
|
||||
- **Vite** - Fast build tool and development server
|
||||
- **Tailwind CSS** - Utility-first CSS framework
|
||||
- **React Query** - Data fetching and caching
|
||||
- **Axios** - HTTP client for API calls
|
||||
- **Lucide React** - Modern icon library
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # React components
|
||||
│ ├── Dashboard.tsx # Main dashboard layout
|
||||
│ ├── AccountsOverview.tsx
|
||||
│ └── TransactionsList.tsx
|
||||
├── lib/ # Utilities and API client
|
||||
│ ├── api.ts # API client and endpoints
|
||||
│ └── utils.ts # Helper functions
|
||||
├── types/ # TypeScript type definitions
|
||||
│ └── api.ts # API response types
|
||||
└── App.tsx # Main application component
|
||||
```
|
||||
|
||||
## API Integration
|
||||
|
||||
The frontend connects to the Leggen API server (configurable via environment variables). The API client handles:
|
||||
|
||||
- Account retrieval and management
|
||||
- Transaction fetching with filtering
|
||||
- Balance information
|
||||
- Error handling and loading states
|
||||
|
||||
## Configuration
|
||||
|
||||
### API URL Configuration
|
||||
|
||||
The frontend supports configurable API URLs through environment variables:
|
||||
|
||||
**Development:**
|
||||
|
||||
- Set `VITE_API_URL` to call external APIs during development
|
||||
- Example: `VITE_API_URL=https://staging-api.example.com npm run dev`
|
||||
|
||||
**Production:**
|
||||
|
||||
- Uses relative URLs (`/api/v1`) that nginx proxies to the backend
|
||||
- Configure nginx proxy target via `API_BACKEND_URL` environment variable
|
||||
- Default: `http://leggen-server:8000`
|
||||
|
||||
**Docker Compose:**
|
||||
|
||||
```bash
|
||||
# Override API backend URL
|
||||
API_BACKEND_URL=https://prod-api.example.com docker-compose up
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
The dashboard is designed to work with the Leggen CLI tool's API endpoints. Make sure your Leggen server is running before starting the frontend development server.
|
||||
|
||||
### Adding New Features
|
||||
|
||||
1. Define TypeScript types in `src/types/api.ts`
|
||||
2. Add API methods to `src/lib/api.ts`
|
||||
3. Create React components in `src/components/`
|
||||
4. Use React Query for data fetching and state management
|
||||
|
||||
## Deployment
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
The built files will be in the `dist/` directory, ready to be served by any static web server.
|
||||
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": {}
|
||||
}
|
||||
33
frontend/default.conf.template
Normal file
33
frontend/default.conf.template
Normal file
@@ -0,0 +1,33 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Enable gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
|
||||
# Handle client-side routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy to backend (configurable via API_BACKEND_URL env var)
|
||||
location /api/ {
|
||||
proxy_pass ${API_BACKEND_URL};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { globalIgnores } from "eslint/config";
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs["recommended-latest"],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
]);
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Leggen</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6691
frontend/package-lock.json
generated
Normal file
6691
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
frontend/package.json
Normal file
52
frontend/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "VITE_API_URL=http://localhost:8000/api/v1 vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"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",
|
||||
"@tanstack/react-query": "^5.87.1",
|
||||
"@tanstack/react-router": "^1.131.36",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/router-cli": "^1.131.36",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.542.0",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.1.1",
|
||||
"react-day-picker": "^9.10.0",
|
||||
"react-dom": "^19.1.1",
|
||||
"recharts": "^3.2.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@tanstack/router-vite-plugin": "^1.131.36",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vite": "^7.1.2"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
4
frontend/public/favicon.svg
Normal file
4
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#3B82F6"/>
|
||||
<path d="M8 24V8h6c2.2 0 4 1.8 4 4v4c0 2.2-1.8 4-4 4H12v4H8zm4-8h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-2v4z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 257 B |
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
frontend/src/App.css
Normal file
1
frontend/src/App.css
Normal file
@@ -0,0 +1 @@
|
||||
/* Additional styles if needed */
|
||||
23
frontend/src/App.tsx
Normal file
23
frontend/src/App.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
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;
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
363
frontend/src/components/AccountsOverview.tsx
Normal file
363
frontend/src/components/AccountsOverview.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CreditCard,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Building2,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Edit2,
|
||||
Check,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "../lib/api";
|
||||
import { formatCurrency, formatDate } from "../lib/utils";
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
import type { Account, Balance } from "../types/api";
|
||||
|
||||
// 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() {
|
||||
const {
|
||||
data: accounts,
|
||||
isLoading: accountsLoading,
|
||||
error: accountsError,
|
||||
refetch: refetchAccounts,
|
||||
} = useQuery<Account[]>({
|
||||
queryKey: ["accounts"],
|
||||
queryFn: apiClient.getAccounts,
|
||||
});
|
||||
|
||||
const { data: balances } = useQuery<Balance[]>({
|
||||
queryKey: ["balances"],
|
||||
queryFn: () => apiClient.getBalances(),
|
||||
});
|
||||
|
||||
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateAccountMutation = useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
||||
apiClient.updateAccount(id, { name }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
||||
setEditingAccountId(null);
|
||||
setEditingName("");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to update account:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleEditStart = (account: Account) => {
|
||||
setEditingAccountId(account.id);
|
||||
setEditingName(account.name || "");
|
||||
};
|
||||
|
||||
const handleEditSave = () => {
|
||||
if (editingAccountId && editingName.trim()) {
|
||||
updateAccountMutation.mutate({
|
||||
id: editingAccountId,
|
||||
name: editingName.trim(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditCancel = () => {
|
||||
setEditingAccountId(null);
|
||||
setEditingName("");
|
||||
};
|
||||
|
||||
if (accountsLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<LoadingSpinner message="Loading accounts..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (accountsError) {
|
||||
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 accounts
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Unable to connect to the Leggen API. Please check your
|
||||
configuration and ensure the API server is running.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetchAccounts()}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const totalBalance =
|
||||
accounts?.reduce((sum, account) => {
|
||||
// Get the first available balance from the balances array
|
||||
const primaryBalance = account.balances?.[0]?.amount || 0;
|
||||
return sum + primaryBalance;
|
||||
}, 0) || 0;
|
||||
const totalAccounts = accounts?.length || 0;
|
||||
const uniqueBanks = new Set(accounts?.map((acc) => acc.institution_id) || [])
|
||||
.size;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Balance</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{formatCurrency(totalBalance)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-100 rounded-full">
|
||||
<TrendingUp className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">
|
||||
Total Accounts
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{totalAccounts}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-100 rounded-full">
|
||||
<CreditCard className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">
|
||||
Connected Banks
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{uniqueBanks}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-purple-100 rounded-full">
|
||||
<Building2 className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accounts List */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Bank Accounts</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Manage your connected bank accounts
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!accounts || accounts.length === 0 ? (
|
||||
<div className="p-6 text-center">
|
||||
<CreditCard className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
No accounts found
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Connect your first bank account to get started with Leggen.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{accounts.map((account) => {
|
||||
// Get balance from account's balances array or fallback to balances query
|
||||
const accountBalance = account.balances?.[0];
|
||||
const fallbackBalance = balances?.find(
|
||||
(b) => b.account_id === account.id,
|
||||
);
|
||||
const balance =
|
||||
accountBalance?.amount || fallbackBalance?.balance_amount || 0;
|
||||
const currency =
|
||||
accountBalance?.currency ||
|
||||
fallbackBalance?.currency ||
|
||||
account.currency ||
|
||||
"EUR";
|
||||
const isPositive = balance >= 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={account.id}
|
||||
className="p-4 sm:p-6 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{/* Mobile layout - stack vertically */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||
<div className="flex-shrink-0 p-2 sm:p-3 bg-gray-100 rounded-full">
|
||||
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-gray-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingAccountId === account.id ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
className="flex-1 px-3 py-1 text-base sm:text-lg font-medium border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Account name"
|
||||
name="search"
|
||||
autoComplete="off"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleEditSave();
|
||||
if (e.key === "Escape") handleEditCancel();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleEditSave}
|
||||
disabled={
|
||||
!editingName.trim() ||
|
||||
updateAccountMutation.isPending
|
||||
}
|
||||
className="p-1 text-green-600 hover:text-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Save changes"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEditCancel}
|
||||
className="p-1 text-gray-600 hover:text-gray-700"
|
||||
title="Cancel editing"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 truncate">
|
||||
{account.institution_id}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 min-w-0">
|
||||
<h4 className="text-base sm:text-lg font-medium text-gray-900 truncate">
|
||||
{account.name || "Unnamed Account"}
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => handleEditStart(account)}
|
||||
className="flex-shrink-0 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 truncate">
|
||||
{account.institution_id}
|
||||
</p>
|
||||
{account.iban && (
|
||||
<p className="text-xs text-gray-500 mt-1 font-mono break-all sm:break-normal">
|
||||
IBAN: {account.iban}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance and date section */}
|
||||
<div className="flex items-center justify-between sm:flex-col sm:items-end sm:text-right flex-shrink-0">
|
||||
{/* Mobile: date/status on left, balance on right */}
|
||||
{/* Desktop: balance on top, date/status on bottom */}
|
||||
|
||||
{/* Date and status indicator - left on mobile, bottom on desktop */}
|
||||
<div className="flex items-center space-x-2 order-1 sm:order-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${getStatusIndicator(account.status).color} relative group cursor-help`}
|
||||
role="img"
|
||||
aria-label={`Account status: ${getStatusIndicator(account.status).tooltip}`}
|
||||
>
|
||||
{/* Tooltip */}
|
||||
<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-gray-500 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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
frontend/src/components/Dashboard.tsx
Normal file
197
frontend/src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
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 TransactionsTable from "./TransactionsTable";
|
||||
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" && <TransactionsTable />}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
89
frontend/src/components/ErrorBoundary.tsx
Normal file
89
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Component } from "react";
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
errorInfo?: ErrorInfo;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("ErrorBoundary caught an error:", error, errorInfo);
|
||||
this.setState({ error, errorInfo });
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: undefined, errorInfo: undefined });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-center text-center">
|
||||
<div>
|
||||
<AlertTriangle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Something went wrong
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
An error occurred while rendering this component. Please try
|
||||
refreshing or check the console for more details.
|
||||
</p>
|
||||
|
||||
{this.state.error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-3 mb-4 text-left">
|
||||
<p className="text-sm font-mono text-red-800">
|
||||
<strong>Error:</strong> {this.state.error.message}
|
||||
</p>
|
||||
{this.state.error.stack && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-sm text-red-600 cursor-pointer">
|
||||
Stack trace
|
||||
</summary>
|
||||
<pre className="text-xs text-red-700 mt-1 whitespace-pre-wrap">
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
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" />
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
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>
|
||||
);
|
||||
}
|
||||
70
frontend/src/components/Header.tsx
Normal file
70
frontend/src/components/Header.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useLocation } from "@tanstack/react-router";
|
||||
import { Menu, Activity, Wifi, WifiOff } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "../lib/api";
|
||||
|
||||
const navigation = [
|
||||
{ name: "Overview", to: "/" },
|
||||
{ name: "Transactions", to: "/transactions" },
|
||||
{ name: "Analytics", to: "/analytics" },
|
||||
{ name: "Notifications", to: "/notifications" },
|
||||
];
|
||||
|
||||
interface HeaderProps {
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Header({ setSidebarOpen }: HeaderProps) {
|
||||
const location = useLocation();
|
||||
const currentPage =
|
||||
navigation.find((item) => item.to === location.pathname)?.name ||
|
||||
"Dashboard";
|
||||
|
||||
const {
|
||||
data: healthStatus,
|
||||
isLoading: healthLoading,
|
||||
isError: healthError,
|
||||
} = useQuery({
|
||||
queryKey: ["health"],
|
||||
queryFn: apiClient.getHealth,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
return (
|
||||
<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">
|
||||
{currentPage}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
18
frontend/src/components/LoadingSpinner.tsx
Normal file
18
frontend/src/components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export default function LoadingSpinner({
|
||||
message = "Loading...",
|
||||
}: LoadingSpinnerProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-blue-600 mx-auto mb-2" />
|
||||
<p className="text-gray-600 text-sm">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
320
frontend/src/components/Notifications.tsx
Normal file
320
frontend/src/components/Notifications.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Bell,
|
||||
MessageSquare,
|
||||
Send,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Settings,
|
||||
TestTube,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "../lib/api";
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
import type { NotificationSettings, NotificationService } from "../types/api";
|
||||
|
||||
export default function Notifications() {
|
||||
const [testService, setTestService] = useState("");
|
||||
const [testMessage, setTestMessage] = useState(
|
||||
"Test notification from Leggen",
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: settings,
|
||||
isLoading: settingsLoading,
|
||||
error: settingsError,
|
||||
refetch: refetchSettings,
|
||||
} = useQuery<NotificationSettings>({
|
||||
queryKey: ["notificationSettings"],
|
||||
queryFn: apiClient.getNotificationSettings,
|
||||
});
|
||||
|
||||
const {
|
||||
data: services,
|
||||
isLoading: servicesLoading,
|
||||
error: servicesError,
|
||||
refetch: refetchServices,
|
||||
} = useQuery<NotificationService[]>({
|
||||
queryKey: ["notificationServices"],
|
||||
queryFn: apiClient.getNotificationServices,
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: apiClient.testNotification,
|
||||
onSuccess: () => {
|
||||
// Could show a success toast here
|
||||
console.log("Test notification sent successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to send test notification:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteServiceMutation = useMutation({
|
||||
mutationFn: apiClient.deleteNotificationService,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["notificationServices"] });
|
||||
},
|
||||
});
|
||||
|
||||
if (settingsLoading || servicesLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<LoadingSpinner message="Loading notifications..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (settingsError || servicesError) {
|
||||
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 notifications
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Unable to connect to the Leggen API. Please check your
|
||||
configuration and ensure the API server is running.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
refetchSettings();
|
||||
refetchServices();
|
||||
}}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const handleTestNotification = () => {
|
||||
if (!testService) return;
|
||||
|
||||
testMutation.mutate({
|
||||
service: testService.toLowerCase(),
|
||||
message: testMessage,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteService = (serviceName: string) => {
|
||||
if (
|
||||
confirm(
|
||||
`Are you sure you want to delete the ${serviceName} notification service?`,
|
||||
)
|
||||
) {
|
||||
deleteServiceMutation.mutate(serviceName.toLowerCase());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Test Notification Section */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<TestTube className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Test Notifications
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Service
|
||||
</label>
|
||||
<select
|
||||
value={testService}
|
||||
onChange={(e) => setTestService(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"
|
||||
>
|
||||
<option value="">Select a service...</option>
|
||||
{services?.map((service) => (
|
||||
<option key={service.name} value={service.name}>
|
||||
{service.name} {service.enabled ? "(Enabled)" : "(Disabled)"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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 */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Bell className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Notification Services
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Manage your notification services
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!services || services.length === 0 ? (
|
||||
<div className="p-6 text-center">
|
||||
<Bell className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
No notification services configured
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Configure notification services in your backend to receive alerts.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{services.map((service) => (
|
||||
<div
|
||||
key={service.name}
|
||||
className="p-6 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3 bg-gray-100 rounded-full">
|
||||
{service.name.toLowerCase().includes("discord") ? (
|
||||
<MessageSquare className="h-6 w-6 text-gray-600" />
|
||||
) : service.name.toLowerCase().includes("telegram") ? (
|
||||
<Send className="h-6 w-6 text-gray-600" />
|
||||
) : (
|
||||
<Bell className="h-6 w-6 text-gray-600" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-medium text-gray-900 capitalize">
|
||||
{service.name}
|
||||
</h4>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
service.enabled
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{service.enabled ? (
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
) : (
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{service.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
service.configured
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}
|
||||
>
|
||||
{service.configured ? "Configured" : "Not Configured"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleDeleteService(service.name)}
|
||||
disabled={deleteServiceMutation.isPending}
|
||||
className="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors"
|
||||
title={`Delete ${service.name} service`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notification Settings */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Settings className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Notification Settings
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{settings && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">
|
||||
Filters
|
||||
</h4>
|
||||
<div className="bg-gray-50 rounded-md p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Case Insensitive Filters
|
||||
</label>
|
||||
<p className="text-sm text-gray-900">
|
||||
{settings.filters.case_insensitive.length > 0
|
||||
? settings.filters.case_insensitive.join(", ")
|
||||
: "None"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Case Sensitive Filters
|
||||
</label>
|
||||
<p className="text-sm text-gray-900">
|
||||
{settings.filters.case_sensitive &&
|
||||
settings.filters.case_sensitive.length > 0
|
||||
? settings.filters.case_sensitive.join(", ")
|
||||
: "None"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>
|
||||
Configure notification settings through your backend API to
|
||||
customize filters and service configurations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
frontend/src/components/RawTransactionModal.tsx
Normal file
120
frontend/src/components/RawTransactionModal.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { X, Copy, Check } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { RawTransactionData } from "../types/api";
|
||||
|
||||
interface RawTransactionModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
rawTransaction: RawTransactionData | undefined;
|
||||
transactionId: string;
|
||||
}
|
||||
|
||||
export default function RawTransactionModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
rawTransaction,
|
||||
transactionId,
|
||||
}: RawTransactionModalProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!rawTransaction) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(
|
||||
JSON.stringify(rawTransaction, null, 2),
|
||||
);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy to clipboard:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<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 */}
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 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="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Raw Transaction Data
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
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"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-1 text-green-600" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
Copy JSON
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Transaction ID:{" "}
|
||||
<code className="bg-gray-100 px-2 py-1 rounded text-xs">
|
||||
{transactionId}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{rawTransaction ? (
|
||||
<div className="bg-gray-50 rounded-lg p-4 overflow-auto max-h-96">
|
||||
<pre className="text-sm text-gray-800 whitespace-pre-wrap">
|
||||
{JSON.stringify(rawTransaction, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-50 rounded-lg p-8 text-center">
|
||||
<p className="text-gray-600">
|
||||
Raw transaction data is not available for this transaction.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Try refreshing the page or check if the transaction was
|
||||
fetched with summary_only=false.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
frontend/src/components/Sidebar.tsx
Normal file
106
frontend/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Link, useLocation } from "@tanstack/react-router";
|
||||
import {
|
||||
CreditCard,
|
||||
Home,
|
||||
List,
|
||||
BarChart3,
|
||||
Bell,
|
||||
TrendingUp,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "../lib/api";
|
||||
import { formatCurrency } from "../lib/utils";
|
||||
import { cn } from "../lib/utils";
|
||||
import type { Account } from "../types/api";
|
||||
|
||||
const navigation = [
|
||||
{ name: "Overview", icon: Home, to: "/" },
|
||||
{ name: "Transactions", icon: List, to: "/transactions" },
|
||||
{ name: "Analytics", icon: BarChart3, to: "/analytics" },
|
||||
{ name: "Notifications", icon: Bell, to: "/notifications" },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
sidebarOpen: boolean;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
|
||||
const location = useLocation();
|
||||
|
||||
const { data: accounts } = useQuery<Account[]>({
|
||||
queryKey: ["accounts"],
|
||||
queryFn: apiClient.getAccounts,
|
||||
});
|
||||
|
||||
const totalBalance =
|
||||
accounts?.reduce((sum, account) => {
|
||||
const primaryBalance = account.balances?.[0]?.amount || 0;
|
||||
return sum + primaryBalance;
|
||||
}, 0) || 0;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="flex items-center space-x-2 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<CreditCard className="h-8 w-8 text-blue-600" />
|
||||
<h1 className="text-xl font-bold text-gray-900">Leggen</h1>
|
||||
</Link>
|
||||
<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) => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={`flex items-center w-full px-3 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
location.pathname === item.to
|
||||
? "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}
|
||||
</Link>
|
||||
))}
|
||||
</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">
|
||||
{formatCurrency(totalBalance)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{accounts?.length || 0} accounts
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
812
frontend/src/components/TransactionsTable.tsx
Normal file
812
frontend/src/components/TransactionsTable.tsx
Normal file
@@ -0,0 +1,812 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
getFilteredRowModel,
|
||||
flexRender,
|
||||
} from "@tanstack/react-table";
|
||||
import type {
|
||||
ColumnDef,
|
||||
SortingState,
|
||||
ColumnFiltersState,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Eye,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "../lib/api";
|
||||
import { formatCurrency, formatDate } from "../lib/utils";
|
||||
import TransactionSkeleton from "./TransactionSkeleton";
|
||||
import FiltersSkeleton from "./FiltersSkeleton";
|
||||
import RawTransactionModal from "./RawTransactionModal";
|
||||
import { FilterBar, type FilterState } from "./filters";
|
||||
import type { Account, Transaction, ApiResponse, Balance } from "../types/api";
|
||||
|
||||
export default function TransactionsTable() {
|
||||
// Filter state consolidated into a single object
|
||||
const [filterState, setFilterState] = useState<FilterState>({
|
||||
searchTerm: "",
|
||||
selectedAccount: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
minAmount: "",
|
||||
maxAmount: "",
|
||||
});
|
||||
|
||||
const [showRawModal, setShowRawModal] = useState(false);
|
||||
const [selectedTransaction, setSelectedTransaction] =
|
||||
useState<Transaction | null>(null);
|
||||
const [showRunningBalance, setShowRunningBalance] = useState(true);
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(50);
|
||||
|
||||
// Debounced search state
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(filterState.searchTerm);
|
||||
|
||||
// Table state (remove pagination from table)
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
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
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearchTerm(filterState.searchTerm);
|
||||
}, 300); // 300ms delay
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [filterState.searchTerm]);
|
||||
|
||||
// Reset pagination when search term changes
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm !== filterState.searchTerm) {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
}, [debouncedSearchTerm, filterState.searchTerm]);
|
||||
|
||||
const { data: accounts } = useQuery<Account[]>({
|
||||
queryKey: ["accounts"],
|
||||
queryFn: apiClient.getAccounts,
|
||||
});
|
||||
|
||||
const { data: balances } = useQuery<Balance[]>({
|
||||
queryKey: ["balances"],
|
||||
queryFn: apiClient.getBalances,
|
||||
enabled: showRunningBalance,
|
||||
});
|
||||
|
||||
const {
|
||||
data: transactionsResponse,
|
||||
isLoading: transactionsLoading,
|
||||
error: transactionsError,
|
||||
refetch: refetchTransactions,
|
||||
} = useQuery<ApiResponse<Transaction[]>>({
|
||||
queryKey: [
|
||||
"transactions",
|
||||
filterState.selectedAccount,
|
||||
filterState.startDate,
|
||||
filterState.endDate,
|
||||
currentPage,
|
||||
perPage,
|
||||
debouncedSearchTerm,
|
||||
filterState.minAmount,
|
||||
filterState.maxAmount,
|
||||
],
|
||||
queryFn: () =>
|
||||
apiClient.getTransactions({
|
||||
accountId: filterState.selectedAccount || undefined,
|
||||
startDate: filterState.startDate || undefined,
|
||||
endDate: filterState.endDate || undefined,
|
||||
page: currentPage,
|
||||
perPage: perPage,
|
||||
search: debouncedSearchTerm || undefined,
|
||||
summaryOnly: false,
|
||||
minAmount: filterState.minAmount ? parseFloat(filterState.minAmount) : undefined,
|
||||
maxAmount: filterState.maxAmount ? parseFloat(filterState.maxAmount) : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const transactions = transactionsResponse?.data || [];
|
||||
const pagination = transactionsResponse?.pagination;
|
||||
|
||||
// Check if search is currently debouncing
|
||||
const isSearchLoading = filterState.searchTerm !== debouncedSearchTerm;
|
||||
|
||||
// Reset pagination when total becomes 0 (no results)
|
||||
useEffect(() => {
|
||||
if (pagination && pagination.total === 0 && currentPage > 1) {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
}, [pagination, currentPage]);
|
||||
|
||||
// Reset pagination when filters change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filterState.selectedAccount, filterState.startDate, filterState.endDate, filterState.minAmount, filterState.maxAmount]);
|
||||
|
||||
const handleViewRaw = (transaction: Transaction) => {
|
||||
setSelectedTransaction(transaction);
|
||||
setShowRawModal(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setShowRawModal(false);
|
||||
setSelectedTransaction(null);
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
filterState.searchTerm ||
|
||||
filterState.selectedAccount ||
|
||||
filterState.startDate ||
|
||||
filterState.endDate ||
|
||||
filterState.minAmount ||
|
||||
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
|
||||
const columns: ColumnDef<Transaction>[] = [
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Description",
|
||||
cell: ({ row }) => {
|
||||
const transaction = row.original;
|
||||
const account = accounts?.find(
|
||||
(acc) => acc.id === transaction.account_id,
|
||||
);
|
||||
const isPositive = transaction.transaction_value > 0;
|
||||
|
||||
return (
|
||||
<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 min-w-0">
|
||||
<h4 className="text-sm font-medium text-gray-900 truncate">
|
||||
{transaction.description}
|
||||
</h4>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
{account && (
|
||||
<p className="truncate">
|
||||
{account.name || "Unnamed Account"} •{" "}
|
||||
{account.institution_id}
|
||||
</p>
|
||||
)}
|
||||
{(transaction.creditor_name || transaction.debtor_name) && (
|
||||
<p className="truncate">
|
||||
{isPositive ? "From: " : "To: "}
|
||||
{transaction.creditor_name || transaction.debtor_name}
|
||||
</p>
|
||||
)}
|
||||
{transaction.reference && (
|
||||
<p className="truncate">Ref: {transaction.reference}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "transaction_value",
|
||||
header: "Amount",
|
||||
cell: ({ row }) => {
|
||||
const transaction = row.original;
|
||||
const isPositive = transaction.transaction_value > 0;
|
||||
return (
|
||||
<div className="text-right">
|
||||
<p
|
||||
className={`text-lg font-semibold ${
|
||||
isPositive ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{isPositive ? "+" : ""}
|
||||
{formatCurrency(
|
||||
transaction.transaction_value,
|
||||
transaction.transaction_currency,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
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-gray-900">
|
||||
{formatCurrency(balance, transaction.transaction_currency)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}] : []),
|
||||
{
|
||||
accessorKey: "transaction_date",
|
||||
header: "Date",
|
||||
cell: ({ row }) => {
|
||||
const transaction = row.original;
|
||||
return (
|
||||
<div className="text-sm text-gray-900">
|
||||
{transaction.transaction_date
|
||||
? formatDate(transaction.transaction_date)
|
||||
: "No date"}
|
||||
{transaction.booking_date &&
|
||||
transaction.booking_date !== transaction.transaction_date && (
|
||||
<p className="text-xs text-gray-400">
|
||||
Booked: {formatDate(transaction.booking_date)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
sortingFn: "datetime",
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => {
|
||||
const transaction = row.original;
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: transactions,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
globalFilter: filterState.searchTerm,
|
||||
},
|
||||
onGlobalFilterChange: (value: string) => handleFilterChange("searchTerm", value),
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
// Custom global filter that searches multiple fields
|
||||
const transaction = row.original;
|
||||
const searchLower = filterValue.toLowerCase();
|
||||
|
||||
const description = transaction.description || "";
|
||||
const creditorName = transaction.creditor_name || "";
|
||||
const debtorName = transaction.debtor_name || "";
|
||||
const reference = transaction.reference || "";
|
||||
|
||||
return (
|
||||
description.toLowerCase().includes(searchLower) ||
|
||||
creditorName.toLowerCase().includes(searchLower) ||
|
||||
debtorName.toLowerCase().includes(searchLower) ||
|
||||
reference.toLowerCase().includes(searchLower)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (transactionsLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<FiltersSkeleton />
|
||||
<TransactionSkeleton rows={10} view="table" />
|
||||
<div className="md:hidden">
|
||||
<TransactionSkeleton rows={10} view="mobile" />
|
||||
</div>
|
||||
</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">
|
||||
{/* New FilterBar */}
|
||||
<FilterBar
|
||||
filterState={filterState}
|
||||
onFilterChange={handleFilterChange}
|
||||
onClearFilters={handleClearFilters}
|
||||
accounts={accounts}
|
||||
isSearchLoading={isSearchLoading}
|
||||
showRunningBalance={showRunningBalance}
|
||||
onToggleRunningBalance={() => setShowRunningBalance(!showRunningBalance)}
|
||||
/>
|
||||
|
||||
{/* Results Summary */}
|
||||
<div className="bg-white rounded-lg shadow border">
|
||||
<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
|
||||
{transactions.length !== 1 ? "s" : ""} (
|
||||
{pagination ? (
|
||||
<>
|
||||
{(pagination.page - 1) * pagination.per_page + 1}-
|
||||
{Math.min(
|
||||
pagination.page * pagination.per_page,
|
||||
pagination.total,
|
||||
)}{" "}
|
||||
of {pagination.total}
|
||||
</>
|
||||
) : (
|
||||
"loading..."
|
||||
)}
|
||||
)
|
||||
{filterState.selectedAccount && accounts && (
|
||||
<span className="ml-1">
|
||||
for {accounts.find((acc) => acc.id === filterState.selectedAccount)?.name}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Responsive Table/Cards */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{/* Desktop Table View (hidden on mobile) */}
|
||||
<div className="hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
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"
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</span>
|
||||
{header.column.getCanSort() && (
|
||||
<div className="flex flex-col">
|
||||
<ChevronUp
|
||||
className={`h-3 w-3 ${
|
||||
header.column.getIsSorted() === "asc"
|
||||
? "text-blue-600"
|
||||
: "text-gray-400"
|
||||
}`}
|
||||
/>
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 -mt-1 ${
|
||||
header.column.getIsSorted() === "desc"
|
||||
? "text-blue-600"
|
||||
: "text-gray-400"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{table.getRowModel().rows.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
className="px-6 py-12 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>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id} className="hover:bg-gray-50">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-6 py-4 whitespace-nowrap">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Card View (visible only on mobile) */}
|
||||
<div className="md:hidden">
|
||||
{table.getRowModel().rows.length === 0 ? (
|
||||
<div className="px-6 py-12 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="divide-y divide-gray-200">
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
const transaction = row.original;
|
||||
const account = accounts?.find(
|
||||
(acc) => acc.id === transaction.account_id,
|
||||
);
|
||||
const isPositive = transaction.transaction_value > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
className="p-4 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div
|
||||
className={`p-2 rounded-full flex-shrink-0 ${
|
||||
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 min-w-0">
|
||||
<h4 className="text-sm font-medium text-gray-900 break-words">
|
||||
{transaction.description}
|
||||
</h4>
|
||||
<div className="text-xs text-gray-500 space-y-1 mt-1">
|
||||
{account && (
|
||||
<p className="break-words">
|
||||
{account.name || "Unnamed Account"} •{" "}
|
||||
{account.institution_id}
|
||||
</p>
|
||||
)}
|
||||
{(transaction.creditor_name || transaction.debtor_name) && (
|
||||
<p className="break-words">
|
||||
{isPositive ? "From: " : "To: "}
|
||||
{transaction.creditor_name || transaction.debtor_name}
|
||||
</p>
|
||||
)}
|
||||
{transaction.reference && (
|
||||
<p className="break-words">Ref: {transaction.reference}</p>
|
||||
)}
|
||||
<p className="text-gray-400">
|
||||
{transaction.transaction_date
|
||||
? formatDate(transaction.transaction_date)
|
||||
: "No date"}
|
||||
{transaction.booking_date &&
|
||||
transaction.booking_date !== transaction.transaction_date && (
|
||||
<span className="ml-2">
|
||||
(Booked: {formatDate(transaction.booking_date)})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right ml-3 flex-shrink-0">
|
||||
<p
|
||||
className={`text-lg font-semibold mb-1 ${
|
||||
isPositive ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{isPositive ? "+" : ""}
|
||||
{formatCurrency(
|
||||
transaction.transaction_value,
|
||||
transaction.transaction_currency,
|
||||
)}
|
||||
</p>
|
||||
{showRunningBalance && (
|
||||
<p className="text-xs text-gray-500 mb-1">
|
||||
Balance: {formatCurrency(
|
||||
runningBalances[`${transaction.account_id}-${transaction.transaction_id}`] || 0,
|
||||
transaction.transaction_currency,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{/* Mobile pagination controls */}
|
||||
<div className="flex justify-between w-full sm:hidden">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={pagination.page === 1}
|
||||
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"
|
||||
>
|
||||
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>
|
||||
|
||||
{/* Raw Transaction Modal */}
|
||||
<RawTransactionModal
|
||||
isOpen={showRawModal}
|
||||
onClose={handleCloseModal}
|
||||
rawTransaction={selectedTransaction?.raw_transaction}
|
||||
transactionId={selectedTransaction?.transaction_id || "unknown"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
frontend/src/components/analytics/BalanceChart.tsx
Normal file
157
frontend/src/components/analytics/BalanceChart.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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"];
|
||||
|
||||
if (finalData.length === 0) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Balance Progress
|
||||
</h3>
|
||||
<div className="h-80 flex items-center justify-center text-gray-500">
|
||||
No balance data available
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<h3 className="text-lg font-medium text-gray-900 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
|
||||
formatter={(value: number, name: string) => [
|
||||
`€${value.toLocaleString()}`,
|
||||
getAccountDisplayName(name),
|
||||
]}
|
||||
labelFormatter={(label) => `Date: ${label}`}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
137
frontend/src/components/analytics/MonthlyTrends.tsx
Normal file
137
frontend/src/components/analytics/MonthlyTrends.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
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-gray-900 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-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayData.length === 0) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Monthly Spending Trends
|
||||
</h3>
|
||||
<div className="h-80 flex items-center justify-center text-gray-500">
|
||||
No transaction data available
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-white p-3 border rounded shadow-lg">
|
||||
<p className="font-medium">{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-gray-900 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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
64
frontend/src/components/analytics/StatCard.tsx
Normal file
64
frontend/src/components/analytics/StatCard.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={clsx(
|
||||
"bg-white rounded-lg shadow p-6 border border-gray-200",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<Icon className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
{title}
|
||||
</dt>
|
||||
<dd className="flex items-baseline">
|
||||
<div className="text-2xl font-semibold text-gray-900">
|
||||
{value}
|
||||
</div>
|
||||
{trend && (
|
||||
<div
|
||||
className={clsx(
|
||||
"ml-2 flex items-baseline text-sm font-semibold",
|
||||
trend.isPositive ? "text-green-600" : "text-red-600"
|
||||
)}
|
||||
>
|
||||
{trend.isPositive ? "+" : ""}
|
||||
{trend.value}%
|
||||
</div>
|
||||
)}
|
||||
</dd>
|
||||
{subtitle && (
|
||||
<dd className="text-sm text-gray-600 mt-1">{subtitle}</dd>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 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-gray-700">
|
||||
<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)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
selectedPeriod.value === period.value
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{period.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
frontend/src/components/analytics/TransactionDistribution.tsx
Normal file
142
frontend/src/components/analytics/TransactionDistribution.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
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-gray-900 mb-4">
|
||||
Account Distribution
|
||||
</h3>
|
||||
<div className="h-80 flex items-center justify-center text-gray-500">
|
||||
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-white p-3 border rounded shadow-lg">
|
||||
<p className="font-medium">{data.name}</p>
|
||||
<p className="text-blue-600">
|
||||
Balance: €{data.value.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-gray-600">{percentage}% of total</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<h3 className="text-lg font-medium text-gray-900 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-gray-700">{item.name}</span>
|
||||
</div>
|
||||
<span className="font-medium">€{item.value.toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
frontend/src/components/filters/AccountCombobox.tsx
Normal file
118
frontend/src/components/filters/AccountCombobox.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
134
frontend/src/components/filters/ActiveFilterChips.tsx
Normal file
134
frontend/src/components/filters/ActiveFilterChips.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
122
frontend/src/components/filters/AdvancedFiltersPopover.tsx
Normal file
122
frontend/src/components/filters/AdvancedFiltersPopover.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
207
frontend/src/components/filters/DateRangePicker.tsx
Normal file
207
frontend/src/components/filters/DateRangePicker.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
139
frontend/src/components/filters/FilterBar.tsx
Normal file
139
frontend/src/components/filters/FilterBar.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
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-white 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-gray-900">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-gray-400" />
|
||||
<Input
|
||||
placeholder="Search transactions..."
|
||||
value={filterState.searchTerm}
|
||||
onChange={(e) => onFilterChange("searchTerm", e.target.value)}
|
||||
className="pl-9 pr-8"
|
||||
/>
|
||||
{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>
|
||||
|
||||
{/* 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-gray-600"
|
||||
>
|
||||
<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';
|
||||
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 }
|
||||
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,
|
||||
}
|
||||
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 }
|
||||
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,
|
||||
}
|
||||
68
frontend/src/index.css
Normal file
68
frontend/src/index.css
Normal file
@@ -0,0 +1,68 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@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;
|
||||
}
|
||||
}
|
||||
212
frontend/src/lib/api.ts
Normal file
212
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import axios from "axios";
|
||||
import type {
|
||||
Account,
|
||||
Transaction,
|
||||
AnalyticsTransaction,
|
||||
Balance,
|
||||
ApiResponse,
|
||||
NotificationSettings,
|
||||
NotificationTest,
|
||||
NotificationService,
|
||||
NotificationServicesResponse,
|
||||
HealthData,
|
||||
AccountUpdate,
|
||||
TransactionStats,
|
||||
} from "../types/api";
|
||||
|
||||
// Use VITE_API_URL for development, relative URLs for production
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || "/api/v1";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
export const apiClient = {
|
||||
// Get all accounts
|
||||
getAccounts: async (): Promise<Account[]> => {
|
||||
const response = await api.get<ApiResponse<Account[]>>("/accounts");
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get account by ID
|
||||
getAccount: async (id: string): Promise<Account> => {
|
||||
const response = await api.get<ApiResponse<Account>>(`/accounts/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Update account details
|
||||
updateAccount: async (
|
||||
id: string,
|
||||
updates: AccountUpdate,
|
||||
): Promise<{ id: string; name?: string }> => {
|
||||
const response = await api.put<ApiResponse<{ id: string; name?: string }>>(
|
||||
`/accounts/${id}`,
|
||||
updates,
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get all balances
|
||||
getBalances: async (): Promise<Balance[]> => {
|
||||
const response = await api.get<ApiResponse<Balance[]>>("/balances");
|
||||
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
|
||||
getAccountBalances: async (accountId: string): Promise<Balance[]> => {
|
||||
const response = await api.get<ApiResponse<Balance[]>>(
|
||||
`/accounts/${accountId}/balances`,
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get transactions with optional filters
|
||||
getTransactions: async (params?: {
|
||||
accountId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
search?: string;
|
||||
summaryOnly?: boolean;
|
||||
minAmount?: number;
|
||||
maxAmount?: number;
|
||||
}): Promise<ApiResponse<Transaction[]>> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params?.accountId) queryParams.append("account_id", params.accountId);
|
||||
if (params?.startDate) queryParams.append("date_from", params.startDate);
|
||||
if (params?.endDate) queryParams.append("date_to", params.endDate);
|
||||
if (params?.page) queryParams.append("page", params.page.toString());
|
||||
if (params?.perPage)
|
||||
queryParams.append("per_page", params.perPage.toString());
|
||||
if (params?.search) queryParams.append("search", params.search);
|
||||
if (params?.summaryOnly !== undefined) {
|
||||
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[]>>(
|
||||
`/transactions?${queryParams.toString()}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get transaction by ID
|
||||
getTransaction: async (id: string): Promise<Transaction> => {
|
||||
const response = await api.get<ApiResponse<Transaction>>(
|
||||
`/transactions/${id}`,
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get notification settings
|
||||
getNotificationSettings: async (): Promise<NotificationSettings> => {
|
||||
const response = await api.get<ApiResponse<NotificationSettings>>(
|
||||
"/notifications/settings",
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Update notification settings
|
||||
updateNotificationSettings: async (
|
||||
settings: NotificationSettings,
|
||||
): Promise<NotificationSettings> => {
|
||||
const response = await api.put<ApiResponse<NotificationSettings>>(
|
||||
"/notifications/settings",
|
||||
settings,
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Test notification
|
||||
testNotification: async (test: NotificationTest): Promise<void> => {
|
||||
await api.post("/notifications/test", test);
|
||||
},
|
||||
|
||||
// Get notification services
|
||||
getNotificationServices: async (): Promise<NotificationService[]> => {
|
||||
const response = await api.get<ApiResponse<NotificationServicesResponse>>(
|
||||
"/notifications/services",
|
||||
);
|
||||
// Convert object to array format
|
||||
const servicesData = response.data.data;
|
||||
return Object.values(servicesData);
|
||||
},
|
||||
|
||||
// Delete notification service
|
||||
deleteNotificationService: async (service: string): Promise<void> => {
|
||||
await api.delete(`/notifications/settings/${service}`);
|
||||
},
|
||||
|
||||
// Health check
|
||||
getHealth: async (): Promise<HealthData> => {
|
||||
const response = await api.get<ApiResponse<HealthData>>("/health");
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// 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;
|
||||
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" },
|
||||
];
|
||||
22
frontend/src/lib/utils.ts
Normal file
22
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number, currency: string = "EUR"): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
25
frontend/src/main.tsx
Normal file
25
frontend/src/main.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { createRouter, RouterProvider } from "@tanstack/react-router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import "./index.css";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
const router = createRouter({ routeTree });
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
113
frontend/src/routeTree.gen.ts
Normal file
113
frontend/src/routeTree.gen.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as TransactionsRouteImport } from './routes/transactions'
|
||||
import { Route as NotificationsRouteImport } from './routes/notifications'
|
||||
import { Route as AnalyticsRouteImport } from './routes/analytics'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
|
||||
const TransactionsRoute = TransactionsRouteImport.update({
|
||||
id: '/transactions',
|
||||
path: '/transactions',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const NotificationsRoute = NotificationsRouteImport.update({
|
||||
id: '/notifications',
|
||||
path: '/notifications',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AnalyticsRoute = AnalyticsRouteImport.update({
|
||||
id: '/analytics',
|
||||
path: '/analytics',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/analytics': typeof AnalyticsRoute
|
||||
'/notifications': typeof NotificationsRoute
|
||||
'/transactions': typeof TransactionsRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/analytics': typeof AnalyticsRoute
|
||||
'/notifications': typeof NotificationsRoute
|
||||
'/transactions': typeof TransactionsRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/analytics': typeof AnalyticsRoute
|
||||
'/notifications': typeof NotificationsRoute
|
||||
'/transactions': typeof TransactionsRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/analytics' | '/notifications' | '/transactions'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/analytics' | '/notifications' | '/transactions'
|
||||
id: '__root__' | '/' | '/analytics' | '/notifications' | '/transactions'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AnalyticsRoute: typeof AnalyticsRoute
|
||||
NotificationsRoute: typeof NotificationsRoute
|
||||
TransactionsRoute: typeof TransactionsRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/transactions': {
|
||||
id: '/transactions'
|
||||
path: '/transactions'
|
||||
fullPath: '/transactions'
|
||||
preLoaderRoute: typeof TransactionsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/notifications': {
|
||||
id: '/notifications'
|
||||
path: '/notifications'
|
||||
fullPath: '/notifications'
|
||||
preLoaderRoute: typeof NotificationsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/analytics': {
|
||||
id: '/analytics'
|
||||
path: '/analytics'
|
||||
fullPath: '/analytics'
|
||||
preLoaderRoute: typeof AnalyticsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AnalyticsRoute: AnalyticsRoute,
|
||||
NotificationsRoute: NotificationsRoute,
|
||||
TransactionsRoute: TransactionsRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
33
frontend/src/routes/__root.tsx
Normal file
33
frontend/src/routes/__root.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import Sidebar from "../components/Sidebar";
|
||||
import Header from "../components/Header";
|
||||
|
||||
function RootLayout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
<Header setSidebarOpen={setSidebarOpen} />
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
});
|
||||
141
frontend/src/routes/analytics.tsx
Normal file
141
frontend/src/routes/analytics.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
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 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-gray-200 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-gray-200 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="h-96 bg-gray-200 rounded"></div>
|
||||
<div className="h-96 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-8">
|
||||
{/* Time Period Filter */}
|
||||
<TimePeriodFilter
|
||||
selectedPeriod={selectedPeriod}
|
||||
onPeriodChange={setSelectedPeriod}
|
||||
className="bg-white rounded-lg shadow p-4 border border-gray-200"
|
||||
/>
|
||||
|
||||
{/* 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">
|
||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||
<BalanceChart data={balances || []} accounts={accounts || []} />
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||
<TransactionDistribution accounts={accounts || []} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monthly Trends */}
|
||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||
<MonthlyTrends days={selectedPeriod.days} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/analytics")({
|
||||
component: AnalyticsDashboard,
|
||||
});
|
||||
6
frontend/src/routes/index.tsx
Normal file
6
frontend/src/routes/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import AccountsOverview from "../components/AccountsOverview";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: AccountsOverview,
|
||||
});
|
||||
6
frontend/src/routes/notifications.tsx
Normal file
6
frontend/src/routes/notifications.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import Notifications from "../components/Notifications";
|
||||
|
||||
export const Route = createFileRoute("/notifications")({
|
||||
component: Notifications,
|
||||
});
|
||||
11
frontend/src/routes/transactions.tsx
Normal file
11
frontend/src/routes/transactions.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import TransactionsTable from "../components/TransactionsTable";
|
||||
|
||||
export const Route = createFileRoute("/transactions")({
|
||||
component: TransactionsTable,
|
||||
validateSearch: (search) => ({
|
||||
accountId: search.accountId as string | undefined,
|
||||
startDate: search.startDate as string | undefined,
|
||||
endDate: search.endDate as string | undefined,
|
||||
}),
|
||||
});
|
||||
214
frontend/src/types/api.ts
Normal file
214
frontend/src/types/api.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
export interface AccountBalance {
|
||||
amount: number;
|
||||
currency: string;
|
||||
balance_type: string;
|
||||
last_change_date?: string;
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: string;
|
||||
institution_id: string;
|
||||
status: string;
|
||||
iban?: string;
|
||||
name?: string;
|
||||
currency?: string;
|
||||
created: string;
|
||||
last_accessed?: string;
|
||||
balances: AccountBalance[];
|
||||
}
|
||||
|
||||
export interface AccountUpdate {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface RawTransactionData {
|
||||
transactionId?: string;
|
||||
bookingDate?: string;
|
||||
valueDate?: string;
|
||||
bookingDateTime?: string;
|
||||
valueDateTime?: string;
|
||||
transactionAmount?: {
|
||||
amount: string;
|
||||
currency: string;
|
||||
};
|
||||
currencyExchange?: {
|
||||
instructedAmount?: {
|
||||
amount: string;
|
||||
currency: string;
|
||||
};
|
||||
sourceCurrency?: string;
|
||||
exchangeRate?: string;
|
||||
unitCurrency?: string;
|
||||
targetCurrency?: string;
|
||||
};
|
||||
creditorName?: string;
|
||||
debtorName?: string;
|
||||
debtorAccount?: {
|
||||
iban?: string;
|
||||
};
|
||||
remittanceInformationUnstructuredArray?: string[];
|
||||
proprietaryBankTransactionCode?: string;
|
||||
balanceAfterTransaction?: {
|
||||
balanceAmount: {
|
||||
amount: string;
|
||||
currency: string;
|
||||
};
|
||||
balanceType: string;
|
||||
};
|
||||
internalTransactionId?: string;
|
||||
[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 {
|
||||
transaction_id: string; // NEW: stable bank-provided transaction ID
|
||||
internal_transaction_id: string | null; // OLD: unstable GoCardless ID
|
||||
account_id: string;
|
||||
transaction_value: number;
|
||||
transaction_currency: string;
|
||||
description: string;
|
||||
transaction_date: string;
|
||||
transaction_status: string;
|
||||
// Optional fields that may be present in some transactions
|
||||
institution_id?: string;
|
||||
iban?: string;
|
||||
booking_date?: string;
|
||||
value_date?: string;
|
||||
creditor_name?: string;
|
||||
debtor_name?: string;
|
||||
reference?: string;
|
||||
category?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
// Raw transaction data (only present when summary_only=false)
|
||||
raw_transaction?: RawTransactionData;
|
||||
}
|
||||
|
||||
// Type for raw transaction data from API (before sanitization)
|
||||
export interface RawTransaction {
|
||||
id?: string;
|
||||
internal_id?: string;
|
||||
account_id?: string;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
description?: string;
|
||||
transaction_date?: string;
|
||||
booking_date?: string;
|
||||
value_date?: string;
|
||||
creditor_name?: string;
|
||||
debtor_name?: string;
|
||||
reference?: string;
|
||||
category?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface Balance {
|
||||
id: string;
|
||||
account_id: string;
|
||||
balance_amount: number;
|
||||
balance_type: string;
|
||||
currency: string;
|
||||
reference_date: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Bank {
|
||||
id: string;
|
||||
name: string;
|
||||
country_code: string;
|
||||
logo_url?: string;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
success: boolean;
|
||||
pagination?: {
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
// Notification types
|
||||
export interface DiscordConfig {
|
||||
webhook: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface TelegramConfig {
|
||||
token: string;
|
||||
chat_id: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationFilters {
|
||||
case_insensitive: string[];
|
||||
case_sensitive?: string[];
|
||||
}
|
||||
|
||||
export interface NotificationSettings {
|
||||
discord?: DiscordConfig;
|
||||
telegram?: TelegramConfig;
|
||||
filters: NotificationFilters;
|
||||
}
|
||||
|
||||
export interface NotificationTest {
|
||||
service: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface NotificationService {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationServicesResponse {
|
||||
[serviceName: string]: NotificationService;
|
||||
}
|
||||
|
||||
// Health check response data
|
||||
export interface HealthData {
|
||||
status: string;
|
||||
config_loaded?: boolean;
|
||||
message?: 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
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
57
frontend/tailwind.config.js
Normal file
57
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,57 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
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"), require("tailwindcss-animate")],
|
||||
};
|
||||
33
frontend/tsconfig.app.json
Normal file
33
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
13
frontend/tsconfig.json
Normal file
13
frontend/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
25
frontend/tsconfig.node.json
Normal file
25
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
13
frontend/vite.config.ts
Normal file
13
frontend/vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [TanStackRouterVite(), react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": "/src",
|
||||
},
|
||||
},
|
||||
});
|
||||
0
leggen/__init__.py
Normal file
0
leggen/__init__.py
Normal file
77
leggen/api/models/accounts.py
Normal file
77
leggen/api/models/accounts.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AccountBalance(BaseModel):
|
||||
"""Account balance model"""
|
||||
|
||||
amount: float
|
||||
currency: str
|
||||
balance_type: str
|
||||
last_change_date: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
json_encoders = {datetime: lambda v: v.isoformat() if v else None}
|
||||
|
||||
|
||||
class AccountDetails(BaseModel):
|
||||
"""Account details model"""
|
||||
|
||||
id: str
|
||||
institution_id: str
|
||||
status: str
|
||||
iban: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
currency: Optional[str] = None
|
||||
created: datetime
|
||||
last_accessed: Optional[datetime] = None
|
||||
balances: List[AccountBalance] = []
|
||||
|
||||
class Config:
|
||||
json_encoders = {datetime: lambda v: v.isoformat() if v else None}
|
||||
|
||||
|
||||
class AccountUpdate(BaseModel):
|
||||
"""Account update model"""
|
||||
|
||||
name: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
json_encoders = {datetime: lambda v: v.isoformat() if v else None}
|
||||
|
||||
|
||||
class Transaction(BaseModel):
|
||||
"""Transaction model"""
|
||||
|
||||
transaction_id: str # NEW: stable bank-provided transaction ID
|
||||
internal_transaction_id: Optional[str] = None # OLD: unstable GoCardless ID
|
||||
institution_id: str
|
||||
iban: Optional[str] = None
|
||||
account_id: str
|
||||
transaction_date: datetime
|
||||
description: str
|
||||
transaction_value: float
|
||||
transaction_currency: str
|
||||
transaction_status: str # "booked" or "pending"
|
||||
raw_transaction: Dict[str, Any]
|
||||
|
||||
class Config:
|
||||
json_encoders = {datetime: lambda v: v.isoformat()}
|
||||
|
||||
|
||||
class TransactionSummary(BaseModel):
|
||||
"""Transaction summary for lists"""
|
||||
|
||||
transaction_id: str # NEW: stable bank-provided transaction ID
|
||||
internal_transaction_id: Optional[str] = None
|
||||
date: datetime
|
||||
description: str
|
||||
amount: float
|
||||
currency: str
|
||||
status: str
|
||||
account_id: str
|
||||
|
||||
class Config:
|
||||
json_encoders = {datetime: lambda v: v.isoformat()}
|
||||
52
leggen/api/models/banks.py
Normal file
52
leggen/api/models/banks.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class BankInstitution(BaseModel):
|
||||
"""Bank institution model"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
bic: Optional[str] = None
|
||||
transaction_total_days: int
|
||||
countries: List[str]
|
||||
logo: Optional[str] = None
|
||||
|
||||
|
||||
class BankConnectionRequest(BaseModel):
|
||||
"""Request to connect to a bank"""
|
||||
|
||||
institution_id: str
|
||||
redirect_url: Optional[str] = "http://localhost:8000/"
|
||||
|
||||
|
||||
class BankRequisition(BaseModel):
|
||||
"""Bank requisition/connection model"""
|
||||
|
||||
id: str
|
||||
institution_id: str
|
||||
status: str
|
||||
status_display: Optional[str] = None
|
||||
created: datetime
|
||||
link: str
|
||||
accounts: List[str] = []
|
||||
|
||||
class Config:
|
||||
json_encoders = {datetime: lambda v: v.isoformat()}
|
||||
|
||||
|
||||
class BankConnectionStatus(BaseModel):
|
||||
"""Bank connection status response"""
|
||||
|
||||
bank_id: str
|
||||
bank_name: str
|
||||
status: str
|
||||
status_display: str
|
||||
created_at: datetime
|
||||
requisition_id: str
|
||||
accounts_count: int
|
||||
|
||||
class Config:
|
||||
json_encoders = {datetime: lambda v: v.isoformat()}
|
||||
29
leggen/api/models/common.py
Normal file
29
leggen/api/models/common.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class APIResponse(BaseModel):
|
||||
"""Base API response model"""
|
||||
|
||||
success: bool = True
|
||||
message: Optional[str] = None
|
||||
data: Optional[Any] = None
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Error response model"""
|
||||
|
||||
success: bool = False
|
||||
message: str
|
||||
error_code: Optional[str] = None
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel):
|
||||
"""Paginated response model"""
|
||||
|
||||
success: bool = True
|
||||
data: list
|
||||
pagination: Dict[str, Any]
|
||||
message: Optional[str] = None
|
||||
51
leggen/api/models/notifications.py
Normal file
51
leggen/api/models/notifications.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DiscordConfig(BaseModel):
|
||||
"""Discord notification configuration"""
|
||||
|
||||
webhook: str
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class TelegramConfig(BaseModel):
|
||||
"""Telegram notification configuration"""
|
||||
|
||||
token: str
|
||||
chat_id: int
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class NotificationFilters(BaseModel):
|
||||
"""Notification filters configuration"""
|
||||
|
||||
case_insensitive: List[str] = []
|
||||
case_sensitive: Optional[List[str]] = None
|
||||
|
||||
|
||||
class NotificationSettings(BaseModel):
|
||||
"""Complete notification settings"""
|
||||
|
||||
discord: Optional[DiscordConfig] = None
|
||||
telegram: Optional[TelegramConfig] = None
|
||||
filters: NotificationFilters = NotificationFilters()
|
||||
|
||||
|
||||
class NotificationTest(BaseModel):
|
||||
"""Test notification request"""
|
||||
|
||||
service: str # "discord" or "telegram"
|
||||
message: str = "Test notification from Leggen"
|
||||
|
||||
|
||||
class NotificationHistory(BaseModel):
|
||||
"""Notification history entry"""
|
||||
|
||||
id: str
|
||||
service: str
|
||||
message: str
|
||||
status: str # "sent", "failed"
|
||||
sent_at: str
|
||||
error: Optional[str] = None
|
||||
55
leggen/api/models/sync.py
Normal file
55
leggen/api/models/sync.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SyncRequest(BaseModel):
|
||||
"""Request to trigger a sync"""
|
||||
|
||||
account_ids: Optional[list[str]] = None # If None, sync all accounts
|
||||
force: bool = False # Force sync even if recently synced
|
||||
|
||||
|
||||
class SyncStatus(BaseModel):
|
||||
"""Sync operation status"""
|
||||
|
||||
is_running: bool
|
||||
last_sync: Optional[datetime] = None
|
||||
next_sync: Optional[datetime] = None
|
||||
accounts_synced: int = 0
|
||||
total_accounts: int = 0
|
||||
transactions_added: int = 0
|
||||
errors: list[str] = []
|
||||
|
||||
class Config:
|
||||
json_encoders = {datetime: lambda v: v.isoformat() if v else None}
|
||||
|
||||
|
||||
class SyncResult(BaseModel):
|
||||
"""Result of a sync operation"""
|
||||
|
||||
success: bool
|
||||
accounts_processed: int
|
||||
transactions_added: int
|
||||
transactions_updated: int
|
||||
balances_updated: int
|
||||
duration_seconds: float
|
||||
errors: list[str] = []
|
||||
started_at: datetime
|
||||
completed_at: datetime
|
||||
|
||||
class Config:
|
||||
json_encoders = {datetime: lambda v: v.isoformat()}
|
||||
|
||||
|
||||
class SchedulerConfig(BaseModel):
|
||||
"""Scheduler configuration model"""
|
||||
|
||||
enabled: bool = True
|
||||
hour: Optional[int] = 3
|
||||
minute: Optional[int] = 0
|
||||
cron: Optional[str] = None # Custom cron expression
|
||||
|
||||
class Config:
|
||||
extra = "forbid"
|
||||
357
leggen/api/routes/accounts.py
Normal file
357
leggen/api/routes/accounts.py
Normal file
@@ -0,0 +1,357 @@
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from loguru import logger
|
||||
|
||||
from leggen.api.models.accounts import (
|
||||
AccountBalance,
|
||||
AccountDetails,
|
||||
AccountUpdate,
|
||||
Transaction,
|
||||
TransactionSummary,
|
||||
)
|
||||
from leggen.api.models.common import APIResponse
|
||||
from leggen.services.database_service import DatabaseService
|
||||
|
||||
router = APIRouter()
|
||||
database_service = DatabaseService()
|
||||
|
||||
|
||||
@router.get("/accounts", response_model=APIResponse)
|
||||
async def get_all_accounts() -> APIResponse:
|
||||
"""Get all connected accounts from database"""
|
||||
try:
|
||||
accounts = []
|
||||
|
||||
# Get all account details from database
|
||||
db_accounts = await database_service.get_accounts_from_db()
|
||||
|
||||
# Process accounts found in database
|
||||
for db_account in db_accounts:
|
||||
try:
|
||||
# Get latest balances from database for this account
|
||||
balances_data = await database_service.get_balances_from_db(
|
||||
db_account["id"]
|
||||
)
|
||||
|
||||
# Process balances
|
||||
balances = []
|
||||
for balance in balances_data:
|
||||
balances.append(
|
||||
AccountBalance(
|
||||
amount=balance["amount"],
|
||||
currency=balance["currency"],
|
||||
balance_type=balance["type"],
|
||||
last_change_date=balance.get("timestamp"),
|
||||
)
|
||||
)
|
||||
|
||||
accounts.append(
|
||||
AccountDetails(
|
||||
id=db_account["id"],
|
||||
institution_id=db_account["institution_id"],
|
||||
status=db_account["status"],
|
||||
iban=db_account.get("iban"),
|
||||
name=db_account.get("name"),
|
||||
currency=db_account.get("currency"),
|
||||
created=db_account["created"],
|
||||
last_accessed=db_account.get("last_accessed"),
|
||||
balances=balances,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to process database account {db_account['id']}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=accounts,
|
||||
message=f"Retrieved {len(accounts)} accounts from database",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get accounts: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to get accounts: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/accounts/{account_id}", response_model=APIResponse)
|
||||
async def get_account_details(account_id: str) -> APIResponse:
|
||||
"""Get details for a specific account from database"""
|
||||
try:
|
||||
# Get account details from database
|
||||
db_account = await database_service.get_account_details_from_db(account_id)
|
||||
|
||||
if not db_account:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Account {account_id} not found in database"
|
||||
)
|
||||
|
||||
# Get latest balances from database for this account
|
||||
balances_data = await database_service.get_balances_from_db(account_id)
|
||||
|
||||
# Process balances
|
||||
balances = []
|
||||
for balance in balances_data:
|
||||
balances.append(
|
||||
AccountBalance(
|
||||
amount=balance["amount"],
|
||||
currency=balance["currency"],
|
||||
balance_type=balance["type"],
|
||||
last_change_date=balance.get("timestamp"),
|
||||
)
|
||||
)
|
||||
|
||||
account = AccountDetails(
|
||||
id=db_account["id"],
|
||||
institution_id=db_account["institution_id"],
|
||||
status=db_account["status"],
|
||||
iban=db_account.get("iban"),
|
||||
name=db_account.get("name"),
|
||||
currency=db_account.get("currency"),
|
||||
created=db_account["created"],
|
||||
last_accessed=db_account.get("last_accessed"),
|
||||
balances=balances,
|
||||
)
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=account,
|
||||
message=f"Account details retrieved from database for {account_id}",
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get account details for {account_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to get account details: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/accounts/{account_id}/balances", response_model=APIResponse)
|
||||
async def get_account_balances(account_id: str) -> APIResponse:
|
||||
"""Get balances for a specific account from database"""
|
||||
try:
|
||||
# Get balances from database instead of GoCardless API
|
||||
db_balances = await database_service.get_balances_from_db(account_id=account_id)
|
||||
|
||||
balances = []
|
||||
for balance in db_balances:
|
||||
balances.append(
|
||||
AccountBalance(
|
||||
amount=balance["amount"],
|
||||
currency=balance["currency"],
|
||||
balance_type=balance["type"],
|
||||
last_change_date=balance.get("timestamp"),
|
||||
)
|
||||
)
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=balances,
|
||||
message=f"Retrieved {len(balances)} balances for account {account_id}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to get balances from database for account {account_id}: {e}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Failed to get balances: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/balances", response_model=APIResponse)
|
||||
async def get_all_balances() -> APIResponse:
|
||||
"""Get all balances from all accounts in database"""
|
||||
try:
|
||||
# Get all accounts first to iterate through them
|
||||
db_accounts = await database_service.get_accounts_from_db()
|
||||
|
||||
all_balances = []
|
||||
for db_account in db_accounts:
|
||||
try:
|
||||
# Get balances for this account
|
||||
db_balances = await database_service.get_balances_from_db(
|
||||
account_id=db_account["id"]
|
||||
)
|
||||
|
||||
# Process balances and add account info
|
||||
for balance in db_balances:
|
||||
balance_data = {
|
||||
"id": f"{db_account['id']}_{balance['type']}", # Create unique ID
|
||||
"account_id": db_account["id"],
|
||||
"balance_amount": balance["amount"],
|
||||
"balance_type": balance["type"],
|
||||
"currency": balance["currency"],
|
||||
"reference_date": balance.get(
|
||||
"timestamp", db_account.get("last_accessed")
|
||||
),
|
||||
"created_at": db_account.get("created"),
|
||||
"updated_at": db_account.get("last_accessed"),
|
||||
}
|
||||
all_balances.append(balance_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to get balances for account {db_account['id']}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=all_balances,
|
||||
message=f"Retrieved {len(all_balances)} balances from {len(db_accounts)} accounts",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get all balances: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to get balances: {str(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)
|
||||
async def get_account_transactions(
|
||||
account_id: str,
|
||||
limit: Optional[int] = Query(default=100, le=500),
|
||||
offset: Optional[int] = Query(default=0, ge=0),
|
||||
summary_only: bool = Query(
|
||||
default=False, description="Return transaction summaries only"
|
||||
),
|
||||
) -> APIResponse:
|
||||
"""Get transactions for a specific account from database"""
|
||||
try:
|
||||
# Get transactions from database instead of GoCardless API
|
||||
db_transactions = await database_service.get_transactions_from_db(
|
||||
account_id=account_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
# Get total count for pagination info
|
||||
total_transactions = await database_service.get_transaction_count_from_db(
|
||||
account_id=account_id,
|
||||
)
|
||||
|
||||
data: Union[List[TransactionSummary], List[Transaction]]
|
||||
|
||||
if summary_only:
|
||||
# Return simplified transaction summaries
|
||||
data = [
|
||||
TransactionSummary(
|
||||
transaction_id=txn["transactionId"], # NEW: stable bank-provided ID
|
||||
internal_transaction_id=txn.get("internalTransactionId"),
|
||||
date=txn["transactionDate"],
|
||||
description=txn["description"],
|
||||
amount=txn["transactionValue"],
|
||||
currency=txn["transactionCurrency"],
|
||||
status=txn["transactionStatus"],
|
||||
account_id=txn["accountId"],
|
||||
)
|
||||
for txn in db_transactions
|
||||
]
|
||||
else:
|
||||
# Return full transaction details
|
||||
data = [
|
||||
Transaction(
|
||||
transaction_id=txn["transactionId"], # NEW: stable bank-provided ID
|
||||
internal_transaction_id=txn.get("internalTransactionId"),
|
||||
institution_id=txn["institutionId"],
|
||||
iban=txn["iban"],
|
||||
account_id=txn["accountId"],
|
||||
transaction_date=txn["transactionDate"],
|
||||
description=txn["description"],
|
||||
transaction_value=txn["transactionValue"],
|
||||
transaction_currency=txn["transactionCurrency"],
|
||||
transaction_status=txn["transactionStatus"],
|
||||
raw_transaction=txn["rawTransaction"],
|
||||
)
|
||||
for txn in db_transactions
|
||||
]
|
||||
|
||||
actual_offset = offset or 0
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=data,
|
||||
message=f"Retrieved {len(data)} transactions (showing {actual_offset + 1}-{actual_offset + len(data)} of {total_transactions})",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to get transactions from database for account {account_id}: {e}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Failed to get transactions: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.put("/accounts/{account_id}", response_model=APIResponse)
|
||||
async def update_account_details(
|
||||
account_id: str, update_data: AccountUpdate
|
||||
) -> APIResponse:
|
||||
"""Update account details (currently only name)"""
|
||||
try:
|
||||
# Get current account details
|
||||
current_account = await database_service.get_account_details_from_db(account_id)
|
||||
|
||||
if not current_account:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Account {account_id} not found"
|
||||
)
|
||||
|
||||
# Prepare updated account data
|
||||
updated_account_data = current_account.copy()
|
||||
if update_data.name is not None:
|
||||
updated_account_data["name"] = update_data.name
|
||||
|
||||
# Persist updated account details
|
||||
await database_service.persist_account_details(updated_account_data)
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data={"id": account_id, "name": update_data.name},
|
||||
message=f"Account {account_id} name updated successfully",
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update account {account_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to update account: {str(e)}"
|
||||
) from e
|
||||
179
leggen/api/routes/banks.py
Normal file
179
leggen/api/routes/banks.py
Normal file
@@ -0,0 +1,179 @@
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from loguru import logger
|
||||
|
||||
from leggen.api.models.banks import (
|
||||
BankConnectionRequest,
|
||||
BankConnectionStatus,
|
||||
BankInstitution,
|
||||
BankRequisition,
|
||||
)
|
||||
from leggen.api.models.common import APIResponse
|
||||
from leggen.services.gocardless_service import GoCardlessService
|
||||
from leggen.utils.gocardless import REQUISITION_STATUS
|
||||
|
||||
router = APIRouter()
|
||||
gocardless_service = GoCardlessService()
|
||||
|
||||
|
||||
@router.get("/banks/institutions", response_model=APIResponse)
|
||||
async def get_bank_institutions(
|
||||
country: str = Query(default="PT", description="Country code (e.g., PT, ES, FR)"),
|
||||
) -> APIResponse:
|
||||
"""Get available bank institutions for a country"""
|
||||
try:
|
||||
institutions_data = await gocardless_service.get_institutions(country)
|
||||
|
||||
institutions = [
|
||||
BankInstitution(
|
||||
id=inst["id"],
|
||||
name=inst["name"],
|
||||
bic=inst.get("bic"),
|
||||
transaction_total_days=inst["transaction_total_days"],
|
||||
countries=inst["countries"],
|
||||
logo=inst.get("logo"),
|
||||
)
|
||||
for inst in institutions_data
|
||||
]
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=institutions,
|
||||
message=f"Found {len(institutions)} institutions for {country}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get institutions for {country}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to get institutions: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/banks/connect", response_model=APIResponse)
|
||||
async def connect_to_bank(request: BankConnectionRequest) -> APIResponse:
|
||||
"""Create a connection to a bank (requisition)"""
|
||||
try:
|
||||
redirect_url = request.redirect_url or "http://localhost:8000/"
|
||||
requisition_data = await gocardless_service.create_requisition(
|
||||
request.institution_id, redirect_url
|
||||
)
|
||||
|
||||
requisition = BankRequisition(
|
||||
id=requisition_data["id"],
|
||||
institution_id=requisition_data["institution_id"],
|
||||
status=requisition_data["status"],
|
||||
created=requisition_data["created"],
|
||||
link=requisition_data["link"],
|
||||
accounts=requisition_data.get("accounts", []),
|
||||
)
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=requisition,
|
||||
message="Bank connection created. Please visit the link to authorize.",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to bank {request.institution_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to connect to bank: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/banks/status", response_model=APIResponse)
|
||||
async def get_bank_connections_status() -> APIResponse:
|
||||
"""Get status of all bank connections"""
|
||||
try:
|
||||
requisitions_data = await gocardless_service.get_requisitions()
|
||||
|
||||
connections = []
|
||||
for req in requisitions_data.get("results", []):
|
||||
status = req["status"]
|
||||
status_display = REQUISITION_STATUS.get(status, "UNKNOWN")
|
||||
|
||||
connections.append(
|
||||
BankConnectionStatus(
|
||||
bank_id=req["institution_id"],
|
||||
bank_name=req[
|
||||
"institution_id"
|
||||
], # Could be enhanced with actual bank names
|
||||
status=status,
|
||||
status_display=status_display,
|
||||
created_at=req["created"],
|
||||
requisition_id=req["id"],
|
||||
accounts_count=len(req.get("accounts", [])),
|
||||
)
|
||||
)
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=connections,
|
||||
message=f"Found {len(connections)} bank connections",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get bank connection status: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to get bank status: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.delete("/banks/connections/{requisition_id}", response_model=APIResponse)
|
||||
async def delete_bank_connection(requisition_id: str) -> APIResponse:
|
||||
"""Delete a bank connection"""
|
||||
try:
|
||||
# This would need to be implemented in GoCardlessService
|
||||
# For now, return success
|
||||
return APIResponse(
|
||||
success=True,
|
||||
message=f"Bank connection {requisition_id} deleted successfully",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete bank connection {requisition_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to delete connection: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/banks/countries", response_model=APIResponse)
|
||||
async def get_supported_countries() -> APIResponse:
|
||||
"""Get list of supported countries"""
|
||||
countries = [
|
||||
{"code": "AT", "name": "Austria"},
|
||||
{"code": "BE", "name": "Belgium"},
|
||||
{"code": "BG", "name": "Bulgaria"},
|
||||
{"code": "HR", "name": "Croatia"},
|
||||
{"code": "CY", "name": "Cyprus"},
|
||||
{"code": "CZ", "name": "Czech Republic"},
|
||||
{"code": "DK", "name": "Denmark"},
|
||||
{"code": "EE", "name": "Estonia"},
|
||||
{"code": "FI", "name": "Finland"},
|
||||
{"code": "FR", "name": "France"},
|
||||
{"code": "DE", "name": "Germany"},
|
||||
{"code": "GR", "name": "Greece"},
|
||||
{"code": "HU", "name": "Hungary"},
|
||||
{"code": "IS", "name": "Iceland"},
|
||||
{"code": "IE", "name": "Ireland"},
|
||||
{"code": "IT", "name": "Italy"},
|
||||
{"code": "LV", "name": "Latvia"},
|
||||
{"code": "LI", "name": "Liechtenstein"},
|
||||
{"code": "LT", "name": "Lithuania"},
|
||||
{"code": "LU", "name": "Luxembourg"},
|
||||
{"code": "MT", "name": "Malta"},
|
||||
{"code": "NL", "name": "Netherlands"},
|
||||
{"code": "NO", "name": "Norway"},
|
||||
{"code": "PL", "name": "Poland"},
|
||||
{"code": "PT", "name": "Portugal"},
|
||||
{"code": "RO", "name": "Romania"},
|
||||
{"code": "SK", "name": "Slovakia"},
|
||||
{"code": "SI", "name": "Slovenia"},
|
||||
{"code": "ES", "name": "Spain"},
|
||||
{"code": "SE", "name": "Sweden"},
|
||||
{"code": "GB", "name": "United Kingdom"},
|
||||
]
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=countries,
|
||||
message="Supported countries retrieved successfully",
|
||||
)
|
||||
204
leggen/api/routes/notifications.py
Normal file
204
leggen/api/routes/notifications.py
Normal file
@@ -0,0 +1,204 @@
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from leggen.api.models.common import APIResponse
|
||||
from leggen.api.models.notifications import (
|
||||
DiscordConfig,
|
||||
NotificationFilters,
|
||||
NotificationSettings,
|
||||
NotificationTest,
|
||||
TelegramConfig,
|
||||
)
|
||||
from leggen.services.notification_service import NotificationService
|
||||
from leggen.utils.config import config
|
||||
|
||||
router = APIRouter()
|
||||
notification_service = NotificationService()
|
||||
|
||||
|
||||
@router.get("/notifications/settings", response_model=APIResponse)
|
||||
async def get_notification_settings() -> APIResponse:
|
||||
"""Get current notification settings"""
|
||||
try:
|
||||
notifications_config = config.notifications_config
|
||||
filters_config = config.filters_config
|
||||
|
||||
# Build response safely without exposing secrets
|
||||
discord_config = notifications_config.get("discord", {})
|
||||
telegram_config = notifications_config.get("telegram", {})
|
||||
|
||||
settings = NotificationSettings(
|
||||
discord=DiscordConfig(
|
||||
webhook="***" if discord_config.get("webhook") else "",
|
||||
enabled=discord_config.get("enabled", True),
|
||||
)
|
||||
if discord_config.get("webhook")
|
||||
else None,
|
||||
telegram=TelegramConfig(
|
||||
token="***" if telegram_config.get("api-key") else "",
|
||||
chat_id=telegram_config.get("chat-id", 0),
|
||||
enabled=telegram_config.get("enabled", True),
|
||||
)
|
||||
if telegram_config.get("api-key")
|
||||
else None,
|
||||
filters=NotificationFilters(
|
||||
case_insensitive=filters_config.get("case-insensitive", []),
|
||||
case_sensitive=filters_config.get("case-sensitive"),
|
||||
),
|
||||
)
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=settings,
|
||||
message="Notification settings retrieved successfully",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get notification settings: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to get notification settings: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.put("/notifications/settings", response_model=APIResponse)
|
||||
async def update_notification_settings(settings: NotificationSettings) -> APIResponse:
|
||||
"""Update notification settings"""
|
||||
try:
|
||||
# Update notifications config
|
||||
notifications_config = {}
|
||||
|
||||
if settings.discord:
|
||||
notifications_config["discord"] = {
|
||||
"webhook": settings.discord.webhook,
|
||||
"enabled": settings.discord.enabled,
|
||||
}
|
||||
|
||||
if settings.telegram:
|
||||
notifications_config["telegram"] = {
|
||||
"api-key": settings.telegram.token,
|
||||
"chat-id": settings.telegram.chat_id,
|
||||
"enabled": settings.telegram.enabled,
|
||||
}
|
||||
|
||||
# Update filters config
|
||||
filters_config: Dict[str, Any] = {}
|
||||
if settings.filters.case_insensitive:
|
||||
filters_config["case-insensitive"] = settings.filters.case_insensitive
|
||||
if settings.filters.case_sensitive:
|
||||
filters_config["case-sensitive"] = settings.filters.case_sensitive
|
||||
|
||||
# Save to config
|
||||
if notifications_config:
|
||||
config.update_section("notifications", notifications_config)
|
||||
if filters_config:
|
||||
config.update_section("filters", filters_config)
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data={"updated": True},
|
||||
message="Notification settings updated successfully",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update notification settings: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to update notification settings: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/notifications/test", response_model=APIResponse)
|
||||
async def test_notification(test_request: NotificationTest) -> APIResponse:
|
||||
"""Send a test notification"""
|
||||
try:
|
||||
success = await notification_service.send_test_notification(
|
||||
test_request.service, test_request.message
|
||||
)
|
||||
|
||||
if success:
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data={"sent": True},
|
||||
message=f"Test notification sent to {test_request.service} successfully",
|
||||
)
|
||||
else:
|
||||
return APIResponse(
|
||||
success=False,
|
||||
message=f"Failed to send test notification to {test_request.service}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send test notification: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to send test notification: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/notifications/services", response_model=APIResponse)
|
||||
async def get_notification_services() -> APIResponse:
|
||||
"""Get available notification services and their status"""
|
||||
try:
|
||||
notifications_config = config.notifications_config
|
||||
|
||||
services = {
|
||||
"discord": {
|
||||
"name": "Discord",
|
||||
"enabled": bool(notifications_config.get("discord", {}).get("webhook")),
|
||||
"configured": bool(
|
||||
notifications_config.get("discord", {}).get("webhook")
|
||||
),
|
||||
"active": notifications_config.get("discord", {}).get("enabled", True),
|
||||
},
|
||||
"telegram": {
|
||||
"name": "Telegram",
|
||||
"enabled": bool(
|
||||
notifications_config.get("telegram", {}).get("api-key")
|
||||
and notifications_config.get("telegram", {}).get("chat-id")
|
||||
),
|
||||
"configured": bool(
|
||||
notifications_config.get("telegram", {}).get("api-key")
|
||||
and notifications_config.get("telegram", {}).get("chat-id")
|
||||
),
|
||||
"active": notifications_config.get("telegram", {}).get("enabled", True),
|
||||
},
|
||||
}
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=services,
|
||||
message="Notification services status retrieved successfully",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get notification services: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to get notification services: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.delete("/notifications/settings/{service}", response_model=APIResponse)
|
||||
async def delete_notification_service(service: str) -> APIResponse:
|
||||
"""Delete/disable a notification service"""
|
||||
try:
|
||||
if service not in ["discord", "telegram"]:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Service must be 'discord' or 'telegram'"
|
||||
)
|
||||
|
||||
notifications_config = config.notifications_config.copy()
|
||||
if service in notifications_config:
|
||||
del notifications_config[service]
|
||||
config.update_section("notifications", notifications_config)
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data={"deleted": service},
|
||||
message=f"{service.capitalize()} notification service deleted successfully",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete notification service {service}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to delete notification service: {str(e)}"
|
||||
) from e
|
||||
213
leggen/api/routes/sync.py
Normal file
213
leggen/api/routes/sync.py
Normal file
@@ -0,0 +1,213 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from leggen.api.models.common import APIResponse
|
||||
from leggen.api.models.sync import SchedulerConfig, SyncRequest
|
||||
from leggen.background.scheduler import scheduler
|
||||
from leggen.services.sync_service import SyncService
|
||||
from leggen.utils.config import config
|
||||
|
||||
router = APIRouter()
|
||||
sync_service = SyncService()
|
||||
|
||||
|
||||
@router.get("/sync/status", response_model=APIResponse)
|
||||
async def get_sync_status() -> APIResponse:
|
||||
"""Get current sync status"""
|
||||
try:
|
||||
status = await sync_service.get_sync_status()
|
||||
|
||||
# Add scheduler information
|
||||
next_sync_time = scheduler.get_next_sync_time()
|
||||
if next_sync_time:
|
||||
status.next_sync = next_sync_time
|
||||
|
||||
return APIResponse(
|
||||
success=True, data=status, message="Sync status retrieved successfully"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get sync status: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to get sync status: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/sync", response_model=APIResponse)
|
||||
async def trigger_sync(
|
||||
background_tasks: BackgroundTasks, sync_request: Optional[SyncRequest] = None
|
||||
) -> APIResponse:
|
||||
"""Trigger a manual sync operation"""
|
||||
try:
|
||||
# Check if sync is already running
|
||||
status = await sync_service.get_sync_status()
|
||||
if status.is_running and not (sync_request and sync_request.force):
|
||||
return APIResponse(
|
||||
success=False,
|
||||
message="Sync is already running. Use 'force: true' to override.",
|
||||
)
|
||||
|
||||
# Determine what to sync
|
||||
if sync_request and sync_request.account_ids:
|
||||
# Sync specific accounts in background
|
||||
background_tasks.add_task(
|
||||
sync_service.sync_specific_accounts,
|
||||
sync_request.account_ids,
|
||||
sync_request.force if sync_request else False,
|
||||
)
|
||||
message = (
|
||||
f"Started sync for {len(sync_request.account_ids)} specific accounts"
|
||||
)
|
||||
else:
|
||||
# Sync all accounts in background
|
||||
background_tasks.add_task(
|
||||
sync_service.sync_all_accounts,
|
||||
sync_request.force if sync_request else False,
|
||||
)
|
||||
message = "Started sync for all accounts"
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data={
|
||||
"sync_started": True,
|
||||
"force": sync_request.force if sync_request else False,
|
||||
},
|
||||
message=message,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to trigger sync: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to trigger sync: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/sync/now", response_model=APIResponse)
|
||||
async def sync_now(sync_request: Optional[SyncRequest] = None) -> APIResponse:
|
||||
"""Run sync synchronously and return results (slower, for testing)"""
|
||||
try:
|
||||
if sync_request and sync_request.account_ids:
|
||||
result = await sync_service.sync_specific_accounts(
|
||||
sync_request.account_ids, sync_request.force
|
||||
)
|
||||
else:
|
||||
result = await sync_service.sync_all_accounts(
|
||||
sync_request.force if sync_request else False
|
||||
)
|
||||
|
||||
return APIResponse(
|
||||
success=result.success,
|
||||
data=result,
|
||||
message="Sync completed"
|
||||
if result.success
|
||||
else f"Sync failed with {len(result.errors)} errors",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to run sync: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to run sync: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/sync/scheduler", response_model=APIResponse)
|
||||
async def get_scheduler_config() -> APIResponse:
|
||||
"""Get current scheduler configuration"""
|
||||
try:
|
||||
scheduler_config = config.scheduler_config
|
||||
next_sync_time = scheduler.get_next_sync_time()
|
||||
|
||||
response_data = {
|
||||
**scheduler_config,
|
||||
"next_scheduled_sync": next_sync_time.isoformat()
|
||||
if next_sync_time
|
||||
else None,
|
||||
"is_running": scheduler.scheduler.running
|
||||
if hasattr(scheduler, "scheduler")
|
||||
else False,
|
||||
}
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=response_data,
|
||||
message="Scheduler configuration retrieved successfully",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get scheduler config: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to get scheduler config: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.put("/sync/scheduler", response_model=APIResponse)
|
||||
async def update_scheduler_config(scheduler_config: SchedulerConfig) -> APIResponse:
|
||||
"""Update scheduler configuration"""
|
||||
try:
|
||||
# Validate cron expression if provided
|
||||
if scheduler_config.cron:
|
||||
try:
|
||||
cron_parts = scheduler_config.cron.split()
|
||||
if len(cron_parts) != 5:
|
||||
raise ValueError(
|
||||
"Cron expression must have 5 parts: minute hour day month day_of_week"
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Invalid cron expression: {str(e)}"
|
||||
) from e
|
||||
|
||||
# Update configuration
|
||||
schedule_data = scheduler_config.dict(exclude_none=True)
|
||||
config.update_section("scheduler", {"sync": schedule_data})
|
||||
|
||||
# Reschedule the job
|
||||
scheduler.reschedule_sync(schedule_data)
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=schedule_data,
|
||||
message="Scheduler configuration updated successfully",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update scheduler config: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to update scheduler config: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/sync/scheduler/start", response_model=APIResponse)
|
||||
async def start_scheduler() -> APIResponse:
|
||||
"""Start the background scheduler"""
|
||||
try:
|
||||
if not scheduler.scheduler.running:
|
||||
scheduler.start()
|
||||
return APIResponse(success=True, message="Scheduler started successfully")
|
||||
else:
|
||||
return APIResponse(success=True, message="Scheduler is already running")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start scheduler: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to start scheduler: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/sync/scheduler/stop", response_model=APIResponse)
|
||||
async def stop_scheduler() -> APIResponse:
|
||||
"""Stop the background scheduler"""
|
||||
try:
|
||||
if scheduler.scheduler.running:
|
||||
scheduler.shutdown()
|
||||
return APIResponse(success=True, message="Scheduler stopped successfully")
|
||||
else:
|
||||
return APIResponse(success=True, message="Scheduler is already stopped")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop scheduler: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to stop scheduler: {str(e)}"
|
||||
) from e
|
||||
290
leggen/api/routes/transactions.py
Normal file
290
leggen/api/routes/transactions.py
Normal file
@@ -0,0 +1,290 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from loguru import logger
|
||||
|
||||
from leggen.api.models.accounts import Transaction, TransactionSummary
|
||||
from leggen.api.models.common import APIResponse, PaginatedResponse
|
||||
from leggen.services.database_service import DatabaseService
|
||||
|
||||
router = APIRouter()
|
||||
database_service = DatabaseService()
|
||||
|
||||
|
||||
@router.get("/transactions", response_model=PaginatedResponse)
|
||||
async def get_all_transactions(
|
||||
page: int = Query(default=1, ge=1, description="Page number (1-based)"),
|
||||
per_page: int = Query(default=50, le=500, description="Items per page"),
|
||||
summary_only: bool = Query(
|
||||
default=True, description="Return transaction summaries only"
|
||||
),
|
||||
date_from: Optional[str] = Query(
|
||||
default=None, description="Filter from date (YYYY-MM-DD)"
|
||||
),
|
||||
date_to: Optional[str] = Query(
|
||||
default=None, description="Filter to date (YYYY-MM-DD)"
|
||||
),
|
||||
min_amount: Optional[float] = Query(
|
||||
default=None, description="Minimum transaction amount"
|
||||
),
|
||||
max_amount: Optional[float] = Query(
|
||||
default=None, description="Maximum transaction amount"
|
||||
),
|
||||
search: Optional[str] = Query(
|
||||
default=None, description="Search in transaction descriptions"
|
||||
),
|
||||
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
|
||||
) -> PaginatedResponse:
|
||||
"""Get all transactions from database with filtering options"""
|
||||
try:
|
||||
# Calculate offset from page and per_page
|
||||
offset = (page - 1) * per_page
|
||||
limit = per_page
|
||||
|
||||
# Get transactions from database instead of GoCardless API
|
||||
db_transactions = await database_service.get_transactions_from_db(
|
||||
account_id=account_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
min_amount=min_amount,
|
||||
max_amount=max_amount,
|
||||
search=search,
|
||||
)
|
||||
|
||||
# Get total count for pagination info (respecting the same filters)
|
||||
total_transactions = await database_service.get_transaction_count_from_db(
|
||||
account_id=account_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
min_amount=min_amount,
|
||||
max_amount=max_amount,
|
||||
search=search,
|
||||
)
|
||||
|
||||
data: Union[List[TransactionSummary], List[Transaction]]
|
||||
|
||||
if summary_only:
|
||||
# Return simplified transaction summaries
|
||||
data = [
|
||||
TransactionSummary(
|
||||
transaction_id=txn["transactionId"], # NEW: stable bank-provided ID
|
||||
internal_transaction_id=txn.get("internalTransactionId"),
|
||||
date=txn["transactionDate"],
|
||||
description=txn["description"],
|
||||
amount=txn["transactionValue"],
|
||||
currency=txn["transactionCurrency"],
|
||||
status=txn["transactionStatus"],
|
||||
account_id=txn["accountId"],
|
||||
)
|
||||
for txn in db_transactions
|
||||
]
|
||||
else:
|
||||
# Return full transaction details
|
||||
data = [
|
||||
Transaction(
|
||||
transaction_id=txn["transactionId"], # NEW: stable bank-provided ID
|
||||
internal_transaction_id=txn.get("internalTransactionId"),
|
||||
institution_id=txn["institutionId"],
|
||||
iban=txn["iban"],
|
||||
account_id=txn["accountId"],
|
||||
transaction_date=txn["transactionDate"],
|
||||
description=txn["description"],
|
||||
transaction_value=txn["transactionValue"],
|
||||
transaction_currency=txn["transactionCurrency"],
|
||||
transaction_status=txn["transactionStatus"],
|
||||
raw_transaction=txn["rawTransaction"],
|
||||
)
|
||||
for txn in db_transactions
|
||||
]
|
||||
|
||||
total_pages = (total_transactions + per_page - 1) // per_page
|
||||
|
||||
return PaginatedResponse(
|
||||
success=True,
|
||||
data=data,
|
||||
pagination={
|
||||
"total": total_transactions,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total_pages": total_pages,
|
||||
"has_next": page < total_pages,
|
||||
"has_prev": page > 1,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get transactions from database: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to get transactions: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/transactions/stats", response_model=APIResponse)
|
||||
async def get_transaction_stats(
|
||||
days: int = Query(default=30, description="Number of days to include in stats"),
|
||||
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
|
||||
) -> APIResponse:
|
||||
"""Get transaction statistics for the last N days from database"""
|
||||
try:
|
||||
# Date range for 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 transactions from database
|
||||
recent_transactions = await database_service.get_transactions_from_db(
|
||||
account_id=account_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=None, # Get all matching transactions for stats
|
||||
)
|
||||
|
||||
# Calculate stats
|
||||
total_transactions = len(recent_transactions)
|
||||
total_income = sum(
|
||||
txn["transactionValue"]
|
||||
for txn in recent_transactions
|
||||
if txn["transactionValue"] > 0
|
||||
)
|
||||
total_expenses = sum(
|
||||
abs(txn["transactionValue"])
|
||||
for txn in recent_transactions
|
||||
if txn["transactionValue"] < 0
|
||||
)
|
||||
net_change = total_income - total_expenses
|
||||
|
||||
# Count by status
|
||||
booked_count = len(
|
||||
[txn for txn in recent_transactions if txn["transactionStatus"] == "booked"]
|
||||
)
|
||||
pending_count = len(
|
||||
[
|
||||
txn
|
||||
for txn in recent_transactions
|
||||
if txn["transactionStatus"] == "pending"
|
||||
]
|
||||
)
|
||||
|
||||
# Count unique accounts
|
||||
unique_accounts = len({txn["accountId"] for txn in recent_transactions})
|
||||
|
||||
stats = {
|
||||
"period_days": days,
|
||||
"total_transactions": total_transactions,
|
||||
"booked_transactions": booked_count,
|
||||
"pending_transactions": pending_count,
|
||||
"total_income": round(total_income, 2),
|
||||
"total_expenses": round(total_expenses, 2),
|
||||
"net_change": round(net_change, 2),
|
||||
"average_transaction": round(
|
||||
sum(txn["transactionValue"] for txn in recent_transactions)
|
||||
/ total_transactions,
|
||||
2,
|
||||
)
|
||||
if total_transactions > 0
|
||||
else 0,
|
||||
"accounts_included": unique_accounts,
|
||||
}
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=stats,
|
||||
message=f"Transaction statistics for last {days} days",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get transaction stats from database: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to get transaction stats: {str(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
|
||||
189
leggen/api_client.py
Normal file
189
leggen/api_client.py
Normal file
@@ -0,0 +1,189 @@
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
|
||||
from leggen.utils.text import error
|
||||
|
||||
|
||||
class LeggenAPIClient:
|
||||
"""Client for communicating with the leggen FastAPI service"""
|
||||
|
||||
base_url: str
|
||||
|
||||
def __init__(self, base_url: Optional[str] = None):
|
||||
self.base_url = (
|
||||
base_url
|
||||
or os.environ.get("LEGGEN_API_URL", "http://localhost:8000")
|
||||
or "http://localhost:8000"
|
||||
)
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{"Content-Type": "application/json", "Accept": "application/json"}
|
||||
)
|
||||
|
||||
def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Make HTTP request to the API"""
|
||||
url = urljoin(self.base_url, endpoint)
|
||||
|
||||
try:
|
||||
response = self.session.request(method, url, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.ConnectionError:
|
||||
error("Could not connect to leggen server. Is it running?")
|
||||
error(f"Trying to connect to: {self.base_url}")
|
||||
raise
|
||||
except requests.exceptions.HTTPError as e:
|
||||
error(f"API request failed: {e}")
|
||||
if response.text:
|
||||
try:
|
||||
error_data = response.json()
|
||||
error(f"Error details: {error_data.get('detail', 'Unknown error')}")
|
||||
except Exception:
|
||||
error(f"Response: {response.text}")
|
||||
raise
|
||||
except Exception as e:
|
||||
error(f"Unexpected error: {e}")
|
||||
raise
|
||||
|
||||
def health_check(self) -> bool:
|
||||
"""Check if the leggen server is healthy"""
|
||||
try:
|
||||
response = self._make_request("GET", "/health")
|
||||
return response.get("status") == "healthy"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# Bank endpoints
|
||||
def get_institutions(self, country: str = "PT") -> List[Dict[str, Any]]:
|
||||
"""Get bank institutions for a country"""
|
||||
response = self._make_request(
|
||||
"GET", "/api/v1/banks/institutions", params={"country": country}
|
||||
)
|
||||
return response.get("data", [])
|
||||
|
||||
def connect_to_bank(
|
||||
self, institution_id: str, redirect_url: str = "http://localhost:8000/"
|
||||
) -> Dict[str, Any]:
|
||||
"""Connect to a bank"""
|
||||
response = self._make_request(
|
||||
"POST",
|
||||
"/api/v1/banks/connect",
|
||||
json={"institution_id": institution_id, "redirect_url": redirect_url},
|
||||
)
|
||||
return response.get("data", {})
|
||||
|
||||
def get_bank_status(self) -> List[Dict[str, Any]]:
|
||||
"""Get bank connection status"""
|
||||
response = self._make_request("GET", "/api/v1/banks/status")
|
||||
return response.get("data", [])
|
||||
|
||||
def get_supported_countries(self) -> List[Dict[str, Any]]:
|
||||
"""Get supported countries"""
|
||||
response = self._make_request("GET", "/api/v1/banks/countries")
|
||||
return response.get("data", [])
|
||||
|
||||
# Account endpoints
|
||||
def get_accounts(self) -> List[Dict[str, Any]]:
|
||||
"""Get all accounts"""
|
||||
response = self._make_request("GET", "/api/v1/accounts")
|
||||
return response.get("data", [])
|
||||
|
||||
def get_account_details(self, account_id: str) -> Dict[str, Any]:
|
||||
"""Get account details"""
|
||||
response = self._make_request("GET", f"/api/v1/accounts/{account_id}")
|
||||
return response.get("data", {})
|
||||
|
||||
def get_account_balances(self, account_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get account balances"""
|
||||
response = self._make_request("GET", f"/api/v1/accounts/{account_id}/balances")
|
||||
return response.get("data", [])
|
||||
|
||||
def get_account_transactions(
|
||||
self, account_id: str, limit: int = 100, summary_only: bool = False
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get account transactions"""
|
||||
response = self._make_request(
|
||||
"GET",
|
||||
f"/api/v1/accounts/{account_id}/transactions",
|
||||
params={"limit": limit, "summary_only": summary_only},
|
||||
)
|
||||
return response.get("data", [])
|
||||
|
||||
# Transaction endpoints
|
||||
def get_all_transactions(
|
||||
self, limit: int = 100, summary_only: bool = True, **filters
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get all transactions with optional filters"""
|
||||
params = {"limit": limit, "summary_only": summary_only}
|
||||
params.update(filters)
|
||||
|
||||
response = self._make_request("GET", "/api/v1/transactions", params=params)
|
||||
return response.get("data", [])
|
||||
|
||||
def get_transaction_stats(
|
||||
self, days: int = 30, account_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Get transaction statistics"""
|
||||
params: Dict[str, Union[int, str]] = {"days": days}
|
||||
if account_id:
|
||||
params["account_id"] = account_id
|
||||
|
||||
response = self._make_request(
|
||||
"GET", "/api/v1/transactions/stats", params=params
|
||||
)
|
||||
return response.get("data", {})
|
||||
|
||||
# Sync endpoints
|
||||
def get_sync_status(self) -> Dict[str, Any]:
|
||||
"""Get sync status"""
|
||||
response = self._make_request("GET", "/api/v1/sync/status")
|
||||
return response.get("data", {})
|
||||
|
||||
def trigger_sync(
|
||||
self, account_ids: Optional[List[str]] = None, force: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Trigger a sync"""
|
||||
data: Dict[str, Union[bool, List[str]]] = {"force": force}
|
||||
if account_ids:
|
||||
data["account_ids"] = account_ids
|
||||
|
||||
response = self._make_request("POST", "/api/v1/sync", json=data)
|
||||
return response.get("data", {})
|
||||
|
||||
def sync_now(
|
||||
self, account_ids: Optional[List[str]] = None, force: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Run sync synchronously"""
|
||||
data: Dict[str, Union[bool, List[str]]] = {"force": force}
|
||||
if account_ids:
|
||||
data["account_ids"] = account_ids
|
||||
|
||||
response = self._make_request("POST", "/api/v1/sync/now", json=data)
|
||||
return response.get("data", {})
|
||||
|
||||
def get_scheduler_config(self) -> Dict[str, Any]:
|
||||
"""Get scheduler configuration"""
|
||||
response = self._make_request("GET", "/api/v1/sync/scheduler")
|
||||
return response.get("data", {})
|
||||
|
||||
def update_scheduler_config(
|
||||
self,
|
||||
enabled: bool = True,
|
||||
hour: int = 3,
|
||||
minute: int = 0,
|
||||
cron: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Update scheduler configuration"""
|
||||
data: Dict[str, Union[bool, int, str]] = {
|
||||
"enabled": enabled,
|
||||
"hour": hour,
|
||||
"minute": minute,
|
||||
}
|
||||
if cron:
|
||||
data["cron"] = cron
|
||||
|
||||
response = self._make_request("PUT", "/api/v1/sync/scheduler", json=data)
|
||||
return response.get("data", {})
|
||||
168
leggen/background/scheduler.py
Normal file
168
leggen/background/scheduler.py
Normal file
@@ -0,0 +1,168 @@
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from loguru import logger
|
||||
|
||||
from leggen.services.notification_service import NotificationService
|
||||
from leggen.services.sync_service import SyncService
|
||||
from leggen.utils.config import config
|
||||
|
||||
|
||||
class BackgroundScheduler:
|
||||
def __init__(self):
|
||||
self.scheduler = AsyncIOScheduler()
|
||||
self.sync_service = SyncService()
|
||||
self.notification_service = NotificationService()
|
||||
self.max_retries = 3
|
||||
self.retry_delay = 300 # 5 minutes
|
||||
|
||||
def start(self):
|
||||
"""Start the scheduler and configure sync jobs based on configuration"""
|
||||
schedule_config = config.scheduler_config.get("sync", {})
|
||||
|
||||
if not schedule_config.get("enabled", True):
|
||||
logger.info("Sync scheduling is disabled in configuration")
|
||||
self.scheduler.start()
|
||||
return
|
||||
|
||||
# Parse schedule configuration
|
||||
trigger = self._parse_cron_config(schedule_config)
|
||||
if not trigger:
|
||||
return
|
||||
|
||||
self.scheduler.add_job(
|
||||
self._run_sync,
|
||||
trigger,
|
||||
id="daily_sync",
|
||||
name="Scheduled sync of all transactions",
|
||||
max_instances=1,
|
||||
)
|
||||
|
||||
self.scheduler.start()
|
||||
logger.info(f"Background scheduler started with sync job: {trigger}")
|
||||
|
||||
def shutdown(self):
|
||||
if self.scheduler.running:
|
||||
self.scheduler.shutdown()
|
||||
logger.info("Background scheduler shutdown")
|
||||
|
||||
def reschedule_sync(self, schedule_config: dict):
|
||||
"""Reschedule the sync job with new configuration"""
|
||||
if self.scheduler.running:
|
||||
try:
|
||||
self.scheduler.remove_job("daily_sync")
|
||||
logger.info("Removed existing sync job")
|
||||
except Exception:
|
||||
pass # Job might not exist
|
||||
|
||||
if not schedule_config.get("enabled", True):
|
||||
logger.info("Sync scheduling disabled")
|
||||
return
|
||||
|
||||
# Configure new schedule
|
||||
trigger = self._parse_cron_config(schedule_config)
|
||||
if not trigger:
|
||||
return
|
||||
|
||||
self.scheduler.add_job(
|
||||
self._run_sync,
|
||||
trigger,
|
||||
id="daily_sync",
|
||||
name="Scheduled sync of all transactions",
|
||||
max_instances=1,
|
||||
)
|
||||
logger.info(f"Rescheduled sync job with: {trigger}")
|
||||
|
||||
def _parse_cron_config(self, schedule_config: dict) -> CronTrigger:
|
||||
"""Parse cron configuration and return CronTrigger"""
|
||||
if schedule_config.get("cron"):
|
||||
# Parse custom cron expression (e.g., "0 3 * * *" for daily at 3 AM)
|
||||
try:
|
||||
cron_parts = schedule_config["cron"].split()
|
||||
if len(cron_parts) == 5:
|
||||
minute, hour, day, month, day_of_week = cron_parts
|
||||
return CronTrigger(
|
||||
minute=minute,
|
||||
hour=hour,
|
||||
day=day if day != "*" else None,
|
||||
month=month if month != "*" else None,
|
||||
day_of_week=day_of_week if day_of_week != "*" else None,
|
||||
)
|
||||
else:
|
||||
logger.error(f"Invalid cron expression: {schedule_config['cron']}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing cron expression: {e}")
|
||||
return None
|
||||
else:
|
||||
# Use hour/minute configuration (default: 3:00 AM daily)
|
||||
hour = schedule_config.get("hour", 3)
|
||||
minute = schedule_config.get("minute", 0)
|
||||
return CronTrigger(hour=hour, minute=minute)
|
||||
|
||||
async def _run_sync(self, retry_count: int = 0):
|
||||
"""Run sync with enhanced error handling and retry logic"""
|
||||
try:
|
||||
logger.info("Starting scheduled sync job")
|
||||
await self.sync_service.sync_all_accounts()
|
||||
logger.info("Scheduled sync job completed successfully")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Scheduled sync job failed (attempt {retry_count + 1}/{self.max_retries}): {e}"
|
||||
)
|
||||
|
||||
# Send notification about the failure
|
||||
try:
|
||||
await self.notification_service.send_expiry_notification(
|
||||
{
|
||||
"type": "sync_failure",
|
||||
"error": str(e),
|
||||
"retry_count": retry_count + 1,
|
||||
"max_retries": self.max_retries,
|
||||
}
|
||||
)
|
||||
except Exception as notification_error:
|
||||
logger.error(
|
||||
f"Failed to send failure notification: {notification_error}"
|
||||
)
|
||||
|
||||
# Implement retry logic for transient failures
|
||||
if retry_count < self.max_retries - 1:
|
||||
import datetime
|
||||
|
||||
logger.info(f"Retrying sync job in {self.retry_delay} seconds...")
|
||||
# Schedule a retry
|
||||
retry_time = datetime.datetime.now() + datetime.timedelta(
|
||||
seconds=self.retry_delay
|
||||
)
|
||||
self.scheduler.add_job(
|
||||
self._run_sync,
|
||||
"date",
|
||||
args=[retry_count + 1],
|
||||
id=f"sync_retry_{retry_count + 1}",
|
||||
run_date=retry_time,
|
||||
)
|
||||
else:
|
||||
logger.error("Maximum retries exceeded for sync job")
|
||||
# Send final failure notification
|
||||
try:
|
||||
await self.notification_service.send_expiry_notification(
|
||||
{
|
||||
"type": "sync_final_failure",
|
||||
"error": str(e),
|
||||
"retry_count": retry_count + 1,
|
||||
}
|
||||
)
|
||||
except Exception as notification_error:
|
||||
logger.error(
|
||||
f"Failed to send final failure notification: {notification_error}"
|
||||
)
|
||||
|
||||
def get_next_sync_time(self):
|
||||
"""Get the next scheduled sync time"""
|
||||
job = self.scheduler.get_job("daily_sync")
|
||||
if job:
|
||||
return job.next_run_time
|
||||
return None
|
||||
|
||||
|
||||
scheduler = BackgroundScheduler()
|
||||
@@ -1,7 +1,7 @@
|
||||
import click
|
||||
|
||||
from leggen.api_client import LeggenAPIClient
|
||||
from leggen.main import cli
|
||||
from leggen.utils.network import get
|
||||
from leggen.utils.text import datefmt, print_table
|
||||
|
||||
|
||||
@@ -11,36 +11,33 @@ def balances(ctx: click.Context):
|
||||
"""
|
||||
List balances of all connected accounts
|
||||
"""
|
||||
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||
|
||||
res = get(ctx, "/requisitions/")
|
||||
accounts = set()
|
||||
for r in res.get("results", []):
|
||||
accounts.update(r.get("accounts", []))
|
||||
# Check if leggen server is available
|
||||
if not api_client.health_check():
|
||||
click.echo(
|
||||
"Error: Cannot connect to leggen server. Please ensure it's running."
|
||||
)
|
||||
return
|
||||
|
||||
accounts = api_client.get_accounts()
|
||||
|
||||
all_balances = []
|
||||
for account in accounts:
|
||||
account_ballances = get(ctx, f"/accounts/{account}/balances/").get(
|
||||
"balances", []
|
||||
)
|
||||
for balance in account_ballances:
|
||||
balance_amount = balance["balanceAmount"]
|
||||
amount = round(float(balance_amount["amount"]), 2)
|
||||
symbol = (
|
||||
"€"
|
||||
if balance_amount["currency"] == "EUR"
|
||||
else f" {balance_amount['currency']}"
|
||||
)
|
||||
for balance in account.get("balances", []):
|
||||
amount = round(float(balance["amount"]), 2)
|
||||
symbol = "€" if balance["currency"] == "EUR" else f" {balance['currency']}"
|
||||
amount_str = f"{amount}{symbol}"
|
||||
date = (
|
||||
datefmt(balance.get("lastChangeDateTime"))
|
||||
if balance.get("lastChangeDateTime")
|
||||
datefmt(balance.get("last_change_date"))
|
||||
if balance.get("last_change_date")
|
||||
else ""
|
||||
)
|
||||
all_balances.append(
|
||||
{
|
||||
"Account": account,
|
||||
"Account": account["id"],
|
||||
"Amount": amount_str,
|
||||
"Type": balance["balanceType"],
|
||||
"Type": balance["balance_type"],
|
||||
"Last change at": date,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import os
|
||||
|
||||
import click
|
||||
|
||||
from leggen.main import cli
|
||||
|
||||
cmd_folder = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
|
||||
class BankGroup(click.Group):
|
||||
def list_commands(self, ctx):
|
||||
rv = []
|
||||
for filename in os.listdir(cmd_folder):
|
||||
if filename.endswith(".py") and not filename.startswith("__init__"):
|
||||
if filename == "list_banks.py":
|
||||
rv.append("list")
|
||||
else:
|
||||
rv.append(filename[:-3])
|
||||
rv.sort()
|
||||
return rv
|
||||
|
||||
def get_command(self, ctx, name):
|
||||
try:
|
||||
if name == "list":
|
||||
name = "list_banks"
|
||||
mod = __import__(f"leggen.commands.bank.{name}", None, None, [name])
|
||||
except ImportError:
|
||||
return
|
||||
return getattr(mod, name)
|
||||
|
||||
|
||||
@cli.group(cls=BankGroup)
|
||||
@click.pass_context
|
||||
def bank(ctx):
|
||||
"""Manage banks connections"""
|
||||
return
|
||||
@@ -1,9 +1,9 @@
|
||||
import click
|
||||
|
||||
from leggen.api_client import LeggenAPIClient
|
||||
from leggen.main import cli
|
||||
from leggen.utils.disk import save_file
|
||||
from leggen.utils.network import get, post
|
||||
from leggen.utils.text import info, print_table, warning
|
||||
from leggen.utils.text import info, print_table, success, warning
|
||||
|
||||
|
||||
@cli.command()
|
||||
@@ -12,48 +12,33 @@ def add(ctx):
|
||||
"""
|
||||
Connect to a bank
|
||||
"""
|
||||
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||
|
||||
# Check if leggen server is available
|
||||
if not api_client.health_check():
|
||||
click.echo(
|
||||
"Error: Cannot connect to leggen server. Please ensure it's running."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Get supported countries
|
||||
countries = api_client.get_supported_countries()
|
||||
country_codes = [c["code"] for c in countries]
|
||||
|
||||
country = click.prompt(
|
||||
"Bank Country",
|
||||
type=click.Choice(
|
||||
[
|
||||
"AT",
|
||||
"BE",
|
||||
"BG",
|
||||
"HR",
|
||||
"CY",
|
||||
"CZ",
|
||||
"DK",
|
||||
"EE",
|
||||
"FI",
|
||||
"FR",
|
||||
"DE",
|
||||
"GR",
|
||||
"HU",
|
||||
"IS",
|
||||
"IE",
|
||||
"IT",
|
||||
"LV",
|
||||
"LI",
|
||||
"LT",
|
||||
"LU",
|
||||
"MT",
|
||||
"NL",
|
||||
"NO",
|
||||
"PL",
|
||||
"PT",
|
||||
"RO",
|
||||
"SK",
|
||||
"SI",
|
||||
"ES",
|
||||
"SE",
|
||||
"GB",
|
||||
],
|
||||
case_sensitive=True,
|
||||
),
|
||||
type=click.Choice(country_codes, case_sensitive=True),
|
||||
default="PT",
|
||||
)
|
||||
|
||||
info(f"Getting bank list for country: {country}")
|
||||
banks = get(ctx, "/institutions/", {"country": country})
|
||||
banks = api_client.get_institutions(country)
|
||||
|
||||
if not banks:
|
||||
warning(f"No banks available for country {country}")
|
||||
return
|
||||
|
||||
filtered_banks = [
|
||||
{
|
||||
"id": bank["id"],
|
||||
@@ -63,18 +48,34 @@ def add(ctx):
|
||||
for bank in banks
|
||||
]
|
||||
print_table(filtered_banks)
|
||||
|
||||
allowed_ids = [str(bank["id"]) for bank in banks]
|
||||
bank_id = click.prompt("Bank ID", type=click.Choice(allowed_ids))
|
||||
|
||||
# Show bank details
|
||||
selected_bank = next(bank for bank in banks if bank["id"] == bank_id)
|
||||
info(f"Selected bank: {selected_bank['name']}")
|
||||
|
||||
click.confirm("Do you agree to connect to this bank?", abort=True)
|
||||
|
||||
info(f"Connecting to bank with ID: {bank_id}")
|
||||
|
||||
res = post(
|
||||
ctx,
|
||||
"/requisitions/",
|
||||
{"institution_id": bank_id, "redirect": "http://localhost:8000/"},
|
||||
# Connect to bank via API
|
||||
result = api_client.connect_to_bank(bank_id, "http://localhost:8000/")
|
||||
|
||||
# Save requisition details
|
||||
save_file(f"req_{result['id']}.json", result)
|
||||
|
||||
success("Bank connection request created successfully!")
|
||||
warning(
|
||||
"Please open the following URL in your browser to complete the authorization:"
|
||||
)
|
||||
click.echo(f"\n{result['link']}\n")
|
||||
|
||||
info(f"Requisition ID: {result['id']}")
|
||||
info(
|
||||
"After completing the authorization, you can check the connection status with 'leggen status'"
|
||||
)
|
||||
|
||||
save_file(f"req_{res['id']}.json", res)
|
||||
|
||||
warning(f"Please open the following URL in your browser to accept: {res['link']}")
|
||||
except Exception as e:
|
||||
click.echo(f"Error: Failed to connect to bank: {str(e)}")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import click
|
||||
|
||||
from leggen.main import cli
|
||||
from leggen.utils.network import delete as http_delete
|
||||
from leggen.utils.text import info, success
|
||||
|
||||
|
||||
@@ -16,11 +15,12 @@ def delete(ctx, requisition_id: str):
|
||||
|
||||
Check `leggen status` to get the REQUISITION_ID
|
||||
"""
|
||||
import requests
|
||||
|
||||
info(f"Deleting Bank Requisition: {requisition_id}")
|
||||
|
||||
_ = http_delete(
|
||||
ctx,
|
||||
f"/requisitions/{requisition_id}",
|
||||
)
|
||||
api_url = ctx.obj.get("api_url", "http://localhost:8000")
|
||||
res = requests.delete(f"{api_url}/requisitions/{requisition_id}")
|
||||
res.raise_for_status()
|
||||
|
||||
success(f"Bank Requisition {requisition_id} deleted")
|
||||
|
||||
68
leggen/commands/generate_sample_db.py
Normal file
68
leggen/commands/generate_sample_db.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Generate sample database command."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--database",
|
||||
type=click.Path(path_type=Path),
|
||||
help="Path to database file (default: uses LEGGEN_DATABASE_PATH or ~/.config/leggen/leggen-dev.db)",
|
||||
)
|
||||
@click.option(
|
||||
"--accounts",
|
||||
type=int,
|
||||
default=3,
|
||||
help="Number of sample accounts to generate (default: 3)",
|
||||
)
|
||||
@click.option(
|
||||
"--transactions",
|
||||
type=int,
|
||||
default=50,
|
||||
help="Number of transactions per account (default: 50)",
|
||||
)
|
||||
@click.option(
|
||||
"--force",
|
||||
is_flag=True,
|
||||
help="Overwrite existing database without confirmation",
|
||||
)
|
||||
@click.pass_context
|
||||
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."""
|
||||
|
||||
# Import here to avoid circular imports
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path as PathlibPath
|
||||
|
||||
# Get the script path
|
||||
script_path = (
|
||||
PathlibPath(__file__).parent.parent.parent / "scripts" / "generate_sample_db.py"
|
||||
)
|
||||
|
||||
# Build command arguments
|
||||
cmd = [sys.executable, str(script_path)]
|
||||
|
||||
if database:
|
||||
cmd.extend(["--database", str(database)])
|
||||
|
||||
cmd.extend(["--accounts", str(accounts)])
|
||||
cmd.extend(["--transactions", str(transactions)])
|
||||
|
||||
if force:
|
||||
cmd.append("--force")
|
||||
|
||||
# Execute the script
|
||||
try:
|
||||
subprocess.run(cmd, check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
click.echo(f"Error generating sample database: {e}")
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
# Export the command
|
||||
generate_sample_db = generate_sample_db
|
||||
178
leggen/commands/server.py
Normal file
178
leggen/commands/server.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from importlib import metadata
|
||||
|
||||
import click
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from loguru import logger
|
||||
|
||||
from leggen.api.routes import accounts, banks, notifications, sync, transactions
|
||||
from leggen.background.scheduler import scheduler
|
||||
from leggen.utils.config import config
|
||||
from leggen.utils.paths import path_manager
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
logger.info("Starting leggen server...")
|
||||
|
||||
# Load configuration
|
||||
try:
|
||||
config.load_config()
|
||||
logger.info("Configuration loaded successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load configuration: {e}")
|
||||
raise
|
||||
|
||||
# Run database migrations
|
||||
try:
|
||||
from leggen.services.database_service import DatabaseService
|
||||
|
||||
db_service = DatabaseService()
|
||||
await db_service.run_migrations_if_needed()
|
||||
logger.info("Database migrations completed")
|
||||
except Exception as e:
|
||||
logger.error(f"Database migration failed: {e}")
|
||||
raise
|
||||
|
||||
# Start background scheduler
|
||||
scheduler.start()
|
||||
logger.info("Background scheduler started")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("Shutting down leggen server...")
|
||||
scheduler.shutdown()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
# Get version dynamically from package metadata
|
||||
try:
|
||||
version = metadata.version("leggen")
|
||||
except metadata.PackageNotFoundError:
|
||||
version = "unknown"
|
||||
|
||||
app = FastAPI(
|
||||
title="Leggen API",
|
||||
description="Open Banking API for Leggen",
|
||||
version=version,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"http://frontend:80",
|
||||
], # Frontend container and dev servers
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include API routes
|
||||
app.include_router(banks.router, prefix="/api/v1", tags=["banks"])
|
||||
app.include_router(accounts.router, prefix="/api/v1", tags=["accounts"])
|
||||
app.include_router(transactions.router, prefix="/api/v1", tags=["transactions"])
|
||||
app.include_router(sync.router, prefix="/api/v1", tags=["sync"])
|
||||
app.include_router(notifications.router, prefix="/api/v1", tags=["notifications"])
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
# Get version dynamically
|
||||
try:
|
||||
version = metadata.version("leggen")
|
||||
except metadata.PackageNotFoundError:
|
||||
version = "unknown"
|
||||
return {"message": "Leggen API is running", "version": version}
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
async def health():
|
||||
"""Health check endpoint for API connectivity"""
|
||||
try:
|
||||
from leggen.api.models.common import APIResponse
|
||||
|
||||
config_loaded = config._config is not None
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data={
|
||||
"status": "healthy",
|
||||
"config_loaded": config_loaded,
|
||||
"message": "API is running and responsive",
|
||||
},
|
||||
message="Health check successful",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Health check failed: {e}")
|
||||
from leggen.api.models.common import APIResponse
|
||||
|
||||
return APIResponse(
|
||||
success=False,
|
||||
data={"status": "unhealthy", "error": str(e)},
|
||||
message="Health check failed",
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--reload",
|
||||
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"""
|
||||
|
||||
# Get config_dir and database from main CLI context
|
||||
config_dir = None
|
||||
database = None
|
||||
if ctx.parent:
|
||||
config_dir = ctx.parent.params.get("config_dir")
|
||||
database = ctx.parent.params.get("database")
|
||||
|
||||
# Set up path manager with user-provided paths
|
||||
if config_dir:
|
||||
path_manager.set_config_dir(config_dir)
|
||||
if database:
|
||||
path_manager.set_database_path(database)
|
||||
|
||||
if reload:
|
||||
# Use string import for reload to work properly
|
||||
uvicorn.run(
|
||||
"leggen.commands.server:create_app",
|
||||
factory=True,
|
||||
host=host,
|
||||
port=port,
|
||||
log_level="info",
|
||||
access_log=True,
|
||||
reload=True,
|
||||
reload_dirs=["leggen"], # Watch leggen directory
|
||||
)
|
||||
else:
|
||||
app = create_app()
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=host,
|
||||
port=port,
|
||||
log_level="info",
|
||||
access_log=True,
|
||||
)
|
||||
@@ -1,8 +1,7 @@
|
||||
import click
|
||||
|
||||
from leggen.api_client import LeggenAPIClient
|
||||
from leggen.main import cli
|
||||
from leggen.utils.gocardless import REQUISITION_STATUS
|
||||
from leggen.utils.network import get
|
||||
from leggen.utils.text import datefmt, echo, info, print_table
|
||||
|
||||
|
||||
@@ -12,36 +11,44 @@ def status(ctx: click.Context):
|
||||
"""
|
||||
List all connected banks and their status
|
||||
"""
|
||||
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||
|
||||
res = get(ctx, "/requisitions/")
|
||||
# Check if leggen server is available
|
||||
if not api_client.health_check():
|
||||
click.echo(
|
||||
"Error: Cannot connect to leggen server. Please ensure it's running."
|
||||
)
|
||||
return
|
||||
|
||||
# Get bank connection status
|
||||
bank_connections = api_client.get_bank_status()
|
||||
requisitions = []
|
||||
accounts = set()
|
||||
for r in res["results"]:
|
||||
for conn in bank_connections:
|
||||
requisitions.append(
|
||||
{
|
||||
"Bank": r["institution_id"],
|
||||
"Status": REQUISITION_STATUS.get(r["status"], "UNKNOWN"),
|
||||
"Created at": datefmt(r["created"]),
|
||||
"Requisition ID": r["id"],
|
||||
"Bank": conn["bank_id"],
|
||||
"Status": conn["status_display"],
|
||||
"Created at": datefmt(conn["created_at"]),
|
||||
"Requisition ID": conn["requisition_id"],
|
||||
}
|
||||
)
|
||||
accounts.update(r.get("accounts", []))
|
||||
info("Banks")
|
||||
print_table(requisitions)
|
||||
|
||||
# Get account details
|
||||
accounts = api_client.get_accounts()
|
||||
account_details = []
|
||||
for account in accounts:
|
||||
details = get(ctx, f"/accounts/{account}")
|
||||
account_details.append(
|
||||
{
|
||||
"ID": details["id"],
|
||||
"Bank": details["institution_id"],
|
||||
"Status": details["status"],
|
||||
"IBAN": details.get("iban", "N/A"),
|
||||
"Created at": datefmt(details["created"]),
|
||||
"ID": account["id"],
|
||||
"Bank": account["institution_id"],
|
||||
"Status": account["status"],
|
||||
"IBAN": account.get("iban", "N/A"),
|
||||
"Created at": datefmt(account["created"]),
|
||||
"Last accessed at": (
|
||||
datefmt(details["last_accessed"])
|
||||
if details.get("last_accessed")
|
||||
datefmt(account["last_accessed"])
|
||||
if account.get("last_accessed")
|
||||
else "N/A"
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,79 +1,61 @@
|
||||
import datetime
|
||||
|
||||
import click
|
||||
|
||||
from leggen.api_client import LeggenAPIClient
|
||||
from leggen.main import cli
|
||||
from leggen.utils.database import persist_balance, save_transactions
|
||||
from leggen.utils.gocardless import REQUISITION_STATUS
|
||||
from leggen.utils.network import get
|
||||
from leggen.utils.notifications import send_expire_notification, send_notification
|
||||
from leggen.utils.text import error, info
|
||||
from leggen.utils.text import error, info, success
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--wait", is_flag=True, help="Wait for sync to complete (synchronous)")
|
||||
@click.option("--force", is_flag=True, help="Force sync even if already running")
|
||||
@click.pass_context
|
||||
def sync(ctx: click.Context):
|
||||
def sync(ctx: click.Context, wait: bool, force: bool):
|
||||
"""
|
||||
Sync all transactions with database
|
||||
"""
|
||||
info("Getting accounts details")
|
||||
res = get(ctx, "/requisitions/")
|
||||
accounts = set()
|
||||
for r in res.get("results", []):
|
||||
accounts.update(r.get("accounts", []))
|
||||
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||
|
||||
for r in res.get("results", []):
|
||||
account_status = REQUISITION_STATUS.get(r["status"], "UNKNOWN")
|
||||
if account_status != "LINKED":
|
||||
created_at = datetime.datetime.fromisoformat(r["created"])
|
||||
now = datetime.datetime.now(tz=datetime.timezone.utc)
|
||||
days_left = 90 - (now - created_at).days
|
||||
if days_left <= 15:
|
||||
n = {
|
||||
"bank": r["institution_id"],
|
||||
"status": REQUISITION_STATUS.get(r["status"], "UNKNOWN"),
|
||||
"created_at": created_at.timestamp(),
|
||||
"requisition_id": r["id"],
|
||||
"days_left": days_left,
|
||||
}
|
||||
send_expire_notification(ctx, n)
|
||||
# Check if leggen server is available
|
||||
if not api_client.health_check():
|
||||
error("Cannot connect to leggen server. Please ensure it's running.")
|
||||
return
|
||||
|
||||
info(f"Syncing balances for {len(accounts)} accounts")
|
||||
|
||||
for account in accounts:
|
||||
try:
|
||||
account_details = get(ctx, f"/accounts/{account}")
|
||||
account_balances = get(ctx, f"/accounts/{account}/balances/").get(
|
||||
"balances", []
|
||||
if wait:
|
||||
# Run sync synchronously and wait for completion
|
||||
info("Starting synchronous sync...")
|
||||
result = api_client.sync_now(force=force)
|
||||
|
||||
if result.get("success"):
|
||||
success("Sync completed successfully!")
|
||||
info(f"Accounts processed: {result.get('accounts_processed', 0)}")
|
||||
info(f"Transactions added: {result.get('transactions_added', 0)}")
|
||||
info(f"Balances updated: {result.get('balances_updated', 0)}")
|
||||
if result.get("duration_seconds"):
|
||||
info(f"Duration: {result['duration_seconds']:.2f} seconds")
|
||||
|
||||
if result.get("errors"):
|
||||
error(f"Errors encountered: {len(result['errors'])}")
|
||||
for err in result["errors"]:
|
||||
error(f" - {err}")
|
||||
else:
|
||||
error("Sync failed")
|
||||
if result.get("errors"):
|
||||
for err in result["errors"]:
|
||||
error(f" - {err}")
|
||||
else:
|
||||
# Trigger async sync
|
||||
info("Starting background sync...")
|
||||
result = api_client.trigger_sync(force=force)
|
||||
|
||||
if result.get("sync_started"):
|
||||
success("Sync started successfully in the background")
|
||||
info(
|
||||
"Use 'leggen sync --wait' to run synchronously or check status with API"
|
||||
)
|
||||
for balance in account_balances:
|
||||
balance_amount = balance["balanceAmount"]
|
||||
amount = round(float(balance_amount["amount"]), 2)
|
||||
balance_document = {
|
||||
"account_id": account,
|
||||
"bank": account_details["institution_id"],
|
||||
"status": account_details["status"],
|
||||
"iban": account_details.get("iban", "N/A"),
|
||||
"amount": amount,
|
||||
"currency": balance_amount["currency"],
|
||||
"type": balance["balanceType"],
|
||||
"timestamp": datetime.datetime.now().timestamp(),
|
||||
}
|
||||
persist_balance(ctx, account, balance_document)
|
||||
except Exception as e:
|
||||
error(f"[{account}] Error: Sync failed, skipping account, exception: {e}")
|
||||
continue
|
||||
else:
|
||||
error("Failed to start sync")
|
||||
|
||||
info(f"Syncing transactions for {len(accounts)} accounts")
|
||||
|
||||
for account in accounts:
|
||||
try:
|
||||
new_transactions = save_transactions(ctx, account)
|
||||
except Exception as e:
|
||||
error(f"[{account}] Error: Sync failed, skipping account, exception: {e}")
|
||||
continue
|
||||
try:
|
||||
send_notification(ctx, new_transactions)
|
||||
except Exception as e:
|
||||
error(f"[{account}] Error: Notification failed, exception: {e}")
|
||||
continue
|
||||
error(f"Sync failed: {str(e)}")
|
||||
return
|
||||
|
||||
@@ -1,31 +1,18 @@
|
||||
import click
|
||||
|
||||
from leggen.api_client import LeggenAPIClient
|
||||
from leggen.main import cli
|
||||
from leggen.utils.network import get
|
||||
from leggen.utils.text import info, print_table
|
||||
|
||||
|
||||
def print_transactions(
|
||||
ctx: click.Context, account_info: dict, account_transactions: dict
|
||||
):
|
||||
info(f"Bank: {account_info['institution_id']}")
|
||||
info(f"IBAN: {account_info.get('iban', 'N/A')}")
|
||||
all_transactions = []
|
||||
for transaction in account_transactions.get("booked", []):
|
||||
transaction["TYPE"] = "booked"
|
||||
all_transactions.append(transaction)
|
||||
|
||||
for transaction in account_transactions.get("pending", []):
|
||||
transaction["TYPE"] = "pending"
|
||||
all_transactions.append(transaction)
|
||||
|
||||
print_table(all_transactions)
|
||||
from leggen.utils.text import datefmt, info, print_table
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("-a", "--account", type=str, help="Account ID")
|
||||
@click.option(
|
||||
"-l", "--limit", type=int, default=50, help="Number of transactions to show"
|
||||
)
|
||||
@click.option("--full", is_flag=True, help="Show full transaction details")
|
||||
@click.pass_context
|
||||
def transactions(ctx: click.Context, account: str):
|
||||
def transactions(ctx: click.Context, account: str, limit: int, full: bool):
|
||||
"""
|
||||
List transactions
|
||||
|
||||
@@ -33,20 +20,76 @@ def transactions(ctx: click.Context, account: str):
|
||||
|
||||
If the --account option is used, it will only list transactions for that account.
|
||||
"""
|
||||
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||
|
||||
# Check if leggen server is available
|
||||
if not api_client.health_check():
|
||||
click.echo(
|
||||
"Error: Cannot connect to leggen server. Please ensure it's running."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
if account:
|
||||
account_info = get(ctx, f"/accounts/{account}")
|
||||
account_transactions = get(ctx, f"/accounts/{account}/transactions/").get(
|
||||
"transactions", []
|
||||
# Get transactions for specific account
|
||||
account_details = api_client.get_account_details(account)
|
||||
transactions_data = api_client.get_account_transactions(
|
||||
account, limit=limit, summary_only=not full
|
||||
)
|
||||
print_transactions(ctx, account_info, account_transactions)
|
||||
|
||||
info(f"Bank: {account_details['institution_id']}")
|
||||
info(f"IBAN: {account_details.get('iban', 'N/A')}")
|
||||
|
||||
else:
|
||||
res = get(ctx, "/requisitions/")
|
||||
accounts = set()
|
||||
for r in res["results"]:
|
||||
accounts.update(r.get("accounts", []))
|
||||
for account in accounts:
|
||||
account_details = get(ctx, f"/accounts/{account}")
|
||||
account_transactions = get(ctx, f"/accounts/{account}/transactions/").get(
|
||||
"transactions", []
|
||||
# Get all transactions
|
||||
transactions_data = api_client.get_all_transactions(
|
||||
limit=limit, summary_only=not full, account_id=account
|
||||
)
|
||||
print_transactions(ctx, account_details, account_transactions)
|
||||
|
||||
# Format transactions for display
|
||||
if full:
|
||||
# Full transaction details
|
||||
formatted_transactions = []
|
||||
for txn in transactions_data:
|
||||
# Handle optional internal_transaction_id
|
||||
txn_id = txn.get("internal_transaction_id")
|
||||
txn_id_display = txn_id[:12] + "..." if txn_id else "N/A"
|
||||
|
||||
formatted_transactions.append(
|
||||
{
|
||||
"ID": txn_id_display,
|
||||
"Date": datefmt(txn["transaction_date"]),
|
||||
"Description": txn["description"][:50] + "..."
|
||||
if len(txn["description"]) > 50
|
||||
else txn["description"],
|
||||
"Amount": f"{txn['transaction_value']:.2f} {txn['transaction_currency']}",
|
||||
"Status": txn["transaction_status"].upper(),
|
||||
"Account": txn["account_id"][:8] + "...",
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Summary view
|
||||
formatted_transactions = []
|
||||
for txn in transactions_data:
|
||||
# Handle optional internal_transaction_id
|
||||
txn_id = txn.get("internal_transaction_id")
|
||||
|
||||
formatted_transactions.append(
|
||||
{
|
||||
"Date": datefmt(txn["date"]),
|
||||
"Description": txn["description"][:60] + "..."
|
||||
if len(txn["description"]) > 60
|
||||
else txn["description"],
|
||||
"Amount": f"{txn['amount']:.2f} {txn['currency']}",
|
||||
"Status": txn["status"].upper(),
|
||||
}
|
||||
)
|
||||
|
||||
if formatted_transactions:
|
||||
print_table(formatted_transactions)
|
||||
info(f"Showing {len(formatted_transactions)} transactions")
|
||||
else:
|
||||
info("No transactions found")
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"Error: Failed to get transactions: {str(e)}")
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import click
|
||||
from pymongo import MongoClient
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
|
||||
from leggen.utils.text import success, warning
|
||||
|
||||
|
||||
def persist_balances(ctx: click.Context, balance: dict) -> None:
|
||||
# Connect to MongoDB
|
||||
mongo_uri = ctx.obj.get("database", {}).get("mongodb", {}).get("uri")
|
||||
client = MongoClient(mongo_uri)
|
||||
db = client["leggen"]
|
||||
balances_collection = db["balances"]
|
||||
|
||||
# Insert balance into MongoDB
|
||||
try:
|
||||
balances_collection.insert_one(balance)
|
||||
success(
|
||||
f"[{balance['account_id']}] Inserted new balance if type {balance['type']}"
|
||||
)
|
||||
except DuplicateKeyError:
|
||||
warning(f"[{balance['account_id']}] Skipped duplicate balance")
|
||||
|
||||
client.close()
|
||||
|
||||
|
||||
def persist_transactions(ctx: click.Context, account: str, transactions: list) -> list:
|
||||
# Connect to MongoDB
|
||||
mongo_uri = ctx.obj.get("database", {}).get("mongodb", {}).get("uri")
|
||||
client = MongoClient(mongo_uri)
|
||||
db = client["leggen"]
|
||||
transactions_collection = db["transactions"]
|
||||
|
||||
# Create a unique index on internalTransactionId
|
||||
transactions_collection.create_index("internalTransactionId", unique=True)
|
||||
|
||||
# Insert transactions into MongoDB
|
||||
duplicates_count = 0
|
||||
|
||||
new_transactions = []
|
||||
|
||||
for transaction in transactions:
|
||||
try:
|
||||
transactions_collection.insert_one(transaction)
|
||||
new_transactions.append(transaction)
|
||||
except DuplicateKeyError:
|
||||
# A transaction with the same ID already exists, skip insertion
|
||||
duplicates_count += 1
|
||||
|
||||
success(f"[{account}] Inserted {len(new_transactions)} new transactions")
|
||||
if duplicates_count:
|
||||
warning(f"[{account}] Skipped {duplicates_count} duplicate transactions")
|
||||
|
||||
return new_transactions
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user