Compare commits
28 Commits
eb27f19196
...
2025.9.16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7d6cf8128 | ||
|
|
6589c2dd66 | ||
|
|
571072f6ac | ||
|
|
be4f7f8cec | ||
|
|
056c33b9c5 | ||
|
|
02c4f5c6ef | ||
|
|
30d7c2ed4e | ||
|
|
61442a598f | ||
|
|
b7da446fa5 | ||
|
|
5a626b5394 | ||
|
|
d9a39c30ab | ||
|
|
155a48d7dc | ||
|
|
8ab760815c | ||
|
|
2825dba2e9 | ||
|
|
3049a8cd2f | ||
|
|
86891441d6 | ||
|
|
81d7d16301 | ||
|
|
84e609a774 | ||
|
|
fb310a5953 | ||
|
|
c83386b1d5 | ||
|
|
bfb5a7ef76 | ||
|
|
95b3b93a8a | ||
|
|
9a2199873c | ||
|
|
82a12dadad | ||
|
|
33a7ad5ad2 | ||
|
|
3352e110b8 | ||
|
|
74a700ff87 | ||
|
|
66db34c712 |
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(mkdir:*)",
|
|
||||||
"Bash(uv sync:*)",
|
|
||||||
"Bash(uv run pytest:*)",
|
|
||||||
"Bash(git commit:*)",
|
|
||||||
"Bash(ruff check:*)",
|
|
||||||
"Bash(git add:*)",
|
|
||||||
"Bash(mypy:*)",
|
|
||||||
"WebFetch(domain:localhost)",
|
|
||||||
"Bash(npm create:*)",
|
|
||||||
"Bash(npm install)",
|
|
||||||
"Bash(npm install:*)",
|
|
||||||
"Bash(npx tailwindcss init:*)",
|
|
||||||
"Bash(./node_modules/.bin/tailwindcss:*)",
|
|
||||||
"Bash(npm run build:*)"
|
|
||||||
],
|
|
||||||
"deny": [],
|
|
||||||
"ask": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2
.github/workflows/ci.yml
vendored
@@ -10,6 +10,7 @@ jobs:
|
|||||||
test-python:
|
test-python:
|
||||||
name: Test Python
|
name: Test Python
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: "!contains(github.event.head_commit.message, 'chore(ci): Bump version to')"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ jobs:
|
|||||||
test-frontend:
|
test-frontend:
|
||||||
name: Test Frontend
|
name: Test Frontend
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: "!contains(github.event.head_commit.message, 'chore(ci): Bump version to')"
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -164,3 +164,4 @@ sql/
|
|||||||
leggen.db
|
leggen.db
|
||||||
*.db
|
*.db
|
||||||
config.toml
|
config.toml
|
||||||
|
.claude/
|
||||||
|
|||||||
17
.mcp.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"shadcn": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"shadcn@latest",
|
||||||
|
"mcp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browsermcp": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"@browsermcp/mcp@latest"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
AGENTS.md
@@ -85,6 +85,29 @@ The command outputs instructions for setting the required environment variable t
|
|||||||
- **Data fetching**: @tanstack/react-query with proper error handling
|
- **Data fetching**: @tanstack/react-query with proper error handling
|
||||||
- **Components**: Functional components with hooks, proper TypeScript typing
|
- **Components**: Functional components with hooks, proper TypeScript typing
|
||||||
|
|
||||||
|
## Frontend Structure
|
||||||
|
|
||||||
|
### Layout Architecture
|
||||||
|
- **Root Layout**: `frontend/src/routes/__root.tsx` - Contains main app structure with Sidebar and Header
|
||||||
|
- **Header/Navbar**: `frontend/src/components/Header.tsx` - Top navigation bar (sticky on mobile only)
|
||||||
|
- **Sidebar**: `frontend/src/components/Sidebar.tsx` - Left navigation sidebar
|
||||||
|
- **Routes**: `frontend/src/routes/` - TanStack Router file-based routing
|
||||||
|
|
||||||
|
### Key Components Location
|
||||||
|
- **UI Components**: `frontend/src/components/ui/` - Reusable UI primitives
|
||||||
|
- **Feature Components**: `frontend/src/components/` - Main app components
|
||||||
|
- **Pages**: `frontend/src/routes/` - Route components (index.tsx, transactions.tsx, etc.)
|
||||||
|
- **Hooks**: `frontend/src/hooks/` - Custom React hooks
|
||||||
|
- **API**: `frontend/src/lib/api.ts` - API client configuration
|
||||||
|
- **Context**: `frontend/src/contexts/` - React contexts (ThemeContext, etc.)
|
||||||
|
|
||||||
|
### Routing Structure
|
||||||
|
- `/` - Overview/Dashboard (TransactionsTable component)
|
||||||
|
- `/transactions` - Transactions page
|
||||||
|
- `/analytics` - Analytics page
|
||||||
|
- `/notifications` - Notifications page
|
||||||
|
- `/settings` - Settings page
|
||||||
|
|
||||||
### General
|
### General
|
||||||
- **Formatting**: ruff for Python, ESLint for TypeScript
|
- **Formatting**: ruff for Python, ESLint for TypeScript
|
||||||
- **Commits**: Use conventional commits with optional scopes, run pre-commit hooks before pushing
|
- **Commits**: Use conventional commits with optional scopes, run pre-commit hooks before pushing
|
||||||
|
|||||||
206
CHANGELOG.md
@@ -1,4 +1,210 @@
|
|||||||
|
|
||||||
|
## 2025.9.16 (2025/09/18)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **frontend:** Add iOS safe area support for PWA sticky header ([6589c2dd](https://github.com/elisiariocouto/leggen/commit/6589c2dd666f8605cf6d1bf9ad7277734d4cd302))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.16 (2025/09/18)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **frontend:** Add iOS safe area support for PWA sticky header ([6589c2dd](https://github.com/elisiariocouto/leggen/commit/6589c2dd666f8605cf6d1bf9ad7277734d4cd302))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.15 (2025/09/18)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **frontend:** Add settings page with account management functionality. ([056c33b9](https://github.com/elisiariocouto/leggen/commit/056c33b9c5cfbc2842cc2dd4ca8c4e3959a2be80))
|
||||||
|
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **frontend:** Simplify filter bar UI and remove advanced filters popover. ([be4f7f8c](https://github.com/elisiariocouto/leggen/commit/be4f7f8cecfe2564abdf0ce1be08497e5a6d7b68))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.15 (2025/09/18)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **frontend:** Add settings page with account management functionality. ([056c33b9](https://github.com/elisiariocouto/leggen/commit/056c33b9c5cfbc2842cc2dd4ca8c4e3959a2be80))
|
||||||
|
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **frontend:** Simplify filter bar UI and remove advanced filters popover. ([be4f7f8c](https://github.com/elisiariocouto/leggen/commit/be4f7f8cecfe2564abdf0ce1be08497e5a6d7b68))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.14 (2025/09/18)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **config:** Remove aliases for configuration keys that were disabling telegram notifications in some cases. ([61442a59](https://github.com/elisiariocouto/leggen/commit/61442a598fa7f38c568e3df7e1d924ed85df7491))
|
||||||
|
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- **ci:** Prevent double GitHub Actions runs on new releases. ([30d7c2ed](https://github.com/elisiariocouto/leggen/commit/30d7c2ed4e9aff144837a1f0ed67a8ded0b5d72a))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.14 (2025/09/18)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **config:** Remove aliases for configuration keys that were disabling telegram notifications in some cases. ([61442a59](https://github.com/elisiariocouto/leggen/commit/61442a598fa7f38c568e3df7e1d924ed85df7491))
|
||||||
|
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- **ci:** Prevent double GitHub Actions runs on new releases. ([30d7c2ed](https://github.com/elisiariocouto/leggen/commit/30d7c2ed4e9aff144837a1f0ed67a8ded0b5d72a))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.13 (2025/09/17)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **frontend:** Resolve linting issue in skeleton component ([fb310a59](https://github.com/elisiariocouto/leggen/commit/fb310a5953cf51d1cac181529311e76a0f4ea9ee))
|
||||||
|
- **frontend:** Add index signature to PieDataPoint interface. ([81d7d163](https://github.com/elisiariocouto/leggen/commit/81d7d16301dafc62a95f63036819565ffb90ddb5))
|
||||||
|
- **frontend:** Resolve dual scroll and excessive whitespace issues on transactions page. ([8ab76081](https://github.com/elisiariocouto/leggen/commit/8ab760815c9ae072b8c2cb2460e31144b193e0b3))
|
||||||
|
- **frontend:** Remove broken running balance feature in transactions table. ([155a48d7](https://github.com/elisiariocouto/leggen/commit/155a48d7dc86b3f453ba6f8c37edf63c0b76c755))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **frontend:** Complete shadcn migration of skeleton and styling components ([c83386b1](https://github.com/elisiariocouto/leggen/commit/c83386b1d5b165910abe8b391ca483e5b48cd35f))
|
||||||
|
- **frontend:** Add comprehensive PWA capabilities with dynamic theme support ([86891441](https://github.com/elisiariocouto/leggen/commit/86891441d65e13757f343cabc39ccdb3ca6adc75))
|
||||||
|
- **frontend:** Add PWA install prompts, update notifications, and app shortcuts ([3049a8cd](https://github.com/elisiariocouto/leggen/commit/3049a8cd2fa80c14f970884fb14df2ab88c418dd))
|
||||||
|
- **frontend:** Update brand identity with new logo and color scheme. ([2825dba2](https://github.com/elisiariocouto/leggen/commit/2825dba2e944b3fe31aaa33127b770e7474ce021))
|
||||||
|
- **frontend:** Update analytics cards to match home page design consistency. ([d9a39c30](https://github.com/elisiariocouto/leggen/commit/d9a39c30ab1248a9fdacff068d401c3daff3f6a5))
|
||||||
|
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- Enable browsermcp and shadcn MCP servers. ([5a626b53](https://github.com/elisiariocouto/leggen/commit/5a626b53947f7e2d1544faf3ee06f8a0f1fb5d7a))
|
||||||
|
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **frontend:** Replace LoadingSpinner with shadcn skeleton components. ([84e609a7](https://github.com/elisiariocouto/leggen/commit/84e609a774ddc0caf9f84eaf1e8cdce021c82785))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.13 (2025/09/17)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **frontend:** Resolve linting issue in skeleton component ([fb310a59](https://github.com/elisiariocouto/leggen/commit/fb310a5953cf51d1cac181529311e76a0f4ea9ee))
|
||||||
|
- **frontend:** Add index signature to PieDataPoint interface. ([81d7d163](https://github.com/elisiariocouto/leggen/commit/81d7d16301dafc62a95f63036819565ffb90ddb5))
|
||||||
|
- **frontend:** Resolve dual scroll and excessive whitespace issues on transactions page. ([8ab76081](https://github.com/elisiariocouto/leggen/commit/8ab760815c9ae072b8c2cb2460e31144b193e0b3))
|
||||||
|
- **frontend:** Remove broken running balance feature in transactions table. ([155a48d7](https://github.com/elisiariocouto/leggen/commit/155a48d7dc86b3f453ba6f8c37edf63c0b76c755))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **frontend:** Complete shadcn migration of skeleton and styling components ([c83386b1](https://github.com/elisiariocouto/leggen/commit/c83386b1d5b165910abe8b391ca483e5b48cd35f))
|
||||||
|
- **frontend:** Add comprehensive PWA capabilities with dynamic theme support ([86891441](https://github.com/elisiariocouto/leggen/commit/86891441d65e13757f343cabc39ccdb3ca6adc75))
|
||||||
|
- **frontend:** Add PWA install prompts, update notifications, and app shortcuts ([3049a8cd](https://github.com/elisiariocouto/leggen/commit/3049a8cd2fa80c14f970884fb14df2ab88c418dd))
|
||||||
|
- **frontend:** Update brand identity with new logo and color scheme. ([2825dba2](https://github.com/elisiariocouto/leggen/commit/2825dba2e944b3fe31aaa33127b770e7474ce021))
|
||||||
|
- **frontend:** Update analytics cards to match home page design consistency. ([d9a39c30](https://github.com/elisiariocouto/leggen/commit/d9a39c30ab1248a9fdacff068d401c3daff3f6a5))
|
||||||
|
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- Enable browsermcp and shadcn MCP servers. ([5a626b53](https://github.com/elisiariocouto/leggen/commit/5a626b53947f7e2d1544faf3ee06f8a0f1fb5d7a))
|
||||||
|
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **frontend:** Replace LoadingSpinner with shadcn skeleton components. ([84e609a7](https://github.com/elisiariocouto/leggen/commit/84e609a774ddc0caf9f84eaf1e8cdce021c82785))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.12 (2025/09/15)
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.12 (2025/09/15)
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.11 (2025/09/15)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **config:** Add Pydantic validation and fix telegram config field mappings. ([2c6e0995](https://github.com/elisiariocouto/leggen/commit/2c6e0995968c9c9917992fd15ec10a89933c0c21))
|
||||||
|
- **config:** Fix example config file. ([d09cf6d0](https://github.com/elisiariocouto/leggen/commit/d09cf6d04ccb6233981f273cd88e0b8ffe074d71))
|
||||||
|
- **docs:** Remove test files and update gitignore ([692bee57](https://github.com/elisiariocouto/leggen/commit/692bee574ee8de16496a3c733bad53be3b256990))
|
||||||
|
- **frontend:** Align balance calculation between sidebar and Analytics page ([35b6d98e](https://github.com/elisiariocouto/leggen/commit/35b6d98e6a37b1e9caf8a232ffe66380e7203cad))
|
||||||
|
- **frontend:** Add ignore rules for eslint on shadcn components. ([74a700ff](https://github.com/elisiariocouto/leggen/commit/74a700ff87b2504c3d394cddd9935c56c3c7a00d))
|
||||||
|
- Resolve all CI failures - linting, typing, and test issues ([c8f0a103](https://github.com/elisiariocouto/leggen/commit/c8f0a103c6ccdb722bbab1ac6973827b41fddc19))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **analytics:** Fix transaction limits and improve chart legends ([e136fc4b](https://github.com/elisiariocouto/leggen/commit/e136fc4b75243b35a77bc0bf0260808006987d7a))
|
||||||
|
- **docs:** Add comprehensive copilot agent setup instructions ([c6ac4455](https://github.com/elisiariocouto/leggen/commit/c6ac4455f848dd429100dd3fc6d43de8c4e5aa6b))
|
||||||
|
- **docs:** Add configuration file setup to agent instructions ([482f16c7](https://github.com/elisiariocouto/leggen/commit/482f16c77eef1f477ba49475fe30f809de9a05d7))
|
||||||
|
- **frontend:** Enhance transactions page with advanced filtering and UI improvements. ([969776fb](https://github.com/elisiariocouto/leggen/commit/969776fb53261acca2f77b0c761584e201fde118))
|
||||||
|
- **frontend:** Replace heavy filter UI with modern shadcn/ui inline filter bar. ([eb27f191](https://github.com/elisiariocouto/leggen/commit/eb27f19196d92a6ae5220b81709fded499a12f4f))
|
||||||
|
- **frontend:** Complete shadcn/ui migration with dark mode support and analytics updates. ([66db34c7](https://github.com/elisiariocouto/leggen/commit/66db34c712300ff4b5dbe7e06246f16d6f6a8469))
|
||||||
|
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- Sort imports, fix deprecated pydantic option. ([2467cb2f](https://github.com/elisiariocouto/leggen/commit/2467cb2f5af07a7262b3221bf61b58ad4017659a))
|
||||||
|
- Check import order using ruff. ([da98b7b2](https://github.com/elisiariocouto/leggen/commit/da98b7b2b77c5b37792dedff11f8256da3b086f7))
|
||||||
|
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **analytics:** Simplify analytics endpoints and eliminate client-side processing. ([077e2bb1](https://github.com/elisiariocouto/leggen/commit/077e2bb1adbdb73ffde17635bd918cd40fe7fb5a))
|
||||||
|
- Unify leggen and leggend packages into single leggen package ([318ca517](https://github.com/elisiariocouto/leggen/commit/318ca517f7ea599b37a8deb47ad80218fbae008f))
|
||||||
|
- Consolidate database layer and eliminate wrapper complexity. ([5ae3a51d](https://github.com/elisiariocouto/leggen/commit/5ae3a51d8138b9aa28dbceabf575ab2577402e70))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.11 (2025/09/15)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **config:** Add Pydantic validation and fix telegram config field mappings. ([2c6e0995](https://github.com/elisiariocouto/leggen/commit/2c6e0995968c9c9917992fd15ec10a89933c0c21))
|
||||||
|
- **config:** Fix example config file. ([d09cf6d0](https://github.com/elisiariocouto/leggen/commit/d09cf6d04ccb6233981f273cd88e0b8ffe074d71))
|
||||||
|
- **docs:** Remove test files and update gitignore ([692bee57](https://github.com/elisiariocouto/leggen/commit/692bee574ee8de16496a3c733bad53be3b256990))
|
||||||
|
- **frontend:** Align balance calculation between sidebar and Analytics page ([35b6d98e](https://github.com/elisiariocouto/leggen/commit/35b6d98e6a37b1e9caf8a232ffe66380e7203cad))
|
||||||
|
- **frontend:** Add ignore rules for eslint on shadcn components. ([74a700ff](https://github.com/elisiariocouto/leggen/commit/74a700ff87b2504c3d394cddd9935c56c3c7a00d))
|
||||||
|
- Resolve all CI failures - linting, typing, and test issues ([c8f0a103](https://github.com/elisiariocouto/leggen/commit/c8f0a103c6ccdb722bbab1ac6973827b41fddc19))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **analytics:** Fix transaction limits and improve chart legends ([e136fc4b](https://github.com/elisiariocouto/leggen/commit/e136fc4b75243b35a77bc0bf0260808006987d7a))
|
||||||
|
- **docs:** Add comprehensive copilot agent setup instructions ([c6ac4455](https://github.com/elisiariocouto/leggen/commit/c6ac4455f848dd429100dd3fc6d43de8c4e5aa6b))
|
||||||
|
- **docs:** Add configuration file setup to agent instructions ([482f16c7](https://github.com/elisiariocouto/leggen/commit/482f16c77eef1f477ba49475fe30f809de9a05d7))
|
||||||
|
- **frontend:** Enhance transactions page with advanced filtering and UI improvements. ([969776fb](https://github.com/elisiariocouto/leggen/commit/969776fb53261acca2f77b0c761584e201fde118))
|
||||||
|
- **frontend:** Replace heavy filter UI with modern shadcn/ui inline filter bar. ([eb27f191](https://github.com/elisiariocouto/leggen/commit/eb27f19196d92a6ae5220b81709fded499a12f4f))
|
||||||
|
- **frontend:** Complete shadcn/ui migration with dark mode support and analytics updates. ([66db34c7](https://github.com/elisiariocouto/leggen/commit/66db34c712300ff4b5dbe7e06246f16d6f6a8469))
|
||||||
|
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- Sort imports, fix deprecated pydantic option. ([2467cb2f](https://github.com/elisiariocouto/leggen/commit/2467cb2f5af07a7262b3221bf61b58ad4017659a))
|
||||||
|
- Check import order using ruff. ([da98b7b2](https://github.com/elisiariocouto/leggen/commit/da98b7b2b77c5b37792dedff11f8256da3b086f7))
|
||||||
|
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **analytics:** Simplify analytics endpoints and eliminate client-side processing. ([077e2bb1](https://github.com/elisiariocouto/leggen/commit/077e2bb1adbdb73ffde17635bd918cd40fe7fb5a))
|
||||||
|
- Unify leggen and leggend packages into single leggen package ([318ca517](https://github.com/elisiariocouto/leggen/commit/318ca517f7ea599b37a8deb47ad80218fbae008f))
|
||||||
|
- Consolidate database layer and eliminate wrapper complexity. ([5ae3a51d](https://github.com/elisiariocouto/leggen/commit/5ae3a51d8138b9aa28dbceabf575ab2577402e70))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 2025.9.10 (2025/09/13)
|
## 2025.9.10 (2025/09/13)
|
||||||
|
|
||||||
### Miscellaneous Tasks
|
### Miscellaneous Tasks
|
||||||
|
|||||||
@@ -146,8 +146,8 @@ enabled = true
|
|||||||
|
|
||||||
# Optional: Transaction filters for notifications
|
# Optional: Transaction filters for notifications
|
||||||
[filters]
|
[filters]
|
||||||
case-insensitive = ["salary", "utility"]
|
case_insensitive = ["salary", "utility"]
|
||||||
case-sensitive = ["SpecificStore"]
|
case_sensitive = ["SpecificStore"]
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📖 Usage
|
## 📖 Usage
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ enabled = true
|
|||||||
|
|
||||||
# Optional: Telegram notifications
|
# Optional: Telegram notifications
|
||||||
[notifications.telegram]
|
[notifications.telegram]
|
||||||
api-key = "your-bot-token"
|
token = "your-bot-token"
|
||||||
chat-id = 12345
|
chat_id = 12345
|
||||||
enabled = true
|
enabled = true
|
||||||
|
|
||||||
# Optional: Transaction filters for notifications
|
# Optional: Transaction filters for notifications
|
||||||
[filters]
|
[filters]
|
||||||
case-insensitive = ["salary", "utility"]
|
case_insensitive = ["salary", "utility"]
|
||||||
case-sensitive = ["SpecificStore"]
|
case_sensitive = ["SpecificStore"]
|
||||||
|
|||||||
1
frontend/.gitignore
vendored
@@ -10,6 +10,7 @@ lerna-debug.log*
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
dev-dist
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import tseslint from "typescript-eslint";
|
|||||||
import { globalIgnores } from "eslint/config";
|
import { globalIgnores } from "eslint/config";
|
||||||
|
|
||||||
export default tseslint.config([
|
export default tseslint.config([
|
||||||
globalIgnores(["dist"]),
|
globalIgnores(["dist", "dev-dist"]),
|
||||||
{
|
{
|
||||||
files: ["**/*.{ts,tsx}"],
|
files: ["**/*.{ts,tsx}"],
|
||||||
extends: [
|
extends: [
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -2,9 +2,35 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>Leggen</title>
|
<title>Leggen</title>
|
||||||
|
|
||||||
|
<!-- PWA Meta Tags -->
|
||||||
|
<meta name="description" content="Personal finance management application" />
|
||||||
|
<meta name="application-name" content="Leggen" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Leggen" />
|
||||||
|
<meta name="format-detection" content="telephone=no" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="msapplication-config" content="/browserconfig.xml" />
|
||||||
|
<meta name="msapplication-TileColor" content="#0b74de" />
|
||||||
|
<meta name="msapplication-tap-highlight" content="no" />
|
||||||
|
|
||||||
|
<!-- Dynamic theme-color - will be updated by JavaScript -->
|
||||||
|
<meta name="theme-color" content="#0b74de" id="theme-color-meta" />
|
||||||
|
<meta name="msapplication-navbutton-color" content="#0b74de" id="ms-theme-color-meta" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" id="apple-status-bar-meta" />
|
||||||
|
|
||||||
|
<!-- Icons -->
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
|
||||||
|
<link rel="mask-icon" href="/favicon.svg" color="#0b74de" />
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
|
|
||||||
|
<!-- Manifest -->
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
8208
frontend/package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"react-day-picker": "^9.10.0",
|
"react-day-picker": "^9.10.0",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"recharts": "^3.2.0",
|
"recharts": "^3.2.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
@@ -40,13 +42,17 @@
|
|||||||
"@tanstack/router-vite-plugin": "^1.131.36",
|
"@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",
|
||||||
|
"@vite-pwa/assets-generator": "^1.0.1",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"eslint": "^9.33.0",
|
"eslint": "^9.33.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
|
"shadcn": "^3.3.1",
|
||||||
|
"sharp": "^0.34.3",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.39.1",
|
"typescript-eslint": "^8.39.1",
|
||||||
"vite": "^7.1.2"
|
"vite": "^7.1.2",
|
||||||
|
"vite-plugin-pwa": "^1.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
frontend/public/apple-touch-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
9
frontend/public/browserconfig.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<browserconfig>
|
||||||
|
<msapplication>
|
||||||
|
<tile>
|
||||||
|
<square150x150logo src="/pwa-192x192.png"/>
|
||||||
|
<TileColor>#3B82F6</TileColor>
|
||||||
|
</tile>
|
||||||
|
</msapplication>
|
||||||
|
</browserconfig>
|
||||||
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 813 B |
@@ -1,4 +1,27 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
<rect width="32" height="32" rx="6" fill="#3B82F6"/>
|
width="32" height="32"
|
||||||
<path d="M8 24V8h6c2.2 0 4 1.8 4 4v4c0 2.2-1.8 4-4 4H12v4H8zm4-8h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-2v4z" fill="white"/>
|
viewBox="0 0 32 32"
|
||||||
|
role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">leggen — stylized italic L</title>
|
||||||
|
<desc id="desc">Square gradient background with italic white L.</desc>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#0b74de"/>
|
||||||
|
<stop offset="100%" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Square background -->
|
||||||
|
<rect width="32" height="32" fill="url(#bg)" rx="4"/>
|
||||||
|
|
||||||
|
<!-- Italic L -->
|
||||||
|
<text x="11" y="22"
|
||||||
|
font-family="Inter, Roboto, Arial, sans-serif"
|
||||||
|
font-weight="700"
|
||||||
|
font-size="20"
|
||||||
|
font-style="italic"
|
||||||
|
fill="#fff">
|
||||||
|
L
|
||||||
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 257 B After Width: | Height: | Size: 769 B |
BIN
frontend/public/maskable-icon-512x512.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/public/pwa-192x192.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
frontend/public/pwa-512x512.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
frontend/public/pwa-64x64.png
Normal file
|
After Width: | Height: | Size: 701 B |
4
frontend/public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: /sitemap.xml
|
||||||
4
frontend/pwa-assets.config.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"preset": "minimal-2023",
|
||||||
|
"images": ["public/favicon.svg"]
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
347
frontend/src/components/AccountSettings.tsx
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
CreditCard,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Building2,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
Edit2,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Plus,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { apiClient } from "../lib/api";
|
||||||
|
import { formatCurrency, formatDate } from "../lib/utils";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "./ui/card";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||||
|
import AccountsSkeleton from "./AccountsSkeleton";
|
||||||
|
import type { Account, Balance } from "../types/api";
|
||||||
|
|
||||||
|
// Helper function to get status indicator color and styles
|
||||||
|
const getStatusIndicator = (status: string) => {
|
||||||
|
const statusLower = status.toLowerCase();
|
||||||
|
|
||||||
|
switch (statusLower) {
|
||||||
|
case "ready":
|
||||||
|
return {
|
||||||
|
color: "bg-green-500",
|
||||||
|
tooltip: "Ready",
|
||||||
|
};
|
||||||
|
case "pending":
|
||||||
|
return {
|
||||||
|
color: "bg-amber-500",
|
||||||
|
tooltip: "Pending",
|
||||||
|
};
|
||||||
|
case "error":
|
||||||
|
case "failed":
|
||||||
|
return {
|
||||||
|
color: "bg-destructive",
|
||||||
|
tooltip: "Error",
|
||||||
|
};
|
||||||
|
case "inactive":
|
||||||
|
return {
|
||||||
|
color: "bg-muted-foreground",
|
||||||
|
tooltip: "Inactive",
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
color: "bg-primary",
|
||||||
|
tooltip: status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AccountSettings() {
|
||||||
|
const {
|
||||||
|
data: accounts,
|
||||||
|
isLoading: accountsLoading,
|
||||||
|
error: accountsError,
|
||||||
|
refetch: refetchAccounts,
|
||||||
|
} = useQuery<Account[]>({
|
||||||
|
queryKey: ["accounts"],
|
||||||
|
queryFn: apiClient.getAccounts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: balances } = useQuery<Balance[]>({
|
||||||
|
queryKey: ["balances"],
|
||||||
|
queryFn: () => apiClient.getBalances(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
|
||||||
|
const [editingName, setEditingName] = useState("");
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const updateAccountMutation = useMutation({
|
||||||
|
mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
|
||||||
|
apiClient.updateAccount(id, { display_name }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
||||||
|
setEditingAccountId(null);
|
||||||
|
setEditingName("");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Failed to update account:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEditStart = (account: Account) => {
|
||||||
|
setEditingAccountId(account.id);
|
||||||
|
// Use display_name if available, otherwise fall back to name
|
||||||
|
setEditingName(account.display_name || account.name || "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSave = () => {
|
||||||
|
if (editingAccountId && editingName.trim()) {
|
||||||
|
updateAccountMutation.mutate({
|
||||||
|
id: editingAccountId,
|
||||||
|
display_name: editingName.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditCancel = () => {
|
||||||
|
setEditingAccountId(null);
|
||||||
|
setEditingName("");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (accountsLoading) {
|
||||||
|
return <AccountsSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountsError) {
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Failed to load accounts</AlertTitle>
|
||||||
|
<AlertDescription className="space-y-3">
|
||||||
|
<p>
|
||||||
|
Unable to connect to the Leggen API. Please check your configuration
|
||||||
|
and ensure the API server is running.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => refetchAccounts()} variant="outline" size="sm">
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Account Management Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Account Management</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage your connected bank accounts and customize their display names
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{!accounts || accounts.length === 0 ? (
|
||||||
|
<CardContent className="p-6 text-center">
|
||||||
|
<CreditCard className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
|
No accounts found
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
Connect your first bank account to get started with Leggen.
|
||||||
|
</p>
|
||||||
|
<Button disabled className="flex items-center space-x-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
<span>Add Bank Account</span>
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
Coming soon: Add new bank connections
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
) : (
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{accounts.map((account) => {
|
||||||
|
// Get balance from account's balances array or fallback to balances query
|
||||||
|
const accountBalance = account.balances?.[0];
|
||||||
|
const fallbackBalance = balances?.find(
|
||||||
|
(b) => b.account_id === account.id,
|
||||||
|
);
|
||||||
|
const balance =
|
||||||
|
accountBalance?.amount ||
|
||||||
|
fallbackBalance?.balance_amount ||
|
||||||
|
0;
|
||||||
|
const currency =
|
||||||
|
accountBalance?.currency ||
|
||||||
|
fallbackBalance?.currency ||
|
||||||
|
account.currency ||
|
||||||
|
"EUR";
|
||||||
|
const isPositive = balance >= 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={account.id}
|
||||||
|
className="p-4 sm:p-6 hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
{/* Mobile layout - stack vertically */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||||
|
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||||
|
<div className="flex-shrink-0 p-2 sm:p-3 bg-muted rounded-full">
|
||||||
|
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{editingAccountId === account.id ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingName}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingName(e.target.value)
|
||||||
|
}
|
||||||
|
className="flex-1 px-3 py-1 text-base sm:text-lg font-medium border border-input rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||||
|
placeholder="Custom account name"
|
||||||
|
name="search"
|
||||||
|
autoComplete="off"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleEditSave();
|
||||||
|
if (e.key === "Escape") handleEditCancel();
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleEditSave}
|
||||||
|
disabled={
|
||||||
|
!editingName.trim() ||
|
||||||
|
updateAccountMutation.isPending
|
||||||
|
}
|
||||||
|
className="p-1 text-green-600 hover:text-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Save changes"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleEditCancel}
|
||||||
|
className="p-1 text-gray-600 hover:text-gray-700"
|
||||||
|
title="Cancel editing"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
|
{account.institution_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-2 min-w-0">
|
||||||
|
<h4 className="text-base sm:text-lg font-medium text-foreground truncate">
|
||||||
|
{account.display_name ||
|
||||||
|
account.name ||
|
||||||
|
"Unnamed Account"}
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditStart(account)}
|
||||||
|
className="flex-shrink-0 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title="Edit account name"
|
||||||
|
>
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
|
{account.institution_id}
|
||||||
|
</p>
|
||||||
|
{account.iban && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 font-mono break-all sm:break-normal">
|
||||||
|
IBAN: {account.iban}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Balance and date section */}
|
||||||
|
<div className="flex items-center justify-between sm:flex-col sm:items-end sm:text-right flex-shrink-0">
|
||||||
|
{/* Mobile: date/status on left, balance on right */}
|
||||||
|
{/* Desktop: balance on top, date/status on bottom */}
|
||||||
|
|
||||||
|
{/* Date and status indicator - left on mobile, bottom on desktop */}
|
||||||
|
<div className="flex items-center space-x-2 order-1 sm:order-2">
|
||||||
|
<div
|
||||||
|
className={`w-3 h-3 rounded-full ${getStatusIndicator(account.status).color} relative group cursor-help`}
|
||||||
|
role="img"
|
||||||
|
aria-label={`Account status: ${getStatusIndicator(account.status).tooltip}`}
|
||||||
|
>
|
||||||
|
{/* Tooltip */}
|
||||||
|
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 hidden group-hover:block bg-gray-900 text-white text-xs rounded py-1 px-2 whitespace-nowrap z-10">
|
||||||
|
{getStatusIndicator(account.status).tooltip}
|
||||||
|
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-2 border-transparent border-t-gray-900"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
Updated{" "}
|
||||||
|
{formatDate(
|
||||||
|
account.last_accessed || account.created,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Balance - right on mobile, top on desktop */}
|
||||||
|
<div className="flex items-center space-x-2 order-2 sm:order-1">
|
||||||
|
{isPositive ? (
|
||||||
|
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="h-4 w-4 text-red-500" />
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
className={`text-base sm:text-lg font-semibold ${
|
||||||
|
isPositive ? "text-green-600" : "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatCurrency(balance, currency)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Add Bank Section (Future Feature) */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Add New Bank Account</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Connect additional bank accounts to track all your finances in one place
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="p-4 bg-muted rounded-lg">
|
||||||
|
<Plus className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Bank connection functionality is coming soon. Stay tuned for updates!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button disabled variant="outline">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Connect Bank Account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,16 @@ import {
|
|||||||
} 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 LoadingSpinner from "./LoadingSpinner";
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "./ui/card";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||||
|
import AccountsSkeleton from "./AccountsSkeleton";
|
||||||
import type { Account, Balance } from "../types/api";
|
import type { Account, Balance } from "../types/api";
|
||||||
|
|
||||||
// Helper function to get status indicator color and styles
|
// Helper function to get status indicator color and styles
|
||||||
@@ -21,30 +30,30 @@ const getStatusIndicator = (status: string) => {
|
|||||||
const statusLower = status.toLowerCase();
|
const statusLower = status.toLowerCase();
|
||||||
|
|
||||||
switch (statusLower) {
|
switch (statusLower) {
|
||||||
case 'ready':
|
case "ready":
|
||||||
return {
|
return {
|
||||||
color: 'bg-green-500',
|
color: "bg-green-500",
|
||||||
tooltip: 'Ready',
|
tooltip: "Ready",
|
||||||
};
|
};
|
||||||
case 'pending':
|
case "pending":
|
||||||
return {
|
return {
|
||||||
color: 'bg-yellow-500',
|
color: "bg-amber-500",
|
||||||
tooltip: 'Pending',
|
tooltip: "Pending",
|
||||||
};
|
};
|
||||||
case 'error':
|
case "error":
|
||||||
case 'failed':
|
case "failed":
|
||||||
return {
|
return {
|
||||||
color: 'bg-red-500',
|
color: "bg-destructive",
|
||||||
tooltip: 'Error',
|
tooltip: "Error",
|
||||||
};
|
};
|
||||||
case 'inactive':
|
case "inactive":
|
||||||
return {
|
return {
|
||||||
color: 'bg-gray-500',
|
color: "bg-muted-foreground",
|
||||||
tooltip: 'Inactive',
|
tooltip: "Inactive",
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
color: 'bg-blue-500',
|
color: "bg-primary",
|
||||||
tooltip: status,
|
tooltip: status,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -72,8 +81,8 @@ export default function AccountsOverview() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const updateAccountMutation = useMutation({
|
const updateAccountMutation = useMutation({
|
||||||
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
|
||||||
apiClient.updateAccount(id, { name }),
|
apiClient.updateAccount(id, { display_name }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
||||||
setEditingAccountId(null);
|
setEditingAccountId(null);
|
||||||
@@ -86,14 +95,15 @@ export default function AccountsOverview() {
|
|||||||
|
|
||||||
const handleEditStart = (account: Account) => {
|
const handleEditStart = (account: Account) => {
|
||||||
setEditingAccountId(account.id);
|
setEditingAccountId(account.id);
|
||||||
setEditingName(account.name || "");
|
// Use display_name if available, otherwise fall back to name
|
||||||
|
setEditingName(account.display_name || account.name || "");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditSave = () => {
|
const handleEditSave = () => {
|
||||||
if (editingAccountId && editingName.trim()) {
|
if (editingAccountId && editingName.trim()) {
|
||||||
updateAccountMutation.mutate({
|
updateAccountMutation.mutate({
|
||||||
id: editingAccountId,
|
id: editingAccountId,
|
||||||
name: editingName.trim(),
|
display_name: editingName.trim(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -104,36 +114,25 @@ export default function AccountsOverview() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (accountsLoading) {
|
if (accountsLoading) {
|
||||||
return (
|
return <AccountsSkeleton />;
|
||||||
<div className="bg-white rounded-lg shadow">
|
|
||||||
<LoadingSpinner message="Loading accounts..." />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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">
|
|
||||||
Unable to connect to the Leggen API. Please check your
|
|
||||||
configuration and ensure the API server is running.
|
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button onClick={() => refetchAccounts()} variant="outline" size="sm">
|
||||||
onClick={() => refetchAccounts()}
|
|
||||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</AlertDescription>
|
||||||
</div>
|
</Alert>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,72 +150,81 @@ 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>
|
||||||
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-600">Total Balance</p>
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
Total Balance
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-foreground">
|
||||||
{formatCurrency(totalBalance)}
|
{formatCurrency(totalBalance)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-green-100 rounded-full">
|
<div className="p-3 bg-green-100 dark:bg-green-900/20 rounded-full">
|
||||||
<TrendingUp className="h-6 w-6 text-green-600" />
|
<TrendingUp className="h-6 w-6 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-600">
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
Total Accounts
|
Total Accounts
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
<p className="text-2xl font-bold text-foreground">
|
||||||
{totalAccounts}
|
{totalAccounts}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-blue-100 rounded-full">
|
<div className="p-3 bg-blue-100 dark:bg-blue-900/20 rounded-full">
|
||||||
<CreditCard className="h-6 w-6 text-blue-600" />
|
<CreditCard className="h-6 w-6 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-600">
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
Connected Banks
|
Connected Banks
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-gray-900">{uniqueBanks}</p>
|
<p className="text-2xl font-bold text-foreground">
|
||||||
</div>
|
{uniqueBanks}
|
||||||
<div className="p-3 bg-purple-100 rounded-full">
|
</p>
|
||||||
<Building2 className="h-6 w-6 text-purple-600" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="p-3 bg-muted rounded-full">
|
||||||
|
<Building2 className="h-6 w-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</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">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
{accounts.map((account) => {
|
{accounts.map((account) => {
|
||||||
// Get balance from account's balances array or fallback to balances query
|
// Get balance from account's balances array or fallback to balances query
|
||||||
const accountBalance = account.balances?.[0];
|
const accountBalance = account.balances?.[0];
|
||||||
@@ -224,7 +232,9 @@ export default function AccountsOverview() {
|
|||||||
(b) => b.account_id === account.id,
|
(b) => b.account_id === account.id,
|
||||||
);
|
);
|
||||||
const balance =
|
const balance =
|
||||||
accountBalance?.amount || fallbackBalance?.balance_amount || 0;
|
accountBalance?.amount ||
|
||||||
|
fallbackBalance?.balance_amount ||
|
||||||
|
0;
|
||||||
const currency =
|
const currency =
|
||||||
accountBalance?.currency ||
|
accountBalance?.currency ||
|
||||||
fallbackBalance?.currency ||
|
fallbackBalance?.currency ||
|
||||||
@@ -235,13 +245,13 @@ export default function AccountsOverview() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={account.id}
|
key={account.id}
|
||||||
className="p-4 sm:p-6 hover:bg-gray-50 transition-colors"
|
className="p-4 sm:p-6 hover:bg-accent transition-colors"
|
||||||
>
|
>
|
||||||
{/* Mobile layout - stack vertically */}
|
{/* Mobile layout - stack vertically */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||||
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||||
<div className="flex-shrink-0 p-2 sm:p-3 bg-gray-100 rounded-full">
|
<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-gray-600" />
|
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{editingAccountId === account.id ? (
|
{editingAccountId === account.id ? (
|
||||||
@@ -250,9 +260,11 @@ export default function AccountsOverview() {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editingName}
|
value={editingName}
|
||||||
onChange={(e) => setEditingName(e.target.value)}
|
onChange={(e) =>
|
||||||
className="flex-1 px-3 py-1 text-base sm:text-lg font-medium border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
setEditingName(e.target.value)
|
||||||
placeholder="Account name"
|
}
|
||||||
|
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"
|
name="search"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -280,29 +292,31 @@ export default function AccountsOverview() {
|
|||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 truncate">
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
{account.institution_id}
|
{account.institution_id}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center space-x-2 min-w-0">
|
<div className="flex items-center space-x-2 min-w-0">
|
||||||
<h4 className="text-base sm:text-lg font-medium text-gray-900 truncate">
|
<h4 className="text-base sm:text-lg font-medium text-foreground truncate">
|
||||||
{account.name || "Unnamed Account"}
|
{account.display_name ||
|
||||||
|
account.name ||
|
||||||
|
"Unnamed Account"}
|
||||||
</h4>
|
</h4>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditStart(account)}
|
onClick={() => handleEditStart(account)}
|
||||||
className="flex-shrink-0 p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
className="flex-shrink-0 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
title="Edit account name"
|
title="Edit account name"
|
||||||
>
|
>
|
||||||
<Edit2 className="h-4 w-4" />
|
<Edit2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 truncate">
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
{account.institution_id}
|
{account.institution_id}
|
||||||
</p>
|
</p>
|
||||||
{account.iban && (
|
{account.iban && (
|
||||||
<p className="text-xs text-gray-500 mt-1 font-mono break-all sm:break-normal">
|
<p className="text-xs text-muted-foreground mt-1 font-mono break-all sm:break-normal">
|
||||||
IBAN: {account.iban}
|
IBAN: {account.iban}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -329,9 +343,11 @@ export default function AccountsOverview() {
|
|||||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-2 border-transparent border-t-gray-900"></div>
|
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-2 border-transparent border-t-gray-900"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs sm:text-sm text-gray-500 whitespace-nowrap">
|
<p className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
|
||||||
Updated{" "}
|
Updated{" "}
|
||||||
{formatDate(account.last_accessed || account.created)}
|
{formatDate(
|
||||||
|
account.last_accessed || account.created,
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -356,8 +372,9 @@ export default function AccountsOverview() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
61
frontend/src/components/AccountsSkeleton.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Skeleton } from "./ui/skeleton";
|
||||||
|
import { Card, CardContent, CardHeader } from "./ui/card";
|
||||||
|
|
||||||
|
export default function AccountsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary Cards Skeleton */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-8 w-24" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-12 w-12 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Accounts List Skeleton */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-32" />
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="p-4 sm:p-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||||
|
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||||
|
<Skeleton className="h-10 w-10 sm:h-12 sm:w-12 rounded-full flex-shrink-0" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-5 w-48" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-3 w-40" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between sm:flex-col sm:items-end sm:text-right flex-shrink-0">
|
||||||
|
<div className="flex items-center space-x-2 order-1 sm:order-2">
|
||||||
|
<Skeleton className="h-3 w-3 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 order-2 sm:order-1">
|
||||||
|
<Skeleton className="h-4 w-4" />
|
||||||
|
<Skeleton className="h-5 w-24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 TransactionsTable from "./TransactionsTable";
|
|
||||||
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" && <TransactionsTable />}
|
|
||||||
{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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Component } from "react";
|
import { Component } from "react";
|
||||||
import type { ErrorInfo, ReactNode } from "react";
|
import type { ErrorInfo, ReactNode } from "react";
|
||||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||||
|
import { Card, CardContent } from "./ui/card";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -39,46 +42,49 @@ class ErrorBoundary extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center justify-center text-center">
|
<div className="flex items-center justify-center text-center">
|
||||||
<div>
|
<div>
|
||||||
<AlertTriangle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
<AlertTriangle className="h-12 w-12 text-destructive 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">
|
||||||
Something went wrong
|
Something went wrong
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
An error occurred while rendering this component. Please try
|
An error occurred while rendering this component. Please try
|
||||||
refreshing or check the console for more details.
|
refreshing or check the console for more details.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{this.state.error && (
|
{this.state.error && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-md p-3 mb-4 text-left">
|
<Alert variant="destructive" className="mb-4 text-left">
|
||||||
<p className="text-sm font-mono text-red-800">
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Error Details</AlertTitle>
|
||||||
|
<AlertDescription className="space-y-2">
|
||||||
|
<p className="text-sm font-mono">
|
||||||
<strong>Error:</strong> {this.state.error.message}
|
<strong>Error:</strong> {this.state.error.message}
|
||||||
</p>
|
</p>
|
||||||
{this.state.error.stack && (
|
{this.state.error.stack && (
|
||||||
<details className="mt-2">
|
<details className="mt-2">
|
||||||
<summary className="text-sm text-red-600 cursor-pointer">
|
<summary className="text-sm cursor-pointer">
|
||||||
Stack trace
|
Stack trace
|
||||||
</summary>
|
</summary>
|
||||||
<pre className="text-xs text-red-700 mt-1 whitespace-pre-wrap">
|
<pre className="text-xs mt-1 whitespace-pre-wrap">
|
||||||
{this.state.error.stack}
|
{this.state.error.stack}
|
||||||
</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
</div>
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<Button onClick={this.handleReset}>
|
||||||
onClick={this.handleReset}
|
|
||||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
Try Again
|
Try Again
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,32 @@
|
|||||||
|
import { Skeleton } from "./ui/skeleton";
|
||||||
|
import { Card, CardContent } from "./ui/card";
|
||||||
|
|
||||||
export default function FiltersSkeleton() {
|
export default function FiltersSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow animate-pulse">
|
<Card>
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
<div className="px-6 py-4 border-b border-border">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="h-6 bg-gray-200 rounded w-32"></div>
|
<Skeleton className="h-6 w-32" />
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="h-8 bg-gray-200 rounded w-24"></div>
|
<Skeleton className="h-8 w-24" />
|
||||||
<div className="h-8 bg-gray-200 rounded w-20"></div>
|
<Skeleton className="h-8 w-20" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
<CardContent className="px-6 py-4 border-b border-border bg-muted/30">
|
||||||
{/* Quick Date Filters Skeleton */}
|
{/* Quick Date Filters Skeleton */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="h-4 bg-gray-200 rounded w-32 mb-3"></div>
|
<Skeleton className="h-4 w-32 mb-3" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="h-10 bg-gray-200 rounded-lg w-24"></div>
|
<Skeleton className="h-10 w-24 rounded-lg" />
|
||||||
<div className="h-10 bg-gray-200 rounded-lg w-20"></div>
|
<Skeleton className="h-10 w-20 rounded-lg" />
|
||||||
<div className="h-10 bg-gray-200 rounded-lg w-28"></div>
|
<Skeleton className="h-10 w-28 rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="h-10 bg-gray-200 rounded-lg w-24"></div>
|
<Skeleton className="h-10 w-24 rounded-lg" />
|
||||||
<div className="h-10 bg-gray-200 rounded-lg w-20"></div>
|
<Skeleton className="h-10 w-20 rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,40 +34,40 @@ export default function FiltersSkeleton() {
|
|||||||
{/* Filter Fields Skeleton */}
|
{/* Filter Fields Skeleton */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<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="sm:col-span-2 lg:col-span-1">
|
||||||
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
|
<Skeleton className="h-4 w-16 mb-1" />
|
||||||
<div className="h-10 bg-gray-200 rounded"></div>
|
<Skeleton className="h-10 w-full" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
|
<Skeleton className="h-4 w-16 mb-1" />
|
||||||
<div className="h-10 bg-gray-200 rounded"></div>
|
<Skeleton className="h-10 w-full" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
|
<Skeleton className="h-4 w-20 mb-1" />
|
||||||
<div className="h-10 bg-gray-200 rounded"></div>
|
<Skeleton className="h-10 w-full" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
|
<Skeleton className="h-4 w-16 mb-1" />
|
||||||
<div className="h-10 bg-gray-200 rounded"></div>
|
<Skeleton className="h-10 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Amount Range Filters Skeleton */}
|
{/* Amount Range Filters Skeleton */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
|
<Skeleton className="h-4 w-20 mb-1" />
|
||||||
<div className="h-10 bg-gray-200 rounded"></div>
|
<Skeleton className="h-10 w-full" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
|
<Skeleton className="h-4 w-20 mb-1" />
|
||||||
<div className="h-10 bg-gray-200 rounded"></div>
|
<Skeleton className="h-10 w-full" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
{/* Results Summary Skeleton */}
|
{/* Results Summary Skeleton */}
|
||||||
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200">
|
<CardContent className="px-6 py-3 bg-muted/30 border-b border-border">
|
||||||
<div className="h-4 bg-gray-200 rounded w-48"></div>
|
<Skeleton className="h-4 w-48" />
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { useLocation } from "@tanstack/react-router";
|
|||||||
import { Menu, Activity, Wifi, WifiOff } from "lucide-react";
|
import { Menu, Activity, Wifi, WifiOff } from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { apiClient } from "../lib/api";
|
import { apiClient } from "../lib/api";
|
||||||
|
import { ThemeToggle } from "./ui/theme-toggle";
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: "Overview", to: "/" },
|
{ name: "Overview", to: "/" },
|
||||||
{ name: "Transactions", to: "/transactions" },
|
{ name: "Transactions", to: "/transactions" },
|
||||||
{ name: "Analytics", to: "/analytics" },
|
{ name: "Analytics", to: "/analytics" },
|
||||||
{ name: "Notifications", to: "/notifications" },
|
{ name: "Notifications", to: "/notifications" },
|
||||||
|
{ name: "Settings", to: "/settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
@@ -31,38 +33,41 @@ export default function Header({ setSidebarOpen }: HeaderProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
<header className="lg:static sticky top-0 z-50 bg-card shadow-sm border-b border-border pt-safe-top">
|
||||||
<div className="flex items-center justify-between h-16 px-6">
|
<div className="flex items-center justify-between h-16 px-6">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSidebarOpen(true)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
className="lg:hidden p-1 rounded-md text-gray-400 hover:text-gray-500"
|
className="lg:hidden p-1 rounded-md text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<Menu className="h-6 w-6" />
|
<Menu className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
<h2 className="text-lg font-semibold text-gray-900 lg:ml-0 ml-4">
|
<h2 className="text-lg font-semibold text-card-foreground lg:ml-0 ml-4">
|
||||||
{currentPage}
|
{currentPage}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
{healthLoading ? (
|
{healthLoading ? (
|
||||||
<>
|
<>
|
||||||
<Activity className="h-4 w-4 text-yellow-500 animate-pulse" />
|
<Activity className="h-4 w-4 text-muted-foreground animate-pulse" />
|
||||||
<span className="text-sm text-gray-600">Checking...</span>
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Checking...
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : healthError || healthStatus?.status !== "healthy" ? (
|
) : healthError || healthStatus?.status !== "healthy" ? (
|
||||||
<>
|
<>
|
||||||
<WifiOff className="h-4 w-4 text-red-500" />
|
<WifiOff className="h-4 w-4 text-destructive" />
|
||||||
<span className="text-sm text-red-500">Disconnected</span>
|
<span className="text-sm text-destructive">Disconnected</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Wifi className="h-4 w-4 text-green-500" />
|
<Wifi className="h-4 w-4 text-green-500" />
|
||||||
<span className="text-sm text-gray-600">Connected</span>
|
<span className="text-sm text-muted-foreground">Connected</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { RefreshCw } from "lucide-react";
|
|
||||||
|
|
||||||
interface LoadingSpinnerProps {
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LoadingSpinner({
|
|
||||||
message = "Loading...",
|
|
||||||
}: LoadingSpinnerProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<RefreshCw className="h-8 w-8 animate-spin text-blue-600 mx-auto mb-2" />
|
|
||||||
<p className="text-gray-600 text-sm">{message}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,26 @@ import {
|
|||||||
TestTube,
|
TestTube,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiClient } from "../lib/api";
|
import { apiClient } from "../lib/api";
|
||||||
import LoadingSpinner from "./LoadingSpinner";
|
import NotificationsSkeleton from "./NotificationsSkeleton";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "./ui/card";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
import { Label } from "./ui/label";
|
||||||
|
import { Badge } from "./ui/badge";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "./ui/select";
|
||||||
import type { NotificationSettings, NotificationService } from "../types/api";
|
import type { NotificationSettings, NotificationService } from "../types/api";
|
||||||
|
|
||||||
export default function Notifications() {
|
export default function Notifications() {
|
||||||
@@ -62,39 +81,32 @@ export default function Notifications() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (settingsLoading || servicesLoading) {
|
if (settingsLoading || servicesLoading) {
|
||||||
return (
|
return <NotificationsSkeleton />;
|
||||||
<div className="bg-white rounded-lg shadow">
|
|
||||||
<LoadingSpinner message="Loading notifications..." />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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">
|
|
||||||
Unable to connect to the Leggen API. Please check your
|
|
||||||
configuration and ensure the API server is running.
|
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
refetchSettings();
|
refetchSettings();
|
||||||
refetchServices();
|
refetchServices();
|
||||||
}}
|
}}
|
||||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</AlertDescription>
|
||||||
</div>
|
</Alert>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,112 +132,108 @@ export default function Notifications() {
|
|||||||
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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<Label htmlFor="service" className="text-foreground">
|
||||||
Service
|
Service
|
||||||
</label>
|
</Label>
|
||||||
<select
|
<Select value={testService} onValueChange={setTestService}>
|
||||||
value={testService}
|
<SelectTrigger>
|
||||||
onChange={(e) => setTestService(e.target.value)}
|
<SelectValue placeholder="Select a service..." />
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<option value="">Select a service...</option>
|
|
||||||
{services?.map((service) => (
|
{services?.map((service) => (
|
||||||
<option key={service.name} value={service.name}>
|
<SelectItem key={service.name} value={service.name}>
|
||||||
{service.name} {service.enabled ? "(Enabled)" : "(Disabled)"}
|
{service.name}{" "}
|
||||||
</option>
|
{service.enabled ? "(Enabled)" : "(Disabled)"}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<Label htmlFor="message" className="text-foreground">
|
||||||
Message
|
Message
|
||||||
</label>
|
</Label>
|
||||||
<input
|
<Input
|
||||||
|
id="message"
|
||||||
type="text"
|
type="text"
|
||||||
value={testMessage}
|
value={testMessage}
|
||||||
onChange={(e) => setTestMessage(e.target.value)}
|
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..."
|
placeholder="Test message..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<button
|
<Button
|
||||||
onClick={handleTestNotification}
|
onClick={handleTestNotification}
|
||||||
disabled={!testService || testMutation.isPending}
|
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" />
|
<Send className="h-4 w-4 mr-2" />
|
||||||
{testMutation.isPending ? "Sending..." : "Send Test Notification"}
|
{testMutation.isPending ? "Sending..." : "Send Test Notification"}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 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">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
{services.map((service) => (
|
{services.map((service) => (
|
||||||
<div
|
<div
|
||||||
key={service.name}
|
key={service.name}
|
||||||
className="p-6 hover:bg-gray-50 transition-colors"
|
className="p-6 hover:bg-accent transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="p-3 bg-gray-100 rounded-full">
|
<div className="p-3 bg-muted rounded-full">
|
||||||
{service.name.toLowerCase().includes("discord") ? (
|
{service.name.toLowerCase().includes("discord") ? (
|
||||||
<MessageSquare className="h-6 w-6 text-gray-600" />
|
<MessageSquare className="h-6 w-6 text-muted-foreground" />
|
||||||
) : service.name.toLowerCase().includes("telegram") ? (
|
) : service.name.toLowerCase().includes("telegram") ? (
|
||||||
<Send className="h-6 w-6 text-gray-600" />
|
<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>
|
<div>
|
||||||
<h4 className="text-lg font-medium text-gray-900 capitalize">
|
<h4 className="text-lg font-medium text-foreground capitalize">
|
||||||
{service.name}
|
{service.name}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex items-center space-x-2 mt-1">
|
<div className="flex items-center space-x-2 mt-1">
|
||||||
<span
|
<Badge
|
||||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
variant={
|
||||||
service.enabled
|
service.enabled ? "default" : "destructive"
|
||||||
? "bg-green-100 text-green-800"
|
}
|
||||||
: "bg-red-100 text-red-800"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{service.enabled ? (
|
{service.enabled ? (
|
||||||
<CheckCircle className="h-3 w-3 mr-1" />
|
<CheckCircle className="h-3 w-3 mr-1" />
|
||||||
@@ -233,69 +241,69 @@ export default function Notifications() {
|
|||||||
<AlertCircle className="h-3 w-3 mr-1" />
|
<AlertCircle className="h-3 w-3 mr-1" />
|
||||||
)}
|
)}
|
||||||
{service.enabled ? "Enabled" : "Disabled"}
|
{service.enabled ? "Enabled" : "Disabled"}
|
||||||
</span>
|
</Badge>
|
||||||
<span
|
<Badge
|
||||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
variant={
|
||||||
service.configured
|
service.configured ? "secondary" : "outline"
|
||||||
? "bg-blue-100 text-blue-800"
|
}
|
||||||
: "bg-yellow-100 text-yellow-800"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{service.configured ? "Configured" : "Not Configured"}
|
{service.configured
|
||||||
</span>
|
? "Configured"
|
||||||
|
: "Not Configured"}
|
||||||
|
</Badge>
|
||||||
</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(", ")
|
||||||
@@ -306,7 +314,7 @@ export default function Notifications() {
|
|||||||
</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.
|
||||||
@@ -314,7 +322,8 @@ export default function Notifications() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
95
frontend/src/components/NotificationsSkeleton.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { Skeleton } from "./ui/skeleton";
|
||||||
|
import { Card, CardContent, CardHeader } from "./ui/card";
|
||||||
|
|
||||||
|
export default function NotificationsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Test Notification Section Skeleton */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Skeleton className="h-5 w-5" />
|
||||||
|
<Skeleton className="h-6 w-36" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Skeleton className="h-10 w-48" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Notification Services Skeleton */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Skeleton className="h-5 w-5" />
|
||||||
|
<Skeleton className="h-6 w-40" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-4 w-56" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Skeleton className="h-12 w-12 rounded-full" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-5 w-24" />
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Skeleton className="h-5 w-16" />
|
||||||
|
<Skeleton className="h-5 w-20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Notification Settings Skeleton */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Skeleton className="h-5 w-5" />
|
||||||
|
<Skeleton className="h-6 w-40" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
<div className="bg-muted rounded-md p-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-3 w-32" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-3 w-28" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
frontend/src/components/PWAPrompts.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { X, Download, RotateCcw } from "lucide-react";
|
||||||
|
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt(): Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PWAPromptProps {
|
||||||
|
onInstall?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PWAInstallPrompt({ onInstall }: PWAPromptProps) {
|
||||||
|
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||||
|
const [showPrompt, setShowPrompt] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
// Prevent the mini-infobar from appearing on mobile
|
||||||
|
e.preventDefault();
|
||||||
|
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||||
|
setShowPrompt(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("beforeinstallprompt", handler);
|
||||||
|
|
||||||
|
return () => window.removeEventListener("beforeinstallprompt", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
if (!deferredPrompt) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deferredPrompt.prompt();
|
||||||
|
const { outcome } = await deferredPrompt.userChoice;
|
||||||
|
|
||||||
|
if (outcome === "accepted") {
|
||||||
|
onInstall?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeferredPrompt(null);
|
||||||
|
setShowPrompt(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error installing PWA:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
setShowPrompt(false);
|
||||||
|
setDeferredPrompt(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!showPrompt || !deferredPrompt) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 z-50">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Download className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
Install Leggen
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Add to your home screen for quick access
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="flex-shrink-0 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleInstall}
|
||||||
|
className="flex-1 bg-blue-600 text-white text-sm font-medium px-3 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
Install
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
||||||
|
>
|
||||||
|
Not now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PWAUpdatePromptProps {
|
||||||
|
updateAvailable: boolean;
|
||||||
|
onUpdate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PWAUpdatePrompt({ updateAvailable, onUpdate }: PWAUpdatePromptProps) {
|
||||||
|
const [showPrompt, setShowPrompt] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (updateAvailable) {
|
||||||
|
setShowPrompt(true);
|
||||||
|
}
|
||||||
|
}, [updateAvailable]);
|
||||||
|
|
||||||
|
const handleUpdate = () => {
|
||||||
|
onUpdate();
|
||||||
|
setShowPrompt(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
setShowPrompt(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!showPrompt || !updateAvailable) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 z-50">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<RotateCcw className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
Update Available
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
A new version of Leggen is ready to install
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="flex-shrink-0 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleUpdate}
|
||||||
|
className="flex-1 bg-green-600 text-white text-sm font-medium px-3 py-2 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
>
|
||||||
|
Update Now
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
||||||
|
>
|
||||||
|
Later
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { X, Copy, Check } from "lucide-react";
|
import { X, Copy, Check } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
import type { RawTransactionData } from "../types/api";
|
import type { RawTransactionData } from "../types/api";
|
||||||
|
|
||||||
interface RawTransactionModalProps {
|
interface RawTransactionModalProps {
|
||||||
@@ -38,26 +39,27 @@ export default function RawTransactionModal({
|
|||||||
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
<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 */}
|
{/* Background overlay */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
className="fixed inset-0 bg-background/80 backdrop-blur-sm transition-opacity"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal panel */}
|
{/* Modal panel */}
|
||||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
|
<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-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
<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">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-medium text-gray-900">
|
<h3 className="text-lg font-medium text-foreground">
|
||||||
Raw Transaction Data
|
Raw Transaction Data
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<button
|
<Button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
disabled={!rawTransaction}
|
disabled={!rawTransaction}
|
||||||
className="inline-flex items-center px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<>
|
<>
|
||||||
<Check className="h-4 w-4 mr-1 text-green-600" />
|
<Check className="h-4 w-4 mr-1 text-green-600 dark:text-green-400" />
|
||||||
Copied!
|
Copied!
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -66,37 +68,34 @@ export default function RawTransactionModal({
|
|||||||
Copy JSON
|
Copy JSON
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button onClick={onClose} variant="ghost" size="sm">
|
||||||
onClick={onClose}
|
|
||||||
className="inline-flex items-center p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-muted-foreground">
|
||||||
Transaction ID:{" "}
|
Transaction ID:{" "}
|
||||||
<code className="bg-gray-100 px-2 py-1 rounded text-xs">
|
<code className="bg-muted px-2 py-1 rounded text-xs text-foreground">
|
||||||
{transactionId}
|
{transactionId}
|
||||||
</code>
|
</code>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rawTransaction ? (
|
{rawTransaction ? (
|
||||||
<div className="bg-gray-50 rounded-lg p-4 overflow-auto max-h-96">
|
<div className="bg-muted rounded-lg p-4 overflow-auto max-h-96">
|
||||||
<pre className="text-sm text-gray-800 whitespace-pre-wrap">
|
<pre className="text-sm text-foreground whitespace-pre-wrap">
|
||||||
{JSON.stringify(rawTransaction, null, 2)}
|
{JSON.stringify(rawTransaction, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-gray-50 rounded-lg p-8 text-center">
|
<div className="bg-muted rounded-lg p-8 text-center">
|
||||||
<p className="text-gray-600">
|
<p className="text-foreground">
|
||||||
Raw transaction data is not available for this transaction.
|
Raw transaction data is not available for this transaction.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 mt-2">
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
Try refreshing the page or check if the transaction was
|
Try refreshing the page or check if the transaction was
|
||||||
fetched with summary_only=false.
|
fetched with summary_only=false.
|
||||||
</p>
|
</p>
|
||||||
@@ -104,14 +103,14 @@ export default function RawTransactionModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
<div className="bg-muted/50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm"
|
className="w-full sm:ml-3 sm:w-auto"
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,29 @@
|
|||||||
import { Link, useLocation } from "@tanstack/react-router";
|
import { Link, useLocation } from "@tanstack/react-router";
|
||||||
import {
|
import {
|
||||||
CreditCard,
|
|
||||||
Home,
|
|
||||||
List,
|
List,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Bell,
|
Bell,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
X,
|
X,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Settings,
|
||||||
|
Building2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Logo } from "./ui/logo";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { apiClient } from "../lib/api";
|
import { apiClient } from "../lib/api";
|
||||||
import { formatCurrency } from "../lib/utils";
|
import { formatCurrency } from "../lib/utils";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { useState } from "react";
|
||||||
import type { Account } from "../types/api";
|
import type { Account } from "../types/api";
|
||||||
|
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: "Overview", icon: Home, to: "/" },
|
{ name: "Overview", icon: List, to: "/" },
|
||||||
{ name: "Transactions", icon: List, to: "/transactions" },
|
|
||||||
{ name: "Analytics", icon: BarChart3, to: "/analytics" },
|
{ name: "Analytics", icon: BarChart3, to: "/analytics" },
|
||||||
{ name: "Notifications", icon: Bell, to: "/notifications" },
|
{ name: "Notifications", icon: Bell, to: "/notifications" },
|
||||||
|
{ name: "Settings", icon: Settings, to: "/settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@@ -28,6 +33,7 @@ interface SidebarProps {
|
|||||||
|
|
||||||
export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
|
export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [accountsExpanded, setAccountsExpanded] = useState(false);
|
||||||
|
|
||||||
const { data: accounts } = useQuery<Account[]>({
|
const { data: accounts } = useQuery<Account[]>({
|
||||||
queryKey: ["accounts"],
|
queryKey: ["accounts"],
|
||||||
@@ -43,22 +49,22 @@ export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
"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",
|
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 justify-between h-16 px-6 border-b border-border">
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
className="flex items-center space-x-2 hover:opacity-80 transition-opacity"
|
className="flex items-center space-x-2 hover:opacity-80 transition-opacity"
|
||||||
>
|
>
|
||||||
<CreditCard className="h-8 w-8 text-blue-600" />
|
<Logo size={32} />
|
||||||
<h1 className="text-xl font-bold text-gray-900">Leggen</h1>
|
<h1 className="text-xl font-bold text-card-foreground">Leggen</h1>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
className="lg:hidden p-1 rounded-md text-gray-400 hover:text-gray-500"
|
className="lg:hidden p-1 rounded-md text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<X className="h-6 w-6" />
|
<X className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
@@ -71,11 +77,12 @@ export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
|
|||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
className={`flex items-center w-full px-3 py-2 text-sm font-medium rounded-md transition-colors ${
|
className={cn(
|
||||||
|
"flex items-center w-full px-3 py-2 text-sm font-medium rounded-md transition-colors",
|
||||||
location.pathname === item.to
|
location.pathname === item.to
|
||||||
? "bg-blue-100 text-blue-700"
|
? "bg-primary text-primary-foreground"
|
||||||
: "text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
: "text-card-foreground hover:text-card-foreground hover:bg-accent",
|
||||||
}`}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="mr-3 h-5 w-5" />
|
<item.icon className="mr-3 h-5 w-5" />
|
||||||
{item.name}
|
{item.name}
|
||||||
@@ -84,22 +91,69 @@ export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Account Summary in Sidebar */}
|
{/* Collapsible Account Summary in Sidebar */}
|
||||||
<div className="px-6 py-4 border-t border-gray-200 mt-auto">
|
<div className="px-6 pt-4 pb-6 border-t border-border mt-auto">
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
<div className="bg-muted rounded-lg">
|
||||||
<div className="flex items-center justify-between">
|
{/* Collapsible Header */}
|
||||||
<span className="text-sm font-medium text-gray-600">
|
<button
|
||||||
|
onClick={() => setAccountsExpanded(!accountsExpanded)}
|
||||||
|
className="w-full p-4 flex items-center justify-between hover:bg-muted/80 transition-colors rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
Total Balance
|
Total Balance
|
||||||
</span>
|
</span>
|
||||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-gray-900 mt-1">
|
{accountsExpanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="px-4 pb-2">
|
||||||
|
<p className="text-2xl font-bold text-foreground">
|
||||||
{formatCurrency(totalBalance)}
|
{formatCurrency(totalBalance)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-muted-foreground">
|
||||||
{accounts?.length || 0} accounts
|
{accounts?.length || 0} accounts
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Account Details */}
|
||||||
|
{accountsExpanded && accounts && accounts.length > 0 && (
|
||||||
|
<div className="border-t border-border/50 max-h-64 overflow-y-auto">
|
||||||
|
{accounts.map((account) => {
|
||||||
|
const primaryBalance = account.balances?.[0]?.amount || 0;
|
||||||
|
const currency = account.balances?.[0]?.currency || account.currency || "EUR";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={account.id}
|
||||||
|
className="p-3 border-b border-border/30 last:border-b-0 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start space-x-2">
|
||||||
|
<div className="flex-shrink-0 p-1 bg-background rounded">
|
||||||
|
<Building2 className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-foreground truncate">
|
||||||
|
{account.display_name || account.name || "Unnamed Account"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold text-foreground">
|
||||||
|
{formatCurrency(primaryBalance, currency)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { Skeleton } from "./ui/skeleton";
|
||||||
|
import { Card } from "./ui/card";
|
||||||
|
|
||||||
interface TransactionSkeletonProps {
|
interface TransactionSkeletonProps {
|
||||||
rows?: number;
|
rows?: number;
|
||||||
view?: "table" | "mobile";
|
view?: "table" | "mobile";
|
||||||
@@ -5,99 +8,95 @@ interface TransactionSkeletonProps {
|
|||||||
|
|
||||||
export default function TransactionSkeleton({
|
export default function TransactionSkeleton({
|
||||||
rows = 5,
|
rows = 5,
|
||||||
view = "table"
|
view = "table",
|
||||||
}: TransactionSkeletonProps) {
|
}: TransactionSkeletonProps) {
|
||||||
const skeletonRows = Array.from({ length: rows }, (_, index) => index);
|
const skeletonRows = Array.from({ length: rows }, (_, index) => index);
|
||||||
|
|
||||||
if (view === "mobile") {
|
if (view === "mobile") {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow divide-y divide-gray-200">
|
<Card className="divide-y divide-border">
|
||||||
{skeletonRows.map((_, index) => (
|
{skeletonRows.map((_, index) => (
|
||||||
<div key={index} className="p-4 animate-pulse">
|
<div key={index} className="p-4">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<div className="p-2 rounded-full bg-gray-200 flex-shrink-0">
|
<Skeleton className="h-10 w-10 rounded-full 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="flex-1 min-w-0 space-y-2">
|
||||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
<Skeleton className="h-4 w-3/4" />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
<Skeleton className="h-3 w-1/2" />
|
||||||
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
|
<Skeleton className="h-3 w-2/3" />
|
||||||
<div className="h-3 bg-gray-200 rounded w-1/3"></div>
|
<Skeleton className="h-3 w-1/3" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right ml-3 flex-shrink-0 space-y-2">
|
<div className="text-right ml-3 flex-shrink-0 space-y-2">
|
||||||
<div className="h-6 bg-gray-200 rounded w-20"></div>
|
<Skeleton className="h-6 w-20" />
|
||||||
<div className="h-4 bg-gray-200 rounded w-16 ml-auto"></div>
|
<Skeleton className="h-4 w-16 ml-auto" />
|
||||||
<div className="h-6 bg-gray-200 rounded w-12 ml-auto"></div>
|
<Skeleton className="h-6 w-12 ml-auto" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-border">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-muted/50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left">
|
<th className="px-6 py-3 text-left">
|
||||||
<div className="h-4 bg-gray-200 rounded w-20 animate-pulse"></div>
|
<Skeleton className="h-4 w-20" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left">
|
<th className="px-6 py-3 text-left">
|
||||||
<div className="h-4 bg-gray-200 rounded w-16 animate-pulse"></div>
|
<Skeleton className="h-4 w-16" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left">
|
<th className="px-6 py-3 text-left">
|
||||||
<div className="h-4 bg-gray-200 rounded w-12 animate-pulse"></div>
|
<Skeleton className="h-4 w-12" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left">
|
<th className="px-6 py-3 text-left">
|
||||||
<div className="h-4 bg-gray-200 rounded w-8 animate-pulse"></div>
|
<Skeleton className="h-4 w-8" />
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-card divide-y divide-border">
|
||||||
{skeletonRows.map((_, index) => (
|
{skeletonRows.map((_, index) => (
|
||||||
<tr key={index} className="animate-pulse">
|
<tr key={index}>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<div className="p-2 rounded-full bg-gray-200 flex-shrink-0">
|
<Skeleton className="h-10 w-10 rounded-full flex-shrink-0" />
|
||||||
<div className="h-4 w-4 bg-gray-300 rounded"></div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
<Skeleton className="h-4 w-3/4" />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
<Skeleton className="h-3 w-1/2" />
|
||||||
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
|
<Skeleton className="h-3 w-2/3" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="h-6 bg-gray-200 rounded w-24 ml-auto mb-1"></div>
|
<Skeleton className="h-6 w-24 ml-auto mb-1" />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="h-4 bg-gray-200 rounded w-20"></div>
|
<Skeleton className="h-4 w-20" />
|
||||||
<div className="h-3 bg-gray-200 rounded w-16"></div>
|
<Skeleton className="h-3 w-16" />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="h-6 bg-gray-200 rounded w-12"></div>
|
<Skeleton className="h-6 w-12" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ import TransactionSkeleton from "./TransactionSkeleton";
|
|||||||
import FiltersSkeleton from "./FiltersSkeleton";
|
import FiltersSkeleton from "./FiltersSkeleton";
|
||||||
import RawTransactionModal from "./RawTransactionModal";
|
import RawTransactionModal from "./RawTransactionModal";
|
||||||
import { FilterBar, type FilterState } from "./filters";
|
import { FilterBar, type FilterState } from "./filters";
|
||||||
import type { Account, Transaction, ApiResponse, Balance } from "../types/api";
|
import { DataTablePagination } from "./ui/data-table-pagination";
|
||||||
|
import { Card } from "./ui/card";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import type { Account, Transaction, ApiResponse } from "../types/api";
|
||||||
|
|
||||||
export default function TransactionsTable() {
|
export default function TransactionsTable() {
|
||||||
// Filter state consolidated into a single object
|
// Filter state consolidated into a single object
|
||||||
@@ -36,21 +40,20 @@ export default function TransactionsTable() {
|
|||||||
selectedAccount: "",
|
selectedAccount: "",
|
||||||
startDate: "",
|
startDate: "",
|
||||||
endDate: "",
|
endDate: "",
|
||||||
minAmount: "",
|
|
||||||
maxAmount: "",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const [showRawModal, setShowRawModal] = useState(false);
|
const [showRawModal, setShowRawModal] = useState(false);
|
||||||
const [selectedTransaction, setSelectedTransaction] =
|
const [selectedTransaction, setSelectedTransaction] =
|
||||||
useState<Transaction | null>(null);
|
useState<Transaction | null>(null);
|
||||||
const [showRunningBalance, setShowRunningBalance] = useState(true);
|
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [perPage, setPerPage] = useState(50);
|
const [perPage, setPerPage] = useState(50);
|
||||||
|
|
||||||
// Debounced search state
|
// Debounced search state
|
||||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(filterState.searchTerm);
|
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(
|
||||||
|
filterState.searchTerm,
|
||||||
|
);
|
||||||
|
|
||||||
// Table state (remove pagination from table)
|
// Table state (remove pagination from table)
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
@@ -68,8 +71,6 @@ export default function TransactionsTable() {
|
|||||||
selectedAccount: "",
|
selectedAccount: "",
|
||||||
startDate: "",
|
startDate: "",
|
||||||
endDate: "",
|
endDate: "",
|
||||||
minAmount: "",
|
|
||||||
maxAmount: "",
|
|
||||||
});
|
});
|
||||||
setColumnFilters([]);
|
setColumnFilters([]);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
@@ -96,11 +97,6 @@ export default function TransactionsTable() {
|
|||||||
queryFn: apiClient.getAccounts,
|
queryFn: apiClient.getAccounts,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: balances } = useQuery<Balance[]>({
|
|
||||||
queryKey: ["balances"],
|
|
||||||
queryFn: apiClient.getBalances,
|
|
||||||
enabled: showRunningBalance,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: transactionsResponse,
|
data: transactionsResponse,
|
||||||
@@ -116,8 +112,6 @@ export default function TransactionsTable() {
|
|||||||
currentPage,
|
currentPage,
|
||||||
perPage,
|
perPage,
|
||||||
debouncedSearchTerm,
|
debouncedSearchTerm,
|
||||||
filterState.minAmount,
|
|
||||||
filterState.maxAmount,
|
|
||||||
],
|
],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiClient.getTransactions({
|
apiClient.getTransactions({
|
||||||
@@ -128,8 +122,6 @@ export default function TransactionsTable() {
|
|||||||
perPage: perPage,
|
perPage: perPage,
|
||||||
search: debouncedSearchTerm || undefined,
|
search: debouncedSearchTerm || undefined,
|
||||||
summaryOnly: false,
|
summaryOnly: false,
|
||||||
minAmount: filterState.minAmount ? parseFloat(filterState.minAmount) : undefined,
|
|
||||||
maxAmount: filterState.maxAmount ? parseFloat(filterState.maxAmount) : undefined,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,7 +141,11 @@ export default function TransactionsTable() {
|
|||||||
// Reset pagination when filters change
|
// Reset pagination when filters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [filterState.selectedAccount, filterState.startDate, filterState.endDate, filterState.minAmount, filterState.maxAmount]);
|
}, [
|
||||||
|
filterState.selectedAccount,
|
||||||
|
filterState.startDate,
|
||||||
|
filterState.endDate,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleViewRaw = (transaction: Transaction) => {
|
const handleViewRaw = (transaction: Transaction) => {
|
||||||
setSelectedTransaction(transaction);
|
setSelectedTransaction(transaction);
|
||||||
@@ -165,54 +161,8 @@ export default function TransactionsTable() {
|
|||||||
filterState.searchTerm ||
|
filterState.searchTerm ||
|
||||||
filterState.selectedAccount ||
|
filterState.selectedAccount ||
|
||||||
filterState.startDate ||
|
filterState.startDate ||
|
||||||
filterState.endDate ||
|
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
|
// Define columns
|
||||||
const columns: ColumnDef<Transaction>[] = [
|
const columns: ColumnDef<Transaction>[] = [
|
||||||
@@ -240,10 +190,10 @@ export default function TransactionsTable() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h4 className="text-sm font-medium text-gray-900 truncate">
|
<h4 className="text-sm font-medium text-foreground truncate">
|
||||||
{transaction.description}
|
{transaction.description}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="text-xs text-gray-500 space-y-1">
|
<div className="text-xs text-muted-foreground space-y-1">
|
||||||
{account && (
|
{account && (
|
||||||
<p className="truncate">
|
<p className="truncate">
|
||||||
{account.name || "Unnamed Account"} •{" "}
|
{account.name || "Unnamed Account"} •{" "}
|
||||||
@@ -289,38 +239,19 @@ export default function TransactionsTable() {
|
|||||||
},
|
},
|
||||||
sortingFn: "basic",
|
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-gray-900">
|
|
||||||
{formatCurrency(balance, transaction.transaction_currency)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}] : []),
|
|
||||||
{
|
{
|
||||||
accessorKey: "transaction_date",
|
accessorKey: "transaction_date",
|
||||||
header: "Date",
|
header: "Date",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const transaction = row.original;
|
const transaction = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="text-sm text-gray-900">
|
<div className="text-sm text-foreground">
|
||||||
{transaction.transaction_date
|
{transaction.transaction_date
|
||||||
? formatDate(transaction.transaction_date)
|
? formatDate(transaction.transaction_date)
|
||||||
: "No date"}
|
: "No date"}
|
||||||
{transaction.booking_date &&
|
{transaction.booking_date &&
|
||||||
transaction.booking_date !== transaction.transaction_date && (
|
transaction.booking_date !== transaction.transaction_date && (
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-muted-foreground">
|
||||||
Booked: {formatDate(transaction.booking_date)}
|
Booked: {formatDate(transaction.booking_date)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -337,7 +268,7 @@ export default function TransactionsTable() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleViewRaw(transaction)}
|
onClick={() => handleViewRaw(transaction)}
|
||||||
className="inline-flex items-center px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
|
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"
|
title="View raw transaction data"
|
||||||
>
|
>
|
||||||
<Eye className="h-3 w-3 mr-1" />
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
@@ -361,7 +292,8 @@ export default function TransactionsTable() {
|
|||||||
columnFilters,
|
columnFilters,
|
||||||
globalFilter: filterState.searchTerm,
|
globalFilter: filterState.searchTerm,
|
||||||
},
|
},
|
||||||
onGlobalFilterChange: (value: string) => handleFilterChange("searchTerm", value),
|
onGlobalFilterChange: (value: string) =>
|
||||||
|
handleFilterChange("searchTerm", value),
|
||||||
globalFilterFn: (row, _columnId, filterValue) => {
|
globalFilterFn: (row, _columnId, filterValue) => {
|
||||||
// Custom global filter that searches multiple fields
|
// Custom global filter that searches multiple fields
|
||||||
const transaction = row.original;
|
const transaction = row.original;
|
||||||
@@ -395,31 +327,26 @@ export default function TransactionsTable() {
|
|||||||
|
|
||||||
if (transactionsError) {
|
if (transactionsError) {
|
||||||
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 transactions</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>Unable to fetch transactions from the Leggen API.</p>
|
||||||
Failed to load transactions
|
<Button
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-4">
|
|
||||||
Unable to fetch transactions from the Leggen API.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => refetchTransactions()}
|
onClick={() => refetchTransactions()}
|
||||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</AlertDescription>
|
||||||
</div>
|
</Alert>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 max-w-full">
|
||||||
{/* New FilterBar */}
|
{/* New FilterBar */}
|
||||||
<FilterBar
|
<FilterBar
|
||||||
filterState={filterState}
|
filterState={filterState}
|
||||||
@@ -427,51 +354,20 @@ export default function TransactionsTable() {
|
|||||||
onClearFilters={handleClearFilters}
|
onClearFilters={handleClearFilters}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
isSearchLoading={isSearchLoading}
|
isSearchLoading={isSearchLoading}
|
||||||
showRunningBalance={showRunningBalance}
|
|
||||||
onToggleRunningBalance={() => setShowRunningBalance(!showRunningBalance)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Results Summary */}
|
|
||||||
<div className="bg-white rounded-lg shadow border">
|
|
||||||
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200">
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Responsive Table/Cards */}
|
{/* Responsive Table/Cards */}
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
<Card>
|
||||||
{/* Desktop Table View (hidden on mobile) */}
|
{/* Desktop Table View (hidden on mobile) */}
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<div className="overflow-x-auto">
|
<table className="min-w-full divide-y divide-border">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<thead className="bg-muted/50">
|
||||||
<thead className="bg-gray-50">
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => (
|
||||||
<th
|
<th
|
||||||
key={header.id}
|
key={header.id}
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
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()}
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
@@ -488,15 +384,15 @@ export default function TransactionsTable() {
|
|||||||
<ChevronUp
|
<ChevronUp
|
||||||
className={`h-3 w-3 ${
|
className={`h-3 w-3 ${
|
||||||
header.column.getIsSorted() === "asc"
|
header.column.getIsSorted() === "asc"
|
||||||
? "text-blue-600"
|
? "text-primary"
|
||||||
: "text-gray-400"
|
: "text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={`h-3 w-3 -mt-1 ${
|
className={`h-3 w-3 -mt-1 ${
|
||||||
header.column.getIsSorted() === "desc"
|
header.column.getIsSorted() === "desc"
|
||||||
? "text-blue-600"
|
? "text-primary"
|
||||||
: "text-gray-400"
|
: "text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -507,20 +403,20 @@ export default function TransactionsTable() {
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-card divide-y divide-border">
|
||||||
{table.getRowModel().rows.length === 0 ? (
|
{table.getRowModel().rows.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={columns.length}
|
colSpan={columns.length}
|
||||||
className="px-6 py-12 text-center"
|
className="px-6 py-12 text-center"
|
||||||
>
|
>
|
||||||
<div className="text-gray-400 mb-4">
|
<div className="text-muted-foreground mb-4">
|
||||||
<TrendingUp className="h-12 w-12 mx-auto" />
|
<TrendingUp className="h-12 w-12 mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
No transactions found
|
No transactions found
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600">
|
<p className="text-muted-foreground">
|
||||||
{hasActiveFilters
|
{hasActiveFilters
|
||||||
? "Try adjusting your filters to see more results."
|
? "Try adjusting your filters to see more results."
|
||||||
: "No transactions are available for the selected criteria."}
|
: "No transactions are available for the selected criteria."}
|
||||||
@@ -529,9 +425,12 @@ export default function TransactionsTable() {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
table.getRowModel().rows.map((row) => (
|
table.getRowModel().rows.map((row) => (
|
||||||
<tr key={row.id} className="hover:bg-gray-50">
|
<tr key={row.id} className="hover:bg-muted/50">
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<td key={cell.id} className="px-6 py-4 whitespace-nowrap">
|
<td
|
||||||
|
key={cell.id}
|
||||||
|
className="px-6 py-4 whitespace-nowrap"
|
||||||
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
cell.column.columnDef.cell,
|
cell.column.columnDef.cell,
|
||||||
cell.getContext(),
|
cell.getContext(),
|
||||||
@@ -544,26 +443,25 @@ export default function TransactionsTable() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Card View (visible only on mobile) */}
|
{/* Mobile Card View (visible only on mobile) */}
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
{table.getRowModel().rows.length === 0 ? (
|
{table.getRowModel().rows.length === 0 ? (
|
||||||
<div className="px-6 py-12 text-center">
|
<div className="px-6 py-12 text-center">
|
||||||
<div className="text-gray-400 mb-4">
|
<div className="text-muted-foreground mb-4">
|
||||||
<TrendingUp className="h-12 w-12 mx-auto" />
|
<TrendingUp className="h-12 w-12 mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
No transactions found
|
No transactions found
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600">
|
<p className="text-muted-foreground">
|
||||||
{hasActiveFilters
|
{hasActiveFilters
|
||||||
? "Try adjusting your filters to see more results."
|
? "Try adjusting your filters to see more results."
|
||||||
: "No transactions are available for the selected criteria."}
|
: "No transactions are available for the selected criteria."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-200">
|
<div className="divide-y divide-border">
|
||||||
{table.getRowModel().rows.map((row) => {
|
{table.getRowModel().rows.map((row) => {
|
||||||
const transaction = row.original;
|
const transaction = row.original;
|
||||||
const account = accounts?.find(
|
const account = accounts?.find(
|
||||||
@@ -574,7 +472,7 @@ export default function TransactionsTable() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={row.id}
|
key={row.id}
|
||||||
className="p-4 hover:bg-gray-50 transition-colors"
|
className="p-4 hover:bg-muted/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -591,33 +489,39 @@ export default function TransactionsTable() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h4 className="text-sm font-medium text-gray-900 break-words">
|
<h4 className="text-sm font-medium text-foreground break-words">
|
||||||
{transaction.description}
|
{transaction.description}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="text-xs text-gray-500 space-y-1 mt-1">
|
<div className="text-xs text-muted-foreground space-y-1 mt-1">
|
||||||
{account && (
|
{account && (
|
||||||
<p className="break-words">
|
<p className="break-words">
|
||||||
{account.name || "Unnamed Account"} •{" "}
|
{account.name || "Unnamed Account"} •{" "}
|
||||||
{account.institution_id}
|
{account.institution_id}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{(transaction.creditor_name || transaction.debtor_name) && (
|
{(transaction.creditor_name ||
|
||||||
|
transaction.debtor_name) && (
|
||||||
<p className="break-words">
|
<p className="break-words">
|
||||||
{isPositive ? "From: " : "To: "}
|
{isPositive ? "From: " : "To: "}
|
||||||
{transaction.creditor_name || transaction.debtor_name}
|
{transaction.creditor_name ||
|
||||||
|
transaction.debtor_name}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{transaction.reference && (
|
{transaction.reference && (
|
||||||
<p className="break-words">Ref: {transaction.reference}</p>
|
<p className="break-words">
|
||||||
|
Ref: {transaction.reference}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-gray-400">
|
<p className="text-muted-foreground">
|
||||||
{transaction.transaction_date
|
{transaction.transaction_date
|
||||||
? formatDate(transaction.transaction_date)
|
? formatDate(transaction.transaction_date)
|
||||||
: "No date"}
|
: "No date"}
|
||||||
{transaction.booking_date &&
|
{transaction.booking_date &&
|
||||||
transaction.booking_date !== transaction.transaction_date && (
|
transaction.booking_date !==
|
||||||
|
transaction.transaction_date && (
|
||||||
<span className="ml-2">
|
<span className="ml-2">
|
||||||
(Booked: {formatDate(transaction.booking_date)})
|
(Booked:{" "}
|
||||||
|
{formatDate(transaction.booking_date)})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
@@ -637,17 +541,9 @@ export default function TransactionsTable() {
|
|||||||
transaction.transaction_currency,
|
transaction.transaction_currency,
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{showRunningBalance && (
|
|
||||||
<p className="text-xs text-gray-500 mb-1">
|
|
||||||
Balance: {formatCurrency(
|
|
||||||
runningBalances[`${transaction.account_id}-${transaction.transaction_id}`] || 0,
|
|
||||||
transaction.transaction_currency,
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleViewRaw(transaction)}
|
onClick={() => handleViewRaw(transaction)}
|
||||||
className="inline-flex items-center px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
|
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"
|
title="View raw transaction data"
|
||||||
>
|
>
|
||||||
<Eye className="h-3 w-3 mr-1" />
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
@@ -664,141 +560,18 @@ export default function TransactionsTable() {
|
|||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{pagination && (
|
{pagination && (
|
||||||
<div className="bg-white px-4 py-3 flex flex-col sm:flex-row items-center justify-between border-t border-gray-200 space-y-3 sm:space-y-0">
|
<DataTablePagination
|
||||||
{/* Mobile pagination controls */}
|
currentPage={pagination.page}
|
||||||
<div className="flex justify-between w-full sm:hidden">
|
totalPages={pagination.total_pages}
|
||||||
<div className="flex space-x-2">
|
pageSize={pagination.per_page}
|
||||||
<button
|
total={pagination.total}
|
||||||
onClick={() => setCurrentPage(1)}
|
hasNext={pagination.has_next}
|
||||||
disabled={pagination.page === 1}
|
hasPrev={pagination.has_prev}
|
||||||
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
onPageChange={setCurrentPage}
|
||||||
>
|
onPageSizeChange={setPerPage}
|
||||||
First
|
/>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setCurrentPage((prev) => Math.max(1, prev - 1))
|
|
||||||
}
|
|
||||||
disabled={!pagination.has_prev}
|
|
||||||
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage((prev) => prev + 1)}
|
|
||||||
disabled={!pagination.has_next}
|
|
||||||
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(pagination.total_pages)}
|
|
||||||
disabled={pagination.page === pagination.total_pages}
|
|
||||||
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Last
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile pagination info */}
|
|
||||||
<div className="text-center w-full sm:hidden">
|
|
||||||
<p className="text-sm text-gray-700">
|
|
||||||
Page <span className="font-medium">{pagination.page}</span> of{" "}
|
|
||||||
<span className="font-medium">{pagination.total_pages}</span>
|
|
||||||
<br />
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
Showing {(pagination.page - 1) * pagination.per_page + 1}-
|
|
||||||
{Math.min(pagination.page * pagination.per_page, pagination.total)} of {pagination.total}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop pagination */}
|
|
||||||
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<p className="text-sm text-gray-700">
|
|
||||||
Showing{" "}
|
|
||||||
<span className="font-medium">
|
|
||||||
{(pagination.page - 1) * pagination.per_page + 1}
|
|
||||||
</span>{" "}
|
|
||||||
to{" "}
|
|
||||||
<span className="font-medium">
|
|
||||||
{Math.min(
|
|
||||||
pagination.page * pagination.per_page,
|
|
||||||
pagination.total,
|
|
||||||
)}
|
)}
|
||||||
</span>{" "}
|
</Card>
|
||||||
of <span className="font-medium">{pagination.total}</span>{" "}
|
|
||||||
results
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<label className="text-sm text-gray-700">
|
|
||||||
Rows per page:
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={perPage}
|
|
||||||
onChange={(e) => {
|
|
||||||
setPerPage(Number(e.target.value));
|
|
||||||
setCurrentPage(1); // Reset to first page when changing page size
|
|
||||||
}}
|
|
||||||
className="border border-gray-300 rounded px-2 py-1 text-sm"
|
|
||||||
>
|
|
||||||
{[10, 25, 50, 100].map((pageSize) => (
|
|
||||||
<option key={pageSize} value={pageSize}>
|
|
||||||
{pageSize}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(1)}
|
|
||||||
disabled={pagination.page === 1}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
First
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setCurrentPage((prev) => Math.max(1, prev - 1))
|
|
||||||
}
|
|
||||||
disabled={!pagination.has_prev}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<span className="text-sm text-gray-700">
|
|
||||||
Page <span className="font-medium">{pagination.page}</span>{" "}
|
|
||||||
of{" "}
|
|
||||||
<span className="font-medium">
|
|
||||||
{pagination.total_pages}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage((prev) => prev + 1)}
|
|
||||||
disabled={!pagination.has_next}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(pagination.total_pages)}
|
|
||||||
disabled={pagination.page === pagination.total_pages}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Last
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Raw Transaction Modal */}
|
{/* Raw Transaction Modal */}
|
||||||
<RawTransactionModal
|
<RawTransactionModal
|
||||||
|
|||||||
@@ -27,22 +27,39 @@ interface AggregatedDataPoint {
|
|||||||
[key: string]: string | number;
|
[key: string]: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BalanceChart({ data, accounts, className }: BalanceChartProps) {
|
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
|
// Create a lookup map for account info
|
||||||
const accountMap = accounts.reduce((map, account) => {
|
const accountMap = accounts.reduce(
|
||||||
|
(map, account) => {
|
||||||
map[account.id] = account;
|
map[account.id] = account;
|
||||||
return map;
|
return map;
|
||||||
}, {} as Record<string, Account>);
|
},
|
||||||
|
{} as Record<string, Account>,
|
||||||
|
);
|
||||||
|
|
||||||
// Helper function to get bank name from institution_id
|
// Helper function to get bank name from institution_id
|
||||||
const getBankName = (institutionId: string): string => {
|
const getBankName = (institutionId: string): string => {
|
||||||
const bankMapping: Record<string, string> = {
|
const bankMapping: Record<string, string> = {
|
||||||
'REVOLUT_REVOLT21': 'Revolut',
|
REVOLUT_REVOLT21: "Revolut",
|
||||||
'NUBANK_NUPBBR25': 'Nu Pagamentos',
|
NUBANK_NUPBBR25: "Nu Pagamentos",
|
||||||
'BANCOBPI_BBPIPTPL': 'Banco BPI',
|
BANCOBPI_BBPIPTPL: "Banco BPI",
|
||||||
// Add more mappings as needed
|
// Add more mappings as needed
|
||||||
};
|
};
|
||||||
return bankMapping[institutionId] || institutionId.split('_')[0];
|
return bankMapping[institutionId] || institutionId.split("_")[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to create display name for account
|
// Helper function to create display name for account
|
||||||
@@ -50,20 +67,24 @@ export default function BalanceChart({ data, accounts, className }: BalanceChart
|
|||||||
const account = accountMap[accountId];
|
const account = accountMap[accountId];
|
||||||
if (account) {
|
if (account) {
|
||||||
const bankName = getBankName(account.institution_id);
|
const bankName = getBankName(account.institution_id);
|
||||||
const accountName = account.name || `Account ${accountId.split('-')[1]}`;
|
const accountName = account.name || `Account ${accountId.split("-")[1]}`;
|
||||||
return `${bankName} - ${accountName}`;
|
return `${bankName} - ${accountName}`;
|
||||||
}
|
}
|
||||||
return `Account ${accountId.split('-')[1]}`;
|
return `Account ${accountId.split("-")[1]}`;
|
||||||
};
|
};
|
||||||
// Process balance data for the chart
|
// Process balance data for the chart
|
||||||
const chartData = data
|
const chartData = data
|
||||||
.filter((balance) => balance.balance_type === "closingBooked")
|
.filter((balance) => balance.balance_type === "closingBooked")
|
||||||
.map((balance) => ({
|
.map((balance) => ({
|
||||||
date: new Date(balance.reference_date).toLocaleDateString('en-GB'), // DD/MM/YYYY format
|
date: new Date(balance.reference_date).toLocaleDateString("en-GB"), // DD/MM/YYYY format
|
||||||
balance: balance.balance_amount,
|
balance: balance.balance_amount,
|
||||||
account_id: balance.account_id,
|
account_id: balance.account_id,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => new Date(a.date.split('/').reverse().join('/')).getTime() - new Date(b.date.split('/').reverse().join('/')).getTime());
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(a.date.split("/").reverse().join("/")).getTime() -
|
||||||
|
new Date(b.date.split("/").reverse().join("/")).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
// Group by account and aggregate
|
// Group by account and aggregate
|
||||||
const accountBalances: { [key: string]: ChartDataPoint[] } = {};
|
const accountBalances: { [key: string]: ChartDataPoint[] } = {};
|
||||||
@@ -86,18 +107,37 @@ export default function BalanceChart({ data, accounts, className }: BalanceChart
|
|||||||
});
|
});
|
||||||
|
|
||||||
const finalData = Object.values(aggregatedData).sort(
|
const finalData = Object.values(aggregatedData).sort(
|
||||||
(a, b) => new Date(a.date.split('/').reverse().join('/')).getTime() - new Date(b.date.split('/').reverse().join('/')).getTime()
|
(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 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) {
|
if (finalData.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
<h3 className="text-lg font-medium text-foreground mb-4">
|
||||||
Balance Progress
|
Balance Progress
|
||||||
</h3>
|
</h3>
|
||||||
<div className="h-80 flex items-center justify-center text-gray-500">
|
<div className="h-80 flex items-center justify-center text-muted-foreground">
|
||||||
No balance data available
|
No balance data available
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,7 +146,7 @@ export default function BalanceChart({ data, accounts, className }: BalanceChart
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
<h3 className="text-lg font-medium text-foreground mb-4">
|
||||||
Balance Progress Over Time
|
Balance Progress Over Time
|
||||||
</h3>
|
</h3>
|
||||||
<div className="h-80">
|
<div className="h-80">
|
||||||
@@ -118,9 +158,9 @@ export default function BalanceChart({ data, accounts, className }: BalanceChart
|
|||||||
tick={{ fontSize: 12 }}
|
tick={{ fontSize: 12 }}
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value) => {
|
||||||
// Convert DD/MM/YYYY back to a proper date for formatting
|
// Convert DD/MM/YYYY back to a proper date for formatting
|
||||||
const [day, month, year] = value.split('/');
|
const [day, month, year] = value.split("/");
|
||||||
const date = new Date(year, month - 1, day);
|
const date = new Date(year, month - 1, day);
|
||||||
return date.toLocaleDateString('en-GB', {
|
return date.toLocaleDateString("en-GB", {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
});
|
});
|
||||||
@@ -130,13 +170,7 @@ export default function BalanceChart({ data, accounts, className }: BalanceChart
|
|||||||
tick={{ fontSize: 12 }}
|
tick={{ fontSize: 12 }}
|
||||||
tickFormatter={(value) => `€${value.toLocaleString()}`}
|
tickFormatter={(value) => `€${value.toLocaleString()}`}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip content={<CustomTooltip />} />
|
||||||
formatter={(value: number, name: string) => [
|
|
||||||
`€${value.toLocaleString()}`,
|
|
||||||
getAccountDisplayName(name),
|
|
||||||
]}
|
|
||||||
labelFormatter={(label) => `Date: ${label}`}
|
|
||||||
/>
|
|
||||||
<Legend />
|
<Legend />
|
||||||
{Object.keys(accountBalances).map((accountId, index) => (
|
{Object.keys(accountBalances).map((accountId, index) => (
|
||||||
<Area
|
<Area
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ interface MonthlyTrendsProps {
|
|||||||
days?: number;
|
days?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
payload?: Array<{
|
payload?: Array<{
|
||||||
@@ -26,7 +25,10 @@ interface TooltipProps {
|
|||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MonthlyTrends({ className, days = 365 }: MonthlyTrendsProps) {
|
export default function MonthlyTrends({
|
||||||
|
className,
|
||||||
|
days = 365,
|
||||||
|
}: MonthlyTrendsProps) {
|
||||||
// Get pre-calculated monthly stats from the new endpoint
|
// Get pre-calculated monthly stats from the new endpoint
|
||||||
const { data: monthlyData, isLoading } = useQuery({
|
const { data: monthlyData, isLoading } = useQuery({
|
||||||
queryKey: ["monthly-stats", days],
|
queryKey: ["monthly-stats", days],
|
||||||
@@ -49,11 +51,11 @@ export default function MonthlyTrends({ className, days = 365 }: MonthlyTrendsPr
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
<h3 className="text-lg font-medium text-foreground mb-4">
|
||||||
Monthly Spending Trends
|
Monthly Spending Trends
|
||||||
</h3>
|
</h3>
|
||||||
<div className="h-80 flex items-center justify-center">
|
<div className="h-80 flex items-center justify-center">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -62,10 +64,10 @@ export default function MonthlyTrends({ className, days = 365 }: MonthlyTrendsPr
|
|||||||
if (displayData.length === 0) {
|
if (displayData.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
<h3 className="text-lg font-medium text-foreground mb-4">
|
||||||
Monthly Spending Trends
|
Monthly Spending Trends
|
||||||
</h3>
|
</h3>
|
||||||
<div className="h-80 flex items-center justify-center text-gray-500">
|
<div className="h-80 flex items-center justify-center text-muted-foreground">
|
||||||
No transaction data available
|
No transaction data available
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,8 +77,8 @@ export default function MonthlyTrends({ className, days = 365 }: MonthlyTrendsPr
|
|||||||
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
|
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
|
||||||
if (active && payload && payload.length) {
|
if (active && payload && payload.length) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-3 border rounded shadow-lg">
|
<div className="bg-card p-3 border rounded shadow-lg">
|
||||||
<p className="font-medium">{label}</p>
|
<p className="font-medium text-foreground">{label}</p>
|
||||||
{payload.map((entry, index) => (
|
{payload.map((entry, index) => (
|
||||||
<p key={index} style={{ color: entry.color }}>
|
<p key={index} style={{ color: entry.color }}>
|
||||||
{entry.name}: €{Math.abs(entry.value).toLocaleString()}
|
{entry.name}: €{Math.abs(entry.value).toLocaleString()}
|
||||||
@@ -98,12 +100,15 @@ export default function MonthlyTrends({ className, days = 365 }: MonthlyTrendsPr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
<h3 className="text-lg font-medium text-foreground mb-4">
|
||||||
{getTitle(days)}
|
{getTitle(days)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="h-80">
|
<div className="h-80">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={displayData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
<BarChart
|
||||||
|
data={displayData}
|
||||||
|
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||||
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="month"
|
dataKey="month"
|
||||||
@@ -122,7 +127,7 @@ export default function MonthlyTrends({ className, days = 365 }: MonthlyTrendsPr
|
|||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex justify-center space-x-6 text-sm">
|
<div className="mt-4 flex justify-center space-x-6 text-sm text-foreground">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="w-3 h-3 bg-green-500 rounded mr-2" />
|
<div className="w-3 h-3 bg-green-500 rounded mr-2" />
|
||||||
<span>Income</span>
|
<span>Income</span>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import clsx from "clsx";
|
import { Card, CardContent } from "../ui/card";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -11,6 +12,7 @@ interface StatCardProps {
|
|||||||
isPositive: boolean;
|
isPositive: boolean;
|
||||||
};
|
};
|
||||||
className?: string;
|
className?: string;
|
||||||
|
iconColor?: "green" | "blue" | "red" | "purple" | "orange" | "default";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StatCard({
|
export default function StatCard({
|
||||||
@@ -20,45 +22,61 @@ export default function StatCard({
|
|||||||
icon: Icon,
|
icon: Icon,
|
||||||
trend,
|
trend,
|
||||||
className,
|
className,
|
||||||
|
iconColor = "default",
|
||||||
}: StatCardProps) {
|
}: StatCardProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<Card className={cn(className)}>
|
||||||
className={clsx(
|
<CardContent className="p-6">
|
||||||
"bg-white rounded-lg shadow p-6 border border-gray-200",
|
<div className="flex items-center justify-between">
|
||||||
className
|
<div>
|
||||||
)}
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Icon className="h-8 w-8 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-5 w-0 flex-1">
|
|
||||||
<dl>
|
|
||||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
|
||||||
{title}
|
{title}
|
||||||
</dt>
|
</p>
|
||||||
<dd className="flex items-baseline">
|
<div className="flex items-baseline">
|
||||||
<div className="text-2xl font-semibold text-gray-900">
|
<p className="text-2xl font-bold text-foreground">
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</p>
|
||||||
{trend && (
|
{trend && (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={cn(
|
||||||
"ml-2 flex items-baseline text-sm font-semibold",
|
"ml-2 flex items-baseline text-sm font-semibold",
|
||||||
trend.isPositive ? "text-green-600" : "text-red-600"
|
trend.isPositive
|
||||||
|
? "text-green-600 dark:text-green-400"
|
||||||
|
: "text-red-600 dark:text-red-400",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{trend.isPositive ? "+" : ""}
|
{trend.isPositive ? "+" : ""}
|
||||||
{trend.value}%
|
{trend.value}%
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dd>
|
</div>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<dd className="text-sm text-gray-600 mt-1">{subtitle}</dd>
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</div>
|
||||||
</div>
|
<div className={cn(
|
||||||
|
"p-3 rounded-full",
|
||||||
|
iconColor === "green" && "bg-green-100 dark:bg-green-900/20",
|
||||||
|
iconColor === "blue" && "bg-blue-100 dark:bg-blue-900/20",
|
||||||
|
iconColor === "red" && "bg-red-100 dark:bg-red-900/20",
|
||||||
|
iconColor === "purple" && "bg-purple-100 dark:bg-purple-900/20",
|
||||||
|
iconColor === "orange" && "bg-orange-100 dark:bg-orange-900/20",
|
||||||
|
iconColor === "default" && "bg-muted"
|
||||||
|
)}>
|
||||||
|
<Icon className={cn(
|
||||||
|
"h-6 w-6",
|
||||||
|
iconColor === "green" && "text-green-600",
|
||||||
|
iconColor === "blue" && "text-blue-600",
|
||||||
|
iconColor === "red" && "text-red-600",
|
||||||
|
iconColor === "purple" && "text-purple-600",
|
||||||
|
iconColor === "orange" && "text-orange-600",
|
||||||
|
iconColor === "default" && "text-muted-foreground"
|
||||||
|
)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Calendar } from "lucide-react";
|
import { Calendar } from "lucide-react";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
import type { TimePeriod } from "../../lib/timePeriods";
|
import type { TimePeriod } from "../../lib/timePeriods";
|
||||||
import { TIME_PERIODS } from "../../lib/timePeriods";
|
import { TIME_PERIODS } from "../../lib/timePeriods";
|
||||||
|
|
||||||
@@ -15,23 +16,22 @@ export default function TimePeriodFilter({
|
|||||||
}: TimePeriodFilterProps) {
|
}: TimePeriodFilterProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center gap-4 ${className}`}>
|
<div className={`flex items-center gap-4 ${className}`}>
|
||||||
<div className="flex items-center gap-2 text-gray-700">
|
<div className="flex items-center gap-2 text-foreground">
|
||||||
<Calendar size={20} />
|
<Calendar size={20} />
|
||||||
<span className="font-medium">Time Period:</span>
|
<span className="font-medium">Time Period:</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{TIME_PERIODS.map((period) => (
|
{TIME_PERIODS.map((period) => (
|
||||||
<button
|
<Button
|
||||||
key={period.value}
|
key={period.value}
|
||||||
onClick={() => onPeriodChange(period)}
|
onClick={() => onPeriodChange(period)}
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
variant={
|
||||||
selectedPeriod.value === period.value
|
selectedPeriod.value === period.value ? "default" : "outline"
|
||||||
? "bg-blue-600 text-white"
|
}
|
||||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
size="sm"
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{period.label}
|
{period.label}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface PieDataPoint {
|
|||||||
name: string;
|
name: string;
|
||||||
value: number;
|
value: number;
|
||||||
color: string;
|
color: string;
|
||||||
|
[key: string]: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
@@ -33,18 +34,18 @@ export default function TransactionDistribution({
|
|||||||
// Helper function to get bank name from institution_id
|
// Helper function to get bank name from institution_id
|
||||||
const getBankName = (institutionId: string): string => {
|
const getBankName = (institutionId: string): string => {
|
||||||
const bankMapping: Record<string, string> = {
|
const bankMapping: Record<string, string> = {
|
||||||
'REVOLUT_REVOLT21': 'Revolut',
|
REVOLUT_REVOLT21: "Revolut",
|
||||||
'NUBANK_NUPBBR25': 'Nu Pagamentos',
|
NUBANK_NUPBBR25: "Nu Pagamentos",
|
||||||
'BANCOBPI_BBPIPTPL': 'Banco BPI',
|
BANCOBPI_BBPIPTPL: "Banco BPI",
|
||||||
// TODO: Add more bank mappings as needed
|
// TODO: Add more bank mappings as needed
|
||||||
};
|
};
|
||||||
return bankMapping[institutionId] || institutionId.split('_')[0];
|
return bankMapping[institutionId] || institutionId.split("_")[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to create display name for account
|
// Helper function to create display name for account
|
||||||
const getAccountDisplayName = (account: Account): string => {
|
const getAccountDisplayName = (account: Account): string => {
|
||||||
const bankName = getBankName(account.institution_id);
|
const bankName = getBankName(account.institution_id);
|
||||||
const accountName = account.name || `Account ${account.id.split('-')[1]}`;
|
const accountName = account.name || `Account ${account.id.split("-")[1]}`;
|
||||||
return `${bankName} - ${accountName}`;
|
return `${bankName} - ${accountName}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,10 +67,10 @@ export default function TransactionDistribution({
|
|||||||
if (pieData.length === 0 || totalBalance === 0) {
|
if (pieData.length === 0 || totalBalance === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
<h3 className="text-lg font-medium text-foreground mb-4">
|
||||||
Account Distribution
|
Account Distribution
|
||||||
</h3>
|
</h3>
|
||||||
<div className="h-80 flex items-center justify-center text-gray-500">
|
<div className="h-80 flex items-center justify-center text-muted-foreground">
|
||||||
No account data available
|
No account data available
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,12 +82,12 @@ export default function TransactionDistribution({
|
|||||||
const data = payload[0].payload;
|
const data = payload[0].payload;
|
||||||
const percentage = ((data.value / totalBalance) * 100).toFixed(1);
|
const percentage = ((data.value / totalBalance) * 100).toFixed(1);
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-3 border rounded shadow-lg">
|
<div className="bg-card p-3 border rounded shadow-lg">
|
||||||
<p className="font-medium">{data.name}</p>
|
<p className="font-medium text-foreground">{data.name}</p>
|
||||||
<p className="text-blue-600">
|
<p className="text-primary">
|
||||||
Balance: €{data.value.toLocaleString()}
|
Balance: €{data.value.toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-600">{percentage}% of total</p>
|
<p className="text-muted-foreground">{percentage}% of total</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -95,7 +96,7 @@ export default function TransactionDistribution({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
<h3 className="text-lg font-medium text-foreground mb-4">
|
||||||
Account Balance Distribution
|
Account Balance Distribution
|
||||||
</h3>
|
</h3>
|
||||||
<div className="h-80">
|
<div className="h-80">
|
||||||
@@ -125,15 +126,20 @@ export default function TransactionDistribution({
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-4 grid grid-cols-1 gap-2">
|
<div className="mt-4 grid grid-cols-1 gap-2">
|
||||||
{pieData.map((item, index) => (
|
{pieData.map((item, index) => (
|
||||||
<div key={index} className="flex items-center justify-between text-sm">
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded-full mr-2"
|
className="w-3 h-3 rounded-full mr-2"
|
||||||
style={{ backgroundColor: item.color }}
|
style={{ backgroundColor: item.color }}
|
||||||
/>
|
/>
|
||||||
<span className="text-gray-700">{item.name}</span>
|
<span className="text-foreground">{item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium">€{item.value.toLocaleString()}</span>
|
<span className="font-medium text-foreground">
|
||||||
|
€{item.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList,
|
CommandList,
|
||||||
} from "@/components/ui/command";
|
} from "@/components/ui/command";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
import type { Account } from "../../types/api";
|
import type { Account } from "../../types/api";
|
||||||
|
|
||||||
export interface AccountComboboxProps {
|
export interface AccountComboboxProps {
|
||||||
@@ -29,10 +33,13 @@ export function AccountCombobox({
|
|||||||
}: AccountComboboxProps) {
|
}: AccountComboboxProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const selectedAccountData = accounts.find((account) => account.id === selectedAccount);
|
const selectedAccountData = accounts.find(
|
||||||
|
(account) => account.id === selectedAccount,
|
||||||
|
);
|
||||||
|
|
||||||
const formatAccountName = (account: Account) => {
|
const formatAccountName = (account: Account) => {
|
||||||
const displayName = account.name || "Unnamed Account";
|
const displayName =
|
||||||
|
account.display_name || account.name || "Unnamed Account";
|
||||||
return `${displayName} (${account.institution_id})`;
|
return `${displayName} (${account.institution_id})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,7 +51,7 @@ export function AccountCombobox({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className="justify-between"
|
className="w-full justify-between"
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Building2 className="mr-2 h-4 w-4" />
|
<Building2 className="mr-2 h-4 w-4" />
|
||||||
@@ -72,7 +79,7 @@ export function AccountCombobox({
|
|||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 h-4 w-4",
|
"mr-2 h-4 w-4",
|
||||||
selectedAccount === "" ? "opacity-100" : "opacity-0"
|
selectedAccount === "" ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Building2 className="mr-2 h-4 w-4 text-gray-400" />
|
<Building2 className="mr-2 h-4 w-4 text-gray-400" />
|
||||||
@@ -83,7 +90,7 @@ export function AccountCombobox({
|
|||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={account.id}
|
key={account.id}
|
||||||
value={`${account.name} ${account.institution_id}`}
|
value={`${account.display_name || account.name} ${account.institution_id}`}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
onAccountChange(account.id);
|
onAccountChange(account.id);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -94,12 +101,14 @@ export function AccountCombobox({
|
|||||||
"mr-2 h-4 w-4",
|
"mr-2 h-4 w-4",
|
||||||
selectedAccount === account.id
|
selectedAccount === account.id
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-0"
|
: "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{account.name || "Unnamed Account"}
|
{account.display_name ||
|
||||||
|
account.name ||
|
||||||
|
"Unnamed Account"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">
|
||||||
{account.institution_id}
|
{account.institution_id}
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import type { Account } from "../../types/api";
|
|||||||
export interface ActiveFilterChipsProps {
|
export interface ActiveFilterChipsProps {
|
||||||
filterState: FilterState;
|
filterState: FilterState;
|
||||||
onFilterChange: (key: keyof FilterState, value: string) => void;
|
onFilterChange: (key: keyof FilterState, value: string) => void;
|
||||||
|
onClearFilters: () => void;
|
||||||
accounts?: Account[];
|
accounts?: Account[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActiveFilterChips({
|
export function ActiveFilterChips({
|
||||||
filterState,
|
filterState,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
|
onClearFilters,
|
||||||
accounts = [],
|
accounts = [],
|
||||||
}: ActiveFilterChipsProps) {
|
}: ActiveFilterChipsProps) {
|
||||||
const chips: Array<{
|
const chips: Array<{
|
||||||
@@ -33,7 +35,9 @@ export function ActiveFilterChips({
|
|||||||
|
|
||||||
// Account chip
|
// Account chip
|
||||||
if (filterState.selectedAccount) {
|
if (filterState.selectedAccount) {
|
||||||
const account = accounts.find((acc) => acc.id === filterState.selectedAccount);
|
const account = accounts.find(
|
||||||
|
(acc) => acc.id === filterState.selectedAccount,
|
||||||
|
);
|
||||||
const accountName = account
|
const accountName = account
|
||||||
? `${account.name || "Unnamed Account"} (${account.institution_id})`
|
? `${account.name || "Unnamed Account"} (${account.institution_id})`
|
||||||
: "Unknown Account";
|
: "Unknown Account";
|
||||||
@@ -66,26 +70,6 @@ export function ActiveFilterChips({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) => {
|
const handleRemoveChip = (key: keyof FilterState) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
@@ -94,11 +78,6 @@ export function ActiveFilterChips({
|
|||||||
onFilterChange("startDate", "");
|
onFilterChange("startDate", "");
|
||||||
onFilterChange("endDate", "");
|
onFilterChange("endDate", "");
|
||||||
break;
|
break;
|
||||||
case "minAmount":
|
|
||||||
// Clear both min and max amount
|
|
||||||
onFilterChange("minAmount", "");
|
|
||||||
onFilterChange("maxAmount", "");
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
onFilterChange(key, "");
|
onFilterChange(key, "");
|
||||||
}
|
}
|
||||||
@@ -129,6 +108,15 @@ export function ActiveFilterChips({
|
|||||||
</Button>
|
</Button>
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
|
<Button
|
||||||
|
onClick={onClearFilters}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground ml-2"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 mr-1" />
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { MoreHorizontal, Euro } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
||||||
|
|
||||||
export interface AdvancedFiltersPopoverProps {
|
|
||||||
minAmount: string;
|
|
||||||
maxAmount: string;
|
|
||||||
onMinAmountChange: (value: string) => void;
|
|
||||||
onMaxAmountChange: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdvancedFiltersPopover({
|
|
||||||
minAmount,
|
|
||||||
maxAmount,
|
|
||||||
onMinAmountChange,
|
|
||||||
onMaxAmountChange,
|
|
||||||
}: AdvancedFiltersPopoverProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const hasAdvancedFilters = minAmount || maxAmount;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={hasAdvancedFilters ? "default" : "outline"}
|
|
||||||
size="default"
|
|
||||||
className="relative"
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="h-4 w-4 mr-2" />
|
|
||||||
More
|
|
||||||
{hasAdvancedFilters && (
|
|
||||||
<div className="absolute -top-1 -right-1 h-2 w-2 bg-blue-600 rounded-full" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-80" align="end">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="font-medium leading-none">Advanced Filters</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Additional filters for more precise results
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
|
||||||
Amount Range
|
|
||||||
</label>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs text-muted-foreground">
|
|
||||||
Minimum
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Euro className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder="0.00"
|
|
||||||
value={minAmount}
|
|
||||||
onChange={(e) => onMinAmountChange(e.target.value)}
|
|
||||||
className="pl-8"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs text-muted-foreground">
|
|
||||||
Maximum
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Euro className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder="1000.00"
|
|
||||||
value={maxAmount}
|
|
||||||
onChange={(e) => onMaxAmountChange(e.target.value)}
|
|
||||||
className="pl-8"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Leave empty for no limit
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Future: Add transaction status filter */}
|
|
||||||
<div className="pt-2 border-t">
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
More filters coming soon: transaction status, categories, and more.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Clear advanced filters */}
|
|
||||||
{hasAdvancedFilters && (
|
|
||||||
<div className="pt-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
onMinAmountChange("");
|
|
||||||
onMaxAmountChange("");
|
|
||||||
}}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
Clear Advanced Filters
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,12 @@ import type { DateRange } from "react-day-picker";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
|
||||||
export interface DateRangePickerProps {
|
export interface DateRangePickerProps {
|
||||||
startDate: string;
|
startDate: string;
|
||||||
@@ -22,31 +27,35 @@ interface DatePreset {
|
|||||||
|
|
||||||
const datePresets: DatePreset[] = [
|
const datePresets: DatePreset[] = [
|
||||||
{
|
{
|
||||||
label: "Last 7 days",
|
label: "Today",
|
||||||
getValue: () => {
|
getValue: () => {
|
||||||
const endDate = new Date();
|
const today = new Date();
|
||||||
const startDate = new Date();
|
|
||||||
startDate.setDate(endDate.getDate() - 7);
|
|
||||||
return {
|
return {
|
||||||
startDate: startDate.toISOString().split("T")[0],
|
startDate: today.toISOString().split("T")[0],
|
||||||
endDate: endDate.toISOString().split("T")[0],
|
endDate: today.toISOString().split("T")[0],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "This week",
|
label: "Yesterday",
|
||||||
getValue: () => {
|
getValue: () => {
|
||||||
const now = new Date();
|
const yesterday = new Date();
|
||||||
const dayOfWeek = now.getDay();
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
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 {
|
return {
|
||||||
startDate: startOfWeek.toISOString().split("T")[0],
|
startDate: yesterday.toISOString().split("T")[0],
|
||||||
endDate: endOfWeek.toISOString().split("T")[0],
|
endDate: yesterday.toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Last 7 days",
|
||||||
|
getValue: () => {
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setDate(endDate.getDate() - 6);
|
||||||
|
return {
|
||||||
|
startDate: startDate.toISOString().split("T")[0],
|
||||||
|
endDate: endDate.toISOString().split("T")[0],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -55,7 +64,7 @@ const datePresets: DatePreset[] = [
|
|||||||
getValue: () => {
|
getValue: () => {
|
||||||
const endDate = new Date();
|
const endDate = new Date();
|
||||||
const startDate = new Date();
|
const startDate = new Date();
|
||||||
startDate.setDate(endDate.getDate() - 30);
|
startDate.setDate(endDate.getDate() - 29);
|
||||||
return {
|
return {
|
||||||
startDate: startDate.toISOString().split("T")[0],
|
startDate: startDate.toISOString().split("T")[0],
|
||||||
endDate: endDate.toISOString().split("T")[0],
|
endDate: endDate.toISOString().split("T")[0],
|
||||||
@@ -75,19 +84,6 @@ const datePresets: DatePreset[] = [
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
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({
|
export function DateRangePicker({
|
||||||
@@ -111,12 +107,12 @@ export function DateRangePicker({
|
|||||||
if (range?.from && range?.to) {
|
if (range?.from && range?.to) {
|
||||||
onDateRangeChange(
|
onDateRangeChange(
|
||||||
range.from.toISOString().split("T")[0],
|
range.from.toISOString().split("T")[0],
|
||||||
range.to.toISOString().split("T")[0]
|
range.to.toISOString().split("T")[0],
|
||||||
);
|
);
|
||||||
} else if (range?.from && !range?.to) {
|
} else if (range?.from && !range?.to) {
|
||||||
onDateRangeChange(
|
onDateRangeChange(
|
||||||
range.from.toISOString().split("T")[0],
|
range.from.toISOString().split("T")[0],
|
||||||
range.from.toISOString().split("T")[0]
|
range.from.toISOString().split("T")[0],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -161,7 +157,7 @@ export function DateRangePicker({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"justify-between text-left font-normal",
|
"justify-between text-left font-normal",
|
||||||
!dateRange && "text-muted-foreground"
|
!dateRange && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -172,34 +168,30 @@ export function DateRangePicker({
|
|||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
<div className="flex">
|
<Card className="w-auto py-4">
|
||||||
{/* Presets */}
|
<CardContent className="px-4">
|
||||||
<div className="border-r p-3 space-y-1">
|
<Calendar
|
||||||
<div className="text-sm font-medium text-gray-700 mb-2">
|
mode="range"
|
||||||
Quick select
|
defaultMonth={dateRange?.from}
|
||||||
</div>
|
selected={dateRange}
|
||||||
|
onSelect={handleDateRangeSelect}
|
||||||
|
className="bg-transparent p-0"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="grid grid-cols-2 gap-1 border-t px-4 !pt-4">
|
||||||
{datePresets.map((preset) => (
|
{datePresets.map((preset) => (
|
||||||
<Button
|
<Button
|
||||||
key={preset.label}
|
key={preset.label}
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full justify-start text-sm"
|
className="text-xs px-2 h-7"
|
||||||
onClick={() => handlePresetClick(preset)}
|
onClick={() => handlePresetClick(preset)}
|
||||||
>
|
>
|
||||||
{preset.label}
|
{preset.label}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</CardFooter>
|
||||||
{/* Calendar */}
|
</Card>
|
||||||
<Calendar
|
|
||||||
initialFocus
|
|
||||||
mode="range"
|
|
||||||
defaultMonth={dateRange?.from}
|
|
||||||
selected={dateRange}
|
|
||||||
onSelect={handleDateRangeSelect}
|
|
||||||
numberOfMonths={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { Search, X } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { DateRangePicker } from "./DateRangePicker";
|
import { DateRangePicker } from "./DateRangePicker";
|
||||||
import { AccountCombobox } from "./AccountCombobox";
|
import { AccountCombobox } from "./AccountCombobox";
|
||||||
import { ActiveFilterChips } from "./ActiveFilterChips";
|
import { ActiveFilterChips } from "./ActiveFilterChips";
|
||||||
import { AdvancedFiltersPopover } from "./AdvancedFiltersPopover";
|
|
||||||
import type { Account } from "../../types/api";
|
import type { Account } from "../../types/api";
|
||||||
|
|
||||||
export interface FilterState {
|
export interface FilterState {
|
||||||
@@ -13,8 +11,6 @@ export interface FilterState {
|
|||||||
selectedAccount: string;
|
selectedAccount: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
minAmount: string;
|
|
||||||
maxAmount: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FilterBarProps {
|
export interface FilterBarProps {
|
||||||
@@ -23,8 +19,6 @@ export interface FilterBarProps {
|
|||||||
onClearFilters: () => void;
|
onClearFilters: () => void;
|
||||||
accounts?: Account[];
|
accounts?: Account[];
|
||||||
isSearchLoading?: boolean;
|
isSearchLoading?: boolean;
|
||||||
showRunningBalance: boolean;
|
|
||||||
onToggleRunningBalance: () => void;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,18 +28,13 @@ export function FilterBar({
|
|||||||
onClearFilters,
|
onClearFilters,
|
||||||
accounts,
|
accounts,
|
||||||
isSearchLoading = false,
|
isSearchLoading = false,
|
||||||
showRunningBalance,
|
|
||||||
onToggleRunningBalance,
|
|
||||||
className,
|
className,
|
||||||
}: FilterBarProps) {
|
}: FilterBarProps) {
|
||||||
|
|
||||||
const hasActiveFilters =
|
const hasActiveFilters =
|
||||||
filterState.searchTerm ||
|
filterState.searchTerm ||
|
||||||
filterState.selectedAccount ||
|
filterState.selectedAccount ||
|
||||||
filterState.startDate ||
|
filterState.startDate ||
|
||||||
filterState.endDate ||
|
filterState.endDate;
|
||||||
filterState.minAmount ||
|
|
||||||
filterState.maxAmount;
|
|
||||||
|
|
||||||
const handleDateRangeChange = (startDate: string, endDate: string) => {
|
const handleDateRangeChange = (startDate: string, endDate: string) => {
|
||||||
onFilterChange("startDate", startDate);
|
onFilterChange("startDate", startDate);
|
||||||
@@ -53,34 +42,33 @@ export function FilterBar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("bg-white rounded-lg shadow border", className)}>
|
<div className={cn("bg-card rounded-lg shadow border", className)}>
|
||||||
{/* Main Filter Bar */}
|
{/* Main Filter Bar */}
|
||||||
<div className="px-6 py-4">
|
<div className="px-6 py-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Transactions</h3>
|
<h3 className="text-lg font-semibold text-card-foreground">
|
||||||
<Button
|
Transactions
|
||||||
onClick={onToggleRunningBalance}
|
</h3>
|
||||||
variant={showRunningBalance ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Balance
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Primary Filters Row */}
|
{/* Primary Filters Row */}
|
||||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
<div className="space-y-4 mb-4">
|
||||||
|
{/* Desktop Layout */}
|
||||||
|
<div className="hidden lg:flex items-center justify-between gap-6">
|
||||||
|
{/* Left Side: Main Filters */}
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
{/* Search Input */}
|
{/* Search Input */}
|
||||||
<div className="relative flex-1 min-w-[240px]">
|
<div className="relative w-[200px]">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search transactions..."
|
placeholder="Search transactions..."
|
||||||
value={filterState.searchTerm}
|
value={filterState.searchTerm}
|
||||||
onChange={(e) => onFilterChange("searchTerm", e.target.value)}
|
onChange={(e) => onFilterChange("searchTerm", e.target.value)}
|
||||||
className="pl-9 pr-8"
|
className="pl-9 pr-8 bg-background"
|
||||||
/>
|
/>
|
||||||
{isSearchLoading && (
|
{isSearchLoading && (
|
||||||
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||||
<div className="animate-spin h-4 w-4 border-2 border-gray-300 border-t-blue-500 rounded-full"></div>
|
<div className="animate-spin h-4 w-4 border-2 border-border border-t-primary rounded-full"></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -92,7 +80,7 @@ export function FilterBar({
|
|||||||
onAccountChange={(accountId) =>
|
onAccountChange={(accountId) =>
|
||||||
onFilterChange("selectedAccount", accountId)
|
onFilterChange("selectedAccount", accountId)
|
||||||
}
|
}
|
||||||
className="w-[200px]"
|
className="w-[180px]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Date Range Picker */}
|
{/* Date Range Picker */}
|
||||||
@@ -100,36 +88,57 @@ export function FilterBar({
|
|||||||
startDate={filterState.startDate}
|
startDate={filterState.startDate}
|
||||||
endDate={filterState.endDate}
|
endDate={filterState.endDate}
|
||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
className="w-[240px]"
|
className="w-[220px]"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Advanced Filters Button */}
|
</div>
|
||||||
<AdvancedFiltersPopover
|
|
||||||
minAmount={filterState.minAmount}
|
{/* Mobile Layout */}
|
||||||
maxAmount={filterState.maxAmount}
|
<div className="lg:hidden space-y-3">
|
||||||
onMinAmountChange={(value) => onFilterChange("minAmount", value)}
|
{/* First Row: Search Input (Full Width) */}
|
||||||
onMaxAmountChange={(value) => onFilterChange("maxAmount", value)}
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search..."
|
||||||
|
value={filterState.searchTerm}
|
||||||
|
onChange={(e) => onFilterChange("searchTerm", e.target.value)}
|
||||||
|
className="pl-9 pr-8 bg-background w-full"
|
||||||
/>
|
/>
|
||||||
|
{isSearchLoading && (
|
||||||
{/* Clear Filters Button */}
|
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||||
{hasActiveFilters && (
|
<div className="animate-spin h-4 w-4 border-2 border-border border-t-primary rounded-full"></div>
|
||||||
<Button
|
</div>
|
||||||
onClick={onClearFilters}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="text-gray-600"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4 mr-1" />
|
|
||||||
Clear All
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Second Row: Account Selection (Full Width) */}
|
||||||
|
<AccountCombobox
|
||||||
|
accounts={accounts}
|
||||||
|
selectedAccount={filterState.selectedAccount}
|
||||||
|
onAccountChange={(accountId) =>
|
||||||
|
onFilterChange("selectedAccount", accountId)
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Third Row: Date Range */}
|
||||||
|
<DateRangePicker
|
||||||
|
startDate={filterState.startDate}
|
||||||
|
endDate={filterState.endDate}
|
||||||
|
onDateRangeChange={handleDateRangeChange}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Active Filter Chips */}
|
{/* Active Filter Chips */}
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<ActiveFilterChips
|
<ActiveFilterChips
|
||||||
filterState={filterState}
|
filterState={filterState}
|
||||||
onFilterChange={onFilterChange}
|
onFilterChange={onFilterChange}
|
||||||
|
onClearFilters={onClearFilters}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export { FilterBar } from './FilterBar';
|
export { FilterBar } from "./FilterBar";
|
||||||
export { DateRangePicker } from './DateRangePicker';
|
export { DateRangePicker } from "./DateRangePicker";
|
||||||
export { AccountCombobox } from './AccountCombobox';
|
export { AccountCombobox } from "./AccountCombobox";
|
||||||
export { ActiveFilterChips } from './ActiveFilterChips';
|
export { ActiveFilterChips } from "./ActiveFilterChips";
|
||||||
export { AdvancedFiltersPopover } from './AdvancedFiltersPopover';
|
export type { FilterState, FilterBarProps } from "./FilterBar";
|
||||||
export type { FilterState, FilterBarProps } from './FilterBar';
|
|
||||||
|
|||||||
59
frontend/src/components/ui/alert.tsx
Normal 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 px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Alert.displayName = "Alert";
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h5
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertTitle.displayName = "AlertTitle";
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDescription.displayName = "AlertDescription";
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
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",
|
"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",
|
||||||
@@ -20,8 +20,8 @@ const badgeVariants = cva(
|
|||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
export interface BadgeProps
|
export interface BadgeProps
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
@@ -30,7 +30,7 @@ export interface BadgeProps
|
|||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
export { Badge, badgeVariants };
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
@@ -31,27 +31,27 @@ const buttonVariants = cva(
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button";
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
Button.displayName = "Button"
|
Button.displayName = "Button";
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants };
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react";
|
||||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
function Calendar({
|
function Calendar({
|
||||||
className,
|
className,
|
||||||
@@ -19,9 +19,9 @@ function Calendar({
|
|||||||
components,
|
components,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DayPicker> & {
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||||
}) {
|
}) {
|
||||||
const defaultClassNames = getDefaultClassNames()
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DayPicker
|
<DayPicker
|
||||||
@@ -30,7 +30,7 @@ function Calendar({
|
|||||||
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
"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\_next>svg]:rotate-180`,
|
||||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
captionLayout={captionLayout}
|
captionLayout={captionLayout}
|
||||||
formatters={{
|
formatters={{
|
||||||
@@ -42,82 +42,82 @@ function Calendar({
|
|||||||
root: cn("w-fit", defaultClassNames.root),
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
months: cn(
|
months: cn(
|
||||||
"relative flex flex-col gap-4 md:flex-row",
|
"relative flex flex-col gap-4 md:flex-row",
|
||||||
defaultClassNames.months
|
defaultClassNames.months,
|
||||||
),
|
),
|
||||||
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||||
nav: cn(
|
nav: cn(
|
||||||
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||||
defaultClassNames.nav
|
defaultClassNames.nav,
|
||||||
),
|
),
|
||||||
button_previous: cn(
|
button_previous: cn(
|
||||||
buttonVariants({ variant: buttonVariant }),
|
buttonVariants({ variant: buttonVariant }),
|
||||||
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||||
defaultClassNames.button_previous
|
defaultClassNames.button_previous,
|
||||||
),
|
),
|
||||||
button_next: cn(
|
button_next: cn(
|
||||||
buttonVariants({ variant: buttonVariant }),
|
buttonVariants({ variant: buttonVariant }),
|
||||||
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||||
defaultClassNames.button_next
|
defaultClassNames.button_next,
|
||||||
),
|
),
|
||||||
month_caption: cn(
|
month_caption: cn(
|
||||||
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
|
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
|
||||||
defaultClassNames.month_caption
|
defaultClassNames.month_caption,
|
||||||
),
|
),
|
||||||
dropdowns: cn(
|
dropdowns: cn(
|
||||||
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
|
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||||
defaultClassNames.dropdowns
|
defaultClassNames.dropdowns,
|
||||||
),
|
),
|
||||||
dropdown_root: cn(
|
dropdown_root: cn(
|
||||||
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
|
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
|
||||||
defaultClassNames.dropdown_root
|
defaultClassNames.dropdown_root,
|
||||||
),
|
),
|
||||||
dropdown: cn(
|
dropdown: cn(
|
||||||
"bg-popover absolute inset-0 opacity-0",
|
"bg-popover absolute inset-0 opacity-0",
|
||||||
defaultClassNames.dropdown
|
defaultClassNames.dropdown,
|
||||||
),
|
),
|
||||||
caption_label: cn(
|
caption_label: cn(
|
||||||
"select-none font-medium",
|
"select-none font-medium",
|
||||||
captionLayout === "label"
|
captionLayout === "label"
|
||||||
? "text-sm"
|
? "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",
|
: "[&>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
|
defaultClassNames.caption_label,
|
||||||
),
|
),
|
||||||
table: "w-full border-collapse",
|
table: "w-full border-collapse",
|
||||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
weekday: cn(
|
weekday: cn(
|
||||||
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||||
defaultClassNames.weekday
|
defaultClassNames.weekday,
|
||||||
),
|
),
|
||||||
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||||
week_number_header: cn(
|
week_number_header: cn(
|
||||||
"w-[--cell-size] select-none",
|
"w-[--cell-size] select-none",
|
||||||
defaultClassNames.week_number_header
|
defaultClassNames.week_number_header,
|
||||||
),
|
),
|
||||||
week_number: cn(
|
week_number: cn(
|
||||||
"text-muted-foreground select-none text-[0.8rem]",
|
"text-muted-foreground select-none text-[0.8rem]",
|
||||||
defaultClassNames.week_number
|
defaultClassNames.week_number,
|
||||||
),
|
),
|
||||||
day: cn(
|
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",
|
"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
|
defaultClassNames.day,
|
||||||
),
|
),
|
||||||
range_start: cn(
|
range_start: cn(
|
||||||
"bg-accent rounded-l-md",
|
"bg-accent rounded-l-md",
|
||||||
defaultClassNames.range_start
|
defaultClassNames.range_start,
|
||||||
),
|
),
|
||||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
|
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
|
||||||
today: cn(
|
today: cn(
|
||||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||||
defaultClassNames.today
|
defaultClassNames.today,
|
||||||
),
|
),
|
||||||
outside: cn(
|
outside: cn(
|
||||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
defaultClassNames.outside
|
defaultClassNames.outside,
|
||||||
),
|
),
|
||||||
disabled: cn(
|
disabled: cn(
|
||||||
"text-muted-foreground opacity-50",
|
"text-muted-foreground opacity-50",
|
||||||
defaultClassNames.disabled
|
defaultClassNames.disabled,
|
||||||
),
|
),
|
||||||
hidden: cn("invisible", defaultClassNames.hidden),
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
...classNames,
|
...classNames,
|
||||||
@@ -131,13 +131,13 @@ function Calendar({
|
|||||||
className={cn(className)}
|
className={cn(className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
Chevron: ({ className, orientation, ...props }) => {
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
if (orientation === "left") {
|
if (orientation === "left") {
|
||||||
return (
|
return (
|
||||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (orientation === "right") {
|
if (orientation === "right") {
|
||||||
@@ -146,12 +146,12 @@ function Calendar({
|
|||||||
className={cn("size-4", className)}
|
className={cn("size-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
DayButton: CalendarDayButton,
|
DayButton: CalendarDayButton,
|
||||||
WeekNumber: ({ children, ...props }) => {
|
WeekNumber: ({ children, ...props }) => {
|
||||||
@@ -161,13 +161,13 @@ function Calendar({
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
...components,
|
...components,
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CalendarDayButton({
|
function CalendarDayButton({
|
||||||
@@ -176,12 +176,12 @@ function CalendarDayButton({
|
|||||||
modifiers,
|
modifiers,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DayButton>) {
|
}: React.ComponentProps<typeof DayButton>) {
|
||||||
const defaultClassNames = getDefaultClassNames()
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
const ref = React.useRef<HTMLButtonElement>(null)
|
const ref = React.useRef<HTMLButtonElement>(null);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (modifiers.focused) ref.current?.focus()
|
if (modifiers.focused) ref.current?.focus();
|
||||||
}, [modifiers.focused])
|
}, [modifiers.focused]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -201,11 +201,11 @@ function CalendarDayButton({
|
|||||||
className={cn(
|
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",
|
"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,
|
defaultClassNames.day,
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Calendar, CalendarDayButton }
|
export { Calendar, CalendarDayButton };
|
||||||
|
|||||||
86
frontend/src/components/ui/card.tsx
Normal 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,
|
||||||
|
};
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
import { type DialogProps } from "@radix-ui/react-dialog";
|
||||||
import { Command as CommandPrimitive } from "cmdk"
|
import { Command as CommandPrimitive } from "cmdk";
|
||||||
import { Search } from "lucide-react"
|
import { Search } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
|
|
||||||
const Command = React.forwardRef<
|
const Command = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive>,
|
React.ElementRef<typeof CommandPrimitive>,
|
||||||
@@ -16,12 +16,12 @@ const Command = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
Command.displayName = CommandPrimitive.displayName
|
Command.displayName = CommandPrimitive.displayName;
|
||||||
|
|
||||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||||
return (
|
return (
|
||||||
@@ -32,8 +32,8 @@ const CommandDialog = ({ children, ...props }: DialogProps) => {
|
|||||||
</Command>
|
</Command>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const CommandInput = React.forwardRef<
|
const CommandInput = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
@@ -45,14 +45,14 @@ const CommandInput = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))
|
));
|
||||||
|
|
||||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||||
|
|
||||||
const CommandList = React.forwardRef<
|
const CommandList = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive.List>,
|
React.ElementRef<typeof CommandPrimitive.List>,
|
||||||
@@ -63,9 +63,9 @@ const CommandList = React.forwardRef<
|
|||||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
|
|
||||||
CommandList.displayName = CommandPrimitive.List.displayName
|
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||||
|
|
||||||
const CommandEmpty = React.forwardRef<
|
const CommandEmpty = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||||
@@ -76,9 +76,9 @@ const CommandEmpty = React.forwardRef<
|
|||||||
className="py-6 text-center text-sm"
|
className="py-6 text-center text-sm"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
|
|
||||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||||
|
|
||||||
const CommandGroup = React.forwardRef<
|
const CommandGroup = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||||
@@ -88,13 +88,13 @@ const CommandGroup = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
|
|
||||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||||
|
|
||||||
const CommandSeparator = React.forwardRef<
|
const CommandSeparator = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||||
@@ -105,8 +105,8 @@ const CommandSeparator = React.forwardRef<
|
|||||||
className={cn("-mx-1 h-px bg-border", className)}
|
className={cn("-mx-1 h-px bg-border", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||||
|
|
||||||
const CommandItem = React.forwardRef<
|
const CommandItem = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||||
@@ -116,13 +116,13 @@ const CommandItem = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
|
|
||||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||||
|
|
||||||
const CommandShortcut = ({
|
const CommandShortcut = ({
|
||||||
className,
|
className,
|
||||||
@@ -132,13 +132,13 @@ const CommandShortcut = ({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
CommandShortcut.displayName = "CommandShortcut"
|
CommandShortcut.displayName = "CommandShortcut";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Command,
|
Command,
|
||||||
@@ -150,4 +150,4 @@ export {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
CommandShortcut,
|
CommandShortcut,
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
}
|
};
|
||||||
|
|||||||
137
frontend/src/components/ui/data-table-pagination.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import { X } from "lucide-react"
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
|
||||||
const DialogTrigger = DialogPrimitive.Trigger
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
|
||||||
const DialogPortal = DialogPrimitive.Portal
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
const DialogClose = DialogPrimitive.Close
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
const DialogOverlay = React.forwardRef<
|
const DialogOverlay = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
@@ -20,12 +20,12 @@ const DialogOverlay = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
@@ -37,7 +37,7 @@ const DialogContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -48,8 +48,8 @@ const DialogContent = React.forwardRef<
|
|||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
))
|
));
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
const DialogHeader = ({
|
const DialogHeader = ({
|
||||||
className,
|
className,
|
||||||
@@ -58,12 +58,12 @@ const DialogHeader = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
DialogHeader.displayName = "DialogHeader"
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
const DialogFooter = ({
|
const DialogFooter = ({
|
||||||
className,
|
className,
|
||||||
@@ -72,12 +72,12 @@ const DialogFooter = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
DialogFooter.displayName = "DialogFooter"
|
DialogFooter.displayName = "DialogFooter";
|
||||||
|
|
||||||
const DialogTitle = React.forwardRef<
|
const DialogTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
@@ -87,12 +87,12 @@ const DialogTitle = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-lg font-semibold leading-none tracking-tight",
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
const DialogDescription = React.forwardRef<
|
const DialogDescription = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
@@ -103,8 +103,8 @@ const DialogDescription = React.forwardRef<
|
|||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -117,4 +117,4 @@ export {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
({ className, type, ...props }, ref) => {
|
({ className, type, ...props }, ref) => {
|
||||||
@@ -9,14 +9,14 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
|||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
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",
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
Input.displayName = "Input"
|
Input.displayName = "Input";
|
||||||
|
|
||||||
export { Input }
|
export { Input };
|
||||||
|
|||||||
19
frontend/src/components/ui/label.tsx
Normal 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 };
|
||||||
44
frontend/src/components/ui/logo.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
interface LogoProps {
|
||||||
|
className?: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Logo({ className = "", size = 32 }: LogoProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
className={className}
|
||||||
|
role="img"
|
||||||
|
aria-labelledby="logo-title logo-desc"
|
||||||
|
>
|
||||||
|
<title id="logo-title">leggen — stylized italic L</title>
|
||||||
|
<desc id="logo-desc">Square gradient background with italic white L.</desc>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="logo-bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#0b74de" />
|
||||||
|
<stop offset="100%" stopColor="#06b6d4" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Square background */}
|
||||||
|
<rect width="32" height="32" fill="url(#logo-bg)" rx="4" />
|
||||||
|
|
||||||
|
{/* Italic L */}
|
||||||
|
<text
|
||||||
|
x="11"
|
||||||
|
y="22"
|
||||||
|
fontFamily="Inter, Roboto, Arial, sans-serif"
|
||||||
|
fontWeight="700"
|
||||||
|
fontSize="20"
|
||||||
|
fontStyle="italic"
|
||||||
|
fill="#fff"
|
||||||
|
>
|
||||||
|
L
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
frontend/src/components/ui/pagination.tsx
Normal 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,
|
||||||
|
};
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Popover = PopoverPrimitive.Root
|
const Popover = PopoverPrimitive.Root;
|
||||||
|
|
||||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||||
|
|
||||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||||
|
|
||||||
const PopoverContent = React.forwardRef<
|
const PopoverContent = React.forwardRef<
|
||||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
@@ -20,12 +20,12 @@ const PopoverContent = React.forwardRef<
|
|||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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]",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</PopoverPrimitive.Portal>
|
</PopoverPrimitive.Portal>
|
||||||
))
|
));
|
||||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||||
|
|||||||
26
frontend/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||||
|
>(({ className, value, ...props }, ref) => (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative h-2 w-full overflow-hidden rounded-full bg-secondary",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
className="h-full w-full flex-1 bg-primary transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
));
|
||||||
|
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Progress };
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Select = SelectPrimitive.Root
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
const SelectGroup = SelectPrimitive.Group
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
|
||||||
const SelectValue = SelectPrimitive.Value
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
const SelectTrigger = React.forwardRef<
|
const SelectTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -27,8 +27,8 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
</SelectPrimitive.Icon>
|
</SelectPrimitive.Icon>
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
))
|
));
|
||||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
const SelectScrollUpButton = React.forwardRef<
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
@@ -38,14 +38,14 @@ const SelectScrollUpButton = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center justify-center py-1",
|
"flex cursor-default items-center justify-center py-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUp className="h-4 w-4" />
|
<ChevronUp className="h-4 w-4" />
|
||||||
</SelectPrimitive.ScrollUpButton>
|
</SelectPrimitive.ScrollUpButton>
|
||||||
))
|
));
|
||||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
const SelectScrollDownButton = React.forwardRef<
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
@@ -55,15 +55,15 @@ const SelectScrollDownButton = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center justify-center py-1",
|
"flex cursor-default items-center justify-center py-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-4 w-4" />
|
||||||
</SelectPrimitive.ScrollDownButton>
|
</SelectPrimitive.ScrollDownButton>
|
||||||
))
|
));
|
||||||
SelectScrollDownButton.displayName =
|
SelectScrollDownButton.displayName =
|
||||||
SelectPrimitive.ScrollDownButton.displayName
|
SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
const SelectContent = React.forwardRef<
|
const SelectContent = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
@@ -76,7 +76,7 @@ const SelectContent = React.forwardRef<
|
|||||||
"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]",
|
"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" &&
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
position={position}
|
position={position}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -86,7 +86,7 @@ const SelectContent = React.forwardRef<
|
|||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
"p-1",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -94,8 +94,8 @@ const SelectContent = React.forwardRef<
|
|||||||
<SelectScrollDownButton />
|
<SelectScrollDownButton />
|
||||||
</SelectPrimitive.Content>
|
</SelectPrimitive.Content>
|
||||||
</SelectPrimitive.Portal>
|
</SelectPrimitive.Portal>
|
||||||
))
|
));
|
||||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
const SelectLabel = React.forwardRef<
|
const SelectLabel = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
@@ -106,8 +106,8 @@ const SelectLabel = React.forwardRef<
|
|||||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
const SelectItem = React.forwardRef<
|
const SelectItem = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
@@ -117,7 +117,7 @@ const SelectItem = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -128,8 +128,8 @@ const SelectItem = React.forwardRef<
|
|||||||
</span>
|
</span>
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
))
|
));
|
||||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
const SelectSeparator = React.forwardRef<
|
const SelectSeparator = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
@@ -140,8 +140,8 @@ const SelectSeparator = React.forwardRef<
|
|||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Select,
|
Select,
|
||||||
@@ -154,4 +154,4 @@ export {
|
|||||||
SelectSeparator,
|
SelectSeparator,
|
||||||
SelectScrollUpButton,
|
SelectScrollUpButton,
|
||||||
SelectScrollDownButton,
|
SelectScrollDownButton,
|
||||||
}
|
};
|
||||||
|
|||||||
138
frontend/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Sheet = SheetPrimitive.Root;
|
||||||
|
|
||||||
|
const SheetTrigger = SheetPrimitive.Trigger;
|
||||||
|
|
||||||
|
const SheetClose = SheetPrimitive.Close;
|
||||||
|
|
||||||
|
const SheetPortal = SheetPrimitive.Portal;
|
||||||
|
|
||||||
|
const SheetOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const sheetVariants = cva(
|
||||||
|
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
side: {
|
||||||
|
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||||
|
bottom:
|
||||||
|
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||||
|
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||||
|
right:
|
||||||
|
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
side: "right",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
interface SheetContentProps
|
||||||
|
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||||
|
VariantProps<typeof sheetVariants> {}
|
||||||
|
|
||||||
|
const SheetContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||||
|
SheetContentProps
|
||||||
|
>(({ side = "right", className, children, ...props }, ref) => (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(sheetVariants({ side }), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
));
|
||||||
|
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SheetHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
SheetHeader.displayName = "SheetHeader";
|
||||||
|
|
||||||
|
const SheetFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
SheetFooter.displayName = "SheetFooter";
|
||||||
|
|
||||||
|
const SheetTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const SheetDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetPortal,
|
||||||
|
SheetOverlay,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
};
|
||||||
15
frontend/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
27
frontend/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Toaster as Sonner } from "sonner";
|
||||||
|
|
||||||
|
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
className="toaster group"
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast:
|
||||||
|
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
|
description: "group-[.toast]:text-muted-foreground",
|
||||||
|
actionButton:
|
||||||
|
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||||
|
cancelButton:
|
||||||
|
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Toaster };
|
||||||
117
frontend/src/components/ui/table.tsx
Normal 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,
|
||||||
|
};
|
||||||
52
frontend/src/components/ui/theme-toggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
frontend/src/contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type Theme = "light" | "dark" | "system";
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
theme: Theme;
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
actualTheme: "light" | "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
// Theme colors for different modes
|
||||||
|
const THEME_COLORS = {
|
||||||
|
light: "#0b74de", // Primary brand color
|
||||||
|
dark: "#0f0f23", // Dark background color that matches typical dark themes
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
|
const stored = localStorage.getItem("theme") as Theme;
|
||||||
|
return stored || "system";
|
||||||
|
});
|
||||||
|
|
||||||
|
const [actualTheme, setActualTheme] = useState<"light" | "dark">("light");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement;
|
||||||
|
|
||||||
|
const updateActualTheme = () => {
|
||||||
|
let resolvedTheme: "light" | "dark";
|
||||||
|
|
||||||
|
if (theme === "system") {
|
||||||
|
resolvedTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||||
|
.matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
} else {
|
||||||
|
resolvedTheme = theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActualTheme(resolvedTheme);
|
||||||
|
|
||||||
|
// Remove previous theme classes
|
||||||
|
root.classList.remove("light", "dark");
|
||||||
|
|
||||||
|
// Add resolved theme class
|
||||||
|
root.classList.add(resolvedTheme);
|
||||||
|
|
||||||
|
// Update theme-color meta tags for PWA status bar
|
||||||
|
const themeColor = THEME_COLORS[resolvedTheme];
|
||||||
|
|
||||||
|
// Update theme-color meta tag
|
||||||
|
const themeColorMeta = document.getElementById("theme-color-meta") as HTMLMetaElement;
|
||||||
|
if (themeColorMeta) {
|
||||||
|
themeColorMeta.content = themeColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Microsoft tile color
|
||||||
|
const msThemeColorMeta = document.getElementById("ms-theme-color-meta") as HTMLMetaElement;
|
||||||
|
if (msThemeColorMeta) {
|
||||||
|
msThemeColorMeta.content = themeColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Apple status bar style for better iOS integration
|
||||||
|
const appleStatusBarMeta = document.getElementById("apple-status-bar-meta") as HTMLMetaElement;
|
||||||
|
if (appleStatusBarMeta) {
|
||||||
|
// Use 'black-translucent' for dark theme, 'default' for light theme
|
||||||
|
appleStatusBarMeta.content = resolvedTheme === "dark" ? "black-translucent" : "default";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateActualTheme();
|
||||||
|
|
||||||
|
// Listen for system theme changes
|
||||||
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const handleChange = () => {
|
||||||
|
if (theme === "system") {
|
||||||
|
updateActualTheme();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaQuery.addEventListener("change", handleChange);
|
||||||
|
|
||||||
|
// Store theme preference
|
||||||
|
localStorage.setItem("theme", theme);
|
||||||
|
|
||||||
|
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, setTheme, actualTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useTheme must be used within a ThemeProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
37
frontend/src/hooks/usePWA.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface PWAUpdate {
|
||||||
|
updateAvailable: boolean;
|
||||||
|
updateSW: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePWA(): PWAUpdate {
|
||||||
|
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||||
|
const [updateSW, setUpdateSW] = useState<() => Promise<void>>(() => async () => {});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if SW registration is available
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
// Import the registerSW function
|
||||||
|
import("virtual:pwa-register").then(({ registerSW }) => {
|
||||||
|
const updateSWFunction = registerSW({
|
||||||
|
onNeedRefresh() {
|
||||||
|
setUpdateAvailable(true);
|
||||||
|
setUpdateSW(() => updateSWFunction);
|
||||||
|
},
|
||||||
|
onOfflineReady() {
|
||||||
|
console.log("App ready to work offline");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
// PWA not available in development mode or when disabled
|
||||||
|
console.log("PWA registration not available");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateAvailable,
|
||||||
|
updateSW,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,10 +10,10 @@
|
|||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
--primary: 222.2 47.4% 11.2%;
|
--primary: 219 91% 46%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--primary-foreground: 210 40% 98%;
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 189 94% 43%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--secondary-foreground: 210 40% 98%;
|
||||||
--muted: 210 40% 96.1%;
|
--muted: 210 40% 96.1%;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
--accent: 210 40% 96.1%;
|
--accent: 210 40% 96.1%;
|
||||||
@@ -28,7 +28,13 @@
|
|||||||
--chart-3: 197 37% 24%;
|
--chart-3: 197 37% 24%;
|
||||||
--chart-4: 43 74% 66%;
|
--chart-4: 43 74% 66%;
|
||||||
--chart-5: 27 87% 67%;
|
--chart-5: 27 87% 67%;
|
||||||
--radius: 0.5rem
|
--radius: 0.5rem;
|
||||||
|
|
||||||
|
/* iOS Safe Area Support for PWA */
|
||||||
|
--safe-area-inset-top: env(safe-area-inset-top, 0px);
|
||||||
|
--safe-area-inset-right: env(safe-area-inset-right, 0px);
|
||||||
|
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
--safe-area-inset-left: env(safe-area-inset-left, 0px);
|
||||||
}
|
}
|
||||||
.dark {
|
.dark {
|
||||||
--background: 222.2 84% 4.9%;
|
--background: 222.2 84% 4.9%;
|
||||||
@@ -37,9 +43,9 @@
|
|||||||
--card-foreground: 210 40% 98%;
|
--card-foreground: 210 40% 98%;
|
||||||
--popover: 222.2 84% 4.9%;
|
--popover: 222.2 84% 4.9%;
|
||||||
--popover-foreground: 210 40% 98%;
|
--popover-foreground: 210 40% 98%;
|
||||||
--primary: 210 40% 98%;
|
--primary: 219 91% 46%;
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
--primary-foreground: 210 40% 98%;
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--secondary: 189 94% 43%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary-foreground: 210 40% 98%;
|
||||||
--muted: 217.2 32.6% 17.5%;
|
--muted: 217.2 32.6% 17.5%;
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
@@ -54,7 +60,7 @@
|
|||||||
--chart-2: 160 60% 45%;
|
--chart-2: 160 60% 45%;
|
||||||
--chart-3: 30 80% 55%;
|
--chart-3: 30 80% 55%;
|
||||||
--chart-4: 280 65% 60%;
|
--chart-4: 280 65% 60%;
|
||||||
--chart-5: 340 75% 55%
|
--chart-5: 340 75% 55%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,11 +41,10 @@ export const apiClient = {
|
|||||||
updateAccount: async (
|
updateAccount: async (
|
||||||
id: string,
|
id: string,
|
||||||
updates: AccountUpdate,
|
updates: AccountUpdate,
|
||||||
): Promise<{ id: string; name?: string }> => {
|
): Promise<{ id: string; display_name?: string }> => {
|
||||||
const response = await api.put<ApiResponse<{ id: string; name?: string }>>(
|
const response = await api.put<
|
||||||
`/accounts/${id}`,
|
ApiResponse<{ id: string; display_name?: string }>
|
||||||
updates,
|
>(`/accounts/${id}`, updates);
|
||||||
);
|
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -56,13 +55,16 @@ export const apiClient = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Get historical balances for balance progression chart
|
// Get historical balances for balance progression chart
|
||||||
getHistoricalBalances: async (days?: number, accountId?: string): Promise<Balance[]> => {
|
getHistoricalBalances: async (
|
||||||
|
days?: number,
|
||||||
|
accountId?: string,
|
||||||
|
): Promise<Balance[]> => {
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
if (days) queryParams.append("days", days.toString());
|
if (days) queryParams.append("days", days.toString());
|
||||||
if (accountId) queryParams.append("account_id", accountId);
|
if (accountId) queryParams.append("account_id", accountId);
|
||||||
|
|
||||||
const response = await api.get<ApiResponse<Balance[]>>(
|
const response = await api.get<ApiResponse<Balance[]>>(
|
||||||
`/balances/history?${queryParams.toString()}`
|
`/balances/history?${queryParams.toString()}`,
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
@@ -171,40 +173,48 @@ export const apiClient = {
|
|||||||
if (days) queryParams.append("days", days.toString());
|
if (days) queryParams.append("days", days.toString());
|
||||||
|
|
||||||
const response = await api.get<ApiResponse<TransactionStats>>(
|
const response = await api.get<ApiResponse<TransactionStats>>(
|
||||||
`/transactions/stats?${queryParams.toString()}`
|
`/transactions/stats?${queryParams.toString()}`,
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get all transactions for analytics (no pagination)
|
// Get all transactions for analytics (no pagination)
|
||||||
getTransactionsForAnalytics: async (days?: number): Promise<AnalyticsTransaction[]> => {
|
getTransactionsForAnalytics: async (
|
||||||
|
days?: number,
|
||||||
|
): Promise<AnalyticsTransaction[]> => {
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
if (days) queryParams.append("days", days.toString());
|
if (days) queryParams.append("days", days.toString());
|
||||||
|
|
||||||
const response = await api.get<ApiResponse<AnalyticsTransaction[]>>(
|
const response = await api.get<ApiResponse<AnalyticsTransaction[]>>(
|
||||||
`/transactions/analytics?${queryParams.toString()}`
|
`/transactions/analytics?${queryParams.toString()}`,
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get monthly transaction statistics (pre-calculated)
|
// Get monthly transaction statistics (pre-calculated)
|
||||||
getMonthlyTransactionStats: async (days?: number): Promise<Array<{
|
getMonthlyTransactionStats: async (
|
||||||
|
days?: number,
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
month: string;
|
month: string;
|
||||||
income: number;
|
income: number;
|
||||||
expenses: number;
|
expenses: number;
|
||||||
net: number;
|
net: number;
|
||||||
}>> => {
|
}>
|
||||||
|
> => {
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
if (days) queryParams.append("days", days.toString());
|
if (days) queryParams.append("days", days.toString());
|
||||||
|
|
||||||
const response = await api.get<ApiResponse<Array<{
|
const response = await api.get<
|
||||||
|
ApiResponse<
|
||||||
|
Array<{
|
||||||
month: string;
|
month: string;
|
||||||
income: number;
|
income: number;
|
||||||
expenses: number;
|
expenses: number;
|
||||||
net: number;
|
net: number;
|
||||||
}>>>(
|
}>
|
||||||
`/transactions/monthly-stats?${queryParams.toString()}`
|
>
|
||||||
);
|
>(`/transactions/monthly-stats?${queryParams.toString()}`);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatCurrency(amount: number, currency: string = "EUR"): string {
|
export function formatCurrency(
|
||||||
|
amount: number,
|
||||||
|
currency: string = "EUR",
|
||||||
|
): string {
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency,
|
currency,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { createRouter, RouterProvider } from "@tanstack/react-router";
|
import { createRouter, RouterProvider } from "@tanstack/react-router";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { routeTree } from "./routeTree.gen";
|
import { routeTree } from "./routeTree.gen";
|
||||||
|
|
||||||
@@ -19,7 +20,9 @@ const queryClient = new QueryClient({
|
|||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ThemeProvider>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as TransactionsRouteImport } from './routes/transactions'
|
import { Route as TransactionsRouteImport } from './routes/transactions'
|
||||||
|
import { Route as SettingsRouteImport } from './routes/settings'
|
||||||
import { Route as NotificationsRouteImport } from './routes/notifications'
|
import { Route as NotificationsRouteImport } from './routes/notifications'
|
||||||
import { Route as AnalyticsRouteImport } from './routes/analytics'
|
import { Route as AnalyticsRouteImport } from './routes/analytics'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
@@ -19,6 +20,11 @@ const TransactionsRoute = TransactionsRouteImport.update({
|
|||||||
path: '/transactions',
|
path: '/transactions',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const SettingsRoute = SettingsRouteImport.update({
|
||||||
|
id: '/settings',
|
||||||
|
path: '/settings',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const NotificationsRoute = NotificationsRouteImport.update({
|
const NotificationsRoute = NotificationsRouteImport.update({
|
||||||
id: '/notifications',
|
id: '/notifications',
|
||||||
path: '/notifications',
|
path: '/notifications',
|
||||||
@@ -39,12 +45,14 @@ export interface FileRoutesByFullPath {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/analytics': typeof AnalyticsRoute
|
'/analytics': typeof AnalyticsRoute
|
||||||
'/notifications': typeof NotificationsRoute
|
'/notifications': typeof NotificationsRoute
|
||||||
|
'/settings': typeof SettingsRoute
|
||||||
'/transactions': typeof TransactionsRoute
|
'/transactions': typeof TransactionsRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/analytics': typeof AnalyticsRoute
|
'/analytics': typeof AnalyticsRoute
|
||||||
'/notifications': typeof NotificationsRoute
|
'/notifications': typeof NotificationsRoute
|
||||||
|
'/settings': typeof SettingsRoute
|
||||||
'/transactions': typeof TransactionsRoute
|
'/transactions': typeof TransactionsRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
@@ -52,20 +60,33 @@ export interface FileRoutesById {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/analytics': typeof AnalyticsRoute
|
'/analytics': typeof AnalyticsRoute
|
||||||
'/notifications': typeof NotificationsRoute
|
'/notifications': typeof NotificationsRoute
|
||||||
|
'/settings': typeof SettingsRoute
|
||||||
'/transactions': typeof TransactionsRoute
|
'/transactions': typeof TransactionsRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/analytics' | '/notifications' | '/transactions'
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/analytics'
|
||||||
|
| '/notifications'
|
||||||
|
| '/settings'
|
||||||
|
| '/transactions'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/analytics' | '/notifications' | '/transactions'
|
to: '/' | '/analytics' | '/notifications' | '/settings' | '/transactions'
|
||||||
id: '__root__' | '/' | '/analytics' | '/notifications' | '/transactions'
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/analytics'
|
||||||
|
| '/notifications'
|
||||||
|
| '/settings'
|
||||||
|
| '/transactions'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AnalyticsRoute: typeof AnalyticsRoute
|
AnalyticsRoute: typeof AnalyticsRoute
|
||||||
NotificationsRoute: typeof NotificationsRoute
|
NotificationsRoute: typeof NotificationsRoute
|
||||||
|
SettingsRoute: typeof SettingsRoute
|
||||||
TransactionsRoute: typeof TransactionsRoute
|
TransactionsRoute: typeof TransactionsRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +99,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof TransactionsRouteImport
|
preLoaderRoute: typeof TransactionsRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/settings': {
|
||||||
|
id: '/settings'
|
||||||
|
path: '/settings'
|
||||||
|
fullPath: '/settings'
|
||||||
|
preLoaderRoute: typeof SettingsRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/notifications': {
|
'/notifications': {
|
||||||
id: '/notifications'
|
id: '/notifications'
|
||||||
path: '/notifications'
|
path: '/notifications'
|
||||||
@@ -106,6 +134,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AnalyticsRoute: AnalyticsRoute,
|
AnalyticsRoute: AnalyticsRoute,
|
||||||
NotificationsRoute: NotificationsRoute,
|
NotificationsRoute: NotificationsRoute,
|
||||||
|
SettingsRoute: SettingsRoute,
|
||||||
TransactionsRoute: TransactionsRoute,
|
TransactionsRoute: TransactionsRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
|
|||||||
@@ -2,28 +2,51 @@ import { createRootRoute, Outlet } from "@tanstack/react-router";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Sidebar from "../components/Sidebar";
|
import Sidebar from "../components/Sidebar";
|
||||||
import Header from "../components/Header";
|
import Header from "../components/Header";
|
||||||
|
import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
|
||||||
|
import { usePWA } from "../hooks/usePWA";
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const { updateAvailable, updateSW } = usePWA();
|
||||||
|
|
||||||
|
const handlePWAInstall = () => {
|
||||||
|
console.log("PWA installed successfully");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePWAUpdate = async () => {
|
||||||
|
try {
|
||||||
|
await updateSW();
|
||||||
|
console.log("PWA updated successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating PWA:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-gray-100">
|
<div className="flex min-h-screen bg-background">
|
||||||
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
|
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
|
||||||
|
|
||||||
{/* Mobile overlay */}
|
{/* Mobile overlay */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
|
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col flex-1 overflow-hidden">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<Header setSidebarOpen={setSidebarOpen} />
|
<Header setSidebarOpen={setSidebarOpen} />
|
||||||
<main className="flex-1 overflow-y-auto p-6">
|
<main className="flex-1 p-6 min-w-0">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* PWA Prompts */}
|
||||||
|
<PWAInstallPrompt onInstall={handlePWAInstall} />
|
||||||
|
<PWAUpdatePrompt
|
||||||
|
updateAvailable={updateAvailable}
|
||||||
|
onUpdate={handlePWAUpdate}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ import BalanceChart from "../components/analytics/BalanceChart";
|
|||||||
import TransactionDistribution from "../components/analytics/TransactionDistribution";
|
import TransactionDistribution from "../components/analytics/TransactionDistribution";
|
||||||
import MonthlyTrends from "../components/analytics/MonthlyTrends";
|
import MonthlyTrends from "../components/analytics/MonthlyTrends";
|
||||||
import TimePeriodFilter from "../components/analytics/TimePeriodFilter";
|
import TimePeriodFilter from "../components/analytics/TimePeriodFilter";
|
||||||
|
import { Card, CardContent } from "../components/ui/card";
|
||||||
import type { TimePeriod } from "../lib/timePeriods";
|
import type { TimePeriod } from "../lib/timePeriods";
|
||||||
import { TIME_PERIODS } from "../lib/timePeriods";
|
import { TIME_PERIODS } from "../lib/timePeriods";
|
||||||
|
|
||||||
function AnalyticsDashboard() {
|
function AnalyticsDashboard() {
|
||||||
// Default to Last 365 days
|
// Default to Last 365 days
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState<TimePeriod>(
|
const [selectedPeriod, setSelectedPeriod] = useState<TimePeriod>(
|
||||||
TIME_PERIODS.find((p) => p.value === "365d") || TIME_PERIODS[3]
|
TIME_PERIODS.find((p) => p.value === "365d") || TIME_PERIODS[3],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch analytics data
|
// Fetch analytics data
|
||||||
@@ -45,15 +46,15 @@ function AnalyticsDashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="animate-pulse">
|
<div className="animate-pulse">
|
||||||
<div className="h-8 bg-gray-200 rounded w-48 mb-6"></div>
|
<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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||||
{[...Array(3)].map((_, i) => (
|
{[...Array(3)].map((_, i) => (
|
||||||
<div key={i} className="h-32 bg-gray-200 rounded"></div>
|
<div key={i} className="h-32 bg-muted rounded"></div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<div className="h-96 bg-gray-200 rounded"></div>
|
<div className="h-96 bg-muted rounded"></div>
|
||||||
<div className="h-96 bg-gray-200 rounded"></div>
|
<div className="h-96 bg-muted rounded"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,11 +64,14 @@ function AnalyticsDashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-8">
|
<div className="p-6 space-y-8">
|
||||||
{/* Time Period Filter */}
|
{/* Time Period Filter */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
<TimePeriodFilter
|
<TimePeriodFilter
|
||||||
selectedPeriod={selectedPeriod}
|
selectedPeriod={selectedPeriod}
|
||||||
onPeriodChange={setSelectedPeriod}
|
onPeriodChange={setSelectedPeriod}
|
||||||
className="bg-white rounded-lg shadow p-4 border border-gray-200"
|
|
||||||
/>
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
@@ -76,20 +80,21 @@ function AnalyticsDashboard() {
|
|||||||
value={stats?.total_transactions || 0}
|
value={stats?.total_transactions || 0}
|
||||||
subtitle={`Last ${stats?.period_days || 0} days`}
|
subtitle={`Last ${stats?.period_days || 0} days`}
|
||||||
icon={Activity}
|
icon={Activity}
|
||||||
|
iconColor="blue"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Total Income"
|
title="Total Income"
|
||||||
value={`€${(stats?.total_income || 0).toLocaleString()}`}
|
value={`€${(stats?.total_income || 0).toLocaleString()}`}
|
||||||
subtitle="Inflows this period"
|
subtitle="Inflows this period"
|
||||||
icon={TrendingUp}
|
icon={TrendingUp}
|
||||||
className="border-green-200"
|
iconColor="green"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Total Expenses"
|
title="Total Expenses"
|
||||||
value={`€${(stats?.total_expenses || 0).toLocaleString()}`}
|
value={`€${(stats?.total_expenses || 0).toLocaleString()}`}
|
||||||
subtitle="Outflows this period"
|
subtitle="Outflows this period"
|
||||||
icon={TrendingDown}
|
icon={TrendingDown}
|
||||||
className="border-red-200"
|
iconColor="red"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -100,38 +105,44 @@ function AnalyticsDashboard() {
|
|||||||
value={`€${(stats?.net_change || 0).toLocaleString()}`}
|
value={`€${(stats?.net_change || 0).toLocaleString()}`}
|
||||||
subtitle="Income minus expenses"
|
subtitle="Income minus expenses"
|
||||||
icon={CreditCard}
|
icon={CreditCard}
|
||||||
className={
|
iconColor={(stats?.net_change || 0) >= 0 ? "green" : "red"}
|
||||||
(stats?.net_change || 0) >= 0 ? "border-green-200" : "border-red-200"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Average Transaction"
|
title="Average Transaction"
|
||||||
value={`€${Math.abs(stats?.average_transaction || 0).toLocaleString()}`}
|
value={`€${Math.abs(stats?.average_transaction || 0).toLocaleString()}`}
|
||||||
subtitle="Per transaction"
|
subtitle="Per transaction"
|
||||||
icon={Activity}
|
icon={Activity}
|
||||||
|
iconColor="purple"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Active Accounts"
|
title="Active Accounts"
|
||||||
value={stats?.accounts_included || 0}
|
value={stats?.accounts_included || 0}
|
||||||
subtitle="With recent activity"
|
subtitle="With recent activity"
|
||||||
icon={Users}
|
icon={Users}
|
||||||
|
iconColor="orange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts */}
|
{/* Charts */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
<BalanceChart data={balances || []} accounts={accounts || []} />
|
<BalanceChart data={balances || []} accounts={accounts || []} />
|
||||||
</div>
|
</CardContent>
|
||||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
<TransactionDistribution accounts={accounts || []} />
|
<TransactionDistribution accounts={accounts || []} />
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Monthly Trends */}
|
{/* Monthly Trends */}
|
||||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
<MonthlyTrends days={selectedPeriod.days} />
|
<MonthlyTrends days={selectedPeriod.days} />
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import AccountsOverview from "../components/AccountsOverview";
|
import TransactionsTable from "../components/TransactionsTable";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
component: AccountsOverview,
|
component: TransactionsTable,
|
||||||
|
validateSearch: (search) => ({
|
||||||
|
accountId: search.accountId as string | undefined,
|
||||||
|
startDate: search.startDate as string | undefined,
|
||||||
|
endDate: search.endDate as string | undefined,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
6
frontend/src/routes/settings.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import AccountSettings from "../components/AccountSettings";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/settings")({
|
||||||
|
component: AccountSettings,
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ 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;
|
||||||
@@ -18,7 +19,7 @@ export interface Account {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountUpdate {
|
export interface AccountUpdate {
|
||||||
name?: string;
|
display_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RawTransactionData {
|
export interface RawTransactionData {
|
||||||
|
|||||||
1
frontend/src/vite-env.d.ts
vendored
@@ -1 +1,2 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-plugin-pwa/client" />
|
||||||
|
|||||||
@@ -5,53 +5,59 @@ export default {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: 'var(--radius)',
|
lg: "var(--radius)",
|
||||||
md: 'calc(var(--radius) - 2px)',
|
md: "calc(var(--radius) - 2px)",
|
||||||
sm: 'calc(var(--radius) - 4px)'
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
'safe-top': 'var(--safe-area-inset-top)',
|
||||||
|
'safe-bottom': 'var(--safe-area-inset-bottom)',
|
||||||
|
'safe-left': 'var(--safe-area-inset-left)',
|
||||||
|
'safe-right': 'var(--safe-area-inset-right)',
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
background: 'hsl(var(--background))',
|
background: "hsl(var(--background))",
|
||||||
foreground: 'hsl(var(--foreground))',
|
foreground: "hsl(var(--foreground))",
|
||||||
card: {
|
card: {
|
||||||
DEFAULT: 'hsl(var(--card))',
|
DEFAULT: "hsl(var(--card))",
|
||||||
foreground: 'hsl(var(--card-foreground))'
|
foreground: "hsl(var(--card-foreground))",
|
||||||
},
|
},
|
||||||
popover: {
|
popover: {
|
||||||
DEFAULT: 'hsl(var(--popover))',
|
DEFAULT: "hsl(var(--popover))",
|
||||||
foreground: 'hsl(var(--popover-foreground))'
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
},
|
},
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: 'hsl(var(--primary))',
|
DEFAULT: "hsl(var(--primary))",
|
||||||
foreground: 'hsl(var(--primary-foreground))'
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
DEFAULT: 'hsl(var(--secondary))',
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
foreground: 'hsl(var(--secondary-foreground))'
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
},
|
},
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: 'hsl(var(--muted))',
|
DEFAULT: "hsl(var(--muted))",
|
||||||
foreground: 'hsl(var(--muted-foreground))'
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
},
|
},
|
||||||
accent: {
|
accent: {
|
||||||
DEFAULT: 'hsl(var(--accent))',
|
DEFAULT: "hsl(var(--accent))",
|
||||||
foreground: 'hsl(var(--accent-foreground))'
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
},
|
},
|
||||||
destructive: {
|
destructive: {
|
||||||
DEFAULT: 'hsl(var(--destructive))',
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
foreground: 'hsl(var(--destructive-foreground))'
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
},
|
},
|
||||||
border: 'hsl(var(--border))',
|
border: "hsl(var(--border))",
|
||||||
input: 'hsl(var(--input))',
|
input: "hsl(var(--input))",
|
||||||
ring: 'hsl(var(--ring))',
|
ring: "hsl(var(--ring))",
|
||||||
chart: {
|
chart: {
|
||||||
'1': 'hsl(var(--chart-1))',
|
1: "hsl(var(--chart-1))",
|
||||||
'2': 'hsl(var(--chart-2))',
|
2: "hsl(var(--chart-2))",
|
||||||
'3': 'hsl(var(--chart-3))',
|
3: "hsl(var(--chart-3))",
|
||||||
'4': 'hsl(var(--chart-4))',
|
4: "hsl(var(--chart-4))",
|
||||||
'5': 'hsl(var(--chart-5))'
|
5: "hsl(var(--chart-5))",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
plugins: [require("@tailwindcss/forms"), require("tailwindcss-animate")],
|
plugins: [require("@tailwindcss/forms"), require("tailwindcss-animate")],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,88 @@
|
|||||||
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";
|
import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
|
||||||
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [TanStackRouterVite(), react()],
|
plugins: [
|
||||||
|
TanStackRouterVite(),
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: "autoUpdate",
|
||||||
|
includeAssets: ["favicon.ico", "apple-touch-icon-180x180.png", "maskable-icon-512x512.png", "robots.txt"],
|
||||||
|
manifest: {
|
||||||
|
name: "Leggen",
|
||||||
|
short_name: "Leggen",
|
||||||
|
description: "Personal finance management application",
|
||||||
|
theme_color: "#0b74de",
|
||||||
|
background_color: "#ffffff",
|
||||||
|
display: "standalone",
|
||||||
|
orientation: "portrait",
|
||||||
|
scope: "/",
|
||||||
|
start_url: "/",
|
||||||
|
categories: ["finance", "productivity"],
|
||||||
|
shortcuts: [
|
||||||
|
{
|
||||||
|
name: "Transactions",
|
||||||
|
short_name: "Transactions",
|
||||||
|
description: "View and manage transactions",
|
||||||
|
url: "/transactions",
|
||||||
|
icons: [{ src: "/pwa-192x192.png", sizes: "192x192" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Analytics",
|
||||||
|
short_name: "Analytics",
|
||||||
|
description: "View financial analytics",
|
||||||
|
url: "/analytics",
|
||||||
|
icons: [{ src: "/pwa-192x192.png", sizes: "192x192" }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "pwa-64x64.png",
|
||||||
|
sizes: "64x64",
|
||||||
|
type: "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "pwa-192x192.png",
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "pwa-512x512.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "maskable-icon-512x512.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ["**/*.{js,css,html,ico,png,svg}"],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/.*\/api\//,
|
||||||
|
handler: "NetworkFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: "api-cache",
|
||||||
|
networkTimeoutSeconds: 10,
|
||||||
|
cacheableResponse: {
|
||||||
|
statuses: [0, 200],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": "/src",
|
"@": "/src",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -36,7 +37,7 @@ class AccountDetails(BaseModel):
|
|||||||
class AccountUpdate(BaseModel):
|
class AccountUpdate(BaseModel):
|
||||||
"""Account update model"""
|
"""Account update model"""
|
||||||
|
|
||||||
name: Optional[str] = None
|
display_name: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
json_encoders = {datetime: lambda v: v.isoformat() if v else None}
|
json_encoders = {datetime: lambda v: v.isoformat() if v else None}
|
||||||
|
|||||||
@@ -53,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"),
|
||||||
@@ -112,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"),
|
||||||
@@ -324,7 +326,7 @@ async def get_account_transactions(
|
|||||||
async def update_account_details(
|
async def update_account_details(
|
||||||
account_id: str, update_data: AccountUpdate
|
account_id: str, update_data: AccountUpdate
|
||||||
) -> APIResponse:
|
) -> APIResponse:
|
||||||
"""Update account details (currently only name)"""
|
"""Update account details (currently only display_name)"""
|
||||||
try:
|
try:
|
||||||
# Get current account details
|
# Get current account details
|
||||||
current_account = await database_service.get_account_details_from_db(account_id)
|
current_account = await database_service.get_account_details_from_db(account_id)
|
||||||
@@ -336,16 +338,16 @@ async def update_account_details(
|
|||||||
|
|
||||||
# Prepare updated account data
|
# Prepare updated account data
|
||||||
updated_account_data = current_account.copy()
|
updated_account_data = current_account.copy()
|
||||||
if update_data.name is not None:
|
if update_data.display_name is not None:
|
||||||
updated_account_data["name"] = update_data.name
|
updated_account_data["display_name"] = update_data.display_name
|
||||||
|
|
||||||
# Persist updated account details
|
# Persist updated account details
|
||||||
await database_service.persist_account_details(updated_account_data)
|
await database_service.persist_account_details(updated_account_data)
|
||||||
|
|
||||||
return APIResponse(
|
return APIResponse(
|
||||||
success=True,
|
success=True,
|
||||||
data={"id": account_id, "name": update_data.name},
|
data={"id": account_id, "display_name": update_data.display_name},
|
||||||
message=f"Account {account_id} name updated successfully",
|
message=f"Account {account_id} display name updated successfully",
|
||||||
)
|
)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|||||||
@@ -37,15 +37,15 @@ 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="***" if telegram_config.get("api-key") else "",
|
token="***" if telegram_config.get("token") else "",
|
||||||
chat_id=telegram_config.get("chat-id", 0),
|
chat_id=telegram_config.get("chat_id", 0),
|
||||||
enabled=telegram_config.get("enabled", True),
|
enabled=telegram_config.get("enabled", True),
|
||||||
)
|
)
|
||||||
if telegram_config.get("api-key")
|
if telegram_config.get("token")
|
||||||
else None,
|
else None,
|
||||||
filters=NotificationFilters(
|
filters=NotificationFilters(
|
||||||
case_insensitive=filters_config.get("case-insensitive", []),
|
case_insensitive=filters_config.get("case_insensitive", []),
|
||||||
case_sensitive=filters_config.get("case-sensitive"),
|
case_sensitive=filters_config.get("case_sensitive"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -77,17 +77,17 @@ async def update_notification_settings(settings: NotificationSettings) -> APIRes
|
|||||||
|
|
||||||
if settings.telegram:
|
if settings.telegram:
|
||||||
notifications_config["telegram"] = {
|
notifications_config["telegram"] = {
|
||||||
"api-key": settings.telegram.token,
|
"token": settings.telegram.token,
|
||||||
"chat-id": settings.telegram.chat_id,
|
"chat_id": settings.telegram.chat_id,
|
||||||
"enabled": settings.telegram.enabled,
|
"enabled": settings.telegram.enabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Update filters config
|
# Update filters config
|
||||||
filters_config: Dict[str, Any] = {}
|
filters_config: Dict[str, Any] = {}
|
||||||
if settings.filters.case_insensitive:
|
if settings.filters.case_insensitive:
|
||||||
filters_config["case-insensitive"] = settings.filters.case_insensitive
|
filters_config["case_insensitive"] = settings.filters.case_insensitive
|
||||||
if settings.filters.case_sensitive:
|
if settings.filters.case_sensitive:
|
||||||
filters_config["case-sensitive"] = settings.filters.case_sensitive
|
filters_config["case_sensitive"] = settings.filters.case_sensitive
|
||||||
|
|
||||||
# Save to config
|
# Save to config
|
||||||
if notifications_config:
|
if notifications_config:
|
||||||
@@ -153,12 +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")
|
and 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")
|
and notifications_config.get("telegram", {}).get("chat_id")
|
||||||
),
|
),
|
||||||
"active": notifications_config.get("telegram", {}).get("enabled", True),
|
"active": notifications_config.get("telegram", {}).get("enabled", True),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ class DiscordNotificationConfig(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class TelegramNotificationConfig(BaseModel):
|
class TelegramNotificationConfig(BaseModel):
|
||||||
token: str = Field(..., alias="api-key", description="Telegram bot token")
|
token: str = Field(..., description="Telegram bot token")
|
||||||
chat_id: int = Field(..., alias="chat-id", description="Telegram chat ID")
|
chat_id: int = Field(..., description="Telegram chat ID")
|
||||||
enabled: bool = Field(default=True, description="Enable Telegram notifications")
|
enabled: bool = Field(default=True, description="Enable Telegram notifications")
|
||||||
|
|
||||||
|
|
||||||
@@ -33,12 +33,8 @@ class NotificationConfig(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class FilterConfig(BaseModel):
|
class FilterConfig(BaseModel):
|
||||||
case_insensitive: Optional[List[str]] = Field(
|
case_insensitive: Optional[List[str]] = Field(default_factory=list)
|
||||||
default_factory=list, alias="case-insensitive"
|
case_sensitive: Optional[List[str]] = Field(default_factory=list)
|
||||||
)
|
|
||||||
case_sensitive: Optional[List[str]] = Field(
|
|
||||||
default_factory=list, alias="case-sensitive"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SyncScheduleConfig(BaseModel):
|
class SyncScheduleConfig(BaseModel):
|
||||||
@@ -60,6 +56,3 @@ class Config(BaseModel):
|
|||||||
notifications: Optional[NotificationConfig] = None
|
notifications: Optional[NotificationConfig] = None
|
||||||
filters: Optional[FilterConfig] = None
|
filters: Optional[FilterConfig] = None
|
||||||
scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig)
|
scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig)
|
||||||
|
|
||||||
class Config:
|
|
||||||
validate_by_name = True
|
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ def escape_markdown(text: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def send_expire_notification(ctx: click.Context, notification: dict):
|
def send_expire_notification(ctx: click.Context, notification: dict):
|
||||||
token = ctx.obj["notifications"]["telegram"]["api-key"]
|
token = ctx.obj["notifications"]["telegram"]["token"]
|
||||||
chat_id = ctx.obj["notifications"]["telegram"]["chat-id"]
|
chat_id = ctx.obj["notifications"]["telegram"]["chat_id"]
|
||||||
bot_url = f"https://api.telegram.org/bot{token}/sendMessage"
|
bot_url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||||
info("Sending expiration notification to Telegram")
|
info("Sending expiration notification to Telegram")
|
||||||
message = "*💲 [Leggen](https://github.com/elisiariocouto/leggen)*\n"
|
message = "*💲 [Leggen](https://github.com/elisiariocouto/leggen)*\n"
|
||||||
@@ -54,8 +54,8 @@ def send_expire_notification(ctx: click.Context, notification: dict):
|
|||||||
|
|
||||||
|
|
||||||
def send_transaction_message(ctx: click.Context, transactions: list):
|
def send_transaction_message(ctx: click.Context, transactions: list):
|
||||||
token = ctx.obj["notifications"]["telegram"]["api-key"]
|
token = ctx.obj["notifications"]["telegram"]["token"]
|
||||||
chat_id = ctx.obj["notifications"]["telegram"]["chat-id"]
|
chat_id = ctx.obj["notifications"]["telegram"]["chat_id"]
|
||||||
bot_url = f"https://api.telegram.org/bot{token}/sendMessage"
|
bot_url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||||
info(f"Got {len(transactions)} new transactions, sending message to Telegram")
|
info(f"Got {len(transactions)} new transactions, sending message to Telegram")
|
||||||
message = "*💲 [Leggen](https://github.com/elisiariocouto/leggen)*\n"
|
message = "*💲 [Leggen](https://github.com/elisiariocouto/leggen)*\n"
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ class DatabaseService:
|
|||||||
await self._migrate_balance_timestamps_if_needed()
|
await self._migrate_balance_timestamps_if_needed()
|
||||||
await self._migrate_null_transaction_ids_if_needed()
|
await self._migrate_null_transaction_ids_if_needed()
|
||||||
await self._migrate_to_composite_key_if_needed()
|
await self._migrate_to_composite_key_if_needed()
|
||||||
|
await self._migrate_add_display_name_if_needed()
|
||||||
|
|
||||||
async def _migrate_balance_timestamps_if_needed(self):
|
async def _migrate_balance_timestamps_if_needed(self):
|
||||||
"""Check and migrate balance timestamps if needed"""
|
"""Check and migrate balance timestamps if needed"""
|
||||||
@@ -632,6 +633,79 @@ class DatabaseService:
|
|||||||
logger.error(f"Composite key migration failed: {e}")
|
logger.error(f"Composite key migration failed: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def _migrate_add_display_name_if_needed(self):
|
||||||
|
"""Check and add display_name column to accounts table if needed"""
|
||||||
|
try:
|
||||||
|
if await self._check_display_name_migration_needed():
|
||||||
|
logger.info("Display name column migration needed, starting...")
|
||||||
|
await self._migrate_add_display_name()
|
||||||
|
logger.info("Display name column migration completed")
|
||||||
|
else:
|
||||||
|
logger.info("Display name column already exists")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Display name column migration failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _check_display_name_migration_needed(self) -> bool:
|
||||||
|
"""Check if display_name column needs to be added to accounts table"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if accounts table exists
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'"
|
||||||
|
)
|
||||||
|
if not cursor.fetchone():
|
||||||
|
conn.close()
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if display_name column exists
|
||||||
|
cursor.execute("PRAGMA table_info(accounts)")
|
||||||
|
columns = cursor.fetchall()
|
||||||
|
|
||||||
|
# Check if display_name column exists
|
||||||
|
has_display_name = any(col[1] == "display_name" for col in columns)
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return not has_display_name
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to check display_name migration status: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _migrate_add_display_name(self):
|
||||||
|
"""Add display_name column to accounts table"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
logger.warning("Database file not found, skipping migration")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
logger.info("Adding display_name column to accounts table...")
|
||||||
|
|
||||||
|
# Add the display_name column
|
||||||
|
cursor.execute("""
|
||||||
|
ALTER TABLE accounts
|
||||||
|
ADD COLUMN display_name TEXT
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logger.info("Display name column migration completed successfully")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Display name column migration failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
def _unix_to_datetime_string(self, unix_timestamp: float) -> str:
|
def _unix_to_datetime_string(self, unix_timestamp: float) -> str:
|
||||||
"""Convert Unix timestamp to datetime string"""
|
"""Convert Unix timestamp to datetime string"""
|
||||||
dt = datetime.fromtimestamp(unix_timestamp)
|
dt = datetime.fromtimestamp(unix_timestamp)
|
||||||
@@ -1045,7 +1119,8 @@ class DatabaseService:
|
|||||||
currency TEXT,
|
currency TEXT,
|
||||||
created DATETIME,
|
created DATETIME,
|
||||||
last_accessed DATETIME,
|
last_accessed DATETIME,
|
||||||
last_updated DATETIME
|
last_updated DATETIME,
|
||||||
|
display_name TEXT
|
||||||
)"""
|
)"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1060,6 +1135,16 @@ class DatabaseService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# First, check if account exists and preserve display_name
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT display_name FROM accounts WHERE id = ?", (account_data["id"],)
|
||||||
|
)
|
||||||
|
existing_row = cursor.fetchone()
|
||||||
|
existing_display_name = existing_row[0] if existing_row else None
|
||||||
|
|
||||||
|
# Use existing display_name if not provided in account_data
|
||||||
|
display_name = account_data.get("display_name", existing_display_name)
|
||||||
|
|
||||||
# Insert or replace account data
|
# Insert or replace account data
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""INSERT OR REPLACE INTO accounts (
|
"""INSERT OR REPLACE INTO accounts (
|
||||||
@@ -1071,8 +1156,9 @@ class DatabaseService:
|
|||||||
currency,
|
currency,
|
||||||
created,
|
created,
|
||||||
last_accessed,
|
last_accessed,
|
||||||
last_updated
|
last_updated,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
display_name
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(
|
(
|
||||||
account_data["id"],
|
account_data["id"],
|
||||||
account_data["institution_id"],
|
account_data["institution_id"],
|
||||||
@@ -1083,6 +1169,7 @@ class DatabaseService:
|
|||||||
account_data["created"],
|
account_data["created"],
|
||||||
account_data.get("last_accessed"),
|
account_data.get("last_accessed"),
|
||||||
account_data.get("last_updated", account_data["created"]),
|
account_data.get("last_updated", account_data["created"]),
|
||||||
|
display_name,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ class NotificationService:
|
|||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Filter transactions based on notification criteria"""
|
"""Filter transactions based on notification criteria"""
|
||||||
matching = []
|
matching = []
|
||||||
filters_case_insensitive = self.filters_config.get("case-insensitive", [])
|
filters_case_insensitive = self.filters_config.get("case_insensitive", [])
|
||||||
filters_case_sensitive = self.filters_config.get("case-sensitive", [])
|
filters_case_sensitive = self.filters_config.get("case_sensitive", [])
|
||||||
|
|
||||||
for transaction in transactions:
|
for transaction in transactions:
|
||||||
description = transaction.get("description", "")
|
description = transaction.get("description", "")
|
||||||
@@ -159,8 +159,8 @@ class NotificationService:
|
|||||||
ctx.obj = {
|
ctx.obj = {
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"api-key": telegram_config.get("token"),
|
"token": telegram_config.get("token"),
|
||||||
"chat-id": telegram_config.get("chat_id"),
|
"chat_id": telegram_config.get("chat_id"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,8 +219,8 @@ class NotificationService:
|
|||||||
ctx.obj = {
|
ctx.obj = {
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"api-key": telegram_config.get("token"),
|
"token": telegram_config.get("token"),
|
||||||
"chat-id": telegram_config.get("chat_id"),
|
"chat_id": telegram_config.get("chat_id"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,8 +277,8 @@ class NotificationService:
|
|||||||
ctx.obj = {
|
ctx.obj = {
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"api-key": telegram_config.get("token"),
|
"token": telegram_config.get("token"),
|
||||||
"chat-id": telegram_config.get("chat_id"),
|
"chat_id": telegram_config.get("chat_id"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def send_notification(ctx: click.Context, transactions: list):
|
|||||||
warning("No filters are enabled, skipping notifications")
|
warning("No filters are enabled, skipping notifications")
|
||||||
return
|
return
|
||||||
|
|
||||||
filters_case_insensitive = ctx.obj.get("filters", {}).get("case-insensitive", {})
|
filters_case_insensitive = ctx.obj.get("filters", {}).get("case_insensitive", {})
|
||||||
|
|
||||||
# Add transaction to the list of transactions to be sent as a notification
|
# Add transaction to the list of transactions to be sent as a notification
|
||||||
notification_transactions = []
|
notification_transactions = []
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "leggen"
|
name = "leggen"
|
||||||
version = "2025.9.10"
|
version = "2025.9.16"
|
||||||
description = "An Open Banking CLI"
|
description = "An Open Banking CLI"
|
||||||
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
||||||
requires-python = "~=3.13.0"
|
requires-python = "~=3.13.0"
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ class SampleDataGenerator:
|
|||||||
currency TEXT,
|
currency TEXT,
|
||||||
created DATETIME,
|
created DATETIME,
|
||||||
last_accessed DATETIME,
|
last_accessed DATETIME,
|
||||||
last_updated DATETIME
|
last_updated DATETIME,
|
||||||
|
display_name TEXT
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -373,8 +374,8 @@ class SampleDataGenerator:
|
|||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR REPLACE INTO accounts
|
INSERT OR REPLACE INTO accounts
|
||||||
(id, institution_id, status, iban, name, currency, created, last_accessed, last_updated)
|
(id, institution_id, status, iban, name, currency, created, last_accessed, last_updated, display_name)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
account["id"],
|
account["id"],
|
||||||
@@ -386,6 +387,7 @@ class SampleDataGenerator:
|
|||||||
account["created"],
|
account["created"],
|
||||||
account["last_accessed"],
|
account["last_accessed"],
|
||||||
account["last_updated"],
|
account["last_updated"],
|
||||||
|
None, # display_name is initially None for sample data
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class TestAccountsAPI:
|
|||||||
"institution_id": "REVOLUT_REVOLT21",
|
"institution_id": "REVOLUT_REVOLT21",
|
||||||
"status": "READY",
|
"status": "READY",
|
||||||
"iban": "LT313250081177977789",
|
"iban": "LT313250081177977789",
|
||||||
|
"name": "Personal Account",
|
||||||
|
"display_name": None,
|
||||||
"created": "2024-02-13T23:56:00Z",
|
"created": "2024-02-13T23:56:00Z",
|
||||||
"last_accessed": "2025-09-01T09:30:00Z",
|
"last_accessed": "2025-09-01T09:30:00Z",
|
||||||
}
|
}
|
||||||
@@ -80,6 +82,8 @@ class TestAccountsAPI:
|
|||||||
"institution_id": "REVOLUT_REVOLT21",
|
"institution_id": "REVOLUT_REVOLT21",
|
||||||
"status": "READY",
|
"status": "READY",
|
||||||
"iban": "LT313250081177977789",
|
"iban": "LT313250081177977789",
|
||||||
|
"name": "Personal Account",
|
||||||
|
"display_name": None,
|
||||||
"created": "2024-02-13T23:56:00Z",
|
"created": "2024-02-13T23:56:00Z",
|
||||||
"last_accessed": "2025-09-01T09:30:00Z",
|
"last_accessed": "2025-09-01T09:30:00Z",
|
||||||
}
|
}
|
||||||
@@ -283,3 +287,58 @@ class TestAccountsAPI:
|
|||||||
response = api_client.get("/api/v1/accounts/nonexistent")
|
response = api_client.get("/api/v1/accounts/nonexistent")
|
||||||
|
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_update_account_display_name_success(
|
||||||
|
self, api_client, mock_config, mock_auth_token, mock_db_path
|
||||||
|
):
|
||||||
|
"""Test successful update of account display name."""
|
||||||
|
mock_account = {
|
||||||
|
"id": "test-account-123",
|
||||||
|
"institution_id": "REVOLUT_REVOLT21",
|
||||||
|
"status": "READY",
|
||||||
|
"iban": "LT313250081177977789",
|
||||||
|
"name": "Personal Account",
|
||||||
|
"display_name": None,
|
||||||
|
"created": "2024-02-13T23:56:00Z",
|
||||||
|
"last_accessed": "2025-09-01T09:30:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("leggen.utils.config.config", mock_config),
|
||||||
|
patch(
|
||||||
|
"leggen.api.routes.accounts.database_service.get_account_details_from_db",
|
||||||
|
return_value=mock_account,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"leggen.api.routes.accounts.database_service.persist_account_details",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
response = api_client.put(
|
||||||
|
"/api/v1/accounts/test-account-123",
|
||||||
|
json={"display_name": "My Custom Account Name"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["data"]["id"] == "test-account-123"
|
||||||
|
assert data["data"]["display_name"] == "My Custom Account Name"
|
||||||
|
|
||||||
|
def test_update_account_not_found(
|
||||||
|
self, api_client, mock_config, mock_auth_token, mock_db_path
|
||||||
|
):
|
||||||
|
"""Test updating non-existent account."""
|
||||||
|
with (
|
||||||
|
patch("leggen.utils.config.config", mock_config),
|
||||||
|
patch(
|
||||||
|
"leggen.api.routes.accounts.database_service.get_account_details_from_db",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
response = api_client.put(
|
||||||
|
"/api/v1/accounts/nonexistent",
|
||||||
|
json={"display_name": "New Name"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|||||||
@@ -216,8 +216,8 @@ class TestConfig:
|
|||||||
"""Test filters configuration access."""
|
"""Test filters configuration access."""
|
||||||
custom_config = {
|
custom_config = {
|
||||||
"filters": {
|
"filters": {
|
||||||
"case-insensitive": ["salary", "utility"],
|
"case_insensitive": ["salary", "utility"],
|
||||||
"case-sensitive": ["SpecificStore"],
|
"case_sensitive": ["SpecificStore"],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +225,6 @@ class TestConfig:
|
|||||||
config._config = custom_config
|
config._config = custom_config
|
||||||
|
|
||||||
filters = config.filters_config
|
filters = config.filters_config
|
||||||
assert "salary" in filters["case-insensitive"]
|
assert "salary" in filters["case_insensitive"]
|
||||||
assert "utility" in filters["case-insensitive"]
|
assert "utility" in filters["case_insensitive"]
|
||||||
assert "SpecificStore" in filters["case-sensitive"]
|
assert "SpecificStore" in filters["case_sensitive"]
|
||||||
|
|||||||
2
uv.lock
generated
@@ -220,7 +220,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "leggen"
|
name = "leggen"
|
||||||
version = "2025.9.10"
|
version = "2025.9.16"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "apscheduler" },
|
{ name = "apscheduler" },
|
||||||
|
|||||||