Compare commits

...

66 Commits

Author SHA1 Message Date
Elisiário Couto
bfb5a7ef76 chore(ci): Bump version to 2025.9.12 2025-09-16 00:14:10 +01:00
copilot-swe-agent[bot]
95b3b93a8a Restore original package.json dev script with VITE_API_URL
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-16 00:12:50 +01:00
Elisiário Couto
9a2199873c Delete frontend/.env.development 2025-09-16 00:12:50 +01:00
copilot-swe-agent[bot]
82a12dadad Complete display_name feature with frontend integration and testing
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-16 00:12:50 +01:00
copilot-swe-agent[bot]
33a7ad5ad2 Implement display_name field with migration and API support
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-16 00:12:50 +01:00
Elisiário Couto
3352e110b8 chore(ci): Bump version to 2025.9.11 2025-09-15 01:49:51 +01:00
Elisiário Couto
74a700ff87 fix(frontend): Add ignore rules for eslint on shadcn components. 2025-09-15 01:47:50 +01:00
Elisiário Couto
66db34c712 feat(frontend): Complete shadcn/ui migration with dark mode support and analytics updates.
- Convert all analytics components to use shadcn Card and semantic colors
- Update RawTransactionModal with proper shadcn styling and theme support
- Fix all remaining hardcoded colors to use CSS variables (bg-card, text-foreground, etc.)
- Ensure consistent theming across light/dark modes for all components
- Add custom tooltips with semantic colors for chart components

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-15 01:30:34 +01:00
Elisiário Couto
eb27f19196 feat(frontend): Replace heavy filter UI with modern shadcn/ui inline filter bar.
- Replace large collapsible filter panel with streamlined horizontal filter bar
- Integrate shadcn/ui component library with Button, Popover, Select, Badge, Input, Calendar, Command components
- Create modular filter components: FilterBar, DateRangePicker, AccountCombobox, ActiveFilterChips, AdvancedFiltersPopover
- Add enhanced date picker with smart presets (Last 7 days, This week, This month, This year) and calendar selection
- Implement searchable account selection with better UX and autocomplete
- Add active filter chips with one-click individual removal for clear visual feedback
- Move secondary filters (amount range) to clean popover interface
- Consolidate filter state management into single FilterState object with proper TypeScript coverage
- Maintain all existing functionality while reducing vertical space usage by 60%
- Add responsive design that adapts to desktop, tablet, and mobile screen sizes
- Include built-in accessibility features (keyboard navigation, ARIA support)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-15 00:21:59 +01:00
Elisiário Couto
969776fb53 feat(frontend): Enhance transactions page with advanced filtering and UI improvements.
- Fix amount range filters (min/max) connection to API parameters
- Add enhanced quick date filters: "This week", "This year" alongside existing options
- Create skeleton loading components (TransactionSkeleton, FiltersSkeleton) to replace simple spinner
- Add running balance column with toggle functionality for both desktop and mobile views
- Consolidate TransactionsList.tsx and TransactionsTable.tsx into single comprehensive component
- Improve UI design with better visual hierarchy, spacing, and responsive layout
- Add proper TypeScript types and fix linting issues

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 23:57:23 +01:00
Elisiário Couto
077e2bb1ad refactor(analytics): Simplify analytics endpoints and eliminate client-side processing.
- Add /transactions/monthly-stats endpoint with SQL aggregation
- Replace client-side monthly processing with server-side calculations
- Reduce data transfer by 99.5% (2,507 → 13 records for yearly data)
- Simplify MonthlyTrends component by removing 40+ lines of aggregation logic
- Clean up unused imports and interfaces

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 23:02:58 +01:00
Elisiário Couto
da98b7b2b7 chore: Check import order using ruff. 2025-09-14 21:12:47 +01:00
Elisiário Couto
2467cb2f5a chore: Sort imports, fix deprecated pydantic option. 2025-09-14 21:11:01 +01:00
Elisiário Couto
5ae3a51d81 refactor: Consolidate database layer and eliminate wrapper complexity.
- Merge leggen/database/sqlite.py functionality directly into DatabaseService
- Extract transaction processing logic to separate TransactionProcessor class
- Remove leggen/utils/database.py and leggen/database/ directory entirely
- Update all tests to use new consolidated structure
- Reduce codebase by ~300 lines while maintaining full functionality
- Improve separation of concerns: data processing vs persistence vs CLI

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 21:01:16 +01:00
Elisiário Couto
d09cf6d04c fix(config): Fix example config file. 2025-09-14 20:31:49 +01:00
Elisiário Couto
2c6e099596 fix(config): Add Pydantic validation and fix telegram config field mappings.
* Add Pydantic models for configuration validation in leggen/models/config.py
* Fix telegram config field aliases (api-key -> token, chat-id -> chat_id)
* Update config.py to use Pydantic validation with proper error handling
* Fix TOML serialization by excluding None values with exclude_none=True
* Update notification service to use correct telegram field names
* Enhance notification service with actual Discord/Telegram implementations
* Fix all failing configuration tests to work with Pydantic validation
* Add pydantic dependency to pyproject.toml

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 20:31:49 +01:00
copilot-swe-agent[bot]
990d0295b3 Remove Total Balance card from Analytics view
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 19:09:19 +01:00
Elisiário Couto
318ca517f7 refactor: Unify leggen and leggend packages into single leggen package
- Merge leggend API components into leggen (api/, services/, background/)
- Replace leggend command with 'leggen server' subcommand
- Consolidate configuration systems into leggen.utils.config
- Update environment variables: LEGGEND_API_URL -> LEGGEN_API_URL
- Rename LeggendAPIClient -> LeggenAPIClient
- Update all documentation, Docker configs, and compose files
- Fix all import statements and test references
- Remove duplicate utility files and clean up package structure

All tests passing (101/101), linting clean, server functionality preserved.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 18:06:13 +01:00
copilot-swe-agent[bot]
0e645d9bae Fix MonthlyTrends date parsing and add AnalyticsTransaction interface
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
d51aa9429e Fix MonthlyTrends dynamic title, remove Period Summary, convert BalanceChart to stacked area chart
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
c8f0a103c6 fix: Resolve all CI failures - linting, typing, and test issues
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
5987a759b8 Remove redundant Analytics Dashboard header section
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
6bfbed8fb6 Fix date parsing and add time period filters to Analytics dashboard
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
b7e4ec4a1b Fix Balance Progress Over Time chart by adding historical balance endpoint
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
35b6d98e6a fix(frontend): Align balance calculation between sidebar and Analytics page
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
3e248f95a8 Address PR feedback: add TODO, remove enhanced-stats, keep stats endpoint
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
e136fc4b75 feat(analytics): Fix transaction limits and improve chart legends
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-14 01:01:25 +01:00
copilot-swe-agent[bot]
692bee574e fix(docs): Remove test files and update gitignore
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-13 19:42:53 +01:00
copilot-swe-agent[bot]
482f16c77e feat(docs): Add configuration file setup to agent instructions
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-13 19:42:53 +01:00
copilot-swe-agent[bot]
c6ac4455f8 feat(docs): Add comprehensive copilot agent setup instructions
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-13 19:42:53 +01:00
Elisiário Couto
ac0fedd8b2 chore(ci): Bump version to 2025.9.10 2025-09-13 12:20:55 +01:00
Elisiário Couto
06cf02f43f chore(frontend): Update dependencies. 2025-09-12 18:30:17 +01:00
copilot-swe-agent[bot]
23aa8b08d4 Implement comprehensive Analytics Dashboard with charts and financial insights
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-12 18:30:17 +01:00
Elisiário Couto
2b69b1e27b Delete config.toml 2025-09-12 17:50:58 +01:00
copilot-swe-agent[bot]
4dec8113fe Implement mobile UI improvements with status indicators and responsive layout
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-12 17:50:58 +01:00
copilot-swe-agent[bot]
28534e97c0 Fix mobile UI issues in accounts page with responsive layout improvements
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-12 17:50:58 +01:00
copilot-swe-agent[bot]
43b6f32145 Initial analysis: Mobile UI issues identified in accounts page
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-12 17:50:58 +01:00
Elisiário Couto
b3eab6ae26 chore(ci): Bump version to 2025.9.9 2025-09-12 00:35:04 +01:00
copilot-swe-agent[bot]
a5d10b3539 feat: Remove config.toml file - should be created when needed
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-12 00:32:06 +01:00
copilot-swe-agent[bot]
1c901a9dda feat(frontend): Improve transactions table mobile UX with responsive card layout
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-12 00:32:06 +01:00
copilot-swe-agent[bot]
1e94333d8f feat(frontend): Improve transactions table mobile UX with responsive card layout
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-12 00:32:06 +01:00
copilot-swe-agent[bot]
4006dd128e fix(core): Handle permission errors gracefully in database path creation.
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-12 00:01:05 +01:00
copilot-swe-agent[bot]
7d9744a40e refactor(core): Integrate directory creation with database path retrieval and remove backup file.
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-12 00:01:05 +01:00
copilot-swe-agent[bot]
8654471042 Add tests for configurable paths and finalize implementation
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-12 00:01:05 +01:00
copilot-swe-agent[bot]
e9711339bd Add centralized path management and sample database generator
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-12 00:01:05 +01:00
Elisiário Couto
0c030efef2 chore(ci): Bump version to 2025.9.8 2025-09-11 18:50:09 +01:00
copilot-swe-agent[bot]
e4e04ea34e feat: update CI workflow to use Node.js 20 instead of 18
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-11 18:48:01 +01:00
copilot-swe-agent[bot]
f4bf549b99 fix: change branch name from develop to dev in CI workflow
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-11 18:48:01 +01:00
copilot-swe-agent[bot]
8cc4f567f8 Update README with CI/CD pipeline information
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-11 18:48:01 +01:00
copilot-swe-agent[bot]
a939b841f3 Add GitHub Actions CI workflow and enhance release workflow
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-11 18:48:01 +01:00
Elisiário Couto
caa43e8eb0 chore(ci): Bump version to 2025.9.7 2025-09-11 14:26:40 +01:00
Elisiário Couto
0a8750ea36 Fix tests. 2025-09-11 14:26:20 +01:00
Elisiário Couto
2d6800eff8 feat: improve transactions API pagination and search
- Update backend /transactions endpoint to use PaginatedResponse
- Change from limit/offset to page/per_page parameters for consistency
- Implement server-side pagination with proper metadata
- Add search debouncing to prevent excessive API calls (300ms delay)
- Add First/Last page buttons to pagination controls
- Fix pagination state reset when filters return 0 results
- Reset pagination to page 1 when filters are applied
- Add visual loading indicator during search debouncing
- Update frontend types and API client to handle new response structure
- Fix TypeScript errors and improve type safety
2025-09-11 14:13:58 +01:00
Elisiário Couto
544527f282 feat(frontend): implement TanStack Table for transactions view
- Add @tanstack/react-table package for advanced table functionality
- Create new TransactionsTable component with sorting, pagination, and filtering
- Implement column sorting for description, amount, and date
- Add pagination with configurable page sizes (10, 25, 50, 100)
- Implement global search across multiple fields (description, creditor, debtor, reference)
- Add quick date filters (Last 7 days, Last 30 days, This month)
- Add amount range filtering (min/max)
- Ensure mobile responsiveness with proper table layout
- Integrate RawTransactionModal with table actions
- Replace TransactionsList with TransactionsTable in routes
- Fix table freezing issue by removing conflicting filtering logic
- Optimize performance with TanStack Table's built-in state management
2025-09-11 12:39:42 +01:00
Elisiário Couto
91020e32ea fix: Simplify notification settings and fix notification test on dashboard. 2025-09-11 12:16:47 +01:00
Elisiário Couto
5a823d62f0 chore(ci): Bump version to 2025.9.6 2025-09-10 23:37:08 +01:00
Elisiário Couto
a00d6ce2ce feat(db): migrate transactions table to composite primary key
- Change primary key from internalTransactionId to (accountId, transactionId)
- Add transactionId as stable bank-provided identifier
- Update INSERT to INSERT OR REPLACE for upsert behavior
- Update migration detection logic for composite key structure
- Update tests to include transactionId in sample data
2025-09-10 23:36:09 +01:00
Elisiário Couto
f47644e8c6 chore(ci): Bump version to 2025.9.5 2025-09-10 23:17:19 +01:00
Elisiário Couto
c0ee21d6fa fix: correct composite key migration check
- Fix _check_composite_key_migration_needed to properly check if internalTransactionId is the primary key
- Use PRAGMA table_info pk flag instead of just checking column existence
- This ensures migration only runs when internalTransactionId is actually the primary key
2025-09-10 23:16:42 +01:00
Elisiário Couto
7dd33084f5 chore(ci): Bump version to 2025.9.4 2025-09-10 22:55:24 +01:00
Elisiário Couto
ca41b7af0a feat(frontend): implement TanStack Router with mobile sidebar
- Install and configure TanStack Router for type-safe routing
- Create route structure with __root.tsx layout and individual route files
- Implement mobile-responsive sidebar with collapse functionality
- Add clickable logo in sidebar that navigates to overview page
- Extract Header and Sidebar components from Dashboard for reusability
- Configure Vite with TanStack Router plugin for development
- Update main.tsx to use RouterProvider instead of direct App rendering
- Maintain existing TanStack Query integration seamlessly
- Add proper TypeScript types for all route components
- Implement responsive design with mobile overlay and hamburger menu

This replaces the tab-based navigation with URL-based routing while
maintaining the same user experience and adding powerful features like:
- Bookmarkable URLs (/transactions, /analytics, /notifications)
- Browser back/forward button support
- Direct linking capabilities
- Mobile-responsive sidebar with smooth animations
- Type-safe navigation with auto-completion
2025-09-10 22:45:01 +01:00
Elisiário Couto
aa97f36819 feat(frontend): add account name editing functionality
- Add AccountUpdate interface to TypeScript types
- Add updateAccount method to API client for PUT /api/v1/accounts/{id}
- Implement inline editing UI in AccountsOverview component
- Add edit/save/cancel buttons with proper state management
- Handle keyboard shortcuts (Enter to save, Escape to cancel)
- Add loading states and error handling for account updates
- Use React Query mutations for optimistic updates
- Refresh account data after successful updates

This enables users to edit account names directly in the Accounts view
using the new API endpoint that was added in the backend.
2025-09-10 22:07:32 +01:00
Elisiário Couto
d9c50d1298 feat(api): add currency extraction and account name updates
- Extract currency from balances and populate account currency field
- Add PUT /api/v1/accounts/{account_id} endpoint for updating account names
- Add AccountUpdate Pydantic model for request validation
- Modify sync service to enrich account details with balance currency

This resolves the issue where account currency and name fields were NULL
by extracting currency from GoCardless balance data and providing an API
endpoint for manual account name updates.
2025-09-10 21:48:07 +01:00
Elisiário Couto
61fafecb78 feat(frontend): adapt to composite key transaction structure
- Update Transaction interface to include stable transaction_id field
- Modify TransactionsList to use stable transaction_id for React keys
- Update API models to handle new transactionId field from database
- Fix API routes to properly map transaction_id in responses
- Update test mocks to include transactionId field
- Ensure backward compatibility with internal_transaction_id

This adapts the frontend to work with the new composite primary key
(accountId, transactionId) structure that prevents duplicate transactions.
2025-09-10 21:11:26 +01:00
Elisiário Couto
13e92ccd34 fix(api): resolve duplicate transactions with composite key migration
- Migrate transactions table to use (accountId, transactionId) composite primary key
- Replace unstable internalTransactionId with stable bank-provided transactionId
- Update persistence logic to use INSERT OR REPLACE for automatic conflict resolution
- Maintain API compatibility by preserving internalTransactionId field
- Update tests to match new transaction processing format

This resolves the issue where GoCardless returns different internalTransactionId
values for the same transaction across sync operations, causing duplicates.
2025-09-10 20:00:43 +01:00
Elisiário Couto
433ba3faf9 feat(web): Add modal to view raw transaction. 2025-09-10 19:57:38 +01:00
121 changed files with 11433 additions and 3404 deletions

55
.github/workflows/ci.yml vendored Normal file
View File

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

View File

@@ -133,3 +133,34 @@ jobs:
push: true push: true
tags: ${{ steps.meta-frontend.outputs.tags }} tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }} labels: ${{ steps.meta-frontend.outputs.labels }}
create-github-release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs: [build, publish-to-pypi, push-docker-backend, push-docker-frontend]
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install git-cliff
run: |
wget -qO- https://github.com/orhun/git-cliff/releases/latest/download/git-cliff-2.10.0-x86_64-unknown-linux-gnu.tar.gz | tar xz
sudo mv git-cliff-*/git-cliff /usr/local/bin/
- name: Generate release notes
id: release_notes
run: |
echo "notes<<EOF" >> $GITHUB_OUTPUT
git-cliff --current >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }}
body: ${{ steps.release_notes.outputs.notes }}
draft: false
prerelease: false

2
.gitignore vendored
View File

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

View File

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

View File

@@ -1,5 +1,55 @@
# Agent Guidelines for Leggen # Agent Guidelines for Leggen
## Quick Setup for Development
### Prerequisites
- **uv** must be installed for Python dependency management (can be installed via `pip install uv`)
- **Configuration file**: Copy `config.example.toml` to `config.toml` before running any commands:
```bash
cp config.example.toml config.toml
```
### Generate Mock Database
The leggen CLI provides a command to generate a mock database for testing:
```bash
# Generate sample database with default settings (3 accounts, 50 transactions each)
uv run leggen --config config.toml generate_sample_db --database /path/to/test.db --force
# Custom configuration
uv run leggen --config config.toml generate_sample_db --database ./test-data.db --accounts 5 --transactions 100 --force
```
The command outputs instructions for setting the required environment variable to use the generated database.
### Start the API Server
1. Install uv if not already installed: `pip install uv`
2. Set the database environment variable to point to your generated mock database:
```bash
export LEGGEN_DATABASE_PATH=/path/to/your/generated/database.db
```
3. Ensure the API can find the configuration file (choose one):
```bash
# Option 1: Copy config to the expected location
mkdir -p ~/.config/leggen && cp config.toml ~/.config/leggen/config.toml
# Option 2: Set environment variable to current config file
export LEGGEN_CONFIG_FILE=./config.toml
```
4. Start the API server:
```bash
uv run leggen server
```
- For development mode with auto-reload: `uv run leggen server --reload`
- API will be available at `http://localhost:8000` with docs at `http://localhost:8000/docs`
### Start the Frontend
1. Navigate to the frontend directory: `cd frontend`
2. Install npm dependencies: `npm install`
3. Start the development server: `npm run dev`
- Frontend will be available at `http://localhost:3000`
- The frontend is configured to connect to the API at `http://localhost:8000/api/v1`
## Build/Lint/Test Commands ## Build/Lint/Test Commands
### Frontend (React/TypeScript) ### Frontend (React/TypeScript)
@@ -10,7 +60,7 @@
### Backend (Python) ### Backend (Python)
- **Lint**: `uv run ruff check .` - **Lint**: `uv run ruff check .`
- **Format**: `uv run ruff format .` - **Format**: `uv run ruff format .`
- **Type check**: `uv run mypy leggen leggend --check-untyped-defs` - **Type check**: `uv run mypy leggen --check-untyped-defs`
- **All checks**: `uv run pre-commit run --all-files` - **All checks**: `uv run pre-commit run --all-files`
- **Run all tests**: `uv run pytest` - **Run all tests**: `uv run pytest`
- **Run single test**: `uv run pytest tests/unit/test_api_accounts.py::TestAccountsAPI::test_get_all_accounts_success -v` - **Run single test**: `uv run pytest tests/unit/test_api_accounts.py::TestAccountsAPI::test_get_all_accounts_success -v`
@@ -37,6 +87,20 @@
### General ### General
- **Formatting**: ruff for Python, ESLint for TypeScript - **Formatting**: ruff for Python, ESLint for TypeScript
- **Commits**: Use conventional commits, run pre-commit hooks before pushing - **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 - Avoid including specific numbers, counts, or data-dependent information that may become outdated
- **Security**: Never log sensitive data, use environment variables for secrets - **Security**: Never log sensitive data, use environment variables for secrets
## Contributing Guidelines
This repository follows conventional changelog practices. Refer to `CONTRIBUTING.md` for detailed contribution guidelines including:
- Commit message format and scoping
- Release process using `scripts/release.sh`
- Pre-commit hooks setup with `pre-commit install`

View File

@@ -1,4 +1,275 @@
## 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) ## 2025.9.3 (2025/09/10)
### Miscellaneous Tasks ### Miscellaneous Tasks

1
CLAUDE.md Symbolic link
View File

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

View File

@@ -18,7 +18,7 @@ FROM python:3.13-alpine
LABEL org.opencontainers.image.source="https://github.com/elisiariocouto/leggen" LABEL org.opencontainers.image.source="https://github.com/elisiariocouto/leggen"
LABEL org.opencontainers.image.authors="Elisiário Couto <elisiario@couto.io>" LABEL org.opencontainers.image.authors="Elisiário Couto <elisiario@couto.io>"
LABEL org.opencontainers.image.licenses="MIT" LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.title="Leggend API" LABEL org.opencontainers.image.title="Leggen API"
LABEL org.opencontainers.image.description="Open Banking API for Leggen" LABEL org.opencontainers.image.description="Open Banking API for Leggen"
LABEL org.opencontainers.image.url="https://github.com/elisiariocouto/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 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/leggend"] CMD ["/app/.venv/bin/leggen", "server"]

View File

@@ -2,14 +2,14 @@
An Open Banking CLI and API service for managing bank connections and transactions. An Open Banking CLI and API service for managing bank connections and transactions.
This tool provides **FastAPI backend service** (`leggend`), a **React Web Interface** and a **command-line interface** (`leggen`) to connect to banks using the GoCardless Open Banking API. This tool provides a **unified command-line interface** (`leggen`) with both CLI commands and an integrated **FastAPI backend service**, plus a **React Web Interface** to connect to banks using the GoCardless Open Banking API.
Having your bank data accessible through both CLI and REST API gives you the power to backup, analyze, create reports, and integrate with other applications. Having your bank data accessible through both CLI and REST API gives you the power to backup, analyze, create reports, and integrate with other applications.
## 🛠️ Technologies ## 🛠️ Technologies
### 🔌 API & Backend ### 🔌 API & Backend
- [FastAPI](https://fastapi.tiangolo.com/): High-performance async API backend (`leggend` service) - [FastAPI](https://fastapi.tiangolo.com/): High-performance async API backend (integrated into `leggen server`)
- [GoCardless Open Banking API](https://developer.gocardless.com/bank-account-data/overview): for connecting to banks - [GoCardless Open Banking API](https://developer.gocardless.com/bank-account-data/overview): for connecting to banks
- [APScheduler](https://apscheduler.readthedocs.io/): Background job scheduling with configurable cron - [APScheduler](https://apscheduler.readthedocs.io/): Background job scheduling with configurable cron
@@ -107,7 +107,7 @@ For development or local installation:
uv sync # or pip install -e . uv sync # or pip install -e .
# Start the API service # Start the API service
uv run leggend --reload # Development mode with auto-reload uv run leggen server --reload # Development mode with auto-reload
# Use the CLI (in another terminal) # Use the CLI (in another terminal)
uv run leggen --help uv run leggen --help
@@ -152,19 +152,19 @@ case-sensitive = ["SpecificStore"]
## 📖 Usage ## 📖 Usage
### API Service (`leggend`) ### API Service (`leggen server`)
Start the FastAPI backend service: Start the FastAPI backend service:
```bash ```bash
# Production mode # Production mode
leggend leggen server
# Development mode with auto-reload # Development mode with auto-reload
leggend --reload leggen server --reload
# Custom host and port # Custom host and port
leggend --host 127.0.0.1 --port 8080 leggen server --host 127.0.0.1 --port 8080
``` ```
**API Documentation**: Visit `http://localhost:8000/docs` for interactive API documentation. **API Documentation**: Visit `http://localhost:8000/docs` for interactive API documentation.
@@ -207,7 +207,7 @@ leggen sync --force --wait
leggen --api-url http://localhost:8080 status leggen --api-url http://localhost:8080 status
# Set via environment variable # Set via environment variable
export LEGGEND_API_URL=http://localhost:8080 export LEGGEN_API_URL=http://localhost:8080
leggen status leggen status
``` ```
@@ -223,7 +223,7 @@ docker compose -f compose.dev.yml ps
# Check logs # Check logs
docker compose -f compose.dev.yml logs frontend docker compose -f compose.dev.yml logs frontend
docker compose -f compose.dev.yml logs leggend docker compose -f compose.dev.yml logs leggen-server
# Stop development services # Stop development services
docker compose -f compose.dev.yml down docker compose -f compose.dev.yml down
@@ -239,7 +239,7 @@ docker compose ps
# Check logs # Check logs
docker compose logs frontend docker compose logs frontend
docker compose logs leggend docker compose logs leggen-server
# Access the web interface at http://localhost:3000 # Access the web interface at http://localhost:3000
# API documentation at http://localhost:8000/docs # API documentation at http://localhost:8000/docs
@@ -290,7 +290,7 @@ cd leggen
uv sync uv sync
# Start API service with auto-reload # Start API service with auto-reload
uv run leggend --reload uv run leggen server --reload
# Use CLI commands # Use CLI commands
uv run leggen status uv run leggen status
@@ -333,13 +333,10 @@ The test suite includes:
leggen/ # CLI application leggen/ # CLI application
├── commands/ # CLI command implementations ├── commands/ # CLI command implementations
├── utils/ # Shared utilities ├── utils/ # Shared utilities
── api_client.py # API client for leggend service ── api/ # FastAPI API routes and models
leggend/ # FastAPI backend service
├── api/ # API routes and models
├── services/ # Business logic ├── services/ # Business logic
├── background/ # Background job scheduler ├── background/ # Background job scheduler
└── main.py # FastAPI application └── api_client.py # API client for server communication
tests/ # Test suite tests/ # Test suite
├── conftest.py # Shared test fixtures ├── conftest.py # Shared test fixtures
@@ -357,6 +354,10 @@ tests/ # Test suite
3. Make your changes with tests 3. Make your changes with tests
4. Submit a pull request 4. Submit a pull request
The repository uses GitHub Actions for CI/CD:
- **CI**: Runs Python tests (`uv run pytest`) and frontend linting/build on every push
- **Release**: Creates GitHub releases with changelog when tags are pushed
## ⚠️ Notes ## ⚠️ Notes
- This project is in active development - This project is in active development
- GoCardless API rate limits apply - GoCardless API rate limits apply

View File

@@ -8,13 +8,13 @@ services:
ports: ports:
- "127.0.0.1:3000:80" - "127.0.0.1:3000:80"
environment: environment:
- API_BACKEND_URL=${API_BACKEND_URL:-http://leggend:8000} - API_BACKEND_URL=${API_BACKEND_URL:-http://leggen-server:8000}
depends_on: depends_on:
leggend: leggen-server:
condition: service_healthy condition: service_healthy
# FastAPI backend service # FastAPI backend service
leggend: leggen-server:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile

View File

@@ -6,11 +6,11 @@ services:
ports: ports:
- "127.0.0.1:3000:80" - "127.0.0.1:3000:80"
depends_on: depends_on:
leggend: leggen-server:
condition: service_healthy condition: service_healthy
# FastAPI backend service # FastAPI backend service
leggend: leggen-server:
image: ghcr.io/elisiariocouto/leggen:latest image: ghcr.io/elisiariocouto/leggen:latest
restart: "unless-stopped" restart: "unless-stopped"
ports: ports:

View File

@@ -20,8 +20,8 @@ enabled = true
# Optional: Telegram notifications # Optional: Telegram notifications
[notifications.telegram] [notifications.telegram]
token = "your-bot-token" api-key = "your-bot-token"
chat_id = 12345 chat-id = 12345
enabled = true enabled = true
# Optional: Transaction filters for notifications # Optional: Transaction filters for notifications

View File

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

View File

@@ -25,7 +25,7 @@ COPY --from=builder /app/dist /usr/share/nginx/html
COPY default.conf.template /etc/nginx/templates/default.conf.template COPY default.conf.template /etc/nginx/templates/default.conf.template
# Set default API backend URL (can be overridden at runtime) # Set default API backend URL (can be overridden at runtime)
ENV API_BACKEND_URL=http://leggend:8000 ENV API_BACKEND_URL=http://leggen-server:8000
# Expose port 80 # Expose port 80
EXPOSE 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 - Uses relative URLs (`/api/v1`) that nginx proxies to the backend
- Configure nginx proxy target via `API_BACKEND_URL` environment variable - Configure nginx proxy target via `API_BACKEND_URL` environment variable
- Default: `http://leggend:8000` - Default: `http://leggen-server:8000`
**Docker Compose:** **Docker Compose:**

22
frontend/components.json Normal file
View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -10,19 +10,34 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tanstack/react-query": "^5.87.1", "@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", "autoprefixer": "^10.4.21",
"axios": "^1.11.0", "axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.542.0", "lucide-react": "^0.542.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"react": "^19.1.1", "react": "^19.1.1",
"react-day-picker": "^9.10.0",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"tailwindcss": "^3.4.17" "recharts": "^3.2.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.33.0", "@eslint/js": "^9.33.0",
"@tanstack/router-vite-plugin": "^1.131.36",
"@types/react": "^19.1.10", "@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",

View File

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

View File

@@ -1,197 +0,0 @@
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

@@ -0,0 +1,70 @@
export default function FiltersSkeleton() {
return (
<div className="bg-white rounded-lg shadow animate-pulse">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="h-6 bg-gray-200 rounded w-32"></div>
<div className="flex items-center space-x-2">
<div className="h-8 bg-gray-200 rounded w-24"></div>
<div className="h-8 bg-gray-200 rounded w-20"></div>
</div>
</div>
</div>
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
{/* Quick Date Filters Skeleton */}
<div className="mb-6">
<div className="h-4 bg-gray-200 rounded w-32 mb-3"></div>
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<div className="h-10 bg-gray-200 rounded-lg w-24"></div>
<div className="h-10 bg-gray-200 rounded-lg w-20"></div>
<div className="h-10 bg-gray-200 rounded-lg w-28"></div>
</div>
<div className="flex flex-wrap gap-2">
<div className="h-10 bg-gray-200 rounded-lg w-24"></div>
<div className="h-10 bg-gray-200 rounded-lg w-20"></div>
</div>
</div>
</div>
{/* Filter Fields Skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="sm:col-span-2 lg:col-span-1">
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
<div className="h-10 bg-gray-200 rounded"></div>
</div>
<div>
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
<div className="h-10 bg-gray-200 rounded"></div>
</div>
<div>
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
<div className="h-10 bg-gray-200 rounded"></div>
</div>
<div>
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
<div className="h-10 bg-gray-200 rounded"></div>
</div>
</div>
{/* Amount Range Filters Skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
<div>
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
<div className="h-10 bg-gray-200 rounded"></div>
</div>
<div>
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
<div className="h-10 bg-gray-200 rounded"></div>
</div>
</div>
</div>
{/* Results Summary Skeleton */}
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200">
<div className="h-4 bg-gray-200 rounded w-48"></div>
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { useLocation } from "@tanstack/react-router";
import { Menu, Activity, Wifi, WifiOff } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../lib/api";
import { ThemeToggle } from "./ui/theme-toggle";
const navigation = [
{ name: "Overview", to: "/" },
{ name: "Transactions", to: "/transactions" },
{ name: "Analytics", to: "/analytics" },
{ name: "Notifications", to: "/notifications" },
];
interface HeaderProps {
setSidebarOpen: (open: boolean) => void;
}
export default function Header({ setSidebarOpen }: HeaderProps) {
const location = useLocation();
const currentPage =
navigation.find((item) => item.to === location.pathname)?.name ||
"Dashboard";
const {
data: healthStatus,
isLoading: healthLoading,
isError: healthError,
} = useQuery({
queryKey: ["health"],
queryFn: apiClient.getHealth,
refetchInterval: 30000,
});
return (
<header className="bg-card shadow-sm border-b border-border">
<div className="flex items-center justify-between h-16 px-6">
<div className="flex items-center">
<button
onClick={() => setSidebarOpen(true)}
className="lg:hidden p-1 rounded-md text-muted-foreground hover:text-foreground"
>
<Menu className="h-6 w-6" />
</button>
<h2 className="text-lg font-semibold text-card-foreground lg:ml-0 ml-4">
{currentPage}
</h2>
</div>
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-1">
{healthLoading ? (
<>
<Activity className="h-4 w-4 text-yellow-500 animate-pulse" />
<span className="text-sm text-muted-foreground">
Checking...
</span>
</>
) : healthError || healthStatus?.status !== "healthy" ? (
<>
<WifiOff className="h-4 w-4 text-red-500" />
<span className="text-sm text-red-500">Disconnected</span>
</>
) : (
<>
<Wifi className="h-4 w-4 text-green-500" />
<span className="text-sm text-muted-foreground">Connected</span>
</>
)}
</div>
<ThemeToggle />
</div>
</div>
</header>
);
}

View File

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

View File

@@ -0,0 +1,119 @@
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

@@ -0,0 +1,107 @@
import { Link, useLocation } from "@tanstack/react-router";
import {
CreditCard,
Home,
List,
BarChart3,
Bell,
TrendingUp,
X,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../lib/api";
import { formatCurrency } from "../lib/utils";
import { cn } from "../lib/utils";
import type { Account } from "../types/api";
const navigation = [
{ name: "Overview", icon: Home, to: "/" },
{ name: "Transactions", icon: List, to: "/transactions" },
{ name: "Analytics", icon: BarChart3, to: "/analytics" },
{ name: "Notifications", icon: Bell, to: "/notifications" },
];
interface SidebarProps {
sidebarOpen: boolean;
setSidebarOpen: (open: boolean) => void;
}
export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
const location = useLocation();
const { data: accounts } = useQuery<Account[]>({
queryKey: ["accounts"],
queryFn: apiClient.getAccounts,
});
const totalBalance =
accounts?.reduce((sum, account) => {
const primaryBalance = account.balances?.[0]?.amount || 0;
return sum + primaryBalance;
}, 0) || 0;
return (
<div
className={cn(
"fixed inset-y-0 left-0 z-50 w-64 bg-card shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0",
sidebarOpen ? "translate-x-0" : "-translate-x-full",
)}
>
<div className="flex items-center justify-between h-16 px-6 border-b border-border">
<Link
to="/"
onClick={() => setSidebarOpen(false)}
className="flex items-center space-x-2 hover:opacity-80 transition-opacity"
>
<CreditCard className="h-8 w-8 text-primary" />
<h1 className="text-xl font-bold text-card-foreground">Leggen</h1>
</Link>
<button
onClick={() => setSidebarOpen(false)}
className="lg:hidden p-1 rounded-md text-muted-foreground hover:text-foreground"
>
<X className="h-6 w-6" />
</button>
</div>
<nav className="px-6 py-4">
<div className="space-y-1">
{navigation.map((item) => (
<Link
key={item.to}
to={item.to}
onClick={() => setSidebarOpen(false)}
className={cn(
"flex items-center w-full px-3 py-2 text-sm font-medium rounded-md transition-colors",
location.pathname === item.to
? "bg-primary text-primary-foreground"
: "text-card-foreground hover:text-card-foreground hover:bg-accent",
)}
>
<item.icon className="mr-3 h-5 w-5" />
{item.name}
</Link>
))}
</div>
</nav>
{/* Account Summary in Sidebar */}
<div className="px-6 py-4 border-t border-border mt-auto">
<div className="bg-muted rounded-lg p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">
Total Balance
</span>
<TrendingUp className="h-4 w-4 text-green-500" />
</div>
<p className="text-2xl font-bold text-foreground mt-1">
{formatCurrency(totalBalance)}
</p>
<p className="text-sm text-muted-foreground mt-1">
{accounts?.length || 0} accounts
</p>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -1,339 +0,0 @@
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

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

View File

@@ -0,0 +1,191 @@
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

@@ -0,0 +1,142 @@
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

@@ -0,0 +1,66 @@
import type { LucideIcon } from "lucide-react";
import { Card, CardContent } from "../ui/card";
import { cn } from "../../lib/utils";
interface StatCardProps {
title: string;
value: string | number;
subtitle?: string;
icon: LucideIcon;
trend?: {
value: number;
isPositive: boolean;
};
className?: string;
}
export default function StatCard({
title,
value,
subtitle,
icon: Icon,
trend,
className,
}: StatCardProps) {
return (
<Card className={cn(className)}>
<CardContent className="p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<Icon className="h-8 w-8 text-primary" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-muted-foreground truncate">
{title}
</dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-foreground">
{value}
</div>
{trend && (
<div
className={cn(
"ml-2 flex items-baseline text-sm font-semibold",
trend.isPositive
? "text-green-600 dark:text-green-400"
: "text-red-600 dark:text-red-400",
)}
>
{trend.isPositive ? "+" : ""}
{trend.value}%
</div>
)}
</dd>
{subtitle && (
<dd className="text-sm text-muted-foreground mt-1">
{subtitle}
</dd>
)}
</dl>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,39 @@
import { Calendar } from "lucide-react";
import { Button } from "../ui/button";
import type { TimePeriod } from "../../lib/timePeriods";
import { TIME_PERIODS } from "../../lib/timePeriods";
interface TimePeriodFilterProps {
selectedPeriod: TimePeriod;
onPeriodChange: (period: TimePeriod) => void;
className?: string;
}
export default function TimePeriodFilter({
selectedPeriod,
onPeriodChange,
className = "",
}: TimePeriodFilterProps) {
return (
<div className={`flex items-center gap-4 ${className}`}>
<div className="flex items-center gap-2 text-foreground">
<Calendar size={20} />
<span className="font-medium">Time Period:</span>
</div>
<div className="flex gap-2">
{TIME_PERIODS.map((period) => (
<Button
key={period.value}
onClick={() => onPeriodChange(period)}
variant={
selectedPeriod.value === period.value ? "default" : "outline"
}
size="sm"
>
{period.label}
</Button>
))}
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,127 @@
import { useState } from "react";
import { MoreHorizontal, Euro } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export interface AdvancedFiltersPopoverProps {
minAmount: string;
maxAmount: string;
onMinAmountChange: (value: string) => void;
onMaxAmountChange: (value: string) => void;
}
export function AdvancedFiltersPopover({
minAmount,
maxAmount,
onMinAmountChange,
onMaxAmountChange,
}: AdvancedFiltersPopoverProps) {
const [open, setOpen] = useState(false);
const hasAdvancedFilters = minAmount || maxAmount;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant={hasAdvancedFilters ? "default" : "outline"}
size="default"
className="relative"
>
<MoreHorizontal className="h-4 w-4 mr-2" />
More
{hasAdvancedFilters && (
<div className="absolute -top-1 -right-1 h-2 w-2 bg-blue-600 rounded-full" />
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end">
<div className="space-y-4">
<div className="space-y-2">
<h4 className="font-medium leading-none">Advanced Filters</h4>
<p className="text-sm text-muted-foreground">
Additional filters for more precise results
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Amount Range
</label>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs text-muted-foreground">
Minimum
</label>
<div className="relative">
<Euro className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
type="number"
placeholder="0.00"
value={minAmount}
onChange={(e) => onMinAmountChange(e.target.value)}
className="pl-8"
step="0.01"
min="0"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">
Maximum
</label>
<div className="relative">
<Euro className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
type="number"
placeholder="1000.00"
value={maxAmount}
onChange={(e) => onMaxAmountChange(e.target.value)}
className="pl-8"
step="0.01"
min="0"
/>
</div>
</div>
</div>
<p className="text-xs text-muted-foreground">
Leave empty for no limit
</p>
</div>
{/* Future: Add transaction status filter */}
<div className="pt-2 border-t">
<div className="text-xs text-muted-foreground">
More filters coming soon: transaction status, categories, and
more.
</div>
</div>
{/* Clear advanced filters */}
{hasAdvancedFilters && (
<div className="pt-2">
<Button
variant="outline"
size="sm"
onClick={() => {
onMinAmountChange("");
onMaxAmountChange("");
}}
className="w-full"
>
Clear Advanced Filters
</Button>
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,213 @@
import { useState } from "react";
import { format } from "date-fns";
import { Calendar as CalendarIcon, ChevronDown } from "lucide-react";
import type { DateRange } from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export interface DateRangePickerProps {
startDate: string;
endDate: string;
onDateRangeChange: (startDate: string, endDate: string) => void;
className?: string;
}
interface DatePreset {
label: string;
getValue: () => { startDate: string; endDate: string };
}
const datePresets: DatePreset[] = [
{
label: "Last 7 days",
getValue: () => {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - 7);
return {
startDate: startDate.toISOString().split("T")[0],
endDate: endDate.toISOString().split("T")[0],
};
},
},
{
label: "This week",
getValue: () => {
const now = new Date();
const dayOfWeek = now.getDay();
const startOfWeek = new Date(now);
startOfWeek.setDate(
now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1),
); // Monday as start
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 6);
return {
startDate: startOfWeek.toISOString().split("T")[0],
endDate: endOfWeek.toISOString().split("T")[0],
};
},
},
{
label: "Last 30 days",
getValue: () => {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - 30);
return {
startDate: startDate.toISOString().split("T")[0],
endDate: endDate.toISOString().split("T")[0],
};
},
},
{
label: "This month",
getValue: () => {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return {
startDate: startOfMonth.toISOString().split("T")[0],
endDate: endOfMonth.toISOString().split("T")[0],
};
},
},
{
label: "This year",
getValue: () => {
const now = new Date();
const startOfYear = new Date(now.getFullYear(), 0, 1);
const endOfYear = new Date(now.getFullYear(), 11, 31);
return {
startDate: startOfYear.toISOString().split("T")[0],
endDate: endOfYear.toISOString().split("T")[0],
};
},
},
];
export function DateRangePicker({
startDate,
endDate,
onDateRangeChange,
className,
}: DateRangePickerProps) {
const [open, setOpen] = useState(false);
// Convert string dates to Date objects for the calendar
const dateRange: DateRange | undefined =
startDate && endDate
? {
from: new Date(startDate),
to: new Date(endDate),
}
: undefined;
const handleDateRangeSelect = (range: DateRange | undefined) => {
if (range?.from && range?.to) {
onDateRangeChange(
range.from.toISOString().split("T")[0],
range.to.toISOString().split("T")[0],
);
} else if (range?.from && !range?.to) {
onDateRangeChange(
range.from.toISOString().split("T")[0],
range.from.toISOString().split("T")[0],
);
}
};
const handlePresetClick = (preset: DatePreset) => {
const { startDate: presetStart, endDate: presetEnd } = preset.getValue();
onDateRangeChange(presetStart, presetEnd);
setOpen(false);
};
const formatDateRange = () => {
if (!startDate || !endDate) {
return "Select date range";
}
const start = new Date(startDate);
const end = new Date(endDate);
// Check if it matches a preset
const matchingPreset = datePresets.find((preset) => {
const { startDate: presetStart, endDate: presetEnd } = preset.getValue();
return presetStart === startDate && presetEnd === endDate;
});
if (matchingPreset) {
return matchingPreset.label;
}
// Format custom range
if (startDate === endDate) {
return format(start, "MMM d, yyyy");
}
return `${format(start, "MMM d")} - ${format(end, "MMM d, yyyy")}`;
};
return (
<div className={cn("grid gap-2", className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"justify-between text-left font-normal",
!dateRange && "text-muted-foreground",
)}
>
<div className="flex items-center">
<CalendarIcon className="mr-2 h-4 w-4" />
{formatDateRange()}
</div>
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="flex">
{/* Presets */}
<div className="border-r p-3 space-y-1">
<div className="text-sm font-medium text-gray-700 mb-2">
Quick select
</div>
{datePresets.map((preset) => (
<Button
key={preset.label}
variant="ghost"
size="sm"
className="w-full justify-start text-sm"
onClick={() => handlePresetClick(preset)}
>
{preset.label}
</Button>
))}
</div>
{/* Calendar */}
<Calendar
initialFocus
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={handleDateRangeSelect}
numberOfMonths={2}
/>
</div>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -0,0 +1,140 @@
import { Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { DateRangePicker } from "./DateRangePicker";
import { AccountCombobox } from "./AccountCombobox";
import { ActiveFilterChips } from "./ActiveFilterChips";
import { AdvancedFiltersPopover } from "./AdvancedFiltersPopover";
import type { Account } from "../../types/api";
export interface FilterState {
searchTerm: string;
selectedAccount: string;
startDate: string;
endDate: string;
minAmount: string;
maxAmount: string;
}
export interface FilterBarProps {
filterState: FilterState;
onFilterChange: (key: keyof FilterState, value: string) => void;
onClearFilters: () => void;
accounts?: Account[];
isSearchLoading?: boolean;
showRunningBalance: boolean;
onToggleRunningBalance: () => void;
className?: string;
}
export function FilterBar({
filterState,
onFilterChange,
onClearFilters,
accounts,
isSearchLoading = false,
showRunningBalance,
onToggleRunningBalance,
className,
}: FilterBarProps) {
const hasActiveFilters =
filterState.searchTerm ||
filterState.selectedAccount ||
filterState.startDate ||
filterState.endDate ||
filterState.minAmount ||
filterState.maxAmount;
const handleDateRangeChange = (startDate: string, endDate: string) => {
onFilterChange("startDate", startDate);
onFilterChange("endDate", endDate);
};
return (
<div className={cn("bg-card rounded-lg shadow border", className)}>
{/* Main Filter Bar */}
<div className="px-6 py-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-card-foreground">
Transactions
</h3>
<Button
onClick={onToggleRunningBalance}
variant={showRunningBalance ? "default" : "outline"}
size="sm"
>
Balance
</Button>
</div>
{/* Primary Filters Row */}
<div className="flex flex-wrap items-center gap-3 mb-4">
{/* Search Input */}
<div className="relative flex-1 min-w-[240px]">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search transactions..."
value={filterState.searchTerm}
onChange={(e) => onFilterChange("searchTerm", e.target.value)}
className="pl-9 pr-8 bg-background"
/>
{isSearchLoading && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<div className="animate-spin h-4 w-4 border-2 border-border border-t-primary rounded-full"></div>
</div>
)}
</div>
{/* Account Selection */}
<AccountCombobox
accounts={accounts}
selectedAccount={filterState.selectedAccount}
onAccountChange={(accountId) =>
onFilterChange("selectedAccount", accountId)
}
className="w-[200px]"
/>
{/* Date Range Picker */}
<DateRangePicker
startDate={filterState.startDate}
endDate={filterState.endDate}
onDateRangeChange={handleDateRangeChange}
className="w-[240px]"
/>
{/* Advanced Filters Button */}
<AdvancedFiltersPopover
minAmount={filterState.minAmount}
maxAmount={filterState.maxAmount}
onMinAmountChange={(value) => onFilterChange("minAmount", value)}
onMaxAmountChange={(value) => onFilterChange("maxAmount", value)}
/>
{/* Clear Filters Button */}
{hasActiveFilters && (
<Button
onClick={onClearFilters}
variant="outline"
size="sm"
className="text-muted-foreground"
>
<X className="h-4 w-4 mr-1" />
Clear All
</Button>
)}
</div>
{/* Active Filter Chips */}
{hasActiveFilters && (
<ActiveFilterChips
filterState={filterState}
onFilterChange={onFilterChange}
accounts={accounts}
/>
)}
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,86 @@
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

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

View File

@@ -0,0 +1,137 @@
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

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

View File

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

View File

@@ -0,0 +1,19 @@
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

@@ -0,0 +1,118 @@
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

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

View File

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

View File

@@ -0,0 +1,117 @@
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

@@ -0,0 +1,52 @@
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

@@ -0,0 +1,76 @@
import React, { createContext, useContext, useEffect, useState } from "react";
type Theme = "light" | "dark" | "system";
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
actualTheme: "light" | "dark";
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
const stored = localStorage.getItem("theme") as Theme;
return stored || "system";
});
const [actualTheme, setActualTheme] = useState<"light" | "dark">("light");
useEffect(() => {
const root = window.document.documentElement;
const updateActualTheme = () => {
let resolvedTheme: "light" | "dark";
if (theme === "system") {
resolvedTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
} else {
resolvedTheme = theme;
}
setActualTheme(resolvedTheme);
// Remove previous theme classes
root.classList.remove("light", "dark");
// Add resolved theme class
root.classList.add(resolvedTheme);
};
updateActualTheme();
// Listen for system theme changes
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (theme === "system") {
updateActualTheme();
}
};
mediaQuery.addEventListener("change", handleChange);
// Store theme preference
localStorage.setItem("theme", theme);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme, actualTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}

View File

@@ -1,3 +1,68 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -2,6 +2,7 @@ import axios from "axios";
import type { import type {
Account, Account,
Transaction, Transaction,
AnalyticsTransaction,
Balance, Balance,
ApiResponse, ApiResponse,
NotificationSettings, NotificationSettings,
@@ -9,6 +10,8 @@ import type {
NotificationService, NotificationService,
NotificationServicesResponse, NotificationServicesResponse,
HealthData, HealthData,
AccountUpdate,
TransactionStats,
} from "../types/api"; } from "../types/api";
// Use VITE_API_URL for development, relative URLs for production // Use VITE_API_URL for development, relative URLs for production
@@ -34,12 +37,39 @@ export const apiClient = {
return response.data.data; 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 // Get all balances
getBalances: async (): Promise<Balance[]> => { getBalances: async (): Promise<Balance[]> => {
const response = await api.get<ApiResponse<Balance[]>>("/balances"); const response = await api.get<ApiResponse<Balance[]>>("/balances");
return response.data.data; 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 // Get balances for specific account
getAccountBalances: async (accountId: string): Promise<Balance[]> => { getAccountBalances: async (accountId: string): Promise<Balance[]> => {
const response = await api.get<ApiResponse<Balance[]>>( const response = await api.get<ApiResponse<Balance[]>>(
@@ -56,21 +86,33 @@ export const apiClient = {
page?: number; page?: number;
perPage?: number; perPage?: number;
search?: string; search?: string;
}): Promise<Transaction[]> => { summaryOnly?: boolean;
minAmount?: number;
maxAmount?: number;
}): Promise<ApiResponse<Transaction[]>> => {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (params?.accountId) queryParams.append("account_id", params.accountId); if (params?.accountId) queryParams.append("account_id", params.accountId);
if (params?.startDate) queryParams.append("start_date", params.startDate); if (params?.startDate) queryParams.append("date_from", params.startDate);
if (params?.endDate) queryParams.append("end_date", params.endDate); if (params?.endDate) queryParams.append("date_to", params.endDate);
if (params?.page) queryParams.append("page", params.page.toString()); if (params?.page) queryParams.append("page", params.page.toString());
if (params?.perPage) if (params?.perPage)
queryParams.append("per_page", params.perPage.toString()); queryParams.append("per_page", params.perPage.toString());
if (params?.search) queryParams.append("search", params.search); 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[]>>( const response = await api.get<ApiResponse<Transaction[]>>(
`/transactions?${queryParams.toString()}`, `/transactions?${queryParams.toString()}`,
); );
return response.data.data; return response.data;
}, },
// Get transaction by ID // Get transaction by ID
@@ -125,6 +167,57 @@ export const apiClient = {
const response = await api.get<ApiResponse<HealthData>>("/health"); const response = await api.get<ApiResponse<HealthData>>("/health");
return response.data.data; return response.data.data;
}, },
// Analytics endpoints
getTransactionStats: async (days?: number): Promise<TransactionStats> => {
const queryParams = new URLSearchParams();
if (days) queryParams.append("days", days.toString());
const response = await api.get<ApiResponse<TransactionStats>>(
`/transactions/stats?${queryParams.toString()}`,
);
return response.data.data;
},
// Get all transactions for analytics (no pagination)
getTransactionsForAnalytics: async (
days?: number,
): Promise<AnalyticsTransaction[]> => {
const queryParams = new URLSearchParams();
if (days) queryParams.append("days", days.toString());
const response = await api.get<ApiResponse<AnalyticsTransaction[]>>(
`/transactions/analytics?${queryParams.toString()}`,
);
return response.data.data;
},
// Get monthly transaction statistics (pre-calculated)
getMonthlyTransactionStats: async (
days?: number,
): Promise<
Array<{
month: string;
income: number;
expenses: number;
net: number;
}>
> => {
const queryParams = new URLSearchParams();
if (days) queryParams.append("days", days.toString());
const response = await api.get<
ApiResponse<
Array<{
month: string;
income: number;
expenses: number;
net: number;
}>
>
>(`/transactions/monthly-stats?${queryParams.toString()}`);
return response.data.data;
},
}; };
export default apiClient; export default apiClient;

View File

@@ -0,0 +1,19 @@
export type TimePeriod = {
label: string;
days: number;
value: string;
};
function getDaysFromYearStart(): number {
const now = new Date();
const yearStart = new Date(now.getFullYear(), 0, 1);
const diffTime = now.getTime() - yearStart.getTime();
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
export const TIME_PERIODS: TimePeriod[] = [
{ label: "Last 30 days", days: 30, value: "30d" },
{ label: "Last 6 months", days: 180, value: "6m" },
{ label: "Year to Date", days: getDaysFromYearStart(), value: "ytd" },
{ label: "Last 365 days", days: 365, value: "365d" },
];

View File

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

View File

@@ -1,10 +1,28 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "./contexts/ThemeContext";
import "./index.css"; import "./index.css";
import App from "./App.tsx"; import { routeTree } from "./routeTree.gen";
const router = createRouter({ routeTree });
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
});
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<App /> <QueryClientProvider client={queryClient}>
<ThemeProvider>
<RouterProvider router={router} />
</ThemeProvider>
</QueryClientProvider>
</StrictMode>, </StrictMode>,
); );

View File

@@ -0,0 +1,113 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as TransactionsRouteImport } from './routes/transactions'
import { Route as NotificationsRouteImport } from './routes/notifications'
import { Route as AnalyticsRouteImport } from './routes/analytics'
import { Route as IndexRouteImport } from './routes/index'
const TransactionsRoute = TransactionsRouteImport.update({
id: '/transactions',
path: '/transactions',
getParentRoute: () => rootRouteImport,
} as any)
const NotificationsRoute = NotificationsRouteImport.update({
id: '/notifications',
path: '/notifications',
getParentRoute: () => rootRouteImport,
} as any)
const AnalyticsRoute = AnalyticsRouteImport.update({
id: '/analytics',
path: '/analytics',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/analytics': typeof AnalyticsRoute
'/notifications': typeof NotificationsRoute
'/transactions': typeof TransactionsRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/analytics': typeof AnalyticsRoute
'/notifications': typeof NotificationsRoute
'/transactions': typeof TransactionsRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/analytics': typeof AnalyticsRoute
'/notifications': typeof NotificationsRoute
'/transactions': typeof TransactionsRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/analytics' | '/notifications' | '/transactions'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/analytics' | '/notifications' | '/transactions'
id: '__root__' | '/' | '/analytics' | '/notifications' | '/transactions'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AnalyticsRoute: typeof AnalyticsRoute
NotificationsRoute: typeof NotificationsRoute
TransactionsRoute: typeof TransactionsRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/transactions': {
id: '/transactions'
path: '/transactions'
fullPath: '/transactions'
preLoaderRoute: typeof TransactionsRouteImport
parentRoute: typeof rootRouteImport
}
'/notifications': {
id: '/notifications'
path: '/notifications'
fullPath: '/notifications'
preLoaderRoute: typeof NotificationsRouteImport
parentRoute: typeof rootRouteImport
}
'/analytics': {
id: '/analytics'
path: '/analytics'
fullPath: '/analytics'
preLoaderRoute: typeof AnalyticsRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AnalyticsRoute: AnalyticsRoute,
NotificationsRoute: NotificationsRoute,
TransactionsRoute: TransactionsRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

View File

@@ -0,0 +1,33 @@
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { useState } from "react";
import Sidebar from "../components/Sidebar";
import Header from "../components/Header";
function RootLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex h-screen bg-background">
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className="flex flex-col flex-1 overflow-hidden">
<Header setSidebarOpen={setSidebarOpen} />
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
</div>
);
}
export const Route = createRootRoute({
component: RootLayout,
});

View File

@@ -0,0 +1,153 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import {
CreditCard,
TrendingUp,
TrendingDown,
Activity,
Users,
} from "lucide-react";
import apiClient from "../lib/api";
import StatCard from "../components/analytics/StatCard";
import BalanceChart from "../components/analytics/BalanceChart";
import TransactionDistribution from "../components/analytics/TransactionDistribution";
import MonthlyTrends from "../components/analytics/MonthlyTrends";
import TimePeriodFilter from "../components/analytics/TimePeriodFilter";
import { Card, CardContent } from "../components/ui/card";
import type { TimePeriod } from "../lib/timePeriods";
import { TIME_PERIODS } from "../lib/timePeriods";
function AnalyticsDashboard() {
// Default to Last 365 days
const [selectedPeriod, setSelectedPeriod] = useState<TimePeriod>(
TIME_PERIODS.find((p) => p.value === "365d") || TIME_PERIODS[3],
);
// Fetch analytics data
const { data: stats, isLoading: statsLoading } = useQuery({
queryKey: ["transaction-stats", selectedPeriod.days],
queryFn: () => apiClient.getTransactionStats(selectedPeriod.days),
});
const { data: accounts, isLoading: accountsLoading } = useQuery({
queryKey: ["accounts"],
queryFn: () => apiClient.getAccounts(),
});
const { data: balances, isLoading: balancesLoading } = useQuery({
queryKey: ["historical-balances", selectedPeriod.days],
queryFn: () => apiClient.getHistoricalBalances(selectedPeriod.days),
});
const isLoading = statsLoading || accountsLoading || balancesLoading;
if (isLoading) {
return (
<div className="p-6">
<div className="animate-pulse">
<div className="h-8 bg-muted rounded w-48 mb-6"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-32 bg-muted rounded"></div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="h-96 bg-muted rounded"></div>
<div className="h-96 bg-muted rounded"></div>
</div>
</div>
</div>
);
}
return (
<div className="p-6 space-y-8">
{/* Time Period Filter */}
<Card>
<CardContent className="p-4">
<TimePeriodFilter
selectedPeriod={selectedPeriod}
onPeriodChange={setSelectedPeriod}
/>
</CardContent>
</Card>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<StatCard
title="Total Transactions"
value={stats?.total_transactions || 0}
subtitle={`Last ${stats?.period_days || 0} days`}
icon={Activity}
/>
<StatCard
title="Total Income"
value={`${(stats?.total_income || 0).toLocaleString()}`}
subtitle="Inflows this period"
icon={TrendingUp}
className="border-green-200"
/>
<StatCard
title="Total Expenses"
value={`${(stats?.total_expenses || 0).toLocaleString()}`}
subtitle="Outflows this period"
icon={TrendingDown}
className="border-red-200"
/>
</div>
{/* Additional Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<StatCard
title="Net Change"
value={`${(stats?.net_change || 0).toLocaleString()}`}
subtitle="Income minus expenses"
icon={CreditCard}
className={
(stats?.net_change || 0) >= 0
? "border-green-200"
: "border-red-200"
}
/>
<StatCard
title="Average Transaction"
value={`${Math.abs(stats?.average_transaction || 0).toLocaleString()}`}
subtitle="Per transaction"
icon={Activity}
/>
<StatCard
title="Active Accounts"
value={stats?.accounts_included || 0}
subtitle="With recent activity"
icon={Users}
/>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<Card>
<CardContent className="p-6">
<BalanceChart data={balances || []} accounts={accounts || []} />
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<TransactionDistribution accounts={accounts || []} />
</CardContent>
</Card>
</div>
{/* Monthly Trends */}
<Card>
<CardContent className="p-6">
<MonthlyTrends days={selectedPeriod.days} />
</CardContent>
</Card>
</div>
);
}
export const Route = createFileRoute("/analytics")({
component: AnalyticsDashboard,
});

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
import { createFileRoute } from "@tanstack/react-router";
import TransactionsTable from "../components/TransactionsTable";
export const Route = createFileRoute("/transactions")({
component: TransactionsTable,
validateSearch: (search) => ({
accountId: search.accountId as string | undefined,
startDate: search.startDate as string | undefined,
endDate: search.endDate as string | undefined,
}),
});

View File

@@ -11,21 +11,78 @@ export interface Account {
status: string; status: string;
iban?: string; iban?: string;
name?: string; name?: string;
display_name?: string;
currency?: string; currency?: string;
created: string; created: string;
last_accessed?: string; last_accessed?: string;
balances: AccountBalance[]; balances: AccountBalance[];
} }
export interface Transaction { export interface AccountUpdate {
internal_transaction_id: string | null; display_name?: string;
account_id: string; }
export interface RawTransactionData {
transactionId?: string;
bookingDate?: string;
valueDate?: string;
bookingDateTime?: string;
valueDateTime?: string;
transactionAmount?: {
amount: string;
currency: string;
};
currencyExchange?: {
instructedAmount?: {
amount: string;
currency: string;
};
sourceCurrency?: string;
exchangeRate?: string;
unitCurrency?: string;
targetCurrency?: string;
};
creditorName?: string;
debtorName?: string;
debtorAccount?: {
iban?: string;
};
remittanceInformationUnstructuredArray?: string[];
proprietaryBankTransactionCode?: string;
balanceAfterTransaction?: {
balanceAmount: {
amount: string;
currency: string;
};
balanceType: string;
};
internalTransactionId?: string;
[key: string]: unknown; // Allow additional fields
}
// Type for analytics transaction data
export interface AnalyticsTransaction {
transaction_id: string;
date: string;
description: string;
amount: number; amount: number;
currency: string; currency: string;
description: string;
date: string;
status: string; status: string;
account_id: string;
}
export interface Transaction {
transaction_id: string; // NEW: stable bank-provided transaction ID
internal_transaction_id: string | null; // OLD: unstable GoCardless ID
account_id: string;
transaction_value: number;
transaction_currency: string;
description: string;
transaction_date: string;
transaction_status: string;
// Optional fields that may be present in some transactions // Optional fields that may be present in some transactions
institution_id?: string;
iban?: string;
booking_date?: string; booking_date?: string;
value_date?: string; value_date?: string;
creditor_name?: string; creditor_name?: string;
@@ -34,6 +91,8 @@ export interface Transaction {
category?: string; category?: string;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
// Raw transaction data (only present when summary_only=false)
raw_transaction?: RawTransactionData;
} }
// Type for raw transaction data from API (before sanitization) // Type for raw transaction data from API (before sanitization)
@@ -77,6 +136,14 @@ export interface ApiResponse<T> {
data: T; data: T;
message?: string; message?: string;
success: boolean; success: boolean;
pagination?: {
total: number;
page: number;
per_page: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
};
} }
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
@@ -133,3 +200,16 @@ export interface HealthData {
message?: string; message?: string;
error?: string; error?: string;
} }
// Analytics data types
export interface TransactionStats {
period_days: number;
total_transactions: number;
booked_transactions: number;
pending_transactions: number;
total_income: number;
total_expenses: number;
net_change: number;
average_transaction: number;
accounts_included: number;
}

View File

@@ -1,8 +1,57 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: { theme: {
extend: {}, extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
chart: {
1: "hsl(var(--chart-1))",
2: "hsl(var(--chart-2))",
3: "hsl(var(--chart-3))",
4: "hsl(var(--chart-4))",
5: "hsl(var(--chart-5))",
},
},
},
}, },
plugins: [require("@tailwindcss/forms")], plugins: [require("@tailwindcss/forms"), require("tailwindcss-animate")],
}; };

View File

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

View File

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

View File

@@ -1,7 +1,13 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [TanStackRouterVite(), react()],
resolve: {
alias: {
"@": "/src",
},
},
}); });

View File

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

View File

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

View File

@@ -1,15 +1,17 @@
from typing import Optional, List, Union from typing import List, Optional, Union
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from loguru import logger from loguru import logger
from leggend.api.models.common import APIResponse from leggen.api.models.accounts import (
from leggend.api.models.accounts import (
AccountDetails,
AccountBalance, AccountBalance,
AccountDetails,
AccountUpdate,
Transaction, Transaction,
TransactionSummary, TransactionSummary,
) )
from leggend.services.database_service import DatabaseService from leggen.api.models.common import APIResponse
from leggen.services.database_service import DatabaseService
router = APIRouter() router = APIRouter()
database_service = DatabaseService() database_service = DatabaseService()
@@ -51,6 +53,7 @@ async def get_all_accounts() -> APIResponse:
status=db_account["status"], status=db_account["status"],
iban=db_account.get("iban"), iban=db_account.get("iban"),
name=db_account.get("name"), name=db_account.get("name"),
display_name=db_account.get("display_name"),
currency=db_account.get("currency"), currency=db_account.get("currency"),
created=db_account["created"], created=db_account["created"],
last_accessed=db_account.get("last_accessed"), last_accessed=db_account.get("last_accessed"),
@@ -110,6 +113,7 @@ async def get_account_details(account_id: str) -> APIResponse:
status=db_account["status"], status=db_account["status"],
iban=db_account.get("iban"), iban=db_account.get("iban"),
name=db_account.get("name"), name=db_account.get("name"),
display_name=db_account.get("display_name"),
currency=db_account.get("currency"), currency=db_account.get("currency"),
created=db_account["created"], created=db_account["created"],
last_accessed=db_account.get("last_accessed"), last_accessed=db_account.get("last_accessed"),
@@ -214,6 +218,35 @@ async def get_all_balances() -> APIResponse:
) from e ) from e
@router.get("/balances/history", response_model=APIResponse)
async def get_historical_balances(
days: Optional[int] = Query(
default=365, le=1095, ge=1, description="Number of days of history to retrieve"
),
account_id: Optional[str] = Query(
default=None, description="Filter by specific account ID"
),
) -> APIResponse:
"""Get historical balance progression calculated from transaction history"""
try:
# Get historical balances from database
historical_balances = await database_service.get_historical_balances_from_db(
account_id=account_id, days=days or 365
)
return APIResponse(
success=True,
data=historical_balances,
message=f"Retrieved {len(historical_balances)} historical balance points over {days} days",
)
except Exception as e:
logger.error(f"Failed to get historical balances: {e}")
raise HTTPException(
status_code=500, detail=f"Failed to get historical balances: {str(e)}"
) from e
@router.get("/accounts/{account_id}/transactions", response_model=APIResponse) @router.get("/accounts/{account_id}/transactions", response_model=APIResponse)
async def get_account_transactions( async def get_account_transactions(
account_id: str, account_id: str,
@@ -243,7 +276,8 @@ async def get_account_transactions(
# Return simplified transaction summaries # Return simplified transaction summaries
data = [ data = [
TransactionSummary( TransactionSummary(
internal_transaction_id=txn["internalTransactionId"], transaction_id=txn["transactionId"], # NEW: stable bank-provided ID
internal_transaction_id=txn.get("internalTransactionId"),
date=txn["transactionDate"], date=txn["transactionDate"],
description=txn["description"], description=txn["description"],
amount=txn["transactionValue"], amount=txn["transactionValue"],
@@ -257,7 +291,8 @@ async def get_account_transactions(
# Return full transaction details # Return full transaction details
data = [ data = [
Transaction( Transaction(
internal_transaction_id=txn["internalTransactionId"], transaction_id=txn["transactionId"], # NEW: stable bank-provided ID
internal_transaction_id=txn.get("internalTransactionId"),
institution_id=txn["institutionId"], institution_id=txn["institutionId"],
iban=txn["iban"], iban=txn["iban"],
account_id=txn["accountId"], account_id=txn["accountId"],
@@ -285,3 +320,40 @@ async def get_account_transactions(
raise HTTPException( raise HTTPException(
status_code=404, detail=f"Failed to get transactions: {str(e)}" status_code=404, detail=f"Failed to get transactions: {str(e)}"
) from e ) from e
@router.put("/accounts/{account_id}", response_model=APIResponse)
async def update_account_details(
account_id: str, update_data: AccountUpdate
) -> APIResponse:
"""Update account details (currently only display_name)"""
try:
# Get current account details
current_account = await database_service.get_account_details_from_db(account_id)
if not current_account:
raise HTTPException(
status_code=404, detail=f"Account {account_id} not found"
)
# Prepare updated account data
updated_account_data = current_account.copy()
if update_data.display_name is not None:
updated_account_data["display_name"] = update_data.display_name
# Persist updated account details
await database_service.persist_account_details(updated_account_data)
return APIResponse(
success=True,
data={"id": account_id, "display_name": update_data.display_name},
message=f"Account {account_id} display name updated successfully",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update account {account_id}: {e}")
raise HTTPException(
status_code=500, detail=f"Failed to update account: {str(e)}"
) from e

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import click import click
from leggen.api_client import LeggenAPIClient
from leggen.main import cli from leggen.main import cli
from leggen.api_client import LeggendAPIClient
from leggen.utils.text import datefmt, print_table from leggen.utils.text import datefmt, print_table
@@ -11,12 +11,12 @@ def balances(ctx: click.Context):
""" """
List balances of all connected accounts List balances of all connected accounts
""" """
api_client = LeggendAPIClient(ctx.obj.get("api_url")) api_client = LeggenAPIClient(ctx.obj.get("api_url"))
# Check if leggend service is available # Check if leggen server is available
if not api_client.health_check(): if not api_client.health_check():
click.echo( click.echo(
"Error: Cannot connect to leggend service. Please ensure it's running." "Error: Cannot connect to leggen server. Please ensure it's running."
) )
return return

View File

@@ -1,9 +1,9 @@
import click import click
from leggen.api_client import LeggenAPIClient
from leggen.main import cli from leggen.main import cli
from leggen.api_client import LeggendAPIClient
from leggen.utils.disk import save_file from leggen.utils.disk import save_file
from leggen.utils.text import info, print_table, warning, success from leggen.utils.text import info, print_table, success, warning
@cli.command() @cli.command()
@@ -12,12 +12,12 @@ def add(ctx):
""" """
Connect to a bank Connect to a bank
""" """
api_client = LeggendAPIClient(ctx.obj.get("api_url")) api_client = LeggenAPIClient(ctx.obj.get("api_url"))
# Check if leggend service is available # Check if leggen server is available
if not api_client.health_check(): if not api_client.health_check():
click.echo( click.echo(
"Error: Cannot connect to leggend service. Please ensure it's running." "Error: Cannot connect to leggen server. Please ensure it's running."
) )
return return

View File

@@ -0,0 +1,68 @@
"""Generate sample database command."""
from pathlib import Path
import click
@click.command()
@click.option(
"--database",
type=click.Path(path_type=Path),
help="Path to database file (default: uses LEGGEN_DATABASE_PATH or ~/.config/leggen/leggen-dev.db)",
)
@click.option(
"--accounts",
type=int,
default=3,
help="Number of sample accounts to generate (default: 3)",
)
@click.option(
"--transactions",
type=int,
default=50,
help="Number of transactions per account (default: 50)",
)
@click.option(
"--force",
is_flag=True,
help="Overwrite existing database without confirmation",
)
@click.pass_context
def generate_sample_db(
ctx: click.Context, database: Path, accounts: int, transactions: int, force: bool
):
"""Generate a sample database with realistic financial data for testing."""
# Import here to avoid circular imports
import subprocess
import sys
from pathlib import Path as PathlibPath
# Get the script path
script_path = (
PathlibPath(__file__).parent.parent.parent / "scripts" / "generate_sample_db.py"
)
# Build command arguments
cmd = [sys.executable, str(script_path)]
if database:
cmd.extend(["--database", str(database)])
cmd.extend(["--accounts", str(accounts)])
cmd.extend(["--transactions", str(transactions)])
if force:
cmd.append("--force")
# Execute the script
try:
subprocess.run(cmd, check=True)
except subprocess.CalledProcessError as e:
click.echo(f"Error generating sample database: {e}")
ctx.exit(1)
# Export the command
generate_sample_db = generate_sample_db

View File

@@ -1,20 +1,22 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from importlib import metadata from importlib import metadata
import click
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from loguru import logger from loguru import logger
from leggend.api.routes import banks, accounts, sync, notifications, transactions from leggen.api.routes import accounts, banks, notifications, sync, transactions
from leggend.background.scheduler import scheduler from leggen.background.scheduler import scheduler
from leggend.config import config from leggen.utils.config import config
from leggen.utils.paths import path_manager
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Startup # Startup
logger.info("Starting leggend service...") logger.info("Starting leggen server...")
# Load configuration # Load configuration
try: try:
@@ -26,7 +28,7 @@ async def lifespan(app: FastAPI):
# Run database migrations # Run database migrations
try: try:
from leggend.services.database_service import DatabaseService from leggen.services.database_service import DatabaseService
db_service = DatabaseService() db_service = DatabaseService()
await db_service.run_migrations_if_needed() await db_service.run_migrations_if_needed()
@@ -42,7 +44,7 @@ async def lifespan(app: FastAPI):
yield yield
# Shutdown # Shutdown
logger.info("Shutting down leggend service...") logger.info("Shutting down leggen server...")
scheduler.shutdown() scheduler.shutdown()
@@ -54,7 +56,7 @@ def create_app() -> FastAPI:
version = "unknown" version = "unknown"
app = FastAPI( app = FastAPI(
title="Leggend API", title="Leggen API",
description="Open Banking API for Leggen", description="Open Banking API for Leggen",
version=version, version=version,
lifespan=lifespan, lifespan=lifespan,
@@ -87,13 +89,13 @@ def create_app() -> FastAPI:
version = metadata.version("leggen") version = metadata.version("leggen")
except metadata.PackageNotFoundError: except metadata.PackageNotFoundError:
version = "unknown" version = "unknown"
return {"message": "Leggend API is running", "version": version} return {"message": "Leggen API is running", "version": version}
@app.get("/api/v1/health") @app.get("/api/v1/health")
async def health(): async def health():
"""Health check endpoint for API connectivity""" """Health check endpoint for API connectivity"""
try: try:
from leggend.api.models.common import APIResponse from leggen.api.models.common import APIResponse
config_loaded = config._config is not None config_loaded = config._config is not None
@@ -108,7 +110,7 @@ def create_app() -> FastAPI:
) )
except Exception as e: except Exception as e:
logger.error(f"Health check failed: {e}") logger.error(f"Health check failed: {e}")
from leggend.api.models.common import APIResponse from leggen.api.models.common import APIResponse
return APIResponse( return APIResponse(
success=False, success=False,
@@ -119,43 +121,58 @@ def create_app() -> FastAPI:
return app return app
def main(): @click.command()
import argparse @click.option(
"--reload",
is_flag=True,
help="Enable auto-reload for development",
)
@click.option(
"--host",
default="0.0.0.0",
help="Host to bind to (default: 0.0.0.0)",
)
@click.option(
"--port",
type=int,
default=8000,
help="Port to bind to (default: 8000)",
)
@click.pass_context
def server(ctx: click.Context, reload: bool, host: str, port: int):
"""Start the Leggen API server"""
parser = argparse.ArgumentParser(description="Start the Leggend API service") # Get config_dir and database from main CLI context
parser.add_argument( config_dir = None
"--reload", action="store_true", help="Enable auto-reload for development" database = None
) if ctx.parent:
parser.add_argument( config_dir = ctx.parent.params.get("config_dir")
"--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)" database = ctx.parent.params.get("database")
)
parser.add_argument(
"--port", type=int, default=8000, help="Port to bind to (default: 8000)"
)
args = parser.parse_args()
if args.reload: # Set up path manager with user-provided paths
if config_dir:
path_manager.set_config_dir(config_dir)
if database:
path_manager.set_database_path(database)
if reload:
# Use string import for reload to work properly # Use string import for reload to work properly
uvicorn.run( uvicorn.run(
"leggend.main:create_app", "leggen.commands.server:create_app",
factory=True, factory=True,
host=args.host, host=host,
port=args.port, port=port,
log_level="info", log_level="info",
access_log=True, access_log=True,
reload=True, reload=True,
reload_dirs=["leggend", "leggen"], # Watch both directories reload_dirs=["leggen"], # Watch leggen directory
) )
else: else:
app = create_app() app = create_app()
uvicorn.run( uvicorn.run(
app, app,
host=args.host, host=host,
port=args.port, port=port,
log_level="info", log_level="info",
access_log=True, access_log=True,
) )
if __name__ == "__main__":
main()

View File

@@ -1,7 +1,7 @@
import click import click
from leggen.api_client import LeggenAPIClient
from leggen.main import cli from leggen.main import cli
from leggen.api_client import LeggendAPIClient
from leggen.utils.text import datefmt, echo, info, print_table from leggen.utils.text import datefmt, echo, info, print_table
@@ -11,12 +11,12 @@ def status(ctx: click.Context):
""" """
List all connected banks and their status List all connected banks and their status
""" """
api_client = LeggendAPIClient(ctx.obj.get("api_url")) api_client = LeggenAPIClient(ctx.obj.get("api_url"))
# Check if leggend service is available # Check if leggen server is available
if not api_client.health_check(): if not api_client.health_check():
click.echo( click.echo(
"Error: Cannot connect to leggend service. Please ensure it's running." "Error: Cannot connect to leggen server. Please ensure it's running."
) )
return return

View File

@@ -1,7 +1,7 @@
import click import click
from leggen.api_client import LeggenAPIClient
from leggen.main import cli from leggen.main import cli
from leggen.api_client import LeggendAPIClient
from leggen.utils.text import error, info, success from leggen.utils.text import error, info, success
@@ -13,11 +13,11 @@ def sync(ctx: click.Context, wait: bool, force: bool):
""" """
Sync all transactions with database Sync all transactions with database
""" """
api_client = LeggendAPIClient(ctx.obj.get("api_url")) api_client = LeggenAPIClient(ctx.obj.get("api_url"))
# Check if leggend service is available # Check if leggen server is available
if not api_client.health_check(): if not api_client.health_check():
error("Cannot connect to leggend service. Please ensure it's running.") error("Cannot connect to leggen server. Please ensure it's running.")
return return
try: try:

View File

@@ -1,7 +1,7 @@
import click import click
from leggen.api_client import LeggenAPIClient
from leggen.main import cli from leggen.main import cli
from leggen.api_client import LeggendAPIClient
from leggen.utils.text import datefmt, info, print_table from leggen.utils.text import datefmt, info, print_table
@@ -20,12 +20,12 @@ def transactions(ctx: click.Context, account: str, limit: int, full: bool):
If the --account option is used, it will only list transactions for that account. If the --account option is used, it will only list transactions for that account.
""" """
api_client = LeggendAPIClient(ctx.obj.get("api_url")) api_client = LeggenAPIClient(ctx.obj.get("api_url"))
# Check if leggend service is available # Check if leggen server is available
if not api_client.health_check(): if not api_client.health_check():
click.echo( click.echo(
"Error: Cannot connect to leggend service. Please ensure it's running." "Error: Cannot connect to leggen server. Please ensure it's running."
) )
return return

View File

@@ -1,538 +0,0 @@
import json
import sqlite3
from sqlite3 import IntegrityError
import click
from leggen.utils.text import success, warning
def persist_balances(ctx: click.Context, balance: dict):
# Connect to SQLite database
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# Create the accounts table if it doesn't exist
cursor.execute(
"""CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
institution_id TEXT,
status TEXT,
iban TEXT,
name TEXT,
currency TEXT,
created DATETIME,
last_accessed DATETIME,
last_updated DATETIME
)"""
)
# Create indexes for accounts table
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_accounts_institution_id
ON accounts(institution_id)"""
)
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_accounts_status
ON accounts(status)"""
)
# Create the balances table if it doesn't exist
cursor.execute(
"""CREATE TABLE IF NOT EXISTS balances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT,
bank TEXT,
status TEXT,
iban TEXT,
amount REAL,
currency TEXT,
type TEXT,
timestamp DATETIME
)"""
)
# Create indexes for better performance
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_balances_account_id
ON balances(account_id)"""
)
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_balances_timestamp
ON balances(timestamp)"""
)
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_balances_account_type_timestamp
ON balances(account_id, type, timestamp)"""
)
# Insert balance into SQLite database
try:
cursor.execute(
"""INSERT INTO balances (
account_id,
bank,
status,
iban,
amount,
currency,
type,
timestamp
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(
balance["account_id"],
balance["bank"],
balance["status"],
balance["iban"],
balance["amount"],
balance["currency"],
balance["type"],
balance["timestamp"],
),
)
except IntegrityError:
warning(f"[{balance['account_id']}] Skipped duplicate balance")
# Commit changes and close the connection
conn.commit()
conn.close()
success(f"[{balance['account_id']}] Inserted balance of type {balance['type']}")
return balance
def persist_transactions(ctx: click.Context, account: str, transactions: list) -> list:
# Connect to SQLite database
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# Create the transactions table if it doesn't exist
cursor.execute(
"""CREATE TABLE IF NOT EXISTS transactions (
internalTransactionId TEXT PRIMARY KEY,
institutionId TEXT,
iban TEXT,
transactionDate DATETIME,
description TEXT,
transactionValue REAL,
transactionCurrency TEXT,
transactionStatus TEXT,
accountId TEXT,
rawTransaction JSON
)"""
)
# Create indexes for better performance
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_transactions_account_id
ON transactions(accountId)"""
)
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_transactions_date
ON transactions(transactionDate)"""
)
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_transactions_account_date
ON transactions(accountId, transactionDate)"""
)
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_transactions_amount
ON transactions(transactionValue)"""
)
# Insert transactions into SQLite database
duplicates_count = 0
# Prepare an SQL statement for inserting data
insert_sql = """INSERT INTO transactions (
internalTransactionId,
institutionId,
iban,
transactionDate,
description,
transactionValue,
transactionCurrency,
transactionStatus,
accountId,
rawTransaction
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"""
new_transactions = []
for transaction in transactions:
try:
cursor.execute(
insert_sql,
(
transaction["internalTransactionId"],
transaction["institutionId"],
transaction["iban"],
transaction["transactionDate"],
transaction["description"],
transaction["transactionValue"],
transaction["transactionCurrency"],
transaction["transactionStatus"],
transaction["accountId"],
json.dumps(transaction["rawTransaction"]),
),
)
new_transactions.append(transaction)
except IntegrityError:
# A transaction with the same ID already exists, indicating a duplicate
duplicates_count += 1
# Commit changes and close the connection
conn.commit()
conn.close()
success(f"[{account}] Inserted {len(new_transactions)} new transactions")
if duplicates_count:
warning(f"[{account}] Skipped {duplicates_count} duplicate transactions")
return new_transactions
def get_transactions(
account_id=None,
limit=100,
offset=0,
date_from=None,
date_to=None,
min_amount=None,
max_amount=None,
search=None,
):
"""Get transactions from SQLite database with optional filtering"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
if not db_path.exists():
return []
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row # Enable dict-like access
cursor = conn.cursor()
# Build query with filters
query = "SELECT * FROM transactions WHERE 1=1"
params = []
if account_id:
query += " AND accountId = ?"
params.append(account_id)
if date_from:
query += " AND transactionDate >= ?"
params.append(date_from)
if date_to:
query += " AND transactionDate <= ?"
params.append(date_to)
if min_amount is not None:
query += " AND transactionValue >= ?"
params.append(min_amount)
if max_amount is not None:
query += " AND transactionValue <= ?"
params.append(max_amount)
if search:
query += " AND description LIKE ?"
params.append(f"%{search}%")
# Add ordering and pagination
query += " ORDER BY transactionDate DESC"
if limit:
query += " LIMIT ?"
params.append(limit)
if offset:
query += " OFFSET ?"
params.append(offset)
try:
cursor.execute(query, params)
rows = cursor.fetchall()
# Convert to list of dicts and parse JSON fields
transactions = []
for row in rows:
transaction = dict(row)
if transaction["rawTransaction"]:
transaction["rawTransaction"] = json.loads(
transaction["rawTransaction"]
)
transactions.append(transaction)
conn.close()
return transactions
except Exception as e:
conn.close()
raise e
def get_balances(account_id=None):
"""Get latest balances from SQLite database"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
if not db_path.exists():
return []
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Get latest balance for each account_id and type combination
query = """
SELECT * FROM balances b1
WHERE b1.timestamp = (
SELECT MAX(b2.timestamp)
FROM balances b2
WHERE b2.account_id = b1.account_id AND b2.type = b1.type
)
"""
params = []
if account_id:
query += " AND b1.account_id = ?"
params.append(account_id)
query += " ORDER BY b1.account_id, b1.type"
try:
cursor.execute(query, params)
rows = cursor.fetchall()
balances = [dict(row) for row in rows]
conn.close()
return balances
except Exception as e:
conn.close()
raise e
def get_account_summary(account_id):
"""Get basic account info from transactions table (avoids GoCardless API call)"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
if not db_path.exists():
return None
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
try:
# Get account info from most recent transaction
cursor.execute(
"""
SELECT DISTINCT accountId, institutionId, iban
FROM transactions
WHERE accountId = ?
ORDER BY transactionDate DESC
LIMIT 1
""",
(account_id,),
)
row = cursor.fetchone()
conn.close()
if row:
return dict(row)
return None
except Exception as e:
conn.close()
raise e
def get_transaction_count(account_id=None, **filters):
"""Get total count of transactions matching filters"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
if not db_path.exists():
return 0
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
query = "SELECT COUNT(*) FROM transactions WHERE 1=1"
params = []
if account_id:
query += " AND accountId = ?"
params.append(account_id)
# Add same filters as get_transactions
if filters.get("date_from"):
query += " AND transactionDate >= ?"
params.append(filters["date_from"])
if filters.get("date_to"):
query += " AND transactionDate <= ?"
params.append(filters["date_to"])
if filters.get("min_amount") is not None:
query += " AND transactionValue >= ?"
params.append(filters["min_amount"])
if filters.get("max_amount") is not None:
query += " AND transactionValue <= ?"
params.append(filters["max_amount"])
if filters.get("search"):
query += " AND description LIKE ?"
params.append(f"%{filters['search']}%")
try:
cursor.execute(query, params)
count = cursor.fetchone()[0]
conn.close()
return count
except Exception as e:
conn.close()
raise e
def persist_account(account_data: dict):
"""Persist account details to SQLite database"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# Create the accounts table if it doesn't exist
cursor.execute(
"""CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
institution_id TEXT,
status TEXT,
iban TEXT,
name TEXT,
currency TEXT,
created DATETIME,
last_accessed DATETIME,
last_updated DATETIME
)"""
)
# Create indexes for accounts table
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_accounts_institution_id
ON accounts(institution_id)"""
)
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_accounts_status
ON accounts(status)"""
)
try:
# Insert or replace account data
cursor.execute(
"""INSERT OR REPLACE INTO accounts (
id,
institution_id,
status,
iban,
name,
currency,
created,
last_accessed,
last_updated
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
account_data["id"],
account_data["institution_id"],
account_data["status"],
account_data.get("iban"),
account_data.get("name"),
account_data.get("currency"),
account_data["created"],
account_data.get("last_accessed"),
account_data.get("last_updated", account_data["created"]),
),
)
conn.commit()
conn.close()
success(f"[{account_data['id']}] Account details persisted to database")
return account_data
except Exception as e:
conn.close()
raise e
def get_accounts(account_ids=None):
"""Get account details from SQLite database"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
if not db_path.exists():
return []
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
query = "SELECT * FROM accounts"
params = []
if account_ids:
placeholders = ",".join("?" * len(account_ids))
query += f" WHERE id IN ({placeholders})"
params.extend(account_ids)
query += " ORDER BY created DESC"
try:
cursor.execute(query, params)
rows = cursor.fetchall()
accounts = [dict(row) for row in rows]
conn.close()
return accounts
except Exception as e:
conn.close()
raise e
def get_account(account_id: str):
"""Get specific account details from SQLite database"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
if not db_path.exists():
return None
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
try:
cursor.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
row = cursor.fetchone()
conn.close()
if row:
return dict(row)
return None
except Exception as e:
conn.close()
raise e

View File

@@ -6,6 +6,7 @@ from pathlib import Path
import click import click
from leggen.utils.config import load_config from leggen.utils.config import load_config
from leggen.utils.paths import path_manager
from leggen.utils.text import error from leggen.utils.text import error
cmd_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), "commands")) cmd_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), "commands"))
@@ -77,7 +78,7 @@ class Group(click.Group):
"-c", "-c",
"--config", "--config",
type=click.Path(dir_okay=False), type=click.Path(dir_okay=False),
default=Path.home() / ".config" / "leggen" / "config.toml", default=lambda: str(path_manager.get_config_file_path()),
show_default=True, show_default=True,
callback=load_config, callback=load_config,
is_eager=True, is_eager=True,
@@ -86,13 +87,27 @@ class Group(click.Group):
show_envvar=True, show_envvar=True,
help="Path to TOML configuration file", help="Path to TOML configuration file",
) )
@click.option(
"--config-dir",
type=click.Path(exists=False, file_okay=False, path_type=Path),
envvar="LEGGEN_CONFIG_DIR",
show_envvar=True,
help="Directory containing configuration files (default: ~/.config/leggen)",
)
@click.option(
"--database",
type=click.Path(dir_okay=False, path_type=Path),
envvar="LEGGEN_DATABASE_PATH",
show_envvar=True,
help="Path to SQLite database file (default: <config-dir>/leggen.db)",
)
@click.option( @click.option(
"--api-url", "--api-url",
type=str, type=str,
default="http://localhost:8000", default="http://localhost:8000",
envvar="LEGGEND_API_URL", envvar="LEGGEN_API_URL",
show_envvar=True, show_envvar=True,
help="URL of the leggend API service", help="URL of the leggen API service",
) )
@click.group( @click.group(
cls=Group, cls=Group,
@@ -100,7 +115,7 @@ class Group(click.Group):
) )
@click.version_option(package_name="leggen") @click.version_option(package_name="leggen")
@click.pass_context @click.pass_context
def cli(ctx: click.Context, api_url: str): def cli(ctx: click.Context, config_dir: Path, database: Path, api_url: str):
""" """
Leggen: An Open Banking CLI Leggen: An Open Banking CLI
""" """
@@ -109,5 +124,11 @@ def cli(ctx: click.Context, api_url: str):
if "--help" in sys.argv[1:] or "-h" in sys.argv[1:]: if "--help" in sys.argv[1:] or "-h" in sys.argv[1:]:
return return
# Set up path manager with user-provided paths
if config_dir:
path_manager.set_config_dir(config_dir)
if database:
path_manager.set_database_path(database)
# Store API URL in context for commands to use # Store API URL in context for commands to use
ctx.obj["api_url"] = api_url ctx.obj["api_url"] = api_url

65
leggen/models/config.py Normal file
View File

@@ -0,0 +1,65 @@
from typing import List, Optional
from pydantic import BaseModel, Field
class GoCardlessConfig(BaseModel):
key: str = Field(..., description="GoCardless API key")
secret: str = Field(..., description="GoCardless API secret")
url: str = Field(
default="https://bankaccountdata.gocardless.com/api/v2",
description="GoCardless API URL",
)
class DatabaseConfig(BaseModel):
sqlite: bool = Field(default=True, description="Enable SQLite database")
class DiscordNotificationConfig(BaseModel):
webhook: str = Field(..., description="Discord webhook URL")
enabled: bool = Field(default=True, description="Enable Discord notifications")
class TelegramNotificationConfig(BaseModel):
token: str = Field(..., alias="api-key", description="Telegram bot token")
chat_id: int = Field(..., alias="chat-id", description="Telegram chat ID")
enabled: bool = Field(default=True, description="Enable Telegram notifications")
class NotificationConfig(BaseModel):
discord: Optional[DiscordNotificationConfig] = None
telegram: Optional[TelegramNotificationConfig] = None
class FilterConfig(BaseModel):
case_insensitive: Optional[List[str]] = Field(
default_factory=list, alias="case-insensitive"
)
case_sensitive: Optional[List[str]] = Field(
default_factory=list, alias="case-sensitive"
)
class SyncScheduleConfig(BaseModel):
enabled: bool = Field(default=True, description="Enable sync scheduling")
hour: int = Field(default=3, ge=0, le=23, description="Hour to run sync (0-23)")
minute: int = Field(default=0, ge=0, le=59, description="Minute to run sync (0-59)")
cron: Optional[str] = Field(
default=None, description="Custom cron expression (overrides hour/minute)"
)
class SchedulerConfig(BaseModel):
sync: SyncScheduleConfig = Field(default_factory=SyncScheduleConfig)
class Config(BaseModel):
gocardless: GoCardlessConfig
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
notifications: Optional[NotificationConfig] = None
filters: Optional[FilterConfig] = None
scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig)
class Config:
validate_by_name = True

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,12 @@
import json import json
import httpx
from pathlib import Path from pathlib import Path
from typing import Dict, Any, List from typing import Any, Dict, List
import httpx
from loguru import logger from loguru import logger
from leggend.config import config from leggen.utils.config import config
from leggen.utils.paths import path_manager
def _log_rate_limits(response): def _log_rate_limits(response):
@@ -39,8 +40,8 @@ class GoCardlessService:
if self._token: if self._token:
return self._token return self._token
# Use ~/.config/leggen for consistency with main config # Use path manager for auth file
auth_file = Path.home() / ".config" / "leggen" / "auth.json" auth_file = path_manager.get_auth_file_path()
if auth_file.exists(): if auth_file.exists():
try: try:

View File

@@ -1,8 +1,8 @@
from typing import List, Dict, Any from typing import Any, Dict, List
from loguru import logger from loguru import logger
from leggend.config import config from leggen.utils.config import config
class NotificationService: class NotificationService:
@@ -110,33 +110,77 @@ class NotificationService:
telegram_config = self.notifications_config.get("telegram", {}) telegram_config = self.notifications_config.get("telegram", {})
return bool( return bool(
telegram_config.get("token") telegram_config.get("token")
or telegram_config.get("api-key") and telegram_config.get("chat_id")
and (telegram_config.get("chat_id") or telegram_config.get("chat-id"))
and telegram_config.get("enabled", True) and telegram_config.get("enabled", True)
) )
async def _send_discord_notifications( async def _send_discord_notifications(
self, transactions: List[Dict[str, Any]] self, transactions: List[Dict[str, Any]]
) -> None: ) -> None:
"""Send Discord notifications - placeholder implementation""" """Send Discord notifications for transactions"""
# Would import and use leggen.notifications.discord try:
logger.info(f"Sending {len(transactions)} transaction notifications to Discord") import click
from leggen.notifications.discord import send_transactions_message
# Create a mock context with the webhook
ctx = click.Context(click.Command("notifications"))
ctx.obj = {
"notifications": {
"discord": {
"webhook": self.notifications_config.get("discord", {}).get(
"webhook"
)
}
}
}
# Send transaction notifications using the actual implementation
send_transactions_message(ctx, transactions)
logger.info(
f"Sent {len(transactions)} transaction notifications to Discord"
)
except Exception as e:
logger.error(f"Failed to send Discord transaction notifications: {e}")
raise
async def _send_telegram_notifications( async def _send_telegram_notifications(
self, transactions: List[Dict[str, Any]] self, transactions: List[Dict[str, Any]]
) -> None: ) -> None:
"""Send Telegram notifications - placeholder implementation""" """Send Telegram notifications for transactions"""
# Would import and use leggen.notifications.telegram try:
logger.info( import click
f"Sending {len(transactions)} transaction notifications to Telegram"
) from leggen.notifications.telegram import send_transaction_message
# Create a mock context with the telegram config
ctx = click.Context(click.Command("notifications"))
telegram_config = self.notifications_config.get("telegram", {})
ctx.obj = {
"notifications": {
"telegram": {
"api-key": telegram_config.get("token"),
"chat-id": telegram_config.get("chat_id"),
}
}
}
# Send transaction notifications using the actual implementation
send_transaction_message(ctx, transactions)
logger.info(
f"Sent {len(transactions)} transaction notifications to Telegram"
)
except Exception as e:
logger.error(f"Failed to send Telegram transaction notifications: {e}")
raise
async def _send_discord_test(self, message: str) -> None: async def _send_discord_test(self, message: str) -> None:
"""Send Discord test notification""" """Send Discord test notification"""
try: try:
from leggen.notifications.discord import send_expire_notification
import click import click
from leggen.notifications.discord import send_expire_notification
# Create a mock context with the webhook # Create a mock context with the webhook
ctx = click.Context(click.Command("test")) ctx = click.Context(click.Command("test"))
ctx.obj = { ctx.obj = {
@@ -165,19 +209,18 @@ class NotificationService:
async def _send_telegram_test(self, message: str) -> None: async def _send_telegram_test(self, message: str) -> None:
"""Send Telegram test notification""" """Send Telegram test notification"""
try: try:
from leggen.notifications.telegram import send_expire_notification
import click import click
from leggen.notifications.telegram import send_expire_notification
# Create a mock context with the telegram config # Create a mock context with the telegram config
ctx = click.Context(click.Command("test")) ctx = click.Context(click.Command("test"))
telegram_config = self.notifications_config.get("telegram", {}) telegram_config = self.notifications_config.get("telegram", {})
ctx.obj = { ctx.obj = {
"notifications": { "notifications": {
"telegram": { "telegram": {
"api-key": telegram_config.get("token") "api-key": telegram_config.get("token"),
or telegram_config.get("api-key"), "chat-id": telegram_config.get("chat_id"),
"chat-id": telegram_config.get("chat_id")
or telegram_config.get("chat-id"),
} }
} }
} }
@@ -197,8 +240,52 @@ class NotificationService:
async def _send_discord_expiry(self, notification_data: Dict[str, Any]) -> None: async def _send_discord_expiry(self, notification_data: Dict[str, Any]) -> None:
"""Send Discord expiry notification""" """Send Discord expiry notification"""
logger.info(f"Sending Discord expiry notification: {notification_data}") try:
import click
from leggen.notifications.discord import send_expire_notification
# Create a mock context with the webhook
ctx = click.Context(click.Command("expiry"))
ctx.obj = {
"notifications": {
"discord": {
"webhook": self.notifications_config.get("discord", {}).get(
"webhook"
)
}
}
}
# Send expiry notification using the actual implementation
send_expire_notification(ctx, notification_data)
logger.info(f"Sent Discord expiry notification: {notification_data}")
except Exception as e:
logger.error(f"Failed to send Discord expiry notification: {e}")
raise
async def _send_telegram_expiry(self, notification_data: Dict[str, Any]) -> None: async def _send_telegram_expiry(self, notification_data: Dict[str, Any]) -> None:
"""Send Telegram expiry notification""" """Send Telegram expiry notification"""
logger.info(f"Sending Telegram expiry notification: {notification_data}") try:
import click
from leggen.notifications.telegram import send_expire_notification
# Create a mock context with the telegram config
ctx = click.Context(click.Command("expiry"))
telegram_config = self.notifications_config.get("telegram", {})
ctx.obj = {
"notifications": {
"telegram": {
"api-key": telegram_config.get("token"),
"chat-id": telegram_config.get("chat_id"),
}
}
}
# Send expiry notification using the actual implementation
send_expire_notification(ctx, notification_data)
logger.info(f"Sent Telegram expiry notification: {notification_data}")
except Exception as e:
logger.error(f"Failed to send Telegram expiry notification: {e}")
raise

View File

@@ -3,10 +3,10 @@ from typing import List
from loguru import logger from loguru import logger
from leggend.api.models.sync import SyncResult, SyncStatus from leggen.api.models.sync import SyncResult, SyncStatus
from leggend.services.gocardless_service import GoCardlessService from leggen.services.database_service import DatabaseService
from leggend.services.database_service import DatabaseService from leggen.services.gocardless_service import GoCardlessService
from leggend.services.notification_service import NotificationService from leggen.services.notification_service import NotificationService
class SyncService: class SyncService:
@@ -55,26 +55,45 @@ class SyncService:
account_id account_id
) )
# Persist account details to database # Get balances to extract currency information
if account_details:
await self.database.persist_account_details(account_details)
# Get and save balances
balances = await self.gocardless.get_account_balances(account_id) balances = await self.gocardless.get_account_balances(account_id)
if balances and account_details:
# Enrich account details with currency and persist
if account_details and balances:
enriched_account_details = account_details.copy()
# Extract currency from first balance
balances_list = balances.get("balances", [])
if balances_list:
first_balance = balances_list[0]
balance_amount = first_balance.get("balanceAmount", {})
currency = balance_amount.get("currency")
if currency:
enriched_account_details["currency"] = currency
# Persist enriched account details to database
await self.database.persist_account_details(
enriched_account_details
)
# Merge account details into balances data for proper persistence # Merge account details into balances data for proper persistence
balances_with_account_info = balances.copy() balances_with_account_info = balances.copy()
balances_with_account_info["institution_id"] = ( balances_with_account_info["institution_id"] = (
account_details.get("institution_id") enriched_account_details.get("institution_id")
)
balances_with_account_info["iban"] = (
enriched_account_details.get("iban")
) )
balances_with_account_info["iban"] = account_details.get("iban")
balances_with_account_info["account_status"] = ( balances_with_account_info["account_status"] = (
account_details.get("status") enriched_account_details.get("status")
) )
await self.database.persist_balance( await self.database.persist_balance(
account_id, balances_with_account_info account_id, balances_with_account_info
) )
balances_updated += len(balances.get("balances", [])) balances_updated += len(balances.get("balances", []))
elif account_details:
# Fallback: persist account details without currency if balances failed
await self.database.persist_account_details(account_details)
# Get and save transactions # Get and save transactions
transactions = await self.gocardless.get_account_transactions( transactions = await self.gocardless.get_account_transactions(

View File

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

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