mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-30 03:29:19 +00:00
Compare commits
10 Commits
2025.9.12
...
1bc259bd3e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bc259bd3e | ||
|
|
fad426aee8 | ||
|
|
7f59e634a1 | ||
|
|
011c792e89 | ||
|
|
4e4c80be48 | ||
|
|
4c10307e12 | ||
|
|
de67ce9ec0 | ||
|
|
d99b1c73db | ||
|
|
69e381fffe | ||
|
|
f6ebb98de3 |
@@ -7,14 +7,7 @@
|
|||||||
"Bash(git commit:*)",
|
"Bash(git commit:*)",
|
||||||
"Bash(ruff check:*)",
|
"Bash(ruff check:*)",
|
||||||
"Bash(git add:*)",
|
"Bash(git add:*)",
|
||||||
"Bash(mypy:*)",
|
"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": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
.git/
|
.git/
|
||||||
data/
|
data/
|
||||||
docker-compose.dev.yml
|
docker-compose.dev.yml
|
||||||
frontend/node_modules/
|
|
||||||
.venv/
|
|
||||||
|
|||||||
55
.github/workflows/ci.yml
vendored
55
.github/workflows/ci.yml
vendored
@@ -1,55 +0,0 @@
|
|||||||
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
|
|
||||||
93
.github/workflows/release.yml
vendored
93
.github/workflows/release.yml
vendored
@@ -42,7 +42,7 @@ jobs:
|
|||||||
- name: Publish package
|
- name: Publish package
|
||||||
run: uv publish
|
run: uv publish
|
||||||
|
|
||||||
push-docker-backend:
|
push-docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -62,12 +62,10 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Docker meta backend
|
- name: Docker meta
|
||||||
id: meta-backend
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
flavor: |
|
|
||||||
latest=false
|
|
||||||
# list of Docker images to use as base name for tags
|
# list of Docker images to use as base name for tags
|
||||||
images: |
|
images: |
|
||||||
elisiariocouto/leggen
|
elisiariocouto/leggen
|
||||||
@@ -77,90 +75,11 @@ jobs:
|
|||||||
type=ref,event=tag
|
type=ref,event=tag
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=raw,value=latest
|
- name: Build and push
|
||||||
- name: Build and push backend
|
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta-backend.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
labels: ${{ steps.meta.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,6 +14,7 @@ dist/
|
|||||||
downloads/
|
downloads/
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
|
lib/
|
||||||
lib64/
|
lib64/
|
||||||
parts/
|
parts/
|
||||||
sdist/
|
sdist/
|
||||||
@@ -162,5 +163,3 @@ docker-compose.dev.yml
|
|||||||
nocodb/
|
nocodb/
|
||||||
sql/
|
sql/
|
||||||
leggen.db
|
leggen.db
|
||||||
*.db
|
|
||||||
config.toml
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||||
rev: "v0.13.0"
|
rev: "v0.12.11"
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v6.0.0
|
rev: v6.0.0
|
||||||
@@ -15,8 +15,8 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: mypy
|
- id: mypy
|
||||||
name: Static type check with mypy
|
name: Static type check with mypy
|
||||||
entry: uv run mypy leggen --check-untyped-defs
|
entry: uv run mypy leggen leggend --check-untyped-defs
|
||||||
files: "^leggen/.*"
|
files: "^leggen(d)?/.*"
|
||||||
language: "system"
|
language: "system"
|
||||||
types: ["python"]
|
types: ["python"]
|
||||||
always_run: true
|
always_run: true
|
||||||
|
|||||||
106
AGENTS.md
106
AGENTS.md
@@ -1,106 +0,0 @@
|
|||||||
# 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`
|
|
||||||
437
CHANGELOG.md
437
CHANGELOG.md
@@ -1,441 +1,4 @@
|
|||||||
|
|
||||||
## 2025.9.12 (2025/09/15)
|
|
||||||
|
|
||||||
|
|
||||||
## 2025.9.12 (2025/09/15)
|
|
||||||
|
|
||||||
|
|
||||||
## 2025.9.11 (2025/09/15)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- **config:** Add Pydantic validation and fix telegram config field mappings. ([2c6e0995](https://github.com/elisiariocouto/leggen/commit/2c6e0995968c9c9917992fd15ec10a89933c0c21))
|
|
||||||
- **config:** Fix example config file. ([d09cf6d0](https://github.com/elisiariocouto/leggen/commit/d09cf6d04ccb6233981f273cd88e0b8ffe074d71))
|
|
||||||
- **docs:** Remove test files and update gitignore ([692bee57](https://github.com/elisiariocouto/leggen/commit/692bee574ee8de16496a3c733bad53be3b256990))
|
|
||||||
- **frontend:** Align balance calculation between sidebar and Analytics page ([35b6d98e](https://github.com/elisiariocouto/leggen/commit/35b6d98e6a37b1e9caf8a232ffe66380e7203cad))
|
|
||||||
- **frontend:** Add ignore rules for eslint on shadcn components. ([74a700ff](https://github.com/elisiariocouto/leggen/commit/74a700ff87b2504c3d394cddd9935c56c3c7a00d))
|
|
||||||
- Resolve all CI failures - linting, typing, and test issues ([c8f0a103](https://github.com/elisiariocouto/leggen/commit/c8f0a103c6ccdb722bbab1ac6973827b41fddc19))
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **analytics:** Fix transaction limits and improve chart legends ([e136fc4b](https://github.com/elisiariocouto/leggen/commit/e136fc4b75243b35a77bc0bf0260808006987d7a))
|
|
||||||
- **docs:** Add comprehensive copilot agent setup instructions ([c6ac4455](https://github.com/elisiariocouto/leggen/commit/c6ac4455f848dd429100dd3fc6d43de8c4e5aa6b))
|
|
||||||
- **docs:** Add configuration file setup to agent instructions ([482f16c7](https://github.com/elisiariocouto/leggen/commit/482f16c77eef1f477ba49475fe30f809de9a05d7))
|
|
||||||
- **frontend:** Enhance transactions page with advanced filtering and UI improvements. ([969776fb](https://github.com/elisiariocouto/leggen/commit/969776fb53261acca2f77b0c761584e201fde118))
|
|
||||||
- **frontend:** Replace heavy filter UI with modern shadcn/ui inline filter bar. ([eb27f191](https://github.com/elisiariocouto/leggen/commit/eb27f19196d92a6ae5220b81709fded499a12f4f))
|
|
||||||
- **frontend:** Complete shadcn/ui migration with dark mode support and analytics updates. ([66db34c7](https://github.com/elisiariocouto/leggen/commit/66db34c712300ff4b5dbe7e06246f16d6f6a8469))
|
|
||||||
|
|
||||||
|
|
||||||
### Miscellaneous Tasks
|
|
||||||
|
|
||||||
- Sort imports, fix deprecated pydantic option. ([2467cb2f](https://github.com/elisiariocouto/leggen/commit/2467cb2f5af07a7262b3221bf61b58ad4017659a))
|
|
||||||
- Check import order using ruff. ([da98b7b2](https://github.com/elisiariocouto/leggen/commit/da98b7b2b77c5b37792dedff11f8256da3b086f7))
|
|
||||||
|
|
||||||
|
|
||||||
### Refactor
|
|
||||||
|
|
||||||
- **analytics:** Simplify analytics endpoints and eliminate client-side processing. ([077e2bb1](https://github.com/elisiariocouto/leggen/commit/077e2bb1adbdb73ffde17635bd918cd40fe7fb5a))
|
|
||||||
- Unify leggen and leggend packages into single leggen package ([318ca517](https://github.com/elisiariocouto/leggen/commit/318ca517f7ea599b37a8deb47ad80218fbae008f))
|
|
||||||
- Consolidate database layer and eliminate wrapper complexity. ([5ae3a51d](https://github.com/elisiariocouto/leggen/commit/5ae3a51d8138b9aa28dbceabf575ab2577402e70))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 2025.9.11 (2025/09/15)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- **config:** Add Pydantic validation and fix telegram config field mappings. ([2c6e0995](https://github.com/elisiariocouto/leggen/commit/2c6e0995968c9c9917992fd15ec10a89933c0c21))
|
|
||||||
- **config:** Fix example config file. ([d09cf6d0](https://github.com/elisiariocouto/leggen/commit/d09cf6d04ccb6233981f273cd88e0b8ffe074d71))
|
|
||||||
- **docs:** Remove test files and update gitignore ([692bee57](https://github.com/elisiariocouto/leggen/commit/692bee574ee8de16496a3c733bad53be3b256990))
|
|
||||||
- **frontend:** Align balance calculation between sidebar and Analytics page ([35b6d98e](https://github.com/elisiariocouto/leggen/commit/35b6d98e6a37b1e9caf8a232ffe66380e7203cad))
|
|
||||||
- **frontend:** Add ignore rules for eslint on shadcn components. ([74a700ff](https://github.com/elisiariocouto/leggen/commit/74a700ff87b2504c3d394cddd9935c56c3c7a00d))
|
|
||||||
- Resolve all CI failures - linting, typing, and test issues ([c8f0a103](https://github.com/elisiariocouto/leggen/commit/c8f0a103c6ccdb722bbab1ac6973827b41fddc19))
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **analytics:** Fix transaction limits and improve chart legends ([e136fc4b](https://github.com/elisiariocouto/leggen/commit/e136fc4b75243b35a77bc0bf0260808006987d7a))
|
|
||||||
- **docs:** Add comprehensive copilot agent setup instructions ([c6ac4455](https://github.com/elisiariocouto/leggen/commit/c6ac4455f848dd429100dd3fc6d43de8c4e5aa6b))
|
|
||||||
- **docs:** Add configuration file setup to agent instructions ([482f16c7](https://github.com/elisiariocouto/leggen/commit/482f16c77eef1f477ba49475fe30f809de9a05d7))
|
|
||||||
- **frontend:** Enhance transactions page with advanced filtering and UI improvements. ([969776fb](https://github.com/elisiariocouto/leggen/commit/969776fb53261acca2f77b0c761584e201fde118))
|
|
||||||
- **frontend:** Replace heavy filter UI with modern shadcn/ui inline filter bar. ([eb27f191](https://github.com/elisiariocouto/leggen/commit/eb27f19196d92a6ae5220b81709fded499a12f4f))
|
|
||||||
- **frontend:** Complete shadcn/ui migration with dark mode support and analytics updates. ([66db34c7](https://github.com/elisiariocouto/leggen/commit/66db34c712300ff4b5dbe7e06246f16d6f6a8469))
|
|
||||||
|
|
||||||
|
|
||||||
### Miscellaneous Tasks
|
|
||||||
|
|
||||||
- Sort imports, fix deprecated pydantic option. ([2467cb2f](https://github.com/elisiariocouto/leggen/commit/2467cb2f5af07a7262b3221bf61b58ad4017659a))
|
|
||||||
- Check import order using ruff. ([da98b7b2](https://github.com/elisiariocouto/leggen/commit/da98b7b2b77c5b37792dedff11f8256da3b086f7))
|
|
||||||
|
|
||||||
|
|
||||||
### Refactor
|
|
||||||
|
|
||||||
- **analytics:** Simplify analytics endpoints and eliminate client-side processing. ([077e2bb1](https://github.com/elisiariocouto/leggen/commit/077e2bb1adbdb73ffde17635bd918cd40fe7fb5a))
|
|
||||||
- Unify leggen and leggend packages into single leggen package ([318ca517](https://github.com/elisiariocouto/leggen/commit/318ca517f7ea599b37a8deb47ad80218fbae008f))
|
|
||||||
- Consolidate database layer and eliminate wrapper complexity. ([5ae3a51d](https://github.com/elisiariocouto/leggen/commit/5ae3a51d8138b9aa28dbceabf575ab2577402e70))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 2025.9.10 (2025/09/13)
|
|
||||||
|
|
||||||
### Miscellaneous Tasks
|
|
||||||
|
|
||||||
- **frontend:** Update dependencies. ([06cf02f4](https://github.com/elisiariocouto/leggen/commit/06cf02f43ff72e4e01692e3a94a06be48d9acb1f))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 2025.9.10 (2025/09/13)
|
|
||||||
|
|
||||||
### Miscellaneous Tasks
|
|
||||||
|
|
||||||
- **frontend:** Update dependencies. ([06cf02f4](https://github.com/elisiariocouto/leggen/commit/06cf02f43ff72e4e01692e3a94a06be48d9acb1f))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 2025.9.9 (2025/09/11)
|
|
||||||
|
|
||||||
### 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)
|
## 0.6.11 (2025/02/23)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
69
CLAUDE.md
Normal file
69
CLAUDE.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Leggen is an Open Banking CLI tool built in Python that connects to banks using the GoCardless Open Banking API. It allows users to sync bank transactions to SQLite/MongoDB databases, visualize data with NocoDB, and send notifications based on transaction filters.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
- **Install dependencies**: `uv sync` (uses uv package manager)
|
||||||
|
- **Run locally**: `uv run leggen --help`
|
||||||
|
- **Lint code**: `ruff check` and `ruff format` (configured in pyproject.toml)
|
||||||
|
- **Build Docker image**: `docker build -t leggen .`
|
||||||
|
- **Run with Docker Compose**: `docker compose up -d`
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Core Structure
|
||||||
|
- `leggen/main.py` - Main CLI entry point using Click framework with custom command loading
|
||||||
|
- `leggen/commands/` - CLI command implementations (balances, sync, transactions, etc.)
|
||||||
|
- `leggen/utils/` - Core utilities for authentication, database operations, network requests, and notifications
|
||||||
|
- `leggen/database/` - Database adapters for SQLite and MongoDB
|
||||||
|
- `leggen/notifications/` - Discord and Telegram notification handlers
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
|
||||||
|
**Configuration System**:
|
||||||
|
- Uses TOML configuration files (default: `~/.config/leggen/config.toml`)
|
||||||
|
- Configuration loaded via `leggen/utils/config.py`
|
||||||
|
- Supports GoCardless API credentials, database settings, and notification configurations
|
||||||
|
|
||||||
|
**Authentication & API**:
|
||||||
|
- GoCardless Open Banking API integration in `leggen/utils/gocardless.py`
|
||||||
|
- Token-based authentication via `leggen/utils/auth.py`
|
||||||
|
- Network utilities in `leggen/utils/network.py`
|
||||||
|
|
||||||
|
**Database Operations**:
|
||||||
|
- Dual database support: SQLite (`database/sqlite.py`) and MongoDB (`database/mongo.py`)
|
||||||
|
- Transaction persistence and balance tracking via `utils/database.py`
|
||||||
|
- Data storage patterns follow bank account and transaction models
|
||||||
|
|
||||||
|
**Command Architecture**:
|
||||||
|
- Dynamic command loading system in `main.py` with support for command groups
|
||||||
|
- Commands organized as modules with individual click decorators
|
||||||
|
- Bank management commands grouped under `commands/bank/`
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
1. Configuration loaded from TOML file
|
||||||
|
2. GoCardless API authentication and bank requisition management
|
||||||
|
3. Account and transaction data retrieval from banks
|
||||||
|
4. Data persistence to configured databases (SQLite/MongoDB)
|
||||||
|
5. Optional notifications sent via Discord/Telegram based on filters
|
||||||
|
6. Data visualization available through NocoDB integration
|
||||||
|
|
||||||
|
## Docker & Deployment
|
||||||
|
|
||||||
|
The project uses multi-stage Docker builds with uv for dependency management. The compose.yml includes:
|
||||||
|
- Main leggen service with sync scheduling via Ofelia
|
||||||
|
- NocoDB for data visualization
|
||||||
|
- Optional MongoDB with mongo-express admin interface
|
||||||
|
|
||||||
|
## Configuration Requirements
|
||||||
|
|
||||||
|
All operations require a valid `config.toml` file with GoCardless API credentials. The configuration structure includes sections for:
|
||||||
|
- `[gocardless]` - API credentials and endpoint
|
||||||
|
- `[database]` - Storage backend selection
|
||||||
|
- `[notifications]` - Discord/Telegram webhook settings
|
||||||
|
- `[filters]` - Transaction matching patterns for notifications
|
||||||
17
Dockerfile
17
Dockerfile
@@ -6,28 +6,25 @@ WORKDIR /app
|
|||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
uv sync --locked --no-install-project --no-editable
|
uv sync --frozen --no-install-project --no-editable
|
||||||
|
|
||||||
COPY . /app
|
COPY . /app
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
uv sync --locked --no-editable --no-group dev
|
uv sync --frozen --no-editable --no-group dev
|
||||||
|
|
||||||
FROM python:3.13-alpine
|
FROM python:3.13-alpine
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source="https://github.com/elisiariocouto/leggen"
|
LABEL org.opencontainers.image.source="https://github.com/elisiariocouto/leggen"
|
||||||
LABEL org.opencontainers.image.authors="Elisiário Couto <elisiario@couto.io>"
|
LABEL org.opencontainers.image.authors="Elisiário Couto <elisiario@couto.io>"
|
||||||
LABEL org.opencontainers.image.licenses="MIT"
|
LABEL org.opencontainers.image.licenses="MIT"
|
||||||
LABEL org.opencontainers.image.title="Leggen API"
|
LABEL org.opencontainers.image.title="leggen"
|
||||||
LABEL org.opencontainers.image.description="Open Banking API for Leggen"
|
LABEL org.opencontainers.image.description="An Open Banking CLI"
|
||||||
LABEL org.opencontainers.image.url="https://github.com/elisiariocouto/leggen"
|
LABEL org.opencontainers.image.url="https://github.com/elisiariocouto/leggen"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
|
|
||||||
COPY --from=builder /app/.venv /app/.venv
|
COPY --from=builder --chown=app:app /app/.venv /app/.venv
|
||||||
|
|
||||||
EXPOSE 8000
|
ENTRYPOINT ["/app/.venv/bin/leggen"]
|
||||||
|
|
||||||
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"]
|
|
||||||
|
|||||||
91
PROJECT.md
Normal file
91
PROJECT.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# Leggen Web Transformation Project
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Transform leggen from CLI-only to web application with FastAPI backend (`leggend`) and SvelteKit frontend (`leggen-web`).
|
||||||
|
|
||||||
|
## Progress Tracking
|
||||||
|
|
||||||
|
### ✅ Phase 1: FastAPI Backend (`leggend`)
|
||||||
|
|
||||||
|
#### 1.1 Core Structure
|
||||||
|
- [x] Create directory structure (`leggend/`, `api/`, `services/`, etc.)
|
||||||
|
- [x] Add FastAPI dependencies to pyproject.toml
|
||||||
|
- [x] Create configuration management system
|
||||||
|
- [x] Set up FastAPI main application
|
||||||
|
- [x] Create Pydantic models for API responses
|
||||||
|
|
||||||
|
#### 1.2 API Endpoints
|
||||||
|
- [x] Banks API (`/api/v1/banks/`)
|
||||||
|
- [x] `GET /institutions` - List available banks
|
||||||
|
- [x] `POST /connect` - Connect to bank
|
||||||
|
- [x] `GET /status` - Bank connection status
|
||||||
|
- [x] Accounts API (`/api/v1/accounts/`)
|
||||||
|
- [x] `GET /` - List all accounts
|
||||||
|
- [x] `GET /{id}/balances` - Account balances
|
||||||
|
- [x] `GET /{id}/transactions` - Account transactions
|
||||||
|
- [x] Sync API (`/api/v1/sync/`)
|
||||||
|
- [x] `POST /` - Trigger manual sync
|
||||||
|
- [x] `GET /status` - Sync status
|
||||||
|
- [x] Notifications API (`/api/v1/notifications/`)
|
||||||
|
- [x] `GET/POST/PUT /settings` - Manage notification settings
|
||||||
|
|
||||||
|
#### 1.3 Background Jobs
|
||||||
|
- [x] Implement APScheduler for sync scheduling
|
||||||
|
- [x] Replace Ofelia with internal Python scheduler
|
||||||
|
- [x] Migrate existing sync logic from CLI
|
||||||
|
|
||||||
|
### ⏳ Phase 2: SvelteKit Frontend (`leggen-web`)
|
||||||
|
|
||||||
|
#### 2.1 Project Setup
|
||||||
|
- [ ] Create SvelteKit project structure
|
||||||
|
- [ ] Set up API client for backend communication
|
||||||
|
- [ ] Design component architecture
|
||||||
|
|
||||||
|
#### 2.2 UI Components
|
||||||
|
- [ ] Dashboard with account overview
|
||||||
|
- [ ] Bank connection wizard
|
||||||
|
- [ ] Transaction history and filtering
|
||||||
|
- [ ] Settings management
|
||||||
|
- [ ] Real-time sync status
|
||||||
|
|
||||||
|
### ✅ Phase 3: CLI Refactoring
|
||||||
|
|
||||||
|
#### 3.1 API Client Integration
|
||||||
|
- [x] Create HTTP client for FastAPI calls
|
||||||
|
- [x] Refactor existing commands to use APIs
|
||||||
|
- [x] Maintain CLI user experience
|
||||||
|
- [x] Add API URL configuration option
|
||||||
|
|
||||||
|
### ✅ Phase 4: Docker & Deployment
|
||||||
|
|
||||||
|
#### 4.1 Container Setup
|
||||||
|
- [x] Create Dockerfile for `leggend` service
|
||||||
|
- [x] Update docker-compose.yml with `leggend` service
|
||||||
|
- [x] Remove Ofelia dependency (scheduler now internal)
|
||||||
|
- [ ] Create Dockerfile for `leggen-web` (deferred - not implementing web UI yet)
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
**Active Phase**: Phase 2 - CLI Integration Complete
|
||||||
|
**Last Updated**: 2025-09-01
|
||||||
|
**Completion**: ~80% (FastAPI backend and CLI refactoring complete)
|
||||||
|
|
||||||
|
## Next Steps (Future Enhancements)
|
||||||
|
1. Implement SvelteKit web frontend
|
||||||
|
2. Add real-time WebSocket support for sync status
|
||||||
|
3. Implement user authentication and multi-user support
|
||||||
|
4. Add more comprehensive error handling and logging
|
||||||
|
5. Implement database migrations for schema changes
|
||||||
|
|
||||||
|
## Recent Achievements
|
||||||
|
- ✅ Complete FastAPI backend with all major endpoints
|
||||||
|
- ✅ Configurable background job scheduler (replaces Ofelia)
|
||||||
|
- ✅ CLI successfully refactored to use API endpoints
|
||||||
|
- ✅ Docker configuration updated for new architecture
|
||||||
|
- ✅ Maintained backward compatibility and user experience
|
||||||
|
|
||||||
|
## Architecture Decisions
|
||||||
|
- **FastAPI**: For high-performance async API backend
|
||||||
|
- **APScheduler**: For internal job scheduling (replacing Ofelia)
|
||||||
|
- **SvelteKit**: For modern, reactive frontend
|
||||||
|
- **Existing Logic**: Reuse all business logic from current CLI commands
|
||||||
|
- **Configuration**: Centralize in `leggend` service, maintain TOML compatibility
|
||||||
127
README.md
127
README.md
@@ -2,25 +2,24 @@
|
|||||||
|
|
||||||
An Open Banking CLI and API service for managing bank connections and transactions.
|
An Open Banking CLI and API service for managing bank connections and transactions.
|
||||||
|
|
||||||
This tool provides 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.
|
This tool provides both a **FastAPI backend service** (`leggend`) and a **command-line interface** (`leggen`) to connect to banks using the GoCardless Open Banking API.
|
||||||
|
|
||||||
|
**New in v0.6.11**: Web-ready architecture with FastAPI backend, enhanced CLI, and background job scheduling.
|
||||||
|
|
||||||
Having your bank data accessible through both CLI and REST API gives you the power to backup, analyze, create reports, and integrate with other applications.
|
Having your bank data accessible through both CLI and REST API gives you the power to backup, analyze, create reports, and integrate with other applications.
|
||||||
|
|
||||||
## 🛠️ Technologies
|
## 🛠️ Technologies
|
||||||
|
|
||||||
### 🔌 API & Backend
|
### 🔌 API & Backend
|
||||||
- [FastAPI](https://fastapi.tiangolo.com/): High-performance async API backend (integrated into `leggen server`)
|
- [FastAPI](https://fastapi.tiangolo.com/): High-performance async API backend (`leggend` service)
|
||||||
- [GoCardless Open Banking API](https://developer.gocardless.com/bank-account-data/overview): for connecting to banks
|
- [GoCardless Open Banking API](https://developer.gocardless.com/bank-account-data/overview): for connecting to banks
|
||||||
- [APScheduler](https://apscheduler.readthedocs.io/): Background job scheduling with configurable cron
|
- [APScheduler](https://apscheduler.readthedocs.io/): Background job scheduling with configurable cron
|
||||||
|
|
||||||
### 📦 Storage
|
### 📦 Storage
|
||||||
- [SQLite](https://www.sqlite.org): for storing transactions, simple and easy to use
|
- [SQLite](https://www.sqlite.org): for storing transactions, simple and easy to use
|
||||||
|
|
||||||
### Frontend
|
### 📊 Visualization
|
||||||
- [React](https://reactjs.org/): Modern web interface with TypeScript
|
- [NocoDB](https://github.com/nocodb/nocodb): for visualizing and querying transactions, a simple and easy to use interface for SQLite
|
||||||
- [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
|
## ✨ Features
|
||||||
|
|
||||||
@@ -40,6 +39,8 @@ Having your bank data accessible through both CLI and REST API gives you the pow
|
|||||||
### 📡 API & Integration
|
### 📡 API & Integration
|
||||||
- **REST API**: Complete FastAPI backend with comprehensive endpoints
|
- **REST API**: Complete FastAPI backend with comprehensive endpoints
|
||||||
- **CLI Interface**: Enhanced command-line tools with new options
|
- **CLI Interface**: Enhanced command-line tools with new options
|
||||||
|
- **Health Checks**: Service monitoring and dependency management
|
||||||
|
- **Auto-reload**: Development mode with file watching
|
||||||
|
|
||||||
### 🔔 Notifications & Monitoring
|
### 🔔 Notifications & Monitoring
|
||||||
- Discord and Telegram notifications for filtered transactions
|
- Discord and Telegram notifications for filtered transactions
|
||||||
@@ -47,6 +48,12 @@ Having your bank data accessible through both CLI and REST API gives you the pow
|
|||||||
- Account expiry notifications and status alerts
|
- Account expiry notifications and status alerts
|
||||||
- Comprehensive logging and error handling
|
- Comprehensive logging and error handling
|
||||||
|
|
||||||
|
### 📊 Visualization & Analysis
|
||||||
|
- NocoDB integration for visual data exploration
|
||||||
|
- Transaction statistics and reporting
|
||||||
|
- Account balance tracking over time
|
||||||
|
- Export capabilities for further analysis
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -56,7 +63,7 @@ Having your bank data accessible through both CLI and REST API gives you the pow
|
|||||||
### Installation Options
|
### Installation Options
|
||||||
|
|
||||||
#### Option 1: Docker Compose (Recommended)
|
#### Option 1: Docker Compose (Recommended)
|
||||||
The easiest way to get started is with Docker Compose, which includes both the React frontend and FastAPI backend:
|
The easiest way to get started is with Docker Compose:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
# Clone the repository
|
||||||
@@ -64,41 +71,13 @@ git clone https://github.com/elisiariocouto/leggen.git
|
|||||||
cd leggen
|
cd leggen
|
||||||
|
|
||||||
# Create your configuration
|
# Create your configuration
|
||||||
mkdir -p data && cp config.example.toml data/config.toml
|
mkdir -p leggen && cp config.example.toml leggen/config.toml
|
||||||
# Edit data/config.toml with your GoCardless credentials
|
# Edit leggen/config.toml with your GoCardless credentials
|
||||||
|
|
||||||
# Start all services (frontend + backend)
|
# Start all services
|
||||||
docker compose up -d
|
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
|
#### Option 2: Local Development
|
||||||
For development or local installation:
|
For development or local installation:
|
||||||
|
|
||||||
@@ -107,7 +86,7 @@ For development or local installation:
|
|||||||
uv sync # or pip install -e .
|
uv sync # or pip install -e .
|
||||||
|
|
||||||
# Start the API service
|
# Start the API service
|
||||||
uv run leggen server --reload # Development mode with auto-reload
|
uv run leggend --reload # Development mode with auto-reload
|
||||||
|
|
||||||
# Use the CLI (in another terminal)
|
# Use the CLI (in another terminal)
|
||||||
uv run leggen --help
|
uv run leggen --help
|
||||||
@@ -115,7 +94,7 @@ uv run leggen --help
|
|||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
Create a configuration file at `./data/config.toml` (for Docker) or `~/.config/leggen/config.toml` (for local development):
|
Create a configuration file at `~/.config/leggen/config.toml`:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[gocardless]
|
[gocardless]
|
||||||
@@ -145,26 +124,26 @@ chat_id = 12345
|
|||||||
enabled = true
|
enabled = true
|
||||||
|
|
||||||
# Optional: Transaction filters for notifications
|
# Optional: Transaction filters for notifications
|
||||||
[filters]
|
[filters.case-insensitive]
|
||||||
case-insensitive = ["salary", "utility"]
|
salary = "salary"
|
||||||
case-sensitive = ["SpecificStore"]
|
bills = "utility"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📖 Usage
|
## 📖 Usage
|
||||||
|
|
||||||
### API Service (`leggen server`)
|
### API Service (`leggend`)
|
||||||
|
|
||||||
Start the FastAPI backend service:
|
Start the FastAPI backend service:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Production mode
|
# Production mode
|
||||||
leggen server
|
leggend
|
||||||
|
|
||||||
# Development mode with auto-reload
|
# Development mode with auto-reload
|
||||||
leggen server --reload
|
leggend --reload
|
||||||
|
|
||||||
# Custom host and port
|
# Custom host and port
|
||||||
leggen server --host 127.0.0.1 --port 8080
|
leggend --host 127.0.0.1 --port 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
**API Documentation**: Visit `http://localhost:8000/docs` for interactive API documentation.
|
**API Documentation**: Visit `http://localhost:8000/docs` for interactive API documentation.
|
||||||
@@ -207,45 +186,24 @@ leggen sync --force --wait
|
|||||||
leggen --api-url http://localhost:8080 status
|
leggen --api-url http://localhost:8080 status
|
||||||
|
|
||||||
# Set via environment variable
|
# Set via environment variable
|
||||||
export LEGGEN_API_URL=http://localhost:8080
|
export LEGGEND_API_URL=http://localhost:8080
|
||||||
leggen status
|
leggen status
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker Usage
|
### Docker Usage
|
||||||
|
|
||||||
#### Development (build from source)
|
|
||||||
```bash
|
```bash
|
||||||
# Start development services
|
# Start all 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
|
docker compose up -d
|
||||||
|
|
||||||
# View service status
|
# Connect to a bank
|
||||||
docker compose ps
|
docker compose run leggen bank add
|
||||||
|
|
||||||
|
# Run a sync
|
||||||
|
docker compose run leggen sync --wait
|
||||||
|
|
||||||
# Check logs
|
# Check logs
|
||||||
docker compose logs frontend
|
docker compose logs leggend
|
||||||
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
|
## 🔌 API Endpoints
|
||||||
@@ -290,7 +248,7 @@ cd leggen
|
|||||||
uv sync
|
uv sync
|
||||||
|
|
||||||
# Start API service with auto-reload
|
# Start API service with auto-reload
|
||||||
uv run leggen server --reload
|
uv run leggend --reload
|
||||||
|
|
||||||
# Use CLI commands
|
# Use CLI commands
|
||||||
uv run leggen status
|
uv run leggen status
|
||||||
@@ -333,10 +291,13 @@ The test suite includes:
|
|||||||
leggen/ # CLI application
|
leggen/ # CLI application
|
||||||
├── commands/ # CLI command implementations
|
├── commands/ # CLI command implementations
|
||||||
├── utils/ # Shared utilities
|
├── utils/ # Shared utilities
|
||||||
├── api/ # FastAPI API routes and models
|
└── api_client.py # API client for leggend service
|
||||||
|
|
||||||
|
leggend/ # FastAPI backend service
|
||||||
|
├── api/ # API routes and models
|
||||||
├── services/ # Business logic
|
├── services/ # Business logic
|
||||||
├── background/ # Background job scheduler
|
├── background/ # Background job scheduler
|
||||||
└── api_client.py # API client for server communication
|
└── main.py # FastAPI application
|
||||||
|
|
||||||
tests/ # Test suite
|
tests/ # Test suite
|
||||||
├── conftest.py # Shared test fixtures
|
├── conftest.py # Shared test fixtures
|
||||||
@@ -354,12 +315,8 @@ tests/ # Test suite
|
|||||||
3. Make your changes with tests
|
3. Make your changes with tests
|
||||||
4. Submit a pull request
|
4. Submit a pull request
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
## ⚠️ Notes
|
## ⚠️ Notes
|
||||||
- This project is in active development
|
- This project is in active development
|
||||||
|
- Web frontend planned for future releases
|
||||||
- GoCardless API rate limits apply
|
- GoCardless API rate limits apply
|
||||||
- Some banks may require additional authorization steps
|
- Some banks may require additional authorization steps
|
||||||
- Docker images are automatically built and published on releases
|
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
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"
|
|
||||||
63
compose.yml
63
compose.yml
@@ -1,19 +1,56 @@
|
|||||||
services:
|
services:
|
||||||
# React frontend service
|
|
||||||
frontend:
|
|
||||||
image: ghcr.io/elisiariocouto/leggen:latest-frontend
|
|
||||||
restart: "unless-stopped"
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:3000:80"
|
|
||||||
depends_on:
|
|
||||||
leggen-server:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
# FastAPI backend service
|
# FastAPI backend service
|
||||||
leggen-server:
|
leggend:
|
||||||
image: ghcr.io/elisiariocouto/leggen:latest
|
build:
|
||||||
|
context: .
|
||||||
restart: "unless-stopped"
|
restart: "unless-stopped"
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8000:8000"
|
- "127.0.0.1:8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- "./data:/root/.config/leggen" # Configuration and database directory
|
- "./leggen:/root/.config/leggen" # Configuration file directory
|
||||||
|
- "./db:/app" # Database storage
|
||||||
|
environment:
|
||||||
|
- LEGGEN_CONFIG_FILE=/root/.config/leggen/config.toml
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
nocodb:
|
||||||
|
image: nocodb/nocodb:latest
|
||||||
|
restart: "unless-stopped"
|
||||||
|
volumes:
|
||||||
|
- "./nocodb:/usr/app/data/"
|
||||||
|
- "./db:/usr/leggen:ro"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8080:8080"
|
||||||
|
depends_on:
|
||||||
|
leggend:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
[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"]
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": ["Bash(find:*)"],
|
|
||||||
"deny": [],
|
|
||||||
"ask": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
24
frontend/.gitignore
vendored
24
frontend/.gitignore
vendored
@@ -1,24 +0,0 @@
|
|||||||
# 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?
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# 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;"]
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"$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": {}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name localhost;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
# Enable gzip compression
|
|
||||||
gzip on;
|
|
||||||
gzip_vary on;
|
|
||||||
gzip_min_length 1024;
|
|
||||||
gzip_proxied expired no-cache no-store private auth;
|
|
||||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
|
||||||
|
|
||||||
# Handle client-side routing
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# API proxy to backend (configurable via API_BACKEND_URL env var)
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass ${API_BACKEND_URL};
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cache static assets
|
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
|
||||||
expires 1y;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
"react-refresh/only-export-components": [
|
|
||||||
"warn",
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ["src/components/**/*.{ts,tsx}", "src/contexts/**/*.{ts,tsx}"],
|
|
||||||
rules: {
|
|
||||||
"react-refresh/only-export-components": "off",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!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
6691
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,52 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 257 B |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1 +0,0 @@
|
|||||||
/* Additional styles if needed */
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1,382 +0,0 @@
|
|||||||
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 {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "./ui/card";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
|
||||||
import LoadingSpinner from "./LoadingSpinner";
|
|
||||||
import 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, display_name }: { id: string; display_name: string }) =>
|
|
||||||
apiClient.updateAccount(id, { display_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);
|
|
||||||
// Use display_name if available, otherwise fall back to name
|
|
||||||
setEditingName(account.display_name || account.name || "");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditSave = () => {
|
|
||||||
if (editingAccountId && editingName.trim()) {
|
|
||||||
updateAccountMutation.mutate({
|
|
||||||
id: editingAccountId,
|
|
||||||
display_name: editingName.trim(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditCancel = () => {
|
|
||||||
setEditingAccountId(null);
|
|
||||||
setEditingName("");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (accountsLoading) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<LoadingSpinner message="Loading accounts..." />
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (accountsError) {
|
|
||||||
return (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertTitle>Failed to load accounts</AlertTitle>
|
|
||||||
<AlertDescription className="space-y-3">
|
|
||||||
<p>
|
|
||||||
Unable to connect to the Leggen API. Please check your configuration
|
|
||||||
and ensure the API server is running.
|
|
||||||
</p>
|
|
||||||
<Button onClick={() => refetchAccounts()} variant="outline" size="sm">
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
|
||||||
Total Balance
|
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-bold text-foreground">
|
|
||||||
{formatCurrency(totalBalance)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 bg-green-100 dark:bg-green-900/20 rounded-full">
|
|
||||||
<TrendingUp className="h-6 w-6 text-green-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
|
||||||
Total Accounts
|
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-bold text-foreground">
|
|
||||||
{totalAccounts}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 bg-blue-100 dark:bg-blue-900/20 rounded-full">
|
|
||||||
<CreditCard className="h-6 w-6 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
|
||||||
Connected Banks
|
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-bold text-foreground">
|
|
||||||
{uniqueBanks}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 bg-purple-100 dark:bg-purple-900/20 rounded-full">
|
|
||||||
<Building2 className="h-6 w-6 text-purple-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Accounts List */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Bank Accounts</CardTitle>
|
|
||||||
<CardDescription>Manage your connected bank accounts</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{!accounts || accounts.length === 0 ? (
|
|
||||||
<CardContent className="p-6 text-center">
|
|
||||||
<CreditCard className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
|
||||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
|
||||||
No accounts found
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Connect your first bank account to get started with Leggen.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
) : (
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{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-accent 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-muted rounded-full">
|
|
||||||
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
|
|
||||||
</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-input rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
|
||||||
placeholder="Custom 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-muted-foreground 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-foreground truncate">
|
|
||||||
{account.display_name || account.name || "Unnamed Account"}
|
|
||||||
</h4>
|
|
||||||
<button
|
|
||||||
onClick={() => handleEditStart(account)}
|
|
||||||
className="flex-shrink-0 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
title="Edit account name"
|
|
||||||
>
|
|
||||||
<Edit2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
|
||||||
{account.institution_id}
|
|
||||||
</p>
|
|
||||||
{account.iban && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-1 font-mono break-all sm:break-normal">
|
|
||||||
IBAN: {account.iban}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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-muted-foreground whitespace-nowrap">
|
|
||||||
Updated{" "}
|
|
||||||
{formatDate(
|
|
||||||
account.last_accessed || account.created,
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Balance - right on mobile, top on desktop */}
|
|
||||||
<div className="flex items-center space-x-2 order-2 sm:order-1">
|
|
||||||
{isPositive ? (
|
|
||||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<TrendingDown className="h-4 w-4 text-red-500" />
|
|
||||||
)}
|
|
||||||
<p
|
|
||||||
className={`text-base sm:text-lg font-semibold ${
|
|
||||||
isPositive ? "text-green-600" : "text-red-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{formatCurrency(balance, currency)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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";
|
|
||||||
import { ThemeToggle } from "./ui/theme-toggle";
|
|
||||||
|
|
||||||
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-card shadow-sm border-b border-border">
|
|
||||||
<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-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Menu className="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
<h2 className="text-lg font-semibold text-card-foreground lg:ml-0 ml-4">
|
|
||||||
{currentPage}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<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-muted-foreground">
|
|
||||||
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-muted-foreground">Connected</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
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 {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "./ui/card";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { Input } from "./ui/input";
|
|
||||||
import { Label } from "./ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "./ui/select";
|
|
||||||
import type { NotificationSettings, NotificationService } from "../types/api";
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Card>
|
|
||||||
<LoadingSpinner message="Loading notifications..." />
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settingsError || servicesError) {
|
|
||||||
return (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertTitle>Failed to load notifications</AlertTitle>
|
|
||||||
<AlertDescription className="space-y-3">
|
|
||||||
<p>
|
|
||||||
Unable to connect to the Leggen API. Please check your configuration
|
|
||||||
and ensure the API server is running.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
refetchSettings();
|
|
||||||
refetchServices();
|
|
||||||
}}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center space-x-2">
|
|
||||||
<TestTube className="h-5 w-5 text-primary" />
|
|
||||||
<span>Test Notifications</span>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="service" className="text-foreground">
|
|
||||||
Service
|
|
||||||
</Label>
|
|
||||||
<Select value={testService} onValueChange={setTestService}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a service..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{services?.map((service) => (
|
|
||||||
<SelectItem key={service.name} value={service.name}>
|
|
||||||
{service.name}{" "}
|
|
||||||
{service.enabled ? "(Enabled)" : "(Disabled)"}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="message" className="text-foreground">
|
|
||||||
Message
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="message"
|
|
||||||
type="text"
|
|
||||||
value={testMessage}
|
|
||||||
onChange={(e) => setTestMessage(e.target.value)}
|
|
||||||
placeholder="Test message..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<Button
|
|
||||||
onClick={handleTestNotification}
|
|
||||||
disabled={!testService || testMutation.isPending}
|
|
||||||
>
|
|
||||||
<Send className="h-4 w-4 mr-2" />
|
|
||||||
{testMutation.isPending ? "Sending..." : "Send Test Notification"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Notification Services */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center space-x-2">
|
|
||||||
<Bell className="h-5 w-5 text-primary" />
|
|
||||||
<span>Notification Services</span>
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>Manage your notification services</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{!services || services.length === 0 ? (
|
|
||||||
<CardContent className="text-center">
|
|
||||||
<Bell className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
|
||||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
|
||||||
No notification services configured
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Configure notification services in your backend to receive alerts.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
) : (
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{services.map((service) => (
|
|
||||||
<div
|
|
||||||
key={service.name}
|
|
||||||
className="p-6 hover:bg-accent transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div className="p-3 bg-muted rounded-full">
|
|
||||||
{service.name.toLowerCase().includes("discord") ? (
|
|
||||||
<MessageSquare className="h-6 w-6 text-muted-foreground" />
|
|
||||||
) : service.name.toLowerCase().includes("telegram") ? (
|
|
||||||
<Send className="h-6 w-6 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<Bell className="h-6 w-6 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-lg font-medium text-foreground 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>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => handleDeleteService(service.name)}
|
|
||||||
disabled={deleteServiceMutation.isPending}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Notification Settings */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center space-x-2">
|
|
||||||
<Settings className="h-5 w-5 text-primary" />
|
|
||||||
<span>Notification Settings</span>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{settings && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-foreground mb-2">
|
|
||||||
Filters
|
|
||||||
</h4>
|
|
||||||
<div className="bg-muted rounded-md p-4">
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs font-medium text-muted-foreground mb-1 block">
|
|
||||||
Case Insensitive Filters
|
|
||||||
</Label>
|
|
||||||
<p className="text-sm text-foreground">
|
|
||||||
{settings.filters.case_insensitive.length > 0
|
|
||||||
? settings.filters.case_insensitive.join(", ")
|
|
||||||
: "None"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs font-medium text-muted-foreground mb-1 block">
|
|
||||||
Case Sensitive Filters
|
|
||||||
</Label>
|
|
||||||
<p className="text-sm text-foreground">
|
|
||||||
{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-muted-foreground">
|
|
||||||
<p>
|
|
||||||
Configure notification settings through your backend API to
|
|
||||||
customize filters and service configurations.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { X, Copy, Check } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
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-background/80 backdrop-blur-sm transition-opacity"
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal panel */}
|
|
||||||
<div className="inline-block align-bottom bg-card rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full border">
|
|
||||||
<div className="bg-card 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-foreground">
|
|
||||||
Raw Transaction Data
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleCopy}
|
|
||||||
disabled={!rawTransaction}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<>
|
|
||||||
<Check className="h-4 w-4 mr-1 text-green-600 dark:text-green-400" />
|
|
||||||
Copied!
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Copy className="h-4 w-4 mr-1" />
|
|
||||||
Copy JSON
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={onClose} variant="ghost" size="sm">
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Transaction ID:{" "}
|
|
||||||
<code className="bg-muted px-2 py-1 rounded text-xs text-foreground">
|
|
||||||
{transactionId}
|
|
||||||
</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{rawTransaction ? (
|
|
||||||
<div className="bg-muted rounded-lg p-4 overflow-auto max-h-96">
|
|
||||||
<pre className="text-sm text-foreground whitespace-pre-wrap">
|
|
||||||
{JSON.stringify(rawTransaction, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-muted rounded-lg p-8 text-center">
|
|
||||||
<p className="text-foreground">
|
|
||||||
Raw transaction data is not available for this transaction.
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-2">
|
|
||||||
Try refreshing the page or check if the transaction was
|
|
||||||
fetched with summary_only=false.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-muted/50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="w-full sm:ml-3 sm:w-auto"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
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-card shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0",
|
|
||||||
sidebarOpen ? "translate-x-0" : "-translate-x-full",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between h-16 px-6 border-b border-border">
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
onClick={() => setSidebarOpen(false)}
|
|
||||||
className="flex items-center space-x-2 hover:opacity-80 transition-opacity"
|
|
||||||
>
|
|
||||||
<CreditCard className="h-8 w-8 text-primary" />
|
|
||||||
<h1 className="text-xl font-bold text-card-foreground">Leggen</h1>
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
onClick={() => setSidebarOpen(false)}
|
|
||||||
className="lg:hidden p-1 rounded-md text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<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={cn(
|
|
||||||
"flex items-center w-full px-3 py-2 text-sm font-medium rounded-md transition-colors",
|
|
||||||
location.pathname === item.to
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "text-card-foreground hover:text-card-foreground hover:bg-accent",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<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-border mt-auto">
|
|
||||||
<div className="bg-muted rounded-lg p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">
|
|
||||||
Total Balance
|
|
||||||
</span>
|
|
||||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold text-foreground mt-1">
|
|
||||||
{formatCurrency(totalBalance)}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
{accounts?.length || 0} accounts
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
interface TransactionSkeletonProps {
|
|
||||||
rows?: number;
|
|
||||||
view?: "table" | "mobile";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TransactionSkeleton({
|
|
||||||
rows = 5,
|
|
||||||
view = "table",
|
|
||||||
}: TransactionSkeletonProps) {
|
|
||||||
const skeletonRows = Array.from({ length: rows }, (_, index) => index);
|
|
||||||
|
|
||||||
if (view === "mobile") {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow divide-y divide-gray-200">
|
|
||||||
{skeletonRows.map((_, index) => (
|
|
||||||
<div key={index} className="p-4 animate-pulse">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-start space-x-3">
|
|
||||||
<div className="p-2 rounded-full bg-gray-200 flex-shrink-0">
|
|
||||||
<div className="h-4 w-4 bg-gray-300 rounded"></div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0 space-y-2">
|
|
||||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
|
||||||
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
|
|
||||||
<div className="h-3 bg-gray-200 rounded w-1/3"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right ml-3 flex-shrink-0 space-y-2">
|
|
||||||
<div className="h-6 bg-gray-200 rounded w-20"></div>
|
|
||||||
<div className="h-4 bg-gray-200 rounded w-16 ml-auto"></div>
|
|
||||||
<div className="h-6 bg-gray-200 rounded w-12 ml-auto"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left">
|
|
||||||
<div className="h-4 bg-gray-200 rounded w-20 animate-pulse"></div>
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left">
|
|
||||||
<div className="h-4 bg-gray-200 rounded w-16 animate-pulse"></div>
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left">
|
|
||||||
<div className="h-4 bg-gray-200 rounded w-12 animate-pulse"></div>
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left">
|
|
||||||
<div className="h-4 bg-gray-200 rounded w-8 animate-pulse"></div>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{skeletonRows.map((_, index) => (
|
|
||||||
<tr key={index} className="animate-pulse">
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-start space-x-3">
|
|
||||||
<div className="p-2 rounded-full bg-gray-200 flex-shrink-0">
|
|
||||||
<div className="h-4 w-4 bg-gray-300 rounded"></div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
|
||||||
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="h-6 bg-gray-200 rounded w-24 ml-auto mb-1"></div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="h-4 bg-gray-200 rounded w-20"></div>
|
|
||||||
<div className="h-3 bg-gray-200 rounded w-16"></div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="h-6 bg-gray-200 rounded w-12"></div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,726 +0,0 @@
|
|||||||
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 { DataTablePagination } from "./ui/data-table-pagination";
|
|
||||||
import { Card, CardContent } from "./ui/card";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import type { Account, Transaction, ApiResponse, Balance } from "../types/api";
|
|
||||||
|
|
||||||
export default function TransactionsTable() {
|
|
||||||
// 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-foreground truncate">
|
|
||||||
{transaction.description}
|
|
||||||
</h4>
|
|
||||||
<div className="text-xs text-muted-foreground 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-foreground">
|
|
||||||
{formatCurrency(balance, transaction.transaction_currency)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
|
||||||
accessorKey: "transaction_date",
|
|
||||||
header: "Date",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const transaction = row.original;
|
|
||||||
return (
|
|
||||||
<div className="text-sm text-foreground">
|
|
||||||
{transaction.transaction_date
|
|
||||||
? formatDate(transaction.transaction_date)
|
|
||||||
: "No date"}
|
|
||||||
{transaction.booking_date &&
|
|
||||||
transaction.booking_date !== transaction.transaction_date && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
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-muted text-muted-foreground rounded hover:bg-accent 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 (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertTitle>Failed to load transactions</AlertTitle>
|
|
||||||
<AlertDescription className="space-y-3">
|
|
||||||
<p>Unable to fetch transactions from the Leggen API.</p>
|
|
||||||
<Button
|
|
||||||
onClick={() => refetchTransactions()}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="px-6 py-3 bg-muted/30 border-b border-border">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
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>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Responsive Table/Cards */}
|
|
||||||
<Card className="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-border">
|
|
||||||
<thead className="bg-muted/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-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted"
|
|
||||||
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-primary"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<ChevronDown
|
|
||||||
className={`h-3 w-3 -mt-1 ${
|
|
||||||
header.column.getIsSorted() === "desc"
|
|
||||||
? "text-primary"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-card divide-y divide-border">
|
|
||||||
{table.getRowModel().rows.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={columns.length}
|
|
||||||
className="px-6 py-12 text-center"
|
|
||||||
>
|
|
||||||
<div className="text-muted-foreground mb-4">
|
|
||||||
<TrendingUp className="h-12 w-12 mx-auto" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
|
||||||
No transactions found
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{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-muted/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-muted-foreground mb-4">
|
|
||||||
<TrendingUp className="h-12 w-12 mx-auto" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
|
||||||
No transactions found
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{hasActiveFilters
|
|
||||||
? "Try adjusting your filters to see more results."
|
|
||||||
: "No transactions are available for the selected criteria."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{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-muted/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-foreground break-words">
|
|
||||||
{transaction.description}
|
|
||||||
</h4>
|
|
||||||
<div className="text-xs text-muted-foreground 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-muted-foreground">
|
|
||||||
{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-muted-foreground 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-muted text-muted-foreground rounded hover:bg-accent transition-colors"
|
|
||||||
title="View raw transaction data"
|
|
||||||
>
|
|
||||||
<Eye className="h-3 w-3 mr-1" />
|
|
||||||
Raw
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{pagination && (
|
|
||||||
<DataTablePagination
|
|
||||||
currentPage={pagination.page}
|
|
||||||
totalPages={pagination.total_pages}
|
|
||||||
pageSize={pagination.per_page}
|
|
||||||
total={pagination.total}
|
|
||||||
hasNext={pagination.has_next}
|
|
||||||
hasPrev={pagination.has_prev}
|
|
||||||
onPageChange={setCurrentPage}
|
|
||||||
onPageSizeChange={setPerPage}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Raw Transaction Modal */}
|
|
||||||
<RawTransactionModal
|
|
||||||
isOpen={showRawModal}
|
|
||||||
onClose={handleCloseModal}
|
|
||||||
rawTransaction={selectedTransaction?.raw_transaction}
|
|
||||||
transactionId={selectedTransaction?.transaction_id || "unknown"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
import {
|
|
||||||
AreaChart,
|
|
||||||
Area,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Legend,
|
|
||||||
} from "recharts";
|
|
||||||
import type { Balance, Account } from "../../types/api";
|
|
||||||
|
|
||||||
interface BalanceChartProps {
|
|
||||||
data: Balance[];
|
|
||||||
accounts: Account[];
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChartDataPoint {
|
|
||||||
date: string;
|
|
||||||
balance: number;
|
|
||||||
account_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AggregatedDataPoint {
|
|
||||||
date: string;
|
|
||||||
[key: string]: string | number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TooltipProps {
|
|
||||||
active?: boolean;
|
|
||||||
payload?: Array<{
|
|
||||||
name: string;
|
|
||||||
value: number;
|
|
||||||
color: string;
|
|
||||||
}>;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BalanceChart({
|
|
||||||
data,
|
|
||||||
accounts,
|
|
||||||
className,
|
|
||||||
}: BalanceChartProps) {
|
|
||||||
// Create a lookup map for account info
|
|
||||||
const accountMap = accounts.reduce(
|
|
||||||
(map, account) => {
|
|
||||||
map[account.id] = account;
|
|
||||||
return map;
|
|
||||||
},
|
|
||||||
{} as Record<string, Account>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Helper function to get bank name from institution_id
|
|
||||||
const getBankName = (institutionId: string): string => {
|
|
||||||
const bankMapping: Record<string, string> = {
|
|
||||||
REVOLUT_REVOLT21: "Revolut",
|
|
||||||
NUBANK_NUPBBR25: "Nu Pagamentos",
|
|
||||||
BANCOBPI_BBPIPTPL: "Banco BPI",
|
|
||||||
// Add more mappings as needed
|
|
||||||
};
|
|
||||||
return bankMapping[institutionId] || institutionId.split("_")[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to create display name for account
|
|
||||||
const getAccountDisplayName = (accountId: string): string => {
|
|
||||||
const account = accountMap[accountId];
|
|
||||||
if (account) {
|
|
||||||
const bankName = getBankName(account.institution_id);
|
|
||||||
const accountName = account.name || `Account ${accountId.split("-")[1]}`;
|
|
||||||
return `${bankName} - ${accountName}`;
|
|
||||||
}
|
|
||||||
return `Account ${accountId.split("-")[1]}`;
|
|
||||||
};
|
|
||||||
// Process balance data for the chart
|
|
||||||
const chartData = data
|
|
||||||
.filter((balance) => balance.balance_type === "closingBooked")
|
|
||||||
.map((balance) => ({
|
|
||||||
date: new Date(balance.reference_date).toLocaleDateString("en-GB"), // DD/MM/YYYY format
|
|
||||||
balance: balance.balance_amount,
|
|
||||||
account_id: balance.account_id,
|
|
||||||
}))
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(a.date.split("/").reverse().join("/")).getTime() -
|
|
||||||
new Date(b.date.split("/").reverse().join("/")).getTime(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Group by account and aggregate
|
|
||||||
const accountBalances: { [key: string]: ChartDataPoint[] } = {};
|
|
||||||
chartData.forEach((item) => {
|
|
||||||
if (!accountBalances[item.account_id]) {
|
|
||||||
accountBalances[item.account_id] = [];
|
|
||||||
}
|
|
||||||
accountBalances[item.account_id].push(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create aggregated data points
|
|
||||||
const aggregatedData: { [key: string]: AggregatedDataPoint } = {};
|
|
||||||
Object.entries(accountBalances).forEach(([accountId, balances]) => {
|
|
||||||
balances.forEach((balance) => {
|
|
||||||
if (!aggregatedData[balance.date]) {
|
|
||||||
aggregatedData[balance.date] = { date: balance.date };
|
|
||||||
}
|
|
||||||
aggregatedData[balance.date][accountId] = balance.balance;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const finalData = Object.values(aggregatedData).sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(a.date.split("/").reverse().join("/")).getTime() -
|
|
||||||
new Date(b.date.split("/").reverse().join("/")).getTime(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
|
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
return (
|
|
||||||
<div className="bg-card p-3 border rounded shadow-lg">
|
|
||||||
<p className="font-medium text-foreground">Date: {label}</p>
|
|
||||||
{payload.map((entry, index) => (
|
|
||||||
<p key={index} style={{ color: entry.color }}>
|
|
||||||
{getAccountDisplayName(entry.name)}: €
|
|
||||||
{entry.value.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (finalData.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<h3 className="text-lg font-medium text-foreground mb-4">
|
|
||||||
Balance Progress
|
|
||||||
</h3>
|
|
||||||
<div className="h-80 flex items-center justify-center text-muted-foreground">
|
|
||||||
No balance data available
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<h3 className="text-lg font-medium text-foreground mb-4">
|
|
||||||
Balance Progress Over Time
|
|
||||||
</h3>
|
|
||||||
<div className="h-80">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart data={finalData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="date"
|
|
||||||
tick={{ fontSize: 12 }}
|
|
||||||
tickFormatter={(value) => {
|
|
||||||
// Convert DD/MM/YYYY back to a proper date for formatting
|
|
||||||
const [day, month, year] = value.split("/");
|
|
||||||
const date = new Date(year, month - 1, day);
|
|
||||||
return date.toLocaleDateString("en-GB", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tick={{ fontSize: 12 }}
|
|
||||||
tickFormatter={(value) => `€${value.toLocaleString()}`}
|
|
||||||
/>
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
<Legend />
|
|
||||||
{Object.keys(accountBalances).map((accountId, index) => (
|
|
||||||
<Area
|
|
||||||
key={accountId}
|
|
||||||
type="monotone"
|
|
||||||
dataKey={accountId}
|
|
||||||
stackId="1"
|
|
||||||
fill={colors[index % colors.length]}
|
|
||||||
stroke={colors[index % colors.length]}
|
|
||||||
name={getAccountDisplayName(accountId)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
import {
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
} from "recharts";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import apiClient from "../../lib/api";
|
|
||||||
|
|
||||||
interface MonthlyTrendsProps {
|
|
||||||
className?: string;
|
|
||||||
days?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TooltipProps {
|
|
||||||
active?: boolean;
|
|
||||||
payload?: Array<{
|
|
||||||
name: string;
|
|
||||||
value: number;
|
|
||||||
color: string;
|
|
||||||
}>;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MonthlyTrends({
|
|
||||||
className,
|
|
||||||
days = 365,
|
|
||||||
}: MonthlyTrendsProps) {
|
|
||||||
// Get pre-calculated monthly stats from the new endpoint
|
|
||||||
const { data: monthlyData, isLoading } = useQuery({
|
|
||||||
queryKey: ["monthly-stats", days],
|
|
||||||
queryFn: async () => {
|
|
||||||
return await apiClient.getMonthlyTransactionStats(days);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate number of months to display based on days filter
|
|
||||||
const getMonthsToDisplay = (days: number): number => {
|
|
||||||
if (days <= 30) return 1;
|
|
||||||
if (days <= 180) return 6;
|
|
||||||
if (days <= 365) return 12;
|
|
||||||
return Math.ceil(days / 30);
|
|
||||||
};
|
|
||||||
|
|
||||||
const monthsToDisplay = getMonthsToDisplay(days);
|
|
||||||
const displayData = monthlyData ? monthlyData.slice(-monthsToDisplay) : [];
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<h3 className="text-lg font-medium text-foreground mb-4">
|
|
||||||
Monthly Spending Trends
|
|
||||||
</h3>
|
|
||||||
<div className="h-80 flex items-center justify-center">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (displayData.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<h3 className="text-lg font-medium text-foreground mb-4">
|
|
||||||
Monthly Spending Trends
|
|
||||||
</h3>
|
|
||||||
<div className="h-80 flex items-center justify-center text-muted-foreground">
|
|
||||||
No transaction data available
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
return (
|
|
||||||
<div className="bg-card p-3 border rounded shadow-lg">
|
|
||||||
<p className="font-medium text-foreground">{label}</p>
|
|
||||||
{payload.map((entry, index) => (
|
|
||||||
<p key={index} style={{ color: entry.color }}>
|
|
||||||
{entry.name}: €{Math.abs(entry.value).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generate dynamic title based on time period
|
|
||||||
const getTitle = (days: number): string => {
|
|
||||||
if (days <= 30) return "Monthly Spending Trends (Last 30 Days)";
|
|
||||||
if (days <= 180) return "Monthly Spending Trends (Last 6 Months)";
|
|
||||||
if (days <= 365) return "Monthly Spending Trends (Last 12 Months)";
|
|
||||||
return `Monthly Spending Trends (Last ${Math.ceil(days / 30)} Months)`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<h3 className="text-lg font-medium text-foreground mb-4">
|
|
||||||
{getTitle(days)}
|
|
||||||
</h3>
|
|
||||||
<div className="h-80">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart
|
|
||||||
data={displayData}
|
|
||||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="month"
|
|
||||||
tick={{ fontSize: 12 }}
|
|
||||||
angle={-45}
|
|
||||||
textAnchor="end"
|
|
||||||
height={60}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tick={{ fontSize: 12 }}
|
|
||||||
tickFormatter={(value) => `€${value.toLocaleString()}`}
|
|
||||||
/>
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
<Bar dataKey="income" fill="#10B981" name="Income" />
|
|
||||||
<Bar dataKey="expenses" fill="#EF4444" name="Expenses" />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex justify-center space-x-6 text-sm text-foreground">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="w-3 h-3 bg-green-500 rounded mr-2" />
|
|
||||||
<span>Income</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="w-3 h-3 bg-red-500 rounded mr-2" />
|
|
||||||
<span>Expenses</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
import { Card, CardContent } from "../ui/card";
|
|
||||||
import { cn } from "../../lib/utils";
|
|
||||||
|
|
||||||
interface StatCardProps {
|
|
||||||
title: string;
|
|
||||||
value: string | number;
|
|
||||||
subtitle?: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
trend?: {
|
|
||||||
value: number;
|
|
||||||
isPositive: boolean;
|
|
||||||
};
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StatCard({
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
subtitle,
|
|
||||||
icon: Icon,
|
|
||||||
trend,
|
|
||||||
className,
|
|
||||||
}: StatCardProps) {
|
|
||||||
return (
|
|
||||||
<Card className={cn(className)}>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Icon className="h-8 w-8 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-5 w-0 flex-1">
|
|
||||||
<dl>
|
|
||||||
<dt className="text-sm font-medium text-muted-foreground truncate">
|
|
||||||
{title}
|
|
||||||
</dt>
|
|
||||||
<dd className="flex items-baseline">
|
|
||||||
<div className="text-2xl font-semibold text-foreground">
|
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
{trend && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"ml-2 flex items-baseline text-sm font-semibold",
|
|
||||||
trend.isPositive
|
|
||||||
? "text-green-600 dark:text-green-400"
|
|
||||||
: "text-red-600 dark:text-red-400",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{trend.isPositive ? "+" : ""}
|
|
||||||
{trend.value}%
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</dd>
|
|
||||||
{subtitle && (
|
|
||||||
<dd className="text-sm text-muted-foreground mt-1">
|
|
||||||
{subtitle}
|
|
||||||
</dd>
|
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { Calendar } from "lucide-react";
|
|
||||||
import { Button } from "../ui/button";
|
|
||||||
import type { TimePeriod } from "../../lib/timePeriods";
|
|
||||||
import { TIME_PERIODS } from "../../lib/timePeriods";
|
|
||||||
|
|
||||||
interface TimePeriodFilterProps {
|
|
||||||
selectedPeriod: TimePeriod;
|
|
||||||
onPeriodChange: (period: TimePeriod) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TimePeriodFilter({
|
|
||||||
selectedPeriod,
|
|
||||||
onPeriodChange,
|
|
||||||
className = "",
|
|
||||||
}: TimePeriodFilterProps) {
|
|
||||||
return (
|
|
||||||
<div className={`flex items-center gap-4 ${className}`}>
|
|
||||||
<div className="flex items-center gap-2 text-foreground">
|
|
||||||
<Calendar size={20} />
|
|
||||||
<span className="font-medium">Time Period:</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{TIME_PERIODS.map((period) => (
|
|
||||||
<Button
|
|
||||||
key={period.value}
|
|
||||||
onClick={() => onPeriodChange(period)}
|
|
||||||
variant={
|
|
||||||
selectedPeriod.value === period.value ? "default" : "outline"
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{period.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
import {
|
|
||||||
PieChart,
|
|
||||||
Pie,
|
|
||||||
Cell,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
} from "recharts";
|
|
||||||
import type { Account } from "../../types/api";
|
|
||||||
|
|
||||||
interface TransactionDistributionProps {
|
|
||||||
accounts: Account[];
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PieDataPoint {
|
|
||||||
name: string;
|
|
||||||
value: number;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TooltipProps {
|
|
||||||
active?: boolean;
|
|
||||||
payload?: Array<{
|
|
||||||
payload: PieDataPoint;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TransactionDistribution({
|
|
||||||
accounts,
|
|
||||||
className,
|
|
||||||
}: TransactionDistributionProps) {
|
|
||||||
// Helper function to get bank name from institution_id
|
|
||||||
const getBankName = (institutionId: string): string => {
|
|
||||||
const bankMapping: Record<string, string> = {
|
|
||||||
REVOLUT_REVOLT21: "Revolut",
|
|
||||||
NUBANK_NUPBBR25: "Nu Pagamentos",
|
|
||||||
BANCOBPI_BBPIPTPL: "Banco BPI",
|
|
||||||
// TODO: Add more bank mappings as needed
|
|
||||||
};
|
|
||||||
return bankMapping[institutionId] || institutionId.split("_")[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to create display name for account
|
|
||||||
const getAccountDisplayName = (account: Account): string => {
|
|
||||||
const bankName = getBankName(account.institution_id);
|
|
||||||
const accountName = account.name || `Account ${account.id.split("-")[1]}`;
|
|
||||||
return `${bankName} - ${accountName}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create pie chart data from account balances
|
|
||||||
const pieData: PieDataPoint[] = accounts.map((account, index) => {
|
|
||||||
const primaryBalance = account.balances?.[0]?.amount || 0;
|
|
||||||
|
|
||||||
const colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: getAccountDisplayName(account),
|
|
||||||
value: primaryBalance,
|
|
||||||
color: colors[index % colors.length],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalBalance = pieData.reduce((sum, item) => sum + item.value, 0);
|
|
||||||
|
|
||||||
if (pieData.length === 0 || totalBalance === 0) {
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<h3 className="text-lg font-medium text-foreground mb-4">
|
|
||||||
Account Distribution
|
|
||||||
</h3>
|
|
||||||
<div className="h-80 flex items-center justify-center text-muted-foreground">
|
|
||||||
No account data available
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload }: TooltipProps) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
const data = payload[0].payload;
|
|
||||||
const percentage = ((data.value / totalBalance) * 100).toFixed(1);
|
|
||||||
return (
|
|
||||||
<div className="bg-card p-3 border rounded shadow-lg">
|
|
||||||
<p className="font-medium text-foreground">{data.name}</p>
|
|
||||||
<p className="text-primary">
|
|
||||||
Balance: €{data.value.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground">{percentage}% of total</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<h3 className="text-lg font-medium text-foreground mb-4">
|
|
||||||
Account Balance Distribution
|
|
||||||
</h3>
|
|
||||||
<div className="h-80">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={pieData}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
outerRadius={100}
|
|
||||||
innerRadius={40}
|
|
||||||
paddingAngle={2}
|
|
||||||
dataKey="value"
|
|
||||||
>
|
|
||||||
{pieData.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
<Legend
|
|
||||||
formatter={(value, entry: { color?: string }) => (
|
|
||||||
<span style={{ color: entry.color }}>{value}</span>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-2">
|
|
||||||
{pieData.map((item, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="flex items-center justify-between text-sm"
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div
|
|
||||||
className="w-3 h-3 rounded-full mr-2"
|
|
||||||
style={{ backgroundColor: item.color }}
|
|
||||||
/>
|
|
||||||
<span className="text-foreground">{item.name}</span>
|
|
||||||
</div>
|
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
€{item.value.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
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.display_name || 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.display_name || 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.display_name || 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import { Search, X } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { DateRangePicker } from "./DateRangePicker";
|
|
||||||
import { AccountCombobox } from "./AccountCombobox";
|
|
||||||
import { ActiveFilterChips } from "./ActiveFilterChips";
|
|
||||||
import { AdvancedFiltersPopover } from "./AdvancedFiltersPopover";
|
|
||||||
import type { Account } from "../../types/api";
|
|
||||||
|
|
||||||
export interface FilterState {
|
|
||||||
searchTerm: string;
|
|
||||||
selectedAccount: string;
|
|
||||||
startDate: string;
|
|
||||||
endDate: string;
|
|
||||||
minAmount: string;
|
|
||||||
maxAmount: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FilterBarProps {
|
|
||||||
filterState: FilterState;
|
|
||||||
onFilterChange: (key: keyof FilterState, value: string) => void;
|
|
||||||
onClearFilters: () => void;
|
|
||||||
accounts?: Account[];
|
|
||||||
isSearchLoading?: boolean;
|
|
||||||
showRunningBalance: boolean;
|
|
||||||
onToggleRunningBalance: () => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FilterBar({
|
|
||||||
filterState,
|
|
||||||
onFilterChange,
|
|
||||||
onClearFilters,
|
|
||||||
accounts,
|
|
||||||
isSearchLoading = false,
|
|
||||||
showRunningBalance,
|
|
||||||
onToggleRunningBalance,
|
|
||||||
className,
|
|
||||||
}: FilterBarProps) {
|
|
||||||
const hasActiveFilters =
|
|
||||||
filterState.searchTerm ||
|
|
||||||
filterState.selectedAccount ||
|
|
||||||
filterState.startDate ||
|
|
||||||
filterState.endDate ||
|
|
||||||
filterState.minAmount ||
|
|
||||||
filterState.maxAmount;
|
|
||||||
|
|
||||||
const handleDateRangeChange = (startDate: string, endDate: string) => {
|
|
||||||
onFilterChange("startDate", startDate);
|
|
||||||
onFilterChange("endDate", endDate);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("bg-card rounded-lg shadow border", className)}>
|
|
||||||
{/* Main Filter Bar */}
|
|
||||||
<div className="px-6 py-4">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-lg font-semibold text-card-foreground">
|
|
||||||
Transactions
|
|
||||||
</h3>
|
|
||||||
<Button
|
|
||||||
onClick={onToggleRunningBalance}
|
|
||||||
variant={showRunningBalance ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Balance
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Primary Filters Row */}
|
|
||||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
|
||||||
{/* Search Input */}
|
|
||||||
<div className="relative flex-1 min-w-[240px]">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search transactions..."
|
|
||||||
value={filterState.searchTerm}
|
|
||||||
onChange={(e) => onFilterChange("searchTerm", e.target.value)}
|
|
||||||
className="pl-9 pr-8 bg-background"
|
|
||||||
/>
|
|
||||||
{isSearchLoading && (
|
|
||||||
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
|
||||||
<div className="animate-spin h-4 w-4 border-2 border-border border-t-primary rounded-full"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Account Selection */}
|
|
||||||
<AccountCombobox
|
|
||||||
accounts={accounts}
|
|
||||||
selectedAccount={filterState.selectedAccount}
|
|
||||||
onAccountChange={(accountId) =>
|
|
||||||
onFilterChange("selectedAccount", accountId)
|
|
||||||
}
|
|
||||||
className="w-[200px]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Date Range Picker */}
|
|
||||||
<DateRangePicker
|
|
||||||
startDate={filterState.startDate}
|
|
||||||
endDate={filterState.endDate}
|
|
||||||
onDateRangeChange={handleDateRangeChange}
|
|
||||||
className="w-[240px]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Advanced Filters Button */}
|
|
||||||
<AdvancedFiltersPopover
|
|
||||||
minAmount={filterState.minAmount}
|
|
||||||
maxAmount={filterState.maxAmount}
|
|
||||||
onMinAmountChange={(value) => onFilterChange("minAmount", value)}
|
|
||||||
onMaxAmountChange={(value) => onFilterChange("maxAmount", value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Clear Filters Button */}
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<Button
|
|
||||||
onClick={onClearFilters}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4 mr-1" />
|
|
||||||
Clear All
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active Filter Chips */}
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<ActiveFilterChips
|
|
||||||
filterState={filterState}
|
|
||||||
onFilterChange={onFilterChange}
|
|
||||||
accounts={accounts}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
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";
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const alertVariants = cva(
|
|
||||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-background text-foreground",
|
|
||||||
destructive:
|
|
||||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const Alert = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
|
||||||
>(({ className, variant, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
role="alert"
|
|
||||||
className={cn(alertVariants({ variant }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
Alert.displayName = "Alert";
|
|
||||||
|
|
||||||
const AlertTitle = React.forwardRef<
|
|
||||||
HTMLParagraphElement,
|
|
||||||
React.HTMLAttributes<HTMLHeadingElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<h5
|
|
||||||
ref={ref}
|
|
||||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
AlertTitle.displayName = "AlertTitle";
|
|
||||||
|
|
||||||
const AlertDescription = React.forwardRef<
|
|
||||||
HTMLParagraphElement,
|
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
AlertDescription.displayName = "AlertDescription";
|
|
||||||
|
|
||||||
export { Alert, AlertTitle, AlertDescription };
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const Card = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
Card.displayName = "Card";
|
|
||||||
|
|
||||||
const CardHeader = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
CardHeader.displayName = "CardHeader";
|
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<
|
|
||||||
HTMLParagraphElement,
|
|
||||||
React.HTMLAttributes<HTMLHeadingElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<h3
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"text-2xl font-semibold leading-none tracking-tight",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
CardTitle.displayName = "CardTitle";
|
|
||||||
|
|
||||||
const CardDescription = React.forwardRef<
|
|
||||||
HTMLParagraphElement,
|
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<p
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
CardDescription.displayName = "CardDescription";
|
|
||||||
|
|
||||||
const CardContent = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
|
||||||
));
|
|
||||||
CardContent.displayName = "CardContent";
|
|
||||||
|
|
||||||
const CardFooter = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn("flex items-center p-6 pt-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
CardFooter.displayName = "CardFooter";
|
|
||||||
|
|
||||||
export {
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardFooter,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
};
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
"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,
|
|
||||||
};
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import {
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
ChevronsLeft,
|
|
||||||
ChevronsRight,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
|
|
||||||
interface DataTablePaginationProps {
|
|
||||||
currentPage: number;
|
|
||||||
totalPages: number;
|
|
||||||
pageSize: number;
|
|
||||||
total: number;
|
|
||||||
hasNext: boolean;
|
|
||||||
hasPrev: boolean;
|
|
||||||
onPageChange: (page: number) => void;
|
|
||||||
onPageSizeChange: (pageSize: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataTablePagination({
|
|
||||||
currentPage,
|
|
||||||
totalPages,
|
|
||||||
pageSize,
|
|
||||||
total,
|
|
||||||
hasNext,
|
|
||||||
hasPrev,
|
|
||||||
onPageChange,
|
|
||||||
onPageSizeChange,
|
|
||||||
}: DataTablePaginationProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between px-2 py-4">
|
|
||||||
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
|
||||||
<div className="flex items-center space-x-6 lg:space-x-8">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<p className="text-sm font-medium text-foreground">Rows per page</p>
|
|
||||||
<Select
|
|
||||||
value={`${pageSize}`}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
onPageSizeChange(Number(value));
|
|
||||||
onPageChange(1); // Reset to first page when changing page size
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-[70px]">
|
|
||||||
<SelectValue placeholder={pageSize} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent side="top">
|
|
||||||
{[10, 25, 50, 100].map((pageSize) => (
|
|
||||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
|
||||||
{pageSize}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-[100px] items-center justify-center text-sm font-medium text-foreground">
|
|
||||||
Page {currentPage} of {totalPages}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="hidden h-8 w-8 p-0 lg:flex"
|
|
||||||
onClick={() => onPageChange(1)}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
>
|
|
||||||
<span className="sr-only">Go to first page</span>
|
|
||||||
<ChevronsLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
onClick={() => onPageChange(currentPage - 1)}
|
|
||||||
disabled={!hasPrev}
|
|
||||||
>
|
|
||||||
<span className="sr-only">Go to previous page</span>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
onClick={() => onPageChange(currentPage + 1)}
|
|
||||||
disabled={!hasNext}
|
|
||||||
>
|
|
||||||
<span className="sr-only">Go to next page</span>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="hidden h-8 w-8 p-0 lg:flex"
|
|
||||||
onClick={() => onPageChange(totalPages)}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
>
|
|
||||||
<span className="sr-only">Go to last page</span>
|
|
||||||
<ChevronsRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Showing {(currentPage - 1) * pageSize + 1} to{" "}
|
|
||||||
{Math.min(currentPage * pageSize, total)} of {total} entries
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile view */}
|
|
||||||
<div className="flex w-full items-center justify-between space-x-4 sm:hidden">
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Page {currentPage} of {totalPages}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onPageChange(currentPage - 1)}
|
|
||||||
disabled={!hasPrev}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onPageChange(currentPage + 1)}
|
|
||||||
disabled={!hasNext}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const Label = React.forwardRef<
|
|
||||||
HTMLLabelElement,
|
|
||||||
React.LabelHTMLAttributes<HTMLLabelElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<label
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
Label.displayName = "Label";
|
|
||||||
|
|
||||||
export { Label };
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
|
||||||
import type { ButtonProps } from "@/components/ui/button";
|
|
||||||
|
|
||||||
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
|
||||||
<nav
|
|
||||||
role="navigation"
|
|
||||||
aria-label="pagination"
|
|
||||||
className={cn("mx-auto flex w-full justify-center", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
Pagination.displayName = "Pagination";
|
|
||||||
|
|
||||||
const PaginationContent = React.forwardRef<
|
|
||||||
HTMLUListElement,
|
|
||||||
React.ComponentProps<"ul">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ul
|
|
||||||
ref={ref}
|
|
||||||
className={cn("flex flex-row items-center gap-1", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
PaginationContent.displayName = "PaginationContent";
|
|
||||||
|
|
||||||
const PaginationItem = React.forwardRef<
|
|
||||||
HTMLLIElement,
|
|
||||||
React.ComponentProps<"li">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<li ref={ref} className={cn("", className)} {...props} />
|
|
||||||
));
|
|
||||||
PaginationItem.displayName = "PaginationItem";
|
|
||||||
|
|
||||||
type PaginationLinkProps = {
|
|
||||||
isActive?: boolean;
|
|
||||||
} & Pick<ButtonProps, "size"> &
|
|
||||||
React.ComponentProps<"a">;
|
|
||||||
|
|
||||||
const PaginationLink = ({
|
|
||||||
className,
|
|
||||||
isActive,
|
|
||||||
size = "icon",
|
|
||||||
...props
|
|
||||||
}: PaginationLinkProps) => (
|
|
||||||
<a
|
|
||||||
aria-current={isActive ? "page" : undefined}
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({
|
|
||||||
variant: isActive ? "outline" : "ghost",
|
|
||||||
size,
|
|
||||||
}),
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
PaginationLink.displayName = "PaginationLink";
|
|
||||||
|
|
||||||
const PaginationPrevious = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
|
||||||
<PaginationLink
|
|
||||||
aria-label="Go to previous page"
|
|
||||||
size="default"
|
|
||||||
className={cn("gap-1 pl-2.5", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
<span>Previous</span>
|
|
||||||
</PaginationLink>
|
|
||||||
);
|
|
||||||
PaginationPrevious.displayName = "PaginationPrevious";
|
|
||||||
|
|
||||||
const PaginationNext = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
|
||||||
<PaginationLink
|
|
||||||
aria-label="Go to next page"
|
|
||||||
size="default"
|
|
||||||
className={cn("gap-1 pr-2.5", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span>Next</span>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</PaginationLink>
|
|
||||||
);
|
|
||||||
PaginationNext.displayName = "PaginationNext";
|
|
||||||
|
|
||||||
const PaginationEllipsis = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) => (
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
<span className="sr-only">More pages</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
PaginationEllipsis.displayName = "PaginationEllipsis";
|
|
||||||
|
|
||||||
export {
|
|
||||||
Pagination,
|
|
||||||
PaginationContent,
|
|
||||||
PaginationEllipsis,
|
|
||||||
PaginationItem,
|
|
||||||
PaginationLink,
|
|
||||||
PaginationNext,
|
|
||||||
PaginationPrevious,
|
|
||||||
};
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const Table = React.forwardRef<
|
|
||||||
HTMLTableElement,
|
|
||||||
React.HTMLAttributes<HTMLTableElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div className="relative w-full overflow-auto">
|
|
||||||
<table
|
|
||||||
ref={ref}
|
|
||||||
className={cn("w-full caption-bottom text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
));
|
|
||||||
Table.displayName = "Table";
|
|
||||||
|
|
||||||
const TableHeader = React.forwardRef<
|
|
||||||
HTMLTableSectionElement,
|
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
|
||||||
));
|
|
||||||
TableHeader.displayName = "TableHeader";
|
|
||||||
|
|
||||||
const TableBody = React.forwardRef<
|
|
||||||
HTMLTableSectionElement,
|
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<tbody
|
|
||||||
ref={ref}
|
|
||||||
className={cn("[&_tr:last-child]:border-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
TableBody.displayName = "TableBody";
|
|
||||||
|
|
||||||
const TableFooter = React.forwardRef<
|
|
||||||
HTMLTableSectionElement,
|
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<tfoot
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
TableFooter.displayName = "TableFooter";
|
|
||||||
|
|
||||||
const TableRow = React.forwardRef<
|
|
||||||
HTMLTableRowElement,
|
|
||||||
React.HTMLAttributes<HTMLTableRowElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<tr
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
TableRow.displayName = "TableRow";
|
|
||||||
|
|
||||||
const TableHead = React.forwardRef<
|
|
||||||
HTMLTableCellElement,
|
|
||||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<th
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
TableHead.displayName = "TableHead";
|
|
||||||
|
|
||||||
const TableCell = React.forwardRef<
|
|
||||||
HTMLTableCellElement,
|
|
||||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<td
|
|
||||||
ref={ref}
|
|
||||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
TableCell.displayName = "TableCell";
|
|
||||||
|
|
||||||
const TableCaption = React.forwardRef<
|
|
||||||
HTMLTableCaptionElement,
|
|
||||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<caption
|
|
||||||
ref={ref}
|
|
||||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
TableCaption.displayName = "TableCaption";
|
|
||||||
|
|
||||||
export {
|
|
||||||
Table,
|
|
||||||
TableHeader,
|
|
||||||
TableBody,
|
|
||||||
TableFooter,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TableCell,
|
|
||||||
TableCaption,
|
|
||||||
};
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { Monitor, Moon, Sun } from "lucide-react";
|
|
||||||
import { Button } from "./button";
|
|
||||||
import { useTheme } from "../../contexts/ThemeContext";
|
|
||||||
|
|
||||||
export function ThemeToggle() {
|
|
||||||
const { theme, setTheme } = useTheme();
|
|
||||||
|
|
||||||
const cycleTheme = () => {
|
|
||||||
if (theme === "light") {
|
|
||||||
setTheme("dark");
|
|
||||||
} else if (theme === "dark") {
|
|
||||||
setTheme("system");
|
|
||||||
} else {
|
|
||||||
setTheme("light");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIcon = () => {
|
|
||||||
switch (theme) {
|
|
||||||
case "light":
|
|
||||||
return <Sun className="h-4 w-4" />;
|
|
||||||
case "dark":
|
|
||||||
return <Moon className="h-4 w-4" />;
|
|
||||||
case "system":
|
|
||||||
return <Monitor className="h-4 w-4" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLabel = () => {
|
|
||||||
switch (theme) {
|
|
||||||
case "light":
|
|
||||||
return "Switch to dark mode";
|
|
||||||
case "dark":
|
|
||||||
return "Switch to system mode";
|
|
||||||
case "system":
|
|
||||||
return "Switch to light mode";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={cycleTheme}
|
|
||||||
className="h-8 w-8"
|
|
||||||
title={getLabel()}
|
|
||||||
>
|
|
||||||
{getIcon()}
|
|
||||||
<span className="sr-only">{getLabel()}</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
type Theme = "light" | "dark" | "system";
|
|
||||||
|
|
||||||
interface ThemeContextType {
|
|
||||||
theme: Theme;
|
|
||||||
setTheme: (theme: Theme) => void;
|
|
||||||
actualTheme: "light" | "dark";
|
|
||||||
}
|
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const [theme, setTheme] = useState<Theme>(() => {
|
|
||||||
const stored = localStorage.getItem("theme") as Theme;
|
|
||||||
return stored || "system";
|
|
||||||
});
|
|
||||||
|
|
||||||
const [actualTheme, setActualTheme] = useState<"light" | "dark">("light");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const root = window.document.documentElement;
|
|
||||||
|
|
||||||
const updateActualTheme = () => {
|
|
||||||
let resolvedTheme: "light" | "dark";
|
|
||||||
|
|
||||||
if (theme === "system") {
|
|
||||||
resolvedTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
|
||||||
.matches
|
|
||||||
? "dark"
|
|
||||||
: "light";
|
|
||||||
} else {
|
|
||||||
resolvedTheme = theme;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActualTheme(resolvedTheme);
|
|
||||||
|
|
||||||
// Remove previous theme classes
|
|
||||||
root.classList.remove("light", "dark");
|
|
||||||
|
|
||||||
// Add resolved theme class
|
|
||||||
root.classList.add(resolvedTheme);
|
|
||||||
};
|
|
||||||
|
|
||||||
updateActualTheme();
|
|
||||||
|
|
||||||
// Listen for system theme changes
|
|
||||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
||||||
const handleChange = () => {
|
|
||||||
if (theme === "system") {
|
|
||||||
updateActualTheme();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
mediaQuery.addEventListener("change", handleChange);
|
|
||||||
|
|
||||||
// Store theme preference
|
|
||||||
localStorage.setItem("theme", theme);
|
|
||||||
|
|
||||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeContext.Provider value={{ theme, setTheme, actualTheme }}>
|
|
||||||
{children}
|
|
||||||
</ThemeContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTheme() {
|
|
||||||
const context = useContext(ThemeContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error("useTheme must be used within a ThemeProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
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; display_name?: string }> => {
|
|
||||||
const response = await api.put<ApiResponse<{ id: string; display_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;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
export type TimePeriod = {
|
|
||||||
label: string;
|
|
||||||
days: number;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getDaysFromYearStart(): number {
|
|
||||||
const now = new Date();
|
|
||||||
const yearStart = new Date(now.getFullYear(), 0, 1);
|
|
||||||
const diffTime = now.getTime() - yearStart.getTime();
|
|
||||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TIME_PERIODS: TimePeriod[] = [
|
|
||||||
{ label: "Last 30 days", days: 30, value: "30d" },
|
|
||||||
{ label: "Last 6 months", days: 180, value: "6m" },
|
|
||||||
{ label: "Year to Date", days: getDaysFromYearStart(), value: "ytd" },
|
|
||||||
{ label: "Last 365 days", days: 365, value: "365d" },
|
|
||||||
];
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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 { ThemeProvider } from "./contexts/ThemeContext";
|
|
||||||
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}>
|
|
||||||
<ThemeProvider>
|
|
||||||
<RouterProvider router={router} />
|
|
||||||
</ThemeProvider>
|
|
||||||
</QueryClientProvider>
|
|
||||||
</StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
/* 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>()
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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-background">
|
|
||||||
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
|
|
||||||
|
|
||||||
{/* Mobile overlay */}
|
|
||||||
{sidebarOpen && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-40 bg-black/50 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,
|
|
||||||
});
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
CreditCard,
|
|
||||||
TrendingUp,
|
|
||||||
TrendingDown,
|
|
||||||
Activity,
|
|
||||||
Users,
|
|
||||||
} from "lucide-react";
|
|
||||||
import apiClient from "../lib/api";
|
|
||||||
import StatCard from "../components/analytics/StatCard";
|
|
||||||
import BalanceChart from "../components/analytics/BalanceChart";
|
|
||||||
import TransactionDistribution from "../components/analytics/TransactionDistribution";
|
|
||||||
import MonthlyTrends from "../components/analytics/MonthlyTrends";
|
|
||||||
import TimePeriodFilter from "../components/analytics/TimePeriodFilter";
|
|
||||||
import { Card, CardContent } from "../components/ui/card";
|
|
||||||
import type { TimePeriod } from "../lib/timePeriods";
|
|
||||||
import { TIME_PERIODS } from "../lib/timePeriods";
|
|
||||||
|
|
||||||
function AnalyticsDashboard() {
|
|
||||||
// Default to Last 365 days
|
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState<TimePeriod>(
|
|
||||||
TIME_PERIODS.find((p) => p.value === "365d") || TIME_PERIODS[3],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fetch analytics data
|
|
||||||
const { data: stats, isLoading: statsLoading } = useQuery({
|
|
||||||
queryKey: ["transaction-stats", selectedPeriod.days],
|
|
||||||
queryFn: () => apiClient.getTransactionStats(selectedPeriod.days),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: accounts, isLoading: accountsLoading } = useQuery({
|
|
||||||
queryKey: ["accounts"],
|
|
||||||
queryFn: () => apiClient.getAccounts(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: balances, isLoading: balancesLoading } = useQuery({
|
|
||||||
queryKey: ["historical-balances", selectedPeriod.days],
|
|
||||||
queryFn: () => apiClient.getHistoricalBalances(selectedPeriod.days),
|
|
||||||
});
|
|
||||||
|
|
||||||
const isLoading = statsLoading || accountsLoading || balancesLoading;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="animate-pulse">
|
|
||||||
<div className="h-8 bg-muted rounded w-48 mb-6"></div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
|
||||||
{[...Array(3)].map((_, i) => (
|
|
||||||
<div key={i} className="h-32 bg-muted rounded"></div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
<div className="h-96 bg-muted rounded"></div>
|
|
||||||
<div className="h-96 bg-muted rounded"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6 space-y-8">
|
|
||||||
{/* Time Period Filter */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<TimePeriodFilter
|
|
||||||
selectedPeriod={selectedPeriod}
|
|
||||||
onPeriodChange={setSelectedPeriod}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Stats Cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
<StatCard
|
|
||||||
title="Total Transactions"
|
|
||||||
value={stats?.total_transactions || 0}
|
|
||||||
subtitle={`Last ${stats?.period_days || 0} days`}
|
|
||||||
icon={Activity}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Total Income"
|
|
||||||
value={`€${(stats?.total_income || 0).toLocaleString()}`}
|
|
||||||
subtitle="Inflows this period"
|
|
||||||
icon={TrendingUp}
|
|
||||||
className="border-green-200"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Total Expenses"
|
|
||||||
value={`€${(stats?.total_expenses || 0).toLocaleString()}`}
|
|
||||||
subtitle="Outflows this period"
|
|
||||||
icon={TrendingDown}
|
|
||||||
className="border-red-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Additional Stats */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<StatCard
|
|
||||||
title="Net Change"
|
|
||||||
value={`€${(stats?.net_change || 0).toLocaleString()}`}
|
|
||||||
subtitle="Income minus expenses"
|
|
||||||
icon={CreditCard}
|
|
||||||
className={
|
|
||||||
(stats?.net_change || 0) >= 0
|
|
||||||
? "border-green-200"
|
|
||||||
: "border-red-200"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Average Transaction"
|
|
||||||
value={`€${Math.abs(stats?.average_transaction || 0).toLocaleString()}`}
|
|
||||||
subtitle="Per transaction"
|
|
||||||
icon={Activity}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Active Accounts"
|
|
||||||
value={stats?.accounts_included || 0}
|
|
||||||
subtitle="With recent activity"
|
|
||||||
icon={Users}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Charts */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<BalanceChart data={balances || []} accounts={accounts || []} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<TransactionDistribution accounts={accounts || []} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Monthly Trends */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<MonthlyTrends days={selectedPeriod.days} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/analytics")({
|
|
||||||
component: AnalyticsDashboard,
|
|
||||||
});
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import AccountsOverview from "../components/AccountsOverview";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
|
||||||
component: AccountsOverview,
|
|
||||||
});
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import Notifications from "../components/Notifications";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/notifications")({
|
|
||||||
component: Notifications,
|
|
||||||
});
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
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;
|
|
||||||
display_name?: string;
|
|
||||||
currency?: string;
|
|
||||||
created: string;
|
|
||||||
last_accessed?: string;
|
|
||||||
balances: AccountBalance[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AccountUpdate {
|
|
||||||
display_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
1
frontend/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
/** @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")],
|
|
||||||
};
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
],
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,359 +0,0 @@
|
|||||||
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"),
|
|
||||||
display_name=db_account.get("display_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"),
|
|
||||||
display_name=db_account.get("display_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 display_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.display_name is not None:
|
|
||||||
updated_account_data["display_name"] = update_data.display_name
|
|
||||||
|
|
||||||
# Persist updated account details
|
|
||||||
await database_service.persist_account_details(updated_account_data)
|
|
||||||
|
|
||||||
return APIResponse(
|
|
||||||
success=True,
|
|
||||||
data={"id": account_id, "display_name": update_data.display_name},
|
|
||||||
message=f"Account {account_id} display 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
|
|
||||||
@@ -1,21 +1,20 @@
|
|||||||
import os
|
import os
|
||||||
from typing import Any, Dict, List, Optional, Union
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from typing import Dict, Any, Optional, List, Union
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
from leggen.utils.text import error
|
from leggen.utils.text import error
|
||||||
|
|
||||||
|
|
||||||
class LeggenAPIClient:
|
class LeggendAPIClient:
|
||||||
"""Client for communicating with the leggen FastAPI service"""
|
"""Client for communicating with the leggend FastAPI service"""
|
||||||
|
|
||||||
base_url: str
|
base_url: str
|
||||||
|
|
||||||
def __init__(self, base_url: Optional[str] = None):
|
def __init__(self, base_url: Optional[str] = None):
|
||||||
self.base_url = (
|
self.base_url = (
|
||||||
base_url
|
base_url
|
||||||
or os.environ.get("LEGGEN_API_URL", "http://localhost:8000")
|
or os.environ.get("LEGGEND_API_URL", "http://localhost:8000")
|
||||||
or "http://localhost:8000"
|
or "http://localhost:8000"
|
||||||
)
|
)
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
@@ -32,7 +31,7 @@ class LeggenAPIClient:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
except requests.exceptions.ConnectionError:
|
except requests.exceptions.ConnectionError:
|
||||||
error("Could not connect to leggen server. Is it running?")
|
error("Could not connect to leggend service. Is it running?")
|
||||||
error(f"Trying to connect to: {self.base_url}")
|
error(f"Trying to connect to: {self.base_url}")
|
||||||
raise
|
raise
|
||||||
except requests.exceptions.HTTPError as e:
|
except requests.exceptions.HTTPError as e:
|
||||||
@@ -49,7 +48,7 @@ class LeggenAPIClient:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
def health_check(self) -> bool:
|
def health_check(self) -> bool:
|
||||||
"""Check if the leggen server is healthy"""
|
"""Check if the leggend service is healthy"""
|
||||||
try:
|
try:
|
||||||
response = self._make_request("GET", "/health")
|
response = self._make_request("GET", "/health")
|
||||||
return response.get("status") == "healthy"
|
return response.get("status") == "healthy"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
from leggen.api_client import LeggenAPIClient
|
|
||||||
from leggen.main import cli
|
from leggen.main import cli
|
||||||
|
from leggen.api_client import LeggendAPIClient
|
||||||
from leggen.utils.text import datefmt, print_table
|
from leggen.utils.text import datefmt, print_table
|
||||||
|
|
||||||
|
|
||||||
@@ -11,12 +11,12 @@ def balances(ctx: click.Context):
|
|||||||
"""
|
"""
|
||||||
List balances of all connected accounts
|
List balances of all connected accounts
|
||||||
"""
|
"""
|
||||||
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
|
||||||
|
|
||||||
# Check if leggen server is available
|
# Check if leggend service is available
|
||||||
if not api_client.health_check():
|
if not api_client.health_check():
|
||||||
click.echo(
|
click.echo(
|
||||||
"Error: Cannot connect to leggen server. Please ensure it's running."
|
"Error: Cannot connect to leggend service. Please ensure it's running."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
from leggen.api_client import LeggenAPIClient
|
|
||||||
from leggen.main import cli
|
from leggen.main import cli
|
||||||
|
from leggen.api_client import LeggendAPIClient
|
||||||
from leggen.utils.disk import save_file
|
from leggen.utils.disk import save_file
|
||||||
from leggen.utils.text import info, print_table, success, warning
|
from leggen.utils.text import info, print_table, warning, success
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@@ -12,12 +12,12 @@ def add(ctx):
|
|||||||
"""
|
"""
|
||||||
Connect to a bank
|
Connect to a bank
|
||||||
"""
|
"""
|
||||||
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
|
||||||
|
|
||||||
# Check if leggen server is available
|
# Check if leggend service is available
|
||||||
if not api_client.health_check():
|
if not api_client.health_check():
|
||||||
click.echo(
|
click.echo(
|
||||||
"Error: Cannot connect to leggen server. Please ensure it's running."
|
"Error: Cannot connect to leggend service. Please ensure it's running."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
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,7 +1,7 @@
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
from leggen.api_client import LeggenAPIClient
|
|
||||||
from leggen.main import cli
|
from leggen.main import cli
|
||||||
|
from leggen.api_client import LeggendAPIClient
|
||||||
from leggen.utils.text import datefmt, echo, info, print_table
|
from leggen.utils.text import datefmt, echo, info, print_table
|
||||||
|
|
||||||
|
|
||||||
@@ -11,12 +11,12 @@ def status(ctx: click.Context):
|
|||||||
"""
|
"""
|
||||||
List all connected banks and their status
|
List all connected banks and their status
|
||||||
"""
|
"""
|
||||||
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
|
||||||
|
|
||||||
# Check if leggen server is available
|
# Check if leggend service is available
|
||||||
if not api_client.health_check():
|
if not api_client.health_check():
|
||||||
click.echo(
|
click.echo(
|
||||||
"Error: Cannot connect to leggen server. Please ensure it's running."
|
"Error: Cannot connect to leggend service. Please ensure it's running."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
from leggen.api_client import LeggenAPIClient
|
|
||||||
from leggen.main import cli
|
from leggen.main import cli
|
||||||
|
from leggen.api_client import LeggendAPIClient
|
||||||
from leggen.utils.text import error, info, success
|
from leggen.utils.text import error, info, success
|
||||||
|
|
||||||
|
|
||||||
@@ -13,11 +13,11 @@ def sync(ctx: click.Context, wait: bool, force: bool):
|
|||||||
"""
|
"""
|
||||||
Sync all transactions with database
|
Sync all transactions with database
|
||||||
"""
|
"""
|
||||||
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
|
||||||
|
|
||||||
# Check if leggen server is available
|
# Check if leggend service is available
|
||||||
if not api_client.health_check():
|
if not api_client.health_check():
|
||||||
error("Cannot connect to leggen server. Please ensure it's running.")
|
error("Cannot connect to leggend service. Please ensure it's running.")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
from leggen.api_client import LeggenAPIClient
|
|
||||||
from leggen.main import cli
|
from leggen.main import cli
|
||||||
|
from leggen.api_client import LeggendAPIClient
|
||||||
from leggen.utils.text import datefmt, info, print_table
|
from leggen.utils.text import datefmt, info, print_table
|
||||||
|
|
||||||
|
|
||||||
@@ -20,12 +20,12 @@ def transactions(ctx: click.Context, account: str, limit: int, full: bool):
|
|||||||
|
|
||||||
If the --account option is used, it will only list transactions for that account.
|
If the --account option is used, it will only list transactions for that account.
|
||||||
"""
|
"""
|
||||||
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
|
||||||
|
|
||||||
# Check if leggen server is available
|
# Check if leggend service is available
|
||||||
if not api_client.health_check():
|
if not api_client.health_check():
|
||||||
click.echo(
|
click.echo(
|
||||||
"Error: Cannot connect to leggen server. Please ensure it's running."
|
"Error: Cannot connect to leggend service. Please ensure it's running."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
383
leggen/database/sqlite.py
Normal file
383
leggen/database/sqlite.py
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from sqlite3 import IntegrityError
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from leggen.utils.text import success, warning
|
||||||
|
|
||||||
|
|
||||||
|
def persist_balances(ctx: click.Context, balance: dict):
|
||||||
|
# Connect to SQLite database
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Create the balances table if it doesn't exist
|
||||||
|
cursor.execute(
|
||||||
|
"""CREATE TABLE IF NOT EXISTS balances (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
account_id TEXT,
|
||||||
|
bank TEXT,
|
||||||
|
status TEXT,
|
||||||
|
iban TEXT,
|
||||||
|
amount REAL,
|
||||||
|
currency TEXT,
|
||||||
|
type TEXT,
|
||||||
|
timestamp DATETIME
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for better performance
|
||||||
|
cursor.execute(
|
||||||
|
"""CREATE INDEX IF NOT EXISTS idx_balances_account_id
|
||||||
|
ON balances(account_id)"""
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"""CREATE INDEX IF NOT EXISTS idx_balances_timestamp
|
||||||
|
ON balances(timestamp)"""
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"""CREATE INDEX IF NOT EXISTS idx_balances_account_type_timestamp
|
||||||
|
ON balances(account_id, type, timestamp)"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert balance into SQLite database
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
"""INSERT INTO balances (
|
||||||
|
account_id,
|
||||||
|
bank,
|
||||||
|
status,
|
||||||
|
iban,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
type,
|
||||||
|
timestamp
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
balance["account_id"],
|
||||||
|
balance["bank"],
|
||||||
|
balance["status"],
|
||||||
|
balance["iban"],
|
||||||
|
balance["amount"],
|
||||||
|
balance["currency"],
|
||||||
|
balance["type"],
|
||||||
|
balance["timestamp"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except IntegrityError:
|
||||||
|
warning(f"[{balance['account_id']}] Skipped duplicate balance")
|
||||||
|
|
||||||
|
# Commit changes and close the connection
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
success(f"[{balance['account_id']}] Inserted balance of type {balance['type']}")
|
||||||
|
|
||||||
|
return balance
|
||||||
|
|
||||||
|
|
||||||
|
def persist_transactions(ctx: click.Context, account: str, transactions: list) -> list:
|
||||||
|
# Connect to SQLite database
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Create the transactions table if it doesn't exist
|
||||||
|
cursor.execute(
|
||||||
|
"""CREATE TABLE IF NOT EXISTS transactions (
|
||||||
|
internalTransactionId TEXT PRIMARY KEY,
|
||||||
|
institutionId TEXT,
|
||||||
|
iban TEXT,
|
||||||
|
transactionDate DATETIME,
|
||||||
|
description TEXT,
|
||||||
|
transactionValue REAL,
|
||||||
|
transactionCurrency TEXT,
|
||||||
|
transactionStatus TEXT,
|
||||||
|
accountId TEXT,
|
||||||
|
rawTransaction JSON
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for better performance
|
||||||
|
cursor.execute(
|
||||||
|
"""CREATE INDEX IF NOT EXISTS idx_transactions_account_id
|
||||||
|
ON transactions(accountId)"""
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"""CREATE INDEX IF NOT EXISTS idx_transactions_date
|
||||||
|
ON transactions(transactionDate)"""
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"""CREATE INDEX IF NOT EXISTS idx_transactions_account_date
|
||||||
|
ON transactions(accountId, transactionDate)"""
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"""CREATE INDEX IF NOT EXISTS idx_transactions_amount
|
||||||
|
ON transactions(transactionValue)"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert transactions into SQLite database
|
||||||
|
duplicates_count = 0
|
||||||
|
|
||||||
|
# Prepare an SQL statement for inserting data
|
||||||
|
insert_sql = """INSERT INTO transactions (
|
||||||
|
internalTransactionId,
|
||||||
|
institutionId,
|
||||||
|
iban,
|
||||||
|
transactionDate,
|
||||||
|
description,
|
||||||
|
transactionValue,
|
||||||
|
transactionCurrency,
|
||||||
|
transactionStatus,
|
||||||
|
accountId,
|
||||||
|
rawTransaction
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"""
|
||||||
|
|
||||||
|
new_transactions = []
|
||||||
|
|
||||||
|
for transaction in transactions:
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
insert_sql,
|
||||||
|
(
|
||||||
|
transaction["internalTransactionId"],
|
||||||
|
transaction["institutionId"],
|
||||||
|
transaction["iban"],
|
||||||
|
transaction["transactionDate"],
|
||||||
|
transaction["description"],
|
||||||
|
transaction["transactionValue"],
|
||||||
|
transaction["transactionCurrency"],
|
||||||
|
transaction["transactionStatus"],
|
||||||
|
transaction["accountId"],
|
||||||
|
json.dumps(transaction["rawTransaction"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
new_transactions.append(transaction)
|
||||||
|
except IntegrityError:
|
||||||
|
# A transaction with the same ID already exists, indicating a duplicate
|
||||||
|
duplicates_count += 1
|
||||||
|
|
||||||
|
# Commit changes and close the connection
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
success(f"[{account}] Inserted {len(new_transactions)} new transactions")
|
||||||
|
if duplicates_count:
|
||||||
|
warning(f"[{account}] Skipped {duplicates_count} duplicate transactions")
|
||||||
|
|
||||||
|
return new_transactions
|
||||||
|
|
||||||
|
|
||||||
|
def get_transactions(
|
||||||
|
account_id=None,
|
||||||
|
limit=100,
|
||||||
|
offset=0,
|
||||||
|
date_from=None,
|
||||||
|
date_to=None,
|
||||||
|
min_amount=None,
|
||||||
|
max_amount=None,
|
||||||
|
search=None,
|
||||||
|
):
|
||||||
|
"""Get transactions from SQLite database with optional filtering"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
||||||
|
if not db_path.exists():
|
||||||
|
return []
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row # Enable dict-like access
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Build query with filters
|
||||||
|
query = "SELECT * FROM transactions WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if account_id:
|
||||||
|
query += " AND accountId = ?"
|
||||||
|
params.append(account_id)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
query += " AND transactionDate >= ?"
|
||||||
|
params.append(date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
query += " AND transactionDate <= ?"
|
||||||
|
params.append(date_to)
|
||||||
|
|
||||||
|
if min_amount is not None:
|
||||||
|
query += " AND transactionValue >= ?"
|
||||||
|
params.append(min_amount)
|
||||||
|
|
||||||
|
if max_amount is not None:
|
||||||
|
query += " AND transactionValue <= ?"
|
||||||
|
params.append(max_amount)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
query += " AND description LIKE ?"
|
||||||
|
params.append(f"%{search}%")
|
||||||
|
|
||||||
|
# Add ordering and pagination
|
||||||
|
query += " ORDER BY transactionDate DESC"
|
||||||
|
|
||||||
|
if limit:
|
||||||
|
query += " LIMIT ?"
|
||||||
|
params.append(limit)
|
||||||
|
|
||||||
|
if offset:
|
||||||
|
query += " OFFSET ?"
|
||||||
|
params.append(offset)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(query, params)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
# Convert to list of dicts and parse JSON fields
|
||||||
|
transactions = []
|
||||||
|
for row in rows:
|
||||||
|
transaction = dict(row)
|
||||||
|
if transaction["rawTransaction"]:
|
||||||
|
transaction["rawTransaction"] = json.loads(
|
||||||
|
transaction["rawTransaction"]
|
||||||
|
)
|
||||||
|
transactions.append(transaction)
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return transactions
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
|
||||||
|
def get_balances(account_id=None):
|
||||||
|
"""Get latest balances from SQLite database"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
||||||
|
if not db_path.exists():
|
||||||
|
return []
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Get latest balance for each account_id and type combination
|
||||||
|
query = """
|
||||||
|
SELECT * FROM balances b1
|
||||||
|
WHERE b1.timestamp = (
|
||||||
|
SELECT MAX(b2.timestamp)
|
||||||
|
FROM balances b2
|
||||||
|
WHERE b2.account_id = b1.account_id AND b2.type = b1.type
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if account_id:
|
||||||
|
query += " AND b1.account_id = ?"
|
||||||
|
params.append(account_id)
|
||||||
|
|
||||||
|
query += " ORDER BY b1.account_id, b1.type"
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(query, params)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
balances = [dict(row) for row in rows]
|
||||||
|
conn.close()
|
||||||
|
return balances
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
|
||||||
|
def get_account_summary(account_id):
|
||||||
|
"""Get basic account info from transactions table (avoids GoCardless API call)"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
||||||
|
if not db_path.exists():
|
||||||
|
return None
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get account info from most recent transaction
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT accountId, institutionId, iban
|
||||||
|
FROM transactions
|
||||||
|
WHERE accountId = ?
|
||||||
|
ORDER BY transactionDate DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(account_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
return dict(row)
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
|
||||||
|
def get_transaction_count(account_id=None, **filters):
|
||||||
|
"""Get total count of transactions matching filters"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
||||||
|
if not db_path.exists():
|
||||||
|
return 0
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
query = "SELECT COUNT(*) FROM transactions WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if account_id:
|
||||||
|
query += " AND accountId = ?"
|
||||||
|
params.append(account_id)
|
||||||
|
|
||||||
|
# Add same filters as get_transactions
|
||||||
|
if filters.get("date_from"):
|
||||||
|
query += " AND transactionDate >= ?"
|
||||||
|
params.append(filters["date_from"])
|
||||||
|
|
||||||
|
if filters.get("date_to"):
|
||||||
|
query += " AND transactionDate <= ?"
|
||||||
|
params.append(filters["date_to"])
|
||||||
|
|
||||||
|
if filters.get("min_amount") is not None:
|
||||||
|
query += " AND transactionValue >= ?"
|
||||||
|
params.append(filters["min_amount"])
|
||||||
|
|
||||||
|
if filters.get("max_amount") is not None:
|
||||||
|
query += " AND transactionValue <= ?"
|
||||||
|
params.append(filters["max_amount"])
|
||||||
|
|
||||||
|
if filters.get("search"):
|
||||||
|
query += " AND description LIKE ?"
|
||||||
|
params.append(f"%{filters['search']}%")
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(query, params)
|
||||||
|
count = cursor.fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
return count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise e
|
||||||
@@ -6,7 +6,6 @@ from pathlib import Path
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
from leggen.utils.config import load_config
|
from leggen.utils.config import load_config
|
||||||
from leggen.utils.paths import path_manager
|
|
||||||
from leggen.utils.text import error
|
from leggen.utils.text import error
|
||||||
|
|
||||||
cmd_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), "commands"))
|
cmd_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), "commands"))
|
||||||
@@ -78,7 +77,7 @@ class Group(click.Group):
|
|||||||
"-c",
|
"-c",
|
||||||
"--config",
|
"--config",
|
||||||
type=click.Path(dir_okay=False),
|
type=click.Path(dir_okay=False),
|
||||||
default=lambda: str(path_manager.get_config_file_path()),
|
default=Path.home() / ".config" / "leggen" / "config.toml",
|
||||||
show_default=True,
|
show_default=True,
|
||||||
callback=load_config,
|
callback=load_config,
|
||||||
is_eager=True,
|
is_eager=True,
|
||||||
@@ -87,27 +86,13 @@ class Group(click.Group):
|
|||||||
show_envvar=True,
|
show_envvar=True,
|
||||||
help="Path to TOML configuration file",
|
help="Path to TOML configuration file",
|
||||||
)
|
)
|
||||||
@click.option(
|
|
||||||
"--config-dir",
|
|
||||||
type=click.Path(exists=False, file_okay=False, path_type=Path),
|
|
||||||
envvar="LEGGEN_CONFIG_DIR",
|
|
||||||
show_envvar=True,
|
|
||||||
help="Directory containing configuration files (default: ~/.config/leggen)",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--database",
|
|
||||||
type=click.Path(dir_okay=False, path_type=Path),
|
|
||||||
envvar="LEGGEN_DATABASE_PATH",
|
|
||||||
show_envvar=True,
|
|
||||||
help="Path to SQLite database file (default: <config-dir>/leggen.db)",
|
|
||||||
)
|
|
||||||
@click.option(
|
@click.option(
|
||||||
"--api-url",
|
"--api-url",
|
||||||
type=str,
|
type=str,
|
||||||
default="http://localhost:8000",
|
default="http://localhost:8000",
|
||||||
envvar="LEGGEN_API_URL",
|
envvar="LEGGEND_API_URL",
|
||||||
show_envvar=True,
|
show_envvar=True,
|
||||||
help="URL of the leggen API service",
|
help="URL of the leggend API service",
|
||||||
)
|
)
|
||||||
@click.group(
|
@click.group(
|
||||||
cls=Group,
|
cls=Group,
|
||||||
@@ -115,7 +100,7 @@ class Group(click.Group):
|
|||||||
)
|
)
|
||||||
@click.version_option(package_name="leggen")
|
@click.version_option(package_name="leggen")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def cli(ctx: click.Context, config_dir: Path, database: Path, api_url: str):
|
def cli(ctx: click.Context, api_url: str):
|
||||||
"""
|
"""
|
||||||
Leggen: An Open Banking CLI
|
Leggen: An Open Banking CLI
|
||||||
"""
|
"""
|
||||||
@@ -124,11 +109,5 @@ def cli(ctx: click.Context, config_dir: Path, database: Path, api_url: str):
|
|||||||
if "--help" in sys.argv[1:] or "-h" in sys.argv[1:]:
|
if "--help" in sys.argv[1:] or "-h" in sys.argv[1:]:
|
||||||
return
|
return
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
# Store API URL in context for commands to use
|
# Store API URL in context for commands to use
|
||||||
ctx.obj["api_url"] = api_url
|
ctx.obj["api_url"] = api_url
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class GoCardlessConfig(BaseModel):
|
|
||||||
key: str = Field(..., description="GoCardless API key")
|
|
||||||
secret: str = Field(..., description="GoCardless API secret")
|
|
||||||
url: str = Field(
|
|
||||||
default="https://bankaccountdata.gocardless.com/api/v2",
|
|
||||||
description="GoCardless API URL",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DatabaseConfig(BaseModel):
|
|
||||||
sqlite: bool = Field(default=True, description="Enable SQLite database")
|
|
||||||
|
|
||||||
|
|
||||||
class DiscordNotificationConfig(BaseModel):
|
|
||||||
webhook: str = Field(..., description="Discord webhook URL")
|
|
||||||
enabled: bool = Field(default=True, description="Enable Discord notifications")
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramNotificationConfig(BaseModel):
|
|
||||||
token: str = Field(..., alias="api-key", description="Telegram bot token")
|
|
||||||
chat_id: int = Field(..., alias="chat-id", description="Telegram chat ID")
|
|
||||||
enabled: bool = Field(default=True, description="Enable Telegram notifications")
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationConfig(BaseModel):
|
|
||||||
discord: Optional[DiscordNotificationConfig] = None
|
|
||||||
telegram: Optional[TelegramNotificationConfig] = None
|
|
||||||
|
|
||||||
|
|
||||||
class FilterConfig(BaseModel):
|
|
||||||
case_insensitive: Optional[List[str]] = Field(
|
|
||||||
default_factory=list, alias="case-insensitive"
|
|
||||||
)
|
|
||||||
case_sensitive: Optional[List[str]] = Field(
|
|
||||||
default_factory=list, alias="case-sensitive"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SyncScheduleConfig(BaseModel):
|
|
||||||
enabled: bool = Field(default=True, description="Enable sync scheduling")
|
|
||||||
hour: int = Field(default=3, ge=0, le=23, description="Hour to run sync (0-23)")
|
|
||||||
minute: int = Field(default=0, ge=0, le=59, description="Minute to run sync (0-59)")
|
|
||||||
cron: Optional[str] = Field(
|
|
||||||
default=None, description="Custom cron expression (overrides hour/minute)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SchedulerConfig(BaseModel):
|
|
||||||
sync: SyncScheduleConfig = Field(default_factory=SyncScheduleConfig)
|
|
||||||
|
|
||||||
|
|
||||||
class Config(BaseModel):
|
|
||||||
gocardless: GoCardlessConfig
|
|
||||||
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
|
|
||||||
notifications: Optional[NotificationConfig] = None
|
|
||||||
filters: Optional[FilterConfig] = None
|
|
||||||
scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig)
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
validate_by_name = True
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,291 +0,0 @@
|
|||||||
from typing import Any, Dict, List
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from leggen.utils.config import config
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationService:
|
|
||||||
def __init__(self):
|
|
||||||
self.notifications_config = config.notifications_config
|
|
||||||
self.filters_config = config.filters_config
|
|
||||||
|
|
||||||
async def send_transaction_notifications(
|
|
||||||
self, transactions: List[Dict[str, Any]]
|
|
||||||
) -> None:
|
|
||||||
"""Send notifications for new transactions that match filters"""
|
|
||||||
if not self.filters_config:
|
|
||||||
logger.info("No notification filters configured, skipping notifications")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Filter transactions that match notification criteria
|
|
||||||
matching_transactions = self._filter_transactions(transactions)
|
|
||||||
|
|
||||||
if not matching_transactions:
|
|
||||||
logger.info("No transactions matched notification filters")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Send to enabled notification services
|
|
||||||
if self._is_discord_enabled():
|
|
||||||
await self._send_discord_notifications(matching_transactions)
|
|
||||||
|
|
||||||
if self._is_telegram_enabled():
|
|
||||||
await self._send_telegram_notifications(matching_transactions)
|
|
||||||
|
|
||||||
async def send_test_notification(self, service: str, message: str) -> bool:
|
|
||||||
"""Send a test notification"""
|
|
||||||
try:
|
|
||||||
if service == "discord" and self._is_discord_enabled():
|
|
||||||
await self._send_discord_test(message)
|
|
||||||
return True
|
|
||||||
elif service == "telegram" and self._is_telegram_enabled():
|
|
||||||
await self._send_telegram_test(message)
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logger.error(
|
|
||||||
f"Notification service '{service}' not enabled or not found"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to send test notification to {service}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def send_expiry_notification(self, notification_data: Dict[str, Any]) -> None:
|
|
||||||
"""Send notification about account expiry"""
|
|
||||||
if self._is_discord_enabled():
|
|
||||||
await self._send_discord_expiry(notification_data)
|
|
||||||
|
|
||||||
if self._is_telegram_enabled():
|
|
||||||
await self._send_telegram_expiry(notification_data)
|
|
||||||
|
|
||||||
def _filter_transactions(
|
|
||||||
self, transactions: List[Dict[str, Any]]
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""Filter transactions based on notification criteria"""
|
|
||||||
matching = []
|
|
||||||
filters_case_insensitive = self.filters_config.get("case-insensitive", [])
|
|
||||||
filters_case_sensitive = self.filters_config.get("case-sensitive", [])
|
|
||||||
|
|
||||||
for transaction in transactions:
|
|
||||||
description = transaction.get("description", "")
|
|
||||||
description_lower = description.lower()
|
|
||||||
|
|
||||||
# Check case-insensitive filters
|
|
||||||
for filter_value in filters_case_insensitive:
|
|
||||||
if filter_value.lower() in description_lower:
|
|
||||||
matching.append(
|
|
||||||
{
|
|
||||||
"name": transaction["description"],
|
|
||||||
"value": transaction["transactionValue"],
|
|
||||||
"currency": transaction["transactionCurrency"],
|
|
||||||
"date": transaction["transactionDate"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
# Check case-sensitive filters
|
|
||||||
for filter_value in filters_case_sensitive:
|
|
||||||
if filter_value in description:
|
|
||||||
matching.append(
|
|
||||||
{
|
|
||||||
"name": transaction["description"],
|
|
||||||
"value": transaction["transactionValue"],
|
|
||||||
"currency": transaction["transactionCurrency"],
|
|
||||||
"date": transaction["transactionDate"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
return matching
|
|
||||||
|
|
||||||
def _is_discord_enabled(self) -> bool:
|
|
||||||
"""Check if Discord notifications are enabled"""
|
|
||||||
discord_config = self.notifications_config.get("discord", {})
|
|
||||||
return bool(
|
|
||||||
discord_config.get("webhook") and discord_config.get("enabled", True)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _is_telegram_enabled(self) -> bool:
|
|
||||||
"""Check if Telegram notifications are enabled"""
|
|
||||||
telegram_config = self.notifications_config.get("telegram", {})
|
|
||||||
return bool(
|
|
||||||
telegram_config.get("token")
|
|
||||||
and telegram_config.get("chat_id")
|
|
||||||
and telegram_config.get("enabled", True)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _send_discord_notifications(
|
|
||||||
self, transactions: List[Dict[str, Any]]
|
|
||||||
) -> None:
|
|
||||||
"""Send Discord notifications for transactions"""
|
|
||||||
try:
|
|
||||||
import click
|
|
||||||
|
|
||||||
from leggen.notifications.discord import send_transactions_message
|
|
||||||
|
|
||||||
# Create a mock context with the webhook
|
|
||||||
ctx = click.Context(click.Command("notifications"))
|
|
||||||
ctx.obj = {
|
|
||||||
"notifications": {
|
|
||||||
"discord": {
|
|
||||||
"webhook": self.notifications_config.get("discord", {}).get(
|
|
||||||
"webhook"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Send transaction notifications using the actual implementation
|
|
||||||
send_transactions_message(ctx, transactions)
|
|
||||||
logger.info(
|
|
||||||
f"Sent {len(transactions)} transaction notifications to Discord"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to send Discord transaction notifications: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def _send_telegram_notifications(
|
|
||||||
self, transactions: List[Dict[str, Any]]
|
|
||||||
) -> None:
|
|
||||||
"""Send Telegram notifications for transactions"""
|
|
||||||
try:
|
|
||||||
import click
|
|
||||||
|
|
||||||
from leggen.notifications.telegram import send_transaction_message
|
|
||||||
|
|
||||||
# Create a mock context with the telegram config
|
|
||||||
ctx = click.Context(click.Command("notifications"))
|
|
||||||
telegram_config = self.notifications_config.get("telegram", {})
|
|
||||||
ctx.obj = {
|
|
||||||
"notifications": {
|
|
||||||
"telegram": {
|
|
||||||
"api-key": telegram_config.get("token"),
|
|
||||||
"chat-id": telegram_config.get("chat_id"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Send transaction notifications using the actual implementation
|
|
||||||
send_transaction_message(ctx, transactions)
|
|
||||||
logger.info(
|
|
||||||
f"Sent {len(transactions)} transaction notifications to Telegram"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to send Telegram transaction notifications: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def _send_discord_test(self, message: str) -> None:
|
|
||||||
"""Send Discord test notification"""
|
|
||||||
try:
|
|
||||||
import click
|
|
||||||
|
|
||||||
from leggen.notifications.discord import send_expire_notification
|
|
||||||
|
|
||||||
# Create a mock context with the webhook
|
|
||||||
ctx = click.Context(click.Command("test"))
|
|
||||||
ctx.obj = {
|
|
||||||
"notifications": {
|
|
||||||
"discord": {
|
|
||||||
"webhook": self.notifications_config.get("discord", {}).get(
|
|
||||||
"webhook"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Send test notification using the actual implementation
|
|
||||||
test_notification = {
|
|
||||||
"bank": "Test",
|
|
||||||
"requisition_id": "test-123",
|
|
||||||
"status": "active",
|
|
||||||
"days_left": 30,
|
|
||||||
}
|
|
||||||
send_expire_notification(ctx, test_notification)
|
|
||||||
logger.info(f"Discord test notification sent: {message}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to send Discord test notification: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def _send_telegram_test(self, message: str) -> None:
|
|
||||||
"""Send Telegram test notification"""
|
|
||||||
try:
|
|
||||||
import click
|
|
||||||
|
|
||||||
from leggen.notifications.telegram import send_expire_notification
|
|
||||||
|
|
||||||
# Create a mock context with the telegram config
|
|
||||||
ctx = click.Context(click.Command("test"))
|
|
||||||
telegram_config = self.notifications_config.get("telegram", {})
|
|
||||||
ctx.obj = {
|
|
||||||
"notifications": {
|
|
||||||
"telegram": {
|
|
||||||
"api-key": telegram_config.get("token"),
|
|
||||||
"chat-id": telegram_config.get("chat_id"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Send test notification using the actual implementation
|
|
||||||
test_notification = {
|
|
||||||
"bank": "Test",
|
|
||||||
"requisition_id": "test-123",
|
|
||||||
"status": "active",
|
|
||||||
"days_left": 30,
|
|
||||||
}
|
|
||||||
send_expire_notification(ctx, test_notification)
|
|
||||||
logger.info(f"Telegram test notification sent: {message}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to send Telegram test notification: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def _send_discord_expiry(self, notification_data: Dict[str, Any]) -> None:
|
|
||||||
"""Send Discord expiry notification"""
|
|
||||||
try:
|
|
||||||
import click
|
|
||||||
|
|
||||||
from leggen.notifications.discord import send_expire_notification
|
|
||||||
|
|
||||||
# Create a mock context with the webhook
|
|
||||||
ctx = click.Context(click.Command("expiry"))
|
|
||||||
ctx.obj = {
|
|
||||||
"notifications": {
|
|
||||||
"discord": {
|
|
||||||
"webhook": self.notifications_config.get("discord", {}).get(
|
|
||||||
"webhook"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Send expiry notification using the actual implementation
|
|
||||||
send_expire_notification(ctx, notification_data)
|
|
||||||
logger.info(f"Sent Discord expiry notification: {notification_data}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to send Discord expiry notification: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def _send_telegram_expiry(self, notification_data: Dict[str, Any]) -> None:
|
|
||||||
"""Send Telegram expiry notification"""
|
|
||||||
try:
|
|
||||||
import click
|
|
||||||
|
|
||||||
from leggen.notifications.telegram import send_expire_notification
|
|
||||||
|
|
||||||
# Create a mock context with the telegram config
|
|
||||||
ctx = click.Context(click.Command("expiry"))
|
|
||||||
telegram_config = self.notifications_config.get("telegram", {})
|
|
||||||
ctx.obj = {
|
|
||||||
"notifications": {
|
|
||||||
"telegram": {
|
|
||||||
"api-key": telegram_config.get("token"),
|
|
||||||
"chat-id": telegram_config.get("chat_id"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Send expiry notification using the actual implementation
|
|
||||||
send_expire_notification(ctx, notification_data)
|
|
||||||
logger.info(f"Sent Telegram expiry notification: {notification_data}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to send Telegram expiry notification: {e}")
|
|
||||||
raise
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user