Compare commits

...

28 Commits

Author SHA1 Message Date
Elisiário Couto
b7d6cf8128 chore(ci): Bump version to 2025.9.16 2025-09-18 23:29:53 +01:00
copilot-swe-agent[bot]
6589c2dd66 fix(frontend): Add iOS safe area support for PWA sticky header
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-18 23:28:49 +01:00
Elisiário Couto
571072f6ac chore(ci): Bump version to 2025.9.15 2025-09-18 23:03:01 +01:00
Elisiário Couto
be4f7f8cec refactor(frontend): Simplify filter bar UI and remove advanced filters popover.
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 23:01:51 +01:00
Elisiário Couto
056c33b9c5 feat(frontend): Add settings page with account management functionality.
Added comprehensive settings page with account settings component, integrated with existing layout and routing
structure. Updated project documentation with frontend architecture details.

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

Co-Authored-By: Claude <noreply@anthropic.com>")
2025-09-18 22:19:36 +01:00
Elisiário Couto
02c4f5c6ef chore(ci): Bump version to 2025.9.14 2025-09-18 11:49:36 +01:00
Elisiário Couto
30d7c2ed4e chore(ci): Prevent double GitHub Actions runs on new releases. 2025-09-18 11:21:04 +01:00
Elisiário Couto
61442a598f fix(config): Remove aliases for configuration keys that were disabling telegram notifications in some cases. 2025-09-18 11:09:43 +01:00
Elisiário Couto
b7da446fa5 chore(ci): Bump version to 2025.9.13 2025-09-17 23:29:02 +01:00
Elisiário Couto
5a626b5394 chore: Enable browsermcp and shadcn MCP servers. 2025-09-17 23:27:14 +01:00
Elisiário Couto
d9a39c30ab feat(frontend): Update analytics cards to match home page design consistency.
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 22:23:34 +01:00
Elisiário Couto
155a48d7dc fix(frontend): Remove broken running balance feature in transactions table. 2025-09-17 22:16:13 +01:00
Elisiário Couto
8ab760815c fix(frontend): Resolve dual scroll and excessive whitespace issues on transactions page.
- Change root layout from h-screen to min-h-screen to prevent height conflicts
- Remove overflow-hidden and overflow-y-auto from main container to eliminate competing scroll contexts
- Streamline TransactionsTable layout by removing unnecessary overflow wrappers
- Add max-w-full constraint to prevent horizontal overflow issues

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 22:11:03 +01:00
Elisiário Couto
2825dba2e9 feat(frontend): Update brand identity with new logo and color scheme.
- Add new Logo component with gradient design (blue #0b74de to cyan #06b6d4)
- Replace CreditCard icon with custom Logo in sidebar
- Update primary and secondary theme colors to match brand gradient
- Regenerate all PWA icons with new logo design
- Update theme colors in PWA manifest and meta tags
- Fix ESLint config to ignore generated PWA files

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 21:58:46 +01:00
copilot-swe-agent[bot]
3049a8cd2f feat(frontend): Add PWA install prompts, update notifications, and app shortcuts
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-17 21:58:46 +01:00
copilot-swe-agent[bot]
86891441d6 feat(frontend): Add comprehensive PWA capabilities with dynamic theme support
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-17 21:58:46 +01:00
Elisiário Couto
81d7d16301 fix(frontend): Add index signature to PieDataPoint interface.
Resolves TypeScript error where PieDataPoint[] was not assignable to
ChartDataInput[] by adding the required string index signature.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-16 18:30:36 +01:00
Elisiário Couto
84e609a774 refactor(frontend): Replace LoadingSpinner with shadcn skeleton components.
- Created AccountsSkeleton.tsx and NotificationsSkeleton.tsx components
- Updated AccountsOverview.tsx and Notifications.tsx to use skeletons
- Removed unused LoadingSpinner.tsx component
- Improved loading state UX by showing content structure

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-15 01:30:34 +01:00
99 changed files with 11645 additions and 2332 deletions

View File

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

View File

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

@@ -164,3 +164,4 @@ sql/
leggen.db leggen.db
*.db *.db
config.toml config.toml
.claude/

17
.mcp.json Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 813 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 B

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -1,197 +0,0 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
CreditCard,
TrendingUp,
Activity,
Menu,
X,
Home,
List,
BarChart3,
Wifi,
WifiOff,
Bell,
} from "lucide-react";
import { apiClient } from "../lib/api";
import AccountsOverview from "./AccountsOverview";
import 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1,2 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = []

View File

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

View File

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

View File

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

View File

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

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