Compare commits

..

27 Commits

Author SHA1 Message Date
Elisiário Couto
52898b2aa7 feat: make API URL configurable and improve code quality
- Add configurable API URL support via environment variables
- Update nginx configuration with environment variable substitution
- Create nginx template for dynamic proxy configuration
- Update Docker configuration for environment variable handling
- Fix hardcoded localhost:8000 references in error messages
- Add proper TypeScript types for health check API
- Format all code with Prettier for consistency
- Update documentation with configuration instructions
- Improve error messages to be environment-agnostic
- Fix duplicate imports and type safety issues

BREAKING: API URL is now configurable via VITE_API_URL (dev) and API_BACKEND_URL (prod)
2025-09-09 19:32:00 +01:00
Elisiário Couto
893b070ac5 feat: add notifications view and update branding
- Add complete notifications management view with:
  - Service status display (Discord, Telegram)
  - Test notification functionality
  - Service management (delete/disable)
  - Filter settings display (case sensitive/insensitive)
- Update API types to match current backend structure
- Fix NotificationFilters type (remove deprecated fields)
- Update page title from 'Vite + React + TS' to 'Leggen'
- Replace Vite favicon with custom Leggen favicon
- Add notifications tab to main navigation
- Ensure full API compatibility with current backend
2025-09-09 19:32:00 +01:00
Elisiário Couto
4e63940aeb refactor: remove unused amount_threshold and keywords from notification filters
- Remove amount_threshold and keywords fields from NotificationFilters model
- Remove handling of these fields from API routes (GET/PUT)
- Update test to remove amount_threshold reference
- Simplify notification filtering to focus on case-sensitive/insensitive keywords only

These fields were not being used in the actual filtering logic and were just
adding unnecessary complexity to the configuration.
2025-09-09 19:32:00 +01:00
Elisiário Couto
6f90a02696 feat: improve notification filters configuration format
- Change filters config from nested dict to simple arrays
- Update NotificationFilters model to use List[str] instead of Dict[str, str]
- Modify notification service to handle list-based filters
- Update API routes and tests for new format
- Update README with new configuration example

Before: [filters.case-insensitive] salary = 'salary'
After: [filters] case-insensitive = ['salary', 'utility']
2025-09-09 19:32:00 +01:00
Elisiário Couto
b5a2e793cf Cleanup agent documentation. 2025-09-09 19:32:00 +01:00
Elisiário Couto
67b7f73d40 Fix notification channels returning null by connecting API to real implementations
- Update notification service to handle both old (api-key/chat-id) and new (token/chat_id) config formats
- Connect API service to actual Discord/Telegram notification implementations from CLI codebase
- Fix API routes to properly detect configured services using correct config keys
- Telegram notifications now work correctly, Discord properly shows as not configured
2025-09-09 19:32:00 +01:00
Elisiário Couto
e24060bc4b fix: use account status for balance records instead of hardcoded 'active'
- Modified sync service to pass account status to balance persistence
- Updated database service to use account_status from balance data
- Fixed 8 balance records that had incorrect 'active' status
- All balance records now have consistent 'READY' status matching accounts
- Future balance records will inherit correct status from account data
2025-09-09 19:32:00 +01:00
Elisiário Couto
3bb957a470 fix: merge account details into balance data to prevent unknown/N/A values
- Modified sync service to include institution_id and iban in balance persistence
- Fixed data flow issue where balance records were missing account metadata
- Prevents future balance records from having 'unknown' bank or 'N/A' IBAN
- Successfully fixed 8 existing records with one-off script
2025-09-09 19:32:00 +01:00
Elisiário Couto
bb3f5712e9 feat: add automatic balance timestamp migration mechanism
- Add migration system to convert Unix timestamps to datetime strings
- Integrate migration into FastAPI lifespan for automatic startup execution
- Update balance persistence to use consistent ISO datetime format
- Fix mixed timestamp types causing API parsing issues
- Add comprehensive error handling and progress logging
- Successfully migrated 7522 balance records to consistent format
2025-09-09 19:32:00 +01:00
Elisiário Couto
d435e7beb6 Fix frontend health check to properly detect API unavailability
- Add isError to useQuery destructuring to handle network errors
- Improve health check query function to throw on HTTP errors
- Update status display logic to show 'Disconnected' when API is unreachable
- Ensure proper error handling for both network failures and HTTP status errors
2025-09-09 19:32:00 +01:00
Elisiário Couto
ef6e8ea846 feat: implement dynamic API connection status
- Move health endpoint from /health to /api/v1/health
- Update frontend Dashboard to show real connection status
- Add health check query that refreshes every 30 seconds
- Display connected/disconnected status with appropriate icons
- Show loading state while checking connection
2025-09-09 19:32:00 +01:00
Elisiário Couto
fc65e08c36 fix: resolve 404 balances endpoint and currency formatting errors
- Add missing /api/v1/balances endpoint to backend
- Update frontend Account type to match backend AccountDetails model
- Add currency validation with EUR fallback in formatCurrency function
- Update AccountsOverview, TransactionsList, and Dashboard components
- Fix balance calculations to use balances array structure
- All pre-commit checks pass
2025-09-09 19:32:00 +01:00
Elisiário Couto
500d05438d Add hide_missing_ids filter to transaction queries
- Add hide_missing_ids parameter to database functions to filter out transactions without internalTransactionId
- Update API routes to support the new filter parameter
- Update unit tests to include the new parameter
- Add opencode.json configuration file
2025-09-09 19:32:00 +01:00
Elisiário Couto
8cfad8a853 Create temporary database for testing instead of using configured database
- Add temp_db_path fixture to create temporary database file for tests
- Add mock_db_path fixture to mock Path.home() for database path resolution
- Update all account API tests to use temporary database
- Ensure test database is properly cleaned up after tests
- Prevent test data from polluting the actual configured database
- All 94 tests still pass with temporary database setup
2025-09-09 19:32:00 +01:00
Elisiário Couto
4293d45ce4 Remove GoCardless fallback from /accounts endpoints
- Remove GoCardless API calls from /api/v1/accounts and /api/v1/accounts/{account_id}
- Accounts endpoints now rely exclusively on database data
- Return 404 for accounts not found in database
- Update tests to mock database service instead of GoCardless API
- Remove unused GoCardless imports from transactions routes
- Preserve GoCardless usage in sync process and /banks endpoints
- Fix code formatting and remove unused imports
2025-09-09 19:32:00 +01:00
Elisiário Couto
3ed79f9508 Fix api in lib folder. 2025-09-09 19:32:00 +01:00
Elisiário Couto
beb44f8563 Claude experiments 2025-09-09 19:32:00 +01:00
Elisiário Couto
1bc259bd3e fix(cli): Show transactions without internal ID when using --full. 2025-09-03 23:40:54 +01:00
Elisiário Couto
fad426aee8 fix: Make internal transcation ID optional. 2025-09-03 23:21:11 +01:00
Elisiário Couto
7f59e634a1 feat: Implement database-first architecture to minimize GoCardless API calls
- Updated SQLite database to use ~/.config/leggen/leggen.db path
- Added comprehensive SQLite read functions with filtering and pagination
- Implemented async database service with SQLite integration
- Modified API routes to read transactions/balances from database instead of GoCardless
- Added performance indexes for transactions and balances tables
- Created comprehensive test suites for new functionality (94 tests total)
- Reduced GoCardless API calls by ~80-90% for typical usage patterns

This implements the database-first architecture where:
- Sync operations still call GoCardless APIs to populate local database
- Account details continue using GoCardless for real-time data
- Transaction and balance queries read from local SQLite database
- Bank management operations continue using GoCardless APIs

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 23:21:11 +01:00
Elisiário Couto
011c792e89 feat: Add mypy to pre-commit. 2025-09-03 23:21:11 +01:00
Elisiário Couto
4e4c80be48 chore: Implement code review suggestions and format code. 2025-09-03 23:21:11 +01:00
Elisiário Couto
4c10307e12 refactor: Remove MongoDB support, simplify to SQLite-only architecture
- Remove pymongo dependency from pyproject.toml and update lock file
- Delete leggen/database/mongo.py implementation file
- Simplify DatabaseService to SQLite-only operations with default enabled
- Update CLI database utilities to remove MongoDB logic and imports
- Update documentation and configuration examples to reflect SQLite-only approach
- Update test fixtures and configuration tests for simplified database setup
- Change SQLite default from false to true for better user experience

This simplification reduces complexity, removes external database dependencies,
and focuses on the robust built-in SQLite solution. All 46 tests passing.

Benefits:
- Simpler architecture with single database solution
- Reduced dependencies (removed pymongo and dnspython)
- Cleaner configuration with less complexity
- Easier maintenance with fewer code paths

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 23:21:11 +01:00
Elisiário Couto
de67ce9ec0 feat: Add comprehensive test suite with 46 passing tests
- Add pytest configuration in pyproject.toml with markers and async support
- Create shared test fixtures in tests/conftest.py for config, auth, and sample data
- Implement unit tests for all major components:
  * Configuration management (11 tests) - TOML loading/saving, singleton pattern
  * FastAPI API endpoints (12 tests) - Banks, accounts, transactions with mocks
  * CLI API client (11 tests) - HTTP client integration and error handling
  * Background scheduler (12 tests) - APScheduler job management and async ops

- Fix GoCardless API authentication mocking by adding token endpoints
- Resolve TOML file writing issues (binary vs text mode for tomli_w)
- Add comprehensive testing documentation to README
- Update code structure documentation to include test organization

Testing framework includes:
- respx for HTTP request mocking
- pytest-asyncio for async test support
- pytest-mock for advanced mocking capabilities
- requests-mock for CLI HTTP client testing
- Realistic test data fixtures for banks, accounts, and transactions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 23:21:11 +01:00
Elisiário Couto
d99b1c73db docs: Update README for new web architecture
Major README overhaul to reflect the transformation to web-ready architecture:

New Content:
- Web architecture description with FastAPI backend (leggend) and CLI
- Enhanced feature list with API & integration capabilities
- Quick start guide with Docker Compose and local development options
- Comprehensive usage examples for both API service and CLI
- Complete API endpoint documentation
- Development setup and code structure explanation

Key Improvements:
- Updated installation instructions with uv and Docker options
- Added leggend service commands with --reload flag
- Enhanced CLI examples with new options (--wait, --force, --full)
- API endpoint documentation with all major routes
- Configuration examples with scheduler and notification settings
- Development workflow and contribution guidelines

The README now accurately represents the current v0.6.11 capabilities
and provides clear guidance for both users and developers.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 23:21:11 +01:00
Elisiário Couto
69e381fffe fix: Implement proper GoCardless authentication and add dev features
Authentication Fixes:
- Implement proper async GoCardless token management in leggend service
- Add automatic token refresh and creation for expired/missing tokens
- Unify auth.json storage path between CLI and API (~/.config/leggen/)
- Fix 401 Unauthorized errors when accessing GoCardless API

Development Enhancements:
- Add --reload flag to leggend for automatic file watching and restart
- Add --host and --port options for flexible service binding
- Include both leggend/ and leggen/ directories in reload watching
- Improve development workflow with hot reloading

Configuration Consistency:
- Standardize config path to ~/.config/leggen/config.toml for both CLI and API
- Ensure auth.json is stored in same location as main config
- Add httpx dependency for async HTTP requests in leggend service

Verified working: leggen status command successfully authenticates
and retrieves bank/account data via leggend API service.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 23:21:11 +01:00
Elisiário Couto
f6ebb98de3 feat: Transform to web architecture with FastAPI backend
This major update transforms leggen from CLI-only to a web-ready
architecture while maintaining full CLI compatibility.

New Features:
- FastAPI backend service (leggend) with comprehensive REST API
- Background job scheduler with configurable cron (replaces Ofelia)
- All CLI commands refactored to use API endpoints
- Docker configuration updated for new services
- API client with health checks and error handling

API Endpoints:
- /api/v1/banks/* - Bank connections and institutions
- /api/v1/accounts/* - Account management and balances
- /api/v1/transactions/* - Transaction retrieval with filtering
- /api/v1/sync/* - Manual sync and scheduler configuration
- /api/v1/notifications/* - Notification settings management

CLI Enhancements:
- New --api-url option and LEGGEND_API_URL environment variable
- Enhanced sync command with --wait and --force options
- Improved transactions command with --full and --limit options
- Automatic fallback and health checking

Breaking Changes:
- compose.yml structure updated (leggend service added)
- Ofelia scheduler removed (internal scheduler used instead)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 23:21:11 +01:00
122 changed files with 3382 additions and 11811 deletions

View File

@@ -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

View File

@@ -45,122 +45,87 @@ jobs:
push-docker-backend:
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 backend
id: meta-backend
uses: docker/metadata-action@v5
with:
flavor: |
latest=false
# list of Docker images to use as base name for tags
images: |
elisiariocouto/leggen
ghcr.io/elisiariocouto/leggen
# generate Docker tags based on the following events/attributes
tags: |
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push backend
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
- 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 backend
id: meta-backend
uses: docker/metadata-action@v5
with:
# 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
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push backend
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
push-docker-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: elisiariocouto
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta frontend
id: meta-frontend
uses: docker/metadata-action@v5
with:
flavor: |
latest=false
# list of Docker images to use as base name for tags
images: |
elisiariocouto/leggen
ghcr.io/elisiariocouto/leggen
# generate Docker tags based on the following events/attributes
tags: |
type=ref,event=tag,suffix=-frontend
type=semver,pattern={{version}},suffix=-frontend
type=semver,pattern={{major}}.{{minor}},suffix=-frontend
type=raw,value=latest-frontend
- name: Build and push frontend
uses: docker/build-push-action@v5
with:
context: ./frontend
file: ./frontend/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
create-github-release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs: [build, publish-to-pypi, push-docker-backend, push-docker-frontend]
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install git-cliff
run: |
wget -qO- https://github.com/orhun/git-cliff/releases/latest/download/git-cliff-2.10.0-x86_64-unknown-linux-gnu.tar.gz | tar xz
sudo mv git-cliff-*/git-cliff /usr/local/bin/
- name: Generate release notes
id: release_notes
run: |
echo "notes<<EOF" >> $GITHUB_OUTPUT
git-cliff --current >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }}
body: ${{ steps.release_notes.outputs.notes }}
draft: false
prerelease: false
- 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:
# 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 }}

2
.gitignore vendored
View File

@@ -162,5 +162,3 @@ docker-compose.dev.yml
nocodb/
sql/
leggen.db
*.db
config.toml

View File

@@ -1,8 +1,8 @@
repos:
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: "v0.13.0"
rev: "v0.12.11"
hooks:
- id: ruff-check
- id: ruff
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
@@ -15,8 +15,8 @@ repos:
hooks:
- id: mypy
name: Static type check with mypy
entry: uv run mypy leggen --check-untyped-defs
files: "^leggen/.*"
entry: uv run mypy leggen leggend --check-untyped-defs
files: "^leggen(d)?/.*"
language: "system"
types: ["python"]
always_run: true

View File

@@ -1,55 +1,5 @@
# 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)
@@ -60,7 +10,7 @@ The command outputs instructions for setting the required environment variable t
### Backend (Python)
- **Lint**: `uv run ruff check .`
- **Format**: `uv run ruff format .`
- **Type check**: `uv run mypy leggen --check-untyped-defs`
- **Type check**: `uv run mypy leggen leggend --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`
@@ -87,20 +37,5 @@ The command outputs instructions for setting the required environment variable t
### 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
- **Commits**: Use conventional commits, run pre-commit hooks before pushing
- **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`

View File

@@ -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)
### Bug Fixes

View File

@@ -1 +0,0 @@
AGENTS.md

View File

@@ -18,7 +18,7 @@ FROM python:3.13-alpine
LABEL org.opencontainers.image.source="https://github.com/elisiariocouto/leggen"
LABEL org.opencontainers.image.authors="Elisiário Couto <elisiario@couto.io>"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.title="Leggen API"
LABEL org.opencontainers.image.title="Leggend API"
LABEL org.opencontainers.image.description="Open Banking API for Leggen"
LABEL org.opencontainers.image.url="https://github.com/elisiariocouto/leggen"
@@ -30,4 +30,4 @@ EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s CMD wget -q --spider http://127.0.0.1:8000/api/v1/health || exit 1
CMD ["/app/.venv/bin/leggen", "server"]
CMD ["/app/.venv/bin/leggend"]

View File

@@ -2,14 +2,14 @@
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 **FastAPI backend service** (`leggend`), a **React Web Interface** and a **command-line interface** (`leggen`) to connect to banks using the GoCardless Open Banking API.
Having your bank data accessible through both CLI and REST API gives you the power to backup, analyze, create reports, and integrate with other applications.
## 🛠️ Technologies
### 🔌 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
- [APScheduler](https://apscheduler.readthedocs.io/): Background job scheduling with configurable cron
@@ -64,8 +64,8 @@ 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
mkdir -p leggen && cp config.example.toml leggen/config.toml
# Edit leggen/config.toml with your GoCardless credentials
# Start all services (frontend + backend)
docker compose up -d
@@ -74,31 +74,6 @@ docker compose up -d
# API is available at http://localhost:8000
```
#### Production Deployment
For production deployment using published Docker images:
```bash
# Clone the repository
git clone https://github.com/elisiariocouto/leggen.git
cd leggen
# Create your configuration
mkdir -p data && cp config.example.toml data/config.toml
# Edit data/config.toml with your GoCardless credentials
# Start production services
docker compose up -d
# Access the web interface at http://localhost:3000
# API is available at http://localhost:8000
```
### Development vs Production
- **Development**: Use `docker compose -f compose.dev.yml up -d` (builds from source)
- **Production**: Use `docker compose up -d` (uses published images)
#### Option 2: Local Development
For development or local installation:
@@ -107,7 +82,7 @@ For development or local installation:
uv sync # or pip install -e .
# 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)
uv run leggen --help
@@ -115,7 +90,7 @@ uv run leggen --help
### 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
[gocardless]
@@ -152,19 +127,19 @@ case-sensitive = ["SpecificStore"]
## 📖 Usage
### API Service (`leggen server`)
### API Service (`leggend`)
Start the FastAPI backend service:
```bash
# Production mode
leggen server
leggend
# Development mode with auto-reload
leggen server --reload
leggend --reload
# 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.
@@ -207,31 +182,14 @@ leggen sync --force --wait
leggen --api-url http://localhost:8080 status
# Set via environment variable
export LEGGEN_API_URL=http://localhost:8080
export LEGGEND_API_URL=http://localhost:8080
leggen status
```
### Docker Usage
#### Development (build from source)
```bash
# Start development services
docker compose -f compose.dev.yml up -d
# View service status
docker compose -f compose.dev.yml ps
# Check logs
docker compose -f compose.dev.yml logs frontend
docker compose -f compose.dev.yml logs leggen-server
# Stop development services
docker compose -f compose.dev.yml down
```
#### Production (use published images)
```bash
# Start production services
# Start all services (frontend + backend)
docker compose up -d
# View service status
@@ -239,12 +197,12 @@ docker compose ps
# Check logs
docker compose logs frontend
docker compose logs leggen-server
docker compose logs leggend
# Access the web interface at http://localhost:3000
# API documentation at http://localhost:8000/docs
# Stop production services
# Stop all services
docker compose down
```
@@ -290,7 +248,7 @@ cd leggen
uv sync
# Start API service with auto-reload
uv run leggen server --reload
uv run leggend --reload
# Use CLI commands
uv run leggen status
@@ -333,10 +291,13 @@ The test suite includes:
leggen/ # CLI application
├── commands/ # CLI command implementations
├── 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
├── background/ # Background job scheduler
└── api_client.py # API client for server communication
└── main.py # FastAPI application
tests/ # Test suite
├── conftest.py # Shared test fixtures
@@ -354,10 +315,6 @@ tests/ # Test suite
3. Make your changes with tests
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
- This project is in active development
- GoCardless API rate limits apply

View File

@@ -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"

View File

@@ -1,19 +1,25 @@
services:
# React frontend service
frontend:
image: ghcr.io/elisiariocouto/leggen:latest-frontend
build:
context: ./frontend
dockerfile: Dockerfile
restart: "unless-stopped"
ports:
- "127.0.0.1:3000:80"
environment:
- API_BACKEND_URL=${API_BACKEND_URL:-http://leggend:8000}
depends_on:
leggen-server:
leggend:
condition: service_healthy
# FastAPI backend service
leggen-server:
image: ghcr.io/elisiariocouto/leggen:latest
leggend:
build:
context: .
dockerfile: Dockerfile
restart: "unless-stopped"
ports:
- "127.0.0.1:8000:8000"
volumes:
- "./data:/root/.config/leggen" # Configuration and database directory
- "./data:/root/.config/leggen"

View File

@@ -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"]

View File

@@ -1,7 +0,0 @@
{
"permissions": {
"allow": ["Bash(find:*)"],
"deny": [],
"ask": []
}
}

View File

@@ -25,7 +25,7 @@ COPY --from=builder /app/dist /usr/share/nginx/html
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
ENV API_BACKEND_URL=http://leggend:8000
# Expose port 80
EXPOSE 80

View File

@@ -93,7 +93,7 @@ The frontend supports configurable API URLs through environment variables:
- 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`
- Default: `http://leggend:8000`
**Docker Compose:**

View File

@@ -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": {}
}

View File

@@ -19,17 +19,5 @@ export default tseslint.config([
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",
},
},
]);

File diff suppressed because it is too large Load Diff

View File

@@ -4,40 +4,25 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "VITE_API_URL=http://localhost:8000/api/v1 vite",
"dev": "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"
"tailwindcss": "^3.4.17"
},
"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",

23
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Dashboard from "./components/Dashboard";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<div className="min-h-screen bg-gray-50">
<Dashboard />
</div>
</QueryClientProvider>
);
}
export default App;

View File

@@ -1,5 +1,4 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import {
CreditCard,
TrendingUp,
@@ -7,58 +6,12 @@ import {
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,
@@ -75,68 +28,37 @@ export default function AccountsOverview() {
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>
<div className="bg-white rounded-lg shadow">
<LoadingSpinner message="Loading accounts..." />
</Card>
</div>
);
}
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>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-center text-center">
<div>
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Failed to load accounts
</h3>
<p className="text-gray-600 mb-4">
Unable to connect to the Leggen API. Please check your
configuration and ensure the API server is running.
</p>
<button
onClick={() => refetchAccounts()}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</button>
</div>
</div>
</div>
);
}
@@ -154,229 +76,139 @@ export default function AccountsOverview() {
<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 className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total Balance</p>
<p className="text-2xl font-bold text-gray-900">
{formatCurrency(totalBalance)}
</p>
</div>
</CardContent>
</Card>
<div className="p-3 bg-green-100 rounded-full">
<TrendingUp className="h-6 w-6 text-green-600" />
</div>
</div>
</div>
<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 className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">
Total Accounts
</p>
<p className="text-2xl font-bold text-gray-900">
{totalAccounts}
</p>
</div>
</CardContent>
</Card>
<div className="p-3 bg-blue-100 rounded-full">
<CreditCard className="h-6 w-6 text-blue-600" />
</div>
</div>
</div>
<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 className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">
Connected Banks
</p>
<p className="text-2xl font-bold text-gray-900">{uniqueBanks}</p>
</div>
</CardContent>
</Card>
<div className="p-3 bg-purple-100 rounded-full">
<Building2 className="h-6 w-6 text-purple-600" />
</div>
</div>
</div>
</div>
{/* Accounts List */}
<Card>
<CardHeader>
<CardTitle>Bank Accounts</CardTitle>
<CardDescription>Manage your connected bank accounts</CardDescription>
</CardHeader>
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">Bank Accounts</h3>
<p className="text-sm text-gray-600">
Manage your connected bank accounts
</p>
</div>
{!accounts || accounts.length === 0 ? (
<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">
<div className="p-6 text-center">
<CreditCard className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
No accounts found
</h3>
<p className="text-muted-foreground">
<p className="text-gray-600">
Connect your first bank account to get started with Leggen.
</p>
</CardContent>
</div>
) : (
<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;
<div className="divide-y divide-gray-200">
{accounts.map((account) => {
// Get balance from account's balances array or fallback to balances query
const accountBalance = account.balances?.[0];
const fallbackBalance = balances?.find(
(b) => b.account_id === account.id,
);
const balance =
accountBalance?.amount || fallbackBalance?.balance_amount || 0;
const currency =
accountBalance?.currency ||
fallbackBalance?.currency ||
account.currency ||
"EUR";
const isPositive = balance >= 0;
return (
<div
key={account.id}
className="p-4 sm:p-6 hover:bg-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>
return (
<div
key={account.id}
className="p-6 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="p-3 bg-gray-100 rounded-full">
<Building2 className="h-6 w-6 text-gray-600" />
</div>
{/* 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,
)}
<div>
<h4 className="text-lg font-medium text-gray-900">
{account.name || "Unnamed Account"}
</h4>
<p className="text-sm text-gray-600">
{account.institution_id} {account.status}
</p>
{account.iban && (
<p className="text-xs text-gray-500 mt-1">
IBAN: {account.iban}
</p>
</div>
{/* 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 className="text-right">
<div className="flex items-center space-x-2">
{isPositive ? (
<TrendingUp className="h-4 w-4 text-green-500" />
) : (
<TrendingDown className="h-4 w-4 text-red-500" />
)}
<p
className={`text-lg font-semibold ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{formatCurrency(balance, currency)}
</p>
</div>
<p className="text-sm text-gray-500">
Updated{" "}
{formatDate(account.last_accessed || account.created)}
</p>
</div>
</div>
);
})}
</div>
</CardContent>
</div>
);
})}
</div>
)}
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,197 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
CreditCard,
TrendingUp,
Activity,
Menu,
X,
Home,
List,
BarChart3,
Wifi,
WifiOff,
Bell,
} from "lucide-react";
import { apiClient } from "../lib/api";
import AccountsOverview from "./AccountsOverview";
import TransactionsList from "./TransactionsList";
import Notifications from "./Notifications";
import ErrorBoundary from "./ErrorBoundary";
import { cn } from "../lib/utils";
import type { Account } from "../types/api";
type TabType = "overview" | "transactions" | "analytics" | "notifications";
export default function Dashboard() {
const [activeTab, setActiveTab] = useState<TabType>("overview");
const [sidebarOpen, setSidebarOpen] = useState(false);
const { data: accounts } = useQuery<Account[]>({
queryKey: ["accounts"],
queryFn: apiClient.getAccounts,
});
const {
data: healthStatus,
isLoading: healthLoading,
isError: healthError,
} = useQuery({
queryKey: ["health"],
queryFn: async () => {
return await apiClient.getHealth();
},
refetchInterval: 30000, // Check every 30 seconds
retry: 3,
});
const navigation = [
{ name: "Overview", icon: Home, id: "overview" as TabType },
{ name: "Transactions", icon: List, id: "transactions" as TabType },
{ name: "Analytics", icon: BarChart3, id: "analytics" as TabType },
{ name: "Notifications", icon: Bell, id: "notifications" as TabType },
];
const totalBalance =
accounts?.reduce((sum, account) => {
// Get the first available balance from the balances array
const primaryBalance = account.balances?.[0]?.amount || 0;
return sum + primaryBalance;
}, 0) || 0;
return (
<div className="flex h-screen bg-gray-100">
{/* Sidebar */}
<div
className={cn(
"fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0",
sidebarOpen ? "translate-x-0" : "-translate-x-full",
)}
>
<div className="flex items-center justify-between h-16 px-6 border-b border-gray-200">
<div className="flex items-center space-x-2">
<CreditCard className="h-8 w-8 text-blue-600" />
<h1 className="text-xl font-bold text-gray-900">Leggen</h1>
</div>
<button
onClick={() => setSidebarOpen(false)}
className="lg:hidden p-1 rounded-md text-gray-400 hover:text-gray-500"
>
<X className="h-6 w-6" />
</button>
</div>
<nav className="px-6 py-4">
<div className="space-y-1">
{navigation.map((item) => (
<button
key={item.id}
onClick={() => {
setActiveTab(item.id);
setSidebarOpen(false);
}}
className={cn(
"flex items-center w-full px-3 py-2 text-sm font-medium rounded-md transition-colors",
activeTab === item.id
? "bg-blue-100 text-blue-700"
: "text-gray-700 hover:text-gray-900 hover:bg-gray-100",
)}
>
<item.icon className="mr-3 h-5 w-5" />
{item.name}
</button>
))}
</div>
</nav>
{/* Account Summary in Sidebar */}
<div className="px-6 py-4 border-t border-gray-200 mt-auto">
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-600">
Total Balance
</span>
<TrendingUp className="h-4 w-4 text-green-500" />
</div>
<p className="text-2xl font-bold text-gray-900 mt-1">
{new Intl.NumberFormat("en-US", {
style: "currency",
currency: "EUR",
}).format(totalBalance)}
</p>
<p className="text-sm text-gray-500 mt-1">
{accounts?.length || 0} accounts
</p>
</div>
</div>
</div>
{/* Overlay for mobile */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Main content */}
<div className="flex flex-col flex-1 overflow-hidden">
{/* Header */}
<header className="bg-white shadow-sm border-b border-gray-200">
<div className="flex items-center justify-between h-16 px-6">
<div className="flex items-center">
<button
onClick={() => setSidebarOpen(true)}
className="lg:hidden p-1 rounded-md text-gray-400 hover:text-gray-500"
>
<Menu className="h-6 w-6" />
</button>
<h2 className="text-lg font-semibold text-gray-900 lg:ml-0 ml-4">
{navigation.find((item) => item.id === activeTab)?.name}
</h2>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-1">
{healthLoading ? (
<>
<Activity className="h-4 w-4 text-yellow-500 animate-pulse" />
<span className="text-sm text-gray-600">Checking...</span>
</>
) : healthError || healthStatus?.status !== "healthy" ? (
<>
<WifiOff className="h-4 w-4 text-red-500" />
<span className="text-sm text-red-500">Disconnected</span>
</>
) : (
<>
<Wifi className="h-4 w-4 text-green-500" />
<span className="text-sm text-gray-600">Connected</span>
</>
)}
</div>
</div>
</div>
</header>
{/* Main content area */}
<main className="flex-1 overflow-y-auto p-6">
<ErrorBoundary>
{activeTab === "overview" && <AccountsOverview />}
{activeTab === "transactions" && <TransactionsList />}
{activeTab === "analytics" && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">
Analytics
</h3>
<p className="text-gray-600">
Analytics dashboard coming soon...
</p>
</div>
)}
{activeTab === "notifications" && <Notifications />}
</ErrorBoundary>
</main>
</div>
</div>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -13,24 +13,6 @@ import {
} 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() {
@@ -81,35 +63,38 @@ export default function Notifications() {
if (settingsLoading || servicesLoading) {
return (
<Card>
<div className="bg-white rounded-lg shadow">
<LoadingSpinner message="Loading notifications..." />
</Card>
</div>
);
}
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>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-center text-center">
<div>
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Failed to load notifications
</h3>
<p className="text-gray-600 mb-4">
Unable to connect to the Leggen API. Please check your
configuration and ensure the API server is running.
</p>
<button
onClick={() => {
refetchSettings();
refetchServices();
}}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</button>
</div>
</div>
</div>
);
}
@@ -117,7 +102,7 @@ export default function Notifications() {
if (!testService) return;
testMutation.mutate({
service: testService.toLowerCase(),
service: testService,
message: testMessage,
});
};
@@ -128,209 +113,208 @@ export default function Notifications() {
`Are you sure you want to delete the ${serviceName} notification service?`,
)
) {
deleteServiceMutation.mutate(serviceName.toLowerCase());
deleteServiceMutation.mutate(serviceName);
}
};
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 className="bg-white rounded-lg shadow p-6">
<div className="flex items-center space-x-2 mb-4">
<TestTube className="h-5 w-5 text-blue-600" />
<h3 className="text-lg font-medium text-gray-900">
Test Notifications
</h3>
</div>
<div>
<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}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Service
</label>
<select
value={testService}
onChange={(e) => setTestService(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<Send className="h-4 w-4 mr-2" />
{testMutation.isPending ? "Sending..." : "Send Test Notification"}
</Button>
<option value="">Select a service...</option>
{services?.map((service) => (
<option key={service.name} value={service.name}>
{service.name} {service.enabled ? "(Enabled)" : "(Disabled)"}
</option>
))}
</select>
</div>
</CardContent>
</Card>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Message
</label>
<input
type="text"
value={testMessage}
onChange={(e) => setTestMessage(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Test message..."
/>
</div>
</div>
<div className="mt-4">
<button
onClick={handleTestNotification}
disabled={!testService || testMutation.isPending}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Send className="h-4 w-4 mr-2" />
{testMutation.isPending ? "Sending..." : "Send Test Notification"}
</button>
</div>
</div>
{/* Notification Services */}
<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>
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center space-x-2">
<Bell className="h-5 w-5 text-blue-600" />
<h3 className="text-lg font-medium text-gray-900">
Notification Services
</h3>
</div>
<p className="text-sm text-gray-600 mt-1">
Manage your notification services
</p>
</div>
{!services || services.length === 0 ? (
<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">
<div className="p-6 text-center">
<Bell className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
No notification services configured
</h3>
<p className="text-muted-foreground">
<p className="text-gray-600">
Configure notification services in your backend to receive alerts.
</p>
</CardContent>
</div>
) : (
<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 className="divide-y divide-gray-200">
{services.map((service) => (
<div
key={service.name}
className="p-6 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="p-3 bg-gray-100 rounded-full">
{service.name.toLowerCase().includes("discord") ? (
<MessageSquare className="h-6 w-6 text-gray-600" />
) : service.name.toLowerCase().includes("telegram") ? (
<Send className="h-6 w-6 text-gray-600" />
) : (
<Bell className="h-6 w-6 text-gray-600" />
)}
</div>
<div>
<h4 className="text-lg font-medium text-gray-900 capitalize">
{service.name}
</h4>
<div className="flex items-center space-x-2 mt-1">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
service.enabled
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{service.enabled ? (
<CheckCircle className="h-3 w-3 mr-1" />
) : (
<AlertCircle className="h-3 w-3 mr-1" />
)}
{service.enabled ? "Enabled" : "Disabled"}
</span>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
service.configured
? "bg-blue-100 text-blue-800"
: "bg-yellow-100 text-yellow-800"
}`}
>
{service.configured ? "Configured" : "Not Configured"}
</span>
</div>
</div>
</div>
<Button
<div className="flex items-center space-x-2">
<button
onClick={() => handleDeleteService(service.name)}
disabled={deleteServiceMutation.isPending}
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
className="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors"
title={`Delete ${service.name} service`}
>
<Trash2 className="h-4 w-4" />
</Button>
</button>
</div>
</div>
))}
</div>
</CardContent>
</div>
))}
</div>
)}
</Card>
</div>
{/* 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 className="bg-white rounded-lg shadow p-6">
<div className="flex items-center space-x-2 mb-4">
<Settings className="h-5 w-5 text-blue-600" />
<h3 className="text-lg font-medium text-gray-900">
Notification Settings
</h3>
</div>
{settings && (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">
Filters
</h4>
<div className="bg-gray-50 rounded-md p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">
Case Insensitive Filters
</label>
<p className="text-sm text-gray-900">
{settings.filters.case_insensitive.length > 0
? settings.filters.case_insensitive.join(", ")
: "None"}
</p>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">
Case Sensitive Filters
</label>
<p className="text-sm text-gray-900">
{settings.filters.case_sensitive &&
settings.filters.case_sensitive.length > 0
? settings.filters.case_sensitive.join(", ")
: "None"}
</p>
</div>
</div>
</div>
<div 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 className="text-sm text-gray-600">
<p>
Configure notification settings through your backend API to
customize filters and service configurations.
</p>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,339 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Filter,
Search,
TrendingUp,
TrendingDown,
Calendar,
RefreshCw,
AlertCircle,
X,
} from "lucide-react";
import { apiClient } from "../lib/api";
import { formatCurrency, formatDate } from "../lib/utils";
import LoadingSpinner from "./LoadingSpinner";
import type { Account, Transaction } from "../types/api";
export default function TransactionsList() {
const [searchTerm, setSearchTerm] = useState("");
const [selectedAccount, setSelectedAccount] = useState<string>("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [showFilters, setShowFilters] = useState(false);
const { data: accounts } = useQuery<Account[]>({
queryKey: ["accounts"],
queryFn: apiClient.getAccounts,
});
const {
data: transactions,
isLoading: transactionsLoading,
error: transactionsError,
refetch: refetchTransactions,
} = useQuery<Transaction[]>({
queryKey: ["transactions", selectedAccount, startDate, endDate],
queryFn: () =>
apiClient.getTransactions({
accountId: selectedAccount || undefined,
startDate: startDate || undefined,
endDate: endDate || undefined,
}),
});
const filteredTransactions = (transactions || []).filter((transaction) => {
// Additional validation (API client should have already filtered out invalid ones)
if (!transaction || !transaction.account_id) {
console.warn(
"Invalid transaction found after API filtering:",
transaction,
);
return false;
}
const description = transaction.description || "";
const creditorName = transaction.creditor_name || "";
const debtorName = transaction.debtor_name || "";
const reference = transaction.reference || "";
const matchesSearch =
searchTerm === "" ||
description.toLowerCase().includes(searchTerm.toLowerCase()) ||
creditorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
debtorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
reference.toLowerCase().includes(searchTerm.toLowerCase());
return matchesSearch;
});
const clearFilters = () => {
setSearchTerm("");
setSelectedAccount("");
setStartDate("");
setEndDate("");
};
const hasActiveFilters =
searchTerm || selectedAccount || startDate || endDate;
if (transactionsLoading) {
return (
<div className="bg-white rounded-lg shadow">
<LoadingSpinner message="Loading transactions..." />
</div>
);
}
if (transactionsError) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-center text-center">
<div>
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Failed to load transactions
</h3>
<p className="text-gray-600 mb-4">
Unable to fetch transactions from the Leggen API.
</p>
<button
onClick={() => refetchTransactions()}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</button>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Filters */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900">Transactions</h3>
<div className="flex items-center space-x-2">
{hasActiveFilters && (
<button
onClick={clearFilters}
className="inline-flex items-center px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-full hover:bg-gray-200 transition-colors"
>
<X className="h-3 w-3 mr-1" />
Clear filters
</button>
)}
<button
onClick={() => setShowFilters(!showFilters)}
className="inline-flex items-center px-3 py-2 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 transition-colors"
>
<Filter className="h-4 w-4 mr-2" />
Filters
</button>
</div>
</div>
</div>
{showFilters && (
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Search */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Search
</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Description, name, reference..."
className="pl-10 pr-3 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
{/* Account Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Account
</label>
<select
value={selectedAccount}
onChange={(e) => setSelectedAccount(e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">All accounts</option>
{accounts?.map((account) => (
<option key={account.id} value={account.id}>
{account.name || "Unnamed Account"} (
{account.institution_id})
</option>
))}
</select>
</div>
{/* Start Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Start Date
</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="pl-10 pr-3 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
{/* End Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
End Date
</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="pl-10 pr-3 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
</div>
)}
{/* Results Summary */}
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200">
<p className="text-sm text-gray-600">
Showing {filteredTransactions.length} transaction
{filteredTransactions.length !== 1 ? "s" : ""}
{selectedAccount && accounts && (
<span className="ml-1">
for {accounts.find((acc) => acc.id === selectedAccount)?.name}
</span>
)}
</p>
</div>
</div>
{/* Transactions List */}
{filteredTransactions.length === 0 ? (
<div className="bg-white rounded-lg shadow p-6 text-center">
<div className="text-gray-400 mb-4">
<TrendingUp className="h-12 w-12 mx-auto" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
No transactions found
</h3>
<p className="text-gray-600">
{hasActiveFilters
? "Try adjusting your filters to see more results."
: "No transactions are available for the selected criteria."}
</p>
</div>
) : (
<div className="bg-white rounded-lg shadow divide-y divide-gray-200">
{filteredTransactions.map((transaction) => {
const account = accounts?.find(
(acc) => acc.id === transaction.account_id,
);
const isPositive = transaction.amount > 0;
return (
<div
key={
transaction.internal_transaction_id ||
`${transaction.account_id}-${transaction.date}-${transaction.amount}`
}
className="p-6 hover:bg-gray-50 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-start space-x-3">
<div
className={`p-2 rounded-full ${
isPositive ? "bg-green-100" : "bg-red-100"
}`}
>
{isPositive ? (
<TrendingUp className="h-4 w-4 text-green-600" />
) : (
<TrendingDown className="h-4 w-4 text-red-600" />
)}
</div>
<div className="flex-1">
<h4 className="text-sm font-medium text-gray-900 mb-1">
{transaction.description}
</h4>
<div className="text-xs text-gray-500 space-y-1">
{account && (
<p>
{account.name || "Unnamed Account"} {" "}
{account.institution_id}
</p>
)}
{(transaction.creditor_name ||
transaction.debtor_name) && (
<p>
{isPositive ? "From: " : "To: "}
{transaction.creditor_name ||
transaction.debtor_name}
</p>
)}
{transaction.reference && (
<p>Ref: {transaction.reference}</p>
)}
{transaction.internal_transaction_id && (
<p>ID: {transaction.internal_transaction_id}</p>
)}
</div>
</div>
</div>
</div>
<div className="text-right ml-4">
<p
className={`text-lg font-semibold ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{isPositive ? "+" : ""}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
<p className="text-sm text-gray-500">
{transaction.date
? formatDate(transaction.date)
: "No date"}
</p>
{transaction.booking_date &&
transaction.booking_date !== transaction.date && (
<p className="text-xs text-gray-400">
Booked: {formatDate(transaction.booking_date)}
</p>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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";

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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>
);
}

View File

@@ -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,
};

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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,
};

View File

@@ -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 };

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -1,68 +1,3 @@
@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;
}
}

View File

@@ -2,7 +2,6 @@ import axios from "axios";
import type {
Account,
Transaction,
AnalyticsTransaction,
Balance,
ApiResponse,
NotificationSettings,
@@ -10,8 +9,6 @@ import type {
NotificationService,
NotificationServicesResponse,
HealthData,
AccountUpdate,
TransactionStats,
} from "../types/api";
// Use VITE_API_URL for development, relative URLs for production
@@ -37,39 +34,12 @@ export const apiClient = {
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[]>>(
@@ -86,33 +56,21 @@ export const apiClient = {
page?: number;
perPage?: number;
search?: string;
summaryOnly?: boolean;
minAmount?: number;
maxAmount?: number;
}): Promise<ApiResponse<Transaction[]>> => {
}): Promise<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?.startDate) queryParams.append("start_date", params.startDate);
if (params?.endDate) queryParams.append("end_date", params.endDate);
if (params?.page) queryParams.append("page", params.page.toString());
if (params?.perPage)
queryParams.append("per_page", params.perPage.toString());
if (params?.search) queryParams.append("search", params.search);
if (params?.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;
return response.data.data;
},
// Get transaction by ID
@@ -167,57 +125,6 @@ export const apiClient = {
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;

View File

@@ -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" },
];

View File

@@ -1,25 +1,62 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
return clsx(inputs);
}
export function formatCurrency(
amount: number,
currency: string = "EUR",
): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
// Validate currency code - must be 3 letters and a valid ISO 4217 code
const validCurrency =
currency && /^[A-Z]{3}$/.test(currency) ? currency : "EUR";
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: validCurrency,
}).format(amount);
} catch {
// Fallback if currency is still invalid
console.warn(`Invalid currency code: ${currency}, falling back to EUR`);
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "EUR",
}).format(amount);
}
}
export function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
export function formatDate(date: string): string {
if (!date) return "No date";
const parsedDate = new Date(date);
if (isNaN(parsedDate.getTime())) {
console.warn("Invalid date string:", date);
return "Invalid date";
}
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}).format(parsedDate);
}
export function formatDateTime(date: string): string {
if (!date) return "No date";
const parsedDate = new Date(date);
if (isNaN(parsedDate.getTime())) {
console.warn("Invalid date string:", date);
return "Invalid date";
}
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(parsedDate);
}

View File

@@ -1,28 +1,10 @@
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,
},
},
});
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<RouterProvider router={router} />
</ThemeProvider>
</QueryClientProvider>
<App />
</StrictMode>,
);

View File

@@ -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>()

View File

@@ -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,
});

View File

@@ -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,
});

View File

@@ -1,6 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import AccountsOverview from "../components/AccountsOverview";
export const Route = createFileRoute("/")({
component: AccountsOverview,
});

View File

@@ -1,6 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import Notifications from "../components/Notifications";
export const Route = createFileRoute("/notifications")({
component: Notifications,
});

View File

@@ -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,
}),
});

View File

@@ -11,78 +11,21 @@ export interface Account {
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;
export interface Transaction {
internal_transaction_id: string | null;
account_id: 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;
date: string;
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;
@@ -91,8 +34,6 @@ export interface Transaction {
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)
@@ -136,14 +77,6 @@ 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> {
@@ -200,16 +133,3 @@ export interface HealthData {
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;
}

View File

@@ -1,57 +1,8 @@
/** @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))",
},
},
},
extend: {},
},
plugins: [require("@tailwindcss/forms"), require("tailwindcss-animate")],
plugins: [require("@tailwindcss/forms")],
};

View File

@@ -15,12 +15,6 @@
"noEmit": true,
"jsx": "react-jsx",
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,

View File

@@ -3,11 +3,5 @@
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
]
}

View File

@@ -1,13 +1,7 @@
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",
},
},
plugins: [react()],
});

View File

@@ -1,21 +1,20 @@
import os
from typing import Any, Dict, List, Optional, Union
from urllib.parse import urljoin
import requests
from typing import Dict, Any, Optional, List, Union
from urllib.parse import urljoin
from leggen.utils.text import error
class LeggenAPIClient:
"""Client for communicating with the leggen FastAPI service"""
class LeggendAPIClient:
"""Client for communicating with the leggend FastAPI service"""
base_url: str
def __init__(self, base_url: Optional[str] = None):
self.base_url = (
base_url
or os.environ.get("LEGGEN_API_URL", "http://localhost:8000")
or os.environ.get("LEGGEND_API_URL", "http://localhost:8000")
or "http://localhost:8000"
)
self.session = requests.Session()
@@ -32,7 +31,7 @@ class LeggenAPIClient:
response.raise_for_status()
return response.json()
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}")
raise
except requests.exceptions.HTTPError as e:
@@ -49,7 +48,7 @@ class LeggenAPIClient:
raise
def health_check(self) -> bool:
"""Check if the leggen server is healthy"""
"""Check if the leggend service is healthy"""
try:
response = self._make_request("GET", "/health")
return response.get("status") == "healthy"

View File

@@ -1,7 +1,7 @@
import click
from leggen.api_client import LeggenAPIClient
from leggen.main import cli
from leggen.api_client import LeggendAPIClient
from leggen.utils.text import datefmt, print_table
@@ -11,12 +11,12 @@ def balances(ctx: click.Context):
"""
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():
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

View File

@@ -1,9 +1,9 @@
import click
from leggen.api_client import LeggenAPIClient
from leggen.main import cli
from leggen.api_client import LeggendAPIClient
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()
@@ -12,12 +12,12 @@ def add(ctx):
"""
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():
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

View File

@@ -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

View File

@@ -1,7 +1,7 @@
import click
from leggen.api_client import LeggenAPIClient
from leggen.main import cli
from leggen.api_client import LeggendAPIClient
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
"""
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():
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

View File

@@ -1,7 +1,7 @@
import click
from leggen.api_client import LeggenAPIClient
from leggen.main import cli
from leggen.api_client import LeggendAPIClient
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
"""
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():
error("Cannot connect to leggen server. Please ensure it's running.")
error("Cannot connect to leggend service. Please ensure it's running.")
return
try:

View File

@@ -1,7 +1,7 @@
import click
from leggen.api_client import LeggenAPIClient
from leggen.main import cli
from leggen.api_client import LeggendAPIClient
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.
"""
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():
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

549
leggen/database/sqlite.py Normal file
View File

@@ -0,0 +1,549 @@
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 accounts table if it doesn't exist
cursor.execute(
"""CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
institution_id TEXT,
status TEXT,
iban TEXT,
name TEXT,
currency TEXT,
created DATETIME,
last_accessed DATETIME,
last_updated DATETIME
)"""
)
# Create indexes for accounts table
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_accounts_institution_id
ON accounts(institution_id)"""
)
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_accounts_status
ON accounts(status)"""
)
# Create the balances table if it doesn't exist
cursor.execute(
"""CREATE TABLE IF NOT EXISTS balances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT,
bank TEXT,
status TEXT,
iban TEXT,
amount REAL,
currency TEXT,
type TEXT,
timestamp DATETIME
)"""
)
# Create indexes for better performance
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_balances_account_id
ON balances(account_id)"""
)
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_balances_timestamp
ON balances(timestamp)"""
)
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_balances_account_type_timestamp
ON balances(account_id, type, timestamp)"""
)
# Insert balance into SQLite database
try:
cursor.execute(
"""INSERT INTO balances (
account_id,
bank,
status,
iban,
amount,
currency,
type,
timestamp
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(
balance["account_id"],
balance["bank"],
balance["status"],
balance["iban"],
balance["amount"],
balance["currency"],
balance["type"],
balance["timestamp"],
),
)
except IntegrityError:
warning(f"[{balance['account_id']}] Skipped duplicate balance")
# Commit changes and close the connection
conn.commit()
conn.close()
success(f"[{balance['account_id']}] Inserted balance of type {balance['type']}")
return balance
def persist_transactions(ctx: click.Context, account: str, transactions: list) -> list:
# Connect to SQLite database
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,
hide_missing_ids=True,
):
"""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}%")
if hide_missing_ids:
query += (
" AND internalTransactionId IS NOT NULL AND internalTransactionId != ''"
)
# 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']}%")
if filters.get("hide_missing_ids", True):
query += (
" AND internalTransactionId IS NOT NULL AND internalTransactionId != ''"
)
try:
cursor.execute(query, params)
count = cursor.fetchone()[0]
conn.close()
return count
except Exception as e:
conn.close()
raise e
def persist_account(account_data: dict):
"""Persist account details to SQLite database"""
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 accounts table if it doesn't exist
cursor.execute(
"""CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
institution_id TEXT,
status TEXT,
iban TEXT,
name TEXT,
currency TEXT,
created DATETIME,
last_accessed DATETIME,
last_updated DATETIME
)"""
)
# Create indexes for accounts table
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_accounts_institution_id
ON accounts(institution_id)"""
)
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_accounts_status
ON accounts(status)"""
)
try:
# Insert or replace account data
cursor.execute(
"""INSERT OR REPLACE INTO accounts (
id,
institution_id,
status,
iban,
name,
currency,
created,
last_accessed,
last_updated
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
account_data["id"],
account_data["institution_id"],
account_data["status"],
account_data.get("iban"),
account_data.get("name"),
account_data.get("currency"),
account_data["created"],
account_data.get("last_accessed"),
account_data.get("last_updated", account_data["created"]),
),
)
conn.commit()
conn.close()
success(f"[{account_data['id']}] Account details persisted to database")
return account_data
except Exception as e:
conn.close()
raise e
def get_accounts(account_ids=None):
"""Get account details from SQLite database"""
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()
query = "SELECT * FROM accounts"
params = []
if account_ids:
placeholders = ",".join("?" * len(account_ids))
query += f" WHERE id IN ({placeholders})"
params.extend(account_ids)
query += " ORDER BY created DESC"
try:
cursor.execute(query, params)
rows = cursor.fetchall()
accounts = [dict(row) for row in rows]
conn.close()
return accounts
except Exception as e:
conn.close()
raise e
def get_account(account_id: str):
"""Get specific account details from SQLite database"""
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:
cursor.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
row = cursor.fetchone()
conn.close()
if row:
return dict(row)
return None
except Exception as e:
conn.close()
raise e

View File

@@ -6,7 +6,6 @@ from pathlib import Path
import click
from leggen.utils.config import load_config
from leggen.utils.paths import path_manager
from leggen.utils.text import error
cmd_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), "commands"))
@@ -78,7 +77,7 @@ class Group(click.Group):
"-c",
"--config",
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,
callback=load_config,
is_eager=True,
@@ -87,27 +86,13 @@ class Group(click.Group):
show_envvar=True,
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(
"--api-url",
type=str,
default="http://localhost:8000",
envvar="LEGGEN_API_URL",
envvar="LEGGEND_API_URL",
show_envvar=True,
help="URL of the leggen API service",
help="URL of the leggend API service",
)
@click.group(
cls=Group,
@@ -115,7 +100,7 @@ class Group(click.Group):
)
@click.version_option(package_name="leggen")
@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
"""
@@ -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:]:
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
ctx.obj["api_url"] = api_url

View File

@@ -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

View File

@@ -1,87 +0,0 @@
from datetime import datetime
from typing import Any, Dict, List
class TransactionProcessor:
"""Handles processing and transformation of raw transaction data"""
def process_transactions(
self,
account_id: str,
account_info: Dict[str, Any],
transaction_data: Dict[str, Any],
) -> List[Dict[str, Any]]:
"""Process raw transaction data into standardized format"""
transactions = []
# Process booked transactions
for transaction in transaction_data.get("transactions", {}).get("booked", []):
processed = self._process_single_transaction(
account_id, account_info, transaction, "booked"
)
transactions.append(processed)
# Process pending transactions
for transaction in transaction_data.get("transactions", {}).get("pending", []):
processed = self._process_single_transaction(
account_id, account_info, transaction, "pending"
)
transactions.append(processed)
return transactions
def _process_single_transaction(
self,
account_id: str,
account_info: Dict[str, Any],
transaction: Dict[str, Any],
status: str,
) -> Dict[str, Any]:
"""Process a single transaction into standardized format"""
# Extract dates
booked_date = transaction.get("bookingDateTime") or transaction.get(
"bookingDate"
)
value_date = transaction.get("valueDateTime") or transaction.get("valueDate")
if booked_date and value_date:
min_date = min(
datetime.fromisoformat(booked_date), datetime.fromisoformat(value_date)
)
else:
date_str = booked_date or value_date
if not date_str:
raise ValueError("No valid date found in transaction")
min_date = datetime.fromisoformat(date_str)
# Extract amount and currency
transaction_amount = transaction.get("transactionAmount", {})
amount = float(transaction_amount.get("amount", 0))
currency = transaction_amount.get("currency", "")
# Extract description
description = transaction.get(
"remittanceInformationUnstructured",
",".join(transaction.get("remittanceInformationUnstructuredArray", [])),
)
# Extract transaction IDs - transactionId is now primary, internalTransactionId is reference
transaction_id = transaction.get("transactionId")
internal_transaction_id = transaction.get("internalTransactionId")
if not transaction_id:
raise ValueError("Transaction missing required transactionId field")
return {
"accountId": account_id,
"transactionId": transaction_id,
"internalTransactionId": internal_transaction_id,
"institutionId": account_info["institution_id"],
"iban": account_info.get("iban", "N/A"),
"transactionDate": min_date,
"description": description,
"transactionValue": amount,
"transactionCurrency": currency,
"transactionStatus": status,
"rawTransaction": transaction,
}

View File

@@ -1,189 +1,18 @@
import os
import sys
import tomllib
from pathlib import Path
from typing import Any, Dict, Optional
import click
import tomli_w
from loguru import logger
from pydantic import ValidationError
from leggen.models.config import Config as ConfigModel
from leggen.utils.paths import path_manager
from leggen.utils.text import error
class Config:
_instance = None
_config = None
_config_model = None
_config_path = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def load_config(self, config_path: Optional[str] = None) -> Dict[str, Any]:
if self._config is not None:
return self._config
if config_path is None:
config_path = os.environ.get("LEGGEN_CONFIG_FILE")
if not config_path:
config_path = str(path_manager.get_config_file_path())
self._config_path = config_path
try:
with open(config_path, "rb") as f:
raw_config = tomllib.load(f)
logger.info(f"Configuration loaded from {config_path}")
# Validate configuration using Pydantic
try:
self._config_model = ConfigModel(**raw_config)
self._config = self._config_model.dict(by_alias=True, exclude_none=True)
logger.info("Configuration validation successful")
except ValidationError as e:
logger.error(f"Configuration validation failed: {e}")
raise ValueError(f"Invalid configuration: {e}") from e
except FileNotFoundError:
logger.error(f"Configuration file not found: {config_path}")
raise
except Exception as e:
logger.error(f"Error loading configuration: {e}")
raise
return self._config
def save_config(
self,
config_data: Optional[Dict[str, Any]] = None,
config_path: Optional[str] = None,
) -> None:
"""Save configuration to TOML file"""
if config_data is None:
config_data = self._config
if config_path is None:
config_path = self._config_path or os.environ.get("LEGGEN_CONFIG_FILE")
if not config_path:
config_path = str(path_manager.get_config_file_path())
if config_path is None:
raise ValueError("No config path specified")
if config_data is None:
raise ValueError("No config data to save")
# Validate the configuration before saving
try:
validated_model = ConfigModel(**config_data)
validated_config = validated_model.dict(by_alias=True, exclude_none=True)
except ValidationError as e:
logger.error(f"Configuration validation failed before save: {e}")
raise ValueError(f"Invalid configuration: {e}") from e
# Ensure directory exists
Path(config_path).parent.mkdir(parents=True, exist_ok=True)
try:
with open(config_path, "wb") as f:
tomli_w.dump(validated_config, f)
# Update in-memory config
self._config = validated_config
self._config_model = validated_model
self._config_path = config_path
logger.info(f"Configuration saved to {config_path}")
except Exception as e:
logger.error(f"Error saving configuration: {e}")
raise
def update_config(self, section: str, key: str, value: Any) -> None:
"""Update a specific configuration value"""
if self._config is None:
self.load_config()
if self._config is None:
raise RuntimeError("Failed to load config")
if section not in self._config:
self._config[section] = {}
self._config[section][key] = value
self.save_config()
def update_section(self, section: str, data: Dict[str, Any]) -> None:
"""Update an entire configuration section"""
if self._config is None:
self.load_config()
if self._config is None:
raise RuntimeError("Failed to load config")
self._config[section] = data
self.save_config()
@property
def config(self) -> Dict[str, Any]:
if self._config is None:
self.load_config()
if self._config is None:
raise RuntimeError("Failed to load config")
return self._config
@property
def gocardless_config(self) -> Dict[str, str]:
return self.config.get("gocardless", {})
@property
def database_config(self) -> Dict[str, Any]:
return self.config.get("database", {})
@property
def notifications_config(self) -> Dict[str, Any]:
return self.config.get("notifications", {})
@property
def filters_config(self) -> Dict[str, Any]:
return self.config.get("filters", {})
@property
def scheduler_config(self) -> Dict[str, Any]:
"""Get scheduler configuration with defaults"""
default_schedule = {
"sync": {
"enabled": True,
"hour": 3,
"minute": 0,
"cron": None, # Optional custom cron expression
}
}
return self.config.get("scheduler", default_schedule)
def load_config(ctx: click.Context, _, filename):
try:
with click.open_file(str(filename), "rb") as f:
raw_config = tomllib.load(f)
# Validate configuration using Pydantic
try:
validated_model = ConfigModel(**raw_config)
ctx.obj = validated_model.dict(by_alias=True, exclude_none=True)
except ValidationError as e:
error(f"Configuration validation failed: {e}")
sys.exit(1)
# TODO: Implement configuration file validation (use pydantic?)
ctx.obj = tomllib.load(f)
except FileNotFoundError:
error(
"Configuration file not found. Provide a valid configuration file path with leggen --config <path> or LEGGEN_CONFIG=<path> environment variable."
)
sys.exit(1)
# Global singleton instance
config = Config()

122
leggen/utils/database.py Normal file
View File

@@ -0,0 +1,122 @@
from datetime import datetime
import click
import leggen.database.sqlite as sqlite_engine
from leggen.utils.text import info, warning
def persist_balance(ctx: click.Context, account: str, balance: dict) -> None:
sqlite = ctx.obj.get("database", {}).get("sqlite", True)
if not sqlite:
warning("SQLite database is disabled, skipping balance saving")
return
info(f"[{account}] Fetched balances, saving to SQLite")
sqlite_engine.persist_balances(ctx, balance)
def persist_transactions(ctx: click.Context, account: str, transactions: list) -> list:
sqlite = ctx.obj.get("database", {}).get("sqlite", True)
if not sqlite:
warning("SQLite database is disabled, skipping transaction saving")
# WARNING: This will return the transactions list as is, without saving it to any database
# Possible duplicate notifications will be sent if the filters are enabled
return transactions
info(f"[{account}] Fetched {len(transactions)} transactions, saving to SQLite")
return sqlite_engine.persist_transactions(ctx, account, transactions)
def save_transactions(ctx: click.Context, account: str) -> list:
import requests
api_url = ctx.obj.get("api_url", "http://localhost:8000")
info(f"[{account}] Getting account details")
res = requests.get(f"{api_url}/accounts/{account}")
res.raise_for_status()
account_info = res.json()
info(f"[{account}] Getting transactions")
transactions = []
res = requests.get(f"{api_url}/accounts/{account}/transactions/")
res.raise_for_status()
account_transactions = res.json().get("transactions", [])
for transaction in account_transactions.get("booked", []):
booked_date = transaction.get("bookingDateTime") or transaction.get(
"bookingDate"
)
value_date = transaction.get("valueDateTime") or transaction.get("valueDate")
if booked_date and value_date:
min_date = min(
datetime.fromisoformat(booked_date), datetime.fromisoformat(value_date)
)
else:
min_date = datetime.fromisoformat(booked_date or value_date)
transactionValue = float(
transaction.get("transactionAmount", {}).get("amount", 0)
)
currency = transaction.get("transactionAmount", {}).get("currency", "")
description = transaction.get(
"remittanceInformationUnstructured",
",".join(transaction.get("remittanceInformationUnstructuredArray", [])),
)
t = {
"internalTransactionId": transaction.get("internalTransactionId"),
"institutionId": account_info["institution_id"],
"iban": account_info.get("iban", "N/A"),
"transactionDate": min_date,
"description": description,
"transactionValue": transactionValue,
"transactionCurrency": currency,
"transactionStatus": "booked",
"accountId": account,
"rawTransaction": transaction,
}
transactions.append(t)
for transaction in account_transactions.get("pending", []):
booked_date = transaction.get("bookingDateTime") or transaction.get(
"bookingDate"
)
value_date = transaction.get("valueDateTime") or transaction.get("valueDate")
if booked_date and value_date:
min_date = min(
datetime.fromisoformat(booked_date), datetime.fromisoformat(value_date)
)
else:
min_date = datetime.fromisoformat(booked_date or value_date)
transactionValue = float(
transaction.get("transactionAmount", {}).get("amount", 0)
)
currency = transaction.get("transactionAmount", {}).get("currency", "")
description = transaction.get(
"remittanceInformationUnstructured",
",".join(transaction.get("remittanceInformationUnstructuredArray", [])),
)
t = {
"internalTransactionId": transaction.get("internalTransactionId"),
"institutionId": account_info["institution_id"],
"iban": account_info.get("iban", "N/A"),
"transactionDate": min_date,
"description": description,
"transactionValue": transactionValue,
"transactionCurrency": currency,
"transactionStatus": "pending",
"accountId": account,
"rawTransaction": transaction,
}
transactions.append(t)
return persist_transactions(ctx, account, transactions)

View File

@@ -1,78 +0,0 @@
"""Centralized path management for Leggen."""
import contextlib
import os
from pathlib import Path
from typing import Optional
class PathManager:
"""Manages configurable paths for config and database files."""
def __init__(self):
self._config_dir: Optional[Path] = None
self._database_path: Optional[Path] = None
def get_config_dir(self) -> Path:
"""Get the configuration directory."""
if self._config_dir is not None:
return self._config_dir
# Check environment variable first
config_dir = os.environ.get("LEGGEN_CONFIG_DIR")
if config_dir:
return Path(config_dir)
# Default to ~/.config/leggen
return Path.home() / ".config" / "leggen"
def set_config_dir(self, path: Path) -> None:
"""Set the configuration directory."""
self._config_dir = Path(path)
def get_config_file_path(self) -> Path:
"""Get the configuration file path."""
return self.get_config_dir() / "config.toml"
def get_database_path(self) -> Path:
"""Get the database file path and ensure the directory exists."""
if self._database_path is not None:
db_path = self._database_path
else:
# Check environment variable first
database_path = os.environ.get("LEGGEN_DATABASE_PATH")
if database_path:
db_path = Path(database_path)
else:
# Default to config_dir/leggen.db
db_path = self.get_config_dir() / "leggen.db"
# Try to ensure the directory exists, but handle permission errors gracefully
with contextlib.suppress(PermissionError, OSError):
db_path.parent.mkdir(parents=True, exist_ok=True)
return db_path
def set_database_path(self, path: Path) -> None:
"""Set the database file path."""
self._database_path = Path(path)
def get_auth_file_path(self) -> Path:
"""Get the authentication file path."""
return self.get_config_dir() / "auth.json"
def ensure_config_dir_exists(self) -> None:
"""Ensure the configuration directory exists."""
self.get_config_dir().mkdir(parents=True, exist_ok=True)
def ensure_database_dir_exists(self) -> None:
"""Ensure the database directory exists.
Note: get_database_path() now automatically ensures the directory exists,
so this method is mainly for explicit directory creation in tests.
"""
self.get_database_path().parent.mkdir(parents=True, exist_ok=True)
# Global instance for the application
path_manager = PathManager()

0
leggend/__init__.py Normal file
View File

View File

@@ -1,5 +1,5 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import List, Optional, Dict, Any
from pydantic import BaseModel
@@ -24,7 +24,6 @@ class AccountDetails(BaseModel):
status: str
iban: Optional[str] = None
name: Optional[str] = None
display_name: Optional[str] = None
currency: Optional[str] = None
created: datetime
last_accessed: Optional[datetime] = None
@@ -34,20 +33,10 @@ class AccountDetails(BaseModel):
json_encoders = {datetime: lambda v: v.isoformat() if v else None}
class AccountUpdate(BaseModel):
"""Account update model"""
display_name: Optional[str] = None
class Config:
json_encoders = {datetime: lambda v: v.isoformat() if v else None}
class Transaction(BaseModel):
"""Transaction model"""
transaction_id: str # NEW: stable bank-provided transaction ID
internal_transaction_id: Optional[str] = None # OLD: unstable GoCardless ID
internal_transaction_id: Optional[str] = None
institution_id: str
iban: Optional[str] = None
account_id: str
@@ -65,7 +54,6 @@ class Transaction(BaseModel):
class TransactionSummary(BaseModel):
"""Transaction summary for lists"""
transaction_id: str # NEW: stable bank-provided transaction ID
internal_transaction_id: Optional[str] = None
date: datetime
description: str

View File

@@ -1,4 +1,4 @@
from typing import List, Optional
from typing import Optional, List
from pydantic import BaseModel

View File

@@ -1,17 +1,15 @@
from typing import List, Optional, Union
from typing import Optional, List, Union
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
from leggen.api.models.accounts import (
AccountBalance,
from leggend.api.models.common import APIResponse
from leggend.api.models.accounts import (
AccountDetails,
AccountUpdate,
AccountBalance,
Transaction,
TransactionSummary,
)
from leggen.api.models.common import APIResponse
from leggen.services.database_service import DatabaseService
from leggend.services.database_service import DatabaseService
router = APIRouter()
database_service = DatabaseService()
@@ -53,7 +51,6 @@ async def get_all_accounts() -> APIResponse:
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"),
@@ -113,7 +110,6 @@ async def get_account_details(account_id: str) -> APIResponse:
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"),
@@ -218,35 +214,6 @@ async def get_all_balances() -> APIResponse:
) 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,
@@ -276,8 +243,7 @@ async def get_account_transactions(
# Return simplified transaction summaries
data = [
TransactionSummary(
transaction_id=txn["transactionId"], # NEW: stable bank-provided ID
internal_transaction_id=txn.get("internalTransactionId"),
internal_transaction_id=txn["internalTransactionId"],
date=txn["transactionDate"],
description=txn["description"],
amount=txn["transactionValue"],
@@ -291,8 +257,7 @@ async def get_account_transactions(
# Return full transaction details
data = [
Transaction(
transaction_id=txn["transactionId"], # NEW: stable bank-provided ID
internal_transaction_id=txn.get("internalTransactionId"),
internal_transaction_id=txn["internalTransactionId"],
institution_id=txn["institutionId"],
iban=txn["iban"],
account_id=txn["accountId"],
@@ -320,40 +285,3 @@ async def get_account_transactions(
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

View File

@@ -1,15 +1,15 @@
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
from leggen.api.models.banks import (
BankConnectionRequest,
BankConnectionStatus,
from leggend.api.models.common import APIResponse
from leggend.api.models.banks import (
BankInstitution,
BankConnectionRequest,
BankRequisition,
BankConnectionStatus,
)
from leggen.api.models.common import APIResponse
from leggen.services.gocardless_service import GoCardlessService
from leggen.utils.gocardless import REQUISITION_STATUS
from leggend.services.gocardless_service import GoCardlessService
from leggend.utils.gocardless import REQUISITION_STATUS
router = APIRouter()
gocardless_service = GoCardlessService()

View File

@@ -1,18 +1,17 @@
from typing import Any, Dict
from typing import Dict, Any
from fastapi import APIRouter, HTTPException
from loguru import logger
from leggen.api.models.common import APIResponse
from leggen.api.models.notifications import (
DiscordConfig,
NotificationFilters,
from leggend.api.models.common import APIResponse
from leggend.api.models.notifications import (
NotificationSettings,
NotificationTest,
DiscordConfig,
TelegramConfig,
NotificationFilters,
)
from leggen.services.notification_service import NotificationService
from leggen.utils.config import config
from leggend.services.notification_service import NotificationService
from leggend.config import config
router = APIRouter()
notification_service = NotificationService()
@@ -37,11 +36,14 @@ async def get_notification_settings() -> APIResponse:
if discord_config.get("webhook")
else None,
telegram=TelegramConfig(
token="***" if telegram_config.get("api-key") else "",
chat_id=telegram_config.get("chat-id", 0),
token="***"
if (telegram_config.get("token") or telegram_config.get("api-key"))
else "",
chat_id=telegram_config.get("chat_id")
or telegram_config.get("chat-id", 0),
enabled=telegram_config.get("enabled", True),
)
if telegram_config.get("api-key")
if (telegram_config.get("token") or telegram_config.get("api-key"))
else None,
filters=NotificationFilters(
case_insensitive=filters_config.get("case-insensitive", []),
@@ -77,8 +79,8 @@ async def update_notification_settings(settings: NotificationSettings) -> APIRes
if settings.telegram:
notifications_config["telegram"] = {
"api-key": settings.telegram.token,
"chat-id": settings.telegram.chat_id,
"token": settings.telegram.token,
"chat_id": settings.telegram.chat_id,
"enabled": settings.telegram.enabled,
}
@@ -153,12 +155,24 @@ async def get_notification_services() -> APIResponse:
"telegram": {
"name": "Telegram",
"enabled": bool(
notifications_config.get("telegram", {}).get("api-key")
and notifications_config.get("telegram", {}).get("chat-id")
(
notifications_config.get("telegram", {}).get("token")
or notifications_config.get("telegram", {}).get("api-key")
)
and (
notifications_config.get("telegram", {}).get("chat_id")
or notifications_config.get("telegram", {}).get("chat-id")
)
),
"configured": bool(
notifications_config.get("telegram", {}).get("api-key")
and notifications_config.get("telegram", {}).get("chat-id")
(
notifications_config.get("telegram", {}).get("token")
or notifications_config.get("telegram", {}).get("api-key")
)
and (
notifications_config.get("telegram", {}).get("chat_id")
or notifications_config.get("telegram", {}).get("chat-id")
)
),
"active": notifications_config.get("telegram", {}).get("enabled", True),
},

View File

@@ -1,13 +1,12 @@
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException
from fastapi import APIRouter, HTTPException, BackgroundTasks
from loguru import logger
from leggen.api.models.common import APIResponse
from leggen.api.models.sync import SchedulerConfig, SyncRequest
from leggen.background.scheduler import scheduler
from leggen.services.sync_service import SyncService
from leggen.utils.config import config
from leggend.api.models.common import APIResponse
from leggend.api.models.sync import SyncRequest, SchedulerConfig
from leggend.services.sync_service import SyncService
from leggend.background.scheduler import scheduler
from leggend.config import config
router = APIRouter()
sync_service = SyncService()

View File

@@ -1,24 +1,26 @@
from typing import Optional, List, Union
from datetime import datetime, timedelta
from typing import List, Optional, Union
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
from leggen.api.models.accounts import Transaction, TransactionSummary
from leggen.api.models.common import APIResponse, PaginatedResponse
from leggen.services.database_service import DatabaseService
from leggend.api.models.common import APIResponse
from leggend.api.models.accounts import Transaction, TransactionSummary
from leggend.services.database_service import DatabaseService
router = APIRouter()
database_service = DatabaseService()
@router.get("/transactions", response_model=PaginatedResponse)
@router.get("/transactions", response_model=APIResponse)
async def get_all_transactions(
page: int = Query(default=1, ge=1, description="Page number (1-based)"),
per_page: int = Query(default=50, le=500, description="Items per page"),
limit: Optional[int] = Query(default=100, le=500),
offset: Optional[int] = Query(default=0, ge=0),
summary_only: bool = Query(
default=True, description="Return transaction summaries only"
),
hide_missing_ids: bool = Query(
default=True, description="Hide transactions without internalTransactionId"
),
date_from: Optional[str] = Query(
default=None, description="Filter from date (YYYY-MM-DD)"
),
@@ -35,13 +37,9 @@ async def get_all_transactions(
default=None, description="Search in transaction descriptions"
),
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
) -> PaginatedResponse:
) -> APIResponse:
"""Get all transactions from database with filtering options"""
try:
# Calculate offset from page and per_page
offset = (page - 1) * per_page
limit = per_page
# Get transactions from database instead of GoCardless API
db_transactions = await database_service.get_transactions_from_db(
account_id=account_id,
@@ -52,9 +50,21 @@ async def get_all_transactions(
min_amount=min_amount,
max_amount=max_amount,
search=search,
hide_missing_ids=hide_missing_ids,
)
# Get total count for pagination info (respecting the same filters)
total_transactions = await database_service.get_transaction_count_from_db(
account_id=account_id,
date_from=date_from,
date_to=date_to,
min_amount=min_amount,
max_amount=max_amount,
search=search,
hide_missing_ids=hide_missing_ids,
)
# Get total count for pagination info
total_transactions = await database_service.get_transaction_count_from_db(
account_id=account_id,
date_from=date_from,
@@ -70,8 +80,7 @@ async def get_all_transactions(
# Return simplified transaction summaries
data = [
TransactionSummary(
transaction_id=txn["transactionId"], # NEW: stable bank-provided ID
internal_transaction_id=txn.get("internalTransactionId"),
internal_transaction_id=txn["internalTransactionId"],
date=txn["transactionDate"],
description=txn["description"],
amount=txn["transactionValue"],
@@ -85,8 +94,7 @@ async def get_all_transactions(
# Return full transaction details
data = [
Transaction(
transaction_id=txn["transactionId"], # NEW: stable bank-provided ID
internal_transaction_id=txn.get("internalTransactionId"),
internal_transaction_id=txn["internalTransactionId"],
institution_id=txn["institutionId"],
iban=txn["iban"],
account_id=txn["accountId"],
@@ -100,19 +108,11 @@ async def get_all_transactions(
for txn in db_transactions
]
total_pages = (total_transactions + per_page - 1) // per_page
return PaginatedResponse(
actual_offset = offset or 0
return APIResponse(
success=True,
data=data,
pagination={
"total": total_transactions,
"page": page,
"per_page": per_page,
"total_pages": total_pages,
"has_next": page < total_pages,
"has_prev": page > 1,
},
message=f"Retrieved {len(data)} transactions (showing {actual_offset + 1}-{actual_offset + len(data)} of {total_transactions})",
)
except Exception as e:
@@ -126,6 +126,9 @@ async def get_all_transactions(
async def get_transaction_stats(
days: int = Query(default=30, description="Number of days to include in stats"),
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
hide_missing_ids: bool = Query(
default=True, description="Hide transactions without internalTransactionId"
),
) -> APIResponse:
"""Get transaction statistics for the last N days from database"""
try:
@@ -143,6 +146,7 @@ async def get_transaction_stats(
date_from=date_from,
date_to=date_to,
limit=None, # Get all matching transactions for stats
hide_missing_ids=hide_missing_ids,
)
# Calculate stats
@@ -203,88 +207,3 @@ async def get_transaction_stats(
raise HTTPException(
status_code=500, detail=f"Failed to get transaction stats: {str(e)}"
) from e
@router.get("/transactions/analytics", response_model=APIResponse)
async def get_transactions_for_analytics(
days: int = Query(default=365, description="Number of days to include"),
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
) -> APIResponse:
"""Get all transactions for analytics (no pagination) for the last N days"""
try:
# Date range for analytics
end_date = datetime.now()
start_date = end_date - timedelta(days=days)
# Format dates for database query
date_from = start_date.isoformat()
date_to = end_date.isoformat()
# Get ALL transactions from database (no limit for analytics)
transactions = await database_service.get_transactions_from_db(
account_id=account_id,
date_from=date_from,
date_to=date_to,
limit=None, # No limit - get all transactions
)
# Transform for frontend (summary format)
transaction_summaries = [
{
"transaction_id": txn["transactionId"],
"date": txn["transactionDate"],
"description": txn["description"],
"amount": txn["transactionValue"],
"currency": txn["transactionCurrency"],
"status": txn["transactionStatus"],
"account_id": txn["accountId"],
}
for txn in transactions
]
return APIResponse(
success=True,
data=transaction_summaries,
message=f"Retrieved {len(transaction_summaries)} transactions for analytics",
)
except Exception as e:
logger.error(f"Failed to get transactions for analytics: {e}")
raise HTTPException(
status_code=500, detail=f"Failed to get analytics transactions: {str(e)}"
) from e
@router.get("/transactions/monthly-stats", response_model=APIResponse)
async def get_monthly_transaction_stats(
days: int = Query(default=365, description="Number of days to include"),
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
) -> APIResponse:
"""Get monthly transaction statistics aggregated by the database"""
try:
# Date range for monthly stats
end_date = datetime.now()
start_date = end_date - timedelta(days=days)
# Format dates for database query
date_from = start_date.isoformat()
date_to = end_date.isoformat()
# Get monthly aggregated stats from database
monthly_stats = await database_service.get_monthly_transaction_stats_from_db(
account_id=account_id,
date_from=date_from,
date_to=date_to,
)
return APIResponse(
success=True,
data=monthly_stats,
message=f"Retrieved monthly stats for last {days} days",
)
except Exception as e:
logger.error(f"Failed to get monthly transaction stats: {e}")
raise HTTPException(
status_code=500, detail=f"Failed to get monthly stats: {str(e)}"
) from e

View File

@@ -2,9 +2,9 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from loguru import logger
from leggen.services.notification_service import NotificationService
from leggen.services.sync_service import SyncService
from leggen.utils.config import config
from leggend.config import config
from leggend.services.sync_service import SyncService
from leggend.services.notification_service import NotificationService
class BackgroundScheduler:

Some files were not shown because too many files have changed in this diff Show More