Compare commits

...

146 Commits

Author SHA1 Message Date
Elisiário Couto
b32853e8fd chore(ci): Bump version to 2025.10.1 2025-10-06 00:06:07 +01:00
Elisiário Couto
0750c41b7b docs: Improve documentation, add gif showing web app. 2025-10-06 00:02:56 +01:00
Elisiário Couto
1cd63731a3 fix(frontend): Fix PWA caching system, remove prompts. 2025-10-05 23:15:29 +01:00
Elisiário Couto
38fddeb281 refactor(frontend): Standardize button styling using shadcn Button component.
Replace inconsistent native HTML button elements with shadcn/ui Button
component across all components for consistent styling and behavior.

Changes:
- TransactionsTable: Use Button with ghost variant for Raw action buttons
- Settings: Standardize edit, save, cancel, and delete buttons with icon variant
- AccountsOverview: Apply consistent Button component for account actions
- AccountSettings: Update account editing buttons to use Button component
- NotificationFiltersDrawer: Convert filter removal buttons to Button component

Benefits:
- Consistent design system throughout the app
- Better accessibility and keyboard navigation
- Proper theme support and state handling
- Reduced custom CSS and improved maintainability
2025-10-05 23:14:19 +01:00
Elisiário Couto
0205e5be0d chore(ci): Bump version to 2025.10.0 2025-10-01 11:30:48 +01:00
Elisiário Couto
ca7968cc3c fix(gocardless): Increase timeout to 30 seconds, some requests take some time. 2025-10-01 11:05:52 +01:00
Elisiário Couto
e6da6ee9ab chore(ci): Bump version to 2025.9.26 2025-09-30 14:09:57 +01:00
Elisiário Couto
8802d24789 debug: Log different sets of GoCardless rate limits. 2025-09-30 14:07:10 +01:00
Elisiário Couto
d3954f079b chore(ci): Bump version to 2025.9.25 2025-09-30 10:49:00 +01:00
Elisiário Couto
0b68038739 Lock dependencies before commiting next version. 2025-09-30 10:48:49 +01:00
Elisiário Couto
d36568da54 chore: Log more rate limit headers. 2025-09-30 10:46:13 +01:00
Elisiário Couto
473f126d3e feat(frontend): Add ability to list backups and create a backup on demand. 2025-09-28 23:23:44 +01:00
Elisiário Couto
222bb2ec64 Lint and reformat. 2025-09-28 23:23:44 +01:00
Elisiário Couto
22ec0e36b1 fix(api): Fix S3 backup path-style configuration and improve UX.
- Fix critical S3 client configuration bug for path-style addressing
- Add toast notifications for better user feedback on S3 config operations
- Set up Toaster component in root layout for app-wide notifications
- Clean up unused imports in test files

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 23:23:44 +01:00
copilot-swe-agent[bot]
0122913052 feat(frontend): Add S3 backup UI and complete backup functionality
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-28 23:23:44 +01:00
copilot-swe-agent[bot]
7f2a4634c5 feat(api): Add S3 backup functionality to backend
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-28 23:23:44 +01:00
Elisiário Couto
704c3d4cb7 chore(ci): Bump version to 2025.9.24 2025-09-25 12:10:40 +01:00
Elisiário Couto
ef7c026db9 feat(frontend): Add comprehensive bank account management system.
- Add drawer-based bank account connection flow with country/bank selection
- Create bank connection success page with redirect handling
- Add bank connections status card showing all requisitions and their states
- Move account management actions to appropriate sections (add to connections, edit in accounts)
- Implement proper delete functionality for bank connections via GoCardless API
- Add proper TypeScript types for all bank-related data structures
- Improve error handling for bank connection operations with specific HTTP status codes
- Remove transaction data when disconnecting accounts while preserving history

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-25 11:31:15 +01:00
Elisiário Couto
dc3522220a chore(ci): Bump version to 2025.9.23 2025-09-25 00:34:45 +01:00
Elisiário Couto
1693b3a50d Resolve test issues. 2025-09-25 00:02:42 +01:00
Elisiário Couto
460c5af6ea fix: Correct sync trigger types from manual to scheduled/retry.
Fixed scheduled syncs being incorrectly saved as "manual" in database.
Now properly identifies scheduled syncs as "scheduled" and retry
attempts as "retry". Updated frontend to capitalize trigger type
badges for better display.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 23:58:43 +01:00
Elisiário Couto
5a8614e019 Small fixes. 2025-09-24 23:52:51 +01:00
Elisiário Couto
ae5d034d4b fix(cli): Fix API URL handling for subpaths and improve client robustness.
- Automatically append /api/v1 to base URL if not present
- Fix URL construction to handle subpaths correctly
- Update health check to parse new nested response format
- Refactor bank delete command to use API client instead of direct requests
- Remove redundant /api/v1 prefixes from endpoint calls

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 23:52:34 +01:00
copilot-swe-agent[bot]
d4edf69f2c feat(frontend): Add version-based cache invalidation for PWA updates
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-24 21:46:12 +01:00
Elisiário Couto
d3a1696d4d chore(ci): Bump version to 2025.9.22 2025-09-24 20:08:20 +01:00
Elisiário Couto
24792744f9 fix(api): Fix banks API test fixtures to match GoCardless response format.
Updated test fixtures to correctly mock GoCardless API response format
with "results" key for institutions data. Fixed API client test to use
processed institutions data instead of raw GoCardless format.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 20:04:44 +01:00
Elisiário Couto
b9ca74e7e6 feat(api): Add bank logo support and fix banks endpoint type errors.
Backend changes:
- Add logo field to AccountDetails model
- Update accounts API endpoints to include logo data
- Add database migration for logo column in accounts table
- Implement institution details fetching from GoCardless API
- Enrich account data with institution logos during sync
- Fix type errors in banks endpoint with proper response parsing

Frontend changes:
- Add failedImages state to track logo loading failures
- Implement conditional rendering to show bank logos when available
- Add proper error handling with fallback to Building2 icon
- Fix image sizing to w-6 h-6 sm:w-8 sm:h-8 for proper display
- Update Account interface to include optional logo field
- Remove unused useState import from System component

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 19:57:03 +01:00
Elisiário Couto
a8f704129b chore: Add pre-commit instructions to AGENTS.md. 2025-09-24 15:20:50 +01:00
Elisiário Couto
62cd55e48f feat(frontend): Improve System page and TransactionsTable UX.
System page improvements:
- Add View Logs button to each sync operation with modal dialog
- Implement responsive design for mobile devices
- Remove redundant error count indicators
- Show full transaction text on mobile ("X new transactions")

TransactionsTable improvements:
- Use display_name instead of name • institution_id format
- Show only clean account display names in transaction rows

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 15:20:08 +01:00
Elisiário Couto
e4e3f885ea feat(api): Add separate sync failure notifications.
- Create dedicated sync failure notification templates for Telegram and Discord
- Add send_sync_failure_notification method to NotificationService
- Update scheduler to use proper notification method instead of expiry notifications
- Telegram: Shows error details with retry count and failure status
- Discord: Color-coded embeds (orange for retries, red for final failures)
- Fixes KeyError: 'bank' when sync failures occur

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 15:04:01 +01:00
Elisiário Couto
36d698f7ce fix(api): Add automatic token refresh on 401 errors in GoCardless service.
- Add _make_authenticated_request helper that automatically handles 401 errors
- Clear token cache and retry once when encountering expired tokens
- Refactor all API methods to use centralized request handling
- Fix banks API to properly handle institutions response structure
- Eliminates need for container restarts when tokens expire

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 14:58:45 +01:00
Elisiário Couto
d211a14703 chore(ci): Bump version to 2025.9.21 2025-09-23 00:50:13 +01:00
Elisiário Couto
c332642e64 feat(frontend): Implement notification settings with separate drawers and improved design.
- Add shadcn/ui drawer and switch components
- Create NotificationFiltersDrawer for editing notification filters
- Create DiscordConfigDrawer with test functionality
- Create TelegramConfigDrawer with test functionality
- Add reusable EditButton component for consistent design language
- Refactor Settings page to use separate drawers per configuration type
- Remove test notifications card, integrate testing into service drawers
- Simplify notification service status indicators for cleaner UI
- Remove redundant service descriptions for streamlined layout

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-23 00:49:07 +01:00
copilot-swe-agent[bot]
27f3f2dbba fix(frontend): Remove duplicate padding from Analytics page for consistent layout
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-22 23:25:10 +01:00
Elisiário Couto
02748181b9 chore(ci): Bump version to 2025.9.20 2025-09-22 23:12:34 +01:00
Elisiário Couto
dcb1f39ff1 Use git-cliff action. 2025-09-22 23:12:00 +01:00
Elisiário Couto
eb38264c68 Reformat files. 2025-09-22 23:01:55 +01:00
Elisiário Couto
65404848aa refactor(frontend): Reorganize pages with tabbed Settings and focused System page
- Create new tabbed Settings component combining accounts and notifications
- Extract sync operations into dedicated System component
- Update routing: /notifications → /system with proper navigation labels
- Remove duplicate page headers (using existing SiteHeader)
- Add shadcn tabs component for better UX
- Fix mypy error in database_service.py (handle None lastrowid)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-22 23:01:55 +01:00
copilot-swe-agent[bot]
3f2ff21eac feat(frontend): Rename notifications page to System Status and add sync operations section
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-22 23:01:55 +01:00
copilot-swe-agent[bot]
61f9592095 feat(api): Add sync operations tracking and database storage
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-22 23:01:55 +01:00
Elisiário Couto
76a30d23af feat: Consolidate version display to use health endpoint. 2025-09-22 18:43:53 +01:00
Elisiário Couto
e9924e9d96 chore(ci): Bump version to 2025.9.19 2025-09-22 00:38:36 +01:00
copilot-swe-agent[bot]
340e1a3235 feat(frontend): Add version display in header near connection status
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-22 00:36:36 +01:00
copilot-swe-agent[bot]
4ce56fdc04 fix(frontend): Resolve mobile horizontal scroll in Time Period filters
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-21 23:52:42 +01:00
copilot-swe-agent[bot]
dd24a0e0d3 fix(frontend): Close mobile sidebar on navigation item click
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-21 23:39:43 +01:00
Elisiário Couto
ff9bccc0e9 chore(ci): Bump version to 2025.9.18 2025-09-19 11:25:10 +01:00
Elisiário Couto
83bb3fcef2 docs: Add instructions for shadcn/ui. 2025-09-19 11:24:49 +01:00
Elisiário Couto
fbb9e33279 feat(frontend): Transform layout to use shadcn dashboard-01 with iOS PWA safe area support.
- Replace custom layout with modern SidebarProvider/SidebarInset structure
- Add AppSidebar component using shadcn patterns with preserved account summary
- Add SiteHeader component with SidebarTrigger integration
- Install shadcn sidebar, separator, and related UI components
- Fix iOS PWA safe area issues - sidebar no longer overlaps dynamic island/notch
- Add pt-safe-top and pl-safe-left classes to sidebar and header components
- Remove legacy Sidebar.tsx and Header.tsx components
- Preserve all existing functionality: navigation, API health status, theme toggle, PWA features
- Improve mobile responsive behavior with built-in shadcn drawer patterns

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-19 11:17:24 +01:00
Elisiário Couto
8228974c0c chore(ci): Bump version to 2025.9.17 2025-09-18 23:45:10 +01:00
Elisiário Couto
848eccb35b chore: Format files. 2025-09-18 23:43:08 +01:00
Elisiário Couto
25747d7d37 fix(api): Prevent duplicate notifications for existing transactions during sync.
The notification system was incorrectly sending notifications for existing
transactions that were being updated during sync operations. This change
modifies the transaction persistence logic to only return genuinely new
transactions, preventing duplicate notifications while maintaining data
integrity through INSERT OR REPLACE.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This resolves the issue where GoCardless returns different internalTransactionId
values for the same transaction across sync operations, causing duplicates.
2025-09-10 20:00:43 +01:00
Elisiário Couto
433ba3faf9 feat(web): Add modal to view raw transaction. 2025-09-10 19:57:38 +01:00
Elisiário Couto
da6c7bbf3e chore(ci): Bump version to 2025.9.3 2025-09-10 01:21:49 +01:00
Elisiário Couto
90e58734ad chore(ci): Fix GitHub Actions syntax. 2025-09-10 01:21:39 +01:00
Elisiário Couto
03e16a9b54 chore(ci): Bump version to 2025.9.2 2025-09-10 01:12:08 +01:00
Elisiário Couto
53e08e8e4b fix(ci): Prevent duplicate Docker tags in GitHub Actions
- Add latest=false flavor to both backend and frontend jobs
- Fix confusion between latest and latest-frontend tags
- Ensure proper image separation in Docker registries
2025-09-10 01:11:38 +01:00
Elisiário Couto
84fe79b37b feat(docker): Add Docker containerization for React frontend
- Add production compose.yml using published ghcr.io images
- Rename compose.yml to compose.dev.yml for development
- Create config.example.toml configuration template
- Update README.md with Docker setup instructions
- Use ./data directory for configuration and database storage
- Separate development and production Docker workflows
2025-09-10 00:53:49 +01:00
Elisiário Couto
1a6578100a chore(ci): Bump version to 2025.9.1 2025-09-10 00:40:37 +01:00
Elisiário Couto
3270dc4585 chore: Improve AGENTS.md. 2025-09-10 00:39:46 +01:00
Elisiário Couto
8fabaf7b86 fix: handle duplicate transactionId values in migration
- Fix UNIQUE constraint violation in null transaction ID migration
- Generate unique IDs for records with duplicate transactionId values
- Use pattern: original_transactionId + '_' + 8_char_hex_suffix
- Successfully migrated records with duplicate IDs
- All transaction records now have valid internalTransactionId values

The migration now handles cases where multiple transactions have the same
transactionId in their raw data by generating unique identifiers.
2025-09-10 00:39:46 +01:00
Elisiário Couto
8006e5e1f6 refactor: remove unused hide_missing_ids functionality
- Remove hide_missing_ids parameter from all database functions
- Remove hide_missing_ids from API routes and query parameters
- Remove hide_missing_ids filtering logic from SQLite queries
- Update all tests to remove hide_missing_ids assertions
- Clean up codebase since internalTransactionId extraction is now fixed

This functionality was added as a workaround for missing internalTransactionId
values, but we've now fixed the root cause by properly extracting transaction
IDs from raw data during sync, making this workaround unnecessary.
2025-09-10 00:39:45 +01:00
177 changed files with 27529 additions and 4303 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": []
}
}

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

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

View File

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

4
.gitignore vendored
View File

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

12
.mcp.json Normal file
View File

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

View File

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

104
AGENTS.md
View File

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

View File

@@ -1,4 +1,757 @@
## 2025.10.1 (2025/10/05)
### Bug Fixes
- **frontend:** Fix PWA caching system, remove prompts. ([1cd63731](https://github.com/elisiariocouto/leggen/commit/1cd63731a35a1c77a59d7ae1a898ad8f22e362e4))
### Documentation
- Improve documentation, add gif showing web app. ([0750c41b](https://github.com/elisiariocouto/leggen/commit/0750c41b7b6634900ec19b1701d58b06346028e3))
### Refactor
- **frontend:** Standardize button styling using shadcn Button component. ([38fddeb2](https://github.com/elisiariocouto/leggen/commit/38fddeb281588de41d8ff6292c1dd48443a059a4))
## 2025.10.0 (2025/10/01)
### Bug Fixes
- **gocardless:** Increase timeout to 30 seconds, some requests take some time. ([ca7968cc](https://github.com/elisiariocouto/leggen/commit/ca7968cc3c625e243fe2d75590a9e56f3100072b))
## 2025.9.26 (2025/09/30)
### Debug
- Log different sets of GoCardless rate limits. ([8802d247](https://github.com/elisiariocouto/leggen/commit/8802d24789cbb8e854d857a0d7cc89a25a26f378))
## 2025.9.25 (2025/09/30)
### Bug Fixes
- **api:** Fix S3 backup path-style configuration and improve UX. ([22ec0e36](https://github.com/elisiariocouto/leggen/commit/22ec0e36b11e5b017075bee51de0423a53ec4648))
### Features
- **api:** Add S3 backup functionality to backend ([7f2a4634](https://github.com/elisiariocouto/leggen/commit/7f2a4634c51814b6785433a25ce42d20aea0558c))
- **frontend:** Add S3 backup UI and complete backup functionality ([01229130](https://github.com/elisiariocouto/leggen/commit/0122913052793bcbf011cb557ef182be21c5de93))
- **frontend:** Add ability to list backups and create a backup on demand. ([473f126d](https://github.com/elisiariocouto/leggen/commit/473f126d3e699521172539f2ca0bff0579ccee51))
### Miscellaneous Tasks
- Log more rate limit headers. ([d36568da](https://github.com/elisiariocouto/leggen/commit/d36568da540d4fb4ae1fa10b322a3fa77dcc5360))
## 2025.9.24 (2025/09/25)
### Features
- **frontend:** Add comprehensive bank account management system. ([ef7c026d](https://github.com/elisiariocouto/leggen/commit/ef7c026db9911cc3be8d5f48e42a4d7beb4b9d0a))
## 2025.9.24 (2025/09/25)
### Features
- **frontend:** Add comprehensive bank account management system. ([ef7c026d](https://github.com/elisiariocouto/leggen/commit/ef7c026db9911cc3be8d5f48e42a4d7beb4b9d0a))
## 2025.9.23 (2025/09/24)
### Bug Fixes
- **cli:** Fix API URL handling for subpaths and improve client robustness. ([ae5d034d](https://github.com/elisiariocouto/leggen/commit/ae5d034d4b1da785e3dc240c1d60c2cae7de8010))
- Correct sync trigger types from manual to scheduled/retry. ([460c5af6](https://github.com/elisiariocouto/leggen/commit/460c5af6ea343ef5685b716413d01d7a30fa9acf))
### Features
- **frontend:** Add version-based cache invalidation for PWA updates ([d4edf69f](https://github.com/elisiariocouto/leggen/commit/d4edf69f2cea2515a00435ee974116948057148d))
## 2025.9.23 (2025/09/24)
### Bug Fixes
- **cli:** Fix API URL handling for subpaths and improve client robustness. ([ae5d034d](https://github.com/elisiariocouto/leggen/commit/ae5d034d4b1da785e3dc240c1d60c2cae7de8010))
- Correct sync trigger types from manual to scheduled/retry. ([460c5af6](https://github.com/elisiariocouto/leggen/commit/460c5af6ea343ef5685b716413d01d7a30fa9acf))
### Features
- **frontend:** Add version-based cache invalidation for PWA updates ([d4edf69f](https://github.com/elisiariocouto/leggen/commit/d4edf69f2cea2515a00435ee974116948057148d))
## 2025.9.22 (2025/09/24)
### Bug Fixes
- **api:** Add automatic token refresh on 401 errors in GoCardless service. ([36d698f7](https://github.com/elisiariocouto/leggen/commit/36d698f7ce05c7db0e4b07dd07979de2c70b053e))
- **api:** Fix banks API test fixtures to match GoCardless response format. ([24792744](https://github.com/elisiariocouto/leggen/commit/24792744f9660063e1a3abb9ed8e925fea9a5e60))
### Features
- **api:** Add separate sync failure notifications. ([e4e3f885](https://github.com/elisiariocouto/leggen/commit/e4e3f885eab1d45b0e10465ca04eb3f74e9c5a4d))
- **api:** Add bank logo support and fix banks endpoint type errors. ([b9ca74e7](https://github.com/elisiariocouto/leggen/commit/b9ca74e7e67c3877728b749a42f15f0c0d906561))
- **frontend:** Improve System page and TransactionsTable UX. ([62cd55e4](https://github.com/elisiariocouto/leggen/commit/62cd55e48fff7c2f5db9dd8230a7bd500e8f6eed))
### Miscellaneous Tasks
- Add pre-commit instructions to AGENTS.md. ([a8f70412](https://github.com/elisiariocouto/leggen/commit/a8f704129b2453e604cf2ab776791ba1e91e6fc7))
## 2025.9.22 (2025/09/24)
### Bug Fixes
- **api:** Add automatic token refresh on 401 errors in GoCardless service. ([36d698f7](https://github.com/elisiariocouto/leggen/commit/36d698f7ce05c7db0e4b07dd07979de2c70b053e))
- **api:** Fix banks API test fixtures to match GoCardless response format. ([24792744](https://github.com/elisiariocouto/leggen/commit/24792744f9660063e1a3abb9ed8e925fea9a5e60))
### Features
- **api:** Add separate sync failure notifications. ([e4e3f885](https://github.com/elisiariocouto/leggen/commit/e4e3f885eab1d45b0e10465ca04eb3f74e9c5a4d))
- **api:** Add bank logo support and fix banks endpoint type errors. ([b9ca74e7](https://github.com/elisiariocouto/leggen/commit/b9ca74e7e67c3877728b749a42f15f0c0d906561))
- **frontend:** Improve System page and TransactionsTable UX. ([62cd55e4](https://github.com/elisiariocouto/leggen/commit/62cd55e48fff7c2f5db9dd8230a7bd500e8f6eed))
### Miscellaneous Tasks
- Add pre-commit instructions to AGENTS.md. ([a8f70412](https://github.com/elisiariocouto/leggen/commit/a8f704129b2453e604cf2ab776791ba1e91e6fc7))
## 2025.9.21 (2025/09/22)
### Bug Fixes
- **frontend:** Remove duplicate padding from Analytics page for consistent layout ([27f3f2db](https://github.com/elisiariocouto/leggen/commit/27f3f2dbba91777234769cca08de5dbe8b378f10))
### Features
- **frontend:** Implement notification settings with separate drawers and improved design. ([c332642e](https://github.com/elisiariocouto/leggen/commit/c332642e648cb0a29100b500c03e17ae322845f8))
## 2025.9.21 (2025/09/22)
### Bug Fixes
- **frontend:** Remove duplicate padding from Analytics page for consistent layout ([27f3f2db](https://github.com/elisiariocouto/leggen/commit/27f3f2dbba91777234769cca08de5dbe8b378f10))
### Features
- **frontend:** Implement notification settings with separate drawers and improved design. ([c332642e](https://github.com/elisiariocouto/leggen/commit/c332642e648cb0a29100b500c03e17ae322845f8))
## 2025.9.20 (2025/09/22)
### Features
- **api:** Add sync operations tracking and database storage ([61f95920](https://github.com/elisiariocouto/leggen/commit/61f9592095220f47b758e19a63d70096deb35a92))
- **frontend:** Rename notifications page to System Status and add sync operations section ([3f2ff21e](https://github.com/elisiariocouto/leggen/commit/3f2ff21eac2c24e04d5957bbd15a6b8a5d0c021d))
- Consolidate version display to use health endpoint. ([76a30d23](https://github.com/elisiariocouto/leggen/commit/76a30d23af07466ecfd571e7b7bb6724412652c1))
### Refactor
- **frontend:** Reorganize pages with tabbed Settings and focused System page ([65404848](https://github.com/elisiariocouto/leggen/commit/65404848aa27cfcb11a371c194ca533b17cb08ff))
## 2025.9.20 (2025/09/22)
### Features
- **api:** Add sync operations tracking and database storage ([61f95920](https://github.com/elisiariocouto/leggen/commit/61f9592095220f47b758e19a63d70096deb35a92))
- **frontend:** Rename notifications page to System Status and add sync operations section ([3f2ff21e](https://github.com/elisiariocouto/leggen/commit/3f2ff21eac2c24e04d5957bbd15a6b8a5d0c021d))
- Consolidate version display to use health endpoint. ([76a30d23](https://github.com/elisiariocouto/leggen/commit/76a30d23af07466ecfd571e7b7bb6724412652c1))
### Refactor
- **frontend:** Reorganize pages with tabbed Settings and focused System page ([65404848](https://github.com/elisiariocouto/leggen/commit/65404848aa27cfcb11a371c194ca533b17cb08ff))
## 2025.9.19 (2025/09/21)
### Bug Fixes
- **frontend:** Close mobile sidebar on navigation item click ([dd24a0e0](https://github.com/elisiariocouto/leggen/commit/dd24a0e0d34c3b2ff37bc75b50162768b4d15cc5))
- **frontend:** Resolve mobile horizontal scroll in Time Period filters ([4ce56fdc](https://github.com/elisiariocouto/leggen/commit/4ce56fdc042b0dbf3442a1ab201392700add90d6))
### Features
- **frontend:** Add version display in header near connection status ([340e1a32](https://github.com/elisiariocouto/leggen/commit/340e1a3235916566a4e403e9ec7b82ea799fbffd))
## 2025.9.19 (2025/09/21)
### Bug Fixes
- **frontend:** Close mobile sidebar on navigation item click ([dd24a0e0](https://github.com/elisiariocouto/leggen/commit/dd24a0e0d34c3b2ff37bc75b50162768b4d15cc5))
- **frontend:** Resolve mobile horizontal scroll in Time Period filters ([4ce56fdc](https://github.com/elisiariocouto/leggen/commit/4ce56fdc042b0dbf3442a1ab201392700add90d6))
### Features
- **frontend:** Add version display in header near connection status ([340e1a32](https://github.com/elisiariocouto/leggen/commit/340e1a3235916566a4e403e9ec7b82ea799fbffd))
## 2025.9.18 (2025/09/19)
### Documentation
- Add instructions for shadcn/ui. ([83bb3fce](https://github.com/elisiariocouto/leggen/commit/83bb3fcef20d21a210bc53ce77aa533d37771668))
### Features
- **frontend:** Transform layout to use shadcn dashboard-01 with iOS PWA safe area support. ([fbb9e332](https://github.com/elisiariocouto/leggen/commit/fbb9e33279028a6a7ccf46c3696a012ec16a9ca7))
## 2025.9.18 (2025/09/19)
### Documentation
- Add instructions for shadcn/ui. ([83bb3fce](https://github.com/elisiariocouto/leggen/commit/83bb3fcef20d21a210bc53ce77aa533d37771668))
### Features
- **frontend:** Transform layout to use shadcn dashboard-01 with iOS PWA safe area support. ([fbb9e332](https://github.com/elisiariocouto/leggen/commit/fbb9e33279028a6a7ccf46c3696a012ec16a9ca7))
## 2025.9.17 (2025/09/18)
### Bug Fixes
- **api:** Prevent duplicate notifications for existing transactions during sync. ([25747d7d](https://github.com/elisiariocouto/leggen/commit/25747d7d372e291090764a6814f9d8d0b76aea3b))
### Miscellaneous Tasks
- Format files. ([848eccb3](https://github.com/elisiariocouto/leggen/commit/848eccb35b910c8121d15611547dca8da0b12756))
## 2025.9.17 (2025/09/18)
### Bug Fixes
- **api:** Prevent duplicate notifications for existing transactions during sync. ([25747d7d](https://github.com/elisiariocouto/leggen/commit/25747d7d372e291090764a6814f9d8d0b76aea3b))
### Miscellaneous Tasks
- Format files. ([848eccb3](https://github.com/elisiariocouto/leggen/commit/848eccb35b910c8121d15611547dca8da0b12756))
## 2025.9.16 (2025/09/18)
### Bug Fixes
- **frontend:** Add iOS safe area support for PWA sticky header ([6589c2dd](https://github.com/elisiariocouto/leggen/commit/6589c2dd666f8605cf6d1bf9ad7277734d4cd302))
## 2025.9.16 (2025/09/18)
### Bug Fixes
- **frontend:** Add iOS safe area support for PWA sticky header ([6589c2dd](https://github.com/elisiariocouto/leggen/commit/6589c2dd666f8605cf6d1bf9ad7277734d4cd302))
## 2025.9.15 (2025/09/18)
### Features
- **frontend:** Add settings page with account management functionality. ([056c33b9](https://github.com/elisiariocouto/leggen/commit/056c33b9c5cfbc2842cc2dd4ca8c4e3959a2be80))
### Refactor
- **frontend:** Simplify filter bar UI and remove advanced filters popover. ([be4f7f8c](https://github.com/elisiariocouto/leggen/commit/be4f7f8cecfe2564abdf0ce1be08497e5a6d7b68))
## 2025.9.15 (2025/09/18)
### Features
- **frontend:** Add settings page with account management functionality. ([056c33b9](https://github.com/elisiariocouto/leggen/commit/056c33b9c5cfbc2842cc2dd4ca8c4e3959a2be80))
### Refactor
- **frontend:** Simplify filter bar UI and remove advanced filters popover. ([be4f7f8c](https://github.com/elisiariocouto/leggen/commit/be4f7f8cecfe2564abdf0ce1be08497e5a6d7b68))
## 2025.9.14 (2025/09/18)
### Bug Fixes
- **config:** Remove aliases for configuration keys that were disabling telegram notifications in some cases. ([61442a59](https://github.com/elisiariocouto/leggen/commit/61442a598fa7f38c568e3df7e1d924ed85df7491))
### Miscellaneous Tasks
- **ci:** Prevent double GitHub Actions runs on new releases. ([30d7c2ed](https://github.com/elisiariocouto/leggen/commit/30d7c2ed4e9aff144837a1f0ed67a8ded0b5d72a))
## 2025.9.14 (2025/09/18)
### Bug Fixes
- **config:** Remove aliases for configuration keys that were disabling telegram notifications in some cases. ([61442a59](https://github.com/elisiariocouto/leggen/commit/61442a598fa7f38c568e3df7e1d924ed85df7491))
### Miscellaneous Tasks
- **ci:** Prevent double GitHub Actions runs on new releases. ([30d7c2ed](https://github.com/elisiariocouto/leggen/commit/30d7c2ed4e9aff144837a1f0ed67a8ded0b5d72a))
## 2025.9.13 (2025/09/17)
### Bug Fixes
- **frontend:** Resolve linting issue in skeleton component ([fb310a59](https://github.com/elisiariocouto/leggen/commit/fb310a5953cf51d1cac181529311e76a0f4ea9ee))
- **frontend:** Add index signature to PieDataPoint interface. ([81d7d163](https://github.com/elisiariocouto/leggen/commit/81d7d16301dafc62a95f63036819565ffb90ddb5))
- **frontend:** Resolve dual scroll and excessive whitespace issues on transactions page. ([8ab76081](https://github.com/elisiariocouto/leggen/commit/8ab760815c9ae072b8c2cb2460e31144b193e0b3))
- **frontend:** Remove broken running balance feature in transactions table. ([155a48d7](https://github.com/elisiariocouto/leggen/commit/155a48d7dc86b3f453ba6f8c37edf63c0b76c755))
### Features
- **frontend:** Complete shadcn migration of skeleton and styling components ([c83386b1](https://github.com/elisiariocouto/leggen/commit/c83386b1d5b165910abe8b391ca483e5b48cd35f))
- **frontend:** Add comprehensive PWA capabilities with dynamic theme support ([86891441](https://github.com/elisiariocouto/leggen/commit/86891441d65e13757f343cabc39ccdb3ca6adc75))
- **frontend:** Add PWA install prompts, update notifications, and app shortcuts ([3049a8cd](https://github.com/elisiariocouto/leggen/commit/3049a8cd2fa80c14f970884fb14df2ab88c418dd))
- **frontend:** Update brand identity with new logo and color scheme. ([2825dba2](https://github.com/elisiariocouto/leggen/commit/2825dba2e944b3fe31aaa33127b770e7474ce021))
- **frontend:** Update analytics cards to match home page design consistency. ([d9a39c30](https://github.com/elisiariocouto/leggen/commit/d9a39c30ab1248a9fdacff068d401c3daff3f6a5))
### Miscellaneous Tasks
- Enable browsermcp and shadcn MCP servers. ([5a626b53](https://github.com/elisiariocouto/leggen/commit/5a626b53947f7e2d1544faf3ee06f8a0f1fb5d7a))
### Refactor
- **frontend:** Replace LoadingSpinner with shadcn skeleton components. ([84e609a7](https://github.com/elisiariocouto/leggen/commit/84e609a774ddc0caf9f84eaf1e8cdce021c82785))
## 2025.9.13 (2025/09/17)
### Bug Fixes
- **frontend:** Resolve linting issue in skeleton component ([fb310a59](https://github.com/elisiariocouto/leggen/commit/fb310a5953cf51d1cac181529311e76a0f4ea9ee))
- **frontend:** Add index signature to PieDataPoint interface. ([81d7d163](https://github.com/elisiariocouto/leggen/commit/81d7d16301dafc62a95f63036819565ffb90ddb5))
- **frontend:** Resolve dual scroll and excessive whitespace issues on transactions page. ([8ab76081](https://github.com/elisiariocouto/leggen/commit/8ab760815c9ae072b8c2cb2460e31144b193e0b3))
- **frontend:** Remove broken running balance feature in transactions table. ([155a48d7](https://github.com/elisiariocouto/leggen/commit/155a48d7dc86b3f453ba6f8c37edf63c0b76c755))
### Features
- **frontend:** Complete shadcn migration of skeleton and styling components ([c83386b1](https://github.com/elisiariocouto/leggen/commit/c83386b1d5b165910abe8b391ca483e5b48cd35f))
- **frontend:** Add comprehensive PWA capabilities with dynamic theme support ([86891441](https://github.com/elisiariocouto/leggen/commit/86891441d65e13757f343cabc39ccdb3ca6adc75))
- **frontend:** Add PWA install prompts, update notifications, and app shortcuts ([3049a8cd](https://github.com/elisiariocouto/leggen/commit/3049a8cd2fa80c14f970884fb14df2ab88c418dd))
- **frontend:** Update brand identity with new logo and color scheme. ([2825dba2](https://github.com/elisiariocouto/leggen/commit/2825dba2e944b3fe31aaa33127b770e7474ce021))
- **frontend:** Update analytics cards to match home page design consistency. ([d9a39c30](https://github.com/elisiariocouto/leggen/commit/d9a39c30ab1248a9fdacff068d401c3daff3f6a5))
### Miscellaneous Tasks
- Enable browsermcp and shadcn MCP servers. ([5a626b53](https://github.com/elisiariocouto/leggen/commit/5a626b53947f7e2d1544faf3ee06f8a0f1fb5d7a))
### Refactor
- **frontend:** Replace LoadingSpinner with shadcn skeleton components. ([84e609a7](https://github.com/elisiariocouto/leggen/commit/84e609a774ddc0caf9f84eaf1e8cdce021c82785))
## 2025.9.12 (2025/09/15)
## 2025.9.12 (2025/09/15)
## 2025.9.11 (2025/09/15)
### Bug Fixes
- **config:** Add Pydantic validation and fix telegram config field mappings. ([2c6e0995](https://github.com/elisiariocouto/leggen/commit/2c6e0995968c9c9917992fd15ec10a89933c0c21))
- **config:** Fix example config file. ([d09cf6d0](https://github.com/elisiariocouto/leggen/commit/d09cf6d04ccb6233981f273cd88e0b8ffe074d71))
- **docs:** Remove test files and update gitignore ([692bee57](https://github.com/elisiariocouto/leggen/commit/692bee574ee8de16496a3c733bad53be3b256990))
- **frontend:** Align balance calculation between sidebar and Analytics page ([35b6d98e](https://github.com/elisiariocouto/leggen/commit/35b6d98e6a37b1e9caf8a232ffe66380e7203cad))
- **frontend:** Add ignore rules for eslint on shadcn components. ([74a700ff](https://github.com/elisiariocouto/leggen/commit/74a700ff87b2504c3d394cddd9935c56c3c7a00d))
- Resolve all CI failures - linting, typing, and test issues ([c8f0a103](https://github.com/elisiariocouto/leggen/commit/c8f0a103c6ccdb722bbab1ac6973827b41fddc19))
### Features
- **analytics:** Fix transaction limits and improve chart legends ([e136fc4b](https://github.com/elisiariocouto/leggen/commit/e136fc4b75243b35a77bc0bf0260808006987d7a))
- **docs:** Add comprehensive copilot agent setup instructions ([c6ac4455](https://github.com/elisiariocouto/leggen/commit/c6ac4455f848dd429100dd3fc6d43de8c4e5aa6b))
- **docs:** Add configuration file setup to agent instructions ([482f16c7](https://github.com/elisiariocouto/leggen/commit/482f16c77eef1f477ba49475fe30f809de9a05d7))
- **frontend:** Enhance transactions page with advanced filtering and UI improvements. ([969776fb](https://github.com/elisiariocouto/leggen/commit/969776fb53261acca2f77b0c761584e201fde118))
- **frontend:** Replace heavy filter UI with modern shadcn/ui inline filter bar. ([eb27f191](https://github.com/elisiariocouto/leggen/commit/eb27f19196d92a6ae5220b81709fded499a12f4f))
- **frontend:** Complete shadcn/ui migration with dark mode support and analytics updates. ([66db34c7](https://github.com/elisiariocouto/leggen/commit/66db34c712300ff4b5dbe7e06246f16d6f6a8469))
### Miscellaneous Tasks
- Sort imports, fix deprecated pydantic option. ([2467cb2f](https://github.com/elisiariocouto/leggen/commit/2467cb2f5af07a7262b3221bf61b58ad4017659a))
- Check import order using ruff. ([da98b7b2](https://github.com/elisiariocouto/leggen/commit/da98b7b2b77c5b37792dedff11f8256da3b086f7))
### Refactor
- **analytics:** Simplify analytics endpoints and eliminate client-side processing. ([077e2bb1](https://github.com/elisiariocouto/leggen/commit/077e2bb1adbdb73ffde17635bd918cd40fe7fb5a))
- Unify leggen and leggend packages into single leggen package ([318ca517](https://github.com/elisiariocouto/leggen/commit/318ca517f7ea599b37a8deb47ad80218fbae008f))
- Consolidate database layer and eliminate wrapper complexity. ([5ae3a51d](https://github.com/elisiariocouto/leggen/commit/5ae3a51d8138b9aa28dbceabf575ab2577402e70))
## 2025.9.11 (2025/09/15)
### Bug Fixes
- **config:** Add Pydantic validation and fix telegram config field mappings. ([2c6e0995](https://github.com/elisiariocouto/leggen/commit/2c6e0995968c9c9917992fd15ec10a89933c0c21))
- **config:** Fix example config file. ([d09cf6d0](https://github.com/elisiariocouto/leggen/commit/d09cf6d04ccb6233981f273cd88e0b8ffe074d71))
- **docs:** Remove test files and update gitignore ([692bee57](https://github.com/elisiariocouto/leggen/commit/692bee574ee8de16496a3c733bad53be3b256990))
- **frontend:** Align balance calculation between sidebar and Analytics page ([35b6d98e](https://github.com/elisiariocouto/leggen/commit/35b6d98e6a37b1e9caf8a232ffe66380e7203cad))
- **frontend:** Add ignore rules for eslint on shadcn components. ([74a700ff](https://github.com/elisiariocouto/leggen/commit/74a700ff87b2504c3d394cddd9935c56c3c7a00d))
- Resolve all CI failures - linting, typing, and test issues ([c8f0a103](https://github.com/elisiariocouto/leggen/commit/c8f0a103c6ccdb722bbab1ac6973827b41fddc19))
### Features
- **analytics:** Fix transaction limits and improve chart legends ([e136fc4b](https://github.com/elisiariocouto/leggen/commit/e136fc4b75243b35a77bc0bf0260808006987d7a))
- **docs:** Add comprehensive copilot agent setup instructions ([c6ac4455](https://github.com/elisiariocouto/leggen/commit/c6ac4455f848dd429100dd3fc6d43de8c4e5aa6b))
- **docs:** Add configuration file setup to agent instructions ([482f16c7](https://github.com/elisiariocouto/leggen/commit/482f16c77eef1f477ba49475fe30f809de9a05d7))
- **frontend:** Enhance transactions page with advanced filtering and UI improvements. ([969776fb](https://github.com/elisiariocouto/leggen/commit/969776fb53261acca2f77b0c761584e201fde118))
- **frontend:** Replace heavy filter UI with modern shadcn/ui inline filter bar. ([eb27f191](https://github.com/elisiariocouto/leggen/commit/eb27f19196d92a6ae5220b81709fded499a12f4f))
- **frontend:** Complete shadcn/ui migration with dark mode support and analytics updates. ([66db34c7](https://github.com/elisiariocouto/leggen/commit/66db34c712300ff4b5dbe7e06246f16d6f6a8469))
### Miscellaneous Tasks
- Sort imports, fix deprecated pydantic option. ([2467cb2f](https://github.com/elisiariocouto/leggen/commit/2467cb2f5af07a7262b3221bf61b58ad4017659a))
- Check import order using ruff. ([da98b7b2](https://github.com/elisiariocouto/leggen/commit/da98b7b2b77c5b37792dedff11f8256da3b086f7))
### Refactor
- **analytics:** Simplify analytics endpoints and eliminate client-side processing. ([077e2bb1](https://github.com/elisiariocouto/leggen/commit/077e2bb1adbdb73ffde17635bd918cd40fe7fb5a))
- Unify leggen and leggend packages into single leggen package ([318ca517](https://github.com/elisiariocouto/leggen/commit/318ca517f7ea599b37a8deb47ad80218fbae008f))
- Consolidate database layer and eliminate wrapper complexity. ([5ae3a51d](https://github.com/elisiariocouto/leggen/commit/5ae3a51d8138b9aa28dbceabf575ab2577402e70))
## 2025.9.10 (2025/09/13)
### Miscellaneous Tasks
- **frontend:** Update dependencies. ([06cf02f4](https://github.com/elisiariocouto/leggen/commit/06cf02f43ff72e4e01692e3a94a06be48d9acb1f))
## 2025.9.10 (2025/09/13)
### Miscellaneous Tasks
- **frontend:** Update dependencies. ([06cf02f4](https://github.com/elisiariocouto/leggen/commit/06cf02f43ff72e4e01692e3a94a06be48d9acb1f))
## 2025.9.9 (2025/09/11)
### Bug Fixes
- **core:** Handle permission errors gracefully in database path creation. ([4006dd12](https://github.com/elisiariocouto/leggen/commit/4006dd128e0896b338cb93fad60a1eca90c1873d))
### Features
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1e94333d](https://github.com/elisiariocouto/leggen/commit/1e94333d8f0275542ae7fd6e49fb8b7f03ad3d11))
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1c901a9d](https://github.com/elisiariocouto/leggen/commit/1c901a9ddab0f6515dce56df8cce74518805a6bb))
- Remove config.toml file - should be created when needed ([a5d10b35](https://github.com/elisiariocouto/leggen/commit/a5d10b3539e7cfc649b0fee05b12c4a03681e135))
### Refactor
- **core:** Integrate directory creation with database path retrieval and remove backup file. ([7d9744a4](https://github.com/elisiariocouto/leggen/commit/7d9744a40e7898e5bbe52e2e9f54317aa5c1cdd6))
## 2025.9.9 (2025/09/11)
### Bug Fixes
- **core:** Handle permission errors gracefully in database path creation. ([4006dd12](https://github.com/elisiariocouto/leggen/commit/4006dd128e0896b338cb93fad60a1eca90c1873d))
### Features
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1e94333d](https://github.com/elisiariocouto/leggen/commit/1e94333d8f0275542ae7fd6e49fb8b7f03ad3d11))
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1c901a9d](https://github.com/elisiariocouto/leggen/commit/1c901a9ddab0f6515dce56df8cce74518805a6bb))
- Remove config.toml file - should be created when needed ([a5d10b35](https://github.com/elisiariocouto/leggen/commit/a5d10b3539e7cfc649b0fee05b12c4a03681e135))
### Refactor
- **core:** Integrate directory creation with database path retrieval and remove backup file. ([7d9744a4](https://github.com/elisiariocouto/leggen/commit/7d9744a40e7898e5bbe52e2e9f54317aa5c1cdd6))
## 2025.9.8 (2025/09/11)
### Bug Fixes
- Change branch name from develop to dev in CI workflow ([f4bf549b](https://github.com/elisiariocouto/leggen/commit/f4bf549b99197d70104abf5731ab1ccb67cc9a69))
### Features
- Update CI workflow to use Node.js 20 instead of 18 ([e4e04ea3](https://github.com/elisiariocouto/leggen/commit/e4e04ea34ea568c08292562243b6e6c08234d918))
## 2025.9.8 (2025/09/11)
### Bug Fixes
- Change branch name from develop to dev in CI workflow ([f4bf549b](https://github.com/elisiariocouto/leggen/commit/f4bf549b99197d70104abf5731ab1ccb67cc9a69))
### Features
- Update CI workflow to use Node.js 20 instead of 18 ([e4e04ea3](https://github.com/elisiariocouto/leggen/commit/e4e04ea34ea568c08292562243b6e6c08234d918))
## 2025.9.7 (2025/09/11)
### Bug Fixes
- Simplify notification settings and fix notification test on dashboard. ([91020e32](https://github.com/elisiariocouto/leggen/commit/91020e32ea836ee8af4aeaf5d49525c24b566aed))
### Features
- **frontend:** Implement TanStack Table for transactions view ([544527f2](https://github.com/elisiariocouto/leggen/commit/544527f28284fb9644bec6e721fa5da8ce10739f))
- Improve transactions API pagination and search ([2d6800ef](https://github.com/elisiariocouto/leggen/commit/2d6800eff8e484d3d175225f94d854706584a773))
## 2025.9.7 (2025/09/11)
### Bug Fixes
- Simplify notification settings and fix notification test on dashboard. ([91020e32](https://github.com/elisiariocouto/leggen/commit/91020e32ea836ee8af4aeaf5d49525c24b566aed))
### Features
- **frontend:** Implement TanStack Table for transactions view ([544527f2](https://github.com/elisiariocouto/leggen/commit/544527f28284fb9644bec6e721fa5da8ce10739f))
- Improve transactions API pagination and search ([2d6800ef](https://github.com/elisiariocouto/leggen/commit/2d6800eff8e484d3d175225f94d854706584a773))
## 2025.9.6 (2025/09/10)
### Features
- **db:** Migrate transactions table to composite primary key ([a00d6ce2](https://github.com/elisiariocouto/leggen/commit/a00d6ce2ce2c4a070e9fae56c0cea58b3aab6cec))
## 2025.9.6 (2025/09/10)
### Features
- **db:** Migrate transactions table to composite primary key ([a00d6ce2](https://github.com/elisiariocouto/leggen/commit/a00d6ce2ce2c4a070e9fae56c0cea58b3aab6cec))
## 2025.9.5 (2025/09/10)
### Bug Fixes
- Correct composite key migration check ([c0ee21d6](https://github.com/elisiariocouto/leggen/commit/c0ee21d6fa8d5d61c029bd9334a7674fce99f729))
## 2025.9.5 (2025/09/10)
### Bug Fixes
- Correct composite key migration check ([c0ee21d6](https://github.com/elisiariocouto/leggen/commit/c0ee21d6fa8d5d61c029bd9334a7674fce99f729))
## 2025.9.4 (2025/09/10)
### Bug Fixes
- **api:** Resolve duplicate transactions with composite key migration ([13e92ccd](https://github.com/elisiariocouto/leggen/commit/13e92ccd3497bacf3b8639f6332cd3f4b682bd0a))
### Features
- **api:** Add currency extraction and account name updates ([d9c50d12](https://github.com/elisiariocouto/leggen/commit/d9c50d129825529e0fb6477e5b62c0f990523bca))
- **frontend:** Adapt to composite key transaction structure ([61fafecb](https://github.com/elisiariocouto/leggen/commit/61fafecb780a877a69ecca27ea95a1494669b70d))
- **frontend:** Add account name editing functionality ([aa97f368](https://github.com/elisiariocouto/leggen/commit/aa97f36819f15f1afc34f45642abdc6e2ce6c883))
- **frontend:** Implement TanStack Router with mobile sidebar ([ca41b7af](https://github.com/elisiariocouto/leggen/commit/ca41b7af0a5e50e0350857a4ace7979b7b29eab2))
- **web:** Add modal to view raw transaction. ([433ba3fa](https://github.com/elisiariocouto/leggen/commit/433ba3faf9937613786e66e9ee13152f96d00c43))
## 2025.9.4 (2025/09/10)
### Bug Fixes
- **api:** Resolve duplicate transactions with composite key migration ([13e92ccd](https://github.com/elisiariocouto/leggen/commit/13e92ccd3497bacf3b8639f6332cd3f4b682bd0a))
### Features
- **api:** Add currency extraction and account name updates ([d9c50d12](https://github.com/elisiariocouto/leggen/commit/d9c50d129825529e0fb6477e5b62c0f990523bca))
- **frontend:** Adapt to composite key transaction structure ([61fafecb](https://github.com/elisiariocouto/leggen/commit/61fafecb780a877a69ecca27ea95a1494669b70d))
- **frontend:** Add account name editing functionality ([aa97f368](https://github.com/elisiariocouto/leggen/commit/aa97f36819f15f1afc34f45642abdc6e2ce6c883))
- **frontend:** Implement TanStack Router with mobile sidebar ([ca41b7af](https://github.com/elisiariocouto/leggen/commit/ca41b7af0a5e50e0350857a4ace7979b7b29eab2))
- **web:** Add modal to view raw transaction. ([433ba3fa](https://github.com/elisiariocouto/leggen/commit/433ba3faf9937613786e66e9ee13152f96d00c43))
## 2025.9.4 (2025/09/10)
### Bug Fixes
- **api:** Resolve duplicate transactions with composite key migration ([13e92ccd](https://github.com/elisiariocouto/leggen/commit/13e92ccd3497bacf3b8639f6332cd3f4b682bd0a))
### Features
- **api:** Add currency extraction and account name updates ([d9c50d12](https://github.com/elisiariocouto/leggen/commit/d9c50d129825529e0fb6477e5b62c0f990523bca))
- **frontend:** Adapt to composite key transaction structure ([61fafecb](https://github.com/elisiariocouto/leggen/commit/61fafecb780a877a69ecca27ea95a1494669b70d))
- **frontend:** Add account name editing functionality ([aa97f368](https://github.com/elisiariocouto/leggen/commit/aa97f36819f15f1afc34f45642abdc6e2ce6c883))
- **frontend:** Implement TanStack Router with mobile sidebar ([ca41b7af](https://github.com/elisiariocouto/leggen/commit/ca41b7af0a5e50e0350857a4ace7979b7b29eab2))
- **web:** Add modal to view raw transaction. ([433ba3fa](https://github.com/elisiariocouto/leggen/commit/433ba3faf9937613786e66e9ee13152f96d00c43))
## 2025.9.3 (2025/09/10)
### Miscellaneous Tasks
- **ci:** Fix GitHub Actions syntax. ([90e58734](https://github.com/elisiariocouto/leggen/commit/90e58734adb9638efd695719321874658529561d))
## 2025.9.3 (2025/09/10)
### Miscellaneous Tasks
- **ci:** Fix GitHub Actions syntax. ([90e58734](https://github.com/elisiariocouto/leggen/commit/90e58734adb9638efd695719321874658529561d))
## 2025.9.2 (2025/09/10)
### Bug Fixes
- **ci:** Prevent duplicate Docker tags in GitHub Actions ([53e08e8e](https://github.com/elisiariocouto/leggen/commit/53e08e8e4b909b4895b5a447cfbce515893d31a5))
### Features
- **docker:** Add Docker containerization for React frontend ([84fe79b3](https://github.com/elisiariocouto/leggen/commit/84fe79b37b4f154fa0758f8d037cdba0d166dd3b))
## 2025.9.2 (2025/09/10)
### Bug Fixes
- **ci:** Prevent duplicate Docker tags in GitHub Actions ([53e08e8e](https://github.com/elisiariocouto/leggen/commit/53e08e8e4b909b4895b5a447cfbce515893d31a5))
### Features
- **docker:** Add Docker containerization for React frontend ([84fe79b3](https://github.com/elisiariocouto/leggen/commit/84fe79b37b4f154fa0758f8d037cdba0d166dd3b))
## 2025.9.1 (2025/09/09)
### Bug Fixes
- Handle duplicate transactionId values in migration ([8fabaf7b](https://github.com/elisiariocouto/leggen/commit/8fabaf7b86fde921c61266568ecb0403d3102671))
### Miscellaneous Tasks
- Improve AGENTS.md. ([3270dc45](https://github.com/elisiariocouto/leggen/commit/3270dc4585e6b33d55aef0deecd849753d36fa74))
### Refactor
- Remove unused hide_missing_ids functionality ([8006e5e1](https://github.com/elisiariocouto/leggen/commit/8006e5e1f6373aae39d3c38068d694e142bc85a5))
## 2025.9.1 (2025/09/09)
### Bug Fixes
- Handle duplicate transactionId values in migration ([8fabaf7b](https://github.com/elisiariocouto/leggen/commit/8fabaf7b86fde921c61266568ecb0403d3102671))
### Miscellaneous Tasks
- Improve AGENTS.md. ([3270dc45](https://github.com/elisiariocouto/leggen/commit/3270dc4585e6b33d55aef0deecd849753d36fa74))
### Refactor
- Remove unused hide_missing_ids functionality ([8006e5e1](https://github.com/elisiariocouto/leggen/commit/8006e5e1f6373aae39d3c38068d694e142bc85a5))
## 2025.9.0 (2025/09/09)
### Bug Fixes

1
CLAUDE.md Symbolic link
View File

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

View File

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

View File

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

248
README.md
View File

@@ -1,26 +1,29 @@
# 💲 leggen
An Open Banking CLI and API service for managing bank connections and transactions.
This tool provides **FastAPI backend service** (`leggend`), a **React Web Interface** and a **command-line interface** (`leggen`) to connect to banks using the GoCardless Open Banking API.
A self hosted Open Banking Dashboard, API and CLI for managing bank connections and transactions.
Having your bank data accessible through both CLI and REST API gives you the power to backup, analyze, create reports, and integrate with other applications.
![Leggen demo](docs/leggen_demo.gif)
## 🛠️ Technologies
### Frontend
- [React](https://reactjs.org/): Modern web interface with TypeScript
- [Vite](https://vitejs.dev/): Fast build tool and development server
- [Tailwind CSS](https://tailwindcss.com/): Utility-first CSS framework
- [shadcn/ui](https://ui.shadcn.com/): Modern component system built on Radix UI
- [TanStack Query](https://tanstack.com/query): Powerful data synchronization for React
### 🔌 API & Backend
- [FastAPI](https://fastapi.tiangolo.com/): High-performance async API backend (`leggend` service)
- [FastAPI](https://fastapi.tiangolo.com/): High-performance async API backend (integrated into `leggen server`)
- [GoCardless Open Banking API](https://developer.gocardless.com/bank-account-data/overview): for connecting to banks
- [APScheduler](https://apscheduler.readthedocs.io/): Background job scheduling with configurable cron
### 📦 Storage
- [SQLite](https://www.sqlite.org): for storing transactions, simple and easy to use
### Frontend
- [React](https://reactjs.org/): Modern web interface with TypeScript
- [Vite](https://vitejs.dev/): Fast build tool and development server
- [Tailwind CSS](https://tailwindcss.com/): Utility-first CSS framework
- [TanStack Query](https://tanstack.com/query): Powerful data synchronization for React
## ✨ Features
@@ -53,10 +56,9 @@ Having your bank data accessible through both CLI and REST API gives you the pow
1. Create a GoCardless account at [https://gocardless.com/bank-account-data/](https://gocardless.com/bank-account-data/)
2. Get your API credentials (key and secret)
### Installation Options
### Installation
#### Option 1: Docker Compose (Recommended)
The easiest way to get started is with Docker Compose, which includes both the React frontend and FastAPI backend:
#### Docker Compose (Recommended)
```bash
# Clone the repository
@@ -64,33 +66,19 @@ git clone https://github.com/elisiariocouto/leggen.git
cd leggen
# Create your configuration
mkdir -p leggen && cp config.example.toml leggen/config.toml
# Edit leggen/config.toml with your GoCardless credentials
mkdir -p data && cp config.example.toml data/config.toml
# Edit data/config.toml with your GoCardless credentials
# Start all services (frontend + backend)
# Start all services
docker compose up -d
# Access the web interface at http://localhost:3000
# API is available at http://localhost:8000
```
#### Option 2: Local Development
For development or local installation:
```bash
# Install with uv (recommended) or pip
uv sync # or pip install -e .
# Start the API service
uv run leggend --reload # Development mode with auto-reload
# Use the CLI (in another terminal)
uv run leggen --help
# API documentation at http://localhost:3000/api/v1/docs
```
### Configuration
Create a configuration file at `~/.config/leggen/config.toml`:
Create a configuration file at `./data/config.toml` (for Docker) or `~/.config/leggen/config.toml` (for local development):
```toml
[gocardless]
@@ -121,202 +109,28 @@ enabled = true
# Optional: Transaction filters for notifications
[filters]
case-insensitive = ["salary", "utility"]
case-sensitive = ["SpecificStore"]
case_insensitive = ["salary", "utility"]
case_sensitive = ["SpecificStore"]
```
## 📖 Usage
### API Service (`leggend`)
### Web Interface
Access the React web interface at `http://localhost:3000` after starting the services.
Start the FastAPI backend service:
### API Service
Visit `http://localhost:3000/api/v1/docs` for interactive API documentation.
### CLI Commands
```bash
# Production mode
leggend
# Development mode with auto-reload
leggend --reload
# Custom host and port
leggend --host 127.0.0.1 --port 8080
leggen status # Check connection status
leggen bank add # Connect to a new bank
leggen balances # View account balances
leggen transactions # List transactions
leggen sync # Trigger background sync
```
**API Documentation**: Visit `http://localhost:8000/docs` for interactive API documentation.
### CLI Commands (`leggen`)
#### Basic Commands
```bash
# Check connection status
leggen status
# Connect to a new bank
leggen bank add
# View account balances
leggen balances
# List recent transactions
leggen transactions --limit 20
# View detailed transactions
leggen transactions --full
```
#### Sync Operations
```bash
# Start background sync
leggen sync
# Synchronous sync (wait for completion)
leggen sync --wait
# Force sync (override running sync)
leggen sync --force --wait
```
#### API Integration
```bash
# Use custom API URL
leggen --api-url http://localhost:8080 status
# Set via environment variable
export LEGGEND_API_URL=http://localhost:8080
leggen status
```
### Docker Usage
```bash
# Start all services (frontend + backend)
docker compose up -d
# View service status
docker compose ps
# Check logs
docker compose logs frontend
docker compose logs leggend
# Access the web interface at http://localhost:3000
# API documentation at http://localhost:8000/docs
# Stop all services
docker compose down
```
## 🔌 API Endpoints
The FastAPI backend provides comprehensive REST endpoints:
### Banks & Connections
- `GET /api/v1/banks/institutions?country=PT` - List available banks
- `POST /api/v1/banks/connect` - Create bank connection
- `GET /api/v1/banks/status` - Connection status
- `GET /api/v1/banks/countries` - Supported countries
### Accounts & Balances
- `GET /api/v1/accounts` - List all accounts
- `GET /api/v1/accounts/{id}` - Account details
- `GET /api/v1/accounts/{id}/balances` - Account balances
- `GET /api/v1/accounts/{id}/transactions` - Account transactions
### Transactions
- `GET /api/v1/transactions` - All transactions with filtering
- `GET /api/v1/transactions/stats` - Transaction statistics
### Sync & Scheduling
- `POST /api/v1/sync` - Trigger background sync
- `POST /api/v1/sync/now` - Synchronous sync
- `GET /api/v1/sync/status` - Sync status
- `GET/PUT /api/v1/sync/scheduler` - Scheduler configuration
### Notifications
- `GET/PUT /api/v1/notifications/settings` - Manage notifications
- `POST /api/v1/notifications/test` - Test notifications
## 🛠️ Development
### Local Development Setup
```bash
# Clone and setup
git clone https://github.com/elisiariocouto/leggen.git
cd leggen
# Install dependencies
uv sync
# Start API service with auto-reload
uv run leggend --reload
# Use CLI commands
uv run leggen status
```
### Testing
Run the comprehensive test suite with:
```bash
# Run all tests
uv run pytest
# Run unit tests only
uv run pytest tests/unit/
# Run with verbose output
uv run pytest tests/unit/ -v
# Run specific test files
uv run pytest tests/unit/test_config.py -v
uv run pytest tests/unit/test_scheduler.py -v
uv run pytest tests/unit/test_api_banks.py -v
# Run tests by markers
uv run pytest -m unit # Unit tests
uv run pytest -m api # API endpoint tests
uv run pytest -m cli # CLI tests
```
The test suite includes:
- **Configuration management tests** - TOML config loading/saving
- **API endpoint tests** - FastAPI route testing with mocked dependencies
- **CLI API client tests** - HTTP client integration testing
- **Background scheduler tests** - APScheduler job management
- **Mock data and fixtures** - Realistic test data for banks, accounts, transactions
### Code Structure
```
leggen/ # CLI application
├── commands/ # CLI command implementations
├── utils/ # Shared utilities
└── api_client.py # API client for leggend service
leggend/ # FastAPI backend service
├── api/ # API routes and models
├── services/ # Business logic
├── background/ # Background job scheduler
└── main.py # FastAPI application
tests/ # Test suite
├── conftest.py # Shared test fixtures
└── unit/ # Unit tests
├── test_config.py # Configuration tests
├── test_scheduler.py # Background scheduler tests
├── test_api_banks.py # Banks API tests
├── test_api_accounts.py # Accounts API tests
└── test_api_client.py # CLI API client tests
```
### Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes with tests
4. Submit a pull request
For more options, run `leggen --help` or `leggen <command> --help`.
## ⚠️ Notes
- This project is in active development
- GoCardless API rate limits apply
- Some banks may require additional authorization steps
- Docker images are automatically built and published on releases

25
compose.dev.yml Normal file
View File

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

View File

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

40
config.example.toml Normal file
View File

@@ -0,0 +1,40 @@
[gocardless]
key = "your-api-key"
secret = "your-secret-key"
url = "https://bankaccountdata.gocardless.com/api/v2"
[database]
sqlite = true
# Optional: Background sync scheduling
[scheduler.sync]
enabled = true
hour = 3 # 3 AM
minute = 0
# cron = "0 3 * * *" # Alternative: use cron expression
# Optional: Discord notifications
[notifications.discord]
webhook = "https://discord.com/api/webhooks/..."
enabled = true
# Optional: Telegram notifications
[notifications.telegram]
token = "your-bot-token"
chat_id = 12345
enabled = true
# Optional: Transaction filters for notifications
[filters]
case_insensitive = ["salary", "utility"]
case_sensitive = ["SpecificStore"]
# Optional: S3 backup configuration
[backup.s3]
access_key_id = "your-s3-access-key"
secret_access_key = "your-s3-secret-key"
bucket_name = "your-bucket-name"
region = "us-east-1"
# endpoint_url = "https://custom-s3-endpoint.com" # Optional: for custom S3-compatible endpoints
path_style = false # Set to true for path-style addressing
enabled = true

BIN
docs/leggen_demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

1
frontend/.gitignore vendored
View File

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

View File

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

View File

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

22
frontend/components.json Normal file
View File

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

View File

@@ -1,4 +1,9 @@
server {
types {
application/manifest+json webmanifest;
}
listen 80;
server_name localhost;
root /usr/share/nginx/html;
@@ -13,6 +18,9 @@ server {
# Handle client-side routing
location / {
autoindex off;
expires off;
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
try_files $uri $uri/ /index.html;
}
@@ -26,7 +34,7 @@ server {
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
location ~* \.(css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}

View File

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

View File

@@ -2,9 +2,49 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<title>Leggen</title>
<!-- PWA Meta Tags -->
<meta
name="description"
content="Personal finance management application"
/>
<meta name="application-name" content="Leggen" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Leggen" />
<meta name="format-detection" content="telephone=no" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="msapplication-config" content="/browserconfig.xml" />
<meta name="msapplication-TileColor" content="#0b74de" />
<meta name="msapplication-tap-highlight" content="no" />
<!-- Dynamic theme-color - will be updated by JavaScript -->
<meta name="theme-color" content="#0b74de" id="theme-color-meta" />
<meta
name="msapplication-navbutton-color"
content="#0b74de"
id="ms-theme-color-meta"
/>
<meta
name="apple-mobile-web-app-status-bar-style"
content="default"
id="apple-status-bar-meta"
/>
<!-- Icons -->
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
<link rel="mask-icon" href="/favicon.svg" color="#0b74de" />
<link rel="shortcut icon" href="/favicon.ico" />
<!-- Manifest -->
<link rel="manifest" href="/manifest.webmanifest" />
</head>
<body>
<div id="root"></div>

10854
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,34 +4,73 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "VITE_API_URL=http://localhost:8000/api/v1 vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tabler/icons-react": "^3.35.0",
"@tailwindcss/forms": "^0.5.10",
"@tanstack/react-query": "^5.87.1",
"@tanstack/react-router": "^1.131.36",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-cli": "^1.131.36",
"autoprefixer": "^10.4.21",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.542.0",
"next-themes": "^0.4.6",
"postcss": "^8.5.6",
"react": "^19.1.1",
"react-day-picker": "^9.10.0",
"react-dom": "^19.1.1",
"tailwindcss": "^3.4.17"
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"zod": "^3.25.76"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@tanstack/router-vite-plugin": "^1.131.36",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vite-pwa/assets-generator": "^1.0.1",
"@vitejs/plugin-react": "^5.0.0",
"eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"shadcn": "^3.3.1",
"sharp": "^0.34.3",
"typescript": "~5.8.3",
"typescript-eslint": "^8.39.1",
"vite": "^7.1.2"
"vite": "^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">
<rect width="32" height="32" rx="6" fill="#3B82F6"/>
<path d="M8 24V8h6c2.2 0 4 1.8 4 4v4c0 2.2-1.8 4-4 4H12v4H8zm4-8h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-2v4z" fill="white"/>
<svg xmlns="http://www.w3.org/2000/svg"
width="32" height="32"
viewBox="0 0 32 32"
role="img" aria-labelledby="title desc">
<title id="title">leggen — stylized italic L</title>
<desc id="desc">Square gradient background with italic white L.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0b74de"/>
<stop offset="100%" stop-color="#06b6d4"/>
</linearGradient>
</defs>
<!-- Square background -->
<rect width="32" height="32" fill="url(#bg)" rx="4"/>
<!-- Italic L -->
<text x="11" y="22"
font-family="Inter, Roboto, Arial, sans-serif"
font-weight="700"
font-size="20"
font-style="italic"
fill="#fff">
L
</text>
</svg>

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,373 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
CreditCard,
TrendingUp,
TrendingDown,
Building2,
RefreshCw,
AlertCircle,
Edit2,
Check,
X,
Plus,
} from "lucide-react";
import { apiClient } from "../lib/api";
import { formatCurrency, formatDate } from "../lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./ui/card";
import { Button } from "./ui/button";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import AccountsSkeleton from "./AccountsSkeleton";
import type { Account, Balance } from "../types/api";
// Helper function to get status indicator color and styles
const getStatusIndicator = (status: string) => {
const statusLower = status.toLowerCase();
switch (statusLower) {
case "ready":
return {
color: "bg-green-500",
tooltip: "Ready",
};
case "pending":
return {
color: "bg-amber-500",
tooltip: "Pending",
};
case "error":
case "failed":
return {
color: "bg-destructive",
tooltip: "Error",
};
case "inactive":
return {
color: "bg-muted-foreground",
tooltip: "Inactive",
};
default:
return {
color: "bg-primary",
tooltip: status,
};
}
};
export default function AccountSettings() {
const {
data: accounts,
isLoading: accountsLoading,
error: accountsError,
refetch: refetchAccounts,
} = useQuery<Account[]>({
queryKey: ["accounts"],
queryFn: apiClient.getAccounts,
});
const { data: balances } = useQuery<Balance[]>({
queryKey: ["balances"],
queryFn: () => apiClient.getBalances(),
});
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
const queryClient = useQueryClient();
const updateAccountMutation = useMutation({
mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
apiClient.updateAccount(id, { display_name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["accounts"] });
setEditingAccountId(null);
setEditingName("");
},
onError: (error) => {
console.error("Failed to update account:", error);
},
});
const handleEditStart = (account: Account) => {
setEditingAccountId(account.id);
// Use display_name if available, otherwise fall back to name
setEditingName(account.display_name || account.name || "");
};
const handleEditSave = () => {
if (editingAccountId && editingName.trim()) {
updateAccountMutation.mutate({
id: editingAccountId,
display_name: editingName.trim(),
});
}
};
const handleEditCancel = () => {
setEditingAccountId(null);
setEditingName("");
};
if (accountsLoading) {
return <AccountsSkeleton />;
}
if (accountsError) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Failed to load accounts</AlertTitle>
<AlertDescription className="space-y-3">
<p>
Unable to connect to the Leggen API. Please check your configuration
and ensure the API server is running.
</p>
<Button onClick={() => refetchAccounts()} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-6">
{/* Account Management Section */}
<Card>
<CardHeader>
<CardTitle>Account Management</CardTitle>
<CardDescription>
Manage your connected bank accounts and customize their display
names
</CardDescription>
</CardHeader>
{!accounts || accounts.length === 0 ? (
<CardContent className="p-6 text-center">
<CreditCard className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
No accounts found
</h3>
<p className="text-muted-foreground mb-4">
Connect your first bank account to get started with Leggen.
</p>
<Button disabled className="flex items-center space-x-2">
<Plus className="h-4 w-4" />
<span>Add Bank Account</span>
</Button>
<p className="text-xs text-muted-foreground mt-2">
Coming soon: Add new bank connections
</p>
</CardContent>
) : (
<CardContent className="p-0">
<div className="divide-y divide-border">
{accounts.map((account) => {
// Get balance from account's balances array or fallback to balances query
const accountBalance = account.balances?.[0];
const fallbackBalance = balances?.find(
(b) => b.account_id === account.id,
);
const balance =
accountBalance?.amount ||
fallbackBalance?.balance_amount ||
0;
const currency =
accountBalance?.currency ||
fallbackBalance?.currency ||
account.currency ||
"EUR";
const isPositive = balance >= 0;
return (
<div
key={account.id}
className="p-4 sm:p-6 hover:bg-accent transition-colors"
>
{/* Mobile layout - stack vertically */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
<div className="flex-shrink-0 w-10 h-10 sm:w-12 sm:h-12 rounded-full overflow-hidden bg-muted flex items-center justify-center">
{account.logo && !failedImages.has(account.id) ? (
<img
src={account.logo}
alt={`${account.institution_id} logo`}
className="w-full h-full object-contain"
onError={() => {
console.warn(
`Failed to load bank logo for ${account.institution_id}: ${account.logo}`,
);
setFailedImages(
(prev) => new Set([...prev, account.id]),
);
}}
/>
) : (
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
{editingAccountId === account.id ? (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<input
type="text"
value={editingName}
onChange={(e) =>
setEditingName(e.target.value)
}
className="flex-1 px-3 py-1 text-base sm:text-lg font-medium border border-input rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
placeholder="Custom account name"
name="search"
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter") handleEditSave();
if (e.key === "Escape") handleEditCancel();
}}
autoFocus
/>
<Button
onClick={handleEditSave}
disabled={
!editingName.trim() ||
updateAccountMutation.isPending
}
size="icon"
variant="ghost"
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-100"
title="Save changes"
>
<Check className="h-4 w-4" />
</Button>
<Button
onClick={handleEditCancel}
size="icon"
variant="ghost"
className="h-8 w-8"
title="Cancel editing"
>
<X className="h-4 w-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}
</p>
</div>
) : (
<div>
<div className="flex items-center space-x-2 min-w-0">
<h4 className="text-base sm:text-lg font-medium text-foreground truncate">
{account.display_name ||
account.name ||
"Unnamed Account"}
</h4>
<Button
onClick={() => handleEditStart(account)}
size="icon"
variant="ghost"
className="h-7 w-7 flex-shrink-0"
title="Edit account name"
>
<Edit2 className="h-4 w-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}
</p>
{account.iban && (
<p className="text-xs text-muted-foreground mt-1 font-mono break-all sm:break-normal">
IBAN: {account.iban}
</p>
)}
</div>
)}
</div>
</div>
{/* Balance and date section */}
<div className="flex items-center justify-between sm:flex-col sm:items-end sm:text-right flex-shrink-0">
{/* Mobile: date/status on left, balance on right */}
{/* Desktop: balance on top, date/status on bottom */}
{/* Date and status indicator - left on mobile, bottom on desktop */}
<div className="flex items-center space-x-2 order-1 sm:order-2">
<div
className={`w-3 h-3 rounded-full ${getStatusIndicator(account.status).color} relative group cursor-help`}
role="img"
aria-label={`Account status: ${getStatusIndicator(account.status).tooltip}`}
>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 hidden group-hover:block bg-gray-900 text-white text-xs rounded py-1 px-2 whitespace-nowrap z-10">
{getStatusIndicator(account.status).tooltip}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-2 border-transparent border-t-gray-900"></div>
</div>
</div>
<p className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
Updated{" "}
{formatDate(
account.last_accessed || account.created,
)}
</p>
</div>
{/* Balance - right on mobile, top on desktop */}
<div className="flex items-center space-x-2 order-2 sm:order-1">
{isPositive ? (
<TrendingUp className="h-4 w-4 text-green-500" />
) : (
<TrendingDown className="h-4 w-4 text-red-500" />
)}
<p
className={`text-base sm:text-lg font-semibold ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{formatCurrency(balance, currency)}
</p>
</div>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
)}
</Card>
{/* Add Bank Section (Future Feature) */}
<Card>
<CardHeader>
<CardTitle>Add New Bank Account</CardTitle>
<CardDescription>
Connect additional bank accounts to track all your finances in one
place
</CardDescription>
</CardHeader>
<CardContent className="p-6">
<div className="text-center space-y-4">
<div className="p-4 bg-muted rounded-lg">
<Plus className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
Bank connection functionality is coming soon. Stay tuned for
updates!
</p>
</div>
<Button disabled variant="outline">
<Plus className="h-4 w-4 mr-2" />
Connect Bank Account
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

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

@@ -0,0 +1,203 @@
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Plus, Building2, ExternalLink } from "lucide-react";
import { apiClient } from "../lib/api";
import { Button } from "./ui/button";
import { Label } from "./ui/label";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "./ui/drawer";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
import { Alert, AlertDescription } from "./ui/alert";
export default function AddBankAccountDrawer() {
const [open, setOpen] = useState(false);
const [selectedCountry, setSelectedCountry] = useState<string>("");
const [selectedBank, setSelectedBank] = useState<string>("");
const { data: countries } = useQuery({
queryKey: ["supportedCountries"],
queryFn: apiClient.getSupportedCountries,
});
const { data: banks, isLoading: banksLoading } = useQuery({
queryKey: ["bankInstitutions", selectedCountry],
queryFn: () => apiClient.getBankInstitutions(selectedCountry),
enabled: !!selectedCountry,
});
const connectBankMutation = useMutation({
mutationFn: (institutionId: string) =>
apiClient.createBankConnection(institutionId),
onSuccess: (data) => {
// Redirect to the bank's authorization link
if (data.link) {
window.open(data.link, "_blank");
setOpen(false);
}
},
onError: (error) => {
console.error("Failed to create bank connection:", error);
},
});
const handleCountryChange = (country: string) => {
setSelectedCountry(country);
setSelectedBank("");
};
const handleConnect = () => {
if (selectedBank) {
connectBankMutation.mutate(selectedBank);
}
};
const resetForm = () => {
setSelectedCountry("");
setSelectedBank("");
};
return (
<Drawer
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
if (!isOpen) {
resetForm();
}
}}
>
<DrawerTrigger asChild>
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
Add New Account
</Button>
</DrawerTrigger>
<DrawerContent className="max-h-[80vh]">
<DrawerHeader>
<DrawerTitle>Connect Bank Account</DrawerTitle>
<DrawerDescription>
Select your country and bank to connect your account to Leggen
</DrawerDescription>
</DrawerHeader>
<div className="px-6 space-y-6 overflow-y-auto">
{/* Country Selection */}
<div className="space-y-2">
<Label htmlFor="country">Country</Label>
<Select value={selectedCountry} onValueChange={handleCountryChange}>
<SelectTrigger>
<SelectValue placeholder="Select your country" />
</SelectTrigger>
<SelectContent>
{countries?.map((country) => (
<SelectItem key={country.code} value={country.code}>
{country.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Bank Selection */}
{selectedCountry && (
<div className="space-y-2">
<Label htmlFor="bank">Bank</Label>
{banksLoading ? (
<div className="p-4 text-center text-muted-foreground">
Loading banks...
</div>
) : banks && banks.length > 0 ? (
<Select value={selectedBank} onValueChange={setSelectedBank}>
<SelectTrigger>
<SelectValue placeholder="Select your bank" />
</SelectTrigger>
<SelectContent>
{banks.map((bank) => (
<SelectItem key={bank.id} value={bank.id}>
<div className="flex items-center space-x-2">
{bank.logo ? (
<img
src={bank.logo}
alt={`${bank.name} logo`}
className="w-4 h-4 object-contain"
/>
) : (
<Building2 className="w-4 h-4" />
)}
<span>{bank.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Alert>
<AlertDescription>
No banks available for the selected country.
</AlertDescription>
</Alert>
)}
</div>
)}
{/* Instructions */}
{selectedBank && (
<Alert>
<AlertDescription>
You'll be redirected to your bank's website to authorize the
connection. After approval, you'll return to Leggen and your
account will start syncing.
</AlertDescription>
</Alert>
)}
{/* Error Display */}
{connectBankMutation.isError && (
<Alert variant="destructive">
<AlertDescription>
Failed to create bank connection. Please try again.
</AlertDescription>
</Alert>
)}
</div>
<DrawerFooter>
<div className="flex space-x-2">
<Button
onClick={handleConnect}
disabled={!selectedBank || connectBankMutation.isPending}
className="flex-1"
>
<ExternalLink className="h-4 w-4 mr-2" />
{connectBankMutation.isPending
? "Connecting..."
: "Open Bank Authorization"}
</Button>
<DrawerClose asChild>
<Button
variant="outline"
disabled={connectBankMutation.isPending}
>
Cancel
</Button>
</DrawerClose>
</div>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,200 @@
import { useState, useEffect } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { MessageSquare, TestTube } from "lucide-react";
import { apiClient } from "../lib/api";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Switch } from "./ui/switch";
import { EditButton } from "./ui/edit-button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "./ui/drawer";
import type { NotificationSettings, DiscordConfig } from "../types/api";
interface DiscordConfigDrawerProps {
settings: NotificationSettings | undefined;
trigger?: React.ReactNode;
}
export default function DiscordConfigDrawer({
settings,
trigger,
}: DiscordConfigDrawerProps) {
const [open, setOpen] = useState(false);
const [config, setConfig] = useState<DiscordConfig>({
webhook: "",
enabled: true,
});
const queryClient = useQueryClient();
useEffect(() => {
if (settings?.discord) {
setConfig({ ...settings.discord });
}
}, [settings]);
const updateMutation = useMutation({
mutationFn: (discordConfig: DiscordConfig) =>
apiClient.updateNotificationSettings({
...settings,
discord: discordConfig,
filters: settings?.filters || {
case_insensitive: [],
case_sensitive: [],
},
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
queryClient.invalidateQueries({ queryKey: ["notificationServices"] });
setOpen(false);
},
onError: (error) => {
console.error("Failed to update Discord configuration:", error);
},
});
const testMutation = useMutation({
mutationFn: () =>
apiClient.testNotification({
service: "discord",
message:
"Test notification from Leggen - Discord configuration is working!",
}),
onSuccess: () => {
console.log("Test Discord notification sent successfully");
},
onError: (error) => {
console.error("Failed to send test Discord notification:", error);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
updateMutation.mutate(config);
};
const handleTest = () => {
testMutation.mutate();
};
const isConfigValid =
config.webhook.trim().length > 0 &&
config.webhook.includes("discord.com/api/webhooks");
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{trigger || <EditButton />}</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-md">
<DrawerHeader>
<DrawerTitle className="flex items-center space-x-2">
<MessageSquare className="h-5 w-5 text-primary" />
<span>Discord Configuration</span>
</DrawerTitle>
<DrawerDescription>
Configure Discord webhook notifications for transaction alerts
</DrawerDescription>
</DrawerHeader>
<form onSubmit={handleSubmit} className="p-4 space-y-6">
{/* Enable/Disable Toggle */}
<div className="flex items-center justify-between">
<Label className="text-base font-medium">
Enable Discord Notifications
</Label>
<Switch
checked={config.enabled}
onCheckedChange={(enabled) => setConfig({ ...config, enabled })}
/>
</div>
{/* Webhook URL */}
<div className="space-y-2">
<Label htmlFor="discord-webhook">Discord Webhook URL</Label>
<Input
id="discord-webhook"
type="url"
placeholder="https://discord.com/api/webhooks/..."
value={config.webhook}
onChange={(e) =>
setConfig({ ...config, webhook: e.target.value })
}
disabled={!config.enabled}
/>
<p className="text-xs text-muted-foreground">
Create a webhook in your Discord server settings under
Integrations Webhooks
</p>
</div>
{/* Configuration Status */}
{config.enabled && (
<div className="p-3 bg-muted rounded-md">
<div className="flex items-center space-x-2">
<div
className={`w-2 h-2 rounded-full ${isConfigValid ? "bg-green-500" : "bg-red-500"}`}
/>
<span className="text-sm font-medium">
{isConfigValid
? "Configuration Valid"
: "Invalid Webhook URL"}
</span>
</div>
{!isConfigValid && config.webhook.trim().length > 0 && (
<p className="text-xs text-muted-foreground mt-1">
Please enter a valid Discord webhook URL
</p>
)}
</div>
)}
<DrawerFooter className="px-0">
<div className="flex space-x-2">
<Button
type="submit"
disabled={updateMutation.isPending || !config.enabled}
>
{updateMutation.isPending
? "Saving..."
: "Save Configuration"}
</Button>
{config.enabled && isConfigValid && (
<Button
type="button"
variant="outline"
onClick={handleTest}
disabled={testMutation.isPending}
>
{testMutation.isPending ? (
<>
<TestTube className="h-4 w-4 mr-2 animate-spin" />
Testing...
</>
) : (
<>
<TestTube className="h-4 w-4 mr-2" />
Test
</>
)}
</Button>
)}
</div>
<DrawerClose asChild>
<Button variant="ghost">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</form>
</div>
</DrawerContent>
</Drawer>
);
}

View File

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

View File

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

View File

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

@@ -0,0 +1,259 @@
import { useState, useEffect } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Plus, X } from "lucide-react";
import { apiClient } from "../lib/api";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { EditButton } from "./ui/edit-button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "./ui/drawer";
import type { NotificationSettings, NotificationFilters } from "../types/api";
interface NotificationFiltersDrawerProps {
settings: NotificationSettings | undefined;
trigger?: React.ReactNode;
}
export default function NotificationFiltersDrawer({
settings,
trigger,
}: NotificationFiltersDrawerProps) {
const [open, setOpen] = useState(false);
const [filters, setFilters] = useState<NotificationFilters>({
case_insensitive: [],
case_sensitive: [],
});
const [newCaseInsensitive, setNewCaseInsensitive] = useState("");
const [newCaseSensitive, setNewCaseSensitive] = useState("");
const queryClient = useQueryClient();
useEffect(() => {
if (settings?.filters) {
setFilters({
case_insensitive: [...(settings.filters.case_insensitive || [])],
case_sensitive: [...(settings.filters.case_sensitive || [])],
});
}
}, [settings]);
const updateMutation = useMutation({
mutationFn: (updatedFilters: NotificationFilters) =>
apiClient.updateNotificationSettings({
...settings,
filters: updatedFilters,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
setOpen(false);
},
onError: (error) => {
console.error("Failed to update notification filters:", error);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
updateMutation.mutate(filters);
};
const addCaseInsensitiveFilter = () => {
if (
newCaseInsensitive.trim() &&
!filters.case_insensitive.includes(newCaseInsensitive.trim())
) {
setFilters({
...filters,
case_insensitive: [
...filters.case_insensitive,
newCaseInsensitive.trim(),
],
});
setNewCaseInsensitive("");
}
};
const addCaseSensitiveFilter = () => {
if (
newCaseSensitive.trim() &&
!filters.case_sensitive?.includes(newCaseSensitive.trim())
) {
setFilters({
...filters,
case_sensitive: [
...(filters.case_sensitive || []),
newCaseSensitive.trim(),
],
});
setNewCaseSensitive("");
}
};
const removeCaseInsensitiveFilter = (index: number) => {
setFilters({
...filters,
case_insensitive: filters.case_insensitive.filter((_, i) => i !== index),
});
};
const removeCaseSensitiveFilter = (index: number) => {
setFilters({
...filters,
case_sensitive:
filters.case_sensitive?.filter((_, i) => i !== index) || [],
});
};
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{trigger || <EditButton />}</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-2xl">
<DrawerHeader>
<DrawerTitle>Notification Filters</DrawerTitle>
<DrawerDescription>
Configure which transaction descriptions should trigger
notifications
</DrawerDescription>
</DrawerHeader>
<form onSubmit={handleSubmit} className="p-4 space-y-6">
{/* Case Insensitive Filters */}
<div className="space-y-3">
<Label className="text-base font-medium">
Case Insensitive Filters
</Label>
<p className="text-sm text-muted-foreground">
Filters that match regardless of capitalization (e.g., "AMAZON"
matches "amazon")
</p>
<div className="flex space-x-2">
<Input
placeholder="Add filter term..."
value={newCaseInsensitive}
onChange={(e) => setNewCaseInsensitive(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addCaseInsensitiveFilter();
}
}}
/>
<Button
type="button"
onClick={addCaseInsensitiveFilter}
size="sm"
>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap gap-2 min-h-[2rem] p-3 bg-muted rounded-md">
{filters.case_insensitive.length > 0 ? (
filters.case_insensitive.map((filter, index) => (
<div
key={index}
className="flex items-center space-x-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm"
>
<span>{filter}</span>
<Button
type="button"
onClick={() => removeCaseInsensitiveFilter(index)}
variant="ghost"
size="icon"
className="h-5 w-5 hover:bg-secondary-foreground/10"
>
<X className="h-3 w-3" />
</Button>
</div>
))
) : (
<span className="text-muted-foreground text-sm">
No filters added
</span>
)}
</div>
</div>
{/* Case Sensitive Filters */}
<div className="space-y-3">
<Label className="text-base font-medium">
Case Sensitive Filters
</Label>
<p className="text-sm text-muted-foreground">
Filters that match exactly as typed (e.g., "AMAZON" only matches
"AMAZON")
</p>
<div className="flex space-x-2">
<Input
placeholder="Add filter term..."
value={newCaseSensitive}
onChange={(e) => setNewCaseSensitive(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addCaseSensitiveFilter();
}
}}
/>
<Button
type="button"
onClick={addCaseSensitiveFilter}
size="sm"
>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap gap-2 min-h-[2rem] p-3 bg-muted rounded-md">
{filters.case_sensitive && filters.case_sensitive.length > 0 ? (
filters.case_sensitive.map((filter, index) => (
<div
key={index}
className="flex items-center space-x-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm"
>
<span>{filter}</span>
<Button
type="button"
onClick={() => removeCaseSensitiveFilter(index)}
variant="ghost"
size="icon"
className="h-5 w-5 hover:bg-secondary-foreground/10"
>
<X className="h-3 w-3" />
</Button>
</div>
))
) : (
<span className="text-muted-foreground text-sm">
No filters added
</span>
)}
</div>
</div>
<DrawerFooter className="px-0">
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? "Saving..." : "Save Filters"}
</Button>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</form>
</div>
</DrawerContent>
</Drawer>
);
}

View File

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

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

View File

@@ -0,0 +1,273 @@
import { useState, useEffect } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Cloud, TestTube } from "lucide-react";
import { toast } from "sonner";
import { apiClient } from "../lib/api";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Switch } from "./ui/switch";
import { EditButton } from "./ui/edit-button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "./ui/drawer";
import type { BackupSettings, S3Config } from "../types/api";
interface S3BackupConfigDrawerProps {
settings?: BackupSettings;
trigger?: React.ReactNode;
}
export default function S3BackupConfigDrawer({
settings,
trigger,
}: S3BackupConfigDrawerProps) {
const [open, setOpen] = useState(false);
const [config, setConfig] = useState<S3Config>({
access_key_id: "",
secret_access_key: "",
bucket_name: "",
region: "us-east-1",
endpoint_url: "",
path_style: false,
enabled: true,
});
const queryClient = useQueryClient();
useEffect(() => {
if (settings?.s3) {
setConfig({ ...settings.s3 });
}
}, [settings]);
const updateMutation = useMutation({
mutationFn: (s3Config: S3Config) =>
apiClient.updateBackupSettings({
s3: s3Config,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["backupSettings"] });
setOpen(false);
toast.success("S3 backup configuration saved successfully");
},
onError: (error: Error & { response?: { data?: { detail?: string } } }) => {
console.error("Failed to update S3 backup configuration:", error);
const message =
error?.response?.data?.detail ||
"Failed to save S3 configuration. Please check your settings and try again.";
toast.error(message);
},
});
const testMutation = useMutation({
mutationFn: () =>
apiClient.testBackupConnection({
service: "s3",
config: config,
}),
onSuccess: (response) => {
if (response.success) {
console.log("S3 connection test successful");
toast.success(
"S3 connection test successful! Your configuration is working correctly.",
);
} else {
console.error("S3 connection test failed:", response.message);
toast.error(response.message || "S3 connection test failed. Please verify your credentials and settings.");
}
},
onError: (error: Error & { response?: { data?: { detail?: string } } }) => {
console.error("Failed to test S3 connection:", error);
const message =
error?.response?.data?.detail ||
"S3 connection test failed. Please verify your credentials and settings.";
toast.error(message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
updateMutation.mutate(config);
};
const handleTest = () => {
testMutation.mutate();
};
const isConfigValid =
config.access_key_id.trim().length > 0 &&
config.secret_access_key.trim().length > 0 &&
config.bucket_name.trim().length > 0;
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{trigger || <EditButton />}</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle className="flex items-center space-x-2">
<Cloud className="h-5 w-5 text-primary" />
<span>S3 Backup Configuration</span>
</DrawerTitle>
<DrawerDescription>
Configure S3 settings for automatic database backups
</DrawerDescription>
</DrawerHeader>
<form onSubmit={handleSubmit} className="px-4 space-y-4">
<div className="flex items-center space-x-2">
<Switch
id="enabled"
checked={config.enabled}
onCheckedChange={(checked) =>
setConfig({ ...config, enabled: checked })
}
/>
<Label htmlFor="enabled">Enable S3 backups</Label>
</div>
{config.enabled && (
<>
<div className="space-y-2">
<Label htmlFor="access_key_id">Access Key ID</Label>
<Input
id="access_key_id"
type="text"
value={config.access_key_id}
onChange={(e) =>
setConfig({ ...config, access_key_id: e.target.value })
}
placeholder="Your AWS Access Key ID"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="secret_access_key">Secret Access Key</Label>
<Input
id="secret_access_key"
type="password"
value={config.secret_access_key}
onChange={(e) =>
setConfig({
...config,
secret_access_key: e.target.value,
})
}
placeholder="Your AWS Secret Access Key"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="bucket_name">Bucket Name</Label>
<Input
id="bucket_name"
type="text"
value={config.bucket_name}
onChange={(e) =>
setConfig({ ...config, bucket_name: e.target.value })
}
placeholder="my-backup-bucket"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="region">Region</Label>
<Input
id="region"
type="text"
value={config.region}
onChange={(e) =>
setConfig({ ...config, region: e.target.value })
}
placeholder="us-east-1"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="endpoint_url">
Custom Endpoint URL (Optional)
</Label>
<Input
id="endpoint_url"
type="url"
value={config.endpoint_url || ""}
onChange={(e) =>
setConfig({ ...config, endpoint_url: e.target.value })
}
placeholder="https://custom-s3-endpoint.com"
/>
<p className="text-xs text-muted-foreground">
For S3-compatible services like MinIO or DigitalOcean Spaces
</p>
</div>
<div className="flex items-center space-x-2">
<Switch
id="path_style"
checked={config.path_style}
onCheckedChange={(checked) =>
setConfig({ ...config, path_style: checked })
}
/>
<Label htmlFor="path_style">Use path-style addressing</Label>
</div>
<p className="text-xs text-muted-foreground">
Enable for older S3 implementations or certain S3-compatible
services
</p>
</>
)}
<DrawerFooter className="px-0">
<div className="flex space-x-2">
<Button
type="submit"
disabled={updateMutation.isPending || !config.enabled}
>
{updateMutation.isPending
? "Saving..."
: "Save Configuration"}
</Button>
{config.enabled && isConfigValid && (
<Button
type="button"
variant="outline"
onClick={handleTest}
disabled={testMutation.isPending}
>
{testMutation.isPending ? (
<>
<TestTube className="h-4 w-4 mr-2 animate-spin" />
Testing...
</>
) : (
<>
<TestTube className="h-4 w-4 mr-2" />
Test
</>
)}
</Button>
)}
</div>
<DrawerClose asChild>
<Button variant="ghost">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</form>
</div>
</DrawerContent>
</Drawer>
);
}

View File

@@ -0,0 +1,984 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
CreditCard,
TrendingUp,
TrendingDown,
Building2,
RefreshCw,
AlertCircle,
Edit2,
Check,
X,
Bell,
MessageSquare,
Send,
Trash2,
User,
Filter,
Cloud,
Archive,
Eye,
} from "lucide-react";
import { toast } from "sonner";
import { apiClient } from "../lib/api";
import { formatCurrency, formatDate } from "../lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./ui/card";
import { Button } from "./ui/button";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Label } from "./ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
import AccountsSkeleton from "./AccountsSkeleton";
import NotificationFiltersDrawer from "./NotificationFiltersDrawer";
import DiscordConfigDrawer from "./DiscordConfigDrawer";
import TelegramConfigDrawer from "./TelegramConfigDrawer";
import AddBankAccountDrawer from "./AddBankAccountDrawer";
import S3BackupConfigDrawer from "./S3BackupConfigDrawer";
import type {
Account,
Balance,
NotificationSettings,
NotificationService,
BackupSettings,
BackupInfo,
} from "../types/api";
// Helper function to get status indicator color and styles
const getStatusIndicator = (status: string) => {
const statusLower = status.toLowerCase();
switch (statusLower) {
case "ready":
return {
color: "bg-green-500",
tooltip: "Ready",
};
case "pending":
return {
color: "bg-amber-500",
tooltip: "Pending",
};
case "error":
case "failed":
return {
color: "bg-destructive",
tooltip: "Error",
};
case "inactive":
return {
color: "bg-muted-foreground",
tooltip: "Inactive",
};
default:
return {
color: "bg-primary",
tooltip: status,
};
}
};
export default function Settings() {
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
const [showBackups, setShowBackups] = useState(false);
const queryClient = useQueryClient();
// Account queries
const {
data: accounts,
isLoading: accountsLoading,
error: accountsError,
refetch: refetchAccounts,
} = useQuery<Account[]>({
queryKey: ["accounts"],
queryFn: apiClient.getAccounts,
});
const { data: balances } = useQuery<Balance[]>({
queryKey: ["balances"],
queryFn: () => apiClient.getBalances(),
});
// Notification queries
const {
data: notificationSettings,
isLoading: settingsLoading,
error: settingsError,
refetch: refetchSettings,
} = useQuery<NotificationSettings>({
queryKey: ["notificationSettings"],
queryFn: apiClient.getNotificationSettings,
});
const {
data: services,
isLoading: servicesLoading,
error: servicesError,
refetch: refetchServices,
} = useQuery<NotificationService[]>({
queryKey: ["notificationServices"],
queryFn: apiClient.getNotificationServices,
});
const { data: bankConnections } = useQuery({
queryKey: ["bankConnections"],
queryFn: apiClient.getBankConnectionsStatus,
});
// Backup queries
const {
data: backupSettings,
isLoading: backupLoading,
error: backupError,
refetch: refetchBackup,
} = useQuery<BackupSettings>({
queryKey: ["backupSettings"],
queryFn: apiClient.getBackupSettings,
});
const {
data: backups,
isLoading: backupsLoading,
error: backupsError,
refetch: refetchBackups,
} = useQuery<BackupInfo[]>({
queryKey: ["backups"],
queryFn: apiClient.listBackups,
enabled: showBackups,
});
// Account mutations
const updateAccountMutation = useMutation({
mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
apiClient.updateAccount(id, { display_name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["accounts"] });
setEditingAccountId(null);
setEditingName("");
},
onError: (error) => {
console.error("Failed to update account:", error);
},
});
// Notification mutations
const deleteServiceMutation = useMutation({
mutationFn: apiClient.deleteNotificationService,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
queryClient.invalidateQueries({ queryKey: ["notificationServices"] });
},
});
// Bank connection mutations
const deleteBankConnectionMutation = useMutation({
mutationFn: apiClient.deleteBankConnection,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["accounts"] });
queryClient.invalidateQueries({ queryKey: ["bankConnections"] });
queryClient.invalidateQueries({ queryKey: ["balances"] });
},
});
// Backup mutations
const createBackupMutation = useMutation({
mutationFn: () => apiClient.performBackupOperation({ operation: "backup" }),
onSuccess: (response) => {
if (response.success) {
toast.success(response.message || "Backup created successfully!");
queryClient.invalidateQueries({ queryKey: ["backups"] });
} else {
toast.error(response.message || "Failed to create backup.");
}
},
onError: (error: Error & { response?: { data?: { detail?: string } } }) => {
console.error("Failed to create backup:", error);
const message =
error?.response?.data?.detail ||
"Failed to create backup. Please check your S3 configuration.";
toast.error(message);
},
});
// Account handlers
const handleEditStart = (account: Account) => {
setEditingAccountId(account.id);
setEditingName(account.display_name || account.name || "");
};
const handleEditSave = () => {
if (editingAccountId && editingName.trim()) {
updateAccountMutation.mutate({
id: editingAccountId,
display_name: editingName.trim(),
});
}
};
const handleEditCancel = () => {
setEditingAccountId(null);
setEditingName("");
};
// Notification handlers
const handleDeleteService = (serviceName: string) => {
if (
confirm(
`Are you sure you want to delete the ${serviceName} notification service?`,
)
) {
deleteServiceMutation.mutate(serviceName.toLowerCase());
}
};
// Backup handlers
const handleCreateBackup = () => {
if (!backupSettings?.s3?.enabled) {
toast.error("S3 backup is not enabled. Please configure and enable S3 backup first.");
return;
}
createBackupMutation.mutate();
};
const handleViewBackups = () => {
if (!backupSettings?.s3?.enabled) {
toast.error("S3 backup is not enabled. Please configure and enable S3 backup first.");
return;
}
setShowBackups(true);
};
const isLoading =
accountsLoading || settingsLoading || servicesLoading || backupLoading;
const hasError =
accountsError || settingsError || servicesError || backupError;
if (isLoading) {
return <AccountsSkeleton />;
}
if (hasError) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Failed to load settings</AlertTitle>
<AlertDescription className="space-y-3">
<p>
Unable to connect to the Leggen API. Please check your configuration
and ensure the API server is running.
</p>
<Button
onClick={() => {
refetchAccounts();
refetchSettings();
refetchServices();
refetchBackup();
}}
variant="outline"
size="sm"
>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-6">
<Tabs defaultValue="accounts" className="space-y-6">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="accounts" className="flex items-center space-x-2">
<User className="h-4 w-4" />
<span>Accounts</span>
</TabsTrigger>
<TabsTrigger
value="notifications"
className="flex items-center space-x-2"
>
<Bell className="h-4 w-4" />
<span>Notifications</span>
</TabsTrigger>
<TabsTrigger value="backup" className="flex items-center space-x-2">
<Cloud className="h-4 w-4" />
<span>Backup</span>
</TabsTrigger>
</TabsList>
<TabsContent value="accounts" className="space-y-6">
{/* Account Management Section */}
<Card>
<CardHeader>
<CardTitle>Account Management</CardTitle>
<CardDescription>
Manage your connected bank accounts and customize their display
names
</CardDescription>
</CardHeader>
{!accounts || accounts.length === 0 ? (
<CardContent className="p-6 text-center">
<CreditCard className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
No accounts found
</h3>
<p className="text-muted-foreground mb-4">
Connect your first bank account to get started with Leggen.
</p>
</CardContent>
) : (
<CardContent className="p-0">
<div className="divide-y divide-border">
{accounts.map((account) => {
// Get balance from account's balances array or fallback to balances query
const accountBalance = account.balances?.[0];
const fallbackBalance = balances?.find(
(b) => b.account_id === account.id,
);
const balance =
accountBalance?.amount ||
fallbackBalance?.balance_amount ||
0;
const currency =
accountBalance?.currency ||
fallbackBalance?.currency ||
account.currency ||
"EUR";
const isPositive = balance >= 0;
return (
<div
key={account.id}
className="p-4 sm:p-6 hover:bg-accent transition-colors"
>
{/* Mobile layout - stack vertically */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
<div className="flex-shrink-0 w-10 h-10 sm:w-12 sm:h-12 rounded-full overflow-hidden bg-muted flex items-center justify-center">
{account.logo && !failedImages.has(account.id) ? (
<img
src={account.logo}
alt={`${account.institution_id} logo`}
className="w-6 h-6 sm:w-8 sm:h-8 object-contain"
onError={() => {
console.warn(
`Failed to load bank logo for ${account.institution_id}: ${account.logo}`,
);
setFailedImages(
(prev) => new Set([...prev, account.id]),
);
}}
/>
) : (
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
{editingAccountId === account.id ? (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<input
type="text"
value={editingName}
onChange={(e) =>
setEditingName(e.target.value)
}
className="flex-1 px-3 py-1 text-base sm:text-lg font-medium border border-input rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
placeholder="Custom account name"
name="search"
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter") handleEditSave();
if (e.key === "Escape")
handleEditCancel();
}}
autoFocus
/>
<Button
onClick={handleEditSave}
disabled={
!editingName.trim() ||
updateAccountMutation.isPending
}
size="icon"
variant="ghost"
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-100"
title="Save changes"
>
<Check className="h-4 w-4" />
</Button>
<Button
onClick={handleEditCancel}
size="icon"
variant="ghost"
className="h-8 w-8"
title="Cancel editing"
>
<X className="h-4 w-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}
</p>
</div>
) : (
<div>
<div className="flex items-center space-x-2 min-w-0">
<h4 className="text-base sm:text-lg font-medium text-foreground truncate">
{account.display_name ||
account.name ||
"Unnamed Account"}
</h4>
<Button
onClick={() => handleEditStart(account)}
size="icon"
variant="ghost"
className="h-7 w-7 flex-shrink-0"
title="Edit account name"
>
<Edit2 className="h-4 w-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}
</p>
{account.iban && (
<p className="text-xs text-muted-foreground mt-1 font-mono break-all sm:break-normal">
IBAN: {account.iban}
</p>
)}
</div>
)}
</div>
</div>
{/* Balance and date section */}
<div className="flex items-center justify-between sm:flex-col sm:items-end sm:text-right flex-shrink-0">
{/* Date and status indicator - left on mobile, bottom on desktop */}
<div className="flex items-center space-x-2 order-1 sm:order-2">
<div
className={`w-3 h-3 rounded-full ${getStatusIndicator(account.status).color} relative group cursor-help`}
role="img"
aria-label={`Account status: ${getStatusIndicator(account.status).tooltip}`}
>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 hidden group-hover:block bg-gray-900 text-white text-xs rounded py-1 px-2 whitespace-nowrap z-10">
{getStatusIndicator(account.status).tooltip}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-2 border-transparent border-t-gray-900"></div>
</div>
</div>
<p className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
Updated{" "}
{formatDate(
account.last_accessed || account.created,
)}
</p>
</div>
{/* Balance - right on mobile, top on desktop */}
<div className="flex items-center space-x-2 order-2 sm:order-1">
{isPositive ? (
<TrendingUp className="h-4 w-4 text-green-500" />
) : (
<TrendingDown className="h-4 w-4 text-red-500" />
)}
<p
className={`text-base sm:text-lg font-semibold ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{formatCurrency(balance, currency)}
</p>
</div>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
)}
</Card>
{/* Bank Connections Status */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Bank Connections</CardTitle>
<CardDescription>
Status of all bank connection requests and their
authorization state
</CardDescription>
</div>
<AddBankAccountDrawer />
</div>
</CardHeader>
{!bankConnections || bankConnections.length === 0 ? (
<CardContent className="p-6 text-center">
<Building2 className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
No bank connections found
</h3>
<p className="text-muted-foreground">
Bank connection requests will appear here after you connect
accounts.
</p>
</CardContent>
) : (
<CardContent className="p-0">
<div className="divide-y divide-border">
{bankConnections.map((connection) => {
const statusColor =
connection.status.toLowerCase() === "ln"
? "bg-green-500"
: connection.status.toLowerCase() === "cr"
? "bg-amber-500"
: connection.status.toLowerCase() === "ex"
? "bg-red-500"
: "bg-muted-foreground";
return (
<div
key={connection.requisition_id}
className="p-4 sm:p-6 hover:bg-accent transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4 min-w-0 flex-1">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-muted flex items-center justify-center">
<Building2 className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<h4 className="text-base font-medium text-foreground truncate">
{connection.bank_name}
</h4>
<div
className={`w-3 h-3 rounded-full ${statusColor}`}
title={connection.status_display}
/>
</div>
<p className="text-sm text-muted-foreground">
{connection.status_display} {" "}
{connection.accounts_count} account
{connection.accounts_count !== 1 ? "s" : ""}
</p>
<p className="text-xs text-muted-foreground font-mono">
ID: {connection.requisition_id}
</p>
</div>
</div>
<div className="flex items-center space-x-2 flex-shrink-0">
<div className="text-right">
<p className="text-xs text-muted-foreground">
Created {formatDate(connection.created_at)}
</p>
</div>
<Button
onClick={() => {
const isWorking =
connection.status.toLowerCase() === "ln";
const message = isWorking
? `Are you sure you want to disconnect "${connection.bank_name}"? This will stop syncing new transactions but keep your existing transaction history.`
: `Delete connection to ${connection.bank_name}?`;
if (confirm(message)) {
deleteBankConnectionMutation.mutate(
connection.requisition_id,
);
}
}}
disabled={deleteBankConnectionMutation.isPending}
size="icon"
variant="ghost"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
title="Delete connection"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
)}
</Card>
</TabsContent>
<TabsContent value="notifications" className="space-y-6">
{/* Notification Services */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Bell className="h-5 w-5 text-primary" />
<span>Notification Services</span>
</CardTitle>
<CardDescription>
Manage your notification services
</CardDescription>
</CardHeader>
{!services || services.length === 0 ? (
<CardContent className="text-center">
<Bell className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
No notification services configured
</h3>
<p className="text-muted-foreground">
Configure notification services in your backend to receive
alerts.
</p>
</CardContent>
) : (
<CardContent className="p-0">
<div className="divide-y divide-border">
{services.map((service) => (
<div
key={service.name}
className="p-6 hover:bg-accent transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="p-3 bg-muted rounded-full">
{service.name.toLowerCase().includes("discord") ? (
<MessageSquare className="h-6 w-6 text-muted-foreground" />
) : service.name
.toLowerCase()
.includes("telegram") ? (
<Send className="h-6 w-6 text-muted-foreground" />
) : (
<Bell className="h-6 w-6 text-muted-foreground" />
)}
</div>
<div className="flex-1">
<div className="flex items-center space-x-3">
<h4 className="text-lg font-medium text-foreground capitalize">
{service.name}
</h4>
<div className="flex items-center space-x-2">
<div
className={`w-2 h-2 rounded-full ${
service.enabled && service.configured
? "bg-green-500"
: service.enabled
? "bg-amber-500"
: "bg-muted-foreground"
}`}
/>
<span className="text-sm text-muted-foreground">
{service.enabled && service.configured
? "Active"
: service.enabled
? "Needs Configuration"
: "Disabled"}
</span>
</div>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
{service.name.toLowerCase().includes("discord") ? (
<DiscordConfigDrawer
settings={notificationSettings}
/>
) : service.name
.toLowerCase()
.includes("telegram") ? (
<TelegramConfigDrawer
settings={notificationSettings}
/>
) : null}
<Button
onClick={() => handleDeleteService(service.name)}
disabled={deleteServiceMutation.isPending}
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
</div>
</CardContent>
)}
</Card>
{/* Notification Filters */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center space-x-2">
<Filter className="h-5 w-5 text-primary" />
<span>Notification Filters</span>
</CardTitle>
<NotificationFiltersDrawer settings={notificationSettings} />
</div>
</CardHeader>
<CardContent>
{notificationSettings?.filters ? (
<div className="space-y-4">
<div className="bg-muted rounded-md p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium text-muted-foreground mb-2 block">
Case Insensitive Filters
</Label>
<div className="min-h-[2rem] flex flex-wrap gap-1">
{notificationSettings.filters.case_insensitive
.length > 0 ? (
notificationSettings.filters.case_insensitive.map(
(filter, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-1 bg-secondary text-secondary-foreground rounded text-xs"
>
{filter}
</span>
),
)
) : (
<p className="text-sm text-muted-foreground">
None
</p>
)}
</div>
</div>
<div>
<Label className="text-xs font-medium text-muted-foreground mb-2 block">
Case Sensitive Filters
</Label>
<div className="min-h-[2rem] flex flex-wrap gap-1">
{notificationSettings.filters.case_sensitive &&
notificationSettings.filters.case_sensitive.length >
0 ? (
notificationSettings.filters.case_sensitive.map(
(filter, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-1 bg-secondary text-secondary-foreground rounded text-xs"
>
{filter}
</span>
),
)
) : (
<p className="text-sm text-muted-foreground">
None
</p>
)}
</div>
</div>
</div>
</div>
<p className="text-sm text-muted-foreground">
Filters determine which transaction descriptions will
trigger notifications. Add terms to exclude transactions
containing those words.
</p>
</div>
) : (
<div className="text-center py-8">
<Filter className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
No notification filters configured
</h3>
<p className="text-muted-foreground mb-4">
Set up filters to control which transactions trigger
notifications.
</p>
<NotificationFiltersDrawer settings={notificationSettings} />
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="backup" className="space-y-6">
{/* S3 Backup Configuration */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Cloud className="h-5 w-5 text-primary" />
<span>S3 Backup Configuration</span>
</CardTitle>
<CardDescription>
Configure automatic database backups to Amazon S3 or
S3-compatible storage
</CardDescription>
</CardHeader>
<CardContent>
{!backupSettings?.s3 ? (
<div className="text-center py-8">
<Cloud className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
No S3 backup configured
</h3>
<p className="text-muted-foreground mb-4">
Set up S3 backup to automatically backup your database to
the cloud.
</p>
<S3BackupConfigDrawer settings={backupSettings} />
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center space-x-4">
<div className="p-3 bg-muted rounded-full">
<Cloud className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<div className="flex items-center space-x-3">
<h4 className="text-lg font-medium text-foreground">
S3 Backup
</h4>
<div className="flex items-center space-x-2">
<div
className={`w-2 h-2 rounded-full ${
backupSettings.s3.enabled
? "bg-green-500"
: "bg-muted-foreground"
}`}
/>
<span className="text-sm text-muted-foreground">
{backupSettings.s3.enabled
? "Enabled"
: "Disabled"}
</span>
</div>
</div>
<div className="mt-2 space-y-1">
<p className="text-sm text-muted-foreground">
<span className="font-medium">Bucket:</span>{" "}
{backupSettings.s3.bucket_name}
</p>
<p className="text-sm text-muted-foreground">
<span className="font-medium">Region:</span>{" "}
{backupSettings.s3.region}
</p>
{backupSettings.s3.endpoint_url && (
<p className="text-sm text-muted-foreground">
<span className="font-medium">Endpoint:</span>{" "}
{backupSettings.s3.endpoint_url}
</p>
)}
</div>
</div>
</div>
<S3BackupConfigDrawer settings={backupSettings} />
</div>
<div className="p-4 bg-muted rounded-lg">
<h5 className="font-medium mb-2">Backup Information</h5>
<p className="text-sm text-muted-foreground mb-3">
Database backups are stored in the "leggen_backups/"
folder in your S3 bucket. Backups include the complete
SQLite database file.
</p>
<div className="flex space-x-2">
<Button
size="sm"
variant="outline"
onClick={handleCreateBackup}
disabled={createBackupMutation.isPending}
>
{createBackupMutation.isPending ? (
<>
<Archive className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<Archive className="h-4 w-4 mr-2" />
Create Backup Now
</>
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleViewBackups}
>
<Eye className="h-4 w-4 mr-2" />
View Backups
</Button>
</div>
</div>
{/* Backup List Modal/View */}
{showBackups && (
<div className="mt-6 p-4 border rounded-lg bg-background">
<div className="flex items-center justify-between mb-4">
<h5 className="font-medium">Available Backups</h5>
<Button
size="sm"
variant="ghost"
onClick={() => setShowBackups(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
{backupsLoading ? (
<p className="text-sm text-muted-foreground">Loading backups...</p>
) : backupsError ? (
<div className="space-y-2">
<p className="text-sm text-destructive">Failed to load backups</p>
<Button
size="sm"
variant="outline"
onClick={() => refetchBackups()}
>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
) : !backups || backups.length === 0 ? (
<p className="text-sm text-muted-foreground">No backups found</p>
) : (
<div className="space-y-2">
{backups.map((backup, index) => (
<div
key={backup.key || index}
className="flex items-center justify-between p-3 border rounded bg-muted/50"
>
<div>
<p className="text-sm font-medium">{backup.key}</p>
<div className="flex items-center space-x-4 text-xs text-muted-foreground mt-1">
<span>Modified: {formatDate(backup.last_modified)}</span>
<span>Size: {(backup.size / 1024 / 1024).toFixed(2)} MB</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

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

View File

@@ -0,0 +1,358 @@
import { useQuery } from "@tanstack/react-query";
import {
RefreshCw,
AlertCircle,
CheckCircle,
Activity,
Clock,
TrendingUp,
User,
FileText,
} from "lucide-react";
import { apiClient } from "../lib/api";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./ui/card";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Button } from "./ui/button";
import { Badge } from "./ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "./ui/dialog";
import { ScrollArea } from "./ui/scroll-area";
import type { SyncOperationsResponse, SyncOperation } from "../types/api";
// Component for viewing sync operation logs
function LogsDialog({ operation }: { operation: SyncOperation }) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="shrink-0">
<FileText className="h-3 w-3 mr-1" />
<span className="hidden sm:inline">View Logs</span>
<span className="sm:hidden">Logs</span>
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>Sync Operation Logs</DialogTitle>
<DialogDescription>
Operation #{operation.id} - Started at{" "}
{new Date(operation.started_at).toLocaleString()}
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[60vh] w-full rounded border p-4">
<div className="space-y-2">
{operation.logs.length === 0 ? (
<p className="text-muted-foreground text-sm">No logs available</p>
) : (
operation.logs.map((log, index) => (
<div
key={index}
className="text-sm font-mono bg-muted/50 p-2 rounded text-wrap break-all"
>
{log}
</div>
))
)}
</div>
{operation.errors.length > 0 && (
<>
<div className="mt-4 mb-2 text-sm font-semibold text-destructive">
Errors:
</div>
<div className="space-y-2">
{operation.errors.map((error, index) => (
<div
key={index}
className="text-sm font-mono bg-destructive/10 border border-destructive/20 p-2 rounded text-wrap break-all text-destructive"
>
{error}
</div>
))}
</div>
</>
)}
</ScrollArea>
</DialogContent>
</Dialog>
);
}
export default function System() {
const {
data: syncOperations,
isLoading: syncOperationsLoading,
error: syncOperationsError,
refetch: refetchSyncOperations,
} = useQuery<SyncOperationsResponse>({
queryKey: ["syncOperations"],
queryFn: () => apiClient.getSyncOperations(10, 0), // Get latest 10 operations
});
if (syncOperationsLoading) {
return (
<div className="space-y-6">
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-center">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">
Loading system status...
</span>
</div>
</CardContent>
</Card>
</div>
);
}
if (syncOperationsError) {
return (
<div className="space-y-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Failed to load system data</AlertTitle>
<AlertDescription className="space-y-3">
<p>
Unable to connect to the Leggen API. Please check your
configuration and ensure the API server is running.
</p>
<Button
onClick={() => refetchSyncOperations()}
variant="outline"
size="sm"
>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="space-y-6">
{/* Sync Operations Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Activity className="h-5 w-5 text-primary" />
<span>Recent Sync Operations</span>
</CardTitle>
<CardDescription>
Latest synchronization activities and their status
</CardDescription>
</CardHeader>
<CardContent>
{!syncOperations || syncOperations.operations.length === 0 ? (
<div className="text-center py-6">
<Activity className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
No sync operations yet
</h3>
<p className="text-muted-foreground">
Sync operations will appear here once you start syncing your
accounts.
</p>
</div>
) : (
<div className="space-y-4">
{syncOperations.operations.slice(0, 10).map((operation) => {
const startedAt = new Date(operation.started_at);
const isRunning = !operation.completed_at;
const duration = operation.duration_seconds
? `${Math.round(operation.duration_seconds)}s`
: "";
return (
<div
key={operation.id}
className="border rounded-lg hover:bg-accent transition-colors"
>
{/* Desktop Layout */}
<div className="hidden md:flex items-center justify-between p-4">
<div className="flex items-center space-x-4">
<div
className={`p-2 rounded-full ${
isRunning
? "bg-blue-100 text-blue-600"
: operation.success
? "bg-green-100 text-green-600"
: "bg-red-100 text-red-600"
}`}
>
{isRunning ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : operation.success ? (
<CheckCircle className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
</div>
<div>
<div className="flex items-center space-x-2">
<h4 className="text-sm font-medium text-foreground">
{isRunning
? "Sync Running"
: operation.success
? "Sync Completed"
: "Sync Failed"}
</h4>
<Badge variant="outline" className="text-xs">
{operation.trigger_type.charAt(0).toUpperCase() +
operation.trigger_type.slice(1)}
</Badge>
</div>
<div className="flex items-center space-x-4 mt-1 text-xs text-muted-foreground">
<span className="flex items-center space-x-1">
<Clock className="h-3 w-3" />
<span>
{startedAt.toLocaleDateString()}{" "}
{startedAt.toLocaleTimeString()}
</span>
</span>
{duration && <span>Duration: {duration}</span>}
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="text-right text-sm text-muted-foreground">
<div className="flex items-center space-x-2">
<User className="h-3 w-3" />
<span>{operation.accounts_processed} accounts</span>
</div>
<div className="flex items-center space-x-2 mt-1">
<TrendingUp className="h-3 w-3" />
<span>
{operation.transactions_added} new transactions
</span>
</div>
</div>
<LogsDialog operation={operation} />
</div>
</div>
{/* Mobile Layout */}
<div className="md:hidden p-4 space-y-3">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
<div
className={`p-2 rounded-full ${
isRunning
? "bg-blue-100 text-blue-600"
: operation.success
? "bg-green-100 text-green-600"
: "bg-red-100 text-red-600"
}`}
>
{isRunning ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : operation.success ? (
<CheckCircle className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
</div>
<div>
<h4 className="text-sm font-medium text-foreground">
{isRunning
? "Sync Running"
: operation.success
? "Sync Completed"
: "Sync Failed"}
</h4>
<Badge variant="outline" className="text-xs mt-1">
{operation.trigger_type.charAt(0).toUpperCase() +
operation.trigger_type.slice(1)}
</Badge>
</div>
</div>
<LogsDialog operation={operation} />
</div>
<div className="text-xs text-muted-foreground space-y-2">
<div className="flex items-center space-x-1">
<Clock className="h-3 w-3" />
<span>
{startedAt.toLocaleDateString()}{" "}
{startedAt.toLocaleTimeString()}
</span>
{duration && (
<span className="ml-2"> {duration}</span>
)}
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center space-x-1">
<User className="h-3 w-3" />
<span>{operation.accounts_processed} accounts</span>
</div>
<div className="flex items-center space-x-1">
<TrendingUp className="h-3 w-3" />
<span>
{operation.transactions_added} new transactions
</span>
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* System Health Summary Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<CheckCircle className="h-5 w-5 text-green-500" />
<span>System Health</span>
</CardTitle>
<CardDescription>
Overall system status and performance
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="text-center p-4 bg-green-50 rounded-lg border border-green-200">
<div className="text-2xl font-bold text-green-700">
{syncOperations?.operations.filter((op) => op.success).length ||
0}
</div>
<div className="text-sm text-green-600">Successful Syncs</div>
</div>
<div className="text-center p-4 bg-red-50 rounded-lg border border-red-200">
<div className="text-2xl font-bold text-red-700">
{syncOperations?.operations.filter(
(op) => !op.success && op.completed_at,
).length || 0}
</div>
<div className="text-sm text-red-600">Failed Syncs</div>
</div>
<div className="text-center p-4 bg-blue-50 rounded-lg border border-blue-200">
<div className="text-2xl font-bold text-blue-700">
{syncOperations?.operations.filter((op) => !op.completed_at)
.length || 0}
</div>
<div className="text-sm text-blue-600">Running Operations</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,222 @@
import { useState, useEffect } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Send, TestTube } from "lucide-react";
import { apiClient } from "../lib/api";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Switch } from "./ui/switch";
import { EditButton } from "./ui/edit-button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "./ui/drawer";
import type { NotificationSettings, TelegramConfig } from "../types/api";
interface TelegramConfigDrawerProps {
settings: NotificationSettings | undefined;
trigger?: React.ReactNode;
}
export default function TelegramConfigDrawer({
settings,
trigger,
}: TelegramConfigDrawerProps) {
const [open, setOpen] = useState(false);
const [config, setConfig] = useState<TelegramConfig>({
token: "",
chat_id: 0,
enabled: true,
});
const queryClient = useQueryClient();
useEffect(() => {
if (settings?.telegram) {
setConfig({ ...settings.telegram });
}
}, [settings]);
const updateMutation = useMutation({
mutationFn: (telegramConfig: TelegramConfig) =>
apiClient.updateNotificationSettings({
...settings,
telegram: telegramConfig,
filters: settings?.filters || {
case_insensitive: [],
case_sensitive: [],
},
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
queryClient.invalidateQueries({ queryKey: ["notificationServices"] });
setOpen(false);
},
onError: (error) => {
console.error("Failed to update Telegram configuration:", error);
},
});
const testMutation = useMutation({
mutationFn: () =>
apiClient.testNotification({
service: "telegram",
message:
"Test notification from Leggen - Telegram configuration is working!",
}),
onSuccess: () => {
console.log("Test Telegram notification sent successfully");
},
onError: (error) => {
console.error("Failed to send test Telegram notification:", error);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
updateMutation.mutate(config);
};
const handleTest = () => {
testMutation.mutate();
};
const isConfigValid = config.token.trim().length > 0 && config.chat_id !== 0;
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{trigger || <EditButton />}</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-md">
<DrawerHeader>
<DrawerTitle className="flex items-center space-x-2">
<Send className="h-5 w-5 text-primary" />
<span>Telegram Configuration</span>
</DrawerTitle>
<DrawerDescription>
Configure Telegram bot notifications for transaction alerts
</DrawerDescription>
</DrawerHeader>
<form onSubmit={handleSubmit} className="p-4 space-y-6">
{/* Enable/Disable Toggle */}
<div className="flex items-center justify-between">
<Label className="text-base font-medium">
Enable Telegram Notifications
</Label>
<Switch
checked={config.enabled}
onCheckedChange={(enabled) => setConfig({ ...config, enabled })}
/>
</div>
{/* Bot Token */}
<div className="space-y-2">
<Label htmlFor="telegram-token">Bot Token</Label>
<Input
id="telegram-token"
type="password"
placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
value={config.token}
onChange={(e) =>
setConfig({ ...config, token: e.target.value })
}
disabled={!config.enabled}
/>
<p className="text-xs text-muted-foreground">
Create a bot using @BotFather on Telegram to get your token
</p>
</div>
{/* Chat ID */}
<div className="space-y-2">
<Label htmlFor="telegram-chat-id">Chat ID</Label>
<Input
id="telegram-chat-id"
type="number"
placeholder="123456789"
value={config.chat_id || ""}
onChange={(e) =>
setConfig({
...config,
chat_id: parseInt(e.target.value) || 0,
})
}
disabled={!config.enabled}
/>
<p className="text-xs text-muted-foreground">
Send a message to your bot and visit
https://api.telegram.org/bot&lt;token&gt;/getUpdates to find
your chat ID
</p>
</div>
{/* Configuration Status */}
{config.enabled && (
<div className="p-3 bg-muted rounded-md">
<div className="flex items-center space-x-2">
<div
className={`w-2 h-2 rounded-full ${isConfigValid ? "bg-green-500" : "bg-red-500"}`}
/>
<span className="text-sm font-medium">
{isConfigValid
? "Configuration Valid"
: "Missing Token or Chat ID"}
</span>
</div>
{!isConfigValid &&
(config.token.trim().length > 0 || config.chat_id !== 0) && (
<p className="text-xs text-muted-foreground mt-1">
Both bot token and chat ID are required
</p>
)}
</div>
)}
<DrawerFooter className="px-0">
<div className="flex space-x-2">
<Button
type="submit"
disabled={updateMutation.isPending || !config.enabled}
>
{updateMutation.isPending
? "Saving..."
: "Save Configuration"}
</Button>
{config.enabled && isConfigValid && (
<Button
type="button"
variant="outline"
onClick={handleTest}
disabled={testMutation.isPending}
>
{testMutation.isPending ? (
<>
<TestTube className="h-4 w-4 mr-2 animate-spin" />
Testing...
</>
) : (
<>
<TestTube className="h-4 w-4 mr-2" />
Test
</>
)}
</Button>
)}
</div>
<DrawerClose asChild>
<Button variant="ghost">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</form>
</div>
</DrawerContent>
</Drawer>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,80 @@
import type { LucideIcon } from "lucide-react";
import { Card, CardContent } from "../ui/card";
import { cn } from "../../lib/utils";
interface StatCardProps {
title: string;
value: string | number;
subtitle?: string;
icon: LucideIcon;
trend?: {
value: number;
isPositive: boolean;
};
className?: string;
iconColor?: "green" | "blue" | "red" | "purple" | "orange" | "default";
}
export default function StatCard({
title,
value,
subtitle,
icon: Icon,
trend,
className,
iconColor = "default",
}: StatCardProps) {
return (
<Card className={cn(className)}>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<div className="flex items-baseline">
<p className="text-2xl font-bold text-foreground">{value}</p>
{trend && (
<div
className={cn(
"ml-2 flex items-baseline text-sm font-semibold",
trend.isPositive
? "text-green-600 dark:text-green-400"
: "text-red-600 dark:text-red-400",
)}
>
{trend.isPositive ? "+" : ""}
{trend.value}%
</div>
)}
</div>
{subtitle && (
<p className="text-sm text-muted-foreground mt-1">{subtitle}</p>
)}
</div>
<div
className={cn(
"p-3 rounded-full",
iconColor === "green" && "bg-green-100 dark:bg-green-900/20",
iconColor === "blue" && "bg-blue-100 dark:bg-blue-900/20",
iconColor === "red" && "bg-red-100 dark:bg-red-900/20",
iconColor === "purple" && "bg-purple-100 dark:bg-purple-900/20",
iconColor === "orange" && "bg-orange-100 dark:bg-orange-900/20",
iconColor === "default" && "bg-muted",
)}
>
<Icon
className={cn(
"h-6 w-6",
iconColor === "green" && "text-green-600",
iconColor === "blue" && "text-blue-600",
iconColor === "red" && "text-red-600",
iconColor === "purple" && "text-purple-600",
iconColor === "orange" && "text-orange-600",
iconColor === "default" && "text-muted-foreground",
)}
/>
</div>
</div>
</CardContent>
</Card>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
export { FilterBar } from "./FilterBar";
export { DateRangePicker } from "./DateRangePicker";
export { AccountCombobox } from "./AccountCombobox";
export { ActiveFilterChips } from "./ActiveFilterChips";
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,116 @@
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
);
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className,
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@@ -0,0 +1,45 @@
import { Edit3 } from "lucide-react";
import { Button } from "./button";
import { cn } from "../../lib/utils";
interface EditButtonProps {
onClick?: () => void;
disabled?: boolean;
className?: string;
size?: "default" | "sm" | "lg" | "icon";
variant?:
| "default"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
children?: React.ReactNode;
}
export function EditButton({
onClick,
disabled = false,
className,
size = "sm",
variant = "outline",
children,
...props
}: EditButtonProps) {
return (
<Button
onClick={onClick}
disabled={disabled}
size={size}
variant={variant}
className={cn(
"h-8 px-3 text-muted-foreground hover:text-foreground transition-colors",
className,
)}
{...props}
>
<Edit3 className="h-4 w-4" />
<span className="ml-2">{children || "Edit"}</span>
</Button>
);
}

View File

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

View File

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

View File

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

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

View File

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

@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const ScrollArea = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn(
"relative overflow-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100",
className,
)}
{...props}
>
{children}
</div>
));
ScrollArea.displayName = "ScrollArea";
export { ScrollArea };

View File

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

View File

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

View File

@@ -0,0 +1,140 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className,
)}
{...props}
/>
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

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

View File

@@ -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-primary/10", 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,27 @@
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@@ -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,53 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -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,30 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -0,0 +1,111 @@
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,21 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}

View File

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

View File

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

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