Compare commits

..

27 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,22 @@
{
"permissions": {
"allow": [
"Bash(mkdir:*)",
"Bash(uv sync:*)",
"Bash(uv run pytest:*)",
"Bash(git commit:*)",
"Bash(ruff check:*)",
"Bash(git add:*)",
"Bash(mypy:*)",
"WebFetch(domain:localhost)",
"Bash(npm create:*)",
"Bash(npm install)",
"Bash(npm install:*)",
"Bash(npx tailwindcss init:*)",
"Bash(./node_modules/.bin/tailwindcss:*)",
"Bash(npm run build:*)"
],
"deny": [],
"ask": []
}
}

View File

@@ -1,57 +0,0 @@
name: CI
on:
push:
branches: ["main", "dev"]
pull_request:
branches: ["main", "dev"]
jobs:
test-python:
name: Test Python
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, 'chore(ci): Bump version to')"
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
- name: Create config directory for tests
run: |
mkdir -p ~/.config/leggen
cp config.example.toml ~/.config/leggen/config.toml
- name: Run Python tests
run: uv run pytest
test-frontend:
name: Test Frontend
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, 'chore(ci): Bump version to')"
defaults:
run:
working-directory: ./frontend
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm install
- name: Run lint
run: npm run lint
- name: Run build
run: npm run build

View File

@@ -45,119 +45,87 @@ jobs:
push-docker-backend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: elisiariocouto
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta backend
id: meta-backend
uses: docker/metadata-action@v5
with:
flavor: |
latest=false
# list of Docker images to use as base name for tags
images: |
elisiariocouto/leggen
ghcr.io/elisiariocouto/leggen
# generate Docker tags based on the following events/attributes
tags: |
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push backend
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: elisiariocouto
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta backend
id: meta-backend
uses: docker/metadata-action@v5
with:
# list of Docker images to use as base name for tags
images: |
elisiariocouto/leggen
ghcr.io/elisiariocouto/leggen
# generate Docker tags based on the following events/attributes
tags: |
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push backend
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
push-docker-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: elisiariocouto
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta frontend
id: meta-frontend
uses: docker/metadata-action@v5
with:
flavor: |
latest=false
# list of Docker images to use as base name for tags
images: |
elisiariocouto/leggen
ghcr.io/elisiariocouto/leggen
# generate Docker tags based on the following events/attributes
tags: |
type=ref,event=tag,suffix=-frontend
type=semver,pattern={{version}},suffix=-frontend
type=semver,pattern={{major}}.{{minor}},suffix=-frontend
type=raw,value=latest-frontend
- name: Build and push frontend
uses: docker/build-push-action@v5
with:
context: ./frontend
file: ./frontend/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
create-github-release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs: [build, publish-to-pypi, push-docker-backend, push-docker-frontend]
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate release notes
uses: orhun/git-cliff-action@v4
id: release_notes
with:
config: cliff.toml
args: --current
env:
GITHUB_REPO: ${{ github.repository }}
- 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 }}
draft: false
prerelease: false
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: elisiariocouto
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta frontend
id: meta-frontend
uses: docker/metadata-action@v5
with:
# list of Docker images to use as base name for tags
images: |
elisiariocouto/leggen
ghcr.io/elisiariocouto/leggen
# generate Docker tags based on the following events/attributes
tags: |
type=ref,event=tag,suffix=-frontend
type=semver,pattern={{version}},suffix=-frontend
type=semver,pattern={{major}}.{{minor}},suffix=-frontend
type=raw,value=latest-frontend
- name: Build and push frontend
uses: docker/build-push-action@v5
with:
context: ./frontend
file: ./frontend/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}

4
.gitignore vendored
View File

@@ -162,7 +162,3 @@ docker-compose.dev.yml
nocodb/
sql/
leggen.db
*.db
config.toml
.claude/
.playwright-mcp/

View File

@@ -1,12 +0,0 @@
{
"mcpServers": {
"shadcn": {
"command": "npx",
"args": ["shadcn@latest", "mcp"]
},
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest"]
}
}
}

View File

@@ -1,8 +1,8 @@
repos:
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: "v0.13.0"
rev: "v0.12.11"
hooks:
- id: ruff-check
- id: ruff
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
@@ -10,12 +10,13 @@ repos:
- id: trailing-whitespace
exclude: ".*\\.md$"
- id: end-of-file-fixer
- id: check-added-large-files
- repo: local
hooks:
- id: mypy
name: Static type check with mypy
entry: uv run mypy leggen --check-untyped-defs
files: "^leggen/.*"
entry: uv run mypy leggen leggend --check-untyped-defs
files: "^leggen(d)?/.*"
language: "system"
types: ["python"]
always_run: true

104
AGENTS.md
View File

@@ -1,55 +1,5 @@
# Agent Guidelines for Leggen
## Quick Setup for Development
### Prerequisites
- **uv** must be installed for Python dependency management (can be installed via `pip install uv`)
- **Configuration file**: Copy `config.example.toml` to `config.toml` before running any commands:
```bash
cp config.example.toml config.toml
```
### Generate Mock Database
The leggen CLI provides a command to generate a mock database for testing:
```bash
# Generate sample database with default settings (3 accounts, 50 transactions each)
uv run leggen --config config.toml generate_sample_db --database /path/to/test.db --force
# Custom configuration
uv run leggen --config config.toml generate_sample_db --database ./test-data.db --accounts 5 --transactions 100 --force
```
The command outputs instructions for setting the required environment variable to use the generated database.
### Start the API Server
1. Install uv if not already installed: `pip install uv`
2. Set the database environment variable to point to your generated mock database:
```bash
export LEGGEN_DATABASE_PATH=/path/to/your/generated/database.db
```
3. Ensure the API can find the configuration file (choose one):
```bash
# Option 1: Copy config to the expected location
mkdir -p ~/.config/leggen && cp config.toml ~/.config/leggen/config.toml
# Option 2: Set environment variable to current config file
export LEGGEN_CONFIG_FILE=./config.toml
```
4. Start the API server:
```bash
uv run leggen server
```
- For development mode with auto-reload: `uv run leggen server --reload`
- API will be available at `http://localhost:8000` with docs at `http://localhost:8000/api/v1/docs`
### Start the Frontend
1. Navigate to the frontend directory: `cd frontend`
2. Install npm dependencies: `npm install`
3. Start the development server: `npm run dev`
- Frontend will be available at `http://localhost:3000`
- The frontend is configured to connect to the API at `http://localhost:8000/api/v1`
## Build/Lint/Test Commands
### Frontend (React/TypeScript)
@@ -60,7 +10,7 @@ The command outputs instructions for setting the required environment variable t
### Backend (Python)
- **Lint**: `uv run ruff check .`
- **Format**: `uv run ruff format .`
- **Type check**: `uv run mypy leggen --check-untyped-defs`
- **Type check**: `uv run mypy leggen leggend --check-untyped-defs`
- **All checks**: `uv run pre-commit run --all-files`
- **Run all tests**: `uv run pytest`
- **Run single test**: `uv run pytest tests/unit/test_api_accounts.py::TestAccountsAPI::test_get_all_accounts_success -v`
@@ -81,61 +31,11 @@ The command outputs instructions for setting the required environment variable t
- **Naming**: PascalCase for components, camelCase for variables/functions
- **Types**: Use `import type` for type-only imports, define interfaces/types
- **Styling**: Tailwind CSS with `clsx` utility for conditional classes
- **UI Components**: shadcn/ui components for consistent design system
- **Icons**: lucide-react with consistent naming
- **Data fetching**: @tanstack/react-query with proper error handling
- **Components**: Functional components with hooks, proper TypeScript typing
## Frontend Structure
### Layout Architecture
- **Root Layout**: `frontend/src/routes/__root.tsx` - Contains main app structure with Sidebar and Header
- **Header/Navbar**: `frontend/src/components/Header.tsx` - Top navigation bar (sticky on mobile only)
- **Sidebar**: `frontend/src/components/Sidebar.tsx` - Left navigation sidebar
- **Routes**: `frontend/src/routes/` - TanStack Router file-based routing
### Key Components Location
- **UI Components**: `frontend/src/components/ui/` - shadcn/ui components and reusable UI primitives
- **Feature Components**: `frontend/src/components/` - Main app components
- **Pages**: `frontend/src/routes/` - Route components (index.tsx, transactions.tsx, etc.)
- **Hooks**: `frontend/src/hooks/` - Custom React hooks
- **API**: `frontend/src/lib/api.ts` - API client configuration
- **Context**: `frontend/src/contexts/` - React contexts (ThemeContext, etc.)
### Routing Structure
- `/` - Overview/Dashboard (TransactionsTable component)
- `/transactions` - Transactions page
- `/analytics` - Analytics page
- `/notifications` - Notifications page
- `/settings` - Settings page
### General
- **Formatting**: ruff for Python, ESLint for TypeScript
- **Commits**: Use conventional commits with optional scopes, run pre-commit hooks before pushing
- Format: `type(scope): Description starting with uppercase and ending with period.`
- Scopes: `cli`, `api`, `frontend` (optional)
- Types: `feat`, `fix`, `refactor` (avoid too many different types)
- Examples:
- `feat(frontend): Add support for S3 backups.`
- `fix(api): Resolve authentication timeout issues.`
- `refactor(cli): Improve error handling for missing config.`
- Avoid including specific numbers, counts, or data-dependent information that may become outdated
- **Commits**: Use conventional commits, run pre-commit hooks before pushing
- **Security**: Never log sensitive data, use environment variables for secrets
## AI Development Support
### shadcn/ui Integration
This project uses shadcn/ui for consistent UI components. The MCP server is configured for AI agents to:
- Search and browse available shadcn/ui components
- View component implementation details and examples
- Generate proper installation commands for new components
Use the shadcn MCP tools when working with UI components to ensure consistency with the existing design system.
## Contributing Guidelines
This repository follows conventional changelog practices. Refer to `CONTRIBUTING.md` for detailed contribution guidelines including:
- Commit message format and scoping
- Release process using `scripts/release.sh`
- Pre-commit hooks setup with `pre-commit install`
- When the pre-commit fails, the commit is canceled

View File

@@ -1,863 +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
- Add instructions for shadcn/ui. ([83bb3fce](https://github.com/elisiariocouto/leggen/commit/83bb3fcef20d21a210bc53ce77aa533d37771668))
### Features
- **frontend:** Transform layout to use shadcn dashboard-01 with iOS PWA safe area support. ([fbb9e332](https://github.com/elisiariocouto/leggen/commit/fbb9e33279028a6a7ccf46c3696a012ec16a9ca7))
## 2025.9.18 (2025/09/19)
### Documentation
- Add instructions for shadcn/ui. ([83bb3fce](https://github.com/elisiariocouto/leggen/commit/83bb3fcef20d21a210bc53ce77aa533d37771668))
### Features
- **frontend:** Transform layout to use shadcn dashboard-01 with iOS PWA safe area support. ([fbb9e332](https://github.com/elisiariocouto/leggen/commit/fbb9e33279028a6a7ccf46c3696a012ec16a9ca7))
## 2025.9.17 (2025/09/18)
### Bug Fixes
- **api:** Prevent duplicate notifications for existing transactions during sync. ([25747d7d](https://github.com/elisiariocouto/leggen/commit/25747d7d372e291090764a6814f9d8d0b76aea3b))
### Miscellaneous Tasks
- Format files. ([848eccb3](https://github.com/elisiariocouto/leggen/commit/848eccb35b910c8121d15611547dca8da0b12756))
## 2025.9.17 (2025/09/18)
### Bug Fixes
- **api:** Prevent duplicate notifications for existing transactions during sync. ([25747d7d](https://github.com/elisiariocouto/leggen/commit/25747d7d372e291090764a6814f9d8d0b76aea3b))
### Miscellaneous Tasks
- Format files. ([848eccb3](https://github.com/elisiariocouto/leggen/commit/848eccb35b910c8121d15611547dca8da0b12756))
## 2025.9.16 (2025/09/18)
### Bug Fixes
- **frontend:** Add iOS safe area support for PWA sticky header ([6589c2dd](https://github.com/elisiariocouto/leggen/commit/6589c2dd666f8605cf6d1bf9ad7277734d4cd302))
## 2025.9.16 (2025/09/18)
### Bug Fixes
- **frontend:** Add iOS safe area support for PWA sticky header ([6589c2dd](https://github.com/elisiariocouto/leggen/commit/6589c2dd666f8605cf6d1bf9ad7277734d4cd302))
## 2025.9.15 (2025/09/18)
### Features
- **frontend:** Add settings page with account management functionality. ([056c33b9](https://github.com/elisiariocouto/leggen/commit/056c33b9c5cfbc2842cc2dd4ca8c4e3959a2be80))
### Refactor
- **frontend:** Simplify filter bar UI and remove advanced filters popover. ([be4f7f8c](https://github.com/elisiariocouto/leggen/commit/be4f7f8cecfe2564abdf0ce1be08497e5a6d7b68))
## 2025.9.15 (2025/09/18)
### Features
- **frontend:** Add settings page with account management functionality. ([056c33b9](https://github.com/elisiariocouto/leggen/commit/056c33b9c5cfbc2842cc2dd4ca8c4e3959a2be80))
### Refactor
- **frontend:** Simplify filter bar UI and remove advanced filters popover. ([be4f7f8c](https://github.com/elisiariocouto/leggen/commit/be4f7f8cecfe2564abdf0ce1be08497e5a6d7b68))
## 2025.9.14 (2025/09/18)
### Bug Fixes
- **config:** Remove aliases for configuration keys that were disabling telegram notifications in some cases. ([61442a59](https://github.com/elisiariocouto/leggen/commit/61442a598fa7f38c568e3df7e1d924ed85df7491))
### Miscellaneous Tasks
- **ci:** Prevent double GitHub Actions runs on new releases. ([30d7c2ed](https://github.com/elisiariocouto/leggen/commit/30d7c2ed4e9aff144837a1f0ed67a8ded0b5d72a))
## 2025.9.14 (2025/09/18)
### Bug Fixes
- **config:** Remove aliases for configuration keys that were disabling telegram notifications in some cases. ([61442a59](https://github.com/elisiariocouto/leggen/commit/61442a598fa7f38c568e3df7e1d924ed85df7491))
### Miscellaneous Tasks
- **ci:** Prevent double GitHub Actions runs on new releases. ([30d7c2ed](https://github.com/elisiariocouto/leggen/commit/30d7c2ed4e9aff144837a1f0ed67a8ded0b5d72a))
## 2025.9.13 (2025/09/17)
### Bug Fixes
- **frontend:** Resolve linting issue in skeleton component ([fb310a59](https://github.com/elisiariocouto/leggen/commit/fb310a5953cf51d1cac181529311e76a0f4ea9ee))
- **frontend:** Add index signature to PieDataPoint interface. ([81d7d163](https://github.com/elisiariocouto/leggen/commit/81d7d16301dafc62a95f63036819565ffb90ddb5))
- **frontend:** Resolve dual scroll and excessive whitespace issues on transactions page. ([8ab76081](https://github.com/elisiariocouto/leggen/commit/8ab760815c9ae072b8c2cb2460e31144b193e0b3))
- **frontend:** Remove broken running balance feature in transactions table. ([155a48d7](https://github.com/elisiariocouto/leggen/commit/155a48d7dc86b3f453ba6f8c37edf63c0b76c755))
### Features
- **frontend:** Complete shadcn migration of skeleton and styling components ([c83386b1](https://github.com/elisiariocouto/leggen/commit/c83386b1d5b165910abe8b391ca483e5b48cd35f))
- **frontend:** Add comprehensive PWA capabilities with dynamic theme support ([86891441](https://github.com/elisiariocouto/leggen/commit/86891441d65e13757f343cabc39ccdb3ca6adc75))
- **frontend:** Add PWA install prompts, update notifications, and app shortcuts ([3049a8cd](https://github.com/elisiariocouto/leggen/commit/3049a8cd2fa80c14f970884fb14df2ab88c418dd))
- **frontend:** Update brand identity with new logo and color scheme. ([2825dba2](https://github.com/elisiariocouto/leggen/commit/2825dba2e944b3fe31aaa33127b770e7474ce021))
- **frontend:** Update analytics cards to match home page design consistency. ([d9a39c30](https://github.com/elisiariocouto/leggen/commit/d9a39c30ab1248a9fdacff068d401c3daff3f6a5))
### Miscellaneous Tasks
- Enable browsermcp and shadcn MCP servers. ([5a626b53](https://github.com/elisiariocouto/leggen/commit/5a626b53947f7e2d1544faf3ee06f8a0f1fb5d7a))
### Refactor
- **frontend:** Replace LoadingSpinner with shadcn skeleton components. ([84e609a7](https://github.com/elisiariocouto/leggen/commit/84e609a774ddc0caf9f84eaf1e8cdce021c82785))
## 2025.9.13 (2025/09/17)
### Bug Fixes
- **frontend:** Resolve linting issue in skeleton component ([fb310a59](https://github.com/elisiariocouto/leggen/commit/fb310a5953cf51d1cac181529311e76a0f4ea9ee))
- **frontend:** Add index signature to PieDataPoint interface. ([81d7d163](https://github.com/elisiariocouto/leggen/commit/81d7d16301dafc62a95f63036819565ffb90ddb5))
- **frontend:** Resolve dual scroll and excessive whitespace issues on transactions page. ([8ab76081](https://github.com/elisiariocouto/leggen/commit/8ab760815c9ae072b8c2cb2460e31144b193e0b3))
- **frontend:** Remove broken running balance feature in transactions table. ([155a48d7](https://github.com/elisiariocouto/leggen/commit/155a48d7dc86b3f453ba6f8c37edf63c0b76c755))
### Features
- **frontend:** Complete shadcn migration of skeleton and styling components ([c83386b1](https://github.com/elisiariocouto/leggen/commit/c83386b1d5b165910abe8b391ca483e5b48cd35f))
- **frontend:** Add comprehensive PWA capabilities with dynamic theme support ([86891441](https://github.com/elisiariocouto/leggen/commit/86891441d65e13757f343cabc39ccdb3ca6adc75))
- **frontend:** Add PWA install prompts, update notifications, and app shortcuts ([3049a8cd](https://github.com/elisiariocouto/leggen/commit/3049a8cd2fa80c14f970884fb14df2ab88c418dd))
- **frontend:** Update brand identity with new logo and color scheme. ([2825dba2](https://github.com/elisiariocouto/leggen/commit/2825dba2e944b3fe31aaa33127b770e7474ce021))
- **frontend:** Update analytics cards to match home page design consistency. ([d9a39c30](https://github.com/elisiariocouto/leggen/commit/d9a39c30ab1248a9fdacff068d401c3daff3f6a5))
### Miscellaneous Tasks
- Enable browsermcp and shadcn MCP servers. ([5a626b53](https://github.com/elisiariocouto/leggen/commit/5a626b53947f7e2d1544faf3ee06f8a0f1fb5d7a))
### Refactor
- **frontend:** Replace LoadingSpinner with shadcn skeleton components. ([84e609a7](https://github.com/elisiariocouto/leggen/commit/84e609a774ddc0caf9f84eaf1e8cdce021c82785))
## 2025.9.12 (2025/09/15)
## 2025.9.12 (2025/09/15)
## 2025.9.11 (2025/09/15)
### Bug Fixes
- **config:** Add Pydantic validation and fix telegram config field mappings. ([2c6e0995](https://github.com/elisiariocouto/leggen/commit/2c6e0995968c9c9917992fd15ec10a89933c0c21))
- **config:** Fix example config file. ([d09cf6d0](https://github.com/elisiariocouto/leggen/commit/d09cf6d04ccb6233981f273cd88e0b8ffe074d71))
- **docs:** Remove test files and update gitignore ([692bee57](https://github.com/elisiariocouto/leggen/commit/692bee574ee8de16496a3c733bad53be3b256990))
- **frontend:** Align balance calculation between sidebar and Analytics page ([35b6d98e](https://github.com/elisiariocouto/leggen/commit/35b6d98e6a37b1e9caf8a232ffe66380e7203cad))
- **frontend:** Add ignore rules for eslint on shadcn components. ([74a700ff](https://github.com/elisiariocouto/leggen/commit/74a700ff87b2504c3d394cddd9935c56c3c7a00d))
- Resolve all CI failures - linting, typing, and test issues ([c8f0a103](https://github.com/elisiariocouto/leggen/commit/c8f0a103c6ccdb722bbab1ac6973827b41fddc19))
### Features
- **analytics:** Fix transaction limits and improve chart legends ([e136fc4b](https://github.com/elisiariocouto/leggen/commit/e136fc4b75243b35a77bc0bf0260808006987d7a))
- **docs:** Add comprehensive copilot agent setup instructions ([c6ac4455](https://github.com/elisiariocouto/leggen/commit/c6ac4455f848dd429100dd3fc6d43de8c4e5aa6b))
- **docs:** Add configuration file setup to agent instructions ([482f16c7](https://github.com/elisiariocouto/leggen/commit/482f16c77eef1f477ba49475fe30f809de9a05d7))
- **frontend:** Enhance transactions page with advanced filtering and UI improvements. ([969776fb](https://github.com/elisiariocouto/leggen/commit/969776fb53261acca2f77b0c761584e201fde118))
- **frontend:** Replace heavy filter UI with modern shadcn/ui inline filter bar. ([eb27f191](https://github.com/elisiariocouto/leggen/commit/eb27f19196d92a6ae5220b81709fded499a12f4f))
- **frontend:** Complete shadcn/ui migration with dark mode support and analytics updates. ([66db34c7](https://github.com/elisiariocouto/leggen/commit/66db34c712300ff4b5dbe7e06246f16d6f6a8469))
### Miscellaneous Tasks
- Sort imports, fix deprecated pydantic option. ([2467cb2f](https://github.com/elisiariocouto/leggen/commit/2467cb2f5af07a7262b3221bf61b58ad4017659a))
- Check import order using ruff. ([da98b7b2](https://github.com/elisiariocouto/leggen/commit/da98b7b2b77c5b37792dedff11f8256da3b086f7))
### Refactor
- **analytics:** Simplify analytics endpoints and eliminate client-side processing. ([077e2bb1](https://github.com/elisiariocouto/leggen/commit/077e2bb1adbdb73ffde17635bd918cd40fe7fb5a))
- Unify leggen and leggend packages into single leggen package ([318ca517](https://github.com/elisiariocouto/leggen/commit/318ca517f7ea599b37a8deb47ad80218fbae008f))
- Consolidate database layer and eliminate wrapper complexity. ([5ae3a51d](https://github.com/elisiariocouto/leggen/commit/5ae3a51d8138b9aa28dbceabf575ab2577402e70))
## 2025.9.11 (2025/09/15)
### Bug Fixes
- **config:** Add Pydantic validation and fix telegram config field mappings. ([2c6e0995](https://github.com/elisiariocouto/leggen/commit/2c6e0995968c9c9917992fd15ec10a89933c0c21))
- **config:** Fix example config file. ([d09cf6d0](https://github.com/elisiariocouto/leggen/commit/d09cf6d04ccb6233981f273cd88e0b8ffe074d71))
- **docs:** Remove test files and update gitignore ([692bee57](https://github.com/elisiariocouto/leggen/commit/692bee574ee8de16496a3c733bad53be3b256990))
- **frontend:** Align balance calculation between sidebar and Analytics page ([35b6d98e](https://github.com/elisiariocouto/leggen/commit/35b6d98e6a37b1e9caf8a232ffe66380e7203cad))
- **frontend:** Add ignore rules for eslint on shadcn components. ([74a700ff](https://github.com/elisiariocouto/leggen/commit/74a700ff87b2504c3d394cddd9935c56c3c7a00d))
- Resolve all CI failures - linting, typing, and test issues ([c8f0a103](https://github.com/elisiariocouto/leggen/commit/c8f0a103c6ccdb722bbab1ac6973827b41fddc19))
### Features
- **analytics:** Fix transaction limits and improve chart legends ([e136fc4b](https://github.com/elisiariocouto/leggen/commit/e136fc4b75243b35a77bc0bf0260808006987d7a))
- **docs:** Add comprehensive copilot agent setup instructions ([c6ac4455](https://github.com/elisiariocouto/leggen/commit/c6ac4455f848dd429100dd3fc6d43de8c4e5aa6b))
- **docs:** Add configuration file setup to agent instructions ([482f16c7](https://github.com/elisiariocouto/leggen/commit/482f16c77eef1f477ba49475fe30f809de9a05d7))
- **frontend:** Enhance transactions page with advanced filtering and UI improvements. ([969776fb](https://github.com/elisiariocouto/leggen/commit/969776fb53261acca2f77b0c761584e201fde118))
- **frontend:** Replace heavy filter UI with modern shadcn/ui inline filter bar. ([eb27f191](https://github.com/elisiariocouto/leggen/commit/eb27f19196d92a6ae5220b81709fded499a12f4f))
- **frontend:** Complete shadcn/ui migration with dark mode support and analytics updates. ([66db34c7](https://github.com/elisiariocouto/leggen/commit/66db34c712300ff4b5dbe7e06246f16d6f6a8469))
### Miscellaneous Tasks
- Sort imports, fix deprecated pydantic option. ([2467cb2f](https://github.com/elisiariocouto/leggen/commit/2467cb2f5af07a7262b3221bf61b58ad4017659a))
- Check import order using ruff. ([da98b7b2](https://github.com/elisiariocouto/leggen/commit/da98b7b2b77c5b37792dedff11f8256da3b086f7))
### Refactor
- **analytics:** Simplify analytics endpoints and eliminate client-side processing. ([077e2bb1](https://github.com/elisiariocouto/leggen/commit/077e2bb1adbdb73ffde17635bd918cd40fe7fb5a))
- Unify leggen and leggend packages into single leggen package ([318ca517](https://github.com/elisiariocouto/leggen/commit/318ca517f7ea599b37a8deb47ad80218fbae008f))
- Consolidate database layer and eliminate wrapper complexity. ([5ae3a51d](https://github.com/elisiariocouto/leggen/commit/5ae3a51d8138b9aa28dbceabf575ab2577402e70))
## 2025.9.10 (2025/09/13)
### Miscellaneous Tasks
- **frontend:** Update dependencies. ([06cf02f4](https://github.com/elisiariocouto/leggen/commit/06cf02f43ff72e4e01692e3a94a06be48d9acb1f))
## 2025.9.10 (2025/09/13)
### Miscellaneous Tasks
- **frontend:** Update dependencies. ([06cf02f4](https://github.com/elisiariocouto/leggen/commit/06cf02f43ff72e4e01692e3a94a06be48d9acb1f))
## 2025.9.9 (2025/09/11)
### Bug Fixes
- **core:** Handle permission errors gracefully in database path creation. ([4006dd12](https://github.com/elisiariocouto/leggen/commit/4006dd128e0896b338cb93fad60a1eca90c1873d))
### Features
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1e94333d](https://github.com/elisiariocouto/leggen/commit/1e94333d8f0275542ae7fd6e49fb8b7f03ad3d11))
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1c901a9d](https://github.com/elisiariocouto/leggen/commit/1c901a9ddab0f6515dce56df8cce74518805a6bb))
- Remove config.toml file - should be created when needed ([a5d10b35](https://github.com/elisiariocouto/leggen/commit/a5d10b3539e7cfc649b0fee05b12c4a03681e135))
### Refactor
- **core:** Integrate directory creation with database path retrieval and remove backup file. ([7d9744a4](https://github.com/elisiariocouto/leggen/commit/7d9744a40e7898e5bbe52e2e9f54317aa5c1cdd6))
## 2025.9.9 (2025/09/11)
### Bug Fixes
- **core:** Handle permission errors gracefully in database path creation. ([4006dd12](https://github.com/elisiariocouto/leggen/commit/4006dd128e0896b338cb93fad60a1eca90c1873d))
### Features
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1e94333d](https://github.com/elisiariocouto/leggen/commit/1e94333d8f0275542ae7fd6e49fb8b7f03ad3d11))
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1c901a9d](https://github.com/elisiariocouto/leggen/commit/1c901a9ddab0f6515dce56df8cce74518805a6bb))
- Remove config.toml file - should be created when needed ([a5d10b35](https://github.com/elisiariocouto/leggen/commit/a5d10b3539e7cfc649b0fee05b12c4a03681e135))
### Refactor
- **core:** Integrate directory creation with database path retrieval and remove backup file. ([7d9744a4](https://github.com/elisiariocouto/leggen/commit/7d9744a40e7898e5bbe52e2e9f54317aa5c1cdd6))
## 2025.9.8 (2025/09/11)
### Bug Fixes
- Change branch name from develop to dev in CI workflow ([f4bf549b](https://github.com/elisiariocouto/leggen/commit/f4bf549b99197d70104abf5731ab1ccb67cc9a69))
### Features
- Update CI workflow to use Node.js 20 instead of 18 ([e4e04ea3](https://github.com/elisiariocouto/leggen/commit/e4e04ea34ea568c08292562243b6e6c08234d918))
## 2025.9.8 (2025/09/11)
### Bug Fixes
- Change branch name from develop to dev in CI workflow ([f4bf549b](https://github.com/elisiariocouto/leggen/commit/f4bf549b99197d70104abf5731ab1ccb67cc9a69))
### Features
- Update CI workflow to use Node.js 20 instead of 18 ([e4e04ea3](https://github.com/elisiariocouto/leggen/commit/e4e04ea34ea568c08292562243b6e6c08234d918))
## 2025.9.7 (2025/09/11)
### Bug Fixes
- Simplify notification settings and fix notification test on dashboard. ([91020e32](https://github.com/elisiariocouto/leggen/commit/91020e32ea836ee8af4aeaf5d49525c24b566aed))
### Features
- **frontend:** Implement TanStack Table for transactions view ([544527f2](https://github.com/elisiariocouto/leggen/commit/544527f28284fb9644bec6e721fa5da8ce10739f))
- Improve transactions API pagination and search ([2d6800ef](https://github.com/elisiariocouto/leggen/commit/2d6800eff8e484d3d175225f94d854706584a773))
## 2025.9.7 (2025/09/11)
### Bug Fixes
- Simplify notification settings and fix notification test on dashboard. ([91020e32](https://github.com/elisiariocouto/leggen/commit/91020e32ea836ee8af4aeaf5d49525c24b566aed))
### Features
- **frontend:** Implement TanStack Table for transactions view ([544527f2](https://github.com/elisiariocouto/leggen/commit/544527f28284fb9644bec6e721fa5da8ce10739f))
- Improve transactions API pagination and search ([2d6800ef](https://github.com/elisiariocouto/leggen/commit/2d6800eff8e484d3d175225f94d854706584a773))
## 2025.9.6 (2025/09/10)
### Features
- **db:** Migrate transactions table to composite primary key ([a00d6ce2](https://github.com/elisiariocouto/leggen/commit/a00d6ce2ce2c4a070e9fae56c0cea58b3aab6cec))
## 2025.9.6 (2025/09/10)
### Features
- **db:** Migrate transactions table to composite primary key ([a00d6ce2](https://github.com/elisiariocouto/leggen/commit/a00d6ce2ce2c4a070e9fae56c0cea58b3aab6cec))
## 2025.9.5 (2025/09/10)
### Bug Fixes
- Correct composite key migration check ([c0ee21d6](https://github.com/elisiariocouto/leggen/commit/c0ee21d6fa8d5d61c029bd9334a7674fce99f729))
## 2025.9.5 (2025/09/10)
### Bug Fixes
- Correct composite key migration check ([c0ee21d6](https://github.com/elisiariocouto/leggen/commit/c0ee21d6fa8d5d61c029bd9334a7674fce99f729))
## 2025.9.4 (2025/09/10)
### Bug Fixes
- **api:** Resolve duplicate transactions with composite key migration ([13e92ccd](https://github.com/elisiariocouto/leggen/commit/13e92ccd3497bacf3b8639f6332cd3f4b682bd0a))
### Features
- **api:** Add currency extraction and account name updates ([d9c50d12](https://github.com/elisiariocouto/leggen/commit/d9c50d129825529e0fb6477e5b62c0f990523bca))
- **frontend:** Adapt to composite key transaction structure ([61fafecb](https://github.com/elisiariocouto/leggen/commit/61fafecb780a877a69ecca27ea95a1494669b70d))
- **frontend:** Add account name editing functionality ([aa97f368](https://github.com/elisiariocouto/leggen/commit/aa97f36819f15f1afc34f45642abdc6e2ce6c883))
- **frontend:** Implement TanStack Router with mobile sidebar ([ca41b7af](https://github.com/elisiariocouto/leggen/commit/ca41b7af0a5e50e0350857a4ace7979b7b29eab2))
- **web:** Add modal to view raw transaction. ([433ba3fa](https://github.com/elisiariocouto/leggen/commit/433ba3faf9937613786e66e9ee13152f96d00c43))
## 2025.9.4 (2025/09/10)
### Bug Fixes
- **api:** Resolve duplicate transactions with composite key migration ([13e92ccd](https://github.com/elisiariocouto/leggen/commit/13e92ccd3497bacf3b8639f6332cd3f4b682bd0a))
### Features
- **api:** Add currency extraction and account name updates ([d9c50d12](https://github.com/elisiariocouto/leggen/commit/d9c50d129825529e0fb6477e5b62c0f990523bca))
- **frontend:** Adapt to composite key transaction structure ([61fafecb](https://github.com/elisiariocouto/leggen/commit/61fafecb780a877a69ecca27ea95a1494669b70d))
- **frontend:** Add account name editing functionality ([aa97f368](https://github.com/elisiariocouto/leggen/commit/aa97f36819f15f1afc34f45642abdc6e2ce6c883))
- **frontend:** Implement TanStack Router with mobile sidebar ([ca41b7af](https://github.com/elisiariocouto/leggen/commit/ca41b7af0a5e50e0350857a4ace7979b7b29eab2))
- **web:** Add modal to view raw transaction. ([433ba3fa](https://github.com/elisiariocouto/leggen/commit/433ba3faf9937613786e66e9ee13152f96d00c43))
## 2025.9.4 (2025/09/10)
### Bug Fixes
- **api:** Resolve duplicate transactions with composite key migration ([13e92ccd](https://github.com/elisiariocouto/leggen/commit/13e92ccd3497bacf3b8639f6332cd3f4b682bd0a))
### Features
- **api:** Add currency extraction and account name updates ([d9c50d12](https://github.com/elisiariocouto/leggen/commit/d9c50d129825529e0fb6477e5b62c0f990523bca))
- **frontend:** Adapt to composite key transaction structure ([61fafecb](https://github.com/elisiariocouto/leggen/commit/61fafecb780a877a69ecca27ea95a1494669b70d))
- **frontend:** Add account name editing functionality ([aa97f368](https://github.com/elisiariocouto/leggen/commit/aa97f36819f15f1afc34f45642abdc6e2ce6c883))
- **frontend:** Implement TanStack Router with mobile sidebar ([ca41b7af](https://github.com/elisiariocouto/leggen/commit/ca41b7af0a5e50e0350857a4ace7979b7b29eab2))
- **web:** Add modal to view raw transaction. ([433ba3fa](https://github.com/elisiariocouto/leggen/commit/433ba3faf9937613786e66e9ee13152f96d00c43))
## 2025.9.3 (2025/09/10)
### Miscellaneous Tasks
- **ci:** Fix GitHub Actions syntax. ([90e58734](https://github.com/elisiariocouto/leggen/commit/90e58734adb9638efd695719321874658529561d))
## 2025.9.3 (2025/09/10)
### Miscellaneous Tasks
- **ci:** Fix GitHub Actions syntax. ([90e58734](https://github.com/elisiariocouto/leggen/commit/90e58734adb9638efd695719321874658529561d))
## 2025.9.2 (2025/09/10)
### Bug Fixes
- **ci:** Prevent duplicate Docker tags in GitHub Actions ([53e08e8e](https://github.com/elisiariocouto/leggen/commit/53e08e8e4b909b4895b5a447cfbce515893d31a5))
### Features
- **docker:** Add Docker containerization for React frontend ([84fe79b3](https://github.com/elisiariocouto/leggen/commit/84fe79b37b4f154fa0758f8d037cdba0d166dd3b))
## 2025.9.2 (2025/09/10)
### Bug Fixes
- **ci:** Prevent duplicate Docker tags in GitHub Actions ([53e08e8e](https://github.com/elisiariocouto/leggen/commit/53e08e8e4b909b4895b5a447cfbce515893d31a5))
### Features
- **docker:** Add Docker containerization for React frontend ([84fe79b3](https://github.com/elisiariocouto/leggen/commit/84fe79b37b4f154fa0758f8d037cdba0d166dd3b))
## 2025.9.1 (2025/09/09)
### Bug Fixes
- Handle duplicate transactionId values in migration ([8fabaf7b](https://github.com/elisiariocouto/leggen/commit/8fabaf7b86fde921c61266568ecb0403d3102671))
### Miscellaneous Tasks
- Improve AGENTS.md. ([3270dc45](https://github.com/elisiariocouto/leggen/commit/3270dc4585e6b33d55aef0deecd849753d36fa74))
### Refactor
- Remove unused hide_missing_ids functionality ([8006e5e1](https://github.com/elisiariocouto/leggen/commit/8006e5e1f6373aae39d3c38068d694e142bc85a5))
## 2025.9.1 (2025/09/09)
### Bug Fixes
- Handle duplicate transactionId values in migration ([8fabaf7b](https://github.com/elisiariocouto/leggen/commit/8fabaf7b86fde921c61266568ecb0403d3102671))
### Miscellaneous Tasks
- Improve AGENTS.md. ([3270dc45](https://github.com/elisiariocouto/leggen/commit/3270dc4585e6b33d55aef0deecd849753d36fa74))
### Refactor
- Remove unused hide_missing_ids functionality ([8006e5e1](https://github.com/elisiariocouto/leggen/commit/8006e5e1f6373aae39d3c38068d694e142bc85a5))
## 2025.9.0 (2025/09/09)
### Bug Fixes
- **cli:** Show transactions without internal ID when using --full. ([46f3f5c4](https://github.com/elisiariocouto/leggen/commit/46f3f5c4984224c3f4b421e1a06dcf44d4f211e0))
- Do not install development dependencies. ([73d6bd32](https://github.com/elisiariocouto/leggen/commit/73d6bd32dbc59608ef1472dc65d9e18450f00896))
- Implement proper GoCardless authentication and add dev features ([f0fee4fd](https://github.com/elisiariocouto/leggen/commit/f0fee4fd82e1c788614d73fcd0075f5e16976650))
- Make internal transcation ID optional. ([6bce7eb6](https://github.com/elisiariocouto/leggen/commit/6bce7eb6be5f9a5286eb27e777fbf83a6b1c5f8d))
- Resolve 404 balances endpoint and currency formatting errors ([417b7753](https://github.com/elisiariocouto/leggen/commit/417b77539fc275493d55efb29f92abcea666b210))
- Merge account details into balance data to prevent unknown/N/A values ([eaaea6e4](https://github.com/elisiariocouto/leggen/commit/eaaea6e4598e9c81997573e19f4ef1c58ebe320f))
- Use account status for balance records instead of hardcoded 'active' ([541cb262](https://github.com/elisiariocouto/leggen/commit/541cb262ee5783eedf2b154c148c28ec89845da5))
### Documentation
- Update README for new web architecture ([4018b263](https://github.com/elisiariocouto/leggen/commit/4018b263f27c2b59af31428d7a0878280a291c85))
### Features
- Transform to web architecture with FastAPI backend ([91f53b35](https://github.com/elisiariocouto/leggen/commit/91f53b35b18740869ee9cebfac394db2e12db099))
- Add comprehensive test suite with 46 passing tests ([34e793c7](https://github.com/elisiariocouto/leggen/commit/34e793c75c8df1e57ea240b92ccf0843a80c2a14))
- Add mypy to pre-commit. ([ec8ef834](https://github.com/elisiariocouto/leggen/commit/ec8ef8346add878f3ff4e8ed928b952d9b5dd584))
- Implement database-first architecture to minimize GoCardless API calls ([155c3055](https://github.com/elisiariocouto/leggen/commit/155c30559f4cacd76ef01e50ec29ee436d3f9d56))
- Implement dynamic API connection status ([cb2e70e4](https://github.com/elisiariocouto/leggen/commit/cb2e70e42d1122e9c2e5420b095aeb1e55454c24))
- Add automatic balance timestamp migration mechanism ([34501f5f](https://github.com/elisiariocouto/leggen/commit/34501f5f0d3b3dff68364b60be77bfb99071b269))
- Improve notification filters configuration format ([2191fe90](https://github.com/elisiariocouto/leggen/commit/2191fe906659f4fd22c25b6cb9fbb95c03472f00))
- Add notifications view and update branding ([abf39abe](https://github.com/elisiariocouto/leggen/commit/abf39abe74b75d8cb980109fbcbdd940066cc90b))
- Make API URL configurable and improve code quality ([37949a4e](https://github.com/elisiariocouto/leggen/commit/37949a4e1f25a2656f6abef75ba942f7b205c130))
- Change versioning scheme to calver. ([f2e05484](https://github.com/elisiariocouto/leggen/commit/f2e05484dc688409b6db6bd16858b066d3a16976))
### Miscellaneous Tasks
- Implement code review suggestions and format code. ([de3da84d](https://github.com/elisiariocouto/leggen/commit/de3da84dffd83e0b232cf76836935a66eb704aee))
### Refactor
- Remove MongoDB support, simplify to SQLite-only architecture ([47164e85](https://github.com/elisiariocouto/leggen/commit/47164e854600dfcac482449769b1d2e55c842570))
- Remove unused amount_threshold and keywords from notification filters ([95709978](https://github.com/elisiariocouto/leggen/commit/957099786cb0e48c9ffbda11b3172ec9fae9ac37))
## 2025.9.0 (2025/09/09)
### Bug Fixes
- **cli:** Show transactions without internal ID when using --full. ([46f3f5c4](https://github.com/elisiariocouto/leggen/commit/46f3f5c4984224c3f4b421e1a06dcf44d4f211e0))
- Do not install development dependencies. ([73d6bd32](https://github.com/elisiariocouto/leggen/commit/73d6bd32dbc59608ef1472dc65d9e18450f00896))
- Implement proper GoCardless authentication and add dev features ([f0fee4fd](https://github.com/elisiariocouto/leggen/commit/f0fee4fd82e1c788614d73fcd0075f5e16976650))
- Make internal transcation ID optional. ([6bce7eb6](https://github.com/elisiariocouto/leggen/commit/6bce7eb6be5f9a5286eb27e777fbf83a6b1c5f8d))
- Resolve 404 balances endpoint and currency formatting errors ([417b7753](https://github.com/elisiariocouto/leggen/commit/417b77539fc275493d55efb29f92abcea666b210))
- Merge account details into balance data to prevent unknown/N/A values ([eaaea6e4](https://github.com/elisiariocouto/leggen/commit/eaaea6e4598e9c81997573e19f4ef1c58ebe320f))
- Use account status for balance records instead of hardcoded 'active' ([541cb262](https://github.com/elisiariocouto/leggen/commit/541cb262ee5783eedf2b154c148c28ec89845da5))
### Documentation
- Update README for new web architecture ([4018b263](https://github.com/elisiariocouto/leggen/commit/4018b263f27c2b59af31428d7a0878280a291c85))
### Features
- Transform to web architecture with FastAPI backend ([91f53b35](https://github.com/elisiariocouto/leggen/commit/91f53b35b18740869ee9cebfac394db2e12db099))
- Add comprehensive test suite with 46 passing tests ([34e793c7](https://github.com/elisiariocouto/leggen/commit/34e793c75c8df1e57ea240b92ccf0843a80c2a14))
- Add mypy to pre-commit. ([ec8ef834](https://github.com/elisiariocouto/leggen/commit/ec8ef8346add878f3ff4e8ed928b952d9b5dd584))
- Implement database-first architecture to minimize GoCardless API calls ([155c3055](https://github.com/elisiariocouto/leggen/commit/155c30559f4cacd76ef01e50ec29ee436d3f9d56))
- Implement dynamic API connection status ([cb2e70e4](https://github.com/elisiariocouto/leggen/commit/cb2e70e42d1122e9c2e5420b095aeb1e55454c24))
- Add automatic balance timestamp migration mechanism ([34501f5f](https://github.com/elisiariocouto/leggen/commit/34501f5f0d3b3dff68364b60be77bfb99071b269))
- Improve notification filters configuration format ([2191fe90](https://github.com/elisiariocouto/leggen/commit/2191fe906659f4fd22c25b6cb9fbb95c03472f00))
- Add notifications view and update branding ([abf39abe](https://github.com/elisiariocouto/leggen/commit/abf39abe74b75d8cb980109fbcbdd940066cc90b))
- Make API URL configurable and improve code quality ([37949a4e](https://github.com/elisiariocouto/leggen/commit/37949a4e1f25a2656f6abef75ba942f7b205c130))
- Change versioning scheme to calver. ([f2e05484](https://github.com/elisiariocouto/leggen/commit/f2e05484dc688409b6db6bd16858b066d3a16976))
### Miscellaneous Tasks
- Implement code review suggestions and format code. ([de3da84d](https://github.com/elisiariocouto/leggen/commit/de3da84dffd83e0b232cf76836935a66eb704aee))
### Refactor
- Remove MongoDB support, simplify to SQLite-only architecture ([47164e85](https://github.com/elisiariocouto/leggen/commit/47164e854600dfcac482449769b1d2e55c842570))
- Remove unused amount_threshold and keywords from notification filters ([95709978](https://github.com/elisiariocouto/leggen/commit/957099786cb0e48c9ffbda11b3172ec9fae9ac37))
## 0.6.11 (2025/02/23)
### Bug Fixes

View File

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

View File

@@ -1,19 +1,9 @@
# Contributing
This project uses **uv** for Python dependency management and **shadcn/ui** for frontend components.
## Setup
Install uv and run `uv sync` to install dependencies.
Install Poetry and run `poetry install` to install dependencies. Then run `poetry shell` to activate the virtual environment.
Run `pre-commit install` to install the pre-commit hooks.
## Frontend Development
The frontend uses shadcn/ui components for consistent design. When adding new UI components:
- Check if a shadcn/ui component exists for your use case
- Follow the existing component patterns in `frontend/src/components/ui/`
- Use Tailwind CSS classes for styling
- Ensure components are accessible and follow the design system
## Commit messages
type(scope/[subscope]): Title starting with uppercase and sentence ending with period.

View File

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

248
README.md
View File

@@ -1,29 +1,26 @@
# 💲 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 **FastAPI backend service** (`leggend`), a **React Web Interface** and a **command-line interface** (`leggen`) to connect to banks using the GoCardless Open Banking API.
Having your bank data accessible through both CLI and REST API gives you the power to backup, analyze, create reports, and integrate with other applications.
![Leggen demo](docs/leggen_demo.gif)
## 🛠️ 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`)
- [FastAPI](https://fastapi.tiangolo.com/): High-performance async API backend (`leggend` service)
- [GoCardless Open Banking API](https://developer.gocardless.com/bank-account-data/overview): for connecting to banks
- [APScheduler](https://apscheduler.readthedocs.io/): Background job scheduling with configurable cron
### 📦 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
- [TanStack Query](https://tanstack.com/query): Powerful data synchronization for React
## ✨ Features
@@ -56,9 +53,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
@@ -66,19 +64,33 @@ git clone https://github.com/elisiariocouto/leggen.git
cd leggen
# Create your configuration
mkdir -p data && cp config.example.toml data/config.toml
# Edit data/config.toml with your GoCardless credentials
mkdir -p leggen && cp config.example.toml leggen/config.toml
# Edit leggen/config.toml with your GoCardless credentials
# Start all services
# 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
```
#### 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 leggend --reload # Development mode with auto-reload
# Use the CLI (in another terminal)
uv run leggen --help
```
### Configuration
Create a configuration file at `./data/config.toml` (for Docker) or `~/.config/leggen/config.toml` (for local development):
Create a configuration file at `~/.config/leggen/config.toml`:
```toml
[gocardless]
@@ -109,28 +121,202 @@ enabled = true
# Optional: Transaction filters for notifications
[filters]
case_insensitive = ["salary", "utility"]
case_sensitive = ["SpecificStore"]
case-insensitive = ["salary", "utility"]
case-sensitive = ["SpecificStore"]
```
## 📖 Usage
### Web Interface
Access the React web interface at `http://localhost:3000` after starting the services.
### API Service (`leggend`)
### 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
leggend
# Development mode with auto-reload
leggend --reload
# Custom host and port
leggend --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 LEGGEND_API_URL=http://localhost:8080
leggen status
```
### Docker Usage
```bash
# Start all services (frontend + backend)
docker compose up -d
# View service status
docker compose ps
# Check logs
docker compose logs frontend
docker compose logs leggend
# Access the web interface at http://localhost:3000
# API documentation at http://localhost:8000/docs
# Stop all 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 leggend --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_client.py # API client for leggend service
leggend/ # FastAPI backend service
├── api/ # API routes and models
├── services/ # Business logic
├── background/ # Background job scheduler
└── main.py # FastAPI application
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
## ⚠️ 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

View File

@@ -1,25 +0,0 @@
services:
# React frontend service
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
restart: "unless-stopped"
ports:
- "127.0.0.1:3000:80"
environment:
- API_BACKEND_URL=${API_BACKEND_URL:-http://leggen-server:8000}
depends_on:
leggen-server:
condition: service_healthy
# FastAPI backend service
leggen-server:
build:
context: .
dockerfile: Dockerfile
restart: "unless-stopped"
ports:
- "127.0.0.1:8000:8000"
volumes:
- "./data:/root/.config/leggen"

View File

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

View File

@@ -1,40 +0,0 @@
[gocardless]
key = "your-api-key"
secret = "your-secret-key"
url = "https://bankaccountdata.gocardless.com/api/v2"
[database]
sqlite = true
# Optional: Background sync scheduling
[scheduler.sync]
enabled = true
hour = 3 # 3 AM
minute = 0
# cron = "0 3 * * *" # Alternative: use cron expression
# Optional: Discord notifications
[notifications.discord]
webhook = "https://discord.com/api/webhooks/..."
enabled = true
# Optional: Telegram notifications
[notifications.telegram]
token = "your-bot-token"
chat_id = 12345
enabled = true
# Optional: Transaction filters for notifications
[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
frontend/.gitignore vendored
View File

@@ -10,7 +10,6 @@ lerna-debug.log*
node_modules
dist
dist-ssr
dev-dist
*.local
# Editor directories and files

View File

@@ -25,7 +25,7 @@ COPY --from=builder /app/dist /usr/share/nginx/html
COPY default.conf.template /etc/nginx/templates/default.conf.template
# Set default API backend URL (can be overridden at runtime)
ENV API_BACKEND_URL=http://leggen-server:8000
ENV API_BACKEND_URL=http://leggend:8000
# Expose port 80
EXPOSE 80

View File

@@ -93,7 +93,7 @@ The frontend supports configurable API URLs through environment variables:
- Uses relative URLs (`/api/v1`) that nginx proxies to the backend
- Configure nginx proxy target via `API_BACKEND_URL` environment variable
- Default: `http://leggen-server:8000`
- Default: `http://leggend:8000`
**Docker Compose:**

View File

@@ -1,22 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

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

View File

@@ -6,7 +6,7 @@ import tseslint from "typescript-eslint";
import { globalIgnores } from "eslint/config";
export default tseslint.config([
globalIgnores(["dist", "dev-dist"]),
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
@@ -19,17 +19,5 @@ export default tseslint.config([
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
{
files: ["src/components/**/*.{ts,tsx}", "src/contexts/**/*.{ts,tsx}"],
rules: {
"react-refresh/only-export-components": "off",
},
},
]);

View File

@@ -2,49 +2,9 @@
<html lang="en">
<head>
<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"
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Leggen</title>
<!-- PWA Meta Tags -->
<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" />
<meta name="apple-mobile-web-app-title" content="Leggen" />
<meta name="format-detection" content="telephone=no" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="msapplication-config" content="/browserconfig.xml" />
<meta name="msapplication-TileColor" content="#0b74de" />
<meta name="msapplication-tap-highlight" content="no" />
<!-- 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"
/>
<!-- Icons -->
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
<link rel="mask-icon" href="/favicon.svg" color="#0b74de" />
<link rel="shortcut icon" href="/favicon.ico" />
<!-- Manifest -->
<link rel="manifest" href="/manifest.webmanifest" />
</head>
<body>
<div id="root"></div>

10850
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,73 +4,34 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "VITE_API_URL=http://localhost:8000/api/v1 vite",
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@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",
"@radix-ui/react-tooltip": "^1.2.8",
"@tabler/icons-react": "^3.35.0",
"@tailwindcss/forms": "^0.5.10",
"@tanstack/react-query": "^5.87.1",
"@tanstack/react-router": "^1.131.36",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-cli": "^1.131.36",
"autoprefixer": "^10.4.21",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.542.0",
"next-themes": "^0.4.6",
"postcss": "^8.5.6",
"react": "^19.1.1",
"react-day-picker": "^9.10.0",
"react-dom": "^19.1.1",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"zod": "^3.25.76"
"tailwindcss": "^3.4.17"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@tanstack/router-vite-plugin": "^1.131.36",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vite-pwa/assets-generator": "^1.0.1",
"@vitejs/plugin-react": "^5.0.0",
"eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"shadcn": "^3.3.1",
"sharp": "^0.34.3",
"typescript": "~5.8.3",
"typescript-eslint": "^8.39.1",
"vite": "^7.1.2",
"vite-plugin-pwa": "^1.0.3"
"vite": "^7.1.2"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/pwa-192x192.png"/>
<TileColor>#3B82F6</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 813 B

View File

@@ -1,27 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg"
width="32" height="32"
viewBox="0 0 32 32"
role="img" aria-labelledby="title desc">
<title id="title">leggen — stylized italic L</title>
<desc id="desc">Square gradient background with italic white L.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0b74de"/>
<stop offset="100%" stop-color="#06b6d4"/>
</linearGradient>
</defs>
<!-- Square background -->
<rect width="32" height="32" fill="url(#bg)" rx="4"/>
<!-- Italic L -->
<text x="11" y="22"
font-family="Inter, Roboto, Arial, sans-serif"
font-weight="700"
font-size="20"
font-style="italic"
fill="#fff">
L
</text>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#3B82F6"/>
<path d="M8 24V8h6c2.2 0 4 1.8 4 4v4c0 2.2-1.8 4-4 4H12v4H8zm4-8h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-2v4z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 769 B

After

Width:  |  Height:  |  Size: 257 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 701 B

View File

@@ -1,4 +0,0 @@
User-agent: *
Allow: /
Sitemap: /sitemap.xml

View File

@@ -1,4 +0,0 @@
{
"preset": "minimal-2023",
"images": ["public/favicon.svg"]
}

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

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

View File

@@ -1,373 +0,0 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
CreditCard,
TrendingUp,
TrendingDown,
Building2,
RefreshCw,
AlertCircle,
Edit2,
Check,
X,
Plus,
} from "lucide-react";
import { apiClient } from "../lib/api";
import { formatCurrency, formatDate } from "../lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./ui/card";
import { Button } from "./ui/button";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import AccountsSkeleton from "./AccountsSkeleton";
import type { Account, Balance } from "../types/api";
// Helper function to get status indicator color and styles
const getStatusIndicator = (status: string) => {
const statusLower = status.toLowerCase();
switch (statusLower) {
case "ready":
return {
color: "bg-green-500",
tooltip: "Ready",
};
case "pending":
return {
color: "bg-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 AccountSettings() {
const {
data: accounts,
isLoading: accountsLoading,
error: accountsError,
refetch: refetchAccounts,
} = useQuery<Account[]>({
queryKey: ["accounts"],
queryFn: apiClient.getAccounts,
});
const { data: balances } = useQuery<Balance[]>({
queryKey: ["balances"],
queryFn: () => apiClient.getBalances(),
});
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
const queryClient = useQueryClient();
const updateAccountMutation = useMutation({
mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
apiClient.updateAccount(id, { display_name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["accounts"] });
setEditingAccountId(null);
setEditingName("");
},
onError: (error) => {
console.error("Failed to update account:", error);
},
});
const handleEditStart = (account: Account) => {
setEditingAccountId(account.id);
// Use display_name if available, otherwise fall back to name
setEditingName(account.display_name || account.name || "");
};
const handleEditSave = () => {
if (editingAccountId && editingName.trim()) {
updateAccountMutation.mutate({
id: editingAccountId,
display_name: editingName.trim(),
});
}
};
const handleEditCancel = () => {
setEditingAccountId(null);
setEditingName("");
};
if (accountsLoading) {
return <AccountsSkeleton />;
}
if (accountsError) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Failed to load accounts</AlertTitle>
<AlertDescription className="space-y-3">
<p>
Unable to connect to the Leggen API. Please check your configuration
and ensure the API server is running.
</p>
<Button onClick={() => refetchAccounts()} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</AlertDescription>
</Alert>
);
}
return (
<div 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>
<Button disabled className="flex items-center space-x-2">
<Plus className="h-4 w-4" />
<span>Add Bank Account</span>
</Button>
<p className="text-xs text-muted-foreground mt-2">
Coming soon: Add new bank connections
</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-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>
<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">
{/* Mobile: date/status on left, balance on right */}
{/* Desktop: balance on top, date/status on bottom */}
{/* Date and status indicator - left on mobile, bottom on desktop */}
<div className="flex items-center space-x-2 order-1 sm:order-2">
<div
className={`w-3 h-3 rounded-full ${getStatusIndicator(account.status).color} relative group cursor-help`}
role="img"
aria-label={`Account status: ${getStatusIndicator(account.status).tooltip}`}
>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 hidden group-hover:block bg-gray-900 text-white text-xs rounded py-1 px-2 whitespace-nowrap z-10">
{getStatusIndicator(account.status).tooltip}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-2 border-transparent border-t-gray-900"></div>
</div>
</div>
<p className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
Updated{" "}
{formatDate(
account.last_accessed || account.created,
)}
</p>
</div>
{/* Balance - right on mobile, top on desktop */}
<div className="flex items-center space-x-2 order-2 sm:order-1">
{isPositive ? (
<TrendingUp className="h-4 w-4 text-green-500" />
) : (
<TrendingDown className="h-4 w-4 text-red-500" />
)}
<p
className={`text-base sm:text-lg font-semibold ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{formatCurrency(balance, currency)}
</p>
</div>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
)}
</Card>
{/* Add Bank Section (Future Feature) */}
<Card>
<CardHeader>
<CardTitle>Add New Bank Account</CardTitle>
<CardDescription>
Connect additional bank accounts to track all your finances in one
place
</CardDescription>
</CardHeader>
<CardContent className="p-6">
<div className="text-center space-y-4">
<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!
</p>
</div>
<Button disabled variant="outline">
<Plus className="h-4 w-4 mr-2" />
Connect Bank Account
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,5 +1,4 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import {
CreditCard,
TrendingUp,
@@ -7,58 +6,12 @@ import {
Building2,
RefreshCw,
AlertCircle,
Edit2,
Check,
X,
} from "lucide-react";
import { apiClient } from "../lib/api";
import { formatCurrency, formatDate } from "../lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./ui/card";
import { Button } from "./ui/button";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import AccountsSkeleton from "./AccountsSkeleton";
import LoadingSpinner from "./LoadingSpinner";
import type { Account, Balance } from "../types/api";
// Helper function to get status indicator color and styles
const getStatusIndicator = (status: string) => {
const statusLower = status.toLowerCase();
switch (statusLower) {
case "ready":
return {
color: "bg-green-500",
tooltip: "Ready",
};
case "pending":
return {
color: "bg-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 AccountsOverview() {
const {
data: accounts,
@@ -75,64 +28,37 @@ export default function AccountsOverview() {
queryFn: () => apiClient.getBalances(),
});
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const queryClient = useQueryClient();
const updateAccountMutation = useMutation({
mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
apiClient.updateAccount(id, { display_name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["accounts"] });
setEditingAccountId(null);
setEditingName("");
},
onError: (error) => {
console.error("Failed to update account:", error);
},
});
const handleEditStart = (account: Account) => {
setEditingAccountId(account.id);
// Use display_name if available, otherwise fall back to name
setEditingName(account.display_name || account.name || "");
};
const handleEditSave = () => {
if (editingAccountId && editingName.trim()) {
updateAccountMutation.mutate({
id: editingAccountId,
display_name: editingName.trim(),
});
}
};
const handleEditCancel = () => {
setEditingAccountId(null);
setEditingName("");
};
if (accountsLoading) {
return <AccountsSkeleton />;
return (
<div className="bg-white rounded-lg shadow">
<LoadingSpinner message="Loading accounts..." />
</div>
);
}
if (accountsError) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Failed to load accounts</AlertTitle>
<AlertDescription className="space-y-3">
<p>
Unable to connect to the Leggen API. Please check your configuration
and ensure the API server is running.
</p>
<Button onClick={() => refetchAccounts()} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</AlertDescription>
</Alert>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-center text-center">
<div>
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Failed to load accounts
</h3>
<p className="text-gray-600 mb-4">
Unable to connect to the Leggen API. Please check your
configuration and ensure the API server is running.
</p>
<button
onClick={() => refetchAccounts()}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</button>
</div>
</div>
</div>
);
}
@@ -150,237 +76,139 @@ export default function AccountsOverview() {
<div className="space-y-6">
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
Total Balance
</p>
<p className="text-2xl font-bold text-foreground">
{formatCurrency(totalBalance)}
</p>
</div>
<div className="p-3 bg-green-100 dark:bg-green-900/20 rounded-full">
<TrendingUp className="h-6 w-6 text-green-600" />
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total Balance</p>
<p className="text-2xl font-bold text-gray-900">
{formatCurrency(totalBalance)}
</p>
</div>
</CardContent>
</Card>
<div className="p-3 bg-green-100 rounded-full">
<TrendingUp className="h-6 w-6 text-green-600" />
</div>
</div>
</div>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
Total Accounts
</p>
<p className="text-2xl font-bold text-foreground">
{totalAccounts}
</p>
</div>
<div className="p-3 bg-blue-100 dark:bg-blue-900/20 rounded-full">
<CreditCard className="h-6 w-6 text-blue-600" />
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">
Total Accounts
</p>
<p className="text-2xl font-bold text-gray-900">
{totalAccounts}
</p>
</div>
</CardContent>
</Card>
<div className="p-3 bg-blue-100 rounded-full">
<CreditCard className="h-6 w-6 text-blue-600" />
</div>
</div>
</div>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
Connected Banks
</p>
<p className="text-2xl font-bold text-foreground">
{uniqueBanks}
</p>
</div>
<div className="p-3 bg-muted rounded-full">
<Building2 className="h-6 w-6 text-muted-foreground" />
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">
Connected Banks
</p>
<p className="text-2xl font-bold text-gray-900">{uniqueBanks}</p>
</div>
</CardContent>
</Card>
<div className="p-3 bg-purple-100 rounded-full">
<Building2 className="h-6 w-6 text-purple-600" />
</div>
</div>
</div>
</div>
{/* Accounts List */}
<Card>
<CardHeader>
<CardTitle>Bank Accounts</CardTitle>
<CardDescription>Manage your connected bank accounts</CardDescription>
</CardHeader>
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">Bank Accounts</h3>
<p className="text-sm text-gray-600">
Manage your connected bank accounts
</p>
</div>
{!accounts || accounts.length === 0 ? (
<CardContent className="p-6 text-center">
<CreditCard className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
<div className="p-6 text-center">
<CreditCard className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
No accounts found
</h3>
<p className="text-muted-foreground">
<p className="text-gray-600">
Connect your first bank account to get started with Leggen.
</p>
</CardContent>
</div>
) : (
<CardContent className="p-0">
<div className="divide-y divide-border">
{accounts.map((account) => {
// Get balance from account's balances array or fallback to balances query
const accountBalance = account.balances?.[0];
const fallbackBalance = balances?.find(
(b) => b.account_id === account.id,
);
const balance =
accountBalance?.amount ||
fallbackBalance?.balance_amount ||
0;
const currency =
accountBalance?.currency ||
fallbackBalance?.currency ||
account.currency ||
"EUR";
const isPositive = balance >= 0;
<div className="divide-y divide-gray-200">
{accounts.map((account) => {
// Get balance from account's balances array or fallback to balances query
const accountBalance = account.balances?.[0];
const fallbackBalance = balances?.find(
(b) => b.account_id === account.id,
);
const balance =
accountBalance?.amount || fallbackBalance?.balance_amount || 0;
const currency =
accountBalance?.currency ||
fallbackBalance?.currency ||
account.currency ||
"EUR";
const isPositive = balance >= 0;
return (
<div
key={account.id}
className="p-4 sm:p-6 hover:bg-accent transition-colors"
>
{/* Mobile layout - stack vertically */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
<div className="flex-shrink-0 p-2 sm:p-3 bg-muted rounded-full">
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
{editingAccountId === account.id ? (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<input
type="text"
value={editingName}
onChange={(e) =>
setEditingName(e.target.value)
}
className="flex-1 px-3 py-1 text-base sm:text-lg font-medium border border-input rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
placeholder="Custom account name"
name="search"
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter") handleEditSave();
if (e.key === "Escape") handleEditCancel();
}}
autoFocus
/>
<Button
onClick={handleEditSave}
disabled={
!editingName.trim() ||
updateAccountMutation.isPending
}
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>
return (
<div
key={account.id}
className="p-6 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="p-3 bg-gray-100 rounded-full">
<Building2 className="h-6 w-6 text-gray-600" />
</div>
{/* Balance and date section */}
<div className="flex items-center justify-between sm:flex-col sm:items-end sm:text-right flex-shrink-0">
{/* Mobile: date/status on left, balance on right */}
{/* Desktop: balance on top, date/status on bottom */}
{/* Date and status indicator - left on mobile, bottom on desktop */}
<div className="flex items-center space-x-2 order-1 sm:order-2">
<div
className={`w-3 h-3 rounded-full ${getStatusIndicator(account.status).color} relative group cursor-help`}
role="img"
aria-label={`Account status: ${getStatusIndicator(account.status).tooltip}`}
>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 hidden group-hover:block bg-gray-900 text-white text-xs rounded py-1 px-2 whitespace-nowrap z-10">
{getStatusIndicator(account.status).tooltip}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-2 border-transparent border-t-gray-900"></div>
</div>
</div>
<p className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
Updated{" "}
{formatDate(
account.last_accessed || account.created,
)}
<div>
<h4 className="text-lg font-medium text-gray-900">
{account.name || "Unnamed Account"}
</h4>
<p className="text-sm text-gray-600">
{account.institution_id} {account.status}
</p>
{account.iban && (
<p className="text-xs text-gray-500 mt-1">
IBAN: {account.iban}
</p>
</div>
{/* Balance - right on mobile, top on desktop */}
<div className="flex items-center space-x-2 order-2 sm:order-1">
{isPositive ? (
<TrendingUp className="h-4 w-4 text-green-500" />
) : (
<TrendingDown className="h-4 w-4 text-red-500" />
)}
<p
className={`text-base sm:text-lg font-semibold ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{formatCurrency(balance, currency)}
</p>
</div>
)}
</div>
</div>
<div className="text-right">
<div className="flex items-center space-x-2">
{isPositive ? (
<TrendingUp className="h-4 w-4 text-green-500" />
) : (
<TrendingDown className="h-4 w-4 text-red-500" />
)}
<p
className={`text-lg font-semibold ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{formatCurrency(balance, currency)}
</p>
</div>
<p className="text-sm text-gray-500">
Updated{" "}
{formatDate(account.last_accessed || account.created)}
</p>
</div>
</div>
);
})}
</div>
</CardContent>
</div>
);
})}
</div>
)}
</Card>
</div>
</div>
);
}

View File

@@ -1,61 +0,0 @@
import { Skeleton } from "./ui/skeleton";
import { Card, CardContent, CardHeader } from "./ui/card";
export default function AccountsSkeleton() {
return (
<div className="space-y-6">
{/* Summary Cards Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-8 w-24" />
</div>
<Skeleton className="h-12 w-12 rounded-full" />
</div>
</CardContent>
</Card>
))}
</div>
{/* Accounts List Skeleton */}
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-48" />
</CardHeader>
<CardContent className="p-0">
<div className="divide-y divide-border">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="p-4 sm:p-6">
<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">
<Skeleton className="h-10 w-10 sm:h-12 sm:w-12 rounded-full flex-shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-40" />
</div>
</div>
<div className="flex items-center justify-between sm:flex-col sm:items-end sm:text-right flex-shrink-0">
<div className="flex items-center space-x-2 order-1 sm:order-2">
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-4 w-20" />
</div>
<div className="flex items-center space-x-2 order-2 sm:order-1">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-5 w-24" />
</div>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

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

View File

@@ -1,180 +0,0 @@
import React from "react";
import { Link, useLocation } from "@tanstack/react-router";
import {
List,
BarChart3,
Activity,
Settings,
Building2,
TrendingUp,
ChevronDown,
ChevronUp,
} from "lucide-react";
import { Logo } from "./ui/logo";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../lib/api";
import { formatCurrency } from "../lib/utils";
import { useState } from "react";
import type { Account } from "../types/api";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
useSidebar,
} from "./ui/sidebar";
const navigation = [
{ name: "Overview", icon: List, to: "/" },
{ name: "Analytics", icon: BarChart3, to: "/analytics" },
{ name: "System", icon: Activity, to: "/system" },
{ name: "Settings", icon: Settings, to: "/settings" },
];
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const location = useLocation();
const [accountsExpanded, setAccountsExpanded] = useState(false);
const { isMobile, setOpenMobile } = useSidebar();
const { data: accounts } = useQuery<Account[]>({
queryKey: ["accounts"],
queryFn: apiClient.getAccounts,
});
const totalBalance =
accounts?.reduce((sum, account) => {
const primaryBalance = account.balances?.[0]?.amount || 0;
return sum + primaryBalance;
}, 0) || 0;
// Handler to close mobile sidebar when navigation item is clicked
const handleNavigationClick = () => {
if (isMobile) {
setOpenMobile(false);
}
};
return (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="data-[slot=sidebar-menu-button]:!p-1.5"
>
<Link
to="/"
className="flex items-center space-x-2"
onClick={handleNavigationClick}
>
<Logo size={24} />
<span className="text-base font-semibold">Leggen</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{navigation.map((item) => (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
asChild
tooltip={item.name}
isActive={location.pathname === item.to}
>
<Link to={item.to} onClick={handleNavigationClick}>
<item.icon className="h-5 w-5" />
<span>{item.name}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
{/* Account Summary Section */}
<SidebarGroup>
<SidebarGroupLabel>Account Summary</SidebarGroupLabel>
<div className="bg-muted rounded-lg p-1">
{/* Collapsible Header */}
<button
onClick={() => setAccountsExpanded(!accountsExpanded)}
className="w-full p-3 flex items-center justify-between hover:bg-muted/80 transition-colors rounded-lg"
>
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-muted-foreground">
Total Balance
</span>
<TrendingUp className="h-4 w-4 text-green-500" />
</div>
{accountsExpanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
<div className="px-3 pb-2">
<p className="text-xl font-bold text-foreground">
{formatCurrency(totalBalance)}
</p>
<p className="text-sm text-muted-foreground">
{accounts?.length || 0} accounts
</p>
</div>
{/* Expanded Account Details */}
{accountsExpanded && accounts && accounts.length > 0 && (
<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";
return (
<div
key={account.id}
className="p-2 border-b border-border/30 last:border-b-0 hover:bg-muted/50 transition-colors"
>
<div className="flex items-start space-x-2">
<div className="flex-shrink-0 p-1 bg-background rounded">
<Building2 className="h-3 w-3 text-muted-foreground" />
</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"}
</p>
<p className="text-xs font-semibold text-foreground">
{formatCurrency(primaryBalance, currency)}
</p>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</SidebarGroup>
</SidebarFooter>
</Sidebar>
);
}

View File

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

View File

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

View File

@@ -1,9 +1,6 @@
import { Component } from "react";
import type { ErrorInfo, ReactNode } from "react";
import { AlertTriangle, RefreshCw } from "lucide-react";
import { Card, CardContent } from "./ui/card";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Button } from "./ui/button";
interface Props {
children: ReactNode;
@@ -42,49 +39,46 @@ class ErrorBoundary extends Component<Props, State> {
}
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-center text-center">
<div>
<AlertTriangle className="h-12 w-12 text-destructive mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
Something went wrong
</h3>
<p className="text-muted-foreground mb-4">
An error occurred while rendering this component. Please try
refreshing or check the console for more details.
</p>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-center text-center">
<div>
<AlertTriangle className="h-12 w-12 text-red-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Something went wrong
</h3>
<p className="text-gray-600 mb-4">
An error occurred while rendering this component. Please try
refreshing or check the console for more details.
</p>
{this.state.error && (
<Alert variant="destructive" className="mb-4 text-left">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error Details</AlertTitle>
<AlertDescription className="space-y-2">
<p className="text-sm font-mono">
<strong>Error:</strong> {this.state.error.message}
</p>
{this.state.error.stack && (
<details className="mt-2">
<summary className="text-sm cursor-pointer">
Stack trace
</summary>
<pre className="text-xs mt-1 whitespace-pre-wrap">
{this.state.error.stack}
</pre>
</details>
)}
</AlertDescription>
</Alert>
)}
{this.state.error && (
<div className="bg-red-50 border border-red-200 rounded-md p-3 mb-4 text-left">
<p className="text-sm font-mono text-red-800">
<strong>Error:</strong> {this.state.error.message}
</p>
{this.state.error.stack && (
<details className="mt-2">
<summary className="text-sm text-red-600 cursor-pointer">
Stack trace
</summary>
<pre className="text-xs text-red-700 mt-1 whitespace-pre-wrap">
{this.state.error.stack}
</pre>
</details>
)}
</div>
)}
<Button onClick={this.handleReset}>
<RefreshCw className="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
<button
onClick={this.handleReset}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<RefreshCw className="h-4 w-4 mr-2" />
Try Again
</button>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -1,73 +0,0 @@
import { Skeleton } from "./ui/skeleton";
import { Card, CardContent } from "./ui/card";
export default function FiltersSkeleton() {
return (
<Card>
<div className="px-6 py-4 border-b border-border">
<div className="flex items-center justify-between">
<Skeleton className="h-6 w-32" />
<div className="flex items-center space-x-2">
<Skeleton className="h-8 w-24" />
<Skeleton className="h-8 w-20" />
</div>
</div>
</div>
<CardContent className="px-6 py-4 border-b border-border bg-muted/30">
{/* Quick Date Filters Skeleton */}
<div className="mb-6">
<Skeleton className="h-4 w-32 mb-3" />
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<Skeleton className="h-10 w-24 rounded-lg" />
<Skeleton className="h-10 w-20 rounded-lg" />
<Skeleton className="h-10 w-28 rounded-lg" />
</div>
<div className="flex flex-wrap gap-2">
<Skeleton className="h-10 w-24 rounded-lg" />
<Skeleton className="h-10 w-20 rounded-lg" />
</div>
</div>
</div>
{/* Filter Fields Skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="sm:col-span-2 lg:col-span-1">
<Skeleton className="h-4 w-16 mb-1" />
<Skeleton className="h-10 w-full" />
</div>
<div>
<Skeleton className="h-4 w-16 mb-1" />
<Skeleton className="h-10 w-full" />
</div>
<div>
<Skeleton className="h-4 w-20 mb-1" />
<Skeleton className="h-10 w-full" />
</div>
<div>
<Skeleton className="h-4 w-16 mb-1" />
<Skeleton className="h-10 w-full" />
</div>
</div>
{/* Amount Range Filters Skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
<div>
<Skeleton className="h-4 w-20 mb-1" />
<Skeleton className="h-10 w-full" />
</div>
<div>
<Skeleton className="h-4 w-20 mb-1" />
<Skeleton className="h-10 w-full" />
</div>
</div>
</CardContent>
{/* Results Summary Skeleton */}
<CardContent className="px-6 py-3 bg-muted/30 border-b border-border">
<Skeleton className="h-4 w-48" />
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,18 @@
import { RefreshCw } from "lucide-react";
interface LoadingSpinnerProps {
message?: string;
}
export default function LoadingSpinner({
message = "Loading...",
}: LoadingSpinnerProps) {
return (
<div className="flex items-center justify-center p-8">
<div className="text-center">
<RefreshCw className="h-8 w-8 animate-spin text-blue-600 mx-auto mb-2" />
<p className="text-gray-600 text-sm">{message}</p>
</div>
</div>
);
}

View File

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

View File

@@ -10,37 +10,10 @@ import {
CheckCircle,
Settings,
TestTube,
Activity,
Clock,
TrendingUp,
User,
} from "lucide-react";
import { apiClient } from "../lib/api";
import NotificationsSkeleton from "./NotificationsSkeleton";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./ui/card";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Badge } from "./ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
import type {
NotificationSettings,
NotificationService,
SyncOperationsResponse,
} from "../types/api";
import LoadingSpinner from "./LoadingSpinner";
import type { NotificationSettings, NotificationService } from "../types/api";
export default function Notifications() {
const [testService, setTestService] = useState("");
@@ -69,16 +42,6 @@ export default function Notifications() {
queryFn: apiClient.getNotificationServices,
});
const {
data: syncOperations,
isLoading: syncOperationsLoading,
error: syncOperationsError,
refetch: refetchSyncOperations,
} = useQuery<SyncOperationsResponse>({
queryKey: ["syncOperations"],
queryFn: () => apiClient.getSyncOperations(10, 0), // Get latest 10 operations
});
const testMutation = useMutation({
mutationFn: apiClient.testNotification,
onSuccess: () => {
@@ -98,34 +61,40 @@ export default function Notifications() {
},
});
if (settingsLoading || servicesLoading || syncOperationsLoading) {
return <NotificationsSkeleton />;
if (settingsLoading || servicesLoading) {
return (
<div className="bg-white rounded-lg shadow">
<LoadingSpinner message="Loading notifications..." />
</div>
);
}
if (settingsError || servicesError || syncOperationsError) {
if (settingsError || servicesError) {
return (
<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={() => {
refetchSettings();
refetchServices();
refetchSyncOperations();
}}
variant="outline"
size="sm"
>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</AlertDescription>
</Alert>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-center text-center">
<div>
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Failed to load notifications
</h3>
<p className="text-gray-600 mb-4">
Unable to connect to the Leggen API. Please check your
configuration and ensure the API server is running.
</p>
<button
onClick={() => {
refetchSettings();
refetchServices();
}}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</button>
</div>
</div>
</div>
);
}
@@ -133,7 +102,7 @@ export default function Notifications() {
if (!testService) return;
testMutation.mutate({
service: testService.toLowerCase(),
service: testService,
message: testMessage,
});
};
@@ -144,309 +113,208 @@ export default function Notifications() {
`Are you sure you want to delete the ${serviceName} notification service?`,
)
) {
deleteServiceMutation.mutate(serviceName.toLowerCase());
deleteServiceMutation.mutate(serviceName);
}
};
return (
<div className="space-y-6">
{/* Sync Operations Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Activity className="h-5 w-5 text-primary" />
<span>Sync Operations</span>
</CardTitle>
<CardDescription>Recent synchronization activities</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, 5).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="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"
}`}
>
{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}
</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="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>
{operation.errors.length > 0 && (
<div className="flex items-center space-x-2 mt-1 text-red-600">
<AlertCircle className="h-3 w-3" />
<span>{operation.errors.length} errors</span>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Test Notification Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<TestTube className="h-5 w-5 text-primary" />
<span>Test Notifications</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="service" className="text-foreground">
Service
</Label>
<Select value={testService} onValueChange={setTestService}>
<SelectTrigger>
<SelectValue placeholder="Select a service..." />
</SelectTrigger>
<SelectContent>
{services?.map((service) => (
<SelectItem key={service.name} value={service.name}>
{service.name}{" "}
{service.enabled ? "(Enabled)" : "(Disabled)"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center space-x-2 mb-4">
<TestTube className="h-5 w-5 text-blue-600" />
<h3 className="text-lg font-medium text-gray-900">
Test Notifications
</h3>
</div>
<div>
<Label htmlFor="message" className="text-foreground">
Message
</Label>
<Input
id="message"
type="text"
value={testMessage}
onChange={(e) => setTestMessage(e.target.value)}
placeholder="Test message..."
/>
</div>
</div>
<div className="mt-4">
<Button
onClick={handleTestNotification}
disabled={!testService || testMutation.isPending}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Service
</label>
<select
value={testService}
onChange={(e) => setTestService(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<Send className="h-4 w-4 mr-2" />
{testMutation.isPending ? "Sending..." : "Send Test Notification"}
</Button>
<option value="">Select a service...</option>
{services?.map((service) => (
<option key={service.name} value={service.name}>
{service.name} {service.enabled ? "(Enabled)" : "(Disabled)"}
</option>
))}
</select>
</div>
</CardContent>
</Card>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Message
</label>
<input
type="text"
value={testMessage}
onChange={(e) => setTestMessage(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Test message..."
/>
</div>
</div>
<div className="mt-4">
<button
onClick={handleTestNotification}
disabled={!testService || testMutation.isPending}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Send className="h-4 w-4 mr-2" />
{testMutation.isPending ? "Sending..." : "Send Test Notification"}
</button>
</div>
</div>
{/* Notification Services */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Bell className="h-5 w-5 text-primary" />
<span>Notification Services</span>
</CardTitle>
<CardDescription>Manage your notification services</CardDescription>
</CardHeader>
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center space-x-2">
<Bell className="h-5 w-5 text-blue-600" />
<h3 className="text-lg font-medium text-gray-900">
Notification Services
</h3>
</div>
<p className="text-sm text-gray-600 mt-1">
Manage your notification services
</p>
</div>
{!services || services.length === 0 ? (
<CardContent className="text-center">
<Bell className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
<div className="p-6 text-center">
<Bell className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
No notification services configured
</h3>
<p className="text-muted-foreground">
<p className="text-gray-600">
Configure notification services in your backend to receive alerts.
</p>
</CardContent>
</div>
) : (
<CardContent className="p-0">
<div className="divide-y divide-border">
{services.map((service) => (
<div
key={service.name}
className="p-6 hover:bg-accent transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="p-3 bg-muted rounded-full">
{service.name.toLowerCase().includes("discord") ? (
<MessageSquare className="h-6 w-6 text-muted-foreground" />
) : service.name.toLowerCase().includes("telegram") ? (
<Send className="h-6 w-6 text-muted-foreground" />
) : (
<Bell className="h-6 w-6 text-muted-foreground" />
)}
</div>
<div>
<h4 className="text-lg font-medium text-foreground capitalize">
{service.name}
</h4>
<div className="flex items-center space-x-2 mt-1">
<Badge
variant={
service.enabled ? "default" : "destructive"
}
>
{service.enabled ? (
<CheckCircle className="h-3 w-3 mr-1" />
) : (
<AlertCircle className="h-3 w-3 mr-1" />
)}
{service.enabled ? "Enabled" : "Disabled"}
</Badge>
<Badge
variant={
service.configured ? "secondary" : "outline"
}
>
{service.configured
? "Configured"
: "Not Configured"}
</Badge>
</div>
<div className="divide-y divide-gray-200">
{services.map((service) => (
<div
key={service.name}
className="p-6 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="p-3 bg-gray-100 rounded-full">
{service.name.toLowerCase().includes("discord") ? (
<MessageSquare className="h-6 w-6 text-gray-600" />
) : service.name.toLowerCase().includes("telegram") ? (
<Send className="h-6 w-6 text-gray-600" />
) : (
<Bell className="h-6 w-6 text-gray-600" />
)}
</div>
<div>
<h4 className="text-lg font-medium text-gray-900 capitalize">
{service.name}
</h4>
<div className="flex items-center space-x-2 mt-1">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
service.enabled
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{service.enabled ? (
<CheckCircle className="h-3 w-3 mr-1" />
) : (
<AlertCircle className="h-3 w-3 mr-1" />
)}
{service.enabled ? "Enabled" : "Disabled"}
</span>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
service.configured
? "bg-blue-100 text-blue-800"
: "bg-yellow-100 text-yellow-800"
}`}
>
{service.configured ? "Configured" : "Not Configured"}
</span>
</div>
</div>
</div>
<Button
<div className="flex items-center space-x-2">
<button
onClick={() => handleDeleteService(service.name)}
disabled={deleteServiceMutation.isPending}
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
className="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors"
title={`Delete ${service.name} service`}
>
<Trash2 className="h-4 w-4" />
</Button>
</button>
</div>
</div>
))}
</div>
</CardContent>
</div>
))}
</div>
)}
</Card>
</div>
{/* Notification Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Settings className="h-5 w-5 text-primary" />
<span>Notification Settings</span>
</CardTitle>
</CardHeader>
<CardContent>
{settings && (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-foreground mb-2">
Filters
</h4>
<div className="bg-muted rounded-md p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium text-muted-foreground mb-1 block">
Case Insensitive Filters
</Label>
<p className="text-sm text-foreground">
{settings.filters.case_insensitive.length > 0
? settings.filters.case_insensitive.join(", ")
: "None"}
</p>
</div>
<div>
<Label className="text-xs font-medium text-muted-foreground mb-1 block">
Case Sensitive Filters
</Label>
<p className="text-sm text-foreground">
{settings.filters.case_sensitive &&
settings.filters.case_sensitive.length > 0
? settings.filters.case_sensitive.join(", ")
: "None"}
</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center space-x-2 mb-4">
<Settings className="h-5 w-5 text-blue-600" />
<h3 className="text-lg font-medium text-gray-900">
Notification Settings
</h3>
</div>
{settings && (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">
Filters
</h4>
<div className="bg-gray-50 rounded-md p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">
Case Insensitive Filters
</label>
<p className="text-sm text-gray-900">
{settings.filters.case_insensitive.length > 0
? settings.filters.case_insensitive.join(", ")
: "None"}
</p>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">
Case Sensitive Filters
</label>
<p className="text-sm text-gray-900">
{settings.filters.case_sensitive &&
settings.filters.case_sensitive.length > 0
? settings.filters.case_sensitive.join(", ")
: "None"}
</p>
</div>
</div>
</div>
<div className="text-sm text-muted-foreground">
<p>
Configure notification settings through your backend API to
customize filters and service configurations.
</p>
</div>
</div>
)}
</CardContent>
</Card>
<div className="text-sm text-gray-600">
<p>
Configure notification settings through your backend API to
customize filters and service configurations.
</p>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,95 +0,0 @@
import { Skeleton } from "./ui/skeleton";
import { Card, CardContent, CardHeader } from "./ui/card";
export default function NotificationsSkeleton() {
return (
<div className="space-y-6">
{/* Test Notification Section Skeleton */}
<Card>
<CardHeader>
<div className="flex items-center space-x-2">
<Skeleton className="h-5 w-5" />
<Skeleton className="h-6 w-36" />
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-10 w-full" />
</div>
</div>
<div className="mt-4">
<Skeleton className="h-10 w-48" />
</div>
</CardContent>
</Card>
{/* Notification Services Skeleton */}
<Card>
<CardHeader>
<div className="flex items-center space-x-2">
<Skeleton className="h-5 w-5" />
<Skeleton className="h-6 w-40" />
</div>
<Skeleton className="h-4 w-56" />
</CardHeader>
<CardContent className="p-0">
<div className="divide-y divide-border">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-5 w-24" />
<div className="flex items-center space-x-2">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-20" />
</div>
</div>
</div>
<Skeleton className="h-8 w-8" />
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Notification Settings Skeleton */}
<Card>
<CardHeader>
<div className="flex items-center space-x-2">
<Skeleton className="h-5 w-5" />
<Skeleton className="h-6 w-40" />
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-4 w-16" />
<div className="bg-muted rounded-md p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-4 w-24" />
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-4 w-20" />
</div>
</div>
</div>
</div>
<Skeleton className="h-12 w-full" />
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,119 +0,0 @@
import { X, Copy, Check } from "lucide-react";
import { useState } from "react";
import { Button } from "./ui/button";
import type { RawTransactionData } from "../types/api";
interface RawTransactionModalProps {
isOpen: boolean;
onClose: () => void;
rawTransaction: RawTransactionData | undefined;
transactionId: string;
}
export default function RawTransactionModal({
isOpen,
onClose,
rawTransaction,
transactionId,
}: RawTransactionModalProps) {
const [copied, setCopied] = useState(false);
if (!isOpen) return null;
const handleCopy = async () => {
if (!rawTransaction) return;
try {
await navigator.clipboard.writeText(
JSON.stringify(rawTransaction, null, 2),
);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy to clipboard:", err);
}
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
{/* Background overlay */}
<div
className="fixed inset-0 bg-background/80 backdrop-blur-sm transition-opacity"
onClick={onClose}
/>
{/* Modal panel */}
<div className="inline-block align-bottom bg-card rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full border">
<div className="bg-card px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-foreground">
Raw Transaction Data
</h3>
<div className="flex items-center space-x-2">
<Button
onClick={handleCopy}
disabled={!rawTransaction}
variant="outline"
size="sm"
>
{copied ? (
<>
<Check className="h-4 w-4 mr-1 text-green-600 dark:text-green-400" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy JSON
</>
)}
</Button>
<Button onClick={onClose} variant="ghost" size="sm">
<X className="h-5 w-5" />
</Button>
</div>
</div>
<div className="mb-4">
<p className="text-sm text-muted-foreground">
Transaction ID:{" "}
<code className="bg-muted px-2 py-1 rounded text-xs text-foreground">
{transactionId}
</code>
</p>
</div>
{rawTransaction ? (
<div className="bg-muted rounded-lg p-4 overflow-auto max-h-96">
<pre className="text-sm text-foreground whitespace-pre-wrap">
{JSON.stringify(rawTransaction, null, 2)}
</pre>
</div>
) : (
<div className="bg-muted rounded-lg p-8 text-center">
<p className="text-foreground">
Raw transaction data is not available for this transaction.
</p>
<p className="text-sm text-muted-foreground mt-2">
Try refreshing the page or check if the transaction was
fetched with summary_only=false.
</p>
</div>
)}
</div>
<div className="bg-muted/50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<Button
type="button"
onClick={onClose}
className="w-full sm:ml-3 sm:w-auto"
>
Close
</Button>
</div>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -1,984 +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 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" />
)}
<p
className={`text-base sm:text-lg font-semibold ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{formatCurrency(balance, currency)}
</p>
</div>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
)}
</Card>
{/* 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>
);
}

View File

@@ -1,85 +0,0 @@
import { useLocation } from "@tanstack/react-router";
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 { Separator } from "./ui/separator";
import { SidebarTrigger } from "./ui/sidebar";
const navigation = [
{ name: "Overview", to: "/" },
{ name: "Transactions", to: "/transactions" },
{ name: "Analytics", to: "/analytics" },
{ name: "System", to: "/system" },
{ name: "Settings", to: "/settings" },
];
export function SiteHeader() {
const location = useLocation();
const currentPage =
navigation.find((item) => item.to === location.pathname)?.name ||
"Dashboard";
const {
data: healthStatus,
isLoading: healthLoading,
isError: healthError,
} = useQuery({
queryKey: ["health"],
queryFn: apiClient.getHealth,
refetchInterval: 30000,
});
return (
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4"
/>
<h1 className="text-lg font-semibold text-card-foreground">
{currentPage}
</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 ? (
<>
<Activity className="h-4 w-4 text-muted-foreground animate-pulse" />
<span className="text-sm text-muted-foreground">
Checking...
</span>
</>
) : healthError || healthStatus?.status !== "healthy" ? (
<>
<WifiOff className="h-4 w-4 text-destructive" />
<span className="text-sm text-destructive">Disconnected</span>
</>
) : (
<>
<Wifi className="h-4 w-4 text-green-500" />
<span className="text-sm text-muted-foreground">Connected</span>
</>
)}
</div>
<ThemeToggle />
</div>
</div>
</header>
);
}

View File

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

View File

@@ -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&lt;token&gt;/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>
);
}

View File

@@ -1,102 +0,0 @@
import { Skeleton } from "./ui/skeleton";
import { Card } from "./ui/card";
interface TransactionSkeletonProps {
rows?: number;
view?: "table" | "mobile";
}
export default function TransactionSkeleton({
rows = 5,
view = "table",
}: TransactionSkeletonProps) {
const skeletonRows = Array.from({ length: rows }, (_, index) => index);
if (view === "mobile") {
return (
<Card className="divide-y divide-border">
{skeletonRows.map((_, index) => (
<div key={index} className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-start space-x-3">
<Skeleton className="h-10 w-10 rounded-full flex-shrink-0" />
<div className="flex-1 min-w-0 space-y-2">
<Skeleton className="h-4 w-3/4" />
<div className="space-y-1">
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-3 w-2/3" />
<Skeleton className="h-3 w-1/3" />
</div>
</div>
</div>
</div>
<div className="text-right ml-3 flex-shrink-0 space-y-2">
<Skeleton className="h-6 w-20" />
<Skeleton className="h-4 w-16 ml-auto" />
<Skeleton className="h-6 w-12 ml-auto" />
</div>
</div>
</div>
))}
</Card>
);
}
return (
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted/50">
<tr>
<th className="px-6 py-3 text-left">
<Skeleton className="h-4 w-20" />
</th>
<th className="px-6 py-3 text-left">
<Skeleton className="h-4 w-16" />
</th>
<th className="px-6 py-3 text-left">
<Skeleton className="h-4 w-12" />
</th>
<th className="px-6 py-3 text-left">
<Skeleton className="h-4 w-8" />
</th>
</tr>
</thead>
<tbody className="bg-card divide-y divide-border">
{skeletonRows.map((_, index) => (
<tr key={index}>
<td className="px-6 py-4">
<div className="flex items-start space-x-3">
<Skeleton className="h-10 w-10 rounded-full flex-shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<div className="space-y-1">
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-3 w-2/3" />
</div>
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="text-right">
<Skeleton className="h-6 w-24 ml-auto mb-1" />
</div>
</td>
<td className="px-6 py-4">
<div className="space-y-1">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-3 w-16" />
</div>
</td>
<td className="px-6 py-4">
<Skeleton className="h-6 w-12" />
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
}

View File

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

View File

@@ -1,576 +0,0 @@
import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
} from "@tanstack/react-table";
import type {
ColumnDef,
SortingState,
ColumnFiltersState,
} from "@tanstack/react-table";
import {
TrendingUp,
TrendingDown,
RefreshCw,
AlertCircle,
Eye,
ChevronUp,
ChevronDown,
} from "lucide-react";
import { apiClient } from "../lib/api";
import { formatCurrency, formatDate } from "../lib/utils";
import TransactionSkeleton from "./TransactionSkeleton";
import FiltersSkeleton from "./FiltersSkeleton";
import RawTransactionModal from "./RawTransactionModal";
import { FilterBar, type FilterState } from "./filters";
import { DataTablePagination } from "./ui/data-table-pagination";
import { Card } from "./ui/card";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Button } from "./ui/button";
import type { Account, Transaction, ApiResponse } from "../types/api";
export default function TransactionsTable() {
// Filter state consolidated into a single object
const [filterState, setFilterState] = useState<FilterState>({
searchTerm: "",
selectedAccount: "",
startDate: "",
endDate: "",
});
const [showRawModal, setShowRawModal] = useState(false);
const [selectedTransaction, setSelectedTransaction] =
useState<Transaction | null>(null);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [perPage, setPerPage] = useState(50);
// Debounced search state
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(
filterState.searchTerm,
);
// Table state (remove pagination from table)
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
// Helper function to update filter state
const handleFilterChange = (key: keyof FilterState, value: string) => {
setFilterState((prev) => ({ ...prev, [key]: value }));
};
// Helper function to clear all filters
const handleClearFilters = () => {
setFilterState({
searchTerm: "",
selectedAccount: "",
startDate: "",
endDate: "",
});
setColumnFilters([]);
setCurrentPage(1);
};
// Debounce search term to prevent excessive API calls
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchTerm(filterState.searchTerm);
}, 300); // 300ms delay
return () => clearTimeout(timer);
}, [filterState.searchTerm]);
// Reset pagination when search term changes
useEffect(() => {
if (debouncedSearchTerm !== filterState.searchTerm) {
setCurrentPage(1);
}
}, [debouncedSearchTerm, filterState.searchTerm]);
const { data: accounts } = useQuery<Account[]>({
queryKey: ["accounts"],
queryFn: apiClient.getAccounts,
});
const {
data: transactionsResponse,
isLoading: transactionsLoading,
error: transactionsError,
refetch: refetchTransactions,
} = useQuery<ApiResponse<Transaction[]>>({
queryKey: [
"transactions",
filterState.selectedAccount,
filterState.startDate,
filterState.endDate,
currentPage,
perPage,
debouncedSearchTerm,
],
queryFn: () =>
apiClient.getTransactions({
accountId: filterState.selectedAccount || undefined,
startDate: filterState.startDate || undefined,
endDate: filterState.endDate || undefined,
page: currentPage,
perPage: perPage,
search: debouncedSearchTerm || undefined,
summaryOnly: false,
}),
});
const transactions = transactionsResponse?.data || [];
const pagination = transactionsResponse?.pagination;
// Check if search is currently debouncing
const isSearchLoading = filterState.searchTerm !== debouncedSearchTerm;
// Reset pagination when total becomes 0 (no results)
useEffect(() => {
if (pagination && pagination.total === 0 && currentPage > 1) {
setCurrentPage(1);
}
}, [pagination, currentPage]);
// Reset pagination when filters change
useEffect(() => {
setCurrentPage(1);
}, [filterState.selectedAccount, filterState.startDate, filterState.endDate]);
const handleViewRaw = (transaction: Transaction) => {
setSelectedTransaction(transaction);
setShowRawModal(true);
};
const handleCloseModal = () => {
setShowRawModal(false);
setSelectedTransaction(null);
};
const hasActiveFilters =
filterState.searchTerm ||
filterState.selectedAccount ||
filterState.startDate ||
filterState.endDate;
// Define columns
const columns: ColumnDef<Transaction>[] = [
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => {
const transaction = row.original;
const account = accounts?.find(
(acc) => acc.id === transaction.account_id,
);
const isPositive = transaction.transaction_value > 0;
return (
<div className="flex items-start space-x-3">
<div
className={`p-2 rounded-full ${
isPositive ? "bg-green-100" : "bg-red-100"
}`}
>
{isPositive ? (
<TrendingUp className="h-4 w-4 text-green-600" />
) : (
<TrendingDown className="h-4 w-4 text-red-600" />
)}
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-foreground truncate">
{transaction.description}
</h4>
<div className="text-xs text-muted-foreground space-y-1">
{account && (
<p className="truncate">
{account.display_name || "Unnamed Account"}
</p>
)}
{(transaction.creditor_name || transaction.debtor_name) && (
<p className="truncate">
{isPositive ? "From: " : "To: "}
{transaction.creditor_name || transaction.debtor_name}
</p>
)}
{transaction.reference && (
<p className="truncate">Ref: {transaction.reference}</p>
)}
</div>
</div>
</div>
);
},
},
{
accessorKey: "transaction_value",
header: "Amount",
cell: ({ row }) => {
const transaction = row.original;
const isPositive = transaction.transaction_value > 0;
return (
<div className="text-right">
<p
className={`text-lg font-semibold ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{isPositive ? "+" : ""}
{formatCurrency(
transaction.transaction_value,
transaction.transaction_currency,
)}
</p>
</div>
);
},
sortingFn: "basic",
},
{
accessorKey: "transaction_date",
header: "Date",
cell: ({ row }) => {
const transaction = row.original;
return (
<div className="text-sm text-foreground">
{transaction.transaction_date
? formatDate(transaction.transaction_date)
: "No date"}
{transaction.booking_date &&
transaction.booking_date !== transaction.transaction_date && (
<p className="text-xs text-muted-foreground">
Booked: {formatDate(transaction.booking_date)}
</p>
)}
</div>
);
},
sortingFn: "datetime",
},
{
id: "actions",
header: "",
cell: ({ row }) => {
const transaction = row.original;
return (
<Button
onClick={() => handleViewRaw(transaction)}
variant="ghost"
size="sm"
title="View raw transaction data"
>
<Eye className="h-3 w-3 mr-1" />
Raw
</Button>
);
},
},
];
const table = useReactTable({
data: transactions,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
state: {
sorting,
columnFilters,
globalFilter: filterState.searchTerm,
},
onGlobalFilterChange: (value: string) =>
handleFilterChange("searchTerm", value),
globalFilterFn: (row, _columnId, filterValue) => {
// Custom global filter that searches multiple fields
const transaction = row.original;
const searchLower = filterValue.toLowerCase();
const description = transaction.description || "";
const creditorName = transaction.creditor_name || "";
const debtorName = transaction.debtor_name || "";
const reference = transaction.reference || "";
return (
description.toLowerCase().includes(searchLower) ||
creditorName.toLowerCase().includes(searchLower) ||
debtorName.toLowerCase().includes(searchLower) ||
reference.toLowerCase().includes(searchLower)
);
},
});
if (transactionsLoading) {
return (
<div className="space-y-6">
<FiltersSkeleton />
<TransactionSkeleton rows={10} view="table" />
<div className="md:hidden">
<TransactionSkeleton rows={10} view="mobile" />
</div>
</div>
);
}
if (transactionsError) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Failed to load transactions</AlertTitle>
<AlertDescription className="space-y-3">
<p>Unable to fetch transactions from the Leggen API.</p>
<Button
onClick={() => refetchTransactions()}
variant="outline"
size="sm"
>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-6 max-w-full">
{/* New FilterBar */}
<FilterBar
filterState={filterState}
onFilterChange={handleFilterChange}
onClearFilters={handleClearFilters}
accounts={accounts}
isSearchLoading={isSearchLoading}
/>
{/* Responsive Table/Cards */}
<Card>
{/* Desktop Table View (hidden on mobile) */}
<div className="hidden md:block">
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted/50">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted"
onClick={header.column.getToggleSortingHandler()}
>
<div className="flex items-center space-x-1">
<span>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</span>
{header.column.getCanSort() && (
<div className="flex flex-col">
<ChevronUp
className={`h-3 w-3 ${
header.column.getIsSorted() === "asc"
? "text-primary"
: "text-muted-foreground"
}`}
/>
<ChevronDown
className={`h-3 w-3 -mt-1 ${
header.column.getIsSorted() === "desc"
? "text-primary"
: "text-muted-foreground"
}`}
/>
</div>
)}
</div>
</th>
))}
</tr>
))}
</thead>
<tbody className="bg-card divide-y divide-border">
{table.getRowModel().rows.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className="px-6 py-12 text-center"
>
<div className="text-muted-foreground mb-4">
<TrendingUp className="h-12 w-12 mx-auto" />
</div>
<h3 className="text-lg font-medium text-foreground mb-2">
No transactions found
</h3>
<p className="text-muted-foreground">
{hasActiveFilters
? "Try adjusting your filters to see more results."
: "No transactions are available for the selected criteria."}
</p>
</td>
</tr>
) : (
table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-muted/50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4 whitespace-nowrap">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
{/* Mobile Card View (visible only on mobile) */}
<div className="md:hidden">
{table.getRowModel().rows.length === 0 ? (
<div className="px-6 py-12 text-center">
<div className="text-muted-foreground mb-4">
<TrendingUp className="h-12 w-12 mx-auto" />
</div>
<h3 className="text-lg font-medium text-foreground mb-2">
No transactions found
</h3>
<p className="text-muted-foreground">
{hasActiveFilters
? "Try adjusting your filters to see more results."
: "No transactions are available for the selected criteria."}
</p>
</div>
) : (
<div className="divide-y divide-border">
{table.getRowModel().rows.map((row) => {
const transaction = row.original;
const account = accounts?.find(
(acc) => acc.id === transaction.account_id,
);
const isPositive = transaction.transaction_value > 0;
return (
<div
key={row.id}
className="p-4 hover:bg-muted/50 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-start space-x-3">
<div
className={`p-2 rounded-full flex-shrink-0 ${
isPositive ? "bg-green-100" : "bg-red-100"
}`}
>
{isPositive ? (
<TrendingUp className="h-4 w-4 text-green-600" />
) : (
<TrendingDown className="h-4 w-4 text-red-600" />
)}
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-foreground break-words">
{transaction.description}
</h4>
<div className="text-xs text-muted-foreground space-y-1 mt-1">
{account && (
<p className="break-words">
{account.display_name || "Unnamed Account"}
</p>
)}
{(transaction.creditor_name ||
transaction.debtor_name) && (
<p className="break-words">
{isPositive ? "From: " : "To: "}
{transaction.creditor_name ||
transaction.debtor_name}
</p>
)}
{transaction.reference && (
<p className="break-words">
Ref: {transaction.reference}
</p>
)}
<p className="text-muted-foreground">
{transaction.transaction_date
? formatDate(transaction.transaction_date)
: "No date"}
{transaction.booking_date &&
transaction.booking_date !==
transaction.transaction_date && (
<span className="ml-2">
(Booked:{" "}
{formatDate(transaction.booking_date)})
</span>
)}
</p>
</div>
</div>
</div>
</div>
<div className="text-right ml-3 flex-shrink-0">
<p
className={`text-lg font-semibold mb-1 ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{isPositive ? "+" : ""}
{formatCurrency(
transaction.transaction_value,
transaction.transaction_currency,
)}
</p>
<Button
onClick={() => handleViewRaw(transaction)}
variant="ghost"
size="sm"
title="View raw transaction data"
>
<Eye className="h-3 w-3 mr-1" />
Raw
</Button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Pagination */}
{pagination && (
<DataTablePagination
currentPage={pagination.page}
totalPages={pagination.total_pages}
pageSize={pagination.per_page}
total={pagination.total}
hasNext={pagination.has_next}
hasPrev={pagination.has_prev}
onPageChange={setCurrentPage}
onPageSizeChange={setPerPage}
/>
)}
</Card>
{/* Raw Transaction Modal */}
<RawTransactionModal
isOpen={showRawModal}
onClose={handleCloseModal}
rawTransaction={selectedTransaction?.raw_transaction}
transactionId={selectedTransaction?.transaction_id || "unknown"}
/>
</div>
);
}

View File

@@ -1,191 +0,0 @@
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import type { Balance, Account } from "../../types/api";
interface BalanceChartProps {
data: Balance[];
accounts: Account[];
className?: string;
}
interface ChartDataPoint {
date: string;
balance: number;
account_id: string;
}
interface AggregatedDataPoint {
date: string;
[key: string]: string | number;
}
interface TooltipProps {
active?: boolean;
payload?: Array<{
name: string;
value: number;
color: string;
}>;
label?: string;
}
export default function BalanceChart({
data,
accounts,
className,
}: BalanceChartProps) {
// Create a lookup map for account info
const accountMap = accounts.reduce(
(map, account) => {
map[account.id] = account;
return map;
},
{} as Record<string, Account>,
);
// Helper function to get bank name from institution_id
const getBankName = (institutionId: string): string => {
const bankMapping: Record<string, string> = {
REVOLUT_REVOLT21: "Revolut",
NUBANK_NUPBBR25: "Nu Pagamentos",
BANCOBPI_BBPIPTPL: "Banco BPI",
// Add more mappings as needed
};
return bankMapping[institutionId] || institutionId.split("_")[0];
};
// Helper function to create display name for account
const getAccountDisplayName = (accountId: string): string => {
const account = accountMap[accountId];
if (account) {
const bankName = getBankName(account.institution_id);
const accountName = account.name || `Account ${accountId.split("-")[1]}`;
return `${bankName} - ${accountName}`;
}
return `Account ${accountId.split("-")[1]}`;
};
// Process balance data for the chart
const chartData = data
.filter((balance) => balance.balance_type === "closingBooked")
.map((balance) => ({
date: new Date(balance.reference_date).toLocaleDateString("en-GB"), // DD/MM/YYYY format
balance: balance.balance_amount,
account_id: balance.account_id,
}))
.sort(
(a, b) =>
new Date(a.date.split("/").reverse().join("/")).getTime() -
new Date(b.date.split("/").reverse().join("/")).getTime(),
);
// Group by account and aggregate
const accountBalances: { [key: string]: ChartDataPoint[] } = {};
chartData.forEach((item) => {
if (!accountBalances[item.account_id]) {
accountBalances[item.account_id] = [];
}
accountBalances[item.account_id].push(item);
});
// Create aggregated data points
const aggregatedData: { [key: string]: AggregatedDataPoint } = {};
Object.entries(accountBalances).forEach(([accountId, balances]) => {
balances.forEach((balance) => {
if (!aggregatedData[balance.date]) {
aggregatedData[balance.date] = { date: balance.date };
}
aggregatedData[balance.date][accountId] = balance.balance;
});
});
const finalData = Object.values(aggregatedData).sort(
(a, b) =>
new Date(a.date.split("/").reverse().join("/")).getTime() -
new Date(b.date.split("/").reverse().join("/")).getTime(),
);
const colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
if (active && payload && payload.length) {
return (
<div className="bg-card p-3 border rounded shadow-lg">
<p className="font-medium text-foreground">Date: {label}</p>
{payload.map((entry, index) => (
<p key={index} style={{ color: entry.color }}>
{getAccountDisplayName(entry.name)}:
{entry.value.toLocaleString()}
</p>
))}
</div>
);
}
return null;
};
if (finalData.length === 0) {
return (
<div className={className}>
<h3 className="text-lg font-medium text-foreground mb-4">
Balance Progress
</h3>
<div className="h-80 flex items-center justify-center text-muted-foreground">
No balance data available
</div>
</div>
);
}
return (
<div className={className}>
<h3 className="text-lg font-medium text-foreground mb-4">
Balance Progress Over Time
</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={finalData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tick={{ fontSize: 12 }}
tickFormatter={(value) => {
// Convert DD/MM/YYYY back to a proper date for formatting
const [day, month, year] = value.split("/");
const date = new Date(year, month - 1, day);
return date.toLocaleDateString("en-GB", {
month: "short",
day: "numeric",
});
}}
/>
<YAxis
tick={{ fontSize: 12 }}
tickFormatter={(value) => `${value.toLocaleString()}`}
/>
<Tooltip content={<CustomTooltip />} />
<Legend />
{Object.keys(accountBalances).map((accountId, index) => (
<Area
key={accountId}
type="monotone"
dataKey={accountId}
stackId="1"
fill={colors[index % colors.length]}
stroke={colors[index % colors.length]}
name={getAccountDisplayName(accountId)}
/>
))}
</AreaChart>
</ResponsiveContainer>
</div>
</div>
);
}

View File

@@ -1,142 +0,0 @@
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { useQuery } from "@tanstack/react-query";
import apiClient from "../../lib/api";
interface MonthlyTrendsProps {
className?: string;
days?: number;
}
interface TooltipProps {
active?: boolean;
payload?: Array<{
name: string;
value: number;
color: string;
}>;
label?: string;
}
export default function MonthlyTrends({
className,
days = 365,
}: MonthlyTrendsProps) {
// Get pre-calculated monthly stats from the new endpoint
const { data: monthlyData, isLoading } = useQuery({
queryKey: ["monthly-stats", days],
queryFn: async () => {
return await apiClient.getMonthlyTransactionStats(days);
},
});
// Calculate number of months to display based on days filter
const getMonthsToDisplay = (days: number): number => {
if (days <= 30) return 1;
if (days <= 180) return 6;
if (days <= 365) return 12;
return Math.ceil(days / 30);
};
const monthsToDisplay = getMonthsToDisplay(days);
const displayData = monthlyData ? monthlyData.slice(-monthsToDisplay) : [];
if (isLoading) {
return (
<div className={className}>
<h3 className="text-lg font-medium text-foreground mb-4">
Monthly Spending Trends
</h3>
<div className="h-80 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
</div>
);
}
if (displayData.length === 0) {
return (
<div className={className}>
<h3 className="text-lg font-medium text-foreground mb-4">
Monthly Spending Trends
</h3>
<div className="h-80 flex items-center justify-center text-muted-foreground">
No transaction data available
</div>
</div>
);
}
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
if (active && payload && payload.length) {
return (
<div className="bg-card p-3 border rounded shadow-lg">
<p className="font-medium text-foreground">{label}</p>
{payload.map((entry, index) => (
<p key={index} style={{ color: entry.color }}>
{entry.name}: {Math.abs(entry.value).toLocaleString()}
</p>
))}
</div>
);
}
return null;
};
// Generate dynamic title based on time period
const getTitle = (days: number): string => {
if (days <= 30) return "Monthly Spending Trends (Last 30 Days)";
if (days <= 180) return "Monthly Spending Trends (Last 6 Months)";
if (days <= 365) return "Monthly Spending Trends (Last 12 Months)";
return `Monthly Spending Trends (Last ${Math.ceil(days / 30)} Months)`;
};
return (
<div className={className}>
<h3 className="text-lg font-medium text-foreground mb-4">
{getTitle(days)}
</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={displayData}
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="month"
tick={{ fontSize: 12 }}
angle={-45}
textAnchor="end"
height={60}
/>
<YAxis
tick={{ fontSize: 12 }}
tickFormatter={(value) => `${value.toLocaleString()}`}
/>
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="income" fill="#10B981" name="Income" />
<Bar dataKey="expenses" fill="#EF4444" name="Expenses" />
</BarChart>
</ResponsiveContainer>
</div>
<div className="mt-4 flex justify-center space-x-6 text-sm text-foreground">
<div className="flex items-center">
<div className="w-3 h-3 bg-green-500 rounded mr-2" />
<span>Income</span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 bg-red-500 rounded mr-2" />
<span>Expenses</span>
</div>
</div>
</div>
);
}

View File

@@ -1,80 +0,0 @@
import type { LucideIcon } from "lucide-react";
import { Card, CardContent } from "../ui/card";
import { cn } from "../../lib/utils";
interface StatCardProps {
title: string;
value: string | number;
subtitle?: string;
icon: LucideIcon;
trend?: {
value: number;
isPositive: boolean;
};
className?: string;
iconColor?: "green" | "blue" | "red" | "purple" | "orange" | "default";
}
export default function StatCard({
title,
value,
subtitle,
icon: Icon,
trend,
className,
iconColor = "default",
}: 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>
<div className="flex items-baseline">
<p className="text-2xl font-bold text-foreground">{value}</p>
{trend && (
<div
className={cn(
"ml-2 flex items-baseline text-sm font-semibold",
trend.isPositive
? "text-green-600 dark:text-green-400"
: "text-red-600 dark:text-red-400",
)}
>
{trend.isPositive ? "+" : ""}
{trend.value}%
</div>
)}
</div>
{subtitle && (
<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>
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,41 +0,0 @@
import { Calendar } from "lucide-react";
import { Button } from "../ui/button";
import type { TimePeriod } from "../../lib/timePeriods";
import { TIME_PERIODS } from "../../lib/timePeriods";
interface TimePeriodFilterProps {
selectedPeriod: TimePeriod;
onPeriodChange: (period: TimePeriod) => void;
className?: string;
}
export default function TimePeriodFilter({
selectedPeriod,
onPeriodChange,
className = "",
}: TimePeriodFilterProps) {
return (
<div
className={`flex flex-col sm:flex-row sm: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">
{TIME_PERIODS.map((period) => (
<Button
key={period.value}
onClick={() => onPeriodChange(period)}
variant={
selectedPeriod.value === period.value ? "default" : "outline"
}
size="sm"
>
{period.label}
</Button>
))}
</div>
</div>
);
}

View File

@@ -1,148 +0,0 @@
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Tooltip,
Legend,
} from "recharts";
import type { Account } from "../../types/api";
interface TransactionDistributionProps {
accounts: Account[];
className?: string;
}
interface PieDataPoint {
name: string;
value: number;
color: string;
[key: string]: string | number;
}
interface TooltipProps {
active?: boolean;
payload?: Array<{
payload: PieDataPoint;
}>;
}
export default function TransactionDistribution({
accounts,
className,
}: TransactionDistributionProps) {
// Helper function to get bank name from institution_id
const getBankName = (institutionId: string): string => {
const bankMapping: Record<string, string> = {
REVOLUT_REVOLT21: "Revolut",
NUBANK_NUPBBR25: "Nu Pagamentos",
BANCOBPI_BBPIPTPL: "Banco BPI",
// TODO: Add more bank mappings as needed
};
return bankMapping[institutionId] || institutionId.split("_")[0];
};
// Helper function to create display name for account
const getAccountDisplayName = (account: Account): string => {
const bankName = getBankName(account.institution_id);
const accountName = account.name || `Account ${account.id.split("-")[1]}`;
return `${bankName} - ${accountName}`;
};
// Create pie chart data from account balances
const pieData: PieDataPoint[] = accounts.map((account, index) => {
const primaryBalance = account.balances?.[0]?.amount || 0;
const colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
return {
name: getAccountDisplayName(account),
value: primaryBalance,
color: colors[index % colors.length],
};
});
const totalBalance = pieData.reduce((sum, item) => sum + item.value, 0);
if (pieData.length === 0 || totalBalance === 0) {
return (
<div className={className}>
<h3 className="text-lg font-medium text-foreground mb-4">
Account Distribution
</h3>
<div className="h-80 flex items-center justify-center text-muted-foreground">
No account data available
</div>
</div>
);
}
const CustomTooltip = ({ active, payload }: TooltipProps) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const percentage = ((data.value / totalBalance) * 100).toFixed(1);
return (
<div className="bg-card p-3 border rounded shadow-lg">
<p className="font-medium text-foreground">{data.name}</p>
<p className="text-primary">
Balance: {data.value.toLocaleString()}
</p>
<p className="text-muted-foreground">{percentage}% of total</p>
</div>
);
}
return null;
};
return (
<div className={className}>
<h3 className="text-lg font-medium text-foreground mb-4">
Account Balance Distribution
</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
outerRadius={100}
innerRadius={40}
paddingAngle={2}
dataKey="value"
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Legend
formatter={(value, entry: { color?: string }) => (
<span style={{ color: entry.color }}>{value}</span>
)}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="mt-4 grid grid-cols-1 gap-2">
{pieData.map((item, index) => (
<div
key={index}
className="flex items-center justify-between text-sm"
>
<div className="flex items-center">
<div
className="w-3 h-3 rounded-full mr-2"
style={{ backgroundColor: item.color }}
/>
<span className="text-foreground">{item.name}</span>
</div>
<span className="font-medium text-foreground">
{item.value.toLocaleString()}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,127 +0,0 @@
import { useState } from "react";
import { Check, ChevronDown, Building2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { Account } from "../../types/api";
export interface AccountComboboxProps {
accounts?: Account[];
selectedAccount: string;
onAccountChange: (accountId: string) => void;
className?: string;
}
export function AccountCombobox({
accounts = [],
selectedAccount,
onAccountChange,
className,
}: AccountComboboxProps) {
const [open, setOpen] = useState(false);
const selectedAccountData = accounts.find(
(account) => account.id === selectedAccount,
);
const formatAccountName = (account: Account) => {
const displayName =
account.display_name || account.name || "Unnamed Account";
return `${displayName} (${account.institution_id})`;
};
return (
<div className={className}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
<div className="flex items-center">
<Building2 className="mr-2 h-4 w-4" />
{selectedAccountData
? formatAccountName(selectedAccountData)
: "All accounts"}
</div>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command>
<CommandInput placeholder="Search accounts..." className="h-9" />
<CommandList>
<CommandEmpty>No accounts found.</CommandEmpty>
<CommandGroup>
{/* All accounts option */}
<CommandItem
value="all-accounts"
onSelect={() => {
onAccountChange("");
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedAccount === "" ? "opacity-100" : "opacity-0",
)}
/>
<Building2 className="mr-2 h-4 w-4 text-gray-400" />
All accounts
</CommandItem>
{/* Individual accounts */}
{accounts.map((account) => (
<CommandItem
key={account.id}
value={`${account.display_name || account.name} ${account.institution_id}`}
onSelect={() => {
onAccountChange(account.id);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedAccount === account.id
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">
{account.display_name ||
account.name ||
"Unnamed Account"}
</span>
<span className="text-xs text-gray-500">
{account.institution_id}
{account.iban && `${account.iban.slice(-4)}`}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -1,121 +0,0 @@
import { X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { formatDate } from "@/lib/utils";
import type { FilterState } from "./FilterBar";
import type { Account } from "../../types/api";
export interface ActiveFilterChipsProps {
filterState: FilterState;
onFilterChange: (key: keyof FilterState, value: string) => void;
onClearFilters: () => void;
accounts?: Account[];
}
export function ActiveFilterChips({
filterState,
onFilterChange,
onClearFilters,
accounts = [],
}: ActiveFilterChipsProps) {
const chips: Array<{
key: keyof FilterState;
label: string;
value: string;
}> = [];
// Search term chip
if (filterState.searchTerm) {
chips.push({
key: "searchTerm",
label: `Search: "${filterState.searchTerm}"`,
value: filterState.searchTerm,
});
}
// Account chip
if (filterState.selectedAccount) {
const account = accounts.find(
(acc) => acc.id === filterState.selectedAccount,
);
const accountName = account
? `${account.name || "Unnamed Account"} (${account.institution_id})`
: "Unknown Account";
chips.push({
key: "selectedAccount",
label: accountName,
value: filterState.selectedAccount,
});
}
// Date range chip
if (filterState.startDate || filterState.endDate) {
let dateLabel = "Date: ";
if (filterState.startDate && filterState.endDate) {
if (filterState.startDate === filterState.endDate) {
dateLabel += formatDate(filterState.startDate);
} else {
dateLabel += `${formatDate(filterState.startDate)} - ${formatDate(filterState.endDate)}`;
}
} else if (filterState.startDate) {
dateLabel += `From ${formatDate(filterState.startDate)}`;
} else if (filterState.endDate) {
dateLabel += `Until ${formatDate(filterState.endDate)}`;
}
chips.push({
key: "startDate", // We'll clear both start and end date when removing this chip
label: dateLabel,
value: `${filterState.startDate}-${filterState.endDate}`,
});
}
const handleRemoveChip = (key: keyof FilterState) => {
switch (key) {
case "startDate":
// Clear both start and end date
onFilterChange("startDate", "");
onFilterChange("endDate", "");
break;
default:
onFilterChange(key, "");
}
};
if (chips.length === 0) {
return null;
}
return (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-gray-600 font-medium">Active filters:</span>
{chips.map((chip) => (
<Badge
key={`${chip.key}-${chip.value}`}
variant="secondary"
className="pl-3 pr-1 py-1 bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100"
>
<span className="mr-1 text-xs">{chip.label}</span>
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0 hover:bg-blue-200/50"
onClick={() => handleRemoveChip(chip.key)}
>
<X className="h-3 w-3" />
<span className="sr-only">Remove {chip.label} filter</span>
</Button>
</Badge>
))}
<Button
onClick={onClearFilters}
variant="outline"
size="sm"
className="text-muted-foreground ml-2"
>
<X className="h-4 w-4 mr-1" />
Clear All
</Button>
</div>
);
}

View File

@@ -1,199 +0,0 @@
import { useState } from "react";
import { format } from "date-fns";
import { Calendar as CalendarIcon, ChevronDown } from "lucide-react";
import type { DateRange } from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export interface DateRangePickerProps {
startDate: string;
endDate: string;
onDateRangeChange: (startDate: string, endDate: string) => void;
className?: string;
}
interface DatePreset {
label: string;
getValue: () => { startDate: string; endDate: string };
}
const datePresets: DatePreset[] = [
{
label: "Today",
getValue: () => {
const today = new Date();
return {
startDate: today.toISOString().split("T")[0],
endDate: today.toISOString().split("T")[0],
};
},
},
{
label: "Yesterday",
getValue: () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return {
startDate: yesterday.toISOString().split("T")[0],
endDate: yesterday.toISOString().split("T")[0],
};
},
},
{
label: "Last 7 days",
getValue: () => {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - 6);
return {
startDate: startDate.toISOString().split("T")[0],
endDate: endDate.toISOString().split("T")[0],
};
},
},
{
label: "Last 30 days",
getValue: () => {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - 29);
return {
startDate: startDate.toISOString().split("T")[0],
endDate: endDate.toISOString().split("T")[0],
};
},
},
{
label: "This month",
getValue: () => {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return {
startDate: startOfMonth.toISOString().split("T")[0],
endDate: endOfMonth.toISOString().split("T")[0],
};
},
},
];
export function DateRangePicker({
startDate,
endDate,
onDateRangeChange,
className,
}: DateRangePickerProps) {
const [open, setOpen] = useState(false);
// Convert string dates to Date objects for the calendar
const dateRange: DateRange | undefined =
startDate && endDate
? {
from: new Date(startDate),
to: new Date(endDate),
}
: undefined;
const handleDateRangeSelect = (range: DateRange | undefined) => {
if (range?.from && range?.to) {
onDateRangeChange(
range.from.toISOString().split("T")[0],
range.to.toISOString().split("T")[0],
);
} else if (range?.from && !range?.to) {
onDateRangeChange(
range.from.toISOString().split("T")[0],
range.from.toISOString().split("T")[0],
);
}
};
const handlePresetClick = (preset: DatePreset) => {
const { startDate: presetStart, endDate: presetEnd } = preset.getValue();
onDateRangeChange(presetStart, presetEnd);
setOpen(false);
};
const formatDateRange = () => {
if (!startDate || !endDate) {
return "Select date range";
}
const start = new Date(startDate);
const end = new Date(endDate);
// Check if it matches a preset
const matchingPreset = datePresets.find((preset) => {
const { startDate: presetStart, endDate: presetEnd } = preset.getValue();
return presetStart === startDate && presetEnd === endDate;
});
if (matchingPreset) {
return matchingPreset.label;
}
// Format custom range
if (startDate === endDate) {
return format(start, "MMM d, yyyy");
}
return `${format(start, "MMM d")} - ${format(end, "MMM d, yyyy")}`;
};
return (
<div className={cn("grid gap-2", className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"justify-between text-left font-normal",
!dateRange && "text-muted-foreground",
)}
>
<div className="flex items-center">
<CalendarIcon className="mr-2 h-4 w-4" />
{formatDateRange()}
</div>
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Card className="w-auto py-4">
<CardContent className="px-4">
<Calendar
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={handleDateRangeSelect}
className="bg-transparent p-0"
/>
</CardContent>
<CardFooter className="grid grid-cols-2 gap-1 border-t px-4 !pt-4">
{datePresets.map((preset) => (
<Button
key={preset.label}
variant="outline"
size="sm"
className="text-xs px-2 h-7"
onClick={() => handlePresetClick(preset)}
>
{preset.label}
</Button>
))}
</CardFooter>
</Card>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -1,146 +0,0 @@
import { Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { DateRangePicker } from "./DateRangePicker";
import { AccountCombobox } from "./AccountCombobox";
import { ActiveFilterChips } from "./ActiveFilterChips";
import type { Account } from "../../types/api";
export interface FilterState {
searchTerm: string;
selectedAccount: string;
startDate: string;
endDate: string;
}
export interface FilterBarProps {
filterState: FilterState;
onFilterChange: (key: keyof FilterState, value: string) => void;
onClearFilters: () => void;
accounts?: Account[];
isSearchLoading?: boolean;
className?: string;
}
export function FilterBar({
filterState,
onFilterChange,
onClearFilters,
accounts,
isSearchLoading = false,
className,
}: FilterBarProps) {
const hasActiveFilters =
filterState.searchTerm ||
filterState.selectedAccount ||
filterState.startDate ||
filterState.endDate;
const handleDateRangeChange = (startDate: string, endDate: string) => {
onFilterChange("startDate", startDate);
onFilterChange("endDate", endDate);
};
return (
<div className={cn("bg-card rounded-lg shadow border", className)}>
{/* Main Filter Bar */}
<div className="px-6 py-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-card-foreground">
Transactions
</h3>
</div>
{/* Primary Filters Row */}
<div className="space-y-4 mb-4">
{/* Desktop Layout */}
<div className="hidden lg:flex items-center justify-between gap-6">
{/* Left Side: Main Filters */}
<div className="flex items-center gap-3 flex-1">
{/* Search Input */}
<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
placeholder="Search transactions..."
value={filterState.searchTerm}
onChange={(e) => onFilterChange("searchTerm", e.target.value)}
className="pl-9 pr-8 bg-background"
/>
{isSearchLoading && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<div className="animate-spin h-4 w-4 border-2 border-border border-t-primary rounded-full"></div>
</div>
)}
</div>
{/* Account Selection */}
<AccountCombobox
accounts={accounts}
selectedAccount={filterState.selectedAccount}
onAccountChange={(accountId) =>
onFilterChange("selectedAccount", accountId)
}
className="w-[180px]"
/>
{/* Date Range Picker */}
<DateRangePicker
startDate={filterState.startDate}
endDate={filterState.endDate}
onDateRangeChange={handleDateRangeChange}
className="w-[220px]"
/>
</div>
</div>
{/* Mobile Layout */}
<div className="lg:hidden space-y-3">
{/* First Row: Search Input (Full Width) */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search..."
value={filterState.searchTerm}
onChange={(e) => onFilterChange("searchTerm", e.target.value)}
className="pl-9 pr-8 bg-background w-full"
/>
{isSearchLoading && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<div className="animate-spin h-4 w-4 border-2 border-border border-t-primary rounded-full"></div>
</div>
)}
</div>
{/* Second Row: Account Selection (Full Width) */}
<AccountCombobox
accounts={accounts}
selectedAccount={filterState.selectedAccount}
onAccountChange={(accountId) =>
onFilterChange("selectedAccount", accountId)
}
className="w-full"
/>
{/* Third Row: Date Range */}
<DateRangePicker
startDate={filterState.startDate}
endDate={filterState.endDate}
onDateRangeChange={handleDateRangeChange}
className="w-full"
/>
</div>
</div>
{/* Active Filter Chips */}
{hasActiveFilters && (
<ActiveFilterChips
filterState={filterState}
onFilterChange={onFilterChange}
onClearFilters={onClearFilters}
accounts={accounts}
/>
)}
</div>
</div>
);
}

View File

@@ -1,5 +0,0 @@
export { FilterBar } from "./FilterBar";
export { DateRangePicker } from "./DateRangePicker";
export { AccountCombobox } from "./AccountCombobox";
export { ActiveFilterChips } from "./ActiveFilterChips";
export type { FilterState, FilterBarProps } from "./FilterBar";

View File

@@ -1,59 +0,0 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };

View File

@@ -1,36 +0,0 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -1,57 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -1,211 +0,0 @@
import * as React from "react";
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months,
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next,
),
month_caption: cn(
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
defaultClassNames.month_caption,
),
dropdowns: cn(
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns,
),
dropdown_root: cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root,
),
dropdown: cn(
"bg-popover absolute inset-0 opacity-0",
defaultClassNames.dropdown,
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label,
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday,
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-[--cell-size] select-none",
defaultClassNames.week_number_header,
),
week_number: cn(
"text-muted-foreground select-none text-[0.8rem]",
defaultClassNames.week_number,
),
day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day,
),
range_start: cn(
"bg-accent rounded-l-md",
defaultClassNames.range_start,
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today,
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside,
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled,
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
);
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
);
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-[--cell-size] items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className,
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };

View File

@@ -1,86 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@@ -1,153 +0,0 @@
"use client";
import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className,
)}
{...props}
/>
);
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -1,137 +0,0 @@
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface DataTablePaginationProps {
currentPage: number;
totalPages: number;
pageSize: number;
total: number;
hasNext: boolean;
hasPrev: boolean;
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
}
export function DataTablePagination({
currentPage,
totalPages,
pageSize,
total,
hasNext,
hasPrev,
onPageChange,
onPageSizeChange,
}: DataTablePaginationProps) {
return (
<div className="flex items-center justify-between px-2 py-4">
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium text-foreground">Rows per page</p>
<Select
value={`${pageSize}`}
onValueChange={(value) => {
onPageSizeChange(Number(value));
onPageChange(1); // Reset to first page when changing page size
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 25, 50, 100].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-[100px] items-center justify-center text-sm font-medium text-foreground">
Page {currentPage} of {totalPages}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => onPageChange(1)}
disabled={currentPage === 1}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => onPageChange(currentPage - 1)}
disabled={!hasPrev}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => onPageChange(currentPage + 1)}
disabled={!hasNext}
>
<span className="sr-only">Go to next page</span>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => onPageChange(totalPages)}
disabled={currentPage === totalPages}
>
<span className="sr-only">Go to last page</span>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
<div className="text-sm text-muted-foreground">
Showing {(currentPage - 1) * pageSize + 1} to{" "}
{Math.min(currentPage * pageSize, total)} of {total} entries
</div>
</div>
{/* Mobile view */}
<div className="flex w-full items-center justify-between space-x-4 sm:hidden">
<div className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={!hasPrev}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={!hasNext}
>
Next
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,120 +0,0 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

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

View File

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

View File

@@ -1,22 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

View File

@@ -1,19 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
HTMLLabelElement,
React.LabelHTMLAttributes<HTMLLabelElement>
>(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
));
Label.displayName = "Label";
export { Label };

View File

@@ -1,46 +0,0 @@
interface LogoProps {
className?: string;
size?: number;
}
export function Logo({ className = "", size = 32 }: LogoProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 32 32"
className={className}
role="img"
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>
<defs>
<linearGradient id="logo-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#0b74de" />
<stop offset="100%" stopColor="#06b6d4" />
</linearGradient>
</defs>
{/* Square background */}
<rect width="32" height="32" fill="url(#logo-bg)" rx="4" />
{/* Italic L */}
<text
x="11"
y="22"
fontFamily="Inter, Roboto, Arial, sans-serif"
fontWeight="700"
fontSize="20"
fontStyle="italic"
fill="#fff"
>
L
</text>
</svg>
);
}

View File

@@ -1,118 +0,0 @@
import * as React from "react";
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
import type { ButtonProps } from "@/components/ui/button";
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
Pagination.displayName = "Pagination";
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
));
PaginationContent.displayName = "PaginationContent";
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
));
PaginationItem.displayName = "PaginationItem";
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">;
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className,
)}
{...props}
/>
);
PaginationLink.displayName = "PaginationLink";
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = "PaginationNext";
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = "PaginationEllipsis";
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};

View File

@@ -1,31 +0,0 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -1,26 +0,0 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-secondary",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

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

View File

@@ -1,157 +0,0 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@@ -1,29 +0,0 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@@ -1,140 +0,0 @@
"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 { cn } from "@/lib/utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<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,
)}
{...props}
ref={ref}
/>
));
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",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className,
)}
{...props}
/>
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@@ -1,779 +0,0 @@
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 {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} 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";
type SidebarContextProps = {
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);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref,
) => {
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 setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
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 contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className,
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
},
);
SidebarProvider.displayName = "SidebarProvider";
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref,
) => {
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,
)}
ref={ref}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"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]",
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
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,
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
);
},
);
Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
});
SidebarTrigger.displayName = "SidebarTrigger";
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"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,
)}
{...props}
/>
);
});
SidebarRail.displayName = "SidebarRail";
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
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,
)}
{...props}
/>
);
});
SidebarInset.displayName = "SidebarInset";
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className,
)}
{...props}
/>
);
});
SidebarInput.displayName = "SidebarInput";
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
});
SidebarHeader.displayName = "SidebarHeader";
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
});
SidebarFooter.displayName = "SidebarFooter";
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
});
SidebarSeparator.displayName = "SidebarSeparator";
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
});
SidebarContent.displayName = "SidebarContent";
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
});
SidebarGroup.displayName = "SidebarGroup";
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
data-sidebar="group-label"
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,
)}
{...props}
/>
);
});
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarGroupAction.displayName = "SidebarGroupAction";
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
));
SidebarGroupContent.displayName = "SidebarGroupContent";
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
));
SidebarMenu.displayName = "SidebarMenu";
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
));
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",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
},
);
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"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,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
));
SidebarMenuBadge.displayName = "SidebarMenuBadge";
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
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 (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
});
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
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,
)}
{...props}
/>
));
SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...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 = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring 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 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@@ -1,15 +0,0 @@
import { cn } from "@/lib/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -1,27 +0,0 @@
"use client";
import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster };

View File

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

View File

@@ -1,117 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

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

View File

@@ -1,52 +0,0 @@
import { Monitor, Moon, Sun } from "lucide-react";
import { Button } from "./button";
import { useTheme } from "../../contexts/ThemeContext";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const cycleTheme = () => {
if (theme === "light") {
setTheme("dark");
} else if (theme === "dark") {
setTheme("system");
} else {
setTheme("light");
}
};
const getIcon = () => {
switch (theme) {
case "light":
return <Sun className="h-4 w-4" />;
case "dark":
return <Moon className="h-4 w-4" />;
case "system":
return <Monitor className="h-4 w-4" />;
}
};
const getLabel = () => {
switch (theme) {
case "light":
return "Switch to dark mode";
case "dark":
return "Switch to system mode";
case "system":
return "Switch to light mode";
}
};
return (
<Button
variant="outline"
size="icon"
onClick={cycleTheme}
className="h-8 w-8"
title={getLabel()}
>
{getIcon()}
<span className="sr-only">{getLabel()}</span>
</Button>
);
}

View File

@@ -1,30 +0,0 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
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,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -1,111 +0,0 @@
import React, { createContext, useContext, useEffect, useState } from "react";
type Theme = "light" | "dark" | "system";
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
actualTheme: "light" | "dark";
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// Theme colors for different modes
const THEME_COLORS = {
light: "#0b74de", // Primary brand color
dark: "#0f0f23", // Dark background color that matches typical dark themes
} as const;
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
const stored = localStorage.getItem("theme") as Theme;
return stored || "system";
});
const [actualTheme, setActualTheme] = useState<"light" | "dark">("light");
useEffect(() => {
const root = window.document.documentElement;
const updateActualTheme = () => {
let resolvedTheme: "light" | "dark";
if (theme === "system") {
resolvedTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
} else {
resolvedTheme = theme;
}
setActualTheme(resolvedTheme);
// Remove previous theme classes
root.classList.remove("light", "dark");
// Add resolved theme class
root.classList.add(resolvedTheme);
// Update theme-color meta tags for PWA status bar
const themeColor = THEME_COLORS[resolvedTheme];
// Update theme-color meta tag
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;
if (msThemeColorMeta) {
msThemeColorMeta.content = themeColor;
}
// Update Apple status bar style for better iOS integration
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";
}
};
updateActualTheme();
// Listen for system theme changes
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (theme === "system") {
updateActualTheme();
}
};
mediaQuery.addEventListener("change", handleChange);
// Store theme preference
localStorage.setItem("theme", theme);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme, actualTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}

View File

@@ -1,21 +0,0 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
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);
}, []);
return !!isMobile;
}

View File

@@ -1,94 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 219 91% 46%;
--primary-foreground: 210 40% 98%;
--secondary: 189 94% 43%;
--secondary-foreground: 210 40% 98%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
/* iOS Safe Area Support for PWA */
--safe-area-inset-top: env(safe-area-inset-top, 0px);
--safe-area-inset-right: env(safe-area-inset-right, 0px);
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-inset-left: env(safe-area-inset-left, 0px);
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 219 91% 46%;
--primary-foreground: 210 40% 98%;
--secondary: 189 94% 43%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@layer base {
* {
@apply border-border;
}
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);
}
}

View File

@@ -2,7 +2,6 @@ import axios from "axios";
import type {
Account,
Transaction,
AnalyticsTransaction,
Balance,
ApiResponse,
NotificationSettings,
@@ -10,17 +9,6 @@ import type {
NotificationService,
NotificationServicesResponse,
HealthData,
AccountUpdate,
TransactionStats,
SyncOperationsResponse,
BankInstitution,
BankConnectionStatus,
BankRequisition,
Country,
BackupSettings,
BackupTest,
BackupInfo,
BackupOperation,
} from "../types/api";
// Use VITE_API_URL for development, relative URLs for production
@@ -46,38 +34,12 @@ export const apiClient = {
return response.data.data;
},
// Update account details
updateAccount: async (
id: string,
updates: AccountUpdate,
): Promise<{ id: string; display_name?: string }> => {
const response = await api.put<
ApiResponse<{ id: string; display_name?: string }>
>(`/accounts/${id}`, updates);
return response.data.data;
},
// Get all balances
getBalances: async (): Promise<Balance[]> => {
const response = await api.get<ApiResponse<Balance[]>>("/balances");
return response.data.data;
},
// Get historical balances for balance progression chart
getHistoricalBalances: async (
days?: number,
accountId?: string,
): Promise<Balance[]> => {
const queryParams = new URLSearchParams();
if (days) queryParams.append("days", days.toString());
if (accountId) queryParams.append("account_id", accountId);
const response = await api.get<ApiResponse<Balance[]>>(
`/balances/history?${queryParams.toString()}`,
);
return response.data.data;
},
// Get balances for specific account
getAccountBalances: async (accountId: string): Promise<Balance[]> => {
const response = await api.get<ApiResponse<Balance[]>>(
@@ -94,33 +56,21 @@ export const apiClient = {
page?: number;
perPage?: number;
search?: string;
summaryOnly?: boolean;
minAmount?: number;
maxAmount?: number;
}): Promise<ApiResponse<Transaction[]>> => {
}): Promise<Transaction[]> => {
const queryParams = new URLSearchParams();
if (params?.accountId) queryParams.append("account_id", params.accountId);
if (params?.startDate) queryParams.append("date_from", params.startDate);
if (params?.endDate) queryParams.append("date_to", params.endDate);
if (params?.startDate) queryParams.append("start_date", params.startDate);
if (params?.endDate) queryParams.append("end_date", params.endDate);
if (params?.page) queryParams.append("page", params.page.toString());
if (params?.perPage)
queryParams.append("per_page", params.perPage.toString());
if (params?.search) queryParams.append("search", params.search);
if (params?.summaryOnly !== undefined) {
queryParams.append("summary_only", params.summaryOnly.toString());
}
if (params?.minAmount !== undefined) {
queryParams.append("min_amount", params.minAmount.toString());
}
if (params?.maxAmount !== undefined) {
queryParams.append("max_amount", params.maxAmount.toString());
}
const response = await api.get<ApiResponse<Transaction[]>>(
`/transactions?${queryParams.toString()}`,
);
return response.data;
return response.data.data;
},
// Get transaction by ID
@@ -175,141 +125,6 @@ export const apiClient = {
const response = await api.get<ApiResponse<HealthData>>("/health");
return response.data.data;
},
// Analytics endpoints
getTransactionStats: async (days?: number): Promise<TransactionStats> => {
const queryParams = new URLSearchParams();
if (days) queryParams.append("days", days.toString());
const response = await api.get<ApiResponse<TransactionStats>>(
`/transactions/stats?${queryParams.toString()}`,
);
return response.data.data;
},
// Get all transactions for analytics (no pagination)
getTransactionsForAnalytics: async (
days?: number,
): Promise<AnalyticsTransaction[]> => {
const queryParams = new URLSearchParams();
if (days) queryParams.append("days", days.toString());
const response = await api.get<ApiResponse<AnalyticsTransaction[]>>(
`/transactions/analytics?${queryParams.toString()}`,
);
return response.data.data;
},
// Get monthly transaction statistics (pre-calculated)
getMonthlyTransactionStats: async (
days?: number,
): Promise<
Array<{
month: string;
income: number;
expenses: number;
net: number;
}>
> => {
const queryParams = new URLSearchParams();
if (days) queryParams.append("days", days.toString());
const response = await api.get<
ApiResponse<
Array<{
month: string;
income: number;
expenses: number;
net: number;
}>
>
>(`/transactions/monthly-stats?${queryParams.toString()}`);
return response.data.data;
},
// Get sync operations history
getSyncOperations: async (
limit: number = 50,
offset: number = 0,
): Promise<SyncOperationsResponse> => {
const response = await api.get<ApiResponse<SyncOperationsResponse>>(
`/sync/operations?limit=${limit}&offset=${offset}`,
);
return response.data.data;
},
// Bank management endpoints
getBankInstitutions: async (country: string): Promise<BankInstitution[]> => {
const response = await api.get<ApiResponse<BankInstitution[]>>(
`/banks/institutions?country=${country}`,
);
return response.data.data;
},
getBankConnectionsStatus: async (): Promise<BankConnectionStatus[]> => {
const response =
await api.get<ApiResponse<BankConnectionStatus[]>>("/banks/status");
return response.data.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<ApiResponse<BankRequisition>>(
"/banks/connect",
{
institution_id: institutionId,
redirect_url: finalRedirectUrl,
},
);
return response.data.data;
},
deleteBankConnection: async (requisitionId: string): Promise<void> => {
await api.delete(`/banks/connections/${requisitionId}`);
},
getSupportedCountries: async (): Promise<Country[]> => {
const response = await api.get<ApiResponse<Country[]>>("/banks/countries");
return response.data.data;
},
// Backup endpoints
getBackupSettings: async (): Promise<BackupSettings> => {
const response =
await api.get<ApiResponse<BackupSettings>>("/backup/settings");
return response.data.data;
},
updateBackupSettings: async (
settings: BackupSettings,
): Promise<BackupSettings> => {
const response = await api.put<ApiResponse<BackupSettings>>(
"/backup/settings",
settings,
);
return response.data.data;
},
testBackupConnection: async (test: BackupTest): Promise<ApiResponse<{ connected?: boolean }>> => {
const response = await api.post<ApiResponse<{ connected?: boolean }>>("/backup/test", test);
return response.data;
},
listBackups: async (): Promise<BackupInfo[]> => {
const response = await api.get<ApiResponse<BackupInfo[]>>("/backup/list");
return response.data.data;
},
performBackupOperation: async (operation: BackupOperation): Promise<ApiResponse<{ operation: string; completed: boolean }>> => {
const response = await api.post<ApiResponse<{ operation: string; completed: boolean }>>("/backup/operation", operation);
return response.data;
},
};
export default apiClient;

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