mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-29 12:09:16 +00:00
Compare commits
3 Commits
main
...
59e5bc13d6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59e5bc13d6 | ||
|
|
b63d30c7a5 | ||
|
|
ecdf9f8ba3 |
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
@@ -2,12 +2,9 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "dev"]
|
||||
branches: [ "main", "dev" ]
|
||||
pull_request:
|
||||
branches: ["main", "dev"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
branches: [ "main", "dev" ]
|
||||
|
||||
jobs:
|
||||
test-python:
|
||||
@@ -46,8 +43,8 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
30
.github/workflows/release.yml
vendored
30
.github/workflows/release.yml
vendored
@@ -5,11 +5,6 @@ on:
|
||||
tags:
|
||||
- "**"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -49,9 +44,6 @@ jobs:
|
||||
|
||||
push-docker-backend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -98,9 +90,6 @@ jobs:
|
||||
|
||||
push-docker-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -148,8 +137,6 @@ jobs:
|
||||
create-github-release:
|
||||
name: Create GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
needs: [build, publish-to-pypi, push-docker-backend, push-docker-frontend]
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -157,20 +144,23 @@ jobs:
|
||||
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
|
||||
uses: orhun/git-cliff-action@v4
|
||||
id: release_notes
|
||||
with:
|
||||
config: cliff.toml
|
||||
args: --current
|
||||
env:
|
||||
GITHUB_REPO: ${{ github.repository }}
|
||||
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.content }}
|
||||
body: ${{ steps.release_notes.outputs.notes }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -165,4 +165,3 @@ leggen.db
|
||||
*.db
|
||||
config.toml
|
||||
.claude/
|
||||
.playwright-mcp/
|
||||
|
||||
11
.mcp.json
11
.mcp.json
@@ -2,11 +2,16 @@
|
||||
"mcpServers": {
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": ["shadcn@latest", "mcp"]
|
||||
"args": [
|
||||
"shadcn@latest",
|
||||
"mcp"
|
||||
]
|
||||
},
|
||||
"playwright": {
|
||||
"browsermcp": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest"]
|
||||
"args": [
|
||||
"@browsermcp/mcp@latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||
rev: "v0.14.6"
|
||||
rev: "v0.13.0"
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
- id: ruff-format
|
||||
@@ -10,6 +10,7 @@ repos:
|
||||
- id: trailing-whitespace
|
||||
exclude: ".*\\.md$"
|
||||
- id: end-of-file-fixer
|
||||
- id: check-added-large-files
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: mypy
|
||||
|
||||
@@ -41,7 +41,7 @@ The command outputs instructions for setting the required environment variable t
|
||||
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/api/v1/docs`
|
||||
- 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`
|
||||
@@ -138,4 +138,3 @@ This repository follows conventional changelog practices. Refer to `CONTRIBUTING
|
||||
- Commit message format and scoping
|
||||
- Release process using `scripts/release.sh`
|
||||
- Pre-commit hooks setup with `pre-commit install`
|
||||
- When the pre-commit fails, the commit is canceled
|
||||
|
||||
242
CHANGELOG.md
242
CHANGELOG.md
@@ -1,246 +1,4 @@
|
||||
|
||||
## 2025.11.0 (2025/11/22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **frontend:** Apply iOS safe area insets to body element instead of individual components. ([d2bc179d](https://github.com/elisiariocouto/leggen/commit/d2bc179d5937172a01ebbfffd35e7617f0ac32af))
|
||||
- Fallback to internal_transaction_id when bank transactions do not have transaction_id. ([b1b348ba](https://github.com/elisiariocouto/leggen/commit/b1b348badb5d1ea9c01ef9ecab1003252165468c))
|
||||
|
||||
|
||||
|
||||
## 2025.10.2 (2025/10/06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **frontend:** Improve nginx config. ([d78f4811](https://github.com/elisiariocouto/leggen/commit/d78f4811922df7e637abe65b1d0b1157dd331c3c))
|
||||
- **frontend:** Include default mime types. ([7c06a1d8](https://github.com/elisiariocouto/leggen/commit/7c06a1d8b9bca3da2c481d9e89e7564cfffe32a3))
|
||||
|
||||
|
||||
|
||||
## 2025.10.1 (2025/10/05)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **frontend:** Fix PWA caching system, remove prompts. ([1cd63731](https://github.com/elisiariocouto/leggen/commit/1cd63731a35a1c77a59d7ae1a898ad8f22e362e4))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
- Improve documentation, add gif showing web app. ([0750c41b](https://github.com/elisiariocouto/leggen/commit/0750c41b7b6634900ec19b1701d58b06346028e3))
|
||||
|
||||
|
||||
### Refactor
|
||||
|
||||
- **frontend:** Standardize button styling using shadcn Button component. ([38fddeb2](https://github.com/elisiariocouto/leggen/commit/38fddeb281588de41d8ff6292c1dd48443a059a4))
|
||||
|
||||
|
||||
|
||||
## 2025.10.0 (2025/10/01)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **gocardless:** Increase timeout to 30 seconds, some requests take some time. ([ca7968cc](https://github.com/elisiariocouto/leggen/commit/ca7968cc3c625e243fe2d75590a9e56f3100072b))
|
||||
|
||||
|
||||
|
||||
## 2025.9.26 (2025/09/30)
|
||||
|
||||
### Debug
|
||||
|
||||
- Log different sets of GoCardless rate limits. ([8802d247](https://github.com/elisiariocouto/leggen/commit/8802d24789cbb8e854d857a0d7cc89a25a26f378))
|
||||
|
||||
|
||||
|
||||
## 2025.9.25 (2025/09/30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **api:** Fix S3 backup path-style configuration and improve UX. ([22ec0e36](https://github.com/elisiariocouto/leggen/commit/22ec0e36b11e5b017075bee51de0423a53ec4648))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **api:** Add S3 backup functionality to backend ([7f2a4634](https://github.com/elisiariocouto/leggen/commit/7f2a4634c51814b6785433a25ce42d20aea0558c))
|
||||
- **frontend:** Add S3 backup UI and complete backup functionality ([01229130](https://github.com/elisiariocouto/leggen/commit/0122913052793bcbf011cb557ef182be21c5de93))
|
||||
- **frontend:** Add ability to list backups and create a backup on demand. ([473f126d](https://github.com/elisiariocouto/leggen/commit/473f126d3e699521172539f2ca0bff0579ccee51))
|
||||
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Log more rate limit headers. ([d36568da](https://github.com/elisiariocouto/leggen/commit/d36568da540d4fb4ae1fa10b322a3fa77dcc5360))
|
||||
|
||||
|
||||
|
||||
## 2025.9.24 (2025/09/25)
|
||||
|
||||
### Features
|
||||
|
||||
- **frontend:** Add comprehensive bank account management system. ([ef7c026d](https://github.com/elisiariocouto/leggen/commit/ef7c026db9911cc3be8d5f48e42a4d7beb4b9d0a))
|
||||
|
||||
|
||||
|
||||
## 2025.9.24 (2025/09/25)
|
||||
|
||||
### Features
|
||||
|
||||
- **frontend:** Add comprehensive bank account management system. ([ef7c026d](https://github.com/elisiariocouto/leggen/commit/ef7c026db9911cc3be8d5f48e42a4d7beb4b9d0a))
|
||||
|
||||
|
||||
|
||||
## 2025.9.23 (2025/09/24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli:** Fix API URL handling for subpaths and improve client robustness. ([ae5d034d](https://github.com/elisiariocouto/leggen/commit/ae5d034d4b1da785e3dc240c1d60c2cae7de8010))
|
||||
- Correct sync trigger types from manual to scheduled/retry. ([460c5af6](https://github.com/elisiariocouto/leggen/commit/460c5af6ea343ef5685b716413d01d7a30fa9acf))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **frontend:** Add version-based cache invalidation for PWA updates ([d4edf69f](https://github.com/elisiariocouto/leggen/commit/d4edf69f2cea2515a00435ee974116948057148d))
|
||||
|
||||
|
||||
|
||||
## 2025.9.23 (2025/09/24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli:** Fix API URL handling for subpaths and improve client robustness. ([ae5d034d](https://github.com/elisiariocouto/leggen/commit/ae5d034d4b1da785e3dc240c1d60c2cae7de8010))
|
||||
- Correct sync trigger types from manual to scheduled/retry. ([460c5af6](https://github.com/elisiariocouto/leggen/commit/460c5af6ea343ef5685b716413d01d7a30fa9acf))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **frontend:** Add version-based cache invalidation for PWA updates ([d4edf69f](https://github.com/elisiariocouto/leggen/commit/d4edf69f2cea2515a00435ee974116948057148d))
|
||||
|
||||
|
||||
|
||||
## 2025.9.22 (2025/09/24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **api:** Add automatic token refresh on 401 errors in GoCardless service. ([36d698f7](https://github.com/elisiariocouto/leggen/commit/36d698f7ce05c7db0e4b07dd07979de2c70b053e))
|
||||
- **api:** Fix banks API test fixtures to match GoCardless response format. ([24792744](https://github.com/elisiariocouto/leggen/commit/24792744f9660063e1a3abb9ed8e925fea9a5e60))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **api:** Add separate sync failure notifications. ([e4e3f885](https://github.com/elisiariocouto/leggen/commit/e4e3f885eab1d45b0e10465ca04eb3f74e9c5a4d))
|
||||
- **api:** Add bank logo support and fix banks endpoint type errors. ([b9ca74e7](https://github.com/elisiariocouto/leggen/commit/b9ca74e7e67c3877728b749a42f15f0c0d906561))
|
||||
- **frontend:** Improve System page and TransactionsTable UX. ([62cd55e4](https://github.com/elisiariocouto/leggen/commit/62cd55e48fff7c2f5db9dd8230a7bd500e8f6eed))
|
||||
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Add pre-commit instructions to AGENTS.md. ([a8f70412](https://github.com/elisiariocouto/leggen/commit/a8f704129b2453e604cf2ab776791ba1e91e6fc7))
|
||||
|
||||
|
||||
|
||||
## 2025.9.22 (2025/09/24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **api:** Add automatic token refresh on 401 errors in GoCardless service. ([36d698f7](https://github.com/elisiariocouto/leggen/commit/36d698f7ce05c7db0e4b07dd07979de2c70b053e))
|
||||
- **api:** Fix banks API test fixtures to match GoCardless response format. ([24792744](https://github.com/elisiariocouto/leggen/commit/24792744f9660063e1a3abb9ed8e925fea9a5e60))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **api:** Add separate sync failure notifications. ([e4e3f885](https://github.com/elisiariocouto/leggen/commit/e4e3f885eab1d45b0e10465ca04eb3f74e9c5a4d))
|
||||
- **api:** Add bank logo support and fix banks endpoint type errors. ([b9ca74e7](https://github.com/elisiariocouto/leggen/commit/b9ca74e7e67c3877728b749a42f15f0c0d906561))
|
||||
- **frontend:** Improve System page and TransactionsTable UX. ([62cd55e4](https://github.com/elisiariocouto/leggen/commit/62cd55e48fff7c2f5db9dd8230a7bd500e8f6eed))
|
||||
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Add pre-commit instructions to AGENTS.md. ([a8f70412](https://github.com/elisiariocouto/leggen/commit/a8f704129b2453e604cf2ab776791ba1e91e6fc7))
|
||||
|
||||
|
||||
|
||||
## 2025.9.21 (2025/09/22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **frontend:** Remove duplicate padding from Analytics page for consistent layout ([27f3f2db](https://github.com/elisiariocouto/leggen/commit/27f3f2dbba91777234769cca08de5dbe8b378f10))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **frontend:** Implement notification settings with separate drawers and improved design. ([c332642e](https://github.com/elisiariocouto/leggen/commit/c332642e648cb0a29100b500c03e17ae322845f8))
|
||||
|
||||
|
||||
|
||||
## 2025.9.21 (2025/09/22)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **frontend:** Remove duplicate padding from Analytics page for consistent layout ([27f3f2db](https://github.com/elisiariocouto/leggen/commit/27f3f2dbba91777234769cca08de5dbe8b378f10))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **frontend:** Implement notification settings with separate drawers and improved design. ([c332642e](https://github.com/elisiariocouto/leggen/commit/c332642e648cb0a29100b500c03e17ae322845f8))
|
||||
|
||||
|
||||
|
||||
## 2025.9.20 (2025/09/22)
|
||||
|
||||
### Features
|
||||
|
||||
- **api:** Add sync operations tracking and database storage ([61f95920](https://github.com/elisiariocouto/leggen/commit/61f9592095220f47b758e19a63d70096deb35a92))
|
||||
- **frontend:** Rename notifications page to System Status and add sync operations section ([3f2ff21e](https://github.com/elisiariocouto/leggen/commit/3f2ff21eac2c24e04d5957bbd15a6b8a5d0c021d))
|
||||
- Consolidate version display to use health endpoint. ([76a30d23](https://github.com/elisiariocouto/leggen/commit/76a30d23af07466ecfd571e7b7bb6724412652c1))
|
||||
|
||||
|
||||
### Refactor
|
||||
|
||||
- **frontend:** Reorganize pages with tabbed Settings and focused System page ([65404848](https://github.com/elisiariocouto/leggen/commit/65404848aa27cfcb11a371c194ca533b17cb08ff))
|
||||
|
||||
|
||||
|
||||
## 2025.9.20 (2025/09/22)
|
||||
|
||||
### Features
|
||||
|
||||
- **api:** Add sync operations tracking and database storage ([61f95920](https://github.com/elisiariocouto/leggen/commit/61f9592095220f47b758e19a63d70096deb35a92))
|
||||
- **frontend:** Rename notifications page to System Status and add sync operations section ([3f2ff21e](https://github.com/elisiariocouto/leggen/commit/3f2ff21eac2c24e04d5957bbd15a6b8a5d0c021d))
|
||||
- Consolidate version display to use health endpoint. ([76a30d23](https://github.com/elisiariocouto/leggen/commit/76a30d23af07466ecfd571e7b7bb6724412652c1))
|
||||
|
||||
|
||||
### Refactor
|
||||
|
||||
- **frontend:** Reorganize pages with tabbed Settings and focused System page ([65404848](https://github.com/elisiariocouto/leggen/commit/65404848aa27cfcb11a371c194ca533b17cb08ff))
|
||||
|
||||
|
||||
|
||||
## 2025.9.19 (2025/09/21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **frontend:** Close mobile sidebar on navigation item click ([dd24a0e0](https://github.com/elisiariocouto/leggen/commit/dd24a0e0d34c3b2ff37bc75b50162768b4d15cc5))
|
||||
- **frontend:** Resolve mobile horizontal scroll in Time Period filters ([4ce56fdc](https://github.com/elisiariocouto/leggen/commit/4ce56fdc042b0dbf3442a1ab201392700add90d6))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **frontend:** Add version display in header near connection status ([340e1a32](https://github.com/elisiariocouto/leggen/commit/340e1a3235916566a4e403e9ec7b82ea799fbffd))
|
||||
|
||||
|
||||
|
||||
## 2025.9.19 (2025/09/21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **frontend:** Close mobile sidebar on navigation item click ([dd24a0e0](https://github.com/elisiariocouto/leggen/commit/dd24a0e0d34c3b2ff37bc75b50162768b4d15cc5))
|
||||
- **frontend:** Resolve mobile horizontal scroll in Time Period filters ([4ce56fdc](https://github.com/elisiariocouto/leggen/commit/4ce56fdc042b0dbf3442a1ab201392700add90d6))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **frontend:** Add version display in header near connection status ([340e1a32](https://github.com/elisiariocouto/leggen/commit/340e1a3235916566a4e403e9ec7b82ea799fbffd))
|
||||
|
||||
|
||||
|
||||
## 2025.9.18 (2025/09/19)
|
||||
|
||||
### Documentation
|
||||
|
||||
280
README.md
280
README.md
@@ -1,21 +1,13 @@
|
||||
# 💲 leggen
|
||||
|
||||
An Open Banking CLI and API service for managing bank connections and transactions.
|
||||
|
||||
A self hosted Open Banking Dashboard, API and CLI 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.
|
||||
|
||||
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
|
||||
|
||||
### Frontend
|
||||
- [React](https://reactjs.org/): Modern web interface with TypeScript
|
||||
- [Vite](https://vitejs.dev/): Fast build tool and development server
|
||||
- [Tailwind CSS](https://tailwindcss.com/): Utility-first CSS framework
|
||||
- [shadcn/ui](https://ui.shadcn.com/): Modern component system built on Radix UI
|
||||
- [TanStack Query](https://tanstack.com/query): Powerful data synchronization for React
|
||||
|
||||
### 🔌 API & Backend
|
||||
- [FastAPI](https://fastapi.tiangolo.com/): High-performance async API backend (integrated into `leggen server`)
|
||||
- [GoCardless Open Banking API](https://developer.gocardless.com/bank-account-data/overview): for connecting to banks
|
||||
@@ -24,6 +16,12 @@ Having your bank data accessible through both CLI and REST API gives you the pow
|
||||
### 📦 Storage
|
||||
- [SQLite](https://www.sqlite.org): for storing transactions, simple and easy to use
|
||||
|
||||
### Frontend
|
||||
- [React](https://reactjs.org/): Modern web interface with TypeScript
|
||||
- [Vite](https://vitejs.dev/): Fast build tool and development server
|
||||
- [Tailwind CSS](https://tailwindcss.com/): Utility-first CSS framework
|
||||
- [shadcn/ui](https://ui.shadcn.com/): Modern component system built on Radix UI
|
||||
- [TanStack Query](https://tanstack.com/query): Powerful data synchronization for React
|
||||
|
||||
## ✨ Features
|
||||
|
||||
@@ -56,9 +54,10 @@ Having your bank data accessible through both CLI and REST API gives you the pow
|
||||
1. Create a GoCardless account at [https://gocardless.com/bank-account-data/](https://gocardless.com/bank-account-data/)
|
||||
2. Get your API credentials (key and secret)
|
||||
|
||||
### Installation
|
||||
### Installation Options
|
||||
|
||||
#### Docker Compose (Recommended)
|
||||
#### Option 1: Docker Compose (Recommended)
|
||||
The easiest way to get started is with Docker Compose, which includes both the React frontend and FastAPI backend:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
@@ -69,11 +68,50 @@ cd leggen
|
||||
mkdir -p data && cp config.example.toml data/config.toml
|
||||
# Edit data/config.toml with your GoCardless credentials
|
||||
|
||||
# Start all services
|
||||
# Start all services (frontend + backend)
|
||||
docker compose up -d
|
||||
|
||||
# Access the web interface at http://localhost:3000
|
||||
# API documentation at http://localhost:3000/api/v1/docs
|
||||
# API is available at http://localhost:8000
|
||||
```
|
||||
|
||||
#### Production Deployment
|
||||
|
||||
For production deployment using published Docker images:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/elisiariocouto/leggen.git
|
||||
cd leggen
|
||||
|
||||
# Create your configuration
|
||||
mkdir -p data && cp config.example.toml data/config.toml
|
||||
# Edit data/config.toml with your GoCardless credentials
|
||||
|
||||
# Start production services
|
||||
docker compose up -d
|
||||
|
||||
# Access the web interface at http://localhost:3000
|
||||
# API is available at http://localhost:8000
|
||||
```
|
||||
|
||||
### Development vs Production
|
||||
|
||||
- **Development**: Use `docker compose -f compose.dev.yml up -d` (builds from source)
|
||||
- **Production**: Use `docker compose up -d` (uses published images)
|
||||
|
||||
#### Option 2: Local Development
|
||||
For development or local installation:
|
||||
|
||||
```bash
|
||||
# Install with uv (recommended) or pip
|
||||
uv sync # or pip install -e .
|
||||
|
||||
# Start the API service
|
||||
uv run leggen server --reload # Development mode with auto-reload
|
||||
|
||||
# Use the CLI (in another terminal)
|
||||
uv run leggen --help
|
||||
```
|
||||
|
||||
### Configuration
|
||||
@@ -115,22 +153,214 @@ case_sensitive = ["SpecificStore"]
|
||||
|
||||
## 📖 Usage
|
||||
|
||||
### Web Interface
|
||||
Access the React web interface at `http://localhost:3000` after starting the services.
|
||||
### API Service (`leggen server`)
|
||||
|
||||
### API Service
|
||||
Visit `http://localhost:3000/api/v1/docs` for interactive API documentation.
|
||||
Start the FastAPI backend service:
|
||||
|
||||
### CLI Commands
|
||||
```bash
|
||||
leggen status # Check connection status
|
||||
leggen bank add # Connect to a new bank
|
||||
leggen balances # View account balances
|
||||
leggen transactions # List transactions
|
||||
leggen sync # Trigger background sync
|
||||
# Production mode
|
||||
leggen server
|
||||
|
||||
# Development mode with auto-reload
|
||||
leggen server --reload
|
||||
|
||||
# Custom host and port
|
||||
leggen server --host 127.0.0.1 --port 8080
|
||||
```
|
||||
|
||||
For more options, run `leggen --help` or `leggen <command> --help`.
|
||||
**API Documentation**: Visit `http://localhost:8000/docs` for interactive API documentation.
|
||||
|
||||
### CLI Commands (`leggen`)
|
||||
|
||||
#### Basic Commands
|
||||
```bash
|
||||
# Check connection status
|
||||
leggen status
|
||||
|
||||
# Connect to a new bank
|
||||
leggen bank add
|
||||
|
||||
# View account balances
|
||||
leggen balances
|
||||
|
||||
# List recent transactions
|
||||
leggen transactions --limit 20
|
||||
|
||||
# View detailed transactions
|
||||
leggen transactions --full
|
||||
```
|
||||
|
||||
#### Sync Operations
|
||||
```bash
|
||||
# Start background sync
|
||||
leggen sync
|
||||
|
||||
# Synchronous sync (wait for completion)
|
||||
leggen sync --wait
|
||||
|
||||
# Force sync (override running sync)
|
||||
leggen sync --force --wait
|
||||
```
|
||||
|
||||
#### API Integration
|
||||
```bash
|
||||
# Use custom API URL
|
||||
leggen --api-url http://localhost:8080 status
|
||||
|
||||
# Set via environment variable
|
||||
export LEGGEN_API_URL=http://localhost:8080
|
||||
leggen status
|
||||
```
|
||||
|
||||
### Docker Usage
|
||||
|
||||
#### Development (build from source)
|
||||
```bash
|
||||
# Start development services
|
||||
docker compose -f compose.dev.yml up -d
|
||||
|
||||
# View service status
|
||||
docker compose -f compose.dev.yml ps
|
||||
|
||||
# Check logs
|
||||
docker compose -f compose.dev.yml logs frontend
|
||||
docker compose -f compose.dev.yml logs leggen-server
|
||||
|
||||
# Stop development services
|
||||
docker compose -f compose.dev.yml down
|
||||
```
|
||||
|
||||
#### Production (use published images)
|
||||
```bash
|
||||
# Start production services
|
||||
docker compose up -d
|
||||
|
||||
# View service status
|
||||
docker compose ps
|
||||
|
||||
# Check logs
|
||||
docker compose logs frontend
|
||||
docker compose logs leggen-server
|
||||
|
||||
# Access the web interface at http://localhost:3000
|
||||
# API documentation at http://localhost:8000/docs
|
||||
|
||||
# Stop production services
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## 🔌 API Endpoints
|
||||
|
||||
The FastAPI backend provides comprehensive REST endpoints:
|
||||
|
||||
### Banks & Connections
|
||||
- `GET /api/v1/banks/institutions?country=PT` - List available banks
|
||||
- `POST /api/v1/banks/connect` - Create bank connection
|
||||
- `GET /api/v1/banks/status` - Connection status
|
||||
- `GET /api/v1/banks/countries` - Supported countries
|
||||
|
||||
### Accounts & Balances
|
||||
- `GET /api/v1/accounts` - List all accounts
|
||||
- `GET /api/v1/accounts/{id}` - Account details
|
||||
- `GET /api/v1/accounts/{id}/balances` - Account balances
|
||||
- `GET /api/v1/accounts/{id}/transactions` - Account transactions
|
||||
|
||||
### Transactions
|
||||
- `GET /api/v1/transactions` - All transactions with filtering
|
||||
- `GET /api/v1/transactions/stats` - Transaction statistics
|
||||
|
||||
### Sync & Scheduling
|
||||
- `POST /api/v1/sync` - Trigger background sync
|
||||
- `POST /api/v1/sync/now` - Synchronous sync
|
||||
- `GET /api/v1/sync/status` - Sync status
|
||||
- `GET/PUT /api/v1/sync/scheduler` - Scheduler configuration
|
||||
|
||||
### Notifications
|
||||
- `GET/PUT /api/v1/notifications/settings` - Manage notifications
|
||||
- `POST /api/v1/notifications/test` - Test notifications
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Local Development Setup
|
||||
```bash
|
||||
# Clone and setup
|
||||
git clone https://github.com/elisiariocouto/leggen.git
|
||||
cd leggen
|
||||
|
||||
# Install dependencies
|
||||
uv sync
|
||||
|
||||
# Start API service with auto-reload
|
||||
uv run leggen server --reload
|
||||
|
||||
# Use CLI commands
|
||||
uv run leggen status
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
Run the comprehensive test suite with:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
uv run pytest
|
||||
|
||||
# Run unit tests only
|
||||
uv run pytest tests/unit/
|
||||
|
||||
# Run with verbose output
|
||||
uv run pytest tests/unit/ -v
|
||||
|
||||
# Run specific test files
|
||||
uv run pytest tests/unit/test_config.py -v
|
||||
uv run pytest tests/unit/test_scheduler.py -v
|
||||
uv run pytest tests/unit/test_api_banks.py -v
|
||||
|
||||
# Run tests by markers
|
||||
uv run pytest -m unit # Unit tests
|
||||
uv run pytest -m api # API endpoint tests
|
||||
uv run pytest -m cli # CLI tests
|
||||
```
|
||||
|
||||
The test suite includes:
|
||||
- **Configuration management tests** - TOML config loading/saving
|
||||
- **API endpoint tests** - FastAPI route testing with mocked dependencies
|
||||
- **CLI API client tests** - HTTP client integration testing
|
||||
- **Background scheduler tests** - APScheduler job management
|
||||
- **Mock data and fixtures** - Realistic test data for banks, accounts, transactions
|
||||
|
||||
### Code Structure
|
||||
```
|
||||
leggen/ # CLI application
|
||||
├── commands/ # CLI command implementations
|
||||
├── utils/ # Shared utilities
|
||||
├── api/ # FastAPI API routes and models
|
||||
├── services/ # Business logic
|
||||
├── background/ # Background job scheduler
|
||||
└── api_client.py # API client for server communication
|
||||
|
||||
tests/ # Test suite
|
||||
├── conftest.py # Shared test fixtures
|
||||
└── unit/ # Unit tests
|
||||
├── test_config.py # Configuration tests
|
||||
├── test_scheduler.py # Background scheduler tests
|
||||
├── test_api_banks.py # Banks API tests
|
||||
├── test_api_accounts.py # Accounts API tests
|
||||
└── test_api_client.py # CLI API client tests
|
||||
```
|
||||
|
||||
### Contributing
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
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
|
||||
- Some banks may require additional authorization steps
|
||||
- Docker images are automatically built and published on releases
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
# Backend Refactoring Summary
|
||||
|
||||
## What Was Accomplished ✅
|
||||
|
||||
### 1. Removed DatabaseService Layer from Production Code
|
||||
- **Removed**: The `DatabaseService` class is no longer used in production API routes
|
||||
- **Replaced with**: Direct repository usage via FastAPI dependency injection
|
||||
- **Files changed**:
|
||||
- `leggen/api/routes/accounts.py` - Now uses `AccountRepo`, `BalanceRepo`, `TransactionRepo`, `AnalyticsProc`
|
||||
- `leggen/api/routes/transactions.py` - Now uses `TransactionRepo`, `AnalyticsProc`
|
||||
- `leggen/services/sync_service.py` - Now uses repositories directly
|
||||
- `leggen/commands/server.py` - Now uses `MigrationRepository` directly
|
||||
|
||||
### 2. Created Dependency Injection System
|
||||
- **New file**: `leggen/api/dependencies.py`
|
||||
- **Provides**: Centralized dependency injection setup for FastAPI
|
||||
- **Includes**: Factory functions for all repositories and data processors
|
||||
- **Type annotations**: `AccountRepo`, `BalanceRepo`, `TransactionRepo`, etc.
|
||||
|
||||
### 3. Simplified Code Architecture
|
||||
- **Before**: Routes → DatabaseService → Repositories
|
||||
- **After**: Routes → Repositories (via DI)
|
||||
- **Benefits**:
|
||||
- One less layer of indirection
|
||||
- Clearer dependencies
|
||||
- Easier to test with FastAPI's `app.dependency_overrides`
|
||||
- Better separation of concerns
|
||||
|
||||
### 4. Maintained Backward Compatibility
|
||||
- **DatabaseService** is kept but deprecated for test compatibility
|
||||
- Added deprecation warning when instantiated
|
||||
- Tests continue to work without immediate changes required
|
||||
|
||||
## Code Statistics
|
||||
|
||||
- **Lines removed from API layer**: ~16 imports of DatabaseService
|
||||
- **New dependency injection file**: 80 lines
|
||||
- **Files refactored**: 4 main files
|
||||
|
||||
## Benefits Achieved
|
||||
|
||||
1. **Cleaner Architecture**: Removed unnecessary abstraction layer
|
||||
2. **Better Testability**: FastAPI dependency overrides are cleaner than mocking
|
||||
3. **More Explicit Dependencies**: Function signatures show exactly what's needed
|
||||
4. **Easier to Maintain**: Less indirection makes code easier to follow
|
||||
5. **Performance**: Slightly fewer object instantiations per request
|
||||
|
||||
## What Still Needs Work
|
||||
|
||||
### Tests Need Updating
|
||||
The test files still patch `database_service` which no longer exists in routes:
|
||||
|
||||
```python
|
||||
# Old test pattern (needs updating):
|
||||
patch("leggen.api.routes.accounts.database_service.get_accounts_from_db")
|
||||
|
||||
# New pattern (should use):
|
||||
app.dependency_overrides[get_account_repository] = lambda: mock_repo
|
||||
```
|
||||
|
||||
**Files needing test updates**:
|
||||
- `tests/unit/test_api_accounts.py` (7 tests failing)
|
||||
- `tests/unit/test_api_transactions.py` (10 tests failing)
|
||||
- `tests/unit/test_analytics_fix.py` (2 tests failing)
|
||||
|
||||
### Test Update Strategy
|
||||
|
||||
**Option 1 - Quick Fix (Recommended for now)**:
|
||||
Keep `DatabaseService` and have routes import it again temporarily, update tests at leisure.
|
||||
|
||||
**Option 2 - Proper Fix**:
|
||||
Update all tests to use FastAPI dependency overrides pattern:
|
||||
|
||||
```python
|
||||
def test_get_accounts(fastapi_app, api_client, mock_account_repo):
|
||||
mock_account_repo.get_accounts.return_value = [...]
|
||||
|
||||
fastapi_app.dependency_overrides[get_account_repository] = lambda: mock_account_repo
|
||||
|
||||
response = api_client.get("/api/v1/accounts")
|
||||
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
```
|
||||
|
||||
## Migration Path Forward
|
||||
|
||||
1. ✅ **Phase 1**: Refactor production code (DONE)
|
||||
2. 🔄 **Phase 2**: Update tests to use dependency overrides (TODO)
|
||||
3. 🔄 **Phase 3**: Remove deprecated `DatabaseService` completely (TODO)
|
||||
4. 🔄 **Phase 4**: Consider extracting analytics logic to separate service (TODO)
|
||||
|
||||
## How to Use the New System
|
||||
|
||||
### In API Routes
|
||||
```python
|
||||
from leggen.api.dependencies import AccountRepo, BalanceRepo
|
||||
|
||||
@router.get("/accounts")
|
||||
async def get_accounts(
|
||||
account_repo: AccountRepo, # Injected automatically
|
||||
balance_repo: BalanceRepo, # Injected automatically
|
||||
) -> List[AccountDetails]:
|
||||
accounts = account_repo.get_accounts()
|
||||
# ...
|
||||
```
|
||||
|
||||
### In Tests (Future Pattern)
|
||||
```python
|
||||
def test_endpoint(fastapi_app, api_client):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_accounts.return_value = [...]
|
||||
|
||||
fastapi_app.dependency_overrides[get_account_repository] = lambda: mock_repo
|
||||
|
||||
response = api_client.get("/api/v1/accounts")
|
||||
# assertions...
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
The refactoring successfully simplified the backend architecture by:
|
||||
- Eliminating the DatabaseService middleman layer
|
||||
- Introducing proper dependency injection
|
||||
- Making dependencies more explicit and testable
|
||||
- Maintaining backward compatibility for a smooth transition
|
||||
|
||||
**Next steps**: Update test files to use the new dependency injection pattern, then remove the deprecated `DatabaseService` class entirely.
|
||||
@@ -28,13 +28,3 @@ enabled = true
|
||||
[filters]
|
||||
case_insensitive = ["salary", "utility"]
|
||||
case_sensitive = ["SpecificStore"]
|
||||
|
||||
# Optional: S3 backup configuration
|
||||
[backup.s3]
|
||||
access_key_id = "your-s3-access-key"
|
||||
secret_access_key = "your-s3-secret-key"
|
||||
bucket_name = "your-bucket-name"
|
||||
region = "us-east-1"
|
||||
# endpoint_url = "https://custom-s3-endpoint.com" # Optional: for custom S3-compatible endpoints
|
||||
path_style = false # Set to true for path-style addressing
|
||||
enabled = true
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 548 KiB |
@@ -1,102 +1,33 @@
|
||||
server {
|
||||
|
||||
# MIME types for PWA
|
||||
include mime.types;
|
||||
types {
|
||||
application/manifest+json webmanifest;
|
||||
}
|
||||
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Trust proxy headers from Caddy/upstream proxy
|
||||
set_real_ip_from 0.0.0.0/0;
|
||||
real_ip_header X-Forwarded-For;
|
||||
real_ip_recursive on;
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Enable gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json application/manifest+json image/svg+xml;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
|
||||
# Service worker - no cache, must revalidate
|
||||
location ~ ^/(sw\.js|workbox-.*\.js)$ {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
add_header Expires "0" always;
|
||||
add_header Service-Worker-Allowed "/" always;
|
||||
types {
|
||||
application/javascript js;
|
||||
}
|
||||
}
|
||||
|
||||
# PWA manifest - short cache with revalidation
|
||||
location ~ ^/manifest\.webmanifest$ {
|
||||
add_header Cache-Control "public, max-age=3600, must-revalidate" always;
|
||||
types {
|
||||
application/manifest+json webmanifest;
|
||||
}
|
||||
}
|
||||
|
||||
# Handle client-side routing (SPA)
|
||||
# Handle client-side routing
|
||||
location / {
|
||||
autoindex off;
|
||||
expires off;
|
||||
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
|
||||
try_files $uri $uri/ /index.html =404;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy to backend (configurable via API_BACKEND_URL env var)
|
||||
location /api/ {
|
||||
proxy_pass ${API_BACKEND_URL};
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Host $http_x_forwarded_host;
|
||||
proxy_redirect off;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Cache static assets with immutable flag
|
||||
location ~* \.(css|js)$ {
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable" always;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Cache images and icons
|
||||
location ~* \.(png|jpg|jpeg|gif|ico|svg|webp)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable" always;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Cache fonts (if any are added later)
|
||||
location ~* \.(woff|woff2|ttf|eot|otf)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable" always;
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Other static files
|
||||
location ~* \.(xml|txt)$ {
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, must-revalidate" always;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,11 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Leggen</title>
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta
|
||||
name="description"
|
||||
content="Personal finance management application"
|
||||
/>
|
||||
<meta name="description" content="Personal finance management application" />
|
||||
<meta name="application-name" content="Leggen" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
@@ -27,16 +21,8 @@
|
||||
|
||||
<!-- Dynamic theme-color - will be updated by JavaScript -->
|
||||
<meta name="theme-color" content="#0b74de" id="theme-color-meta" />
|
||||
<meta
|
||||
name="msapplication-navbutton-color"
|
||||
content="#0b74de"
|
||||
id="ms-theme-color-meta"
|
||||
/>
|
||||
<meta
|
||||
name="apple-mobile-web-app-status-bar-style"
|
||||
content="default"
|
||||
id="apple-status-bar-meta"
|
||||
/>
|
||||
<meta name="msapplication-navbutton-color" content="#0b74de" id="ms-theme-color-meta" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" id="apple-status-bar-meta" />
|
||||
|
||||
<!-- Icons -->
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
|
||||
|
||||
4082
frontend/package-lock.json
generated
4082
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,6 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
|
||||
@@ -78,7 +78,6 @@ export default function AccountSettings() {
|
||||
|
||||
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -145,8 +144,7 @@ export default function AccountSettings() {
|
||||
<CardHeader>
|
||||
<CardTitle>Account Management</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your connected bank accounts and customize their display
|
||||
names
|
||||
Manage your connected bank accounts and customize their display names
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -195,24 +193,8 @@ export default function AccountSettings() {
|
||||
{/* 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 w-10 h-10 sm:w-12 sm:h-12 rounded-full overflow-hidden bg-muted flex items-center justify-center">
|
||||
{account.logo && !failedImages.has(account.id) ? (
|
||||
<img
|
||||
src={account.logo}
|
||||
alt={`${account.institution_id} logo`}
|
||||
className="w-full h-full object-contain"
|
||||
onError={() => {
|
||||
console.warn(
|
||||
`Failed to load bank logo for ${account.institution_id}: ${account.logo}`,
|
||||
);
|
||||
setFailedImages(
|
||||
(prev) => new Set([...prev, account.id]),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
|
||||
)}
|
||||
<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 ? (
|
||||
@@ -234,28 +216,24 @@ export default function AccountSettings() {
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
<button
|
||||
onClick={handleEditSave}
|
||||
disabled={
|
||||
!editingName.trim() ||
|
||||
updateAccountMutation.isPending
|
||||
}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-100"
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEditCancel}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
className="p-1 text-gray-600 hover:text-gray-700"
|
||||
title="Cancel editing"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{account.institution_id}
|
||||
@@ -269,15 +247,13 @@ export default function AccountSettings() {
|
||||
account.name ||
|
||||
"Unnamed Account"}
|
||||
</h4>
|
||||
<Button
|
||||
<button
|
||||
onClick={() => handleEditStart(account)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 flex-shrink-0"
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{account.institution_id}
|
||||
@@ -348,8 +324,7 @@ export default function AccountSettings() {
|
||||
<CardHeader>
|
||||
<CardTitle>Add New Bank Account</CardTitle>
|
||||
<CardDescription>
|
||||
Connect additional bank accounts to track all your finances in one
|
||||
place
|
||||
Connect additional bank accounts to track all your finances in one place
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
@@ -357,8 +332,7 @@ export default function AccountSettings() {
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<Plus className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bank connection functionality is coming soon. Stay tuned for
|
||||
updates!
|
||||
Bank connection functionality is coming soon. Stay tuned for updates!
|
||||
</p>
|
||||
</div>
|
||||
<Button disabled variant="outline">
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
import { Button } from "./ui/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import AccountsSkeleton from "./AccountsSkeleton";
|
||||
import { BlurredValue } from "./ui/blurred-value";
|
||||
import type { Account, Balance } from "../types/api";
|
||||
|
||||
// Helper function to get status indicator color and styles
|
||||
@@ -159,7 +158,7 @@ export default function AccountsOverview() {
|
||||
Total Balance
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
<BlurredValue>{formatCurrency(totalBalance)}</BlurredValue>
|
||||
{formatCurrency(totalBalance)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-100 dark:bg-green-900/20 rounded-full">
|
||||
@@ -274,28 +273,24 @@ export default function AccountsOverview() {
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
<button
|
||||
onClick={handleEditSave}
|
||||
disabled={
|
||||
!editingName.trim() ||
|
||||
updateAccountMutation.isPending
|
||||
}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-100"
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEditCancel}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
className="p-1 text-gray-600 hover:text-gray-700"
|
||||
title="Cancel editing"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{account.institution_id}
|
||||
@@ -309,15 +304,13 @@ export default function AccountsOverview() {
|
||||
account.name ||
|
||||
"Unnamed Account"}
|
||||
</h4>
|
||||
<Button
|
||||
<button
|
||||
onClick={() => handleEditStart(account)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 flex-shrink-0"
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{account.institution_id}
|
||||
@@ -370,9 +363,7 @@ export default function AccountsOverview() {
|
||||
isPositive ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
<BlurredValue>
|
||||
{formatCurrency(balance, currency)}
|
||||
</BlurredValue>
|
||||
{formatCurrency(balance, currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { Plus, Building2, ExternalLink } from "lucide-react";
|
||||
import { apiClient } from "../lib/api";
|
||||
import { Button } from "./ui/button";
|
||||
import { Label } from "./ui/label";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "./ui/drawer";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "./ui/select";
|
||||
import { Alert, AlertDescription } from "./ui/alert";
|
||||
|
||||
export default function AddBankAccountDrawer() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedCountry, setSelectedCountry] = useState<string>("");
|
||||
const [selectedBank, setSelectedBank] = useState<string>("");
|
||||
|
||||
const { data: countries } = useQuery({
|
||||
queryKey: ["supportedCountries"],
|
||||
queryFn: apiClient.getSupportedCountries,
|
||||
});
|
||||
|
||||
const { data: banks, isLoading: banksLoading } = useQuery({
|
||||
queryKey: ["bankInstitutions", selectedCountry],
|
||||
queryFn: () => apiClient.getBankInstitutions(selectedCountry),
|
||||
enabled: !!selectedCountry,
|
||||
});
|
||||
|
||||
const connectBankMutation = useMutation({
|
||||
mutationFn: (institutionId: string) =>
|
||||
apiClient.createBankConnection(institutionId),
|
||||
onSuccess: (data) => {
|
||||
// Redirect to the bank's authorization link
|
||||
if (data.link) {
|
||||
window.open(data.link, "_blank");
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to create bank connection:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleCountryChange = (country: string) => {
|
||||
setSelectedCountry(country);
|
||||
setSelectedBank("");
|
||||
};
|
||||
|
||||
const handleConnect = () => {
|
||||
if (selectedBank) {
|
||||
connectBankMutation.mutate(selectedBank);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setSelectedCountry("");
|
||||
setSelectedBank("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
resetForm();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add New Account
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="max-h-[80vh]">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Connect Bank Account</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Select your country and bank to connect your account to Leggen
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="px-6 space-y-6 overflow-y-auto">
|
||||
{/* Country Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="country">Country</Label>
|
||||
<Select value={selectedCountry} onValueChange={handleCountryChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select your country" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{countries?.map((country) => (
|
||||
<SelectItem key={country.code} value={country.code}>
|
||||
{country.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Bank Selection */}
|
||||
{selectedCountry && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bank">Bank</Label>
|
||||
{banksLoading ? (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
Loading banks...
|
||||
</div>
|
||||
) : banks && banks.length > 0 ? (
|
||||
<Select value={selectedBank} onValueChange={setSelectedBank}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select your bank" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{banks.map((bank) => (
|
||||
<SelectItem key={bank.id} value={bank.id}>
|
||||
<div className="flex items-center space-x-2">
|
||||
{bank.logo ? (
|
||||
<img
|
||||
src={bank.logo}
|
||||
alt={`${bank.name} logo`}
|
||||
className="w-4 h-4 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Building2 className="w-4 h-4" />
|
||||
)}
|
||||
<span>{bank.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
No banks available for the selected country.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
{selectedBank && (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
You'll be redirected to your bank's website to authorize the
|
||||
connection. After approval, you'll return to Leggen and your
|
||||
account will start syncing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{connectBankMutation.isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
Failed to create bank connection. Please try again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DrawerFooter>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={!selectedBank || connectBankMutation.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
{connectBankMutation.isPending
|
||||
? "Connecting..."
|
||||
: "Open Bank Authorization"}
|
||||
</Button>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={connectBankMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</div>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import { apiClient } from "../lib/api";
|
||||
import { formatCurrency } from "../lib/utils";
|
||||
import { useState } from "react";
|
||||
import type { Account } from "../types/api";
|
||||
import { BlurredValue } from "./ui/blurred-value";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -34,7 +33,7 @@ import {
|
||||
const navigation = [
|
||||
{ name: "Overview", icon: List, to: "/" },
|
||||
{ name: "Analytics", icon: BarChart3, to: "/analytics" },
|
||||
{ name: "System", icon: Activity, to: "/system" },
|
||||
{ name: "System Status", icon: Activity, to: "/notifications" },
|
||||
{ name: "Settings", icon: Settings, to: "/settings" },
|
||||
];
|
||||
|
||||
@@ -62,7 +61,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
<Sidebar collapsible="icon" className="pt-safe-top pl-safe-left" {...props}>
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
@@ -70,11 +69,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
asChild
|
||||
className="data-[slot=sidebar-menu-button]:!p-1.5"
|
||||
>
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center space-x-2"
|
||||
onClick={handleNavigationClick}
|
||||
>
|
||||
<Link to="/" className="flex items-center space-x-2" onClick={handleNavigationClick}>
|
||||
<Logo size={24} />
|
||||
<span className="text-base font-semibold">Leggen</span>
|
||||
</Link>
|
||||
@@ -131,7 +126,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
|
||||
<div className="px-3 pb-2">
|
||||
<p className="text-xl font-bold text-foreground">
|
||||
<BlurredValue>{formatCurrency(totalBalance)}</BlurredValue>
|
||||
{formatCurrency(totalBalance)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{accounts?.length || 0} accounts
|
||||
@@ -143,10 +138,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<div className="border-t border-border/50 max-h-48 overflow-y-auto">
|
||||
{accounts.map((account) => {
|
||||
const primaryBalance = account.balances?.[0]?.amount || 0;
|
||||
const currency =
|
||||
account.balances?.[0]?.currency ||
|
||||
account.currency ||
|
||||
"EUR";
|
||||
const currency = account.balances?.[0]?.currency || account.currency || "EUR";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -159,14 +151,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
</div>
|
||||
<div className="space-y-1 min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-foreground truncate">
|
||||
{account.display_name ||
|
||||
account.name ||
|
||||
"Unnamed Account"}
|
||||
{account.display_name || account.name || "Unnamed Account"}
|
||||
</p>
|
||||
<p className="text-xs font-semibold text-foreground">
|
||||
<BlurredValue>
|
||||
{formatCurrency(primaryBalance, currency)}
|
||||
</BlurredValue>
|
||||
{formatCurrency(primaryBalance, currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { MessageSquare, TestTube } from "lucide-react";
|
||||
import { apiClient } from "../lib/api";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { EditButton } from "./ui/edit-button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "./ui/drawer";
|
||||
import type { NotificationSettings, DiscordConfig } from "../types/api";
|
||||
|
||||
interface DiscordConfigDrawerProps {
|
||||
settings: NotificationSettings | undefined;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function DiscordConfigDrawer({
|
||||
settings,
|
||||
trigger,
|
||||
}: DiscordConfigDrawerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [config, setConfig] = useState<DiscordConfig>({
|
||||
webhook: "",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.discord) {
|
||||
setConfig({ ...settings.discord });
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (discordConfig: DiscordConfig) =>
|
||||
apiClient.updateNotificationSettings({
|
||||
...settings,
|
||||
discord: discordConfig,
|
||||
filters: settings?.filters || {
|
||||
case_insensitive: [],
|
||||
case_sensitive: [],
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["notificationServices"] });
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to update Discord configuration:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiClient.testNotification({
|
||||
service: "discord",
|
||||
message:
|
||||
"Test notification from Leggen - Discord configuration is working!",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
console.log("Test Discord notification sent successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to send test Discord notification:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
updateMutation.mutate(config);
|
||||
};
|
||||
|
||||
const handleTest = () => {
|
||||
testMutation.mutate();
|
||||
};
|
||||
|
||||
const isConfigValid =
|
||||
config.webhook.trim().length > 0 &&
|
||||
config.webhook.includes("discord.com/api/webhooks");
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{trigger || <EditButton />}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle className="flex items-center space-x-2">
|
||||
<MessageSquare className="h-5 w-5 text-primary" />
|
||||
<span>Discord Configuration</span>
|
||||
</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Configure Discord webhook notifications for transaction alerts
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
||||
{/* Enable/Disable Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base font-medium">
|
||||
Enable Discord Notifications
|
||||
</Label>
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
onCheckedChange={(enabled) => setConfig({ ...config, enabled })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Webhook URL */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="discord-webhook">Discord Webhook URL</Label>
|
||||
<Input
|
||||
id="discord-webhook"
|
||||
type="url"
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
value={config.webhook}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, webhook: e.target.value })
|
||||
}
|
||||
disabled={!config.enabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create a webhook in your Discord server settings under
|
||||
Integrations → Webhooks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Configuration Status */}
|
||||
{config.enabled && (
|
||||
<div className="p-3 bg-muted rounded-md">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${isConfigValid ? "bg-green-500" : "bg-red-500"}`}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{isConfigValid
|
||||
? "Configuration Valid"
|
||||
: "Invalid Webhook URL"}
|
||||
</span>
|
||||
</div>
|
||||
{!isConfigValid && config.webhook.trim().length > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Please enter a valid Discord webhook URL
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DrawerFooter className="px-0">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending || !config.enabled}
|
||||
>
|
||||
{updateMutation.isPending
|
||||
? "Saving..."
|
||||
: "Save Configuration"}
|
||||
</Button>
|
||||
{config.enabled && isConfigValid && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTest}
|
||||
disabled={testMutation.isPending}
|
||||
>
|
||||
{testMutation.isPending ? (
|
||||
<>
|
||||
<TestTube className="h-4 w-4 mr-2 animate-spin" />
|
||||
Testing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TestTube className="h-4 w-4 mr-2" />
|
||||
Test
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="ghost">Cancel</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</form>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { apiClient } from "../lib/api";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { EditButton } from "./ui/edit-button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "./ui/drawer";
|
||||
import type { NotificationSettings, NotificationFilters } from "../types/api";
|
||||
|
||||
interface NotificationFiltersDrawerProps {
|
||||
settings: NotificationSettings | undefined;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function NotificationFiltersDrawer({
|
||||
settings,
|
||||
trigger,
|
||||
}: NotificationFiltersDrawerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<NotificationFilters>({
|
||||
case_insensitive: [],
|
||||
case_sensitive: [],
|
||||
});
|
||||
const [newCaseInsensitive, setNewCaseInsensitive] = useState("");
|
||||
const [newCaseSensitive, setNewCaseSensitive] = useState("");
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.filters) {
|
||||
setFilters({
|
||||
case_insensitive: [...(settings.filters.case_insensitive || [])],
|
||||
case_sensitive: [...(settings.filters.case_sensitive || [])],
|
||||
});
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (updatedFilters: NotificationFilters) =>
|
||||
apiClient.updateNotificationSettings({
|
||||
...settings,
|
||||
filters: updatedFilters,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to update notification filters:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
updateMutation.mutate(filters);
|
||||
};
|
||||
|
||||
const addCaseInsensitiveFilter = () => {
|
||||
if (
|
||||
newCaseInsensitive.trim() &&
|
||||
!filters.case_insensitive.includes(newCaseInsensitive.trim())
|
||||
) {
|
||||
setFilters({
|
||||
...filters,
|
||||
case_insensitive: [
|
||||
...filters.case_insensitive,
|
||||
newCaseInsensitive.trim(),
|
||||
],
|
||||
});
|
||||
setNewCaseInsensitive("");
|
||||
}
|
||||
};
|
||||
|
||||
const addCaseSensitiveFilter = () => {
|
||||
if (
|
||||
newCaseSensitive.trim() &&
|
||||
!filters.case_sensitive?.includes(newCaseSensitive.trim())
|
||||
) {
|
||||
setFilters({
|
||||
...filters,
|
||||
case_sensitive: [
|
||||
...(filters.case_sensitive || []),
|
||||
newCaseSensitive.trim(),
|
||||
],
|
||||
});
|
||||
setNewCaseSensitive("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeCaseInsensitiveFilter = (index: number) => {
|
||||
setFilters({
|
||||
...filters,
|
||||
case_insensitive: filters.case_insensitive.filter((_, i) => i !== index),
|
||||
});
|
||||
};
|
||||
|
||||
const removeCaseSensitiveFilter = (index: number) => {
|
||||
setFilters({
|
||||
...filters,
|
||||
case_sensitive:
|
||||
filters.case_sensitive?.filter((_, i) => i !== index) || [],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{trigger || <EditButton />}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<div className="mx-auto w-full max-w-2xl">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Notification Filters</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Configure which transaction descriptions should trigger
|
||||
notifications
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
||||
{/* Case Insensitive Filters */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-medium">
|
||||
Case Insensitive Filters
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Filters that match regardless of capitalization (e.g., "AMAZON"
|
||||
matches "amazon")
|
||||
</p>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
placeholder="Add filter term..."
|
||||
value={newCaseInsensitive}
|
||||
onChange={(e) => setNewCaseInsensitive(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addCaseInsensitiveFilter();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addCaseInsensitiveFilter}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 min-h-[2rem] p-3 bg-muted rounded-md">
|
||||
{filters.case_insensitive.length > 0 ? (
|
||||
filters.case_insensitive.map((filter, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center space-x-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm"
|
||||
>
|
||||
<span>{filter}</span>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => removeCaseInsensitiveFilter(index)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 hover:bg-secondary-foreground/10"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
No filters added
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Case Sensitive Filters */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-medium">
|
||||
Case Sensitive Filters
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Filters that match exactly as typed (e.g., "AMAZON" only matches
|
||||
"AMAZON")
|
||||
</p>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
placeholder="Add filter term..."
|
||||
value={newCaseSensitive}
|
||||
onChange={(e) => setNewCaseSensitive(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addCaseSensitiveFilter();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addCaseSensitiveFilter}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 min-h-[2rem] p-3 bg-muted rounded-md">
|
||||
{filters.case_sensitive && filters.case_sensitive.length > 0 ? (
|
||||
filters.case_sensitive.map((filter, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center space-x-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm"
|
||||
>
|
||||
<span>{filter}</span>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => removeCaseSensitiveFilter(index)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 hover:bg-secondary-foreground/10"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
No filters added
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DrawerFooter className="px-0">
|
||||
<Button type="submit" disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? "Saving..." : "Save Filters"}
|
||||
</Button>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</form>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -36,11 +36,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "./ui/select";
|
||||
import type {
|
||||
NotificationSettings,
|
||||
NotificationService,
|
||||
SyncOperationsResponse,
|
||||
} from "../types/api";
|
||||
import type { NotificationSettings, NotificationService, SyncOperationsResponse } from "../types/api";
|
||||
|
||||
export default function Notifications() {
|
||||
const [testService, setTestService] = useState("");
|
||||
@@ -167,8 +163,7 @@ export default function Notifications() {
|
||||
No sync operations yet
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Sync operations will appear here once you start syncing your
|
||||
accounts.
|
||||
Sync operations will appear here once you start syncing your accounts.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -176,9 +171,9 @@ export default function Notifications() {
|
||||
{syncOperations.operations.slice(0, 5).map((operation) => {
|
||||
const startedAt = new Date(operation.started_at);
|
||||
const isRunning = !operation.completed_at;
|
||||
const duration = operation.duration_seconds
|
||||
const duration = operation.duration_seconds
|
||||
? `${Math.round(operation.duration_seconds)}s`
|
||||
: "";
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -186,15 +181,13 @@ export default function Notifications() {
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-accent transition-colors"
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div
|
||||
className={`p-2 rounded-full ${
|
||||
isRunning
|
||||
? "bg-blue-100 text-blue-600"
|
||||
: operation.success
|
||||
? "bg-green-100 text-green-600"
|
||||
: "bg-red-100 text-red-600"
|
||||
}`}
|
||||
>
|
||||
<div className={`p-2 rounded-full ${
|
||||
isRunning
|
||||
? 'bg-blue-100 text-blue-600'
|
||||
: operation.success
|
||||
? 'bg-green-100 text-green-600'
|
||||
: 'bg-red-100 text-red-600'
|
||||
}`}>
|
||||
{isRunning ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : operation.success ? (
|
||||
@@ -206,11 +199,7 @@ export default function Notifications() {
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{isRunning
|
||||
? "Sync Running"
|
||||
: operation.success
|
||||
? "Sync Completed"
|
||||
: "Sync Failed"}
|
||||
{isRunning ? 'Sync Running' : operation.success ? 'Sync Completed' : 'Sync Failed'}
|
||||
</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{operation.trigger_type}
|
||||
@@ -219,12 +208,11 @@ export default function Notifications() {
|
||||
<div className="flex items-center space-x-4 mt-1 text-xs text-muted-foreground">
|
||||
<span className="flex items-center space-x-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>
|
||||
{startedAt.toLocaleDateString()}{" "}
|
||||
{startedAt.toLocaleTimeString()}
|
||||
</span>
|
||||
<span>{startedAt.toLocaleDateString()} {startedAt.toLocaleTimeString()}</span>
|
||||
</span>
|
||||
{duration && <span>Duration: {duration}</span>}
|
||||
{duration && (
|
||||
<span>Duration: {duration}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -235,9 +223,7 @@ export default function Notifications() {
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
<span>
|
||||
{operation.transactions_added} new transactions
|
||||
</span>
|
||||
<span>{operation.transactions_added} new transactions</span>
|
||||
</div>
|
||||
{operation.errors.length > 0 && (
|
||||
<div className="flex items-center space-x-2 mt-1 text-red-600">
|
||||
|
||||
156
frontend/src/components/PWAPrompts.tsx
Normal file
156
frontend/src/components/PWAPrompts.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, Download, RotateCcw } from "lucide-react";
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
}
|
||||
|
||||
interface PWAPromptProps {
|
||||
onInstall?: () => void;
|
||||
}
|
||||
|
||||
export function PWAInstallPrompt({ onInstall }: PWAPromptProps) {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
// Prevent the mini-infobar from appearing on mobile
|
||||
e.preventDefault();
|
||||
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||
setShowPrompt(true);
|
||||
};
|
||||
|
||||
window.addEventListener("beforeinstallprompt", handler);
|
||||
|
||||
return () => window.removeEventListener("beforeinstallprompt", handler);
|
||||
}, []);
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!deferredPrompt) return;
|
||||
|
||||
try {
|
||||
await deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
|
||||
if (outcome === "accepted") {
|
||||
onInstall?.();
|
||||
}
|
||||
|
||||
setDeferredPrompt(null);
|
||||
setShowPrompt(false);
|
||||
} catch (error) {
|
||||
console.error("Error installing PWA:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowPrompt(false);
|
||||
setDeferredPrompt(null);
|
||||
};
|
||||
|
||||
if (!showPrompt || !deferredPrompt) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 z-50">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<Download className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Install Leggen
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Add to your home screen for quick access
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-shrink-0 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="flex-1 bg-blue-600 text-white text-sm font-medium px-3 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PWAUpdatePromptProps {
|
||||
updateAvailable: boolean;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export function PWAUpdatePrompt({ updateAvailable, onUpdate }: PWAUpdatePromptProps) {
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (updateAvailable) {
|
||||
setShowPrompt(true);
|
||||
}
|
||||
}, [updateAvailable]);
|
||||
|
||||
const handleUpdate = () => {
|
||||
onUpdate();
|
||||
setShowPrompt(false);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowPrompt(false);
|
||||
};
|
||||
|
||||
if (!showPrompt || !updateAvailable) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 z-50">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<RotateCcw className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Update Available
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
A new version of Leggen is ready to install
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-shrink-0 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={handleUpdate}
|
||||
className="flex-1 bg-green-600 text-white text-sm font-medium px-3 py-2 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
Update Now
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Cloud, TestTube } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { apiClient } from "../lib/api";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { EditButton } from "./ui/edit-button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "./ui/drawer";
|
||||
import type { BackupSettings, S3Config } from "../types/api";
|
||||
|
||||
interface S3BackupConfigDrawerProps {
|
||||
settings?: BackupSettings;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function S3BackupConfigDrawer({
|
||||
settings,
|
||||
trigger,
|
||||
}: S3BackupConfigDrawerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [config, setConfig] = useState<S3Config>({
|
||||
access_key_id: "",
|
||||
secret_access_key: "",
|
||||
bucket_name: "",
|
||||
region: "us-east-1",
|
||||
endpoint_url: "",
|
||||
path_style: false,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.s3) {
|
||||
setConfig({ ...settings.s3 });
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (s3Config: S3Config) =>
|
||||
apiClient.updateBackupSettings({
|
||||
s3: s3Config,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["backupSettings"] });
|
||||
setOpen(false);
|
||||
toast.success("S3 backup configuration saved successfully");
|
||||
},
|
||||
onError: (error: Error & { response?: { data?: { detail?: string } } }) => {
|
||||
console.error("Failed to update S3 backup configuration:", error);
|
||||
const message =
|
||||
error?.response?.data?.detail ||
|
||||
"Failed to save S3 configuration. Please check your settings and try again.";
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiClient.testBackupConnection({
|
||||
service: "s3",
|
||||
config: config,
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
if (response.success) {
|
||||
console.log("S3 connection test successful");
|
||||
toast.success(
|
||||
"S3 connection test successful! Your configuration is working correctly.",
|
||||
);
|
||||
} else {
|
||||
console.error("S3 connection test failed:", response.message);
|
||||
toast.error(response.message || "S3 connection test failed. Please verify your credentials and settings.");
|
||||
}
|
||||
},
|
||||
onError: (error: Error & { response?: { data?: { detail?: string } } }) => {
|
||||
console.error("Failed to test S3 connection:", error);
|
||||
const message =
|
||||
error?.response?.data?.detail ||
|
||||
"S3 connection test failed. Please verify your credentials and settings.";
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
updateMutation.mutate(config);
|
||||
};
|
||||
|
||||
const handleTest = () => {
|
||||
testMutation.mutate();
|
||||
};
|
||||
|
||||
const isConfigValid =
|
||||
config.access_key_id.trim().length > 0 &&
|
||||
config.secret_access_key.trim().length > 0 &&
|
||||
config.bucket_name.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{trigger || <EditButton />}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<div className="mx-auto w-full max-w-sm">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle className="flex items-center space-x-2">
|
||||
<Cloud className="h-5 w-5 text-primary" />
|
||||
<span>S3 Backup Configuration</span>
|
||||
</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Configure S3 settings for automatic database backups
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-4 space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="enabled"
|
||||
checked={config.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setConfig({ ...config, enabled: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="enabled">Enable S3 backups</Label>
|
||||
</div>
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="access_key_id">Access Key ID</Label>
|
||||
<Input
|
||||
id="access_key_id"
|
||||
type="text"
|
||||
value={config.access_key_id}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, access_key_id: e.target.value })
|
||||
}
|
||||
placeholder="Your AWS Access Key ID"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="secret_access_key">Secret Access Key</Label>
|
||||
<Input
|
||||
id="secret_access_key"
|
||||
type="password"
|
||||
value={config.secret_access_key}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
secret_access_key: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Your AWS Secret Access Key"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bucket_name">Bucket Name</Label>
|
||||
<Input
|
||||
id="bucket_name"
|
||||
type="text"
|
||||
value={config.bucket_name}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, bucket_name: e.target.value })
|
||||
}
|
||||
placeholder="my-backup-bucket"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="region">Region</Label>
|
||||
<Input
|
||||
id="region"
|
||||
type="text"
|
||||
value={config.region}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, region: e.target.value })
|
||||
}
|
||||
placeholder="us-east-1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint_url">
|
||||
Custom Endpoint URL (Optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="endpoint_url"
|
||||
type="url"
|
||||
value={config.endpoint_url || ""}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, endpoint_url: e.target.value })
|
||||
}
|
||||
placeholder="https://custom-s3-endpoint.com"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For S3-compatible services like MinIO or DigitalOcean Spaces
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="path_style"
|
||||
checked={config.path_style}
|
||||
onCheckedChange={(checked) =>
|
||||
setConfig({ ...config, path_style: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="path_style">Use path-style addressing</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable for older S3 implementations or certain S3-compatible
|
||||
services
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DrawerFooter className="px-0">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending || !config.enabled}
|
||||
>
|
||||
{updateMutation.isPending
|
||||
? "Saving..."
|
||||
: "Save Configuration"}
|
||||
</Button>
|
||||
{config.enabled && isConfigValid && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTest}
|
||||
disabled={testMutation.isPending}
|
||||
>
|
||||
{testMutation.isPending ? (
|
||||
<>
|
||||
<TestTube className="h-4 w-4 mr-2 animate-spin" />
|
||||
Testing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TestTube className="h-4 w-4 mr-2" />
|
||||
Test
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="ghost">Cancel</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</form>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -1,985 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CreditCard,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Building2,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Edit2,
|
||||
Check,
|
||||
X,
|
||||
Bell,
|
||||
MessageSquare,
|
||||
Send,
|
||||
Trash2,
|
||||
User,
|
||||
Filter,
|
||||
Cloud,
|
||||
Archive,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
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 { Label } from "./ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
import { BlurredValue } from "./ui/blurred-value";
|
||||
import AccountsSkeleton from "./AccountsSkeleton";
|
||||
import NotificationFiltersDrawer from "./NotificationFiltersDrawer";
|
||||
import DiscordConfigDrawer from "./DiscordConfigDrawer";
|
||||
import TelegramConfigDrawer from "./TelegramConfigDrawer";
|
||||
import AddBankAccountDrawer from "./AddBankAccountDrawer";
|
||||
import S3BackupConfigDrawer from "./S3BackupConfigDrawer";
|
||||
import type {
|
||||
Account,
|
||||
Balance,
|
||||
NotificationSettings,
|
||||
NotificationService,
|
||||
BackupSettings,
|
||||
BackupInfo,
|
||||
} 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-amber-500",
|
||||
tooltip: "Pending",
|
||||
};
|
||||
case "error":
|
||||
case "failed":
|
||||
return {
|
||||
color: "bg-destructive",
|
||||
tooltip: "Error",
|
||||
};
|
||||
case "inactive":
|
||||
return {
|
||||
color: "bg-muted-foreground",
|
||||
tooltip: "Inactive",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: "bg-primary",
|
||||
tooltip: status,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default function Settings() {
|
||||
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
|
||||
const [showBackups, setShowBackups] = useState(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Account queries
|
||||
const {
|
||||
data: accounts,
|
||||
isLoading: accountsLoading,
|
||||
error: accountsError,
|
||||
refetch: refetchAccounts,
|
||||
} = useQuery<Account[]>({
|
||||
queryKey: ["accounts"],
|
||||
queryFn: apiClient.getAccounts,
|
||||
});
|
||||
|
||||
const { data: balances } = useQuery<Balance[]>({
|
||||
queryKey: ["balances"],
|
||||
queryFn: () => apiClient.getBalances(),
|
||||
});
|
||||
|
||||
// Notification queries
|
||||
const {
|
||||
data: notificationSettings,
|
||||
isLoading: settingsLoading,
|
||||
error: settingsError,
|
||||
refetch: refetchSettings,
|
||||
} = useQuery<NotificationSettings>({
|
||||
queryKey: ["notificationSettings"],
|
||||
queryFn: apiClient.getNotificationSettings,
|
||||
});
|
||||
|
||||
const {
|
||||
data: services,
|
||||
isLoading: servicesLoading,
|
||||
error: servicesError,
|
||||
refetch: refetchServices,
|
||||
} = useQuery<NotificationService[]>({
|
||||
queryKey: ["notificationServices"],
|
||||
queryFn: apiClient.getNotificationServices,
|
||||
});
|
||||
|
||||
const { data: bankConnections } = useQuery({
|
||||
queryKey: ["bankConnections"],
|
||||
queryFn: apiClient.getBankConnectionsStatus,
|
||||
});
|
||||
|
||||
// Backup queries
|
||||
const {
|
||||
data: backupSettings,
|
||||
isLoading: backupLoading,
|
||||
error: backupError,
|
||||
refetch: refetchBackup,
|
||||
} = useQuery<BackupSettings>({
|
||||
queryKey: ["backupSettings"],
|
||||
queryFn: apiClient.getBackupSettings,
|
||||
});
|
||||
|
||||
const {
|
||||
data: backups,
|
||||
isLoading: backupsLoading,
|
||||
error: backupsError,
|
||||
refetch: refetchBackups,
|
||||
} = useQuery<BackupInfo[]>({
|
||||
queryKey: ["backups"],
|
||||
queryFn: apiClient.listBackups,
|
||||
enabled: showBackups,
|
||||
});
|
||||
|
||||
// Account mutations
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
// Notification mutations
|
||||
const deleteServiceMutation = useMutation({
|
||||
mutationFn: apiClient.deleteNotificationService,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["notificationServices"] });
|
||||
},
|
||||
});
|
||||
|
||||
// Bank connection mutations
|
||||
const deleteBankConnectionMutation = useMutation({
|
||||
mutationFn: apiClient.deleteBankConnection,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["bankConnections"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["balances"] });
|
||||
},
|
||||
});
|
||||
|
||||
// Backup mutations
|
||||
const createBackupMutation = useMutation({
|
||||
mutationFn: () => apiClient.performBackupOperation({ operation: "backup" }),
|
||||
onSuccess: (response) => {
|
||||
if (response.success) {
|
||||
toast.success(response.message || "Backup created successfully!");
|
||||
queryClient.invalidateQueries({ queryKey: ["backups"] });
|
||||
} else {
|
||||
toast.error(response.message || "Failed to create backup.");
|
||||
}
|
||||
},
|
||||
onError: (error: Error & { response?: { data?: { detail?: string } } }) => {
|
||||
console.error("Failed to create backup:", error);
|
||||
const message =
|
||||
error?.response?.data?.detail ||
|
||||
"Failed to create backup. Please check your S3 configuration.";
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
// Account handlers
|
||||
const handleEditStart = (account: Account) => {
|
||||
setEditingAccountId(account.id);
|
||||
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("");
|
||||
};
|
||||
|
||||
// Notification handlers
|
||||
const handleDeleteService = (serviceName: string) => {
|
||||
if (
|
||||
confirm(
|
||||
`Are you sure you want to delete the ${serviceName} notification service?`,
|
||||
)
|
||||
) {
|
||||
deleteServiceMutation.mutate(serviceName.toLowerCase());
|
||||
}
|
||||
};
|
||||
|
||||
// Backup handlers
|
||||
const handleCreateBackup = () => {
|
||||
if (!backupSettings?.s3?.enabled) {
|
||||
toast.error("S3 backup is not enabled. Please configure and enable S3 backup first.");
|
||||
return;
|
||||
}
|
||||
createBackupMutation.mutate();
|
||||
};
|
||||
|
||||
const handleViewBackups = () => {
|
||||
if (!backupSettings?.s3?.enabled) {
|
||||
toast.error("S3 backup is not enabled. Please configure and enable S3 backup first.");
|
||||
return;
|
||||
}
|
||||
setShowBackups(true);
|
||||
};
|
||||
|
||||
const isLoading =
|
||||
accountsLoading || settingsLoading || servicesLoading || backupLoading;
|
||||
const hasError =
|
||||
accountsError || settingsError || servicesError || backupError;
|
||||
|
||||
if (isLoading) {
|
||||
return <AccountsSkeleton />;
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Failed to load settings</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();
|
||||
refetchSettings();
|
||||
refetchServices();
|
||||
refetchBackup();
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Tabs defaultValue="accounts" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="accounts" className="flex items-center space-x-2">
|
||||
<User className="h-4 w-4" />
|
||||
<span>Accounts</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="notifications"
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
<span>Notifications</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="backup" className="flex items-center space-x-2">
|
||||
<Cloud className="h-4 w-4" />
|
||||
<span>Backup</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="accounts" className="space-y-6">
|
||||
{/* Account Management Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account Management</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your connected bank accounts and customize their display
|
||||
names
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{!accounts || accounts.length === 0 ? (
|
||||
<CardContent className="p-6 text-center">
|
||||
<CreditCard className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
No accounts found
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Connect your first bank account to get started with Leggen.
|
||||
</p>
|
||||
</CardContent>
|
||||
) : (
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-border">
|
||||
{accounts.map((account) => {
|
||||
// Get balance from account's balances array or fallback to balances query
|
||||
const accountBalance = account.balances?.[0];
|
||||
const fallbackBalance = balances?.find(
|
||||
(b) => b.account_id === account.id,
|
||||
);
|
||||
const balance =
|
||||
accountBalance?.amount ||
|
||||
fallbackBalance?.balance_amount ||
|
||||
0;
|
||||
const currency =
|
||||
accountBalance?.currency ||
|
||||
fallbackBalance?.currency ||
|
||||
account.currency ||
|
||||
"EUR";
|
||||
const isPositive = balance >= 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={account.id}
|
||||
className="p-4 sm:p-6 hover:bg-accent transition-colors"
|
||||
>
|
||||
{/* Mobile layout - stack vertically */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||
<div className="flex-shrink-0 w-10 h-10 sm:w-12 sm:h-12 rounded-full overflow-hidden bg-muted flex items-center justify-center">
|
||||
{account.logo && !failedImages.has(account.id) ? (
|
||||
<img
|
||||
src={account.logo}
|
||||
alt={`${account.institution_id} logo`}
|
||||
className="w-6 h-6 sm:w-8 sm:h-8 object-contain"
|
||||
onError={() => {
|
||||
console.warn(
|
||||
`Failed to load bank logo for ${account.institution_id}: ${account.logo}`,
|
||||
);
|
||||
setFailedImages(
|
||||
(prev) => new Set([...prev, account.id]),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<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
|
||||
}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-100"
|
||||
title="Save changes"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleEditCancel}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
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)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 flex-shrink-0"
|
||||
title="Edit account name"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{account.institution_id}
|
||||
</p>
|
||||
{account.iban && (
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono break-all sm:break-normal">
|
||||
IBAN: {account.iban}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance and date section */}
|
||||
<div className="flex items-center justify-between sm:flex-col sm:items-end sm:text-right flex-shrink-0">
|
||||
{/* Date and status indicator - left on mobile, bottom on desktop */}
|
||||
<div className="flex items-center space-x-2 order-1 sm:order-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${getStatusIndicator(account.status).color} relative group cursor-help`}
|
||||
role="img"
|
||||
aria-label={`Account status: ${getStatusIndicator(account.status).tooltip}`}
|
||||
>
|
||||
{/* Tooltip */}
|
||||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 hidden group-hover:block bg-gray-900 text-white text-xs rounded py-1 px-2 whitespace-nowrap z-10">
|
||||
{getStatusIndicator(account.status).tooltip}
|
||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-2 border-transparent border-t-gray-900"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
|
||||
Updated{" "}
|
||||
{formatDate(
|
||||
account.last_accessed || account.created,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Balance - right on mobile, top on desktop */}
|
||||
<div className="flex items-center space-x-2 order-2 sm:order-1">
|
||||
{isPositive ? (
|
||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
<BlurredValue
|
||||
className={`text-base sm:text-lg font-semibold ${
|
||||
isPositive ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{formatCurrency(balance, currency)}
|
||||
</BlurredValue>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Bank Connections Status */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Bank Connections</CardTitle>
|
||||
<CardDescription>
|
||||
Status of all bank connection requests and their
|
||||
authorization state
|
||||
</CardDescription>
|
||||
</div>
|
||||
<AddBankAccountDrawer />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{!bankConnections || bankConnections.length === 0 ? (
|
||||
<CardContent className="p-6 text-center">
|
||||
<Building2 className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
No bank connections found
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Bank connection requests will appear here after you connect
|
||||
accounts.
|
||||
</p>
|
||||
</CardContent>
|
||||
) : (
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-border">
|
||||
{bankConnections.map((connection) => {
|
||||
const statusColor =
|
||||
connection.status.toLowerCase() === "ln"
|
||||
? "bg-green-500"
|
||||
: connection.status.toLowerCase() === "cr"
|
||||
? "bg-amber-500"
|
||||
: connection.status.toLowerCase() === "ex"
|
||||
? "bg-red-500"
|
||||
: "bg-muted-foreground";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={connection.requisition_id}
|
||||
className="p-4 sm:p-6 hover:bg-accent transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4 min-w-0 flex-1">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-muted flex items-center justify-center">
|
||||
<Building2 className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="text-base font-medium text-foreground truncate">
|
||||
{connection.bank_name}
|
||||
</h4>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${statusColor}`}
|
||||
title={connection.status_display}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{connection.status_display} •{" "}
|
||||
{connection.accounts_count} account
|
||||
{connection.accounts_count !== 1 ? "s" : ""}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
ID: {connection.requisition_id}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 flex-shrink-0">
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Created {formatDate(connection.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const isWorking =
|
||||
connection.status.toLowerCase() === "ln";
|
||||
const message = isWorking
|
||||
? `Are you sure you want to disconnect "${connection.bank_name}"? This will stop syncing new transactions but keep your existing transaction history.`
|
||||
: `Delete connection to ${connection.bank_name}?`;
|
||||
|
||||
if (confirm(message)) {
|
||||
deleteBankConnectionMutation.mutate(
|
||||
connection.requisition_id,
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={deleteBankConnectionMutation.isPending}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
title="Delete connection"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notifications" className="space-y-6">
|
||||
{/* Notification Services */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
<span>Notification Services</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your notification services
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{!services || services.length === 0 ? (
|
||||
<CardContent className="text-center">
|
||||
<Bell className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
No notification services configured
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Configure notification services in your backend to receive
|
||||
alerts.
|
||||
</p>
|
||||
</CardContent>
|
||||
) : (
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-border">
|
||||
{services.map((service) => (
|
||||
<div
|
||||
key={service.name}
|
||||
className="p-6 hover:bg-accent transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3 bg-muted rounded-full">
|
||||
{service.name.toLowerCase().includes("discord") ? (
|
||||
<MessageSquare className="h-6 w-6 text-muted-foreground" />
|
||||
) : service.name
|
||||
.toLowerCase()
|
||||
.includes("telegram") ? (
|
||||
<Send className="h-6 w-6 text-muted-foreground" />
|
||||
) : (
|
||||
<Bell className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h4 className="text-lg font-medium text-foreground capitalize">
|
||||
{service.name}
|
||||
</h4>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
service.enabled && service.configured
|
||||
? "bg-green-500"
|
||||
: service.enabled
|
||||
? "bg-amber-500"
|
||||
: "bg-muted-foreground"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{service.enabled && service.configured
|
||||
? "Active"
|
||||
: service.enabled
|
||||
? "Needs Configuration"
|
||||
: "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{service.name.toLowerCase().includes("discord") ? (
|
||||
<DiscordConfigDrawer
|
||||
settings={notificationSettings}
|
||||
/>
|
||||
) : service.name
|
||||
.toLowerCase()
|
||||
.includes("telegram") ? (
|
||||
<TelegramConfigDrawer
|
||||
settings={notificationSettings}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
onClick={() => handleDeleteService(service.name)}
|
||||
disabled={deleteServiceMutation.isPending}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Notification Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Filter className="h-5 w-5 text-primary" />
|
||||
<span>Notification Filters</span>
|
||||
</CardTitle>
|
||||
<NotificationFiltersDrawer settings={notificationSettings} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{notificationSettings?.filters ? (
|
||||
<div className="space-y-4">
|
||||
<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-2 block">
|
||||
Case Insensitive Filters
|
||||
</Label>
|
||||
<div className="min-h-[2rem] flex flex-wrap gap-1">
|
||||
{notificationSettings.filters.case_insensitive
|
||||
.length > 0 ? (
|
||||
notificationSettings.filters.case_insensitive.map(
|
||||
(filter, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2 py-1 bg-secondary text-secondary-foreground rounded text-xs"
|
||||
>
|
||||
{filter}
|
||||
</span>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
None
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium text-muted-foreground mb-2 block">
|
||||
Case Sensitive Filters
|
||||
</Label>
|
||||
<div className="min-h-[2rem] flex flex-wrap gap-1">
|
||||
{notificationSettings.filters.case_sensitive &&
|
||||
notificationSettings.filters.case_sensitive.length >
|
||||
0 ? (
|
||||
notificationSettings.filters.case_sensitive.map(
|
||||
(filter, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2 py-1 bg-secondary text-secondary-foreground rounded text-xs"
|
||||
>
|
||||
{filter}
|
||||
</span>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
None
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Filters determine which transaction descriptions will
|
||||
trigger notifications. Add terms to exclude transactions
|
||||
containing those words.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Filter className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
No notification filters configured
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Set up filters to control which transactions trigger
|
||||
notifications.
|
||||
</p>
|
||||
<NotificationFiltersDrawer settings={notificationSettings} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="backup" className="space-y-6">
|
||||
{/* S3 Backup Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Cloud className="h-5 w-5 text-primary" />
|
||||
<span>S3 Backup Configuration</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure automatic database backups to Amazon S3 or
|
||||
S3-compatible storage
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{!backupSettings?.s3 ? (
|
||||
<div className="text-center py-8">
|
||||
<Cloud className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
No S3 backup configured
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Set up S3 backup to automatically backup your database to
|
||||
the cloud.
|
||||
</p>
|
||||
<S3BackupConfigDrawer settings={backupSettings} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3 bg-muted rounded-full">
|
||||
<Cloud className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<h4 className="text-lg font-medium text-foreground">
|
||||
S3 Backup
|
||||
</h4>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
backupSettings.s3.enabled
|
||||
? "bg-green-500"
|
||||
: "bg-muted-foreground"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{backupSettings.s3.enabled
|
||||
? "Enabled"
|
||||
: "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">Bucket:</span>{" "}
|
||||
{backupSettings.s3.bucket_name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">Region:</span>{" "}
|
||||
{backupSettings.s3.region}
|
||||
</p>
|
||||
{backupSettings.s3.endpoint_url && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">Endpoint:</span>{" "}
|
||||
{backupSettings.s3.endpoint_url}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<S3BackupConfigDrawer settings={backupSettings} />
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<h5 className="font-medium mb-2">Backup Information</h5>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Database backups are stored in the "leggen_backups/"
|
||||
folder in your S3 bucket. Backups include the complete
|
||||
SQLite database file.
|
||||
</p>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCreateBackup}
|
||||
disabled={createBackupMutation.isPending}
|
||||
>
|
||||
{createBackupMutation.isPending ? (
|
||||
<>
|
||||
<Archive className="h-4 w-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
Create Backup Now
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleViewBackups}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
View Backups
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backup List Modal/View */}
|
||||
{showBackups && (
|
||||
<div className="mt-6 p-4 border rounded-lg bg-background">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h5 className="font-medium">Available Backups</h5>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShowBackups(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{backupsLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading backups...</p>
|
||||
) : backupsError ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-destructive">Failed to load backups</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refetchBackups()}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : !backups || backups.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No backups found</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{backups.map((backup, index) => (
|
||||
<div
|
||||
key={backup.key || index}
|
||||
className="flex items-center justify-between p-3 border rounded bg-muted/50"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{backup.key}</p>
|
||||
<div className="flex items-center space-x-4 text-xs text-muted-foreground mt-1">
|
||||
<span>Modified: {formatDate(backup.last_modified)}</span>
|
||||
<span>Size: {(backup.size / 1024 / 1024).toFixed(2)} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { Activity, Wifi, WifiOff } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "../lib/api";
|
||||
import { ThemeToggle } from "./ui/theme-toggle";
|
||||
import { BalanceToggle } from "./ui/balance-toggle";
|
||||
import { Separator } from "./ui/separator";
|
||||
import { SidebarTrigger } from "./ui/sidebar";
|
||||
|
||||
@@ -11,7 +10,7 @@ const navigation = [
|
||||
{ name: "Overview", to: "/" },
|
||||
{ name: "Transactions", to: "/transactions" },
|
||||
{ name: "Analytics", to: "/analytics" },
|
||||
{ name: "System", to: "/system" },
|
||||
{ name: "Notifications", to: "/notifications" },
|
||||
{ name: "Settings", to: "/settings" },
|
||||
];
|
||||
|
||||
@@ -32,7 +31,7 @@ export function SiteHeader() {
|
||||
});
|
||||
|
||||
return (
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear">
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear pt-safe-top">
|
||||
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
@@ -44,20 +43,6 @@ export function SiteHeader() {
|
||||
</h1>
|
||||
|
||||
<div className="ml-auto flex items-center space-x-3">
|
||||
{/* Version display */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{healthLoading ? (
|
||||
<span className="text-xs text-muted-foreground">v...</span>
|
||||
) : healthError || !healthStatus ? (
|
||||
<span className="text-xs text-muted-foreground">v?</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
v{healthStatus.version || "?"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Connection status */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{healthLoading ? (
|
||||
<>
|
||||
@@ -78,7 +63,6 @@ export function SiteHeader() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<BalanceToggle />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Activity,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
User,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "../lib/api";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "./ui/card";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import { Button } from "./ui/button";
|
||||
import { Badge } from "./ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "./ui/dialog";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import type { SyncOperationsResponse, SyncOperation } from "../types/api";
|
||||
|
||||
// Component for viewing sync operation logs
|
||||
function LogsDialog({ operation }: { operation: SyncOperation }) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="shrink-0">
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
<span className="hidden sm:inline">View Logs</span>
|
||||
<span className="sm:hidden">Logs</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sync Operation Logs</DialogTitle>
|
||||
<DialogDescription>
|
||||
Operation #{operation.id} - Started at{" "}
|
||||
{new Date(operation.started_at).toLocaleString()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="h-[60vh] w-full rounded border p-4">
|
||||
<div className="space-y-2">
|
||||
{operation.logs.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">No logs available</p>
|
||||
) : (
|
||||
operation.logs.map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-sm font-mono bg-muted/50 p-2 rounded text-wrap break-all"
|
||||
>
|
||||
{log}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{operation.errors.length > 0 && (
|
||||
<>
|
||||
<div className="mt-4 mb-2 text-sm font-semibold text-destructive">
|
||||
Errors:
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{operation.errors.map((error, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-sm font-mono bg-destructive/10 border border-destructive/20 p-2 rounded text-wrap break-all text-destructive"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function System() {
|
||||
const {
|
||||
data: syncOperations,
|
||||
isLoading: syncOperationsLoading,
|
||||
error: syncOperationsError,
|
||||
refetch: refetchSyncOperations,
|
||||
} = useQuery<SyncOperationsResponse>({
|
||||
queryKey: ["syncOperations"],
|
||||
queryFn: () => apiClient.getSyncOperations(10, 0), // Get latest 10 operations
|
||||
});
|
||||
|
||||
if (syncOperationsLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
Loading system status...
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (syncOperationsError) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Failed to load system data</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={() => refetchSyncOperations()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Sync Operations Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Activity className="h-5 w-5 text-primary" />
|
||||
<span>Recent Sync Operations</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Latest synchronization activities and their status
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!syncOperations || syncOperations.operations.length === 0 ? (
|
||||
<div className="text-center py-6">
|
||||
<Activity className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
No sync operations yet
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Sync operations will appear here once you start syncing your
|
||||
accounts.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{syncOperations.operations.slice(0, 10).map((operation) => {
|
||||
const startedAt = new Date(operation.started_at);
|
||||
const isRunning = !operation.completed_at;
|
||||
const duration = operation.duration_seconds
|
||||
? `${Math.round(operation.duration_seconds)}s`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={operation.id}
|
||||
className="border rounded-lg hover:bg-accent transition-colors"
|
||||
>
|
||||
{/* Desktop Layout */}
|
||||
<div className="hidden md:flex items-center justify-between p-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div
|
||||
className={`p-2 rounded-full ${
|
||||
isRunning
|
||||
? "bg-blue-100 text-blue-600"
|
||||
: operation.success
|
||||
? "bg-green-100 text-green-600"
|
||||
: "bg-red-100 text-red-600"
|
||||
}`}
|
||||
>
|
||||
{isRunning ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : operation.success ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{isRunning
|
||||
? "Sync Running"
|
||||
: operation.success
|
||||
? "Sync Completed"
|
||||
: "Sync Failed"}
|
||||
</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{operation.trigger_type.charAt(0).toUpperCase() +
|
||||
operation.trigger_type.slice(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 mt-1 text-xs text-muted-foreground">
|
||||
<span className="flex items-center space-x-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>
|
||||
{startedAt.toLocaleDateString()}{" "}
|
||||
{startedAt.toLocaleTimeString()}
|
||||
</span>
|
||||
</span>
|
||||
{duration && <span>Duration: {duration}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-right text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-2">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{operation.accounts_processed} accounts</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
<span>
|
||||
{operation.transactions_added} new transactions
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<LogsDialog operation={operation} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Layout */}
|
||||
<div className="md:hidden p-4 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className={`p-2 rounded-full ${
|
||||
isRunning
|
||||
? "bg-blue-100 text-blue-600"
|
||||
: operation.success
|
||||
? "bg-green-100 text-green-600"
|
||||
: "bg-red-100 text-red-600"
|
||||
}`}
|
||||
>
|
||||
{isRunning ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : operation.success ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{isRunning
|
||||
? "Sync Running"
|
||||
: operation.success
|
||||
? "Sync Completed"
|
||||
: "Sync Failed"}
|
||||
</h4>
|
||||
<Badge variant="outline" className="text-xs mt-1">
|
||||
{operation.trigger_type.charAt(0).toUpperCase() +
|
||||
operation.trigger_type.slice(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<LogsDialog operation={operation} />
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground space-y-2">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>
|
||||
{startedAt.toLocaleDateString()}{" "}
|
||||
{startedAt.toLocaleTimeString()}
|
||||
</span>
|
||||
{duration && (
|
||||
<span className="ml-2">• {duration}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex items-center space-x-1">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{operation.accounts_processed} accounts</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
<span>
|
||||
{operation.transactions_added} new transactions
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Health Summary Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
<span>System Health</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Overall system status and performance
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="text-center p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<div className="text-2xl font-bold text-green-700">
|
||||
{syncOperations?.operations.filter((op) => op.success).length ||
|
||||
0}
|
||||
</div>
|
||||
<div className="text-sm text-green-600">Successful Syncs</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-red-50 rounded-lg border border-red-200">
|
||||
<div className="text-2xl font-bold text-red-700">
|
||||
{syncOperations?.operations.filter(
|
||||
(op) => !op.success && op.completed_at,
|
||||
).length || 0}
|
||||
</div>
|
||||
<div className="text-sm text-red-600">Failed Syncs</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div className="text-2xl font-bold text-blue-700">
|
||||
{syncOperations?.operations.filter((op) => !op.completed_at)
|
||||
.length || 0}
|
||||
</div>
|
||||
<div className="text-sm text-blue-600">Running Operations</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Send, TestTube } from "lucide-react";
|
||||
import { apiClient } from "../lib/api";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { EditButton } from "./ui/edit-button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "./ui/drawer";
|
||||
import type { NotificationSettings, TelegramConfig } from "../types/api";
|
||||
|
||||
interface TelegramConfigDrawerProps {
|
||||
settings: NotificationSettings | undefined;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function TelegramConfigDrawer({
|
||||
settings,
|
||||
trigger,
|
||||
}: TelegramConfigDrawerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [config, setConfig] = useState<TelegramConfig>({
|
||||
token: "",
|
||||
chat_id: 0,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.telegram) {
|
||||
setConfig({ ...settings.telegram });
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (telegramConfig: TelegramConfig) =>
|
||||
apiClient.updateNotificationSettings({
|
||||
...settings,
|
||||
telegram: telegramConfig,
|
||||
filters: settings?.filters || {
|
||||
case_insensitive: [],
|
||||
case_sensitive: [],
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["notificationServices"] });
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to update Telegram configuration:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiClient.testNotification({
|
||||
service: "telegram",
|
||||
message:
|
||||
"Test notification from Leggen - Telegram configuration is working!",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
console.log("Test Telegram notification sent successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to send test Telegram notification:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
updateMutation.mutate(config);
|
||||
};
|
||||
|
||||
const handleTest = () => {
|
||||
testMutation.mutate();
|
||||
};
|
||||
|
||||
const isConfigValid = config.token.trim().length > 0 && config.chat_id !== 0;
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{trigger || <EditButton />}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle className="flex items-center space-x-2">
|
||||
<Send className="h-5 w-5 text-primary" />
|
||||
<span>Telegram Configuration</span>
|
||||
</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Configure Telegram bot notifications for transaction alerts
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
||||
{/* Enable/Disable Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base font-medium">
|
||||
Enable Telegram Notifications
|
||||
</Label>
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
onCheckedChange={(enabled) => setConfig({ ...config, enabled })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bot Token */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="telegram-token">Bot Token</Label>
|
||||
<Input
|
||||
id="telegram-token"
|
||||
type="password"
|
||||
placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||
value={config.token}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, token: e.target.value })
|
||||
}
|
||||
disabled={!config.enabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create a bot using @BotFather on Telegram to get your token
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Chat ID */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="telegram-chat-id">Chat ID</Label>
|
||||
<Input
|
||||
id="telegram-chat-id"
|
||||
type="number"
|
||||
placeholder="123456789"
|
||||
value={config.chat_id || ""}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
chat_id: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
disabled={!config.enabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Send a message to your bot and visit
|
||||
https://api.telegram.org/bot<token>/getUpdates to find
|
||||
your chat ID
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Configuration Status */}
|
||||
{config.enabled && (
|
||||
<div className="p-3 bg-muted rounded-md">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${isConfigValid ? "bg-green-500" : "bg-red-500"}`}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{isConfigValid
|
||||
? "Configuration Valid"
|
||||
: "Missing Token or Chat ID"}
|
||||
</span>
|
||||
</div>
|
||||
{!isConfigValid &&
|
||||
(config.token.trim().length > 0 || config.chat_id !== 0) && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Both bot token and chat ID are required
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DrawerFooter className="px-0">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending || !config.enabled}
|
||||
>
|
||||
{updateMutation.isPending
|
||||
? "Saving..."
|
||||
: "Save Configuration"}
|
||||
</Button>
|
||||
{config.enabled && isConfigValid && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTest}
|
||||
disabled={testMutation.isPending}
|
||||
>
|
||||
{testMutation.isPending ? (
|
||||
<>
|
||||
<TestTube className="h-4 w-4 mr-2 animate-spin" />
|
||||
Testing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TestTube className="h-4 w-4 mr-2" />
|
||||
Test
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="ghost">Cancel</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</form>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
useReactTable,
|
||||
@@ -31,8 +31,7 @@ import { DataTablePagination } from "./ui/data-table-pagination";
|
||||
import { Card } from "./ui/card";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import { Button } from "./ui/button";
|
||||
import { BlurredValue } from "./ui/blurred-value";
|
||||
import type { Account, Transaction, PaginatedResponse } from "../types/api";
|
||||
import type { Account, Transaction, ApiResponse } from "../types/api";
|
||||
|
||||
export default function TransactionsTable() {
|
||||
// Filter state consolidated into a single object
|
||||
@@ -98,12 +97,13 @@ export default function TransactionsTable() {
|
||||
queryFn: apiClient.getAccounts,
|
||||
});
|
||||
|
||||
|
||||
const {
|
||||
data: transactionsResponse,
|
||||
isLoading: transactionsLoading,
|
||||
error: transactionsError,
|
||||
refetch: refetchTransactions,
|
||||
} = useQuery<PaginatedResponse<Transaction>>({
|
||||
} = useQuery<ApiResponse<Transaction[]>>({
|
||||
queryKey: [
|
||||
"transactions",
|
||||
filterState.selectedAccount,
|
||||
@@ -123,52 +123,10 @@ export default function TransactionsTable() {
|
||||
search: debouncedSearchTerm || undefined,
|
||||
summaryOnly: false,
|
||||
}),
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
|
||||
const transactions = useMemo(
|
||||
() => transactionsResponse?.data || [],
|
||||
[transactionsResponse],
|
||||
);
|
||||
const pagination = useMemo(
|
||||
() =>
|
||||
transactionsResponse
|
||||
? {
|
||||
page: transactionsResponse.page,
|
||||
total_pages: transactionsResponse.total_pages,
|
||||
per_page: transactionsResponse.per_page,
|
||||
total: transactionsResponse.total,
|
||||
has_next: transactionsResponse.has_next,
|
||||
has_prev: transactionsResponse.has_prev,
|
||||
}
|
||||
: undefined,
|
||||
[transactionsResponse],
|
||||
);
|
||||
|
||||
// Calculate stats from current page transactions, memoized for performance
|
||||
const stats = useMemo(() => {
|
||||
const totalIncome = transactions
|
||||
.filter((t: Transaction) => t.transaction_value > 0)
|
||||
.reduce((sum: number, t: Transaction) => sum + t.transaction_value, 0);
|
||||
|
||||
const totalExpenses = Math.abs(
|
||||
transactions
|
||||
.filter((t: Transaction) => t.transaction_value < 0)
|
||||
.reduce((sum: number, t: Transaction) => sum + t.transaction_value, 0)
|
||||
);
|
||||
|
||||
// Get currency from first transaction, fallback to EUR
|
||||
const displayCurrency = transactions.length > 0 ? transactions[0].transaction_currency : "EUR";
|
||||
|
||||
return {
|
||||
totalCount: pagination?.total || 0,
|
||||
pageCount: transactions.length,
|
||||
totalIncome,
|
||||
totalExpenses,
|
||||
netChange: totalIncome - totalExpenses,
|
||||
displayCurrency,
|
||||
};
|
||||
}, [transactions, pagination]);
|
||||
const transactions = transactionsResponse?.data || [];
|
||||
const pagination = transactionsResponse?.pagination;
|
||||
|
||||
// Check if search is currently debouncing
|
||||
const isSearchLoading = filterState.searchTerm !== debouncedSearchTerm;
|
||||
@@ -183,7 +141,11 @@ export default function TransactionsTable() {
|
||||
// Reset pagination when filters change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filterState.selectedAccount, filterState.startDate, filterState.endDate]);
|
||||
}, [
|
||||
filterState.selectedAccount,
|
||||
filterState.startDate,
|
||||
filterState.endDate,
|
||||
]);
|
||||
|
||||
const handleViewRaw = (transaction: Transaction) => {
|
||||
setSelectedTransaction(transaction);
|
||||
@@ -201,6 +163,7 @@ export default function TransactionsTable() {
|
||||
filterState.startDate ||
|
||||
filterState.endDate;
|
||||
|
||||
|
||||
// Define columns
|
||||
const columns: ColumnDef<Transaction>[] = [
|
||||
{
|
||||
@@ -233,7 +196,8 @@ export default function TransactionsTable() {
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
{account && (
|
||||
<p className="truncate">
|
||||
{account.display_name || "Unnamed Account"}
|
||||
{account.name || "Unnamed Account"} •{" "}
|
||||
{account.institution_id}
|
||||
</p>
|
||||
)}
|
||||
{(transaction.creditor_name || transaction.debtor_name) && (
|
||||
@@ -264,13 +228,11 @@ export default function TransactionsTable() {
|
||||
isPositive ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
<BlurredValue>
|
||||
{isPositive ? "+" : ""}
|
||||
{formatCurrency(
|
||||
transaction.transaction_value,
|
||||
transaction.transaction_currency,
|
||||
)}
|
||||
</BlurredValue>
|
||||
{isPositive ? "+" : ""}
|
||||
{formatCurrency(
|
||||
transaction.transaction_value,
|
||||
transaction.transaction_currency,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -304,15 +266,14 @@ export default function TransactionsTable() {
|
||||
cell: ({ row }) => {
|
||||
const transaction = row.original;
|
||||
return (
|
||||
<Button
|
||||
<button
|
||||
onClick={() => handleViewRaw(transaction)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -395,78 +356,6 @@ export default function TransactionsTable() {
|
||||
isSearchLoading={isSearchLoading}
|
||||
/>
|
||||
|
||||
{/* Transaction Statistics */}
|
||||
{transactions.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||
Showing
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">
|
||||
{stats.pageCount}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
of {stats.totalCount} total
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||
Income
|
||||
</p>
|
||||
<BlurredValue className="text-2xl font-bold text-green-600 mt-1 block">
|
||||
+{formatCurrency(stats.totalIncome, stats.displayCurrency)}
|
||||
</BlurredValue>
|
||||
</div>
|
||||
<TrendingUp className="h-8 w-8 text-green-600 opacity-50" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||
Expenses
|
||||
</p>
|
||||
<BlurredValue className="text-2xl font-bold text-red-600 mt-1 block">
|
||||
-{formatCurrency(stats.totalExpenses, stats.displayCurrency)}
|
||||
</BlurredValue>
|
||||
</div>
|
||||
<TrendingDown className="h-8 w-8 text-red-600 opacity-50" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||
Net Change
|
||||
</p>
|
||||
<BlurredValue
|
||||
className={`text-2xl font-bold mt-1 block ${
|
||||
stats.netChange >= 0 ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{stats.netChange >= 0 ? "+" : ""}
|
||||
{formatCurrency(stats.netChange, stats.displayCurrency)}
|
||||
</BlurredValue>
|
||||
</div>
|
||||
{stats.netChange >= 0 ? (
|
||||
<TrendingUp className="h-8 w-8 text-green-600 opacity-50" />
|
||||
) : (
|
||||
<TrendingDown className="h-8 w-8 text-red-600 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Responsive Table/Cards */}
|
||||
<Card>
|
||||
{/* Desktop Table View (hidden on mobile) */}
|
||||
@@ -538,7 +427,10 @@ export default function TransactionsTable() {
|
||||
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">
|
||||
<td
|
||||
key={cell.id}
|
||||
className="px-6 py-4 whitespace-nowrap"
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
@@ -603,7 +495,8 @@ export default function TransactionsTable() {
|
||||
<div className="text-xs text-muted-foreground space-y-1 mt-1">
|
||||
{account && (
|
||||
<p className="break-words">
|
||||
{account.display_name || "Unnamed Account"}
|
||||
{account.name || "Unnamed Account"} •{" "}
|
||||
{account.institution_id}
|
||||
</p>
|
||||
)}
|
||||
{(transaction.creditor_name ||
|
||||
@@ -642,23 +535,20 @@ export default function TransactionsTable() {
|
||||
isPositive ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
<BlurredValue>
|
||||
{isPositive ? "+" : ""}
|
||||
{formatCurrency(
|
||||
transaction.transaction_value,
|
||||
transaction.transaction_currency,
|
||||
)}
|
||||
</BlurredValue>
|
||||
{isPositive ? "+" : ""}
|
||||
{formatCurrency(
|
||||
transaction.transaction_value,
|
||||
transaction.transaction_currency,
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
<button
|
||||
onClick={() => handleViewRaw(transaction)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import { useBalanceVisibility } from "../../contexts/BalanceVisibilityContext";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { Balance, Account } from "../../types/api";
|
||||
|
||||
interface BalanceChartProps {
|
||||
@@ -44,8 +42,6 @@ export default function BalanceChart({
|
||||
accounts,
|
||||
className,
|
||||
}: BalanceChartProps) {
|
||||
const { isBalanceVisible } = useBalanceVisibility();
|
||||
|
||||
// Create a lookup map for account info
|
||||
const accountMap = accounts.reduce(
|
||||
(map, account) => {
|
||||
@@ -153,7 +149,7 @@ export default function BalanceChart({
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">
|
||||
Balance Progress Over Time
|
||||
</h3>
|
||||
<div className={cn("h-80", !isBalanceVisible && "blur-md select-none")}>
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={finalData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useBalanceVisibility } from "../../contexts/BalanceVisibilityContext";
|
||||
import { cn } from "../../lib/utils";
|
||||
import apiClient from "../../lib/api";
|
||||
|
||||
interface MonthlyTrendsProps {
|
||||
@@ -31,8 +29,6 @@ export default function MonthlyTrends({
|
||||
className,
|
||||
days = 365,
|
||||
}: MonthlyTrendsProps) {
|
||||
const { isBalanceVisible } = useBalanceVisibility();
|
||||
|
||||
// Get pre-calculated monthly stats from the new endpoint
|
||||
const { data: monthlyData, isLoading } = useQuery({
|
||||
queryKey: ["monthly-stats", days],
|
||||
@@ -107,7 +103,7 @@ export default function MonthlyTrends({
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">
|
||||
{getTitle(days)}
|
||||
</h3>
|
||||
<div className={cn("h-80", !isBalanceVisible && "blur-md select-none")}>
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={displayData}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Card, CardContent } from "../ui/card";
|
||||
import { BlurredValue } from "../ui/blurred-value";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface StatCardProps {
|
||||
@@ -14,7 +13,6 @@ interface StatCardProps {
|
||||
};
|
||||
className?: string;
|
||||
iconColor?: "green" | "blue" | "red" | "purple" | "orange" | "default";
|
||||
shouldBlur?: boolean;
|
||||
}
|
||||
|
||||
export default function StatCard({
|
||||
@@ -25,17 +23,18 @@ export default function StatCard({
|
||||
trend,
|
||||
className,
|
||||
iconColor = "default",
|
||||
shouldBlur = false,
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<Card className={cn(className)}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{title}
|
||||
</p>
|
||||
<div className="flex items-baseline">
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{shouldBlur ? <BlurredValue>{value}</BlurredValue> : value}
|
||||
{value}
|
||||
</p>
|
||||
{trend && (
|
||||
<div
|
||||
@@ -52,31 +51,29 @@ export default function StatCard({
|
||||
)}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{subtitle}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"p-3 rounded-full",
|
||||
iconColor === "green" && "bg-green-100 dark:bg-green-900/20",
|
||||
iconColor === "blue" && "bg-blue-100 dark:bg-blue-900/20",
|
||||
iconColor === "red" && "bg-red-100 dark:bg-red-900/20",
|
||||
iconColor === "purple" && "bg-purple-100 dark:bg-purple-900/20",
|
||||
iconColor === "orange" && "bg-orange-100 dark:bg-orange-900/20",
|
||||
iconColor === "default" && "bg-muted",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-6 w-6",
|
||||
iconColor === "green" && "text-green-600",
|
||||
iconColor === "blue" && "text-blue-600",
|
||||
iconColor === "red" && "text-red-600",
|
||||
iconColor === "purple" && "text-purple-600",
|
||||
iconColor === "orange" && "text-orange-600",
|
||||
iconColor === "default" && "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
<div className={cn(
|
||||
"p-3 rounded-full",
|
||||
iconColor === "green" && "bg-green-100 dark:bg-green-900/20",
|
||||
iconColor === "blue" && "bg-blue-100 dark:bg-blue-900/20",
|
||||
iconColor === "red" && "bg-red-100 dark:bg-red-900/20",
|
||||
iconColor === "purple" && "bg-purple-100 dark:bg-purple-900/20",
|
||||
iconColor === "orange" && "bg-orange-100 dark:bg-orange-900/20",
|
||||
iconColor === "default" && "bg-muted"
|
||||
)}>
|
||||
<Icon className={cn(
|
||||
"h-6 w-6",
|
||||
iconColor === "green" && "text-green-600",
|
||||
iconColor === "blue" && "text-blue-600",
|
||||
iconColor === "red" && "text-red-600",
|
||||
iconColor === "purple" && "text-purple-600",
|
||||
iconColor === "orange" && "text-orange-600",
|
||||
iconColor === "default" && "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -15,14 +15,12 @@ export default function TimePeriodFilter({
|
||||
className = "",
|
||||
}: TimePeriodFilterProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col sm:flex-row sm:items-center gap-4 ${className}`}
|
||||
>
|
||||
<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 flex-wrap gap-2">
|
||||
<div className="flex gap-2">
|
||||
{TIME_PERIODS.map((period) => (
|
||||
<Button
|
||||
key={period.value}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import { BlurredValue } from "../ui/blurred-value";
|
||||
import type { Account } from "../../types/api";
|
||||
|
||||
interface TransactionDistributionProps {
|
||||
@@ -86,8 +85,7 @@ export default function TransactionDistribution({
|
||||
<div className="bg-card p-3 border rounded shadow-lg">
|
||||
<p className="font-medium text-foreground">{data.name}</p>
|
||||
<p className="text-primary">
|
||||
Balance:{" "}
|
||||
<BlurredValue>€{data.value.toLocaleString()}</BlurredValue>
|
||||
Balance: €{data.value.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-muted-foreground">{percentage}% of total</p>
|
||||
</div>
|
||||
@@ -140,7 +138,7 @@ export default function TransactionDistribution({
|
||||
<span className="text-foreground">{item.name}</span>
|
||||
</div>
|
||||
<span className="font-medium text-foreground">
|
||||
<BlurredValue>€{item.value.toLocaleString()}</BlurredValue>
|
||||
€{item.value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -70,6 +70,7 @@ export function ActiveFilterChips({
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const handleRemoveChip = (key: keyof FilterState) => {
|
||||
switch (key) {
|
||||
case "startDate":
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -31,21 +30,6 @@ export function FilterBar({
|
||||
isSearchLoading = false,
|
||||
className,
|
||||
}: FilterBarProps) {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const cursorPositionRef = useRef<number | null>(null);
|
||||
|
||||
// Maintain focus and cursor position on search input during re-renders
|
||||
useEffect(() => {
|
||||
const currentInput = searchInputRef.current;
|
||||
if (!currentInput) return;
|
||||
|
||||
// Restore focus and cursor position after data fetches complete
|
||||
if (cursorPositionRef.current !== null && document.activeElement !== currentInput) {
|
||||
currentInput.focus();
|
||||
currentInput.setSelectionRange(cursorPositionRef.current, cursorPositionRef.current);
|
||||
}
|
||||
}, [isSearchLoading]);
|
||||
|
||||
const hasActiveFilters =
|
||||
filterState.searchTerm ||
|
||||
filterState.selectedAccount ||
|
||||
@@ -77,19 +61,9 @@ export function FilterBar({
|
||||
<div className="relative w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder="Search transactions..."
|
||||
value={filterState.searchTerm}
|
||||
onChange={(e) => {
|
||||
cursorPositionRef.current = e.target.selectionStart;
|
||||
onFilterChange("searchTerm", e.target.value);
|
||||
}}
|
||||
onFocus={() => {
|
||||
cursorPositionRef.current = searchInputRef.current?.selectionStart ?? null;
|
||||
}}
|
||||
onBlur={() => {
|
||||
cursorPositionRef.current = null;
|
||||
}}
|
||||
onChange={(e) => onFilterChange("searchTerm", e.target.value)}
|
||||
className="pl-9 pr-8 bg-background"
|
||||
/>
|
||||
{isSearchLoading && (
|
||||
@@ -117,6 +91,7 @@ export function FilterBar({
|
||||
className="w-[220px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Mobile Layout */}
|
||||
@@ -125,19 +100,9 @@ export function FilterBar({
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder="Search..."
|
||||
value={filterState.searchTerm}
|
||||
onChange={(e) => {
|
||||
cursorPositionRef.current = e.target.selectionStart;
|
||||
onFilterChange("searchTerm", e.target.value);
|
||||
}}
|
||||
onFocus={() => {
|
||||
cursorPositionRef.current = searchInputRef.current?.selectionStart ?? null;
|
||||
}}
|
||||
onBlur={() => {
|
||||
cursorPositionRef.current = null;
|
||||
}}
|
||||
onChange={(e) => onFilterChange("searchTerm", e.target.value)}
|
||||
className="pl-9 pr-8 bg-background w-full"
|
||||
/>
|
||||
{isSearchLoading && (
|
||||
@@ -164,6 +129,7 @@ export function FilterBar({
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { Button } from "./button";
|
||||
import { useBalanceVisibility } from "../../contexts/BalanceVisibilityContext";
|
||||
|
||||
export function BalanceToggle() {
|
||||
const { isBalanceVisible, toggleBalanceVisibility } = useBalanceVisibility();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={toggleBalanceVisibility}
|
||||
className="h-8 w-8"
|
||||
title={isBalanceVisible ? "Hide balances" : "Show balances"}
|
||||
>
|
||||
{isBalanceVisible ? (
|
||||
<Eye className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{isBalanceVisible ? "Hide balances" : "Show balances"}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import React from "react";
|
||||
import { useBalanceVisibility } from "../../contexts/BalanceVisibilityContext";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface BlurredValueProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BlurredValue({ children, className }: BlurredValueProps) {
|
||||
const { isBalanceVisible } = useBalanceVisibility();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
isBalanceVisible ? "" : "blur-md select-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
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";
|
||||
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",
|
||||
@@ -31,27 +31,27 @@ const buttonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants };
|
||||
export { Button, buttonVariants }
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Drawer.displayName = "Drawer";
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal;
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close;
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
));
|
||||
DrawerContent.displayName = "DrawerContent";
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerHeader.displayName = "DrawerHeader";
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerFooter.displayName = "DrawerFooter";
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Edit3 } from "lucide-react";
|
||||
import { Button } from "./button";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface EditButtonProps {
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
size?: "default" | "sm" | "lg" | "icon";
|
||||
variant?:
|
||||
| "default"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "secondary"
|
||||
| "ghost"
|
||||
| "link";
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EditButton({
|
||||
onClick,
|
||||
disabled = false,
|
||||
className,
|
||||
size = "sm",
|
||||
variant = "outline",
|
||||
children,
|
||||
...props
|
||||
}: EditButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
size={size}
|
||||
variant={variant}
|
||||
className={cn(
|
||||
"h-8 px-3 text-muted-foreground hover:text-foreground transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
<span className="ml-2">{children || "Edit"}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
@@ -9,14 +9,14 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"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,
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input };
|
||||
export { Input }
|
||||
|
||||
@@ -15,9 +15,7 @@ export function Logo({ className = "", size = 32 }: LogoProps) {
|
||||
aria-labelledby="logo-title logo-desc"
|
||||
>
|
||||
<title id="logo-title">leggen — stylized italic L</title>
|
||||
<desc id="logo-desc">
|
||||
Square gradient background with italic white L.
|
||||
</desc>
|
||||
<desc id="logo-desc">Square gradient background with italic white L.</desc>
|
||||
|
||||
<defs>
|
||||
<linearGradient id="logo-bg" x1="0" y1="0" x2="1" y2="1">
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative overflow-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
));
|
||||
ScrollArea.displayName = "ScrollArea";
|
||||
|
||||
export { ScrollArea };
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
@@ -9,7 +9,7 @@ const Separator = React.forwardRef<
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref,
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
@@ -18,12 +18,12 @@ const Separator = React.forwardRef<
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator };
|
||||
export { Separator }
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import * as React from "react";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
@@ -22,13 +22,13 @@ const SheetOverlay = React.forwardRef<
|
||||
<SheetPrimitive.Overlay
|
||||
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,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
@@ -46,8 +46,8 @@ const sheetVariants = cva(
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
@@ -71,8 +71,8 @@ const SheetContent = React.forwardRef<
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
@@ -81,12 +81,12 @@ const SheetHeader = ({
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetHeader.displayName = "SheetHeader";
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
@@ -95,12 +95,12 @@ const SheetFooter = ({
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetFooter.displayName = "SheetFooter";
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
@@ -111,8 +111,8 @@ const SheetTitle = React.forwardRef<
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
@@ -123,8 +123,8 @@ const SheetDescription = React.forwardRef<
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
@@ -137,4 +137,4 @@ export {
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,62 +1,62 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { PanelLeft } from "lucide-react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "16rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context;
|
||||
return context
|
||||
}
|
||||
|
||||
const SidebarProvider = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
>(
|
||||
(
|
||||
@@ -69,36 +69,36 @@ const SidebarProvider = React.forwardRef<
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
ref
|
||||
) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? _open;
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value;
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState);
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState);
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open],
|
||||
);
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile
|
||||
? setOpenMobile((open) => !open)
|
||||
: setOpen((open) => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
: setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
@@ -107,18 +107,18 @@ const SidebarProvider = React.forwardRef<
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed";
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
@@ -130,16 +130,8 @@ const SidebarProvider = React.forwardRef<
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
],
|
||||
);
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
@@ -154,7 +146,7 @@ const SidebarProvider = React.forwardRef<
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
@@ -163,17 +155,17 @@ const SidebarProvider = React.forwardRef<
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarProvider.displayName = "SidebarProvider";
|
||||
)
|
||||
}
|
||||
)
|
||||
SidebarProvider.displayName = "SidebarProvider"
|
||||
|
||||
const Sidebar = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right";
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}
|
||||
>(
|
||||
(
|
||||
@@ -185,23 +177,23 @@ const Sidebar = React.forwardRef<
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
ref
|
||||
) => {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
@@ -225,7 +217,7 @@ const Sidebar = React.forwardRef<
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -245,7 +237,7 @@ const Sidebar = React.forwardRef<
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
@@ -258,7 +250,7 @@ const Sidebar = React.forwardRef<
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -270,16 +262,16 @@ const Sidebar = React.forwardRef<
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
Sidebar.displayName = "Sidebar";
|
||||
)
|
||||
}
|
||||
)
|
||||
Sidebar.displayName = "Sidebar"
|
||||
|
||||
const SidebarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof Button>,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, onClick, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -289,23 +281,23 @@ const SidebarTrigger = React.forwardRef<
|
||||
size="icon"
|
||||
className={cn("h-7 w-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeft />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
SidebarTrigger.displayName = "SidebarTrigger";
|
||||
)
|
||||
})
|
||||
SidebarTrigger.displayName = "SidebarTrigger"
|
||||
|
||||
const SidebarRail = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button">
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -322,13 +314,13 @@ const SidebarRail = React.forwardRef<
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarRail.displayName = "SidebarRail";
|
||||
)
|
||||
})
|
||||
SidebarRail.displayName = "SidebarRail"
|
||||
|
||||
const SidebarInset = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@@ -340,13 +332,13 @@ const SidebarInset = React.forwardRef<
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarInset.displayName = "SidebarInset";
|
||||
)
|
||||
})
|
||||
SidebarInset.displayName = "SidebarInset"
|
||||
|
||||
const SidebarInput = React.forwardRef<
|
||||
React.ElementRef<typeof Input>,
|
||||
@@ -358,13 +350,13 @@ const SidebarInput = React.forwardRef<
|
||||
data-sidebar="input"
|
||||
className={cn(
|
||||
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarInput.displayName = "SidebarInput";
|
||||
)
|
||||
})
|
||||
SidebarInput.displayName = "SidebarInput"
|
||||
|
||||
const SidebarHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@@ -377,9 +369,9 @@ const SidebarHeader = React.forwardRef<
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarHeader.displayName = "SidebarHeader";
|
||||
)
|
||||
})
|
||||
SidebarHeader.displayName = "SidebarHeader"
|
||||
|
||||
const SidebarFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@@ -392,9 +384,9 @@ const SidebarFooter = React.forwardRef<
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarFooter.displayName = "SidebarFooter";
|
||||
)
|
||||
})
|
||||
SidebarFooter.displayName = "SidebarFooter"
|
||||
|
||||
const SidebarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof Separator>,
|
||||
@@ -407,9 +399,9 @@ const SidebarSeparator = React.forwardRef<
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarSeparator.displayName = "SidebarSeparator";
|
||||
)
|
||||
})
|
||||
SidebarSeparator.displayName = "SidebarSeparator"
|
||||
|
||||
const SidebarContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@@ -421,13 +413,13 @@ const SidebarContent = React.forwardRef<
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarContent.displayName = "SidebarContent";
|
||||
)
|
||||
})
|
||||
SidebarContent.displayName = "SidebarContent"
|
||||
|
||||
const SidebarGroup = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@@ -440,15 +432,15 @@ const SidebarGroup = React.forwardRef<
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarGroup.displayName = "SidebarGroup";
|
||||
)
|
||||
})
|
||||
SidebarGroup.displayName = "SidebarGroup"
|
||||
|
||||
const SidebarGroupLabel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -457,19 +449,19 @@ const SidebarGroupLabel = React.forwardRef<
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarGroupLabel.displayName = "SidebarGroupLabel";
|
||||
)
|
||||
})
|
||||
SidebarGroupLabel.displayName = "SidebarGroupLabel"
|
||||
|
||||
const SidebarGroupAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -480,13 +472,13 @@ const SidebarGroupAction = React.forwardRef<
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarGroupAction.displayName = "SidebarGroupAction";
|
||||
)
|
||||
})
|
||||
SidebarGroupAction.displayName = "SidebarGroupAction"
|
||||
|
||||
const SidebarGroupContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@@ -498,8 +490,8 @@ const SidebarGroupContent = React.forwardRef<
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarGroupContent.displayName = "SidebarGroupContent";
|
||||
))
|
||||
SidebarGroupContent.displayName = "SidebarGroupContent"
|
||||
|
||||
const SidebarMenu = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
@@ -511,8 +503,8 @@ const SidebarMenu = React.forwardRef<
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarMenu.displayName = "SidebarMenu";
|
||||
))
|
||||
SidebarMenu.displayName = "SidebarMenu"
|
||||
|
||||
const SidebarMenuItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
@@ -524,8 +516,8 @@ const SidebarMenuItem = React.forwardRef<
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarMenuItem.displayName = "SidebarMenuItem";
|
||||
))
|
||||
SidebarMenuItem.displayName = "SidebarMenuItem"
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
@@ -546,15 +538,15 @@ const sidebarMenuButtonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
const SidebarMenuButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||
>(
|
||||
(
|
||||
@@ -567,10 +559,10 @@ const SidebarMenuButton = React.forwardRef<
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
ref
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
@@ -581,16 +573,16 @@ const SidebarMenuButton = React.forwardRef<
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -603,19 +595,19 @@ const SidebarMenuButton = React.forwardRef<
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarMenuButton.displayName = "SidebarMenuButton";
|
||||
)
|
||||
}
|
||||
)
|
||||
SidebarMenuButton.displayName = "SidebarMenuButton"
|
||||
|
||||
const SidebarMenuAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}
|
||||
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -631,13 +623,13 @@ const SidebarMenuAction = React.forwardRef<
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarMenuAction.displayName = "SidebarMenuAction";
|
||||
)
|
||||
})
|
||||
SidebarMenuAction.displayName = "SidebarMenuAction"
|
||||
|
||||
const SidebarMenuBadge = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@@ -653,23 +645,23 @@ const SidebarMenuBadge = React.forwardRef<
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarMenuBadge.displayName = "SidebarMenuBadge";
|
||||
))
|
||||
SidebarMenuBadge.displayName = "SidebarMenuBadge"
|
||||
|
||||
const SidebarMenuSkeleton = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean;
|
||||
showIcon?: boolean
|
||||
}
|
||||
>(({ className, showIcon = false, ...props }, ref) => {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, []);
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -694,9 +686,9 @@ const SidebarMenuSkeleton = React.forwardRef<
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
|
||||
)
|
||||
})
|
||||
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
|
||||
|
||||
const SidebarMenuSub = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
@@ -708,28 +700,28 @@ const SidebarMenuSub = React.forwardRef<
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarMenuSub.displayName = "SidebarMenuSub";
|
||||
))
|
||||
SidebarMenuSub.displayName = "SidebarMenuSub"
|
||||
|
||||
const SidebarMenuSubItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ ...props }, ref) => <li ref={ref} {...props} />);
|
||||
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
|
||||
>(({ ...props }, ref) => <li ref={ref} {...props} />)
|
||||
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
|
||||
|
||||
const SidebarMenuSubButton = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}
|
||||
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -743,13 +735,13 @@ const SidebarMenuSubButton = React.forwardRef<
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
|
||||
)
|
||||
})
|
||||
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
@@ -776,4 +768,4 @@ export {
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
@@ -9,7 +9,7 @@ function Skeleton({
|
||||
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
export { Skeleton }
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
@@ -1,53 +0,0 @@
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
@@ -19,12 +19,12 @@ const TooltipContent = React.forwardRef<
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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-tooltip-content-transform-origin]",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
interface BalanceVisibilityContextType {
|
||||
isBalanceVisible: boolean;
|
||||
toggleBalanceVisibility: () => void;
|
||||
}
|
||||
|
||||
const BalanceVisibilityContext = createContext<
|
||||
BalanceVisibilityContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
export function BalanceVisibilityProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [isBalanceVisible, setIsBalanceVisible] = useState<boolean>(() => {
|
||||
const stored = localStorage.getItem("balanceVisible");
|
||||
// Default to true (visible) if not set
|
||||
return stored === null ? true : stored === "true";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("balanceVisible", String(isBalanceVisible));
|
||||
}, [isBalanceVisible]);
|
||||
|
||||
const toggleBalanceVisibility = () => {
|
||||
setIsBalanceVisible((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<BalanceVisibilityContext.Provider
|
||||
value={{ isBalanceVisible, toggleBalanceVisibility }}
|
||||
>
|
||||
{children}
|
||||
</BalanceVisibilityContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBalanceVisibility() {
|
||||
const context = useContext(BalanceVisibilityContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"useBalanceVisibility must be used within a BalanceVisibilityProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -51,29 +51,22 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const themeColor = THEME_COLORS[resolvedTheme];
|
||||
|
||||
// Update theme-color meta tag
|
||||
const themeColorMeta = document.getElementById(
|
||||
"theme-color-meta",
|
||||
) as HTMLMetaElement;
|
||||
const themeColorMeta = document.getElementById("theme-color-meta") as HTMLMetaElement;
|
||||
if (themeColorMeta) {
|
||||
themeColorMeta.content = themeColor;
|
||||
}
|
||||
|
||||
// Update Microsoft tile color
|
||||
const msThemeColorMeta = document.getElementById(
|
||||
"ms-theme-color-meta",
|
||||
) as HTMLMetaElement;
|
||||
const msThemeColorMeta = document.getElementById("ms-theme-color-meta") as HTMLMetaElement;
|
||||
if (msThemeColorMeta) {
|
||||
msThemeColorMeta.content = themeColor;
|
||||
}
|
||||
|
||||
// Update Apple status bar style for better iOS integration
|
||||
const appleStatusBarMeta = document.getElementById(
|
||||
"apple-status-bar-meta",
|
||||
) as HTMLMetaElement;
|
||||
const appleStatusBarMeta = document.getElementById("apple-status-bar-meta") as HTMLMetaElement;
|
||||
if (appleStatusBarMeta) {
|
||||
// Use 'black-translucent' for dark theme, 'default' for light theme
|
||||
appleStatusBarMeta.content =
|
||||
resolvedTheme === "dark" ? "black-translucent" : "default";
|
||||
appleStatusBarMeta.content = resolvedTheme === "dark" ? "black-translucent" : "default";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import * as React from "react";
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile;
|
||||
return !!isMobile
|
||||
}
|
||||
|
||||
37
frontend/src/hooks/usePWA.ts
Normal file
37
frontend/src/hooks/usePWA.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface PWAUpdate {
|
||||
updateAvailable: boolean;
|
||||
updateSW: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function usePWA(): PWAUpdate {
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [updateSW, setUpdateSW] = useState<() => Promise<void>>(() => async () => {});
|
||||
|
||||
useEffect(() => {
|
||||
// Check if SW registration is available
|
||||
if ("serviceWorker" in navigator) {
|
||||
// Import the registerSW function
|
||||
import("virtual:pwa-register").then(({ registerSW }) => {
|
||||
const updateSWFunction = registerSW({
|
||||
onNeedRefresh() {
|
||||
setUpdateAvailable(true);
|
||||
setUpdateSW(() => updateSWFunction);
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.log("App ready to work offline");
|
||||
},
|
||||
});
|
||||
}).catch(() => {
|
||||
// PWA not available in development mode or when disabled
|
||||
console.log("PWA registration not available");
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
updateAvailable,
|
||||
updateSW,
|
||||
};
|
||||
}
|
||||
@@ -86,9 +86,5 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
Transaction,
|
||||
AnalyticsTransaction,
|
||||
Balance,
|
||||
PaginatedResponse,
|
||||
ApiResponse,
|
||||
NotificationSettings,
|
||||
NotificationTest,
|
||||
NotificationService,
|
||||
@@ -13,14 +13,6 @@ import type {
|
||||
AccountUpdate,
|
||||
TransactionStats,
|
||||
SyncOperationsResponse,
|
||||
BankInstitution,
|
||||
BankConnectionStatus,
|
||||
BankRequisition,
|
||||
Country,
|
||||
BackupSettings,
|
||||
BackupTest,
|
||||
BackupInfo,
|
||||
BackupOperation,
|
||||
} from "../types/api";
|
||||
|
||||
// Use VITE_API_URL for development, relative URLs for production
|
||||
@@ -36,14 +28,14 @@ const api = axios.create({
|
||||
export const apiClient = {
|
||||
// Get all accounts
|
||||
getAccounts: async (): Promise<Account[]> => {
|
||||
const response = await api.get<Account[]>("/accounts");
|
||||
return response.data;
|
||||
const response = await api.get<ApiResponse<Account[]>>("/accounts");
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get account by ID
|
||||
getAccount: async (id: string): Promise<Account> => {
|
||||
const response = await api.get<Account>(`/accounts/${id}`);
|
||||
return response.data;
|
||||
const response = await api.get<ApiResponse<Account>>(`/accounts/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Update account details
|
||||
@@ -51,17 +43,16 @@ export const apiClient = {
|
||||
id: string,
|
||||
updates: AccountUpdate,
|
||||
): Promise<{ id: string; display_name?: string }> => {
|
||||
const response = await api.put<{ id: string; display_name?: string }>(
|
||||
`/accounts/${id}`,
|
||||
updates,
|
||||
);
|
||||
return response.data;
|
||||
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<Balance[]>("/balances");
|
||||
return response.data;
|
||||
const response = await api.get<ApiResponse<Balance[]>>("/balances");
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get historical balances for balance progression chart
|
||||
@@ -73,18 +64,18 @@ export const apiClient = {
|
||||
if (days) queryParams.append("days", days.toString());
|
||||
if (accountId) queryParams.append("account_id", accountId);
|
||||
|
||||
const response = await api.get<Balance[]>(
|
||||
const response = await api.get<ApiResponse<Balance[]>>(
|
||||
`/balances/history?${queryParams.toString()}`,
|
||||
);
|
||||
return response.data;
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get balances for specific account
|
||||
getAccountBalances: async (accountId: string): Promise<Balance[]> => {
|
||||
const response = await api.get<Balance[]>(
|
||||
const response = await api.get<ApiResponse<Balance[]>>(
|
||||
`/accounts/${accountId}/balances`,
|
||||
);
|
||||
return response.data;
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get transactions with optional filters
|
||||
@@ -98,7 +89,7 @@ export const apiClient = {
|
||||
summaryOnly?: boolean;
|
||||
minAmount?: number;
|
||||
maxAmount?: number;
|
||||
}): Promise<PaginatedResponse<Transaction>> => {
|
||||
}): Promise<ApiResponse<Transaction[]>> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params?.accountId) queryParams.append("account_id", params.accountId);
|
||||
@@ -118,7 +109,7 @@ export const apiClient = {
|
||||
queryParams.append("max_amount", params.maxAmount.toString());
|
||||
}
|
||||
|
||||
const response = await api.get<PaginatedResponse<Transaction>>(
|
||||
const response = await api.get<ApiResponse<Transaction[]>>(
|
||||
`/transactions?${queryParams.toString()}`,
|
||||
);
|
||||
return response.data;
|
||||
@@ -126,27 +117,29 @@ export const apiClient = {
|
||||
|
||||
// Get transaction by ID
|
||||
getTransaction: async (id: string): Promise<Transaction> => {
|
||||
const response = await api.get<Transaction>(`/transactions/${id}`);
|
||||
return response.data;
|
||||
const response = await api.get<ApiResponse<Transaction>>(
|
||||
`/transactions/${id}`,
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get notification settings
|
||||
getNotificationSettings: async (): Promise<NotificationSettings> => {
|
||||
const response = await api.get<NotificationSettings>(
|
||||
const response = await api.get<ApiResponse<NotificationSettings>>(
|
||||
"/notifications/settings",
|
||||
);
|
||||
return response.data;
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Update notification settings
|
||||
updateNotificationSettings: async (
|
||||
settings: NotificationSettings,
|
||||
): Promise<NotificationSettings> => {
|
||||
const response = await api.put<NotificationSettings>(
|
||||
const response = await api.put<ApiResponse<NotificationSettings>>(
|
||||
"/notifications/settings",
|
||||
settings,
|
||||
);
|
||||
return response.data;
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Test notification
|
||||
@@ -156,11 +149,11 @@ export const apiClient = {
|
||||
|
||||
// Get notification services
|
||||
getNotificationServices: async (): Promise<NotificationService[]> => {
|
||||
const response = await api.get<NotificationServicesResponse>(
|
||||
const response = await api.get<ApiResponse<NotificationServicesResponse>>(
|
||||
"/notifications/services",
|
||||
);
|
||||
// Convert object to array format
|
||||
const servicesData = response.data;
|
||||
const servicesData = response.data.data;
|
||||
return Object.values(servicesData);
|
||||
},
|
||||
|
||||
@@ -171,8 +164,8 @@ export const apiClient = {
|
||||
|
||||
// Health check
|
||||
getHealth: async (): Promise<HealthData> => {
|
||||
const response = await api.get<HealthData>("/health");
|
||||
return response.data;
|
||||
const response = await api.get<ApiResponse<HealthData>>("/health");
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Analytics endpoints
|
||||
@@ -180,10 +173,10 @@ export const apiClient = {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (days) queryParams.append("days", days.toString());
|
||||
|
||||
const response = await api.get<TransactionStats>(
|
||||
const response = await api.get<ApiResponse<TransactionStats>>(
|
||||
`/transactions/stats?${queryParams.toString()}`,
|
||||
);
|
||||
return response.data;
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get all transactions for analytics (no pagination)
|
||||
@@ -193,10 +186,10 @@ export const apiClient = {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (days) queryParams.append("days", days.toString());
|
||||
|
||||
const response = await api.get<AnalyticsTransaction[]>(
|
||||
const response = await api.get<ApiResponse<AnalyticsTransaction[]>>(
|
||||
`/transactions/analytics?${queryParams.toString()}`,
|
||||
);
|
||||
return response.data;
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get monthly transaction statistics (pre-calculated)
|
||||
@@ -214,14 +207,16 @@ export const apiClient = {
|
||||
if (days) queryParams.append("days", days.toString());
|
||||
|
||||
const response = await api.get<
|
||||
Array<{
|
||||
month: string;
|
||||
income: number;
|
||||
expenses: number;
|
||||
net: number;
|
||||
}>
|
||||
ApiResponse<
|
||||
Array<{
|
||||
month: string;
|
||||
income: number;
|
||||
expenses: number;
|
||||
net: number;
|
||||
}>
|
||||
>
|
||||
>(`/transactions/monthly-stats?${queryParams.toString()}`);
|
||||
return response.data;
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get sync operations history
|
||||
@@ -229,96 +224,10 @@ export const apiClient = {
|
||||
limit: number = 50,
|
||||
offset: number = 0,
|
||||
): Promise<SyncOperationsResponse> => {
|
||||
const response = await api.get<SyncOperationsResponse>(
|
||||
const response = await api.get<ApiResponse<SyncOperationsResponse>>(
|
||||
`/sync/operations?limit=${limit}&offset=${offset}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Bank management endpoints
|
||||
getBankInstitutions: async (country: string): Promise<BankInstitution[]> => {
|
||||
const response = await api.get<BankInstitution[]>(
|
||||
`/banks/institutions?country=${country}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getBankConnectionsStatus: async (): Promise<BankConnectionStatus[]> => {
|
||||
const response = await api.get<BankConnectionStatus[]>("/banks/status");
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createBankConnection: async (
|
||||
institutionId: string,
|
||||
redirectUrl?: string,
|
||||
): Promise<BankRequisition> => {
|
||||
// If no redirect URL provided, construct it from current location
|
||||
const finalRedirectUrl =
|
||||
redirectUrl || `${window.location.origin}/bank-connected`;
|
||||
|
||||
const response = await api.post<BankRequisition>("/banks/connect", {
|
||||
institution_id: institutionId,
|
||||
redirect_url: finalRedirectUrl,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteBankConnection: async (requisitionId: string): Promise<void> => {
|
||||
await api.delete(`/banks/connections/${requisitionId}`);
|
||||
},
|
||||
|
||||
getSupportedCountries: async (): Promise<Country[]> => {
|
||||
const response = await api.get<Country[]>("/banks/countries");
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Backup endpoints
|
||||
getBackupSettings: async (): Promise<BackupSettings> => {
|
||||
const response = await api.get<BackupSettings>("/backup/settings");
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateBackupSettings: async (
|
||||
settings: BackupSettings,
|
||||
): Promise<BackupSettings> => {
|
||||
const response = await api.put<BackupSettings>(
|
||||
"/backup/settings",
|
||||
settings,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
testBackupConnection: async (
|
||||
test: BackupTest,
|
||||
): Promise<{ connected?: boolean; success?: boolean; message?: string }> => {
|
||||
const response = await api.post<{
|
||||
connected?: boolean;
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
}>("/backup/test", test);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
listBackups: async (): Promise<BackupInfo[]> => {
|
||||
const response = await api.get<BackupInfo[]>("/backup/list");
|
||||
return response.data;
|
||||
},
|
||||
|
||||
performBackupOperation: async (
|
||||
operation: BackupOperation,
|
||||
): Promise<{
|
||||
operation: string;
|
||||
completed: boolean;
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
}> => {
|
||||
const response = await api.post<{
|
||||
operation: string;
|
||||
completed: boolean;
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
}>("/backup/operation", operation);
|
||||
return response.data;
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -3,10 +3,8 @@ 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 { BalanceVisibilityProvider } from "./contexts/BalanceVisibilityContext";
|
||||
import "./index.css";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
|
||||
const router = createRouter({ routeTree });
|
||||
|
||||
@@ -19,64 +17,11 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
const intervalMS = 60 * 60 * 1000;
|
||||
|
||||
registerSW({
|
||||
onRegisteredSW(swUrl, r) {
|
||||
console.log("[PWA] Service worker registered successfully");
|
||||
|
||||
if (r) {
|
||||
setInterval(async () => {
|
||||
console.log("[PWA] Checking for updates...");
|
||||
|
||||
if (r.installing) {
|
||||
console.log("[PWA] Update already installing, skipping check");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator) {
|
||||
console.log("[PWA] Navigator not available, skipping check");
|
||||
return;
|
||||
}
|
||||
|
||||
if ("connection" in navigator && !navigator.onLine) {
|
||||
console.log("[PWA] Device is offline, skipping check");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(swUrl, {
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
cache: "no-store",
|
||||
"cache-control": "no-cache",
|
||||
},
|
||||
});
|
||||
|
||||
if (resp?.status === 200) {
|
||||
console.log("[PWA] Update check successful, triggering update");
|
||||
await r.update();
|
||||
} else {
|
||||
console.log(`[PWA] Update check returned status: ${resp?.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[PWA] Error checking for updates:", error);
|
||||
}
|
||||
}, intervalMS);
|
||||
}
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.log("[PWA] App ready to work offline");
|
||||
},
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<BalanceVisibilityProvider>
|
||||
<RouterProvider router={router} />
|
||||
</BalanceVisibilityProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -10,10 +10,8 @@
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as TransactionsRouteImport } from './routes/transactions'
|
||||
import { Route as SystemRouteImport } from './routes/system'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as NotificationsRouteImport } from './routes/notifications'
|
||||
import { Route as BankConnectedRouteImport } from './routes/bank-connected'
|
||||
import { Route as AnalyticsRouteImport } from './routes/analytics'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
|
||||
@@ -22,11 +20,6 @@ const TransactionsRoute = TransactionsRouteImport.update({
|
||||
path: '/transactions',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SystemRoute = SystemRouteImport.update({
|
||||
id: '/system',
|
||||
path: '/system',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SettingsRoute = SettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
@@ -37,11 +30,6 @@ const NotificationsRoute = NotificationsRouteImport.update({
|
||||
path: '/notifications',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const BankConnectedRoute = BankConnectedRouteImport.update({
|
||||
id: '/bank-connected',
|
||||
path: '/bank-connected',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AnalyticsRoute = AnalyticsRouteImport.update({
|
||||
id: '/analytics',
|
||||
path: '/analytics',
|
||||
@@ -56,29 +44,23 @@ const IndexRoute = IndexRouteImport.update({
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/analytics': typeof AnalyticsRoute
|
||||
'/bank-connected': typeof BankConnectedRoute
|
||||
'/notifications': typeof NotificationsRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/system': typeof SystemRoute
|
||||
'/transactions': typeof TransactionsRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/analytics': typeof AnalyticsRoute
|
||||
'/bank-connected': typeof BankConnectedRoute
|
||||
'/notifications': typeof NotificationsRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/system': typeof SystemRoute
|
||||
'/transactions': typeof TransactionsRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/analytics': typeof AnalyticsRoute
|
||||
'/bank-connected': typeof BankConnectedRoute
|
||||
'/notifications': typeof NotificationsRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/system': typeof SystemRoute
|
||||
'/transactions': typeof TransactionsRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
@@ -86,38 +68,25 @@ export interface FileRouteTypes {
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/analytics'
|
||||
| '/bank-connected'
|
||||
| '/notifications'
|
||||
| '/settings'
|
||||
| '/system'
|
||||
| '/transactions'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/analytics'
|
||||
| '/bank-connected'
|
||||
| '/notifications'
|
||||
| '/settings'
|
||||
| '/system'
|
||||
| '/transactions'
|
||||
to: '/' | '/analytics' | '/notifications' | '/settings' | '/transactions'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/analytics'
|
||||
| '/bank-connected'
|
||||
| '/notifications'
|
||||
| '/settings'
|
||||
| '/system'
|
||||
| '/transactions'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AnalyticsRoute: typeof AnalyticsRoute
|
||||
BankConnectedRoute: typeof BankConnectedRoute
|
||||
NotificationsRoute: typeof NotificationsRoute
|
||||
SettingsRoute: typeof SettingsRoute
|
||||
SystemRoute: typeof SystemRoute
|
||||
TransactionsRoute: typeof TransactionsRoute
|
||||
}
|
||||
|
||||
@@ -130,13 +99,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof TransactionsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/system': {
|
||||
id: '/system'
|
||||
path: '/system'
|
||||
fullPath: '/system'
|
||||
preLoaderRoute: typeof SystemRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/settings': {
|
||||
id: '/settings'
|
||||
path: '/settings'
|
||||
@@ -151,13 +113,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof NotificationsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/bank-connected': {
|
||||
id: '/bank-connected'
|
||||
path: '/bank-connected'
|
||||
fullPath: '/bank-connected'
|
||||
preLoaderRoute: typeof BankConnectedRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/analytics': {
|
||||
id: '/analytics'
|
||||
path: '/analytics'
|
||||
@@ -178,10 +133,8 @@ declare module '@tanstack/react-router' {
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AnalyticsRoute: AnalyticsRoute,
|
||||
BankConnectedRoute: BankConnectedRoute,
|
||||
NotificationsRoute: NotificationsRoute,
|
||||
SettingsRoute: SettingsRoute,
|
||||
SystemRoute: SystemRoute,
|
||||
TransactionsRoute: TransactionsRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||
import { AppSidebar } from "../components/AppSidebar";
|
||||
import { SiteHeader } from "../components/SiteHeader";
|
||||
import { SidebarInset, SidebarProvider } from "../components/ui/sidebar";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
|
||||
import { usePWA } from "../hooks/usePWA";
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
} from "../components/ui/sidebar";
|
||||
|
||||
function RootLayout() {
|
||||
const { updateAvailable, updateSW } = usePWA();
|
||||
|
||||
const handlePWAInstall = () => {
|
||||
console.log("PWA installed successfully");
|
||||
};
|
||||
|
||||
const handlePWAUpdate = async () => {
|
||||
try {
|
||||
await updateSW();
|
||||
console.log("PWA updated successfully");
|
||||
} catch (error) {
|
||||
console.error("Error updating PWA:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
style={
|
||||
@@ -22,8 +41,12 @@ function RootLayout() {
|
||||
</main>
|
||||
</SidebarInset>
|
||||
|
||||
{/* Toast Notifications */}
|
||||
<Toaster />
|
||||
{/* PWA Prompts */}
|
||||
<PWAInstallPrompt onInstall={handlePWAInstall} />
|
||||
<PWAUpdatePrompt
|
||||
updateAvailable={updateAvailable}
|
||||
onUpdate={handlePWAUpdate}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ function AnalyticsDashboard() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<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">
|
||||
@@ -62,7 +62,7 @@ function AnalyticsDashboard() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="p-6 space-y-8">
|
||||
{/* Time Period Filter */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
@@ -88,7 +88,6 @@ function AnalyticsDashboard() {
|
||||
subtitle="Inflows this period"
|
||||
icon={TrendingUp}
|
||||
iconColor="green"
|
||||
shouldBlur={true}
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Expenses"
|
||||
@@ -96,7 +95,6 @@ function AnalyticsDashboard() {
|
||||
subtitle="Outflows this period"
|
||||
icon={TrendingDown}
|
||||
iconColor="red"
|
||||
shouldBlur={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -108,7 +106,6 @@ function AnalyticsDashboard() {
|
||||
subtitle="Income minus expenses"
|
||||
icon={CreditCard}
|
||||
iconColor={(stats?.net_change || 0) >= 0 ? "green" : "red"}
|
||||
shouldBlur={true}
|
||||
/>
|
||||
<StatCard
|
||||
title="Average Transaction"
|
||||
@@ -116,7 +113,6 @@ function AnalyticsDashboard() {
|
||||
subtitle="Per transaction"
|
||||
icon={Activity}
|
||||
iconColor="purple"
|
||||
shouldBlur={true}
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Accounts"
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { createFileRoute, useSearch } from "@tanstack/react-router";
|
||||
import { CheckCircle, ArrowLeft } from "lucide-react";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../components/ui/card";
|
||||
|
||||
function BankConnected() {
|
||||
const search = useSearch({ from: "/bank-connected" });
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto mb-4">
|
||||
<CheckCircle className="h-16 w-16 text-green-500" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Account Connected!</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
Your bank account has been successfully connected to Leggen. We'll
|
||||
start syncing your transactions shortly.
|
||||
</p>
|
||||
|
||||
{search?.bank && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connected to: <strong>{search.bank}</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
onClick={() => (window.location.href = "/settings")}
|
||||
className="w-full"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Go to Settings
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/bank-connected")({
|
||||
component: BankConnected,
|
||||
validateSearch: (search: Record<string, unknown>) => {
|
||||
return {
|
||||
bank: (search.bank as string) || undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import System from "../components/System";
|
||||
import Notifications from "../components/Notifications";
|
||||
|
||||
export const Route = createFileRoute("/notifications")({
|
||||
component: System,
|
||||
component: Notifications,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import Settings from "../components/Settings";
|
||||
import AccountSettings from "../components/AccountSettings";
|
||||
|
||||
export const Route = createFileRoute("/settings")({
|
||||
component: Settings,
|
||||
component: AccountSettings,
|
||||
});
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import System from "../components/System";
|
||||
|
||||
export const Route = createFileRoute("/system")({
|
||||
component: System,
|
||||
});
|
||||
@@ -13,7 +13,6 @@ export interface Account {
|
||||
name?: string;
|
||||
display_name?: string;
|
||||
currency?: string;
|
||||
logo?: string;
|
||||
created: string;
|
||||
last_accessed?: string;
|
||||
balances: AccountBalance[];
|
||||
@@ -133,14 +132,26 @@ export interface Bank {
|
||||
logo_url?: string;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
success: boolean;
|
||||
pagination?: {
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
}
|
||||
|
||||
// Notification types
|
||||
@@ -186,17 +197,10 @@ export interface NotificationServicesResponse {
|
||||
export interface HealthData {
|
||||
status: string;
|
||||
config_loaded?: boolean;
|
||||
version?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Version information from root endpoint
|
||||
export interface VersionData {
|
||||
message: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
// Analytics data types
|
||||
export interface TransactionStats {
|
||||
period_days: number;
|
||||
@@ -223,76 +227,10 @@ export interface SyncOperation {
|
||||
duration_seconds?: number;
|
||||
errors: string[];
|
||||
logs: string[];
|
||||
trigger_type: "manual" | "scheduled" | "api";
|
||||
trigger_type: 'manual' | 'scheduled' | 'api';
|
||||
}
|
||||
|
||||
export interface SyncOperationsResponse {
|
||||
operations: SyncOperation[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
// Bank-related types
|
||||
export interface BankInstitution {
|
||||
id: string;
|
||||
name: string;
|
||||
bic?: string;
|
||||
transaction_total_days: number;
|
||||
countries: string[];
|
||||
logo?: string;
|
||||
}
|
||||
|
||||
export interface BankRequisition {
|
||||
id: string;
|
||||
institution_id: string;
|
||||
status: string;
|
||||
status_display?: string;
|
||||
created: string;
|
||||
link: string;
|
||||
accounts: string[];
|
||||
}
|
||||
|
||||
export interface BankConnectionStatus {
|
||||
bank_id: string;
|
||||
bank_name: string;
|
||||
status: string;
|
||||
status_display: string;
|
||||
created_at: string;
|
||||
requisition_id: string;
|
||||
accounts_count: number;
|
||||
}
|
||||
|
||||
export interface Country {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Backup types
|
||||
export interface S3Config {
|
||||
access_key_id: string;
|
||||
secret_access_key: string;
|
||||
bucket_name: string;
|
||||
region: string;
|
||||
endpoint_url?: string;
|
||||
path_style: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface BackupSettings {
|
||||
s3?: S3Config;
|
||||
}
|
||||
|
||||
export interface BackupTest {
|
||||
service: string;
|
||||
config: S3Config;
|
||||
}
|
||||
|
||||
export interface BackupInfo {
|
||||
key: string;
|
||||
last_modified: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface BackupOperation {
|
||||
operation: string;
|
||||
backup_key?: string;
|
||||
}
|
||||
|
||||
@@ -3,71 +3,71 @@ 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)",
|
||||
},
|
||||
spacing: {
|
||||
"safe-top": "var(--safe-area-inset-top)",
|
||||
"safe-bottom": "var(--safe-area-inset-bottom)",
|
||||
"safe-left": "var(--safe-area-inset-left)",
|
||||
"safe-right": "var(--safe-area-inset-right)",
|
||||
},
|
||||
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))",
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: "hsl(var(--sidebar-background))",
|
||||
foreground: "hsl(var(--sidebar-foreground))",
|
||||
primary: "hsl(var(--sidebar-primary))",
|
||||
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
|
||||
accent: "hsl(var(--sidebar-accent))",
|
||||
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
||||
border: "hsl(var(--sidebar-border))",
|
||||
ring: "hsl(var(--sidebar-ring))",
|
||||
},
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
},
|
||||
spacing: {
|
||||
'safe-top': 'var(--safe-area-inset-top)',
|
||||
'safe-bottom': 'var(--safe-area-inset-bottom)',
|
||||
'safe-left': 'var(--safe-area-inset-left)',
|
||||
'safe-right': 'var(--safe-area-inset-right)'
|
||||
},
|
||||
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))'
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: 'hsl(var(--sidebar-background))',
|
||||
foreground: 'hsl(var(--sidebar-foreground))',
|
||||
primary: 'hsl(var(--sidebar-primary))',
|
||||
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
|
||||
accent: 'hsl(var(--sidebar-accent))',
|
||||
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
|
||||
border: 'hsl(var(--sidebar-border))',
|
||||
ring: 'hsl(var(--sidebar-ring))'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [require("@tailwindcss/forms"), require("tailwindcss-animate")],
|
||||
};
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { tanstackRouter } from "@tanstack/router-vite-plugin";
|
||||
import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tanstackRouter(),
|
||||
TanStackRouterVite(),
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
includeAssets: [
|
||||
"robots.txt"
|
||||
],
|
||||
includeAssets: ["favicon.ico", "apple-touch-icon-180x180.png", "maskable-icon-512x512.png", "robots.txt"],
|
||||
manifest: {
|
||||
name: "Leggen",
|
||||
short_name: "Leggen",
|
||||
@@ -30,38 +28,38 @@ export default defineConfig({
|
||||
short_name: "Transactions",
|
||||
description: "View and manage transactions",
|
||||
url: "/transactions",
|
||||
icons: [{ src: "/pwa-192x192.png", sizes: "192x192" }],
|
||||
icons: [{ src: "/pwa-192x192.png", sizes: "192x192" }]
|
||||
},
|
||||
{
|
||||
name: "Analytics",
|
||||
short_name: "Analytics",
|
||||
description: "View financial analytics",
|
||||
url: "/analytics",
|
||||
icons: [{ src: "/pwa-192x192.png", sizes: "192x192" }],
|
||||
},
|
||||
icons: [{ src: "/pwa-192x192.png", sizes: "192x192" }]
|
||||
}
|
||||
],
|
||||
icons: [
|
||||
{
|
||||
src: "pwa-64x64.png",
|
||||
sizes: "64x64",
|
||||
type: "image/png",
|
||||
type: "image/png"
|
||||
},
|
||||
{
|
||||
src: "pwa-192x192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
type: "image/png"
|
||||
},
|
||||
{
|
||||
src: "pwa-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
type: "image/png"
|
||||
},
|
||||
{
|
||||
src: "maskable-icon-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
purpose: "maskable"
|
||||
}
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
"""FastAPI dependency injection setup for repositories and services."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from leggen.repositories import (
|
||||
AccountRepository,
|
||||
BalanceRepository,
|
||||
MigrationRepository,
|
||||
SyncRepository,
|
||||
TransactionRepository,
|
||||
)
|
||||
from leggen.services.data_processors import (
|
||||
AnalyticsProcessor,
|
||||
BalanceTransformer,
|
||||
TransactionProcessor,
|
||||
)
|
||||
from leggen.utils.config import config
|
||||
|
||||
|
||||
def get_account_repository() -> AccountRepository:
|
||||
"""Get account repository instance."""
|
||||
return AccountRepository()
|
||||
|
||||
|
||||
def get_balance_repository() -> BalanceRepository:
|
||||
"""Get balance repository instance."""
|
||||
return BalanceRepository()
|
||||
|
||||
|
||||
def get_transaction_repository() -> TransactionRepository:
|
||||
"""Get transaction repository instance."""
|
||||
return TransactionRepository()
|
||||
|
||||
|
||||
def get_sync_repository() -> SyncRepository:
|
||||
"""Get sync repository instance."""
|
||||
return SyncRepository()
|
||||
|
||||
|
||||
def get_migration_repository() -> MigrationRepository:
|
||||
"""Get migration repository instance."""
|
||||
return MigrationRepository()
|
||||
|
||||
|
||||
def get_transaction_processor() -> TransactionProcessor:
|
||||
"""Get transaction processor instance."""
|
||||
return TransactionProcessor()
|
||||
|
||||
|
||||
def get_balance_transformer() -> BalanceTransformer:
|
||||
"""Get balance transformer instance."""
|
||||
return BalanceTransformer()
|
||||
|
||||
|
||||
def get_analytics_processor() -> AnalyticsProcessor:
|
||||
"""Get analytics processor instance."""
|
||||
return AnalyticsProcessor()
|
||||
|
||||
|
||||
def is_sqlite_enabled() -> bool:
|
||||
"""Check if SQLite is enabled in configuration."""
|
||||
return config.database_config.get("sqlite", True)
|
||||
|
||||
|
||||
# Type annotations for dependency injection
|
||||
AccountRepo = Annotated[AccountRepository, Depends(get_account_repository)]
|
||||
BalanceRepo = Annotated[BalanceRepository, Depends(get_balance_repository)]
|
||||
TransactionRepo = Annotated[TransactionRepository, Depends(get_transaction_repository)]
|
||||
SyncRepo = Annotated[SyncRepository, Depends(get_sync_repository)]
|
||||
MigrationRepo = Annotated[MigrationRepository, Depends(get_migration_repository)]
|
||||
TransactionProc = Annotated[TransactionProcessor, Depends(get_transaction_processor)]
|
||||
BalanceTransform = Annotated[BalanceTransformer, Depends(get_balance_transformer)]
|
||||
AnalyticsProc = Annotated[AnalyticsProcessor, Depends(get_analytics_processor)]
|
||||
@@ -26,7 +26,6 @@ class AccountDetails(BaseModel):
|
||||
name: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
currency: Optional[str] = None
|
||||
logo: Optional[str] = None
|
||||
created: datetime
|
||||
last_accessed: Optional[datetime] = None
|
||||
balances: List[AccountBalance] = []
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
"""API models for backup endpoints."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class S3Config(BaseModel):
|
||||
"""S3 backup configuration model for API."""
|
||||
|
||||
access_key_id: str = Field(..., description="AWS S3 access key ID")
|
||||
secret_access_key: str = Field(..., description="AWS S3 secret access key")
|
||||
bucket_name: str = Field(..., description="S3 bucket name")
|
||||
region: str = Field(default="us-east-1", description="AWS S3 region")
|
||||
endpoint_url: Optional[str] = Field(
|
||||
default=None, description="Custom S3 endpoint URL"
|
||||
)
|
||||
path_style: bool = Field(default=False, description="Use path-style addressing")
|
||||
enabled: bool = Field(default=True, description="Enable S3 backups")
|
||||
|
||||
|
||||
class BackupSettings(BaseModel):
|
||||
"""Backup settings model for API."""
|
||||
|
||||
s3: Optional[S3Config] = None
|
||||
|
||||
|
||||
class BackupTest(BaseModel):
|
||||
"""Backup connection test request model."""
|
||||
|
||||
service: str = Field(..., description="Backup service type (s3)")
|
||||
config: S3Config = Field(..., description="S3 configuration to test")
|
||||
|
||||
|
||||
class BackupInfo(BaseModel):
|
||||
"""Backup file information model."""
|
||||
|
||||
key: str = Field(..., description="S3 object key")
|
||||
last_modified: str = Field(..., description="Last modified timestamp (ISO format)")
|
||||
size: int = Field(..., description="File size in bytes")
|
||||
|
||||
|
||||
class BackupOperation(BaseModel):
|
||||
"""Backup operation request model."""
|
||||
|
||||
operation: str = Field(..., description="Operation type (backup, restore)")
|
||||
backup_key: Optional[str] = Field(
|
||||
default=None, description="Backup key for restore operations"
|
||||
)
|
||||
@@ -1,17 +1,29 @@
|
||||
from typing import Generic, List, TypeVar
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
class APIResponse(BaseModel):
|
||||
"""Base API response model"""
|
||||
|
||||
success: bool = True
|
||||
message: Optional[str] = None
|
||||
data: Optional[Any] = None
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel, Generic[T]):
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Error response model"""
|
||||
|
||||
success: bool = False
|
||||
message: str
|
||||
error_code: Optional[str] = None
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel):
|
||||
"""Paginated response model"""
|
||||
|
||||
data: List[T]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
total_pages: int
|
||||
has_next: bool
|
||||
has_prev: bool
|
||||
success: bool = True
|
||||
data: list
|
||||
pagination: Dict[str, Any]
|
||||
message: Optional[str] = None
|
||||
|
||||
@@ -18,7 +18,7 @@ class SyncOperation(BaseModel):
|
||||
duration_seconds: Optional[float] = None
|
||||
errors: list[str] = []
|
||||
logs: list[str] = []
|
||||
trigger_type: str = "manual" # manual, scheduled, retry, api
|
||||
trigger_type: str = "manual" # manual, scheduled, api
|
||||
|
||||
class Config:
|
||||
json_encoders = {datetime: lambda v: v.isoformat() if v else None}
|
||||
|
||||
@@ -3,12 +3,6 @@ from typing import List, Optional, Union
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from loguru import logger
|
||||
|
||||
from leggen.api.dependencies import (
|
||||
AccountRepo,
|
||||
AnalyticsProc,
|
||||
BalanceRepo,
|
||||
TransactionRepo,
|
||||
)
|
||||
from leggen.api.models.accounts import (
|
||||
AccountBalance,
|
||||
AccountDetails,
|
||||
@@ -16,27 +10,29 @@ from leggen.api.models.accounts import (
|
||||
Transaction,
|
||||
TransactionSummary,
|
||||
)
|
||||
from leggen.api.models.common import APIResponse
|
||||
from leggen.services.database_service import DatabaseService
|
||||
|
||||
router = APIRouter()
|
||||
database_service = DatabaseService()
|
||||
|
||||
|
||||
@router.get("/accounts")
|
||||
async def get_all_accounts(
|
||||
account_repo: AccountRepo,
|
||||
balance_repo: BalanceRepo,
|
||||
) -> List[AccountDetails]:
|
||||
@router.get("/accounts", response_model=APIResponse)
|
||||
async def get_all_accounts() -> APIResponse:
|
||||
"""Get all connected accounts from database"""
|
||||
try:
|
||||
accounts = []
|
||||
|
||||
# Get all account details from database
|
||||
db_accounts = account_repo.get_accounts()
|
||||
db_accounts = await database_service.get_accounts_from_db()
|
||||
|
||||
# Process accounts found in database
|
||||
for db_account in db_accounts:
|
||||
try:
|
||||
# Get latest balances from database for this account
|
||||
balances_data = balance_repo.get_balances(db_account["id"])
|
||||
balances_data = await database_service.get_balances_from_db(
|
||||
db_account["id"]
|
||||
)
|
||||
|
||||
# Process balances
|
||||
balances = []
|
||||
@@ -59,7 +55,6 @@ async def get_all_accounts(
|
||||
name=db_account.get("name"),
|
||||
display_name=db_account.get("display_name"),
|
||||
currency=db_account.get("currency"),
|
||||
logo=db_account.get("logo"),
|
||||
created=db_account["created"],
|
||||
last_accessed=db_account.get("last_accessed"),
|
||||
balances=balances,
|
||||
@@ -72,7 +67,11 @@ async def get_all_accounts(
|
||||
)
|
||||
continue
|
||||
|
||||
return accounts
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=accounts,
|
||||
message=f"Retrieved {len(accounts)} accounts from database",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get accounts: {e}")
|
||||
@@ -81,16 +80,12 @@ async def get_all_accounts(
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/accounts/{account_id}")
|
||||
async def get_account_details(
|
||||
account_id: str,
|
||||
account_repo: AccountRepo,
|
||||
balance_repo: BalanceRepo,
|
||||
) -> AccountDetails:
|
||||
@router.get("/accounts/{account_id}", response_model=APIResponse)
|
||||
async def get_account_details(account_id: str) -> APIResponse:
|
||||
"""Get details for a specific account from database"""
|
||||
try:
|
||||
# Get account details from database
|
||||
db_account = account_repo.get_account(account_id)
|
||||
db_account = await database_service.get_account_details_from_db(account_id)
|
||||
|
||||
if not db_account:
|
||||
raise HTTPException(
|
||||
@@ -98,7 +93,7 @@ async def get_account_details(
|
||||
)
|
||||
|
||||
# Get latest balances from database for this account
|
||||
balances_data = balance_repo.get_balances(account_id)
|
||||
balances_data = await database_service.get_balances_from_db(account_id)
|
||||
|
||||
# Process balances
|
||||
balances = []
|
||||
@@ -120,13 +115,16 @@ async def get_account_details(
|
||||
name=db_account.get("name"),
|
||||
display_name=db_account.get("display_name"),
|
||||
currency=db_account.get("currency"),
|
||||
logo=db_account.get("logo"),
|
||||
created=db_account["created"],
|
||||
last_accessed=db_account.get("last_accessed"),
|
||||
balances=balances,
|
||||
)
|
||||
|
||||
return account
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=account,
|
||||
message=f"Account details retrieved from database for {account_id}",
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -137,15 +135,12 @@ async def get_account_details(
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/accounts/{account_id}/balances")
|
||||
async def get_account_balances(
|
||||
account_id: str,
|
||||
balance_repo: BalanceRepo,
|
||||
) -> List[AccountBalance]:
|
||||
@router.get("/accounts/{account_id}/balances", response_model=APIResponse)
|
||||
async def get_account_balances(account_id: str) -> APIResponse:
|
||||
"""Get balances for a specific account from database"""
|
||||
try:
|
||||
# Get balances from database instead of GoCardless API
|
||||
db_balances = balance_repo.get_balances(account_id=account_id)
|
||||
db_balances = await database_service.get_balances_from_db(account_id=account_id)
|
||||
|
||||
balances = []
|
||||
for balance in db_balances:
|
||||
@@ -158,7 +153,11 @@ async def get_account_balances(
|
||||
)
|
||||
)
|
||||
|
||||
return balances
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=balances,
|
||||
message=f"Retrieved {len(balances)} balances for account {account_id}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
@@ -169,21 +168,20 @@ async def get_account_balances(
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/balances")
|
||||
async def get_all_balances(
|
||||
account_repo: AccountRepo,
|
||||
balance_repo: BalanceRepo,
|
||||
) -> List[dict]:
|
||||
@router.get("/balances", response_model=APIResponse)
|
||||
async def get_all_balances() -> APIResponse:
|
||||
"""Get all balances from all accounts in database"""
|
||||
try:
|
||||
# Get all accounts first to iterate through them
|
||||
db_accounts = account_repo.get_accounts()
|
||||
db_accounts = await database_service.get_accounts_from_db()
|
||||
|
||||
all_balances = []
|
||||
for db_account in db_accounts:
|
||||
try:
|
||||
# Get balances for this account
|
||||
db_balances = balance_repo.get_balances(account_id=db_account["id"])
|
||||
db_balances = await database_service.get_balances_from_db(
|
||||
account_id=db_account["id"]
|
||||
)
|
||||
|
||||
# Process balances and add account info
|
||||
for balance in db_balances:
|
||||
@@ -207,7 +205,11 @@ async def get_all_balances(
|
||||
)
|
||||
continue
|
||||
|
||||
return all_balances
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=all_balances,
|
||||
message=f"Retrieved {len(all_balances)} balances from {len(db_accounts)} accounts",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get all balances: {e}")
|
||||
@@ -216,27 +218,27 @@ async def get_all_balances(
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/balances/history")
|
||||
@router.get("/balances/history", response_model=APIResponse)
|
||||
async def get_historical_balances(
|
||||
analytics_proc: AnalyticsProc,
|
||||
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"
|
||||
),
|
||||
) -> List[dict]:
|
||||
) -> APIResponse:
|
||||
"""Get historical balance progression calculated from transaction history"""
|
||||
try:
|
||||
from leggen.utils.paths import path_manager
|
||||
|
||||
# Get historical balances from database
|
||||
db_path = path_manager.get_database_path()
|
||||
historical_balances = analytics_proc.calculate_historical_balances(
|
||||
db_path, account_id=account_id, days=days or 365
|
||||
historical_balances = await database_service.get_historical_balances_from_db(
|
||||
account_id=account_id, days=days or 365
|
||||
)
|
||||
|
||||
return historical_balances
|
||||
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}")
|
||||
@@ -245,23 +247,27 @@ async def get_historical_balances(
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/accounts/{account_id}/transactions")
|
||||
@router.get("/accounts/{account_id}/transactions", response_model=APIResponse)
|
||||
async def get_account_transactions(
|
||||
account_id: str,
|
||||
transaction_repo: TransactionRepo,
|
||||
limit: Optional[int] = Query(default=100, le=500),
|
||||
offset: Optional[int] = Query(default=0, ge=0),
|
||||
summary_only: bool = Query(
|
||||
default=False, description="Return transaction summaries only"
|
||||
),
|
||||
) -> Union[List[TransactionSummary], List[Transaction]]:
|
||||
) -> APIResponse:
|
||||
"""Get transactions for a specific account from database"""
|
||||
try:
|
||||
# Get transactions from database instead of GoCardless API
|
||||
db_transactions = transaction_repo.get_transactions(
|
||||
db_transactions = await database_service.get_transactions_from_db(
|
||||
account_id=account_id,
|
||||
limit=limit,
|
||||
offset=offset or 0,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
# Get total count for pagination info
|
||||
total_transactions = await database_service.get_transaction_count_from_db(
|
||||
account_id=account_id,
|
||||
)
|
||||
|
||||
data: Union[List[TransactionSummary], List[Transaction]]
|
||||
@@ -300,7 +306,12 @@ async def get_account_transactions(
|
||||
for txn in db_transactions
|
||||
]
|
||||
|
||||
return data
|
||||
actual_offset = offset or 0
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=data,
|
||||
message=f"Retrieved {len(data)} transactions (showing {actual_offset + 1}-{actual_offset + len(data)} of {total_transactions})",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
@@ -311,16 +322,14 @@ async def get_account_transactions(
|
||||
) from e
|
||||
|
||||
|
||||
@router.put("/accounts/{account_id}")
|
||||
@router.put("/accounts/{account_id}", response_model=APIResponse)
|
||||
async def update_account_details(
|
||||
account_id: str,
|
||||
update_data: AccountUpdate,
|
||||
account_repo: AccountRepo,
|
||||
) -> dict:
|
||||
account_id: str, update_data: AccountUpdate
|
||||
) -> APIResponse:
|
||||
"""Update account details (currently only display_name)"""
|
||||
try:
|
||||
# Get current account details
|
||||
current_account = account_repo.get_account(account_id)
|
||||
current_account = await database_service.get_account_details_from_db(account_id)
|
||||
|
||||
if not current_account:
|
||||
raise HTTPException(
|
||||
@@ -333,9 +342,13 @@ async def update_account_details(
|
||||
updated_account_data["display_name"] = update_data.display_name
|
||||
|
||||
# Persist updated account details
|
||||
account_repo.persist(updated_account_data)
|
||||
await database_service.persist_account_details(updated_account_data)
|
||||
|
||||
return {"id": account_id, "display_name": update_data.display_name}
|
||||
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
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
"""API routes for backup management."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from leggen.api.models.backup import (
|
||||
BackupOperation,
|
||||
BackupSettings,
|
||||
BackupTest,
|
||||
S3Config,
|
||||
)
|
||||
from leggen.models.config import S3BackupConfig
|
||||
from leggen.services.backup_service import BackupService
|
||||
from leggen.utils.config import config
|
||||
from leggen.utils.paths import path_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/backup/settings")
|
||||
async def get_backup_settings() -> BackupSettings:
|
||||
"""Get current backup settings."""
|
||||
try:
|
||||
backup_config = config.backup_config
|
||||
|
||||
# Build response safely without exposing secrets
|
||||
s3_config = backup_config.get("s3", {})
|
||||
|
||||
settings = BackupSettings(
|
||||
s3=S3Config(
|
||||
access_key_id="***" if s3_config.get("access_key_id") else "",
|
||||
secret_access_key="***" if s3_config.get("secret_access_key") else "",
|
||||
bucket_name=s3_config.get("bucket_name", ""),
|
||||
region=s3_config.get("region", "us-east-1"),
|
||||
endpoint_url=s3_config.get("endpoint_url"),
|
||||
path_style=s3_config.get("path_style", False),
|
||||
enabled=s3_config.get("enabled", True),
|
||||
)
|
||||
if s3_config.get("bucket_name")
|
||||
else None,
|
||||
)
|
||||
|
||||
return settings
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get backup settings: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to get backup settings: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.put("/backup/settings")
|
||||
async def update_backup_settings(settings: BackupSettings) -> dict:
|
||||
"""Update backup settings."""
|
||||
try:
|
||||
# First test the connection if S3 config is provided
|
||||
if settings.s3:
|
||||
# Convert API model to config model
|
||||
s3_config = S3BackupConfig(
|
||||
access_key_id=settings.s3.access_key_id,
|
||||
secret_access_key=settings.s3.secret_access_key,
|
||||
bucket_name=settings.s3.bucket_name,
|
||||
region=settings.s3.region,
|
||||
endpoint_url=settings.s3.endpoint_url,
|
||||
path_style=settings.s3.path_style,
|
||||
enabled=settings.s3.enabled,
|
||||
)
|
||||
|
||||
# Test connection
|
||||
backup_service = BackupService()
|
||||
connection_success = await backup_service.test_connection(s3_config)
|
||||
|
||||
if not connection_success:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="S3 connection test failed. Please check your configuration.",
|
||||
)
|
||||
|
||||
# Update backup config
|
||||
backup_config = {}
|
||||
|
||||
if settings.s3:
|
||||
backup_config["s3"] = {
|
||||
"access_key_id": settings.s3.access_key_id,
|
||||
"secret_access_key": settings.s3.secret_access_key,
|
||||
"bucket_name": settings.s3.bucket_name,
|
||||
"region": settings.s3.region,
|
||||
"endpoint_url": settings.s3.endpoint_url,
|
||||
"path_style": settings.s3.path_style,
|
||||
"enabled": settings.s3.enabled,
|
||||
}
|
||||
|
||||
# Save to config
|
||||
if backup_config:
|
||||
config.update_section("backup", backup_config)
|
||||
|
||||
return {"updated": True}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update backup settings: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to update backup settings: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/backup/test")
|
||||
async def test_backup_connection(test_request: BackupTest) -> dict:
|
||||
"""Test backup connection."""
|
||||
try:
|
||||
if test_request.service != "s3":
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Only 's3' service is supported"
|
||||
)
|
||||
|
||||
# Convert API model to config model
|
||||
s3_config = S3BackupConfig(
|
||||
access_key_id=test_request.config.access_key_id,
|
||||
secret_access_key=test_request.config.secret_access_key,
|
||||
bucket_name=test_request.config.bucket_name,
|
||||
region=test_request.config.region,
|
||||
endpoint_url=test_request.config.endpoint_url,
|
||||
path_style=test_request.config.path_style,
|
||||
enabled=test_request.config.enabled,
|
||||
)
|
||||
|
||||
backup_service = BackupService()
|
||||
success = await backup_service.test_connection(s3_config)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="S3 connection test failed")
|
||||
|
||||
return {"connected": True}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to test backup connection: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to test backup connection: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/backup/list")
|
||||
async def list_backups() -> list:
|
||||
"""List available backups."""
|
||||
try:
|
||||
backup_config = config.backup_config.get("s3", {})
|
||||
|
||||
if not backup_config.get("bucket_name"):
|
||||
return []
|
||||
|
||||
# Convert config to model
|
||||
s3_config = S3BackupConfig(**backup_config)
|
||||
backup_service = BackupService(s3_config)
|
||||
|
||||
backups = await backup_service.list_backups()
|
||||
|
||||
return backups
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list backups: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to list backups: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/backup/operation")
|
||||
async def backup_operation(operation_request: BackupOperation) -> dict:
|
||||
"""Perform backup operation (backup or restore)."""
|
||||
try:
|
||||
backup_config = config.backup_config.get("s3", {})
|
||||
|
||||
if not backup_config.get("bucket_name"):
|
||||
raise HTTPException(status_code=400, detail="S3 backup is not configured")
|
||||
|
||||
# Convert config to model with validation
|
||||
try:
|
||||
s3_config = S3BackupConfig(**backup_config)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Invalid S3 configuration: {str(e)}"
|
||||
) from e
|
||||
|
||||
backup_service = BackupService(s3_config)
|
||||
|
||||
if operation_request.operation == "backup":
|
||||
# Backup database
|
||||
database_path = path_manager.get_database_path()
|
||||
success = await backup_service.backup_database(database_path)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Database backup failed")
|
||||
|
||||
return {"operation": "backup", "completed": True}
|
||||
|
||||
elif operation_request.operation == "restore":
|
||||
if not operation_request.backup_key:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="backup_key is required for restore operation",
|
||||
)
|
||||
|
||||
# Restore database
|
||||
database_path = path_manager.get_database_path()
|
||||
success = await backup_service.restore_database(
|
||||
operation_request.backup_key, database_path
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Database restore failed")
|
||||
|
||||
return {"operation": "restore", "completed": True}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid operation. Use 'backup' or 'restore'"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to perform backup operation: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to perform backup operation: {str(e)}"
|
||||
) from e
|
||||
@@ -1,4 +1,3 @@
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from loguru import logger
|
||||
|
||||
@@ -8,6 +7,7 @@ from leggen.api.models.banks import (
|
||||
BankInstitution,
|
||||
BankRequisition,
|
||||
)
|
||||
from leggen.api.models.common import APIResponse
|
||||
from leggen.services.gocardless_service import GoCardlessService
|
||||
from leggen.utils.gocardless import REQUISITION_STATUS
|
||||
|
||||
@@ -15,32 +15,31 @@ router = APIRouter()
|
||||
gocardless_service = GoCardlessService()
|
||||
|
||||
|
||||
@router.get("/banks/institutions")
|
||||
@router.get("/banks/institutions", response_model=APIResponse)
|
||||
async def get_bank_institutions(
|
||||
country: str = Query(default="PT", description="Country code (e.g., PT, ES, FR)"),
|
||||
) -> list[BankInstitution]:
|
||||
) -> APIResponse:
|
||||
"""Get available bank institutions for a country"""
|
||||
try:
|
||||
institutions_response = await gocardless_service.get_institutions(country)
|
||||
# Handle both list and dict responses
|
||||
if isinstance(institutions_response, list):
|
||||
institutions_data = institutions_response
|
||||
else:
|
||||
institutions_data = institutions_response.get("results", [])
|
||||
institutions_data = await gocardless_service.get_institutions(country)
|
||||
|
||||
institutions = [
|
||||
BankInstitution(
|
||||
id=inst["id"],
|
||||
name=inst["name"],
|
||||
bic=inst.get("bic"),
|
||||
transaction_total_days=int(inst["transaction_total_days"]),
|
||||
transaction_total_days=inst["transaction_total_days"],
|
||||
countries=inst["countries"],
|
||||
logo=inst.get("logo"),
|
||||
)
|
||||
for inst in institutions_data
|
||||
]
|
||||
|
||||
return institutions
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=institutions,
|
||||
message=f"Found {len(institutions)} institutions for {country}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get institutions for {country}: {e}")
|
||||
@@ -49,8 +48,8 @@ async def get_bank_institutions(
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/banks/connect")
|
||||
async def connect_to_bank(request: BankConnectionRequest) -> BankRequisition:
|
||||
@router.post("/banks/connect", response_model=APIResponse)
|
||||
async def connect_to_bank(request: BankConnectionRequest) -> APIResponse:
|
||||
"""Create a connection to a bank (requisition)"""
|
||||
try:
|
||||
redirect_url = request.redirect_url or "http://localhost:8000/"
|
||||
@@ -67,7 +66,11 @@ async def connect_to_bank(request: BankConnectionRequest) -> BankRequisition:
|
||||
accounts=requisition_data.get("accounts", []),
|
||||
)
|
||||
|
||||
return requisition
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=requisition,
|
||||
message="Bank connection created. Please visit the link to authorize.",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to bank {request.institution_id}: {e}")
|
||||
@@ -76,8 +79,8 @@ async def connect_to_bank(request: BankConnectionRequest) -> BankRequisition:
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/banks/status")
|
||||
async def get_bank_connections_status() -> list[BankConnectionStatus]:
|
||||
@router.get("/banks/status", response_model=APIResponse)
|
||||
async def get_bank_connections_status() -> APIResponse:
|
||||
"""Get status of all bank connections"""
|
||||
try:
|
||||
requisitions_data = await gocardless_service.get_requisitions()
|
||||
@@ -101,7 +104,11 @@ async def get_bank_connections_status() -> list[BankConnectionStatus]:
|
||||
)
|
||||
)
|
||||
|
||||
return connections
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=connections,
|
||||
message=f"Found {len(connections)} bank connections",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get bank connection status: {e}")
|
||||
@@ -110,37 +117,17 @@ async def get_bank_connections_status() -> list[BankConnectionStatus]:
|
||||
) from e
|
||||
|
||||
|
||||
@router.delete("/banks/connections/{requisition_id}")
|
||||
async def delete_bank_connection(requisition_id: str) -> dict:
|
||||
@router.delete("/banks/connections/{requisition_id}", response_model=APIResponse)
|
||||
async def delete_bank_connection(requisition_id: str) -> APIResponse:
|
||||
"""Delete a bank connection"""
|
||||
try:
|
||||
# Delete the requisition from GoCardless
|
||||
result = await gocardless_service.delete_requisition(requisition_id)
|
||||
|
||||
# GoCardless returns different responses for successful deletes
|
||||
# We should check if the operation was actually successful
|
||||
logger.info(f"GoCardless delete response for {requisition_id}: {result}")
|
||||
|
||||
return {"deleted": requisition_id}
|
||||
|
||||
except httpx.HTTPStatusError as http_err:
|
||||
logger.error(
|
||||
f"HTTP error deleting bank connection {requisition_id}: {http_err}"
|
||||
# This would need to be implemented in GoCardlessService
|
||||
# For now, return success
|
||||
return APIResponse(
|
||||
success=True,
|
||||
message=f"Bank connection {requisition_id} deleted successfully",
|
||||
)
|
||||
if http_err.response.status_code == 404:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Bank connection {requisition_id} not found"
|
||||
) from http_err
|
||||
elif http_err.response.status_code == 400:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid request to delete connection {requisition_id}",
|
||||
) from http_err
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=http_err.response.status_code,
|
||||
detail=f"GoCardless API error: {http_err}",
|
||||
) from http_err
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete bank connection {requisition_id}: {e}")
|
||||
raise HTTPException(
|
||||
@@ -148,8 +135,8 @@ async def delete_bank_connection(requisition_id: str) -> dict:
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/banks/countries")
|
||||
async def get_supported_countries() -> list[dict]:
|
||||
@router.get("/banks/countries", response_model=APIResponse)
|
||||
async def get_supported_countries() -> APIResponse:
|
||||
"""Get list of supported countries"""
|
||||
countries = [
|
||||
{"code": "AT", "name": "Austria"},
|
||||
@@ -185,4 +172,8 @@ async def get_supported_countries() -> list[dict]:
|
||||
{"code": "GB", "name": "United Kingdom"},
|
||||
]
|
||||
|
||||
return countries
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=countries,
|
||||
message="Supported countries retrieved successfully",
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Any, Dict
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from leggen.api.models.common import APIResponse
|
||||
from leggen.api.models.notifications import (
|
||||
DiscordConfig,
|
||||
NotificationFilters,
|
||||
@@ -17,8 +18,8 @@ router = APIRouter()
|
||||
notification_service = NotificationService()
|
||||
|
||||
|
||||
@router.get("/notifications/settings")
|
||||
async def get_notification_settings() -> NotificationSettings:
|
||||
@router.get("/notifications/settings", response_model=APIResponse)
|
||||
async def get_notification_settings() -> APIResponse:
|
||||
"""Get current notification settings"""
|
||||
try:
|
||||
notifications_config = config.notifications_config
|
||||
@@ -48,7 +49,11 @@ async def get_notification_settings() -> NotificationSettings:
|
||||
),
|
||||
)
|
||||
|
||||
return settings
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=settings,
|
||||
message="Notification settings retrieved successfully",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get notification settings: {e}")
|
||||
@@ -57,8 +62,8 @@ async def get_notification_settings() -> NotificationSettings:
|
||||
) from e
|
||||
|
||||
|
||||
@router.put("/notifications/settings")
|
||||
async def update_notification_settings(settings: NotificationSettings) -> dict:
|
||||
@router.put("/notifications/settings", response_model=APIResponse)
|
||||
async def update_notification_settings(settings: NotificationSettings) -> APIResponse:
|
||||
"""Update notification settings"""
|
||||
try:
|
||||
# Update notifications config
|
||||
@@ -90,7 +95,11 @@ async def update_notification_settings(settings: NotificationSettings) -> dict:
|
||||
if filters_config:
|
||||
config.update_section("filters", filters_config)
|
||||
|
||||
return {"updated": True}
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data={"updated": True},
|
||||
message="Notification settings updated successfully",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update notification settings: {e}")
|
||||
@@ -99,24 +108,26 @@ async def update_notification_settings(settings: NotificationSettings) -> dict:
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/notifications/test")
|
||||
async def test_notification(test_request: NotificationTest) -> dict:
|
||||
@router.post("/notifications/test", response_model=APIResponse)
|
||||
async def test_notification(test_request: NotificationTest) -> APIResponse:
|
||||
"""Send a test notification"""
|
||||
try:
|
||||
success = await notification_service.send_test_notification(
|
||||
test_request.service, test_request.message
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to send test notification to {test_request.service}",
|
||||
if success:
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data={"sent": True},
|
||||
message=f"Test notification sent to {test_request.service} successfully",
|
||||
)
|
||||
else:
|
||||
return APIResponse(
|
||||
success=False,
|
||||
message=f"Failed to send test notification to {test_request.service}",
|
||||
)
|
||||
|
||||
return {"sent": True}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send test notification: {e}")
|
||||
raise HTTPException(
|
||||
@@ -124,8 +135,8 @@ async def test_notification(test_request: NotificationTest) -> dict:
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/notifications/services")
|
||||
async def get_notification_services() -> dict:
|
||||
@router.get("/notifications/services", response_model=APIResponse)
|
||||
async def get_notification_services() -> APIResponse:
|
||||
"""Get available notification services and their status"""
|
||||
try:
|
||||
notifications_config = config.notifications_config
|
||||
@@ -153,7 +164,11 @@ async def get_notification_services() -> dict:
|
||||
},
|
||||
}
|
||||
|
||||
return services
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=services,
|
||||
message="Notification services status retrieved successfully",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get notification services: {e}")
|
||||
@@ -162,8 +177,8 @@ async def get_notification_services() -> dict:
|
||||
) from e
|
||||
|
||||
|
||||
@router.delete("/notifications/settings/{service}")
|
||||
async def delete_notification_service(service: str) -> dict:
|
||||
@router.delete("/notifications/settings/{service}", response_model=APIResponse)
|
||||
async def delete_notification_service(service: str) -> APIResponse:
|
||||
"""Delete/disable a notification service"""
|
||||
try:
|
||||
if service not in ["discord", "telegram"]:
|
||||
@@ -176,10 +191,12 @@ async def delete_notification_service(service: str) -> dict:
|
||||
del notifications_config[service]
|
||||
config.update_section("notifications", notifications_config)
|
||||
|
||||
return {"deleted": service}
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data={"deleted": service},
|
||||
message=f"{service.capitalize()} notification service deleted successfully",
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete notification service {service}: {e}")
|
||||
raise HTTPException(
|
||||
|
||||
@@ -3,7 +3,8 @@ from typing import Optional
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from leggen.api.models.sync import SchedulerConfig, SyncRequest, SyncResult, SyncStatus
|
||||
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
|
||||
@@ -12,8 +13,8 @@ router = APIRouter()
|
||||
sync_service = SyncService()
|
||||
|
||||
|
||||
@router.get("/sync/status")
|
||||
async def get_sync_status() -> SyncStatus:
|
||||
@router.get("/sync/status", response_model=APIResponse)
|
||||
async def get_sync_status() -> APIResponse:
|
||||
"""Get current sync status"""
|
||||
try:
|
||||
status = await sync_service.get_sync_status()
|
||||
@@ -23,7 +24,9 @@ async def get_sync_status() -> SyncStatus:
|
||||
if next_sync_time:
|
||||
status.next_sync = next_sync_time
|
||||
|
||||
return status
|
||||
return APIResponse(
|
||||
success=True, data=status, message="Sync status retrieved successfully"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get sync status: {e}")
|
||||
@@ -32,18 +35,18 @@ async def get_sync_status() -> SyncStatus:
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
@router.post("/sync", response_model=APIResponse)
|
||||
async def trigger_sync(
|
||||
background_tasks: BackgroundTasks, sync_request: Optional[SyncRequest] = None
|
||||
) -> dict:
|
||||
) -> APIResponse:
|
||||
"""Trigger a manual sync operation"""
|
||||
try:
|
||||
# Check if sync is already running
|
||||
status = await sync_service.get_sync_status()
|
||||
if status.is_running and not (sync_request and sync_request.force):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Sync is already running. Use 'force: true' to override.",
|
||||
return APIResponse(
|
||||
success=False,
|
||||
message="Sync is already running. Use 'force: true' to override.",
|
||||
)
|
||||
|
||||
# Determine what to sync
|
||||
@@ -55,6 +58,9 @@ async def trigger_sync(
|
||||
sync_request.force if sync_request else False,
|
||||
"api", # trigger_type
|
||||
)
|
||||
message = (
|
||||
f"Started sync for {len(sync_request.account_ids)} specific accounts"
|
||||
)
|
||||
else:
|
||||
# Sync all accounts in background
|
||||
background_tasks.add_task(
|
||||
@@ -62,14 +68,17 @@ async def trigger_sync(
|
||||
sync_request.force if sync_request else False,
|
||||
"api", # trigger_type
|
||||
)
|
||||
message = "Started sync for all accounts"
|
||||
|
||||
return {
|
||||
"sync_started": True,
|
||||
"force": sync_request.force if sync_request else False,
|
||||
}
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data={
|
||||
"sync_started": True,
|
||||
"force": sync_request.force if sync_request else False,
|
||||
},
|
||||
message=message,
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to trigger sync: {e}")
|
||||
raise HTTPException(
|
||||
@@ -77,8 +86,8 @@ async def trigger_sync(
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/sync/now")
|
||||
async def sync_now(sync_request: Optional[SyncRequest] = None) -> SyncResult:
|
||||
@router.post("/sync/now", response_model=APIResponse)
|
||||
async def sync_now(sync_request: Optional[SyncRequest] = None) -> APIResponse:
|
||||
"""Run sync synchronously and return results (slower, for testing)"""
|
||||
try:
|
||||
if sync_request and sync_request.account_ids:
|
||||
@@ -90,7 +99,13 @@ async def sync_now(sync_request: Optional[SyncRequest] = None) -> SyncResult:
|
||||
sync_request.force if sync_request else False, "api"
|
||||
)
|
||||
|
||||
return result
|
||||
return APIResponse(
|
||||
success=result.success,
|
||||
data=result,
|
||||
message="Sync completed"
|
||||
if result.success
|
||||
else f"Sync failed with {len(result.errors)} errors",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to run sync: {e}")
|
||||
@@ -99,8 +114,8 @@ async def sync_now(sync_request: Optional[SyncRequest] = None) -> SyncResult:
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/sync/scheduler")
|
||||
async def get_scheduler_config() -> dict:
|
||||
@router.get("/sync/scheduler", response_model=APIResponse)
|
||||
async def get_scheduler_config() -> APIResponse:
|
||||
"""Get current scheduler configuration"""
|
||||
try:
|
||||
scheduler_config = config.scheduler_config
|
||||
@@ -116,7 +131,11 @@ async def get_scheduler_config() -> dict:
|
||||
else False,
|
||||
}
|
||||
|
||||
return response_data
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=response_data,
|
||||
message="Scheduler configuration retrieved successfully",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get scheduler config: {e}")
|
||||
@@ -125,8 +144,8 @@ async def get_scheduler_config() -> dict:
|
||||
) from e
|
||||
|
||||
|
||||
@router.put("/sync/scheduler")
|
||||
async def update_scheduler_config(scheduler_config: SchedulerConfig) -> dict:
|
||||
@router.put("/sync/scheduler", response_model=APIResponse)
|
||||
async def update_scheduler_config(scheduler_config: SchedulerConfig) -> APIResponse:
|
||||
"""Update scheduler configuration"""
|
||||
try:
|
||||
# Validate cron expression if provided
|
||||
@@ -149,10 +168,12 @@ async def update_scheduler_config(scheduler_config: SchedulerConfig) -> dict:
|
||||
# Reschedule the job
|
||||
scheduler.reschedule_sync(schedule_data)
|
||||
|
||||
return schedule_data
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=schedule_data,
|
||||
message="Scheduler configuration updated successfully",
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update scheduler config: {e}")
|
||||
raise HTTPException(
|
||||
@@ -160,15 +181,15 @@ async def update_scheduler_config(scheduler_config: SchedulerConfig) -> dict:
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/sync/scheduler/start")
|
||||
async def start_scheduler() -> dict:
|
||||
@router.post("/sync/scheduler/start", response_model=APIResponse)
|
||||
async def start_scheduler() -> APIResponse:
|
||||
"""Start the background scheduler"""
|
||||
try:
|
||||
if not scheduler.scheduler.running:
|
||||
scheduler.start()
|
||||
return {"started": True}
|
||||
return APIResponse(success=True, message="Scheduler started successfully")
|
||||
else:
|
||||
return {"started": False, "message": "Scheduler is already running"}
|
||||
return APIResponse(success=True, message="Scheduler is already running")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start scheduler: {e}")
|
||||
@@ -177,15 +198,15 @@ async def start_scheduler() -> dict:
|
||||
) from e
|
||||
|
||||
|
||||
@router.post("/sync/scheduler/stop")
|
||||
async def stop_scheduler() -> dict:
|
||||
@router.post("/sync/scheduler/stop", response_model=APIResponse)
|
||||
async def stop_scheduler() -> APIResponse:
|
||||
"""Stop the background scheduler"""
|
||||
try:
|
||||
if scheduler.scheduler.running:
|
||||
scheduler.shutdown()
|
||||
return {"stopped": True}
|
||||
return APIResponse(success=True, message="Scheduler stopped successfully")
|
||||
else:
|
||||
return {"stopped": False, "message": "Scheduler is already stopped"}
|
||||
return APIResponse(success=True, message="Scheduler is already stopped")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop scheduler: {e}")
|
||||
@@ -194,16 +215,19 @@ async def stop_scheduler() -> dict:
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/sync/operations")
|
||||
async def get_sync_operations(limit: int = 50, offset: int = 0) -> dict:
|
||||
@router.get("/sync/operations", response_model=APIResponse)
|
||||
async def get_sync_operations(
|
||||
limit: int = 50, offset: int = 0
|
||||
) -> APIResponse:
|
||||
"""Get sync operations history"""
|
||||
try:
|
||||
from leggen.repositories import SyncRepository
|
||||
|
||||
sync_repo = SyncRepository()
|
||||
operations = sync_repo.get_operations(limit=limit, offset=offset)
|
||||
|
||||
return {"operations": operations, "count": len(operations)}
|
||||
operations = await sync_service.database.get_sync_operations(limit=limit, offset=offset)
|
||||
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data={"operations": operations, "count": len(operations)},
|
||||
message="Sync operations retrieved successfully",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get sync operations: {e}")
|
||||
|
||||
@@ -4,16 +4,16 @@ from typing import List, Optional, Union
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from loguru import logger
|
||||
|
||||
from leggen.api.dependencies import AnalyticsProc, TransactionRepo
|
||||
from leggen.api.models.accounts import Transaction, TransactionSummary
|
||||
from leggen.api.models.common import PaginatedResponse
|
||||
from leggen.api.models.common import APIResponse, PaginatedResponse
|
||||
from leggen.services.database_service import DatabaseService
|
||||
|
||||
router = APIRouter()
|
||||
database_service = DatabaseService()
|
||||
|
||||
|
||||
@router.get("/transactions")
|
||||
@router.get("/transactions", response_model=PaginatedResponse)
|
||||
async def get_all_transactions(
|
||||
transaction_repo: TransactionRepo,
|
||||
page: int = Query(default=1, ge=1, description="Page number (1-based)"),
|
||||
per_page: int = Query(default=50, le=500, description="Items per page"),
|
||||
summary_only: bool = Query(
|
||||
@@ -35,7 +35,7 @@ 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[Union[TransactionSummary, Transaction]]:
|
||||
) -> PaginatedResponse:
|
||||
"""Get all transactions from database with filtering options"""
|
||||
try:
|
||||
# Calculate offset from page and per_page
|
||||
@@ -43,7 +43,7 @@ async def get_all_transactions(
|
||||
limit = per_page
|
||||
|
||||
# Get transactions from database instead of GoCardless API
|
||||
db_transactions = transaction_repo.get_transactions(
|
||||
db_transactions = await database_service.get_transactions_from_db(
|
||||
account_id=account_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
@@ -55,7 +55,7 @@ async def get_all_transactions(
|
||||
)
|
||||
|
||||
# Get total count for pagination info (respecting the same filters)
|
||||
total_transactions = transaction_repo.get_count(
|
||||
total_transactions = await database_service.get_transaction_count_from_db(
|
||||
account_id=account_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
@@ -64,9 +64,11 @@ async def get_all_transactions(
|
||||
search=search,
|
||||
)
|
||||
|
||||
data: Union[List[TransactionSummary], List[Transaction]]
|
||||
|
||||
if summary_only:
|
||||
# Return simplified transaction summaries
|
||||
data: list[TransactionSummary | Transaction] = [
|
||||
data = [
|
||||
TransactionSummary(
|
||||
transaction_id=txn["transactionId"], # NEW: stable bank-provided ID
|
||||
internal_transaction_id=txn.get("internalTransactionId"),
|
||||
@@ -101,13 +103,16 @@ async def get_all_transactions(
|
||||
total_pages = (total_transactions + per_page - 1) // per_page
|
||||
|
||||
return PaginatedResponse(
|
||||
success=True,
|
||||
data=data,
|
||||
total=total_transactions,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
total_pages=total_pages,
|
||||
has_next=page < total_pages,
|
||||
has_prev=page > 1,
|
||||
pagination={
|
||||
"total": total_transactions,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total_pages": total_pages,
|
||||
"has_next": page < total_pages,
|
||||
"has_prev": page > 1,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -117,12 +122,11 @@ async def get_all_transactions(
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/transactions/stats")
|
||||
@router.get("/transactions/stats", response_model=APIResponse)
|
||||
async def get_transaction_stats(
|
||||
transaction_repo: TransactionRepo,
|
||||
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"),
|
||||
) -> dict:
|
||||
) -> APIResponse:
|
||||
"""Get transaction statistics for the last N days from database"""
|
||||
try:
|
||||
# Date range for stats
|
||||
@@ -134,7 +138,7 @@ async def get_transaction_stats(
|
||||
date_to = end_date.isoformat()
|
||||
|
||||
# Get transactions from database
|
||||
recent_transactions = transaction_repo.get_transactions(
|
||||
recent_transactions = await database_service.get_transactions_from_db(
|
||||
account_id=account_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
@@ -188,7 +192,11 @@ async def get_transaction_stats(
|
||||
"accounts_included": unique_accounts,
|
||||
}
|
||||
|
||||
return stats
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data=stats,
|
||||
message=f"Transaction statistics for last {days} days",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get transaction stats from database: {e}")
|
||||
@@ -197,12 +205,11 @@ async def get_transaction_stats(
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/transactions/analytics")
|
||||
@router.get("/transactions/analytics", response_model=APIResponse)
|
||||
async def get_transactions_for_analytics(
|
||||
transaction_repo: TransactionRepo,
|
||||
days: int = Query(default=365, description="Number of days to include"),
|
||||
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
|
||||
) -> List[dict]:
|
||||
) -> APIResponse:
|
||||
"""Get all transactions for analytics (no pagination) for the last N days"""
|
||||
try:
|
||||
# Date range for analytics
|
||||
@@ -214,7 +221,7 @@ async def get_transactions_for_analytics(
|
||||
date_to = end_date.isoformat()
|
||||
|
||||
# Get ALL transactions from database (no limit for analytics)
|
||||
transactions = transaction_repo.get_transactions(
|
||||
transactions = await database_service.get_transactions_from_db(
|
||||
account_id=account_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
@@ -235,7 +242,11 @@ async def get_transactions_for_analytics(
|
||||
for txn in transactions
|
||||
]
|
||||
|
||||
return transaction_summaries
|
||||
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}")
|
||||
@@ -244,16 +255,13 @@ async def get_transactions_for_analytics(
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/transactions/monthly-stats")
|
||||
@router.get("/transactions/monthly-stats", response_model=APIResponse)
|
||||
async def get_monthly_transaction_stats(
|
||||
analytics_proc: AnalyticsProc,
|
||||
days: int = Query(default=365, description="Number of days to include"),
|
||||
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
|
||||
) -> List[dict]:
|
||||
) -> APIResponse:
|
||||
"""Get monthly transaction statistics aggregated by the database"""
|
||||
try:
|
||||
from leggen.utils.paths import path_manager
|
||||
|
||||
# Date range for monthly stats
|
||||
end_date = datetime.now()
|
||||
start_date = end_date - timedelta(days=days)
|
||||
@@ -263,12 +271,17 @@ async def get_monthly_transaction_stats(
|
||||
date_to = end_date.isoformat()
|
||||
|
||||
# Get monthly aggregated stats from database
|
||||
db_path = path_manager.get_database_path()
|
||||
monthly_stats = analytics_proc.calculate_monthly_stats(
|
||||
db_path, account_id=account_id, date_from=date_from, date_to=date_to
|
||||
monthly_stats = await database_service.get_monthly_transaction_stats_from_db(
|
||||
account_id=account_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
|
||||
return monthly_stats
|
||||
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}")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
|
||||
@@ -13,22 +13,11 @@ class LeggenAPIClient:
|
||||
base_url: str
|
||||
|
||||
def __init__(self, base_url: Optional[str] = None):
|
||||
raw_url = (
|
||||
self.base_url = (
|
||||
base_url
|
||||
or os.environ.get("LEGGEN_API_URL", "http://localhost:8000")
|
||||
or "http://localhost:8000"
|
||||
)
|
||||
# Ensure base_url includes /api/v1 path if not already present
|
||||
parsed = urlparse(raw_url)
|
||||
if not parsed.path or parsed.path == "/":
|
||||
# No path or just root, add /api/v1
|
||||
self.base_url = f"{raw_url.rstrip('/')}/api/v1"
|
||||
elif not parsed.path.startswith("/api/v1"):
|
||||
# Has a path but not /api/v1, add it
|
||||
self.base_url = f"{raw_url.rstrip('/')}/api/v1"
|
||||
else:
|
||||
# Already has /api/v1 path
|
||||
self.base_url = raw_url.rstrip("/")
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{"Content-Type": "application/json", "Accept": "application/json"}
|
||||
@@ -36,14 +25,7 @@ class LeggenAPIClient:
|
||||
|
||||
def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Make HTTP request to the API"""
|
||||
# Construct URL by joining base_url with endpoint
|
||||
# Handle both relative endpoints (starting with /) and paths
|
||||
if endpoint.startswith("/"):
|
||||
# Absolute endpoint path - append to base_url
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
else:
|
||||
# Relative endpoint, use urljoin
|
||||
url = urljoin(f"{self.base_url}/", endpoint)
|
||||
url = urljoin(self.base_url, endpoint)
|
||||
|
||||
try:
|
||||
response = self.session.request(method, url, **kwargs)
|
||||
@@ -70,9 +52,7 @@ class LeggenAPIClient:
|
||||
"""Check if the leggen server is healthy"""
|
||||
try:
|
||||
response = self._make_request("GET", "/health")
|
||||
# The API now returns nested data structure
|
||||
data = response.get("data", {})
|
||||
return data.get("status") == "healthy"
|
||||
return response.get("status") == "healthy"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -80,7 +60,7 @@ class LeggenAPIClient:
|
||||
def get_institutions(self, country: str = "PT") -> List[Dict[str, Any]]:
|
||||
"""Get bank institutions for a country"""
|
||||
response = self._make_request(
|
||||
"GET", "/banks/institutions", params={"country": country}
|
||||
"GET", "/api/v1/banks/institutions", params={"country": country}
|
||||
)
|
||||
return response.get("data", [])
|
||||
|
||||
@@ -90,35 +70,35 @@ class LeggenAPIClient:
|
||||
"""Connect to a bank"""
|
||||
response = self._make_request(
|
||||
"POST",
|
||||
"/banks/connect",
|
||||
"/api/v1/banks/connect",
|
||||
json={"institution_id": institution_id, "redirect_url": redirect_url},
|
||||
)
|
||||
return response.get("data", {})
|
||||
|
||||
def get_bank_status(self) -> List[Dict[str, Any]]:
|
||||
"""Get bank connection status"""
|
||||
response = self._make_request("GET", "/banks/status")
|
||||
response = self._make_request("GET", "/api/v1/banks/status")
|
||||
return response.get("data", [])
|
||||
|
||||
def get_supported_countries(self) -> List[Dict[str, Any]]:
|
||||
"""Get supported countries"""
|
||||
response = self._make_request("GET", "/banks/countries")
|
||||
response = self._make_request("GET", "/api/v1/banks/countries")
|
||||
return response.get("data", [])
|
||||
|
||||
# Account endpoints
|
||||
def get_accounts(self) -> List[Dict[str, Any]]:
|
||||
"""Get all accounts"""
|
||||
response = self._make_request("GET", "/accounts")
|
||||
response = self._make_request("GET", "/api/v1/accounts")
|
||||
return response.get("data", [])
|
||||
|
||||
def get_account_details(self, account_id: str) -> Dict[str, Any]:
|
||||
"""Get account details"""
|
||||
response = self._make_request("GET", f"/accounts/{account_id}")
|
||||
response = self._make_request("GET", f"/api/v1/accounts/{account_id}")
|
||||
return response.get("data", {})
|
||||
|
||||
def get_account_balances(self, account_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get account balances"""
|
||||
response = self._make_request("GET", f"/accounts/{account_id}/balances")
|
||||
response = self._make_request("GET", f"/api/v1/accounts/{account_id}/balances")
|
||||
return response.get("data", [])
|
||||
|
||||
def get_account_transactions(
|
||||
@@ -127,7 +107,7 @@ class LeggenAPIClient:
|
||||
"""Get account transactions"""
|
||||
response = self._make_request(
|
||||
"GET",
|
||||
f"/accounts/{account_id}/transactions",
|
||||
f"/api/v1/accounts/{account_id}/transactions",
|
||||
params={"limit": limit, "summary_only": summary_only},
|
||||
)
|
||||
return response.get("data", [])
|
||||
@@ -140,7 +120,7 @@ class LeggenAPIClient:
|
||||
params = {"limit": limit, "summary_only": summary_only}
|
||||
params.update(filters)
|
||||
|
||||
response = self._make_request("GET", "/transactions", params=params)
|
||||
response = self._make_request("GET", "/api/v1/transactions", params=params)
|
||||
return response.get("data", [])
|
||||
|
||||
def get_transaction_stats(
|
||||
@@ -151,13 +131,15 @@ class LeggenAPIClient:
|
||||
if account_id:
|
||||
params["account_id"] = account_id
|
||||
|
||||
response = self._make_request("GET", "/transactions/stats", params=params)
|
||||
response = self._make_request(
|
||||
"GET", "/api/v1/transactions/stats", params=params
|
||||
)
|
||||
return response.get("data", {})
|
||||
|
||||
# Sync endpoints
|
||||
def get_sync_status(self) -> Dict[str, Any]:
|
||||
"""Get sync status"""
|
||||
response = self._make_request("GET", "/sync/status")
|
||||
response = self._make_request("GET", "/api/v1/sync/status")
|
||||
return response.get("data", {})
|
||||
|
||||
def trigger_sync(
|
||||
@@ -168,7 +150,7 @@ class LeggenAPIClient:
|
||||
if account_ids:
|
||||
data["account_ids"] = account_ids
|
||||
|
||||
response = self._make_request("POST", "/sync", json=data)
|
||||
response = self._make_request("POST", "/api/v1/sync", json=data)
|
||||
return response.get("data", {})
|
||||
|
||||
def sync_now(
|
||||
@@ -179,12 +161,12 @@ class LeggenAPIClient:
|
||||
if account_ids:
|
||||
data["account_ids"] = account_ids
|
||||
|
||||
response = self._make_request("POST", "/sync/now", json=data)
|
||||
response = self._make_request("POST", "/api/v1/sync/now", json=data)
|
||||
return response.get("data", {})
|
||||
|
||||
def get_scheduler_config(self) -> Dict[str, Any]:
|
||||
"""Get scheduler configuration"""
|
||||
response = self._make_request("GET", "/sync/scheduler")
|
||||
response = self._make_request("GET", "/api/v1/sync/scheduler")
|
||||
return response.get("data", {})
|
||||
|
||||
def update_scheduler_config(
|
||||
@@ -203,5 +185,5 @@ class LeggenAPIClient:
|
||||
if cron:
|
||||
data["cron"] = cron
|
||||
|
||||
response = self._make_request("PUT", "/sync/scheduler", json=data)
|
||||
response = self._make_request("PUT", "/api/v1/sync/scheduler", json=data)
|
||||
return response.get("data", {})
|
||||
|
||||
@@ -102,19 +102,17 @@ class BackgroundScheduler:
|
||||
async def _run_sync(self, retry_count: int = 0):
|
||||
"""Run sync with enhanced error handling and retry logic"""
|
||||
try:
|
||||
trigger_type = "retry" if retry_count > 0 else "scheduled"
|
||||
logger.info(f"Starting {trigger_type} sync job")
|
||||
await self.sync_service.sync_all_accounts(trigger_type=trigger_type)
|
||||
logger.info(f"{trigger_type.capitalize()} sync job completed successfully")
|
||||
logger.info("Starting scheduled sync job")
|
||||
await self.sync_service.sync_all_accounts()
|
||||
logger.info("Scheduled sync job completed successfully")
|
||||
except Exception as e:
|
||||
trigger_type = "retry" if retry_count > 0 else "scheduled"
|
||||
logger.error(
|
||||
f"{trigger_type.capitalize()} sync job failed (attempt {retry_count + 1}/{self.max_retries}): {e}"
|
||||
f"Scheduled sync job failed (attempt {retry_count + 1}/{self.max_retries}): {e}"
|
||||
)
|
||||
|
||||
# Send notification about the failure
|
||||
try:
|
||||
await self.notification_service.send_sync_failure_notification(
|
||||
await self.notification_service.send_expiry_notification(
|
||||
{
|
||||
"type": "sync_failure",
|
||||
"error": str(e),
|
||||
@@ -147,7 +145,7 @@ class BackgroundScheduler:
|
||||
logger.error("Maximum retries exceeded for sync job")
|
||||
# Send final failure notification
|
||||
try:
|
||||
await self.notification_service.send_sync_failure_notification(
|
||||
await self.notification_service.send_expiry_notification(
|
||||
{
|
||||
"type": "sync_final_failure",
|
||||
"error": str(e),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import click
|
||||
|
||||
from leggen.api_client import LeggenAPIClient
|
||||
from leggen.main import cli
|
||||
from leggen.utils.text import info, success
|
||||
|
||||
@@ -16,11 +15,12 @@ def delete(ctx, requisition_id: str):
|
||||
|
||||
Check `leggen status` to get the REQUISITION_ID
|
||||
"""
|
||||
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||
import requests
|
||||
|
||||
info(f"Deleting Bank Requisition: {requisition_id}")
|
||||
|
||||
# Use API client to make the delete request
|
||||
api_client._make_request("DELETE", f"/requisitions/{requisition_id}")
|
||||
api_url = ctx.obj.get("api_url", "http://localhost:8000")
|
||||
res = requests.delete(f"{api_url}/requisitions/{requisition_id}")
|
||||
res.raise_for_status()
|
||||
|
||||
success(f"Bank Requisition {requisition_id} deleted")
|
||||
|
||||
@@ -1,489 +1,10 @@
|
||||
"""Generate sample database command."""
|
||||
|
||||
import json
|
||||
import random
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import click
|
||||
|
||||
|
||||
class TransactionType(TypedDict):
|
||||
"""Type definition for transaction type configuration."""
|
||||
|
||||
description: str
|
||||
amount_range: tuple[float, float]
|
||||
frequency: float
|
||||
|
||||
|
||||
class SampleDataGenerator:
|
||||
"""Generates realistic sample data for testing Leggen."""
|
||||
|
||||
def __init__(self, db_path: Path):
|
||||
self.db_path = db_path
|
||||
self.institutions = [
|
||||
{
|
||||
"id": "REVOLUT_REVOLT21",
|
||||
"name": "Revolut",
|
||||
"bic": "REVOLT21",
|
||||
"country": "LT",
|
||||
},
|
||||
{
|
||||
"id": "BANCOBPI_BBPIPTPL",
|
||||
"name": "Banco BPI",
|
||||
"bic": "BBPIPTPL",
|
||||
"country": "PT",
|
||||
},
|
||||
{
|
||||
"id": "MONZO_MONZGB2L",
|
||||
"name": "Monzo Bank",
|
||||
"bic": "MONZGB2L",
|
||||
"country": "GB",
|
||||
},
|
||||
{
|
||||
"id": "NUBANK_NUPBBR25",
|
||||
"name": "Nu Pagamentos",
|
||||
"bic": "NUPBBR25",
|
||||
"country": "BR",
|
||||
},
|
||||
]
|
||||
|
||||
self.transaction_types: list[TransactionType] = [
|
||||
{
|
||||
"description": "Grocery Store",
|
||||
"amount_range": (-150, -20),
|
||||
"frequency": 0.3,
|
||||
},
|
||||
{"description": "Coffee Shop", "amount_range": (-15, -3), "frequency": 0.2},
|
||||
{
|
||||
"description": "Gas Station",
|
||||
"amount_range": (-80, -30),
|
||||
"frequency": 0.1,
|
||||
},
|
||||
{
|
||||
"description": "Online Shopping",
|
||||
"amount_range": (-200, -25),
|
||||
"frequency": 0.15,
|
||||
},
|
||||
{
|
||||
"description": "Restaurant",
|
||||
"amount_range": (-60, -15),
|
||||
"frequency": 0.15,
|
||||
},
|
||||
{"description": "Salary", "amount_range": (2500, 5000), "frequency": 0.02},
|
||||
{
|
||||
"description": "ATM Withdrawal",
|
||||
"amount_range": (-200, -20),
|
||||
"frequency": 0.05,
|
||||
},
|
||||
{
|
||||
"description": "Transfer to Savings",
|
||||
"amount_range": (-1000, -100),
|
||||
"frequency": 0.03,
|
||||
},
|
||||
]
|
||||
|
||||
def ensure_database_dir(self):
|
||||
"""Ensure database directory exists."""
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def create_tables(self):
|
||||
"""Create database tables."""
|
||||
conn = sqlite3.connect(str(self.db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create accounts table
|
||||
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,
|
||||
display_name TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Create transactions table with composite primary key
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
accountId TEXT NOT NULL,
|
||||
transactionId TEXT NOT NULL,
|
||||
internalTransactionId TEXT,
|
||||
institutionId TEXT,
|
||||
iban TEXT,
|
||||
transactionDate DATETIME,
|
||||
description TEXT,
|
||||
transactionValue REAL,
|
||||
transactionCurrency TEXT,
|
||||
transactionStatus TEXT,
|
||||
rawTransaction JSON,
|
||||
PRIMARY KEY (accountId, transactionId)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create balances table
|
||||
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
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_transactions_internal_id ON transactions(internalTransactionId)"
|
||||
)
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(transactionDate)"
|
||||
)
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_transactions_account_date ON transactions(accountId, transactionDate)"
|
||||
)
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_transactions_amount ON transactions(transactionValue)"
|
||||
)
|
||||
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)"
|
||||
)
|
||||
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)"
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def generate_iban(self, country_code: str) -> str:
|
||||
"""Generate a realistic IBAN for the given country."""
|
||||
ibans = {
|
||||
"LT": lambda: f"LT{random.randint(10, 99)}{random.randint(10000, 99999)}{random.randint(10000000, 99999999)}",
|
||||
"PT": lambda: f"PT{random.randint(10, 99)}{random.randint(1000, 9999)}{random.randint(1000, 9999)}{random.randint(10000000000, 99999999999)}",
|
||||
"GB": lambda: f"GB{random.randint(10, 99)}MONZ{random.randint(100000, 999999)}{random.randint(100000, 999999)}",
|
||||
"BR": lambda: f"BR{random.randint(10, 99)}{random.randint(10000000, 99999999)}{random.randint(1000, 9999)}{random.randint(10000000, 99999999)}",
|
||||
}
|
||||
return ibans.get(
|
||||
country_code,
|
||||
lambda: f"{country_code}{random.randint(1000000000000000, 9999999999999999)}",
|
||||
)()
|
||||
|
||||
def generate_accounts(self, num_accounts: int = 3) -> list[dict[str, Any]]:
|
||||
"""Generate sample accounts."""
|
||||
accounts = []
|
||||
base_date = datetime.now() - timedelta(days=90)
|
||||
|
||||
for i in range(num_accounts):
|
||||
institution = random.choice(self.institutions)
|
||||
account_id = f"account-{i + 1:03d}-{random.randint(1000, 9999)}"
|
||||
|
||||
account = {
|
||||
"id": account_id,
|
||||
"institution_id": institution["id"],
|
||||
"status": "READY",
|
||||
"iban": self.generate_iban(institution["country"]),
|
||||
"name": f"Personal Account {i + 1}",
|
||||
"currency": "EUR",
|
||||
"created": (
|
||||
base_date + timedelta(days=random.randint(0, 30))
|
||||
).isoformat(),
|
||||
"last_accessed": (
|
||||
datetime.now() - timedelta(hours=random.randint(1, 48))
|
||||
).isoformat(),
|
||||
"last_updated": datetime.now().isoformat(),
|
||||
}
|
||||
accounts.append(account)
|
||||
|
||||
return accounts
|
||||
|
||||
def generate_transactions(
|
||||
self, accounts: list[dict[str, Any]], num_transactions_per_account: int = 50
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Generate sample transactions for accounts."""
|
||||
transactions = []
|
||||
base_date = datetime.now() - timedelta(days=60)
|
||||
|
||||
for account in accounts:
|
||||
account_transactions = []
|
||||
current_balance = random.uniform(500, 3000)
|
||||
|
||||
for i in range(num_transactions_per_account):
|
||||
# Choose transaction type based on frequency weights
|
||||
transaction_type = random.choices(
|
||||
self.transaction_types,
|
||||
weights=[t["frequency"] for t in self.transaction_types],
|
||||
)[0]
|
||||
|
||||
# Generate transaction amount
|
||||
min_amount: float
|
||||
max_amount: float
|
||||
min_amount, max_amount = transaction_type["amount_range"]
|
||||
amount = round(random.uniform(min_amount, max_amount), 2)
|
||||
|
||||
# Generate transaction date (more recent transactions are more likely)
|
||||
days_ago = random.choices(
|
||||
range(60), weights=[1.5 ** (60 - d) for d in range(60)]
|
||||
)[0]
|
||||
transaction_date = base_date + timedelta(
|
||||
days=days_ago,
|
||||
hours=random.randint(6, 22),
|
||||
minutes=random.randint(0, 59),
|
||||
)
|
||||
|
||||
# Generate transaction IDs
|
||||
transaction_id = f"bank-txn-{account['id']}-{i + 1:04d}"
|
||||
internal_transaction_id = f"int-txn-{random.randint(100000, 999999)}"
|
||||
|
||||
# Create realistic descriptions
|
||||
descriptions: dict[str, list[str]] = {
|
||||
"Grocery Store": [
|
||||
"TESCO",
|
||||
"SAINSBURY'S",
|
||||
"LIDL",
|
||||
"ALDI",
|
||||
"WALMART",
|
||||
"CARREFOUR",
|
||||
],
|
||||
"Coffee Shop": [
|
||||
"STARBUCKS",
|
||||
"COSTA COFFEE",
|
||||
"PRET A MANGER",
|
||||
"LOCAL CAFE",
|
||||
],
|
||||
"Gas Station": ["BP", "SHELL", "ESSO", "GALP", "PETROBRAS"],
|
||||
"Online Shopping": ["AMAZON", "EBAY", "ZALANDO", "ASOS", "APPLE"],
|
||||
"Restaurant": [
|
||||
"PIZZA HUT",
|
||||
"MCDONALD'S",
|
||||
"BURGER KING",
|
||||
"LOCAL RESTAURANT",
|
||||
],
|
||||
"Salary": ["MONTHLY SALARY", "PAYROLL DEPOSIT", "SALARY PAYMENT"],
|
||||
"ATM Withdrawal": ["ATM WITHDRAWAL", "CASH WITHDRAWAL"],
|
||||
"Transfer to Savings": ["SAVINGS TRANSFER", "INVESTMENT TRANSFER"],
|
||||
}
|
||||
|
||||
specific_descriptions: list[str] = descriptions.get(
|
||||
transaction_type["description"], [transaction_type["description"]]
|
||||
)
|
||||
description = random.choice(specific_descriptions)
|
||||
|
||||
# Create raw transaction (simplified GoCardless format)
|
||||
raw_transaction = {
|
||||
"transactionId": transaction_id,
|
||||
"bookingDate": transaction_date.strftime("%Y-%m-%d"),
|
||||
"valueDate": transaction_date.strftime("%Y-%m-%d"),
|
||||
"transactionAmount": {
|
||||
"amount": str(amount),
|
||||
"currency": account["currency"],
|
||||
},
|
||||
"remittanceInformationUnstructured": description,
|
||||
"bankTransactionCode": "PMNT" if amount < 0 else "RCDT",
|
||||
}
|
||||
|
||||
# Determine status (most are booked, some recent ones might be pending)
|
||||
status = (
|
||||
"pending" if days_ago < 2 and random.random() < 0.1 else "booked"
|
||||
)
|
||||
|
||||
transaction = {
|
||||
"accountId": account["id"],
|
||||
"transactionId": transaction_id,
|
||||
"internalTransactionId": internal_transaction_id,
|
||||
"institutionId": account["institution_id"],
|
||||
"iban": account["iban"],
|
||||
"transactionDate": transaction_date.isoformat(),
|
||||
"description": description,
|
||||
"transactionValue": amount,
|
||||
"transactionCurrency": account["currency"],
|
||||
"transactionStatus": status,
|
||||
"rawTransaction": raw_transaction,
|
||||
}
|
||||
|
||||
account_transactions.append(transaction)
|
||||
current_balance += amount
|
||||
|
||||
# Sort transactions by date for realistic ordering
|
||||
account_transactions.sort(key=lambda x: x["transactionDate"])
|
||||
transactions.extend(account_transactions)
|
||||
|
||||
return transactions
|
||||
|
||||
def generate_balances(self, accounts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Generate sample balances for accounts."""
|
||||
balances = []
|
||||
|
||||
for account in accounts:
|
||||
# Calculate balance from transactions (simplified)
|
||||
base_balance = random.uniform(500, 2000)
|
||||
|
||||
balance_types = ["interimAvailable", "closingBooked", "authorised"]
|
||||
|
||||
for balance_type in balance_types:
|
||||
# Add some variation to balance types
|
||||
variation = (
|
||||
random.uniform(-50, 50) if balance_type != "interimAvailable" else 0
|
||||
)
|
||||
balance_amount = base_balance + variation
|
||||
|
||||
balance = {
|
||||
"account_id": account["id"],
|
||||
"bank": account["institution_id"],
|
||||
"status": account["status"],
|
||||
"iban": account["iban"],
|
||||
"amount": round(balance_amount, 2),
|
||||
"currency": account["currency"],
|
||||
"type": balance_type,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
balances.append(balance)
|
||||
|
||||
return balances
|
||||
|
||||
def insert_data(
|
||||
self,
|
||||
accounts: list[dict[str, Any]],
|
||||
transactions: list[dict[str, Any]],
|
||||
balances: list[dict[str, Any]],
|
||||
):
|
||||
"""Insert generated data into the database."""
|
||||
conn = sqlite3.connect(str(self.db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Insert accounts
|
||||
for account in accounts:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO accounts
|
||||
(id, institution_id, status, iban, name, currency, created, last_accessed, last_updated, display_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
account["id"],
|
||||
account["institution_id"],
|
||||
account["status"],
|
||||
account["iban"],
|
||||
account["name"],
|
||||
account["currency"],
|
||||
account["created"],
|
||||
account["last_accessed"],
|
||||
account["last_updated"],
|
||||
None, # display_name is initially None for sample data
|
||||
),
|
||||
)
|
||||
|
||||
# Insert transactions
|
||||
for transaction in transactions:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO transactions
|
||||
(accountId, transactionId, internalTransactionId, institutionId, iban,
|
||||
transactionDate, description, transactionValue, transactionCurrency,
|
||||
transactionStatus, rawTransaction)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
transaction["accountId"],
|
||||
transaction["transactionId"],
|
||||
transaction["internalTransactionId"],
|
||||
transaction["institutionId"],
|
||||
transaction["iban"],
|
||||
transaction["transactionDate"],
|
||||
transaction["description"],
|
||||
transaction["transactionValue"],
|
||||
transaction["transactionCurrency"],
|
||||
transaction["transactionStatus"],
|
||||
json.dumps(transaction["rawTransaction"]),
|
||||
),
|
||||
)
|
||||
|
||||
# Insert balances
|
||||
for balance in balances:
|
||||
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"],
|
||||
),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def generate_sample_database(
|
||||
self, num_accounts: int = 3, num_transactions_per_account: int = 50
|
||||
):
|
||||
"""Generate complete sample database."""
|
||||
click.echo(f"🗄️ Creating sample database at: {self.db_path}")
|
||||
|
||||
self.ensure_database_dir()
|
||||
self.create_tables()
|
||||
|
||||
click.echo(f"👥 Generating {num_accounts} sample accounts...")
|
||||
accounts = self.generate_accounts(num_accounts)
|
||||
|
||||
click.echo(
|
||||
f"💳 Generating {num_transactions_per_account} transactions per account..."
|
||||
)
|
||||
transactions = self.generate_transactions(
|
||||
accounts, num_transactions_per_account
|
||||
)
|
||||
|
||||
click.echo("💰 Generating account balances...")
|
||||
balances = self.generate_balances(accounts)
|
||||
|
||||
click.echo("💾 Inserting data into database...")
|
||||
self.insert_data(accounts, transactions, balances)
|
||||
|
||||
# Print summary
|
||||
click.echo("\n✅ Sample database created successfully!")
|
||||
click.echo("📊 Summary:")
|
||||
click.echo(f" - Accounts: {len(accounts)}")
|
||||
click.echo(f" - Transactions: {len(transactions)}")
|
||||
click.echo(f" - Balances: {len(balances)}")
|
||||
click.echo(f" - Database: {self.db_path}")
|
||||
|
||||
# Show account details
|
||||
click.echo("\n📋 Sample accounts:")
|
||||
for account in accounts:
|
||||
institution_name = next(
|
||||
inst["name"]
|
||||
for inst in self.institutions
|
||||
if inst["id"] == account["institution_id"]
|
||||
)
|
||||
click.echo(f" - {account['id']} ({institution_name}) - {account['iban']}")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--database",
|
||||
@@ -509,45 +30,39 @@ class SampleDataGenerator:
|
||||
)
|
||||
@click.pass_context
|
||||
def generate_sample_db(
|
||||
ctx: click.Context,
|
||||
database: Path | None,
|
||||
accounts: int,
|
||||
transactions: int,
|
||||
force: bool,
|
||||
ctx: click.Context, database: Path, accounts: int, transactions: int, force: bool
|
||||
):
|
||||
"""Generate a sample database with realistic financial data for testing."""
|
||||
import os
|
||||
|
||||
from leggen.utils.paths import path_manager
|
||||
# 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)]
|
||||
|
||||
# Determine database path
|
||||
if database:
|
||||
db_path = database
|
||||
else:
|
||||
# Use development database by default to avoid overwriting production data
|
||||
env_path = os.environ.get("LEGGEN_DATABASE_PATH")
|
||||
if env_path:
|
||||
db_path = Path(env_path)
|
||||
else:
|
||||
# Default to development database in config directory
|
||||
db_path = path_manager.get_config_dir() / "leggen-dev.db"
|
||||
cmd.extend(["--database", str(database)])
|
||||
|
||||
# Check if database exists and ask for confirmation
|
||||
if db_path.exists() and not force:
|
||||
click.echo(f"⚠️ Database already exists: {db_path}")
|
||||
if not click.confirm("Do you want to overwrite it?"):
|
||||
click.echo("Aborted.")
|
||||
ctx.exit(0)
|
||||
cmd.extend(["--accounts", str(accounts)])
|
||||
cmd.extend(["--transactions", str(transactions)])
|
||||
|
||||
# Generate the sample database
|
||||
generator = SampleDataGenerator(db_path)
|
||||
generator.generate_sample_database(accounts, transactions)
|
||||
if force:
|
||||
cmd.append("--force")
|
||||
|
||||
# Show usage instructions
|
||||
click.echo("\n🚀 Usage instructions:")
|
||||
click.echo("To use this sample database with leggen commands:")
|
||||
click.echo(f" export LEGGEN_DATABASE_PATH={db_path}")
|
||||
click.echo(" leggen transactions")
|
||||
click.echo("")
|
||||
click.echo("To use this sample database with leggen server:")
|
||||
click.echo(f" leggen server --database {db_path}")
|
||||
# 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
|
||||
|
||||
@@ -7,7 +7,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from loguru import logger
|
||||
|
||||
from leggen.api.routes import accounts, backup, banks, notifications, sync, transactions
|
||||
from leggen.api.routes import accounts, banks, notifications, sync, transactions
|
||||
from leggen.background.scheduler import scheduler
|
||||
from leggen.utils.config import config
|
||||
from leggen.utils.paths import path_manager
|
||||
@@ -28,10 +28,10 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Run database migrations
|
||||
try:
|
||||
from leggen.api.dependencies import get_migration_repository
|
||||
from leggen.services.database_service import DatabaseService
|
||||
|
||||
migrations = get_migration_repository()
|
||||
await migrations.run_all_migrations()
|
||||
db_service = DatabaseService()
|
||||
await db_service.run_migrations_if_needed()
|
||||
logger.info("Database migrations completed")
|
||||
except Exception as e:
|
||||
logger.error(f"Database migration failed: {e}")
|
||||
@@ -60,8 +60,6 @@ def create_app() -> FastAPI:
|
||||
description="Open Banking API for Leggen",
|
||||
version=version,
|
||||
lifespan=lifespan,
|
||||
docs_url="/api/v1/docs",
|
||||
openapi_url="/api/v1/openapi.json",
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
@@ -83,31 +81,42 @@ def create_app() -> FastAPI:
|
||||
app.include_router(transactions.router, prefix="/api/v1", tags=["transactions"])
|
||||
app.include_router(sync.router, prefix="/api/v1", tags=["sync"])
|
||||
app.include_router(notifications.router, prefix="/api/v1", tags=["notifications"])
|
||||
app.include_router(backup.router, prefix="/api/v1", tags=["backup"])
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
# Get version dynamically
|
||||
try:
|
||||
version = metadata.version("leggen")
|
||||
except metadata.PackageNotFoundError:
|
||||
version = "unknown"
|
||||
return {"message": "Leggen API is running", "version": version}
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
async def health():
|
||||
"""Health check endpoint for API connectivity"""
|
||||
try:
|
||||
from leggen.api.models.common import APIResponse
|
||||
|
||||
config_loaded = config._config is not None
|
||||
|
||||
# Get version dynamically
|
||||
try:
|
||||
version = metadata.version("leggen")
|
||||
except metadata.PackageNotFoundError:
|
||||
version = "dev"
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"config_loaded": config_loaded,
|
||||
"version": version,
|
||||
}
|
||||
return APIResponse(
|
||||
success=True,
|
||||
data={
|
||||
"status": "healthy",
|
||||
"config_loaded": config_loaded,
|
||||
"message": "API is running and responsive",
|
||||
},
|
||||
message="Health check successful",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Health check failed: {e}")
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"error": str(e),
|
||||
}
|
||||
from leggen.api.models.common import APIResponse
|
||||
|
||||
return APIResponse(
|
||||
success=False,
|
||||
data={"status": "unhealthy", "error": str(e)},
|
||||
message="Health check failed",
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -32,22 +32,6 @@ class NotificationConfig(BaseModel):
|
||||
telegram: Optional[TelegramNotificationConfig] = None
|
||||
|
||||
|
||||
class S3BackupConfig(BaseModel):
|
||||
access_key_id: str = Field(..., description="AWS S3 access key ID")
|
||||
secret_access_key: str = Field(..., description="AWS S3 secret access key")
|
||||
bucket_name: str = Field(..., description="S3 bucket name")
|
||||
region: str = Field(default="us-east-1", description="AWS S3 region")
|
||||
endpoint_url: Optional[str] = Field(
|
||||
default=None, description="Custom S3 endpoint URL"
|
||||
)
|
||||
path_style: bool = Field(default=False, description="Use path-style addressing")
|
||||
enabled: bool = Field(default=True, description="Enable S3 backups")
|
||||
|
||||
|
||||
class BackupConfig(BaseModel):
|
||||
s3: Optional[S3BackupConfig] = None
|
||||
|
||||
|
||||
class FilterConfig(BaseModel):
|
||||
case_insensitive: Optional[List[str]] = Field(default_factory=list)
|
||||
case_sensitive: Optional[List[str]] = Field(default_factory=list)
|
||||
@@ -72,4 +56,3 @@ class Config(BaseModel):
|
||||
notifications: Optional[NotificationConfig] = None
|
||||
filters: Optional[FilterConfig] = None
|
||||
scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig)
|
||||
backup: Optional[BackupConfig] = None
|
||||
|
||||
@@ -55,40 +55,3 @@ def send_transactions_message(ctx: click.Context, transactions: list):
|
||||
response.raise_for_status()
|
||||
except Exception as e:
|
||||
raise Exception(f"Discord notification failed: {e}\n{response.text}") from e
|
||||
|
||||
|
||||
def send_sync_failure_notification(ctx: click.Context, notification: dict):
|
||||
info("Sending sync failure notification to Discord")
|
||||
webhook = DiscordWebhook(url=ctx.obj["notifications"]["discord"]["webhook"])
|
||||
|
||||
color = "ffaa00" # Orange for sync failure
|
||||
title = "⚠️ Sync Failure"
|
||||
|
||||
# Build description with account info if available
|
||||
description = "Account sync failed"
|
||||
if notification.get("account_id"):
|
||||
description = f"Account {notification['account_id']} sync failed"
|
||||
|
||||
embed = DiscordEmbed(
|
||||
title=title,
|
||||
description=description,
|
||||
color=color,
|
||||
)
|
||||
embed.set_author(
|
||||
name="Leggen",
|
||||
url="https://github.com/elisiariocouto/leggen",
|
||||
)
|
||||
embed.add_embed_field(
|
||||
name="Error",
|
||||
value=notification["error"][:1024], # Discord has field value limits
|
||||
inline=False,
|
||||
)
|
||||
embed.set_footer(text="Sync failure notification")
|
||||
embed.set_timestamp()
|
||||
|
||||
webhook.add_embed(embed)
|
||||
response = webhook.execute()
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except Exception as e:
|
||||
raise Exception(f"Discord notification failed: {e}\n{response.text}") from e
|
||||
|
||||
@@ -79,33 +79,3 @@ def send_transaction_message(ctx: click.Context, transactions: list):
|
||||
res.raise_for_status()
|
||||
except Exception as e:
|
||||
raise Exception(f"Telegram notification failed: {e}\n{res.text}") from e
|
||||
|
||||
|
||||
def send_sync_failure_notification(ctx: click.Context, notification: dict):
|
||||
token = ctx.obj["notifications"]["telegram"]["token"]
|
||||
chat_id = ctx.obj["notifications"]["telegram"]["chat_id"]
|
||||
bot_url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||
info("Sending sync failure notification to Telegram")
|
||||
|
||||
message = "*⚠️ [Leggen](https://github.com/elisiariocouto/leggen)*\n"
|
||||
message += "*Sync Failed*\n\n"
|
||||
|
||||
# Add account info if available
|
||||
if notification.get("account_id"):
|
||||
message += escape_markdown(f"Account: {notification['account_id']}\n")
|
||||
|
||||
message += escape_markdown(f"Error: {notification['error']}\n")
|
||||
|
||||
res = requests.post(
|
||||
bot_url,
|
||||
json={
|
||||
"chat_id": chat_id,
|
||||
"text": message,
|
||||
"parse_mode": "MarkdownV2",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
res.raise_for_status()
|
||||
except Exception as e:
|
||||
raise Exception(f"Telegram notification failed: {e}\n{res.text}") from e
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
from leggen.repositories.account_repository import AccountRepository
|
||||
from leggen.repositories.balance_repository import BalanceRepository
|
||||
from leggen.repositories.migration_repository import MigrationRepository
|
||||
from leggen.repositories.sync_repository import SyncRepository
|
||||
from leggen.repositories.transaction_repository import TransactionRepository
|
||||
|
||||
__all__ = [
|
||||
"AccountRepository",
|
||||
"BalanceRepository",
|
||||
"MigrationRepository",
|
||||
"SyncRepository",
|
||||
"TransactionRepository",
|
||||
]
|
||||
@@ -1,128 +0,0 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from leggen.repositories.base_repository import BaseRepository
|
||||
|
||||
|
||||
class AccountRepository(BaseRepository):
|
||||
"""Repository for account data operations"""
|
||||
|
||||
def create_table(self):
|
||||
"""Create accounts table with indexes"""
|
||||
with self._get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
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,
|
||||
display_name TEXT,
|
||||
logo TEXT
|
||||
)"""
|
||||
)
|
||||
|
||||
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)"""
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
def persist(self, account_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Persist account details to database"""
|
||||
self.create_table()
|
||||
|
||||
with self._get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if account exists and preserve display_name
|
||||
cursor.execute(
|
||||
"SELECT display_name FROM accounts WHERE id = ?", (account_data["id"],)
|
||||
)
|
||||
existing_row = cursor.fetchone()
|
||||
existing_display_name = existing_row[0] if existing_row else None
|
||||
|
||||
# Use existing display_name if not provided in account_data
|
||||
display_name = account_data.get("display_name", existing_display_name)
|
||||
|
||||
cursor.execute(
|
||||
"""INSERT OR REPLACE INTO accounts (
|
||||
id,
|
||||
institution_id,
|
||||
status,
|
||||
iban,
|
||||
name,
|
||||
currency,
|
||||
created,
|
||||
last_accessed,
|
||||
last_updated,
|
||||
display_name,
|
||||
logo
|
||||
) 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"]),
|
||||
display_name,
|
||||
account_data.get("logo"),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return account_data
|
||||
|
||||
def get_accounts(
|
||||
self, account_ids: Optional[List[str]] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get account details from database"""
|
||||
if not self._db_exists():
|
||||
return []
|
||||
|
||||
with self._get_db_connection(row_factory=True) as conn:
|
||||
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"
|
||||
|
||||
cursor.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def get_account(self, account_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get specific account details from database"""
|
||||
if not self._db_exists():
|
||||
return None
|
||||
|
||||
with self._get_db_connection(row_factory=True) as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
return dict(row)
|
||||
return None
|
||||
@@ -1,107 +0,0 @@
|
||||
import sqlite3
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from leggen.repositories.base_repository import BaseRepository
|
||||
|
||||
|
||||
class BalanceRepository(BaseRepository):
|
||||
"""Repository for balance data operations"""
|
||||
|
||||
def create_table(self):
|
||||
"""Create balances table with indexes"""
|
||||
with self._get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
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
|
||||
)"""
|
||||
)
|
||||
|
||||
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)"""
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
def persist(self, account_id: str, balance_rows: List[tuple]) -> None:
|
||||
"""Persist balance rows to database"""
|
||||
try:
|
||||
self.create_table()
|
||||
|
||||
with self._get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
for row in balance_rows:
|
||||
try:
|
||||
cursor.execute(
|
||||
"""INSERT INTO balances (
|
||||
account_id,
|
||||
bank,
|
||||
status,
|
||||
iban,
|
||||
amount,
|
||||
currency,
|
||||
type,
|
||||
timestamp
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
row,
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
logger.warning(f"Skipped duplicate balance for {account_id}")
|
||||
|
||||
conn.commit()
|
||||
|
||||
logger.info(f"Persisted balances for account {account_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to persist balances: {e}")
|
||||
raise
|
||||
|
||||
def get_balances(self, account_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Get latest balances from database"""
|
||||
if not self._db_exists():
|
||||
return []
|
||||
|
||||
with self._get_db_connection(row_factory=True) as conn:
|
||||
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"
|
||||
|
||||
cursor.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
return [dict(row) for row in rows]
|
||||
@@ -1,28 +0,0 @@
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
|
||||
from leggen.utils.paths import path_manager
|
||||
|
||||
|
||||
class BaseRepository:
|
||||
"""Base repository with shared database connection logic"""
|
||||
|
||||
@contextmanager
|
||||
def _get_db_connection(self, row_factory: bool = False):
|
||||
"""Context manager for database connections with proper cleanup"""
|
||||
db_path = path_manager.get_database_path()
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
if row_factory:
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield conn
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
raise e
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _db_exists(self) -> bool:
|
||||
"""Check if database file exists"""
|
||||
db_path = path_manager.get_database_path()
|
||||
return db_path.exists()
|
||||
@@ -1,626 +0,0 @@
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from leggen.repositories.base_repository import BaseRepository
|
||||
from leggen.utils.paths import path_manager
|
||||
|
||||
|
||||
class MigrationRepository(BaseRepository):
|
||||
"""Repository for database migrations"""
|
||||
|
||||
async def run_all_migrations(self):
|
||||
"""Run all necessary database migrations"""
|
||||
await self.migrate_balance_timestamps_if_needed()
|
||||
await self.migrate_null_transaction_ids_if_needed()
|
||||
await self.migrate_to_composite_key_if_needed()
|
||||
await self.migrate_add_display_name_if_needed()
|
||||
await self.migrate_add_sync_operations_if_needed()
|
||||
await self.migrate_add_logo_if_needed()
|
||||
|
||||
# Balance timestamp migration methods
|
||||
async def migrate_balance_timestamps_if_needed(self):
|
||||
"""Check and migrate balance timestamps if needed"""
|
||||
try:
|
||||
if await self._check_balance_timestamp_migration_needed():
|
||||
logger.info("Balance timestamp migration needed, starting...")
|
||||
await self._migrate_balance_timestamps()
|
||||
logger.info("Balance timestamp migration completed")
|
||||
else:
|
||||
logger.info("Balance timestamps are already consistent")
|
||||
except Exception as e:
|
||||
logger.error(f"Balance timestamp migration failed: {e}")
|
||||
raise
|
||||
|
||||
async def _check_balance_timestamp_migration_needed(self) -> bool:
|
||||
"""Check if balance timestamps need migration"""
|
||||
db_path = path_manager.get_database_path()
|
||||
if not db_path.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT typeof(timestamp) as type, COUNT(*) as count
|
||||
FROM balances
|
||||
GROUP BY typeof(timestamp)
|
||||
""")
|
||||
|
||||
types = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
type_names = [row[0] for row in types]
|
||||
return "real" in type_names and "text" in type_names
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check migration status: {e}")
|
||||
return False
|
||||
|
||||
async def _migrate_balance_timestamps(self):
|
||||
"""Convert all Unix timestamps to datetime strings"""
|
||||
db_path = path_manager.get_database_path()
|
||||
if not db_path.exists():
|
||||
logger.warning("Database file not found, skipping migration")
|
||||
return
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT id, timestamp
|
||||
FROM balances
|
||||
WHERE typeof(timestamp) = 'real'
|
||||
ORDER BY id
|
||||
""")
|
||||
|
||||
unix_records = cursor.fetchall()
|
||||
total_records = len(unix_records)
|
||||
|
||||
if total_records == 0:
|
||||
logger.info("No Unix timestamps found to migrate")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Migrating {total_records} balance records from Unix to datetime format"
|
||||
)
|
||||
|
||||
batch_size = 100
|
||||
migrated_count = 0
|
||||
|
||||
for i in range(0, total_records, batch_size):
|
||||
batch = unix_records[i : i + batch_size]
|
||||
|
||||
for record_id, unix_timestamp in batch:
|
||||
try:
|
||||
dt_string = self._unix_to_datetime_string(float(unix_timestamp))
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE balances
|
||||
SET timestamp = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(dt_string, record_id),
|
||||
)
|
||||
|
||||
migrated_count += 1
|
||||
|
||||
if migrated_count % 100 == 0:
|
||||
logger.info(
|
||||
f"Migrated {migrated_count}/{total_records} balance records"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to migrate record {record_id}: {e}")
|
||||
continue
|
||||
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
logger.info(f"Successfully migrated {migrated_count} balance records")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Balance timestamp migration failed: {e}")
|
||||
raise
|
||||
|
||||
def _unix_to_datetime_string(self, unix_timestamp: float) -> str:
|
||||
"""Convert Unix timestamp to datetime string"""
|
||||
dt = datetime.fromtimestamp(unix_timestamp)
|
||||
return dt.isoformat()
|
||||
|
||||
# Null transaction IDs migration methods
|
||||
async def migrate_null_transaction_ids_if_needed(self):
|
||||
"""Check and migrate null transaction IDs if needed"""
|
||||
try:
|
||||
if await self._check_null_transaction_ids_migration_needed():
|
||||
logger.info("Null transaction IDs migration needed, starting...")
|
||||
await self._migrate_null_transaction_ids()
|
||||
logger.info("Null transaction IDs migration completed")
|
||||
else:
|
||||
logger.info("No null transaction IDs found to migrate")
|
||||
except Exception as e:
|
||||
logger.error(f"Null transaction IDs migration failed: {e}")
|
||||
raise
|
||||
|
||||
async def _check_null_transaction_ids_migration_needed(self) -> bool:
|
||||
"""Check if null transaction IDs need migration"""
|
||||
db_path = path_manager.get_database_path()
|
||||
if not db_path.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*)
|
||||
FROM transactions
|
||||
WHERE (internalTransactionId IS NULL OR internalTransactionId = '')
|
||||
AND json_extract(rawTransaction, '$.transactionId') IS NOT NULL
|
||||
""")
|
||||
|
||||
count = cursor.fetchone()[0]
|
||||
conn.close()
|
||||
|
||||
return count > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check null transaction IDs migration status: {e}")
|
||||
return False
|
||||
|
||||
async def _migrate_null_transaction_ids(self):
|
||||
"""Populate null internalTransactionId fields using transactionId from raw data"""
|
||||
db_path = path_manager.get_database_path()
|
||||
if not db_path.exists():
|
||||
logger.warning("Database file not found, skipping migration")
|
||||
return
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT rowid, json_extract(rawTransaction, '$.transactionId') as transactionId
|
||||
FROM transactions
|
||||
WHERE (internalTransactionId IS NULL OR internalTransactionId = '')
|
||||
AND json_extract(rawTransaction, '$.transactionId') IS NOT NULL
|
||||
ORDER BY rowid
|
||||
""")
|
||||
|
||||
null_records = cursor.fetchall()
|
||||
total_records = len(null_records)
|
||||
|
||||
if total_records == 0:
|
||||
logger.info("No null transaction IDs found to migrate")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Migrating {total_records} transaction records with null internalTransactionId"
|
||||
)
|
||||
|
||||
batch_size = 100
|
||||
migrated_count = 0
|
||||
|
||||
for i in range(0, total_records, batch_size):
|
||||
batch = null_records[i : i + batch_size]
|
||||
|
||||
for rowid, transaction_id in batch:
|
||||
try:
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM transactions WHERE internalTransactionId = ?",
|
||||
(str(transaction_id),),
|
||||
)
|
||||
existing_count = cursor.fetchone()[0]
|
||||
|
||||
if existing_count > 0:
|
||||
unique_id = f"{str(transaction_id)}_{uuid.uuid4().hex[:8]}"
|
||||
logger.debug(
|
||||
f"Generated unique ID for duplicate transactionId: {unique_id}"
|
||||
)
|
||||
else:
|
||||
unique_id = str(transaction_id)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE transactions
|
||||
SET internalTransactionId = ?
|
||||
WHERE rowid = ?
|
||||
""",
|
||||
(unique_id, rowid),
|
||||
)
|
||||
|
||||
migrated_count += 1
|
||||
|
||||
if migrated_count % 100 == 0:
|
||||
logger.info(
|
||||
f"Migrated {migrated_count}/{total_records} transaction records"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to migrate record {rowid}: {e}")
|
||||
continue
|
||||
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
logger.info(f"Successfully migrated {migrated_count} transaction records")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Null transaction IDs migration failed: {e}")
|
||||
raise
|
||||
|
||||
# Composite key migration methods
|
||||
async def migrate_to_composite_key_if_needed(self):
|
||||
"""Check and migrate to composite primary key if needed"""
|
||||
try:
|
||||
if await self._check_composite_key_migration_needed():
|
||||
logger.info("Composite key migration needed, starting...")
|
||||
await self._migrate_to_composite_key()
|
||||
logger.info("Composite key migration completed")
|
||||
else:
|
||||
logger.info("Composite key migration not needed")
|
||||
except Exception as e:
|
||||
logger.error(f"Composite key migration failed: {e}")
|
||||
raise
|
||||
|
||||
async def _check_composite_key_migration_needed(self) -> bool:
|
||||
"""Check if composite key migration is needed"""
|
||||
db_path = path_manager.get_database_path()
|
||||
if not db_path.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='transactions'"
|
||||
)
|
||||
if not cursor.fetchone():
|
||||
conn.close()
|
||||
return False
|
||||
|
||||
cursor.execute("PRAGMA table_info(transactions)")
|
||||
columns = cursor.fetchall()
|
||||
|
||||
internal_transaction_id_is_pk = any(
|
||||
col[1] == "internalTransactionId" and col[5] == 1 for col in columns
|
||||
)
|
||||
|
||||
has_composite_key = any(
|
||||
col[1] in ["accountId", "transactionId"] and col[5] == 1
|
||||
for col in columns
|
||||
)
|
||||
|
||||
conn.close()
|
||||
|
||||
return internal_transaction_id_is_pk or not has_composite_key
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check composite key migration status: {e}")
|
||||
return False
|
||||
|
||||
async def _migrate_to_composite_key(self):
|
||||
"""Migrate transactions table to use composite primary key (accountId, transactionId)"""
|
||||
db_path = path_manager.get_database_path()
|
||||
if not db_path.exists():
|
||||
logger.warning("Database file not found, skipping migration")
|
||||
return
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
logger.info("Starting composite key migration...")
|
||||
|
||||
logger.info("Creating temporary table with composite primary key...")
|
||||
cursor.execute("DROP TABLE IF EXISTS transactions_temp")
|
||||
cursor.execute("""
|
||||
CREATE TABLE transactions_temp (
|
||||
accountId TEXT NOT NULL,
|
||||
transactionId TEXT NOT NULL,
|
||||
internalTransactionId TEXT,
|
||||
institutionId TEXT,
|
||||
iban TEXT,
|
||||
transactionDate DATETIME,
|
||||
description TEXT,
|
||||
transactionValue REAL,
|
||||
transactionCurrency TEXT,
|
||||
transactionStatus TEXT,
|
||||
rawTransaction JSON,
|
||||
PRIMARY KEY (accountId, transactionId)
|
||||
)
|
||||
""")
|
||||
|
||||
logger.info("Inserting deduplicated data...")
|
||||
cursor.execute("""
|
||||
INSERT INTO transactions_temp
|
||||
SELECT
|
||||
accountId,
|
||||
json_extract(rawTransaction, '$.transactionId') as transactionId,
|
||||
internalTransactionId,
|
||||
institutionId,
|
||||
iban,
|
||||
transactionDate,
|
||||
description,
|
||||
transactionValue,
|
||||
transactionCurrency,
|
||||
transactionStatus,
|
||||
rawTransaction
|
||||
FROM (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY accountId, json_extract(rawTransaction, '$.transactionId')
|
||||
ORDER BY transactionDate DESC
|
||||
) as rn
|
||||
FROM transactions
|
||||
WHERE json_extract(rawTransaction, '$.transactionId') IS NOT NULL
|
||||
)
|
||||
WHERE rn = 1
|
||||
""")
|
||||
|
||||
rows_migrated = cursor.rowcount
|
||||
logger.info(f"Migrated {rows_migrated} unique transactions")
|
||||
|
||||
logger.info("Replacing old table...")
|
||||
cursor.execute("DROP TABLE transactions")
|
||||
cursor.execute("ALTER TABLE transactions_temp RENAME TO transactions")
|
||||
|
||||
logger.info("Recreating indexes...")
|
||||
cursor.execute(
|
||||
"""CREATE INDEX IF NOT EXISTS idx_transactions_internal_id
|
||||
ON transactions(internalTransactionId)"""
|
||||
)
|
||||
cursor.execute(
|
||||
"""CREATE INDEX IF NOT EXISTS idx_transactions_date
|
||||
ON transactions(transactionDate)"""
|
||||
)
|
||||
cursor.execute(
|
||||
"""CREATE INDEX IF NOT EXISTS idx_transactions_account_date
|
||||
ON transactions(accountId, transactionDate)"""
|
||||
)
|
||||
cursor.execute(
|
||||
"""CREATE INDEX IF NOT EXISTS idx_transactions_amount
|
||||
ON transactions(transactionValue)"""
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
logger.info("Composite key migration completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Composite key migration failed: {e}")
|
||||
raise
|
||||
|
||||
# Display name migration methods
|
||||
async def migrate_add_display_name_if_needed(self):
|
||||
"""Check and add display_name column if needed"""
|
||||
try:
|
||||
if await self._check_display_name_migration_needed():
|
||||
logger.info("Display name column migration needed, starting...")
|
||||
await self._migrate_add_display_name()
|
||||
logger.info("Display name column migration completed")
|
||||
else:
|
||||
logger.info("Display name column already exists")
|
||||
except Exception as e:
|
||||
logger.error(f"Display name column migration failed: {e}")
|
||||
raise
|
||||
|
||||
async def _check_display_name_migration_needed(self) -> bool:
|
||||
"""Check if display_name column needs to be added"""
|
||||
db_path = path_manager.get_database_path()
|
||||
if not db_path.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'"
|
||||
)
|
||||
if not cursor.fetchone():
|
||||
conn.close()
|
||||
return False
|
||||
|
||||
cursor.execute("PRAGMA table_info(accounts)")
|
||||
columns = cursor.fetchall()
|
||||
|
||||
has_display_name = any(col[1] == "display_name" for col in columns)
|
||||
|
||||
conn.close()
|
||||
return not has_display_name
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check display_name migration status: {e}")
|
||||
return False
|
||||
|
||||
async def _migrate_add_display_name(self):
|
||||
"""Add display_name column to accounts table"""
|
||||
db_path = path_manager.get_database_path()
|
||||
if not db_path.exists():
|
||||
logger.warning("Database file not found, skipping migration")
|
||||
return
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
logger.info("Adding display_name column to accounts table...")
|
||||
|
||||
cursor.execute("""
|
||||
ALTER TABLE accounts
|
||||
ADD COLUMN display_name TEXT
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
logger.info("Display name column migration completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Display name column migration failed: {e}")
|
||||
raise
|
||||
|
||||
# Sync operations migration methods
|
||||
async def migrate_add_sync_operations_if_needed(self):
|
||||
"""Check and add sync_operations table if needed"""
|
||||
try:
|
||||
if await self._check_sync_operations_migration_needed():
|
||||
logger.info("Sync operations table migration needed, starting...")
|
||||
await self._migrate_add_sync_operations()
|
||||
logger.info("Sync operations table migration completed")
|
||||
else:
|
||||
logger.info("Sync operations table already exists")
|
||||
except Exception as e:
|
||||
logger.error(f"Sync operations table migration failed: {e}")
|
||||
raise
|
||||
|
||||
async def _check_sync_operations_migration_needed(self) -> bool:
|
||||
"""Check if sync_operations table needs to be created"""
|
||||
db_path = path_manager.get_database_path()
|
||||
if not db_path.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='sync_operations'"
|
||||
)
|
||||
table_exists = cursor.fetchone() is not None
|
||||
|
||||
conn.close()
|
||||
return not table_exists
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check sync_operations migration status: {e}")
|
||||
return False
|
||||
|
||||
async def _migrate_add_sync_operations(self):
|
||||
"""Add sync_operations table"""
|
||||
db_path = path_manager.get_database_path()
|
||||
if not db_path.exists():
|
||||
logger.warning("Database file not found, skipping migration")
|
||||
return
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
logger.info("Creating sync_operations table...")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE sync_operations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
started_at DATETIME NOT NULL,
|
||||
completed_at DATETIME,
|
||||
success BOOLEAN,
|
||||
accounts_processed INTEGER DEFAULT 0,
|
||||
transactions_added INTEGER DEFAULT 0,
|
||||
transactions_updated INTEGER DEFAULT 0,
|
||||
balances_updated INTEGER DEFAULT 0,
|
||||
duration_seconds REAL,
|
||||
errors TEXT,
|
||||
logs TEXT,
|
||||
trigger_type TEXT DEFAULT 'manual'
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sync_operations_started_at ON sync_operations(started_at)"
|
||||
)
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sync_operations_success ON sync_operations(success)"
|
||||
)
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sync_operations_trigger_type ON sync_operations(trigger_type)"
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
logger.info("Sync operations table migration completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Sync operations table migration failed: {e}")
|
||||
raise
|
||||
|
||||
# Logo migration methods
|
||||
async def migrate_add_logo_if_needed(self):
|
||||
"""Check and add logo column to accounts table if needed"""
|
||||
try:
|
||||
if await self._check_logo_migration_needed():
|
||||
logger.info("Logo column migration needed, starting...")
|
||||
await self._migrate_add_logo()
|
||||
logger.info("Logo column migration completed")
|
||||
else:
|
||||
logger.info("Logo column already exists")
|
||||
except Exception as e:
|
||||
logger.error(f"Logo column migration failed: {e}")
|
||||
raise
|
||||
|
||||
async def _check_logo_migration_needed(self) -> bool:
|
||||
"""Check if logo column needs to be added to accounts table"""
|
||||
db_path = path_manager.get_database_path()
|
||||
if not db_path.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'"
|
||||
)
|
||||
if not cursor.fetchone():
|
||||
conn.close()
|
||||
return False
|
||||
|
||||
cursor.execute("PRAGMA table_info(accounts)")
|
||||
columns = cursor.fetchall()
|
||||
|
||||
has_logo = any(col[1] == "logo" for col in columns)
|
||||
|
||||
conn.close()
|
||||
return not has_logo
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check logo migration status: {e}")
|
||||
return False
|
||||
|
||||
async def _migrate_add_logo(self):
|
||||
"""Add logo column to accounts table"""
|
||||
db_path = path_manager.get_database_path()
|
||||
if not db_path.exists():
|
||||
logger.warning("Database file not found, skipping migration")
|
||||
return
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
logger.info("Adding logo column to accounts table...")
|
||||
|
||||
cursor.execute("""
|
||||
ALTER TABLE accounts
|
||||
ADD COLUMN logo TEXT
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
logger.info("Logo column migration completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Logo column migration failed: {e}")
|
||||
raise
|
||||
@@ -1,132 +0,0 @@
|
||||
import json
|
||||
import sqlite3
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from leggen.repositories.base_repository import BaseRepository
|
||||
from leggen.utils.paths import path_manager
|
||||
|
||||
|
||||
class SyncRepository(BaseRepository):
|
||||
"""Repository for sync operation data"""
|
||||
|
||||
def create_table(self):
|
||||
"""Create sync_operations table with indexes"""
|
||||
with self._get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sync_operations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
started_at DATETIME NOT NULL,
|
||||
completed_at DATETIME,
|
||||
success BOOLEAN,
|
||||
accounts_processed INTEGER DEFAULT 0,
|
||||
transactions_added INTEGER DEFAULT 0,
|
||||
transactions_updated INTEGER DEFAULT 0,
|
||||
balances_updated INTEGER DEFAULT 0,
|
||||
duration_seconds REAL,
|
||||
errors TEXT,
|
||||
logs TEXT,
|
||||
trigger_type TEXT DEFAULT 'manual'
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sync_operations_started_at ON sync_operations(started_at)"
|
||||
)
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sync_operations_success ON sync_operations(success)"
|
||||
)
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sync_operations_trigger_type ON sync_operations(trigger_type)"
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
def persist(self, sync_operation: Dict[str, Any]) -> int:
|
||||
"""Persist sync operation to database and return the ID"""
|
||||
try:
|
||||
self.create_table()
|
||||
|
||||
db_path = path_manager.get_database_path()
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""INSERT INTO sync_operations (
|
||||
started_at, completed_at, success, accounts_processed,
|
||||
transactions_added, transactions_updated, balances_updated,
|
||||
duration_seconds, errors, logs, trigger_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
sync_operation.get("started_at"),
|
||||
sync_operation.get("completed_at"),
|
||||
sync_operation.get("success"),
|
||||
sync_operation.get("accounts_processed", 0),
|
||||
sync_operation.get("transactions_added", 0),
|
||||
sync_operation.get("transactions_updated", 0),
|
||||
sync_operation.get("balances_updated", 0),
|
||||
sync_operation.get("duration_seconds"),
|
||||
json.dumps(sync_operation.get("errors", [])),
|
||||
json.dumps(sync_operation.get("logs", [])),
|
||||
sync_operation.get("trigger_type", "manual"),
|
||||
),
|
||||
)
|
||||
|
||||
operation_id = cursor.lastrowid
|
||||
if operation_id is None:
|
||||
raise ValueError("Failed to get operation ID after insert")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
logger.debug(f"Persisted sync operation with ID: {operation_id}")
|
||||
return operation_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to persist sync operation: {e}")
|
||||
raise
|
||||
|
||||
def get_operations(self, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
|
||||
"""Get sync operations from database"""
|
||||
try:
|
||||
db_path = path_manager.get_database_path()
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""SELECT id, started_at, completed_at, success, accounts_processed,
|
||||
transactions_added, transactions_updated, balances_updated,
|
||||
duration_seconds, errors, logs, trigger_type
|
||||
FROM sync_operations
|
||||
ORDER BY started_at DESC
|
||||
LIMIT ? OFFSET ?""",
|
||||
(limit, offset),
|
||||
)
|
||||
|
||||
operations = []
|
||||
for row in cursor.fetchall():
|
||||
operation = {
|
||||
"id": row[0],
|
||||
"started_at": row[1],
|
||||
"completed_at": row[2],
|
||||
"success": bool(row[3]) if row[3] is not None else None,
|
||||
"accounts_processed": row[4],
|
||||
"transactions_added": row[5],
|
||||
"transactions_updated": row[6],
|
||||
"balances_updated": row[7],
|
||||
"duration_seconds": row[8],
|
||||
"errors": json.loads(row[9]) if row[9] else [],
|
||||
"logs": json.loads(row[10]) if row[10] else [],
|
||||
"trigger_type": row[11],
|
||||
}
|
||||
operations.append(operation)
|
||||
|
||||
conn.close()
|
||||
return operations
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get sync operations: {e}")
|
||||
return []
|
||||
@@ -1,264 +0,0 @@
|
||||
import json
|
||||
import sqlite3
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from leggen.repositories.base_repository import BaseRepository
|
||||
|
||||
|
||||
class TransactionRepository(BaseRepository):
|
||||
"""Repository for transaction data operations"""
|
||||
|
||||
def create_table(self):
|
||||
"""Create transactions table with indexes"""
|
||||
with self._get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS transactions (
|
||||
accountId TEXT NOT NULL,
|
||||
transactionId TEXT NOT NULL,
|
||||
internalTransactionId TEXT,
|
||||
institutionId TEXT,
|
||||
iban TEXT,
|
||||
transactionDate DATETIME,
|
||||
description TEXT,
|
||||
transactionValue REAL,
|
||||
transactionCurrency TEXT,
|
||||
transactionStatus TEXT,
|
||||
rawTransaction JSON,
|
||||
PRIMARY KEY (accountId, transactionId)
|
||||
)"""
|
||||
)
|
||||
|
||||
# Create indexes for better performance
|
||||
cursor.execute(
|
||||
"""CREATE INDEX IF NOT EXISTS idx_transactions_internal_id
|
||||
ON transactions(internalTransactionId)"""
|
||||
)
|
||||
cursor.execute(
|
||||
"""CREATE INDEX IF NOT EXISTS idx_transactions_date
|
||||
ON transactions(transactionDate)"""
|
||||
)
|
||||
cursor.execute(
|
||||
"""CREATE INDEX IF NOT EXISTS idx_transactions_account_date
|
||||
ON transactions(accountId, transactionDate)"""
|
||||
)
|
||||
cursor.execute(
|
||||
"""CREATE INDEX IF NOT EXISTS idx_transactions_amount
|
||||
ON transactions(transactionValue)"""
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
def persist(
|
||||
self, account_id: str, transactions: List[Dict[str, Any]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Persist transactions to database, return new ones"""
|
||||
try:
|
||||
self.create_table()
|
||||
|
||||
with self._get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
insert_sql = """INSERT OR REPLACE INTO transactions (
|
||||
accountId,
|
||||
transactionId,
|
||||
internalTransactionId,
|
||||
institutionId,
|
||||
iban,
|
||||
transactionDate,
|
||||
description,
|
||||
transactionValue,
|
||||
transactionCurrency,
|
||||
transactionStatus,
|
||||
rawTransaction
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"""
|
||||
|
||||
new_transactions = []
|
||||
|
||||
for transaction in transactions:
|
||||
try:
|
||||
# Check if transaction already exists
|
||||
cursor.execute(
|
||||
"""SELECT COUNT(*) FROM transactions
|
||||
WHERE accountId = ? AND transactionId = ?""",
|
||||
(transaction["accountId"], transaction["transactionId"]),
|
||||
)
|
||||
exists = cursor.fetchone()[0] > 0
|
||||
|
||||
cursor.execute(
|
||||
insert_sql,
|
||||
(
|
||||
transaction["accountId"],
|
||||
transaction["transactionId"],
|
||||
transaction.get("internalTransactionId"),
|
||||
transaction["institutionId"],
|
||||
transaction["iban"],
|
||||
transaction["transactionDate"],
|
||||
transaction["description"],
|
||||
transaction["transactionValue"],
|
||||
transaction["transactionCurrency"],
|
||||
transaction["transactionStatus"],
|
||||
json.dumps(transaction["rawTransaction"]),
|
||||
),
|
||||
)
|
||||
|
||||
if not exists:
|
||||
new_transactions.append(transaction)
|
||||
|
||||
except sqlite3.IntegrityError as e:
|
||||
logger.warning(
|
||||
f"Failed to insert transaction {transaction.get('transactionId')}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
conn.commit()
|
||||
|
||||
logger.info(
|
||||
f"Persisted {len(new_transactions)} new transactions for account {account_id}"
|
||||
)
|
||||
return new_transactions
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to persist transactions: {e}")
|
||||
raise
|
||||
|
||||
def get_transactions(
|
||||
self,
|
||||
account_id: Optional[str] = None,
|
||||
limit: Optional[int] = 100,
|
||||
offset: int = 0,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
min_amount: Optional[float] = None,
|
||||
max_amount: Optional[float] = None,
|
||||
search: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get transactions with optional filtering"""
|
||||
if not self._db_exists():
|
||||
return []
|
||||
|
||||
with self._get_db_connection(row_factory=True) as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT * FROM transactions WHERE 1=1"
|
||||
params: List[Union[str, int, float]] = []
|
||||
|
||||
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}%")
|
||||
|
||||
query += " ORDER BY transactionDate DESC"
|
||||
|
||||
if limit:
|
||||
query += " LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
if offset:
|
||||
query += " OFFSET ?"
|
||||
params.append(offset)
|
||||
|
||||
cursor.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
transactions = []
|
||||
for row in rows:
|
||||
transaction = dict(row)
|
||||
if transaction["rawTransaction"]:
|
||||
transaction["rawTransaction"] = json.loads(
|
||||
transaction["rawTransaction"]
|
||||
)
|
||||
transactions.append(transaction)
|
||||
|
||||
return transactions
|
||||
|
||||
def get_count(
|
||||
self,
|
||||
account_id: Optional[str] = None,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
min_amount: Optional[float] = None,
|
||||
max_amount: Optional[float] = None,
|
||||
search: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Get total count of transactions matching filters"""
|
||||
if not self._db_exists():
|
||||
return 0
|
||||
|
||||
with self._get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT COUNT(*) FROM transactions WHERE 1=1"
|
||||
params: List[Union[str, float]] = []
|
||||
|
||||
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}%")
|
||||
|
||||
cursor.execute(query, params)
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
def get_account_summary(self, account_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get basic account info from transactions table"""
|
||||
if not self._db_exists():
|
||||
return None
|
||||
|
||||
with self._get_db_connection(row_factory=True) as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT DISTINCT accountId, institutionId, iban
|
||||
FROM transactions
|
||||
WHERE accountId = ?
|
||||
ORDER BY transactionDate DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(account_id,),
|
||||
)
|
||||
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
return None
|
||||
@@ -1,192 +0,0 @@
|
||||
"""Backup service for S3 storage."""
|
||||
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError, NoCredentialsError
|
||||
from loguru import logger
|
||||
|
||||
from leggen.models.config import S3BackupConfig
|
||||
|
||||
|
||||
class BackupService:
|
||||
"""Service for managing S3 backups."""
|
||||
|
||||
def __init__(self, s3_config: Optional[S3BackupConfig] = None):
|
||||
"""Initialize backup service with S3 configuration."""
|
||||
self.s3_config = s3_config
|
||||
self._s3_client = None
|
||||
|
||||
def _get_s3_client(self, config: Optional[S3BackupConfig] = None):
|
||||
"""Get or create S3 client with current configuration."""
|
||||
current_config = config or self.s3_config
|
||||
if not current_config:
|
||||
raise ValueError("S3 configuration is required")
|
||||
|
||||
# Create S3 client with configuration
|
||||
session = boto3.Session(
|
||||
aws_access_key_id=current_config.access_key_id,
|
||||
aws_secret_access_key=current_config.secret_access_key,
|
||||
region_name=current_config.region,
|
||||
)
|
||||
|
||||
s3_kwargs = {}
|
||||
if current_config.endpoint_url:
|
||||
s3_kwargs["endpoint_url"] = current_config.endpoint_url
|
||||
|
||||
if current_config.path_style:
|
||||
from botocore.config import Config
|
||||
|
||||
s3_kwargs["config"] = Config(s3={"addressing_style": "path"})
|
||||
|
||||
return session.client("s3", **s3_kwargs)
|
||||
|
||||
async def test_connection(self, config: S3BackupConfig) -> bool:
|
||||
"""Test S3 connection with provided configuration.
|
||||
|
||||
Args:
|
||||
config: S3 configuration to test
|
||||
|
||||
Returns:
|
||||
True if connection successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
s3_client = self._get_s3_client(config)
|
||||
|
||||
# Try to list objects in the bucket (limited to 1 to minimize cost)
|
||||
s3_client.list_objects_v2(Bucket=config.bucket_name, MaxKeys=1)
|
||||
|
||||
logger.info(
|
||||
f"S3 connection test successful for bucket: {config.bucket_name}"
|
||||
)
|
||||
return True
|
||||
|
||||
except NoCredentialsError:
|
||||
logger.error("S3 credentials not found or invalid")
|
||||
return False
|
||||
except ClientError as e:
|
||||
error_code = e.response["Error"]["Code"]
|
||||
logger.error(
|
||||
f"S3 connection test failed: {error_code} - {e.response['Error']['Message']}"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during S3 connection test: {str(e)}")
|
||||
return False
|
||||
|
||||
async def backup_database(self, database_path: Path) -> bool:
|
||||
"""Backup database file to S3.
|
||||
|
||||
Args:
|
||||
database_path: Path to the SQLite database file
|
||||
|
||||
Returns:
|
||||
True if backup successful, False otherwise
|
||||
"""
|
||||
if not self.s3_config or not self.s3_config.enabled:
|
||||
logger.warning("S3 backup is not configured or disabled")
|
||||
return False
|
||||
|
||||
if not database_path.exists():
|
||||
logger.error(f"Database file not found: {database_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
s3_client = self._get_s3_client()
|
||||
|
||||
# Generate backup filename with timestamp
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_key = f"leggen_backups/database_backup_{timestamp}.db"
|
||||
|
||||
# Upload database file
|
||||
logger.info(f"Starting database backup to S3: {backup_key}")
|
||||
s3_client.upload_file(
|
||||
str(database_path), self.s3_config.bucket_name, backup_key
|
||||
)
|
||||
|
||||
logger.info(f"Database backup completed successfully: {backup_key}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Database backup failed: {str(e)}")
|
||||
return False
|
||||
|
||||
async def list_backups(self) -> list[dict]:
|
||||
"""List available backups in S3.
|
||||
|
||||
Returns:
|
||||
List of backup metadata dictionaries
|
||||
"""
|
||||
if not self.s3_config or not self.s3_config.enabled:
|
||||
logger.warning("S3 backup is not configured or disabled")
|
||||
return []
|
||||
|
||||
try:
|
||||
s3_client = self._get_s3_client()
|
||||
|
||||
# List objects with backup prefix
|
||||
response = s3_client.list_objects_v2(
|
||||
Bucket=self.s3_config.bucket_name, Prefix="leggen_backups/"
|
||||
)
|
||||
|
||||
backups = []
|
||||
for obj in response.get("Contents", []):
|
||||
backups.append(
|
||||
{
|
||||
"key": obj["Key"],
|
||||
"last_modified": obj["LastModified"].isoformat(),
|
||||
"size": obj["Size"],
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by last modified (newest first)
|
||||
backups.sort(key=lambda x: x["last_modified"], reverse=True)
|
||||
|
||||
return backups
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list backups: {str(e)}")
|
||||
return []
|
||||
|
||||
async def restore_database(self, backup_key: str, restore_path: Path) -> bool:
|
||||
"""Restore database from S3 backup.
|
||||
|
||||
Args:
|
||||
backup_key: S3 key of the backup to restore
|
||||
restore_path: Path where to restore the database
|
||||
|
||||
Returns:
|
||||
True if restore successful, False otherwise
|
||||
"""
|
||||
if not self.s3_config or not self.s3_config.enabled:
|
||||
logger.warning("S3 backup is not configured or disabled")
|
||||
return False
|
||||
|
||||
try:
|
||||
s3_client = self._get_s3_client()
|
||||
|
||||
# Download backup file
|
||||
logger.info(f"Starting database restore from S3: {backup_key}")
|
||||
|
||||
# Create parent directory if it doesn't exist
|
||||
restore_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download to temporary file first, then move to final location
|
||||
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
|
||||
s3_client.download_file(
|
||||
self.s3_config.bucket_name, backup_key, temp_file.name
|
||||
)
|
||||
|
||||
# Move temp file to final location
|
||||
temp_path = Path(temp_file.name)
|
||||
temp_path.replace(restore_path)
|
||||
|
||||
logger.info(f"Database restore completed successfully: {restore_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Database restore failed: {str(e)}")
|
||||
return False
|
||||
@@ -1,13 +0,0 @@
|
||||
"""Data processing layer for all transformation logic."""
|
||||
|
||||
from leggen.services.data_processors.account_enricher import AccountEnricher
|
||||
from leggen.services.data_processors.analytics_processor import AnalyticsProcessor
|
||||
from leggen.services.data_processors.balance_transformer import BalanceTransformer
|
||||
from leggen.services.data_processors.transaction_processor import TransactionProcessor
|
||||
|
||||
__all__ = [
|
||||
"AccountEnricher",
|
||||
"AnalyticsProcessor",
|
||||
"BalanceTransformer",
|
||||
"TransactionProcessor",
|
||||
]
|
||||
@@ -1,71 +0,0 @@
|
||||
"""Account enrichment processor for adding currency, logos, and metadata."""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from leggen.services.gocardless_service import GoCardlessService
|
||||
|
||||
|
||||
class AccountEnricher:
|
||||
"""Enriches account details with currency and institution information."""
|
||||
|
||||
def __init__(self):
|
||||
self.gocardless = GoCardlessService()
|
||||
|
||||
async def enrich_account_details(
|
||||
self,
|
||||
account_details: Dict[str, Any],
|
||||
balances: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Enrich account details with currency from balances and institution logo.
|
||||
|
||||
Args:
|
||||
account_details: Raw account details from GoCardless
|
||||
balances: Balance data containing currency information
|
||||
|
||||
Returns:
|
||||
Enriched account details with currency and logo added
|
||||
"""
|
||||
enriched_account = account_details.copy()
|
||||
|
||||
# Extract currency from first balance
|
||||
currency = self._extract_currency_from_balances(balances)
|
||||
if currency:
|
||||
enriched_account["currency"] = currency
|
||||
|
||||
# Fetch and add institution logo
|
||||
institution_id = enriched_account.get("institution_id")
|
||||
if institution_id:
|
||||
logo = await self._fetch_institution_logo(institution_id)
|
||||
if logo:
|
||||
enriched_account["logo"] = logo
|
||||
|
||||
return enriched_account
|
||||
|
||||
def _extract_currency_from_balances(self, balances: Dict[str, Any]) -> str | None:
|
||||
"""Extract currency from the first balance in the balances data."""
|
||||
balances_list = balances.get("balances", [])
|
||||
if not balances_list:
|
||||
return None
|
||||
|
||||
first_balance = balances_list[0]
|
||||
balance_amount = first_balance.get("balanceAmount", {})
|
||||
return balance_amount.get("currency")
|
||||
|
||||
async def _fetch_institution_logo(self, institution_id: str) -> str | None:
|
||||
"""Fetch institution logo from GoCardless API."""
|
||||
try:
|
||||
institution_details = await self.gocardless.get_institution_details(
|
||||
institution_id
|
||||
)
|
||||
logo = institution_details.get("logo", "")
|
||||
if logo:
|
||||
logger.info(f"Fetched logo for institution {institution_id}: {logo}")
|
||||
return logo
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to fetch institution details for {institution_id}: {e}"
|
||||
)
|
||||
return None
|
||||
@@ -1,201 +0,0 @@
|
||||
"""Analytics processor for calculating historical balances and statistics."""
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class AnalyticsProcessor:
|
||||
"""Calculates historical balances and transaction statistics from database data."""
|
||||
|
||||
def calculate_historical_balances(
|
||||
self,
|
||||
db_path: Path,
|
||||
account_id: Optional[str] = None,
|
||||
days: int = 365,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Generate historical balance progression based on transaction history.
|
||||
|
||||
Uses current balances and subtracts future transactions to calculate
|
||||
balance at each historical point in time.
|
||||
|
||||
Args:
|
||||
db_path: Path to SQLite database
|
||||
account_id: Optional account ID to filter by
|
||||
days: Number of days to look back (default 365)
|
||||
|
||||
Returns:
|
||||
List of historical balance data points
|
||||
"""
|
||||
if not db_path.exists():
|
||||
return []
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cutoff_date = (datetime.now() - timedelta(days=days)).date().isoformat()
|
||||
today_date = datetime.now().date().isoformat()
|
||||
|
||||
# Single SQL query to generate historical balances using window functions
|
||||
query = """
|
||||
WITH RECURSIVE date_series AS (
|
||||
-- Generate weekly dates from cutoff_date to today
|
||||
SELECT date(?) as ref_date
|
||||
UNION ALL
|
||||
SELECT date(ref_date, '+7 days')
|
||||
FROM date_series
|
||||
WHERE ref_date < date(?)
|
||||
),
|
||||
current_balances AS (
|
||||
-- Get current balance for each account/type
|
||||
SELECT account_id, type, amount, currency
|
||||
FROM balances b1
|
||||
WHERE b1.timestamp = (
|
||||
SELECT MAX(b2.timestamp)
|
||||
FROM balances b2
|
||||
WHERE b2.account_id = b1.account_id AND b2.type = b1.type
|
||||
)
|
||||
{account_filter}
|
||||
AND b1.type = 'closingBooked' -- Focus on closingBooked for charts
|
||||
),
|
||||
historical_points AS (
|
||||
-- Calculate balance at each weekly point by subtracting future transactions
|
||||
SELECT
|
||||
cb.account_id,
|
||||
cb.type as balance_type,
|
||||
cb.currency,
|
||||
ds.ref_date,
|
||||
cb.amount - COALESCE(
|
||||
(SELECT SUM(t.transactionValue)
|
||||
FROM transactions t
|
||||
WHERE t.accountId = cb.account_id
|
||||
AND date(t.transactionDate) > ds.ref_date), 0
|
||||
) as balance_amount
|
||||
FROM current_balances cb
|
||||
CROSS JOIN date_series ds
|
||||
)
|
||||
SELECT
|
||||
account_id || '_' || balance_type || '_' || ref_date as id,
|
||||
account_id,
|
||||
balance_amount,
|
||||
balance_type,
|
||||
currency,
|
||||
ref_date as reference_date
|
||||
FROM historical_points
|
||||
ORDER BY account_id, ref_date
|
||||
"""
|
||||
|
||||
# Build parameters and account filter
|
||||
params = [cutoff_date, today_date]
|
||||
if account_id:
|
||||
account_filter = "AND b1.account_id = ?"
|
||||
params.append(account_id)
|
||||
else:
|
||||
account_filter = ""
|
||||
|
||||
# Format the query with conditional filter
|
||||
formatted_query = query.format(account_filter=account_filter)
|
||||
|
||||
cursor.execute(formatted_query, params)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
conn.close()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Failed to calculate historical balances: {e}")
|
||||
raise
|
||||
|
||||
def calculate_monthly_stats(
|
||||
self,
|
||||
db_path: Path,
|
||||
account_id: Optional[str] = None,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Calculate monthly transaction statistics aggregated from database.
|
||||
|
||||
Sums transactions by month and calculates income, expenses, and net values.
|
||||
|
||||
Args:
|
||||
db_path: Path to SQLite database
|
||||
account_id: Optional account ID to filter by
|
||||
date_from: Optional start date (ISO format)
|
||||
date_to: Optional end date (ISO format)
|
||||
|
||||
Returns:
|
||||
List of monthly statistics with income, expenses, and net totals
|
||||
"""
|
||||
if not db_path.exists():
|
||||
return []
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# SQL query to aggregate transactions by month
|
||||
query = """
|
||||
SELECT
|
||||
strftime('%Y-%m', transactionDate) as month,
|
||||
COALESCE(SUM(CASE WHEN transactionValue > 0 THEN transactionValue ELSE 0 END), 0) as income,
|
||||
COALESCE(SUM(CASE WHEN transactionValue < 0 THEN ABS(transactionValue) ELSE 0 END), 0) as expenses,
|
||||
COALESCE(SUM(transactionValue), 0) as net
|
||||
FROM transactions
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
params = []
|
||||
|
||||
if account_id:
|
||||
query += " AND accountId = ?"
|
||||
params.append(account_id)
|
||||
|
||||
if date_from:
|
||||
query += " AND transactionDate >= ?"
|
||||
params.append(date_from)
|
||||
|
||||
if date_to:
|
||||
query += " AND transactionDate <= ?"
|
||||
params.append(date_to)
|
||||
|
||||
query += """
|
||||
GROUP BY strftime('%Y-%m', transactionDate)
|
||||
ORDER BY month ASC
|
||||
"""
|
||||
|
||||
cursor.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
# Convert to desired format with proper month display
|
||||
monthly_stats = []
|
||||
for row in rows:
|
||||
# Convert YYYY-MM to display format like "Mar 2024"
|
||||
year, month_num = row["month"].split("-")
|
||||
month_date = datetime.strptime(f"{year}-{month_num}-01", "%Y-%m-%d")
|
||||
display_month = month_date.strftime("%b %Y")
|
||||
|
||||
monthly_stats.append(
|
||||
{
|
||||
"month": display_month,
|
||||
"income": round(row["income"], 2),
|
||||
"expenses": round(row["expenses"], 2),
|
||||
"net": round(row["net"], 2),
|
||||
}
|
||||
)
|
||||
|
||||
conn.close()
|
||||
return monthly_stats
|
||||
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Failed to calculate monthly stats: {e}")
|
||||
raise
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Balance data transformation processor for format conversions."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
|
||||
class BalanceTransformer:
|
||||
"""Transforms balance data between GoCardless and internal database formats."""
|
||||
|
||||
def merge_account_metadata_into_balances(
|
||||
self,
|
||||
balances: Dict[str, Any],
|
||||
account_details: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Merge account metadata into balance data for proper persistence.
|
||||
|
||||
This adds institution_id, iban, and account_status to the balances
|
||||
so they can be persisted alongside the balance data.
|
||||
|
||||
Args:
|
||||
balances: Raw balance data from GoCardless
|
||||
account_details: Enriched account details containing metadata
|
||||
|
||||
Returns:
|
||||
Balance data with account metadata merged in
|
||||
"""
|
||||
balances_with_metadata = balances.copy()
|
||||
balances_with_metadata["institution_id"] = account_details.get("institution_id")
|
||||
balances_with_metadata["iban"] = account_details.get("iban")
|
||||
balances_with_metadata["account_status"] = account_details.get("status")
|
||||
return balances_with_metadata
|
||||
|
||||
def transform_to_database_format(
|
||||
self,
|
||||
account_id: str,
|
||||
balance_data: Dict[str, Any],
|
||||
) -> List[Tuple[Any, ...]]:
|
||||
"""
|
||||
Transform GoCardless balance format to database row format.
|
||||
|
||||
Converts nested GoCardless balance structure into flat tuples
|
||||
ready for SQLite insertion.
|
||||
|
||||
Args:
|
||||
account_id: The account ID
|
||||
balance_data: Balance data with merged account metadata
|
||||
|
||||
Returns:
|
||||
List of tuples in database row format (account_id, bank, status, ...)
|
||||
"""
|
||||
rows = []
|
||||
|
||||
for balance in balance_data.get("balances", []):
|
||||
balance_amount = balance.get("balanceAmount", {})
|
||||
|
||||
row = (
|
||||
account_id,
|
||||
balance_data.get("institution_id", "unknown"),
|
||||
balance_data.get("account_status"),
|
||||
balance_data.get("iban", "N/A"),
|
||||
float(balance_amount.get("amount", 0)),
|
||||
balance_amount.get("currency"),
|
||||
balance.get("balanceType"),
|
||||
datetime.now().isoformat(),
|
||||
)
|
||||
rows.append(row)
|
||||
|
||||
return rows
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user