Compare commits

..

58 Commits

Author SHA1 Message Date
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
114 changed files with 14803 additions and 1700 deletions

View File

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

View File

@@ -10,6 +10,7 @@ jobs:
test-python:
name: Test Python
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, 'chore(ci): Bump version to')"
steps:
- uses: actions/checkout@v4
@@ -32,6 +33,7 @@ jobs:
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

View File

@@ -144,23 +144,20 @@ jobs:
with:
fetch-depth: 0
- name: Install git-cliff
run: |
wget -qO- https://github.com/orhun/git-cliff/releases/latest/download/git-cliff-2.10.0-x86_64-unknown-linux-gnu.tar.gz | tar xz
sudo mv git-cliff-*/git-cliff /usr/local/bin/
- name: Generate release notes
uses: orhun/git-cliff-action@v4
id: release_notes
run: |
echo "notes<<EOF" >> $GITHUB_OUTPUT
git-cliff --current >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
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.notes }}
body: ${{ steps.release_notes.outputs.content }}
draft: false
prerelease: false

2
.gitignore vendored
View File

@@ -164,3 +164,5 @@ sql/
leggen.db
*.db
config.toml
.claude/
.playwright-mcp/

17
.mcp.json Normal file
View File

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

View File

@@ -81,10 +81,34 @@ The command outputs instructions for setting the required environment variable t
- **Naming**: PascalCase for components, camelCase for variables/functions
- **Types**: Use `import type` for type-only imports, define interfaces/types
- **Styling**: Tailwind CSS with `clsx` utility for conditional classes
- **UI Components**: shadcn/ui components for consistent design system
- **Icons**: lucide-react with consistent naming
- **Data fetching**: @tanstack/react-query with proper error handling
- **Components**: Functional components with hooks, proper TypeScript typing
## Frontend Structure
### Layout Architecture
- **Root Layout**: `frontend/src/routes/__root.tsx` - Contains main app structure with Sidebar and Header
- **Header/Navbar**: `frontend/src/components/Header.tsx` - Top navigation bar (sticky on mobile only)
- **Sidebar**: `frontend/src/components/Sidebar.tsx` - Left navigation sidebar
- **Routes**: `frontend/src/routes/` - TanStack Router file-based routing
### Key Components Location
- **UI Components**: `frontend/src/components/ui/` - shadcn/ui components and reusable UI primitives
- **Feature Components**: `frontend/src/components/` - Main app components
- **Pages**: `frontend/src/routes/` - Route components (index.tsx, transactions.tsx, etc.)
- **Hooks**: `frontend/src/hooks/` - Custom React hooks
- **API**: `frontend/src/lib/api.ts` - API client configuration
- **Context**: `frontend/src/contexts/` - React contexts (ThemeContext, etc.)
### Routing Structure
- `/` - Overview/Dashboard (TransactionsTable component)
- `/transactions` - Transactions page
- `/analytics` - Analytics page
- `/notifications` - Notifications page
- `/settings` - Settings page
### General
- **Formatting**: ruff for Python, ESLint for TypeScript
- **Commits**: Use conventional commits with optional scopes, run pre-commit hooks before pushing
@@ -98,9 +122,20 @@ The command outputs instructions for setting the required environment variable t
- 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,344 @@
## 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

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

@@ -20,6 +20,7 @@ Having your bank data accessible through both CLI and REST API gives you the pow
- [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
## ✨ Features
@@ -146,8 +147,8 @@ enabled = true
# Optional: Transaction filters for notifications
[filters]
case-insensitive = ["salary", "utility"]
case-sensitive = ["SpecificStore"]
case_insensitive = ["salary", "utility"]
case_sensitive = ["SpecificStore"]
```
## 📖 Usage

View File

@@ -20,11 +20,11 @@ enabled = true
# Optional: Telegram notifications
[notifications.telegram]
api-key = "your-bot-token"
chat-id = 12345
token = "your-bot-token"
chat_id = 12345
enabled = true
# Optional: Transaction filters for notifications
[filters]
case-insensitive = ["salary", "utility"]
case-sensitive = ["SpecificStore"]
case_insensitive = ["salary", "utility"]
case_sensitive = ["SpecificStore"]

View File

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

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

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

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>

File diff suppressed because it is too large Load Diff

View File

@@ -10,10 +10,26 @@
"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",
@@ -26,27 +42,35 @@
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.542.0",
"next-themes": "^0.4.6",
"postcss": "^8.5.6",
"react": "^19.1.1",
"react-day-picker": "^9.10.0",
"react-dom": "^19.1.1",
"recharts": "^3.2.0",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7"
"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

@@ -0,0 +1,363 @@
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
}
className="p-1 text-green-600 hover:text-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
title="Save changes"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={handleEditCancel}
className="p-1 text-gray-600 hover:text-gray-700"
title="Cancel editing"
>
<X className="h-4 w-4" />
</button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}
</p>
</div>
) : (
<div>
<div className="flex items-center space-x-2 min-w-0">
<h4 className="text-base sm:text-lg font-medium text-foreground truncate">
{account.display_name ||
account.name ||
"Unnamed Account"}
</h4>
<button
onClick={() => handleEditStart(account)}
className="flex-shrink-0 p-1 text-muted-foreground hover:text-foreground transition-colors"
title="Edit account name"
>
<Edit2 className="h-4 w-4" />
</button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}
</p>
{account.iban && (
<p className="text-xs text-muted-foreground mt-1 font-mono break-all sm:break-normal">
IBAN: {account.iban}
</p>
)}
</div>
)}
</div>
</div>
{/* Balance and date section */}
<div className="flex items-center justify-between sm:flex-col sm:items-end sm:text-right flex-shrink-0">
{/* Mobile: date/status on left, balance on right */}
{/* Desktop: balance on top, date/status on bottom */}
{/* Date and status indicator - left on mobile, bottom on desktop */}
<div className="flex items-center space-x-2 order-1 sm:order-2">
<div
className={`w-3 h-3 rounded-full ${getStatusIndicator(account.status).color} relative group cursor-help`}
role="img"
aria-label={`Account status: ${getStatusIndicator(account.status).tooltip}`}
>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 hidden group-hover:block bg-gray-900 text-white text-xs rounded py-1 px-2 whitespace-nowrap z-10">
{getStatusIndicator(account.status).tooltip}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-2 border-transparent border-t-gray-900"></div>
</div>
</div>
<p className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
Updated{" "}
{formatDate(
account.last_accessed || account.created,
)}
</p>
</div>
{/* Balance - right on mobile, top on desktop */}
<div className="flex items-center space-x-2 order-2 sm:order-1">
{isPositive ? (
<TrendingUp className="h-4 w-4 text-green-500" />
) : (
<TrendingDown className="h-4 w-4 text-red-500" />
)}
<p
className={`text-base sm:text-lg font-semibold ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{formatCurrency(balance, currency)}
</p>
</div>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
)}
</Card>
{/* Add Bank Section (Future Feature) */}
<Card>
<CardHeader>
<CardTitle>Add New Bank Account</CardTitle>
<CardDescription>
Connect additional bank accounts to track all your finances in one
place
</CardDescription>
</CardHeader>
<CardContent className="p-6">
<div className="text-center space-y-4">
<div className="p-4 bg-muted rounded-lg">
<Plus className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
Bank connection functionality is coming soon. Stay tuned for
updates!
</p>
</div>
<Button disabled variant="outline">
<Plus className="h-4 w-4 mr-2" />
Connect Bank Account
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -22,7 +22,7 @@ import {
} from "./ui/card";
import { Button } from "./ui/button";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import LoadingSpinner from "./LoadingSpinner";
import AccountsSkeleton from "./AccountsSkeleton";
import type { Account, Balance } from "../types/api";
// Helper function to get status indicator color and styles
@@ -37,23 +37,23 @@ const getStatusIndicator = (status: string) => {
};
case "pending":
return {
color: "bg-yellow-500",
color: "bg-amber-500",
tooltip: "Pending",
};
case "error":
case "failed":
return {
color: "bg-red-500",
color: "bg-destructive",
tooltip: "Error",
};
case "inactive":
return {
color: "bg-gray-500",
color: "bg-muted-foreground",
tooltip: "Inactive",
};
default:
return {
color: "bg-blue-500",
color: "bg-primary",
tooltip: status,
};
}
@@ -81,8 +81,8 @@ export default function AccountsOverview() {
const queryClient = useQueryClient();
const updateAccountMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
apiClient.updateAccount(id, { name }),
mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
apiClient.updateAccount(id, { display_name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["accounts"] });
setEditingAccountId(null);
@@ -95,14 +95,15 @@ export default function AccountsOverview() {
const handleEditStart = (account: Account) => {
setEditingAccountId(account.id);
setEditingName(account.name || "");
// 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,
name: editingName.trim(),
display_name: editingName.trim(),
});
}
};
@@ -113,11 +114,7 @@ export default function AccountsOverview() {
};
if (accountsLoading) {
return (
<Card>
<LoadingSpinner message="Loading accounts..." />
</Card>
);
return <AccountsSkeleton />;
}
if (accountsError) {
@@ -200,8 +197,8 @@ export default function AccountsOverview() {
{uniqueBanks}
</p>
</div>
<div className="p-3 bg-purple-100 dark:bg-purple-900/20 rounded-full">
<Building2 className="h-6 w-6 text-purple-600" />
<div className="p-3 bg-muted rounded-full">
<Building2 className="h-6 w-6 text-muted-foreground" />
</div>
</div>
</CardContent>
@@ -267,7 +264,7 @@ export default function AccountsOverview() {
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="Account name"
placeholder="Custom account name"
name="search"
autoComplete="off"
onKeyDown={(e) => {
@@ -303,7 +300,9 @@ export default function AccountsOverview() {
<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.name || "Unnamed Account"}
{account.display_name ||
account.name ||
"Unnamed Account"}
</h4>
<button
onClick={() => handleEditStart(account)}

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

@@ -0,0 +1,181 @@
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">
<Card>
<CardContent className="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">
<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-gray-600 mb-4">
<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">
<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 text-red-600 cursor-pointer">
<summary className="text-sm cursor-pointer">
Stack trace
</summary>
<pre className="text-xs text-red-700 mt-1 whitespace-pre-wrap">
<pre className="text-xs mt-1 whitespace-pre-wrap">
{this.state.error.stack}
</pre>
</details>
)}
</div>
</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"
>
<Button onClick={this.handleReset}>
<RefreshCw className="h-4 w-4 mr-2" />
Try Again
</button>
</div>
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,29 +1,32 @@
import { Skeleton } from "./ui/skeleton";
import { Card, CardContent } from "./ui/card";
export default function FiltersSkeleton() {
return (
<div className="bg-white rounded-lg shadow animate-pulse">
<div className="px-6 py-4 border-b border-gray-200">
<Card>
<div className="px-6 py-4 border-b border-border">
<div className="flex items-center justify-between">
<div className="h-6 bg-gray-200 rounded w-32"></div>
<Skeleton className="h-6 w-32" />
<div className="flex items-center space-x-2">
<div className="h-8 bg-gray-200 rounded w-24"></div>
<div className="h-8 bg-gray-200 rounded w-20"></div>
<Skeleton className="h-8 w-24" />
<Skeleton className="h-8 w-20" />
</div>
</div>
</div>
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<CardContent className="px-6 py-4 border-b border-border bg-muted/30">
{/* Quick Date Filters Skeleton */}
<div className="mb-6">
<div className="h-4 bg-gray-200 rounded w-32 mb-3"></div>
<Skeleton className="h-4 w-32 mb-3" />
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<div className="h-10 bg-gray-200 rounded-lg w-24"></div>
<div className="h-10 bg-gray-200 rounded-lg w-20"></div>
<div className="h-10 bg-gray-200 rounded-lg w-28"></div>
<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">
<div className="h-10 bg-gray-200 rounded-lg w-24"></div>
<div className="h-10 bg-gray-200 rounded-lg w-20"></div>
<Skeleton className="h-10 w-24 rounded-lg" />
<Skeleton className="h-10 w-20 rounded-lg" />
</div>
</div>
</div>
@@ -31,40 +34,40 @@ export default function FiltersSkeleton() {
{/* Filter Fields Skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="sm:col-span-2 lg:col-span-1">
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
<div className="h-10 bg-gray-200 rounded"></div>
<Skeleton className="h-4 w-16 mb-1" />
<Skeleton className="h-10 w-full" />
</div>
<div>
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
<div className="h-10 bg-gray-200 rounded"></div>
<Skeleton className="h-4 w-16 mb-1" />
<Skeleton className="h-10 w-full" />
</div>
<div>
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
<div className="h-10 bg-gray-200 rounded"></div>
<Skeleton className="h-4 w-20 mb-1" />
<Skeleton className="h-10 w-full" />
</div>
<div>
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
<div className="h-10 bg-gray-200 rounded"></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>
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
<div className="h-10 bg-gray-200 rounded"></div>
<Skeleton className="h-4 w-20 mb-1" />
<Skeleton className="h-10 w-full" />
</div>
<div>
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
<div className="h-10 bg-gray-200 rounded"></div>
</div>
<Skeleton className="h-4 w-20 mb-1" />
<Skeleton className="h-10 w-full" />
</div>
</div>
</CardContent>
{/* Results Summary Skeleton */}
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200">
<div className="h-4 bg-gray-200 rounded w-48"></div>
</div>
</div>
<CardContent className="px-6 py-3 bg-muted/30 border-b border-border">
<Skeleton className="h-4 w-48" />
</CardContent>
</Card>
);
}

View File

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

View File

@@ -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,225 @@
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)}
className="text-secondary-foreground hover:text-foreground"
>
<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)}
className="text-secondary-foreground hover:text-foreground"
>
<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,9 +10,13 @@ import {
CheckCircle,
Settings,
TestTube,
Activity,
Clock,
TrendingUp,
User,
} from "lucide-react";
import { apiClient } from "../lib/api";
import LoadingSpinner from "./LoadingSpinner";
import NotificationsSkeleton from "./NotificationsSkeleton";
import {
Card,
CardContent,
@@ -24,6 +28,7 @@ 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,
@@ -31,7 +36,11 @@ import {
SelectTrigger,
SelectValue,
} from "./ui/select";
import type { NotificationSettings, NotificationService } from "../types/api";
import type {
NotificationSettings,
NotificationService,
SyncOperationsResponse,
} from "../types/api";
export default function Notifications() {
const [testService, setTestService] = useState("");
@@ -60,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: () => {
@@ -79,19 +98,15 @@ export default function Notifications() {
},
});
if (settingsLoading || servicesLoading) {
return (
<Card>
<LoadingSpinner message="Loading notifications..." />
</Card>
);
if (settingsLoading || servicesLoading || syncOperationsLoading) {
return <NotificationsSkeleton />;
}
if (settingsError || servicesError) {
if (settingsError || servicesError || syncOperationsError) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Failed to load notifications</AlertTitle>
<AlertTitle>Failed to load system data</AlertTitle>
<AlertDescription className="space-y-3">
<p>
Unable to connect to the Leggen API. Please check your configuration
@@ -101,6 +116,7 @@ export default function Notifications() {
onClick={() => {
refetchSettings();
refetchServices();
refetchSyncOperations();
}}
variant="outline"
size="sm"
@@ -134,6 +150,110 @@ export default function Notifications() {
return (
<div className="space-y-6">
{/* Sync Operations Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Activity className="h-5 w-5 text-primary" />
<span>Sync Operations</span>
</CardTitle>
<CardDescription>Recent synchronization activities</CardDescription>
</CardHeader>
<CardContent>
{!syncOperations || syncOperations.operations.length === 0 ? (
<div className="text-center py-6">
<Activity className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
No sync operations yet
</h3>
<p className="text-muted-foreground">
Sync operations will appear here once you start syncing your
accounts.
</p>
</div>
) : (
<div className="space-y-4">
{syncOperations.operations.slice(0, 5).map((operation) => {
const startedAt = new Date(operation.started_at);
const isRunning = !operation.completed_at;
const duration = operation.duration_seconds
? `${Math.round(operation.duration_seconds)}s`
: "";
return (
<div
key={operation.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-accent transition-colors"
>
<div className="flex items-center space-x-4">
<div
className={`p-2 rounded-full ${
isRunning
? "bg-blue-100 text-blue-600"
: operation.success
? "bg-green-100 text-green-600"
: "bg-red-100 text-red-600"
}`}
>
{isRunning ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : operation.success ? (
<CheckCircle className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
</div>
<div>
<div className="flex items-center space-x-2">
<h4 className="text-sm font-medium text-foreground">
{isRunning
? "Sync Running"
: operation.success
? "Sync Completed"
: "Sync Failed"}
</h4>
<Badge variant="outline" className="text-xs">
{operation.trigger_type}
</Badge>
</div>
<div className="flex items-center space-x-4 mt-1 text-xs text-muted-foreground">
<span className="flex items-center space-x-1">
<Clock className="h-3 w-3" />
<span>
{startedAt.toLocaleDateString()}{" "}
{startedAt.toLocaleTimeString()}
</span>
</span>
{duration && <span>Duration: {duration}</span>}
</div>
</div>
</div>
<div className="text-right text-sm text-muted-foreground">
<div className="flex items-center space-x-2">
<User className="h-3 w-3" />
<span>{operation.accounts_processed} accounts</span>
</div>
<div className="flex items-center space-x-2 mt-1">
<TrendingUp className="h-3 w-3" />
<span>
{operation.transactions_added} new transactions
</span>
</div>
{operation.errors.length > 0 && (
<div className="flex items-center space-x-2 mt-1 text-red-600">
<AlertCircle className="h-3 w-3" />
<span>{operation.errors.length} errors</span>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Test Notification Section */}
<Card>
<CardHeader>
@@ -233,12 +353,10 @@ export default function Notifications() {
{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"
}`}
<Badge
variant={
service.enabled ? "default" : "destructive"
}
>
{service.enabled ? (
<CheckCircle className="h-3 w-3 mr-1" />
@@ -246,18 +364,16 @@ export default function Notifications() {
<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"
}`}
</Badge>
<Badge
variant={
service.configured ? "secondary" : "outline"
}
>
{service.configured
? "Configured"
: "Not Configured"}
</span>
</Badge>
</div>
</div>
</div>

View File

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

View File

@@ -0,0 +1,160 @@
import { useEffect, useState } from "react";
import { X, Download, RotateCcw } from "lucide-react";
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
interface PWAPromptProps {
onInstall?: () => void;
}
export function PWAInstallPrompt({ onInstall }: PWAPromptProps) {
const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => {
const handler = (e: Event) => {
// Prevent the mini-infobar from appearing on mobile
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
setShowPrompt(true);
};
window.addEventListener("beforeinstallprompt", handler);
return () => window.removeEventListener("beforeinstallprompt", handler);
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
try {
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === "accepted") {
onInstall?.();
}
setDeferredPrompt(null);
setShowPrompt(false);
} catch (error) {
console.error("Error installing PWA:", error);
}
};
const handleDismiss = () => {
setShowPrompt(false);
setDeferredPrompt(null);
};
if (!showPrompt || !deferredPrompt) return null;
return (
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 z-50">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<Download className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
Install Leggen
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
Add to your home screen for quick access
</p>
</div>
<button
onClick={handleDismiss}
className="flex-shrink-0 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-3 flex gap-2">
<button
onClick={handleInstall}
className="flex-1 bg-blue-600 text-white text-sm font-medium px-3 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Install
</button>
<button
onClick={handleDismiss}
className="px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
>
Not now
</button>
</div>
</div>
);
}
interface PWAUpdatePromptProps {
updateAvailable: boolean;
onUpdate: () => void;
}
export function PWAUpdatePrompt({
updateAvailable,
onUpdate,
}: PWAUpdatePromptProps) {
const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => {
if (updateAvailable) {
setShowPrompt(true);
}
}, [updateAvailable]);
const handleUpdate = () => {
onUpdate();
setShowPrompt(false);
};
const handleDismiss = () => {
setShowPrompt(false);
};
if (!showPrompt || !updateAvailable) return null;
return (
<div className="fixed top-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 z-50">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<RotateCcw className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
Update Available
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
A new version of Leggen is ready to install
</p>
</div>
<button
onClick={handleDismiss}
className="flex-shrink-0 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-3 flex gap-2">
<button
onClick={handleUpdate}
className="flex-1 bg-green-600 text-white text-sm font-medium px-3 py-2 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500"
>
Update Now
</button>
<button
onClick={handleDismiss}
className="px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
>
Later
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,622 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
CreditCard,
TrendingUp,
TrendingDown,
Building2,
RefreshCw,
AlertCircle,
Edit2,
Check,
X,
Plus,
Bell,
MessageSquare,
Send,
Trash2,
User,
Filter,
} 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 { 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 type {
Account,
Balance,
NotificationSettings,
NotificationService,
} 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 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,
});
// 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"] });
},
});
// 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());
}
};
const isLoading = accountsLoading || settingsLoading || servicesLoading;
const hasError = accountsError || settingsError || servicesError;
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();
}}
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-2">
<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>
</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>
<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-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
}
className="p-1 text-green-600 hover:text-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
title="Save changes"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={handleEditCancel}
className="p-1 text-gray-600 hover:text-gray-700"
title="Cancel editing"
>
<X className="h-4 w-4" />
</button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}
</p>
</div>
) : (
<div>
<div className="flex items-center space-x-2 min-w-0">
<h4 className="text-base sm:text-lg font-medium text-foreground truncate">
{account.display_name ||
account.name ||
"Unnamed Account"}
</h4>
<button
onClick={() => handleEditStart(account)}
className="flex-shrink-0 p-1 text-muted-foreground hover:text-foreground transition-colors"
title="Edit account name"
>
<Edit2 className="h-4 w-4" />
</button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}
</p>
{account.iban && (
<p className="text-xs text-muted-foreground mt-1 font-mono break-all sm:break-normal">
IBAN: {account.iban}
</p>
)}
</div>
)}
</div>
</div>
{/* Balance and date section */}
<div className="flex items-center justify-between sm:flex-col sm:items-end sm:text-right flex-shrink-0">
{/* 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>
</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>
</Tabs>
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -28,10 +28,10 @@ import FiltersSkeleton from "./FiltersSkeleton";
import RawTransactionModal from "./RawTransactionModal";
import { FilterBar, type FilterState } from "./filters";
import { DataTablePagination } from "./ui/data-table-pagination";
import { Card, CardContent } from "./ui/card";
import { Card } from "./ui/card";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Button } from "./ui/button";
import type { Account, Transaction, ApiResponse, Balance } from "../types/api";
import type { Account, Transaction, ApiResponse } from "../types/api";
export default function TransactionsTable() {
// Filter state consolidated into a single object
@@ -40,14 +40,11 @@ export default function TransactionsTable() {
selectedAccount: "",
startDate: "",
endDate: "",
minAmount: "",
maxAmount: "",
});
const [showRawModal, setShowRawModal] = useState(false);
const [selectedTransaction, setSelectedTransaction] =
useState<Transaction | null>(null);
const [showRunningBalance, setShowRunningBalance] = useState(true);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
@@ -74,8 +71,6 @@ export default function TransactionsTable() {
selectedAccount: "",
startDate: "",
endDate: "",
minAmount: "",
maxAmount: "",
});
setColumnFilters([]);
setCurrentPage(1);
@@ -102,12 +97,6 @@ export default function TransactionsTable() {
queryFn: apiClient.getAccounts,
});
const { data: balances } = useQuery<Balance[]>({
queryKey: ["balances"],
queryFn: apiClient.getBalances,
enabled: showRunningBalance,
});
const {
data: transactionsResponse,
isLoading: transactionsLoading,
@@ -122,8 +111,6 @@ export default function TransactionsTable() {
currentPage,
perPage,
debouncedSearchTerm,
filterState.minAmount,
filterState.maxAmount,
],
queryFn: () =>
apiClient.getTransactions({
@@ -134,12 +121,6 @@ export default function TransactionsTable() {
perPage: perPage,
search: debouncedSearchTerm || undefined,
summaryOnly: false,
minAmount: filterState.minAmount
? parseFloat(filterState.minAmount)
: undefined,
maxAmount: filterState.maxAmount
? parseFloat(filterState.maxAmount)
: undefined,
}),
});
@@ -159,13 +140,7 @@ export default function TransactionsTable() {
// Reset pagination when filters change
useEffect(() => {
setCurrentPage(1);
}, [
filterState.selectedAccount,
filterState.startDate,
filterState.endDate,
filterState.minAmount,
filterState.maxAmount,
]);
}, [filterState.selectedAccount, filterState.startDate, filterState.endDate]);
const handleViewRaw = (transaction: Transaction) => {
setSelectedTransaction(transaction);
@@ -181,57 +156,7 @@ export default function TransactionsTable() {
filterState.searchTerm ||
filterState.selectedAccount ||
filterState.startDate ||
filterState.endDate ||
filterState.minAmount ||
filterState.maxAmount;
// Calculate running balances
const calculateRunningBalances = (transactions: Transaction[]) => {
if (!balances || !showRunningBalance) return {};
const runningBalances: { [key: string]: number } = {};
const accountBalanceMap = new Map<string, number>();
// Create a map of account current balances
balances.forEach((balance) => {
if (balance.balance_type === "expected") {
accountBalanceMap.set(balance.account_id, balance.balance_amount);
}
});
// Group transactions by account
const transactionsByAccount = new Map<string, Transaction[]>();
transactions.forEach((txn) => {
if (!transactionsByAccount.has(txn.account_id)) {
transactionsByAccount.set(txn.account_id, []);
}
transactionsByAccount.get(txn.account_id)!.push(txn);
});
// Calculate running balance for each account
transactionsByAccount.forEach((accountTransactions, accountId) => {
const currentBalance = accountBalanceMap.get(accountId) || 0;
let runningBalance = currentBalance;
// Sort transactions by date (newest first) to work backwards
const sortedTransactions = [...accountTransactions].sort(
(a, b) =>
new Date(b.transaction_date).getTime() -
new Date(a.transaction_date).getTime(),
);
// Calculate running balance by working backwards from current balance
sortedTransactions.forEach((txn) => {
runningBalances[`${txn.account_id}-${txn.transaction_id}`] =
runningBalance;
runningBalance -= txn.transaction_value;
});
});
return runningBalances;
};
const runningBalances = calculateRunningBalances(transactions);
filterState.endDate;
// Define columns
const columns: ColumnDef<Transaction>[] = [
@@ -265,8 +190,7 @@ export default function TransactionsTable() {
<div className="text-xs text-muted-foreground space-y-1">
{account && (
<p className="truncate">
{account.name || "Unnamed Account"} {" "}
{account.institution_id}
{account.display_name || "Unnamed Account"}
</p>
)}
{(transaction.creditor_name || transaction.debtor_name) && (
@@ -308,29 +232,6 @@ export default function TransactionsTable() {
},
sortingFn: "basic",
},
...(showRunningBalance
? [
{
id: "running_balance",
header: "Running Balance",
cell: ({ row }: { row: { original: Transaction } }) => {
const transaction = row.original;
const balanceKey = `${transaction.account_id}-${transaction.transaction_id}`;
const balance = runningBalances[balanceKey];
if (balance === undefined) return null;
return (
<div className="text-right">
<p className="text-sm font-medium text-foreground">
{formatCurrency(balance, transaction.transaction_currency)}
</p>
</div>
);
},
},
]
: []),
{
accessorKey: "transaction_date",
header: "Date",
@@ -438,7 +339,7 @@ export default function TransactionsTable() {
}
return (
<div className="space-y-6">
<div className="space-y-6 max-w-full">
{/* New FilterBar */}
<FilterBar
filterState={filterState}
@@ -446,49 +347,12 @@ export default function TransactionsTable() {
onClearFilters={handleClearFilters}
accounts={accounts}
isSearchLoading={isSearchLoading}
showRunningBalance={showRunningBalance}
onToggleRunningBalance={() =>
setShowRunningBalance(!showRunningBalance)
}
/>
{/* Results Summary */}
<Card>
<CardContent className="px-6 py-3 bg-muted/30 border-b border-border">
<p className="text-sm text-muted-foreground">
Showing {transactions.length} transaction
{transactions.length !== 1 ? "s" : ""} (
{pagination ? (
<>
{(pagination.page - 1) * pagination.per_page + 1}-
{Math.min(
pagination.page * pagination.per_page,
pagination.total,
)}{" "}
of {pagination.total}
</>
) : (
"loading..."
)}
)
{filterState.selectedAccount && accounts && (
<span className="ml-1">
for{" "}
{
accounts.find((acc) => acc.id === filterState.selectedAccount)
?.name
}
</span>
)}
</p>
</CardContent>
</Card>
{/* Responsive Table/Cards */}
<Card className="overflow-hidden">
<Card>
{/* Desktop Table View (hidden on mobile) */}
<div className="hidden md:block">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted/50">
{table.getHeaderGroups().map((headerGroup) => (
@@ -556,10 +420,7 @@ export default function TransactionsTable() {
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"
>
<td key={cell.id} className="px-6 py-4 whitespace-nowrap">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
@@ -572,7 +433,6 @@ export default function TransactionsTable() {
</tbody>
</table>
</div>
</div>
{/* Mobile Card View (visible only on mobile) */}
<div className="md:hidden">
@@ -625,8 +485,7 @@ export default function TransactionsTable() {
<div className="text-xs text-muted-foreground space-y-1 mt-1">
{account && (
<p className="break-words">
{account.name || "Unnamed Account"} {" "}
{account.institution_id}
{account.display_name || "Unnamed Account"}
</p>
)}
{(transaction.creditor_name ||
@@ -671,17 +530,6 @@ export default function TransactionsTable() {
transaction.transaction_currency,
)}
</p>
{showRunningBalance && (
<p className="text-xs text-muted-foreground mb-1">
Balance:{" "}
{formatCurrency(
runningBalances[
`${transaction.account_id}-${transaction.transaction_id}`
] || 0,
transaction.transaction_currency,
)}
</p>
)}
<button
onClick={() => handleViewRaw(transaction)}
className="inline-flex items-center px-2 py-1 text-xs bg-muted text-muted-foreground rounded hover:bg-accent transition-colors"

View File

@@ -12,6 +12,7 @@ interface StatCardProps {
isPositive: boolean;
};
className?: string;
iconColor?: "green" | "blue" | "red" | "purple" | "orange" | "default";
}
export default function StatCard({
@@ -21,23 +22,16 @@ export default function StatCard({
icon: Icon,
trend,
className,
iconColor = "default",
}: StatCardProps) {
return (
<Card className={cn(className)}>
<CardContent className="p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<Icon className="h-8 w-8 text-primary" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-muted-foreground truncate">
{title}
</dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-foreground">
{value}
</div>
<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(
@@ -51,13 +45,33 @@ export default function StatCard({
{trend.value}%
</div>
)}
</dd>
</div>
{subtitle && (
<dd className="text-sm text-muted-foreground mt-1">
{subtitle}
</dd>
<p className="text-sm text-muted-foreground mt-1">{subtitle}</p>
)}
</dl>
</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>

View File

@@ -15,12 +15,12 @@ export default function TimePeriodFilter({
className = "",
}: TimePeriodFilterProps) {
return (
<div className={`flex items-center gap-4 ${className}`}>
<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 gap-2">
<div className="flex flex-wrap gap-2">
{TIME_PERIODS.map((period) => (
<Button
key={period.value}

View File

@@ -17,6 +17,7 @@ interface PieDataPoint {
name: string;
value: number;
color: string;
[key: string]: string | number;
}
interface TooltipProps {

View File

@@ -38,7 +38,8 @@ export function AccountCombobox({
);
const formatAccountName = (account: Account) => {
const displayName = account.name || "Unnamed Account";
const displayName =
account.display_name || account.name || "Unnamed Account";
return `${displayName} (${account.institution_id})`;
};
@@ -50,7 +51,7 @@ export function AccountCombobox({
variant="outline"
role="combobox"
aria-expanded={open}
className="justify-between"
className="w-full justify-between"
>
<div className="flex items-center">
<Building2 className="mr-2 h-4 w-4" />
@@ -89,7 +90,7 @@ export function AccountCombobox({
{accounts.map((account) => (
<CommandItem
key={account.id}
value={`${account.name} ${account.institution_id}`}
value={`${account.display_name || account.name} ${account.institution_id}`}
onSelect={() => {
onAccountChange(account.id);
setOpen(false);
@@ -105,7 +106,9 @@ export function AccountCombobox({
/>
<div className="flex flex-col">
<span className="font-medium">
{account.name || "Unnamed Account"}
{account.display_name ||
account.name ||
"Unnamed Account"}
</span>
<span className="text-xs text-gray-500">
{account.institution_id}

View File

@@ -8,12 +8,14 @@ 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<{
@@ -68,31 +70,6 @@ export function ActiveFilterChips({
});
}
// Amount range chips
if (filterState.minAmount || filterState.maxAmount) {
let amountLabel = "Amount: ";
const minAmount = filterState.minAmount
? parseFloat(filterState.minAmount)
: null;
const maxAmount = filterState.maxAmount
? parseFloat(filterState.maxAmount)
: null;
if (minAmount && maxAmount) {
amountLabel += `${minAmount} - €${maxAmount}`;
} else if (minAmount) {
amountLabel += `≥ €${minAmount}`;
} else if (maxAmount) {
amountLabel += `≤ €${maxAmount}`;
}
chips.push({
key: "minAmount", // We'll clear both min and max when removing this chip
label: amountLabel,
value: `${filterState.minAmount}-${filterState.maxAmount}`,
});
}
const handleRemoveChip = (key: keyof FilterState) => {
switch (key) {
case "startDate":
@@ -100,11 +77,6 @@ export function ActiveFilterChips({
onFilterChange("startDate", "");
onFilterChange("endDate", "");
break;
case "minAmount":
// Clear both min and max amount
onFilterChange("minAmount", "");
onFilterChange("maxAmount", "");
break;
default:
onFilterChange(key, "");
}
@@ -135,6 +107,15 @@ export function ActiveFilterChips({
</Button>
</Badge>
))}
<Button
onClick={onClearFilters}
variant="outline"
size="sm"
className="text-muted-foreground ml-2"
>
<X className="h-4 w-4 mr-1" />
Clear All
</Button>
</div>
);
}

View File

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

View File

@@ -6,6 +6,7 @@ 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,
@@ -26,33 +27,35 @@ interface DatePreset {
const datePresets: DatePreset[] = [
{
label: "Last 7 days",
label: "Today",
getValue: () => {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - 7);
const today = new Date();
return {
startDate: startDate.toISOString().split("T")[0],
endDate: endDate.toISOString().split("T")[0],
startDate: today.toISOString().split("T")[0],
endDate: today.toISOString().split("T")[0],
};
},
},
{
label: "This week",
label: "Yesterday",
getValue: () => {
const now = new Date();
const dayOfWeek = now.getDay();
const startOfWeek = new Date(now);
startOfWeek.setDate(
now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1),
); // Monday as start
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 6);
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return {
startDate: startOfWeek.toISOString().split("T")[0],
endDate: endOfWeek.toISOString().split("T")[0],
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],
};
},
},
@@ -61,7 +64,7 @@ const datePresets: DatePreset[] = [
getValue: () => {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - 30);
startDate.setDate(endDate.getDate() - 29);
return {
startDate: startDate.toISOString().split("T")[0],
endDate: endDate.toISOString().split("T")[0],
@@ -81,19 +84,6 @@ const datePresets: DatePreset[] = [
};
},
},
{
label: "This year",
getValue: () => {
const now = new Date();
const startOfYear = new Date(now.getFullYear(), 0, 1);
const endOfYear = new Date(now.getFullYear(), 11, 31);
return {
startDate: startOfYear.toISOString().split("T")[0],
endDate: endOfYear.toISOString().split("T")[0],
};
},
},
];
export function DateRangePicker({
@@ -178,34 +168,30 @@ export function DateRangePicker({
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="flex">
{/* Presets */}
<div className="border-r p-3 space-y-1">
<div className="text-sm font-medium text-gray-700 mb-2">
Quick select
</div>
<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="ghost"
variant="outline"
size="sm"
className="w-full justify-start text-sm"
className="text-xs px-2 h-7"
onClick={() => handlePresetClick(preset)}
>
{preset.label}
</Button>
))}
</div>
{/* Calendar */}
<Calendar
initialFocus
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={handleDateRangeSelect}
numberOfMonths={2}
/>
</div>
</CardFooter>
</Card>
</PopoverContent>
</Popover>
</div>

View File

@@ -1,11 +1,9 @@
import { Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
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 { AdvancedFiltersPopover } from "./AdvancedFiltersPopover";
import type { Account } from "../../types/api";
export interface FilterState {
@@ -13,8 +11,6 @@ export interface FilterState {
selectedAccount: string;
startDate: string;
endDate: string;
minAmount: string;
maxAmount: string;
}
export interface FilterBarProps {
@@ -23,8 +19,6 @@ export interface FilterBarProps {
onClearFilters: () => void;
accounts?: Account[];
isSearchLoading?: boolean;
showRunningBalance: boolean;
onToggleRunningBalance: () => void;
className?: string;
}
@@ -34,17 +28,13 @@ export function FilterBar({
onClearFilters,
accounts,
isSearchLoading = false,
showRunningBalance,
onToggleRunningBalance,
className,
}: FilterBarProps) {
const hasActiveFilters =
filterState.searchTerm ||
filterState.selectedAccount ||
filterState.startDate ||
filterState.endDate ||
filterState.minAmount ||
filterState.maxAmount;
filterState.endDate;
const handleDateRangeChange = (startDate: string, endDate: string) => {
onFilterChange("startDate", startDate);
@@ -59,19 +49,16 @@ export function FilterBar({
<h3 className="text-lg font-semibold text-card-foreground">
Transactions
</h3>
<Button
onClick={onToggleRunningBalance}
variant={showRunningBalance ? "default" : "outline"}
size="sm"
>
Balance
</Button>
</div>
{/* Primary Filters Row */}
<div className="flex flex-wrap items-center gap-3 mb-4">
<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 flex-1 min-w-[240px]">
<div className="relative w-[200px]">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search transactions..."
@@ -93,7 +80,7 @@ export function FilterBar({
onAccountChange={(accountId) =>
onFilterChange("selectedAccount", accountId)
}
className="w-[200px]"
className="w-[180px]"
/>
{/* Date Range Picker */}
@@ -101,36 +88,55 @@ export function FilterBar({
startDate={filterState.startDate}
endDate={filterState.endDate}
onDateRangeChange={handleDateRangeChange}
className="w-[240px]"
className="w-[220px]"
/>
</div>
</div>
{/* Advanced Filters Button */}
<AdvancedFiltersPopover
minAmount={filterState.minAmount}
maxAmount={filterState.maxAmount}
onMinAmountChange={(value) => onFilterChange("minAmount", value)}
onMaxAmountChange={(value) => onFilterChange("maxAmount", value)}
{/* 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"
/>
{/* Clear Filters Button */}
{hasActiveFilters && (
<Button
onClick={onClearFilters}
variant="outline"
size="sm"
className="text-muted-foreground"
>
<X className="h-4 w-4 mr-1" />
Clear All
</Button>
{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}
/>
)}

View File

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

View File

@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
"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: {

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

@@ -10,6 +10,12 @@ interface ThemeContextType {
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;
@@ -40,6 +46,35 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
// 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();

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

@@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
interface PWAUpdate {
updateAvailable: boolean;
updateSW: () => Promise<void>;
forceReload: () => Promise<void>;
}
export function usePWA(): PWAUpdate {
const [updateAvailable, setUpdateAvailable] = useState(false);
const [updateSW, setUpdateSW] = useState<() => Promise<void>>(
() => async () => {},
);
const forceReload = async (): Promise<void> => {
try {
// Clear all caches
if ('caches' in window) {
const cacheNames = await caches.keys();
await Promise.all(
cacheNames.map(cacheName => caches.delete(cacheName))
);
console.log("All caches cleared");
}
// Unregister service worker
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map(registration => registration.unregister()));
console.log("All service workers unregistered");
}
// Force reload
window.location.reload();
} catch (error) {
console.error("Error during force reload:", error);
// Fallback: just reload the page
window.location.reload();
}
};
useEffect(() => {
// Check if SW registration is available
if ("serviceWorker" in navigator) {
// Import the registerSW function
import("virtual:pwa-register")
.then(({ registerSW }) => {
const updateSWFunction = registerSW({
onNeedRefresh() {
setUpdateAvailable(true);
setUpdateSW(() => updateSWFunction);
},
onOfflineReady() {
console.log("App ready to work offline");
},
});
})
.catch(() => {
// PWA not available in development mode or when disabled
console.log("PWA registration not available");
});
}
}, []);
return {
updateAvailable,
updateSW,
forceReload,
};
}

View File

@@ -0,0 +1,40 @@
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../lib/api";
const VERSION_STORAGE_KEY = "leggen_app_version";
export function useVersionCheck(forceReload: () => Promise<void>) {
const {
data: healthStatus,
isSuccess: healthSuccess,
} = useQuery({
queryKey: ["health"],
queryFn: apiClient.getHealth,
refetchInterval: 30000,
retry: false,
staleTime: 0, // Always consider data stale to ensure fresh version checks
});
useEffect(() => {
if (healthSuccess && healthStatus?.version) {
const currentVersion = healthStatus.version;
const storedVersion = localStorage.getItem(VERSION_STORAGE_KEY);
if (storedVersion && storedVersion !== currentVersion) {
console.log(`Version mismatch detected: stored=${storedVersion}, current=${currentVersion}`);
console.log("Clearing cache and reloading...");
// Update stored version first
localStorage.setItem(VERSION_STORAGE_KEY, currentVersion);
// Force reload to clear cache
forceReload();
} else if (!storedVersion) {
// First time loading, store the version
localStorage.setItem(VERSION_STORAGE_KEY, currentVersion);
console.log(`Version stored: ${currentVersion}`);
}
}
}, [healthSuccess, healthStatus?.version, forceReload]);
}

View File

@@ -10,10 +10,10 @@
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary: 219 91% 46%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--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%;
@@ -29,6 +29,20 @@
--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%;
@@ -37,9 +51,9 @@
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--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%;
@@ -55,6 +69,14 @@
--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%;
}
}

View File

@@ -12,6 +12,7 @@ import type {
HealthData,
AccountUpdate,
TransactionStats,
SyncOperationsResponse,
} from "../types/api";
// Use VITE_API_URL for development, relative URLs for production
@@ -41,11 +42,10 @@ export const apiClient = {
updateAccount: async (
id: string,
updates: AccountUpdate,
): Promise<{ id: string; name?: string }> => {
const response = await api.put<ApiResponse<{ id: string; name?: string }>>(
`/accounts/${id}`,
updates,
);
): Promise<{ id: string; display_name?: string }> => {
const response = await api.put<
ApiResponse<{ id: string; display_name?: string }>
>(`/accounts/${id}`, updates);
return response.data.data;
},
@@ -168,6 +168,8 @@ export const apiClient = {
return response.data.data;
},
// Analytics endpoints
getTransactionStats: async (days?: number): Promise<TransactionStats> => {
const queryParams = new URLSearchParams();
@@ -218,6 +220,17 @@ export const apiClient = {
>(`/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;
},
};
export default apiClient;

View File

@@ -10,6 +10,8 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as TransactionsRouteImport } from './routes/transactions'
import { Route as SystemRouteImport } from './routes/system'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as NotificationsRouteImport } from './routes/notifications'
import { Route as AnalyticsRouteImport } from './routes/analytics'
import { Route as IndexRouteImport } from './routes/index'
@@ -19,6 +21,16 @@ const TransactionsRoute = TransactionsRouteImport.update({
path: '/transactions',
getParentRoute: () => rootRouteImport,
} as any)
const SystemRoute = SystemRouteImport.update({
id: '/system',
path: '/system',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsRoute = SettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
const NotificationsRoute = NotificationsRouteImport.update({
id: '/notifications',
path: '/notifications',
@@ -39,12 +51,16 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/analytics': typeof AnalyticsRoute
'/notifications': typeof NotificationsRoute
'/settings': typeof SettingsRoute
'/system': typeof SystemRoute
'/transactions': typeof TransactionsRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/analytics': typeof AnalyticsRoute
'/notifications': typeof NotificationsRoute
'/settings': typeof SettingsRoute
'/system': typeof SystemRoute
'/transactions': typeof TransactionsRoute
}
export interface FileRoutesById {
@@ -52,20 +68,43 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/analytics': typeof AnalyticsRoute
'/notifications': typeof NotificationsRoute
'/settings': typeof SettingsRoute
'/system': typeof SystemRoute
'/transactions': typeof TransactionsRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/analytics' | '/notifications' | '/transactions'
fullPaths:
| '/'
| '/analytics'
| '/notifications'
| '/settings'
| '/system'
| '/transactions'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/analytics' | '/notifications' | '/transactions'
id: '__root__' | '/' | '/analytics' | '/notifications' | '/transactions'
to:
| '/'
| '/analytics'
| '/notifications'
| '/settings'
| '/system'
| '/transactions'
id:
| '__root__'
| '/'
| '/analytics'
| '/notifications'
| '/settings'
| '/system'
| '/transactions'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AnalyticsRoute: typeof AnalyticsRoute
NotificationsRoute: typeof NotificationsRoute
SettingsRoute: typeof SettingsRoute
SystemRoute: typeof SystemRoute
TransactionsRoute: typeof TransactionsRoute
}
@@ -78,6 +117,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof TransactionsRouteImport
parentRoute: typeof rootRouteImport
}
'/system': {
id: '/system'
path: '/system'
fullPath: '/system'
preLoaderRoute: typeof SystemRouteImport
parentRoute: typeof rootRouteImport
}
'/settings': {
id: '/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/notifications': {
id: '/notifications'
path: '/notifications'
@@ -106,6 +159,8 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AnalyticsRoute: AnalyticsRoute,
NotificationsRoute: NotificationsRoute,
SettingsRoute: SettingsRoute,
SystemRoute: SystemRoute,
TransactionsRoute: TransactionsRoute,
}
export const routeTree = rootRouteImport

View File

@@ -1,30 +1,54 @@
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { useState } from "react";
import Sidebar from "../components/Sidebar";
import Header from "../components/Header";
import { AppSidebar } from "../components/AppSidebar";
import { SiteHeader } from "../components/SiteHeader";
import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
import { usePWA } from "../hooks/usePWA";
import { useVersionCheck } from "../hooks/useVersionCheck";
import { SidebarInset, SidebarProvider } from "../components/ui/sidebar";
function RootLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const { updateAvailable, updateSW, forceReload } = usePWA();
// Check for version mismatches and force reload if needed
useVersionCheck(forceReload);
const handlePWAInstall = () => {
console.log("PWA installed successfully");
};
const handlePWAUpdate = async () => {
try {
await updateSW();
console.log("PWA updated successfully");
} catch (error) {
console.error("Error updating PWA:", error);
}
};
return (
<div className="flex h-screen bg-background">
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className="flex flex-col flex-1 overflow-hidden">
<Header setSidebarOpen={setSidebarOpen} />
<main className="flex-1 overflow-y-auto p-6">
<SidebarProvider
style={
{
"--sidebar-width": "16rem",
"--header-height": "4rem",
} as React.CSSProperties
}
>
<AppSidebar />
<SidebarInset>
<SiteHeader />
<main className="flex-1 p-6 min-w-0">
<Outlet />
</main>
</div>
</div>
</SidebarInset>
{/* PWA Prompts */}
<PWAInstallPrompt onInstall={handlePWAInstall} />
<PWAUpdatePrompt
updateAvailable={updateAvailable}
onUpdate={handlePWAUpdate}
/>
</SidebarProvider>
);
}

View File

@@ -44,7 +44,7 @@ function AnalyticsDashboard() {
if (isLoading) {
return (
<div className="p-6">
<div className="space-y-8">
<div className="animate-pulse">
<div className="h-8 bg-muted rounded w-48 mb-6"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
@@ -62,7 +62,7 @@ function AnalyticsDashboard() {
}
return (
<div className="p-6 space-y-8">
<div className="space-y-8">
{/* Time Period Filter */}
<Card>
<CardContent className="p-4">
@@ -80,20 +80,21 @@ function AnalyticsDashboard() {
value={stats?.total_transactions || 0}
subtitle={`Last ${stats?.period_days || 0} days`}
icon={Activity}
iconColor="blue"
/>
<StatCard
title="Total Income"
value={`${(stats?.total_income || 0).toLocaleString()}`}
subtitle="Inflows this period"
icon={TrendingUp}
className="border-green-200"
iconColor="green"
/>
<StatCard
title="Total Expenses"
value={`${(stats?.total_expenses || 0).toLocaleString()}`}
subtitle="Outflows this period"
icon={TrendingDown}
className="border-red-200"
iconColor="red"
/>
</div>
@@ -104,23 +105,21 @@ function AnalyticsDashboard() {
value={`${(stats?.net_change || 0).toLocaleString()}`}
subtitle="Income minus expenses"
icon={CreditCard}
className={
(stats?.net_change || 0) >= 0
? "border-green-200"
: "border-red-200"
}
iconColor={(stats?.net_change || 0) >= 0 ? "green" : "red"}
/>
<StatCard
title="Average Transaction"
value={`${Math.abs(stats?.average_transaction || 0).toLocaleString()}`}
subtitle="Per transaction"
icon={Activity}
iconColor="purple"
/>
<StatCard
title="Active Accounts"
value={stats?.accounts_included || 0}
subtitle="With recent activity"
icon={Users}
iconColor="orange"
/>
</div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,14 +11,16 @@ export interface Account {
status: string;
iban?: string;
name?: string;
display_name?: string;
currency?: string;
logo?: string;
created: string;
last_accessed?: string;
balances: AccountBalance[];
}
export interface AccountUpdate {
name?: string;
display_name?: string;
}
export interface RawTransactionData {
@@ -196,10 +198,17 @@ export interface NotificationServicesResponse {
export interface HealthData {
status: string;
config_loaded?: boolean;
version?: string;
message?: string;
error?: string;
}
// Version information from root endpoint
export interface VersionData {
message: string;
version: string;
}
// Analytics data types
export interface TransactionStats {
period_days: number;
@@ -212,3 +221,24 @@ export interface TransactionStats {
average_transaction: number;
accounts_included: number;
}
// Sync operations types
export interface SyncOperation {
id: number;
started_at: string;
completed_at?: string;
success?: boolean;
accounts_processed: number;
transactions_added: number;
transactions_updated: number;
balances_updated: number;
duration_seconds?: number;
errors: string[];
logs: string[];
trigger_type: "manual" | "scheduled" | "api";
}
export interface SyncOperationsResponse {
operations: SyncOperation[];
count: number;
}

View File

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

View File

@@ -9,6 +9,12 @@ export default {
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
spacing: {
"safe-top": "var(--safe-area-inset-top)",
"safe-bottom": "var(--safe-area-inset-bottom)",
"safe-left": "var(--safe-area-inset-left)",
"safe-right": "var(--safe-area-inset-right)",
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
@@ -50,6 +56,16 @@ export default {
4: "hsl(var(--chart-4))",
5: "hsl(var(--chart-5))",
},
sidebar: {
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
},
},
},

View File

@@ -1,10 +1,93 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
import { tanstackRouter } from "@tanstack/router-vite-plugin";
import { VitePWA } from "vite-plugin-pwa";
// https://vite.dev/config/
export default defineConfig({
plugins: [TanStackRouterVite(), react()],
plugins: [
tanstackRouter(),
react(),
VitePWA({
registerType: "autoUpdate",
includeAssets: [
"favicon.ico",
"apple-touch-icon-180x180.png",
"maskable-icon-512x512.png",
"robots.txt",
],
manifest: {
name: "Leggen",
short_name: "Leggen",
description: "Personal finance management application",
theme_color: "#0b74de",
background_color: "#ffffff",
display: "standalone",
orientation: "portrait",
scope: "/",
start_url: "/",
categories: ["finance", "productivity"],
shortcuts: [
{
name: "Transactions",
short_name: "Transactions",
description: "View and manage transactions",
url: "/transactions",
icons: [{ src: "/pwa-192x192.png", sizes: "192x192" }],
},
{
name: "Analytics",
short_name: "Analytics",
description: "View financial analytics",
url: "/analytics",
icons: [{ src: "/pwa-192x192.png", sizes: "192x192" }],
},
],
icons: [
{
src: "pwa-64x64.png",
sizes: "64x64",
type: "image/png",
},
{
src: "pwa-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "pwa-512x512.png",
sizes: "512x512",
type: "image/png",
},
{
src: "maskable-icon-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
},
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg}"],
runtimeCaching: [
{
urlPattern: /^https:\/\/.*\/api\//,
handler: "NetworkFirst",
options: {
cacheName: "api-cache",
networkTimeoutSeconds: 10,
cacheableResponse: {
statuses: [0, 200],
},
},
},
],
},
devOptions: {
enabled: true,
},
}),
],
resolve: {
alias: {
"@": "/src",

View File

@@ -24,7 +24,9 @@ class AccountDetails(BaseModel):
status: str
iban: Optional[str] = None
name: Optional[str] = None
display_name: Optional[str] = None
currency: Optional[str] = None
logo: Optional[str] = None
created: datetime
last_accessed: Optional[datetime] = None
balances: List[AccountBalance] = []
@@ -36,7 +38,7 @@ class AccountDetails(BaseModel):
class AccountUpdate(BaseModel):
"""Account update model"""
name: Optional[str] = None
display_name: Optional[str] = None
class Config:
json_encoders = {datetime: lambda v: v.isoformat() if v else None}

View File

@@ -4,6 +4,26 @@ from typing import Optional
from pydantic import BaseModel
class SyncOperation(BaseModel):
"""Sync operation record for tracking sync history"""
id: Optional[int] = None
started_at: datetime
completed_at: Optional[datetime] = None
success: Optional[bool] = None
accounts_processed: int = 0
transactions_added: int = 0
transactions_updated: int = 0
balances_updated: int = 0
duration_seconds: Optional[float] = None
errors: list[str] = []
logs: list[str] = []
trigger_type: str = "manual" # manual, scheduled, retry, api
class Config:
json_encoders = {datetime: lambda v: v.isoformat() if v else None}
class SyncRequest(BaseModel):
"""Request to trigger a sync"""

View File

@@ -53,7 +53,9 @@ async def get_all_accounts() -> APIResponse:
status=db_account["status"],
iban=db_account.get("iban"),
name=db_account.get("name"),
display_name=db_account.get("display_name"),
currency=db_account.get("currency"),
logo=db_account.get("logo"),
created=db_account["created"],
last_accessed=db_account.get("last_accessed"),
balances=balances,
@@ -112,7 +114,9 @@ async def get_account_details(account_id: str) -> APIResponse:
status=db_account["status"],
iban=db_account.get("iban"),
name=db_account.get("name"),
display_name=db_account.get("display_name"),
currency=db_account.get("currency"),
logo=db_account.get("logo"),
created=db_account["created"],
last_accessed=db_account.get("last_accessed"),
balances=balances,
@@ -324,7 +328,7 @@ async def get_account_transactions(
async def update_account_details(
account_id: str, update_data: AccountUpdate
) -> APIResponse:
"""Update account details (currently only name)"""
"""Update account details (currently only display_name)"""
try:
# Get current account details
current_account = await database_service.get_account_details_from_db(account_id)
@@ -336,16 +340,16 @@ async def update_account_details(
# Prepare updated account data
updated_account_data = current_account.copy()
if update_data.name is not None:
updated_account_data["name"] = update_data.name
if update_data.display_name is not None:
updated_account_data["display_name"] = update_data.display_name
# Persist updated account details
await database_service.persist_account_details(updated_account_data)
return APIResponse(
success=True,
data={"id": account_id, "name": update_data.name},
message=f"Account {account_id} name updated successfully",
data={"id": account_id, "display_name": update_data.display_name},
message=f"Account {account_id} display name updated successfully",
)
except HTTPException:

View File

@@ -21,14 +21,15 @@ async def get_bank_institutions(
) -> APIResponse:
"""Get available bank institutions for a country"""
try:
institutions_data = await gocardless_service.get_institutions(country)
institutions_response = await gocardless_service.get_institutions(country)
institutions_data = institutions_response.get("results", [])
institutions = [
BankInstitution(
id=inst["id"],
name=inst["name"],
bic=inst.get("bic"),
transaction_total_days=inst["transaction_total_days"],
transaction_total_days=int(inst["transaction_total_days"]),
countries=inst["countries"],
logo=inst.get("logo"),
)

View File

@@ -37,15 +37,15 @@ async def get_notification_settings() -> APIResponse:
if discord_config.get("webhook")
else None,
telegram=TelegramConfig(
token="***" if telegram_config.get("api-key") else "",
chat_id=telegram_config.get("chat-id", 0),
token="***" if telegram_config.get("token") else "",
chat_id=telegram_config.get("chat_id", 0),
enabled=telegram_config.get("enabled", True),
)
if telegram_config.get("api-key")
if telegram_config.get("token")
else None,
filters=NotificationFilters(
case_insensitive=filters_config.get("case-insensitive", []),
case_sensitive=filters_config.get("case-sensitive"),
case_insensitive=filters_config.get("case_insensitive", []),
case_sensitive=filters_config.get("case_sensitive"),
),
)
@@ -77,17 +77,17 @@ async def update_notification_settings(settings: NotificationSettings) -> APIRes
if settings.telegram:
notifications_config["telegram"] = {
"api-key": settings.telegram.token,
"chat-id": settings.telegram.chat_id,
"token": settings.telegram.token,
"chat_id": settings.telegram.chat_id,
"enabled": settings.telegram.enabled,
}
# Update filters config
filters_config: Dict[str, Any] = {}
if settings.filters.case_insensitive:
filters_config["case-insensitive"] = settings.filters.case_insensitive
filters_config["case_insensitive"] = settings.filters.case_insensitive
if settings.filters.case_sensitive:
filters_config["case-sensitive"] = settings.filters.case_sensitive
filters_config["case_sensitive"] = settings.filters.case_sensitive
# Save to config
if notifications_config:
@@ -153,12 +153,12 @@ async def get_notification_services() -> APIResponse:
"telegram": {
"name": "Telegram",
"enabled": bool(
notifications_config.get("telegram", {}).get("api-key")
and notifications_config.get("telegram", {}).get("chat-id")
notifications_config.get("telegram", {}).get("token")
and notifications_config.get("telegram", {}).get("chat_id")
),
"configured": bool(
notifications_config.get("telegram", {}).get("api-key")
and notifications_config.get("telegram", {}).get("chat-id")
notifications_config.get("telegram", {}).get("token")
and notifications_config.get("telegram", {}).get("chat_id")
),
"active": notifications_config.get("telegram", {}).get("enabled", True),
},

View File

@@ -56,6 +56,7 @@ async def trigger_sync(
sync_service.sync_specific_accounts,
sync_request.account_ids,
sync_request.force if sync_request else False,
"api", # trigger_type
)
message = (
f"Started sync for {len(sync_request.account_ids)} specific accounts"
@@ -65,6 +66,7 @@ async def trigger_sync(
background_tasks.add_task(
sync_service.sync_all_accounts,
sync_request.force if sync_request else False,
"api", # trigger_type
)
message = "Started sync for all accounts"
@@ -90,11 +92,11 @@ async def sync_now(sync_request: Optional[SyncRequest] = None) -> APIResponse:
try:
if sync_request and sync_request.account_ids:
result = await sync_service.sync_specific_accounts(
sync_request.account_ids, sync_request.force
sync_request.account_ids, sync_request.force, "api"
)
else:
result = await sync_service.sync_all_accounts(
sync_request.force if sync_request else False
sync_request.force if sync_request else False, "api"
)
return APIResponse(
@@ -211,3 +213,24 @@ async def stop_scheduler() -> APIResponse:
raise HTTPException(
status_code=500, detail=f"Failed to stop scheduler: {str(e)}"
) from e
@router.get("/sync/operations", response_model=APIResponse)
async def get_sync_operations(limit: int = 50, offset: int = 0) -> APIResponse:
"""Get sync operations history"""
try:
operations = await sync_service.database.get_sync_operations(
limit=limit, offset=offset
)
return APIResponse(
success=True,
data={"operations": operations, "count": len(operations)},
message="Sync operations retrieved successfully",
)
except Exception as e:
logger.error(f"Failed to get sync operations: {e}")
raise HTTPException(
status_code=500, detail=f"Failed to get sync operations: {str(e)}"
) from e

View File

@@ -1,6 +1,6 @@
import os
from typing import Any, Dict, List, Optional, Union
from urllib.parse import urljoin
from urllib.parse import urljoin, urlparse
import requests
@@ -13,11 +13,22 @@ class LeggenAPIClient:
base_url: str
def __init__(self, base_url: Optional[str] = None):
self.base_url = (
raw_url = (
base_url
or os.environ.get("LEGGEN_API_URL", "http://localhost:8000")
or "http://localhost:8000"
)
# Ensure base_url includes /api/v1 path if not already present
parsed = urlparse(raw_url)
if not parsed.path or parsed.path == "/":
# No path or just root, add /api/v1
self.base_url = f"{raw_url.rstrip('/')}/api/v1"
elif not parsed.path.startswith("/api/v1"):
# Has a path but not /api/v1, add it
self.base_url = f"{raw_url.rstrip('/')}/api/v1"
else:
# Already has /api/v1 path
self.base_url = raw_url.rstrip("/")
self.session = requests.Session()
self.session.headers.update(
{"Content-Type": "application/json", "Accept": "application/json"}
@@ -25,7 +36,14 @@ class LeggenAPIClient:
def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
"""Make HTTP request to the API"""
url = urljoin(self.base_url, endpoint)
# Construct URL by joining base_url with endpoint
# Handle both relative endpoints (starting with /) and paths
if endpoint.startswith("/"):
# Absolute endpoint path - append to base_url
url = f"{self.base_url}{endpoint}"
else:
# Relative endpoint, use urljoin
url = urljoin(f"{self.base_url}/", endpoint)
try:
response = self.session.request(method, url, **kwargs)
@@ -52,7 +70,9 @@ class LeggenAPIClient:
"""Check if the leggen server is healthy"""
try:
response = self._make_request("GET", "/health")
return response.get("status") == "healthy"
# The API now returns nested data structure
data = response.get("data", {})
return data.get("status") == "healthy"
except Exception:
return False
@@ -60,7 +80,7 @@ class LeggenAPIClient:
def get_institutions(self, country: str = "PT") -> List[Dict[str, Any]]:
"""Get bank institutions for a country"""
response = self._make_request(
"GET", "/api/v1/banks/institutions", params={"country": country}
"GET", "/banks/institutions", params={"country": country}
)
return response.get("data", [])
@@ -70,35 +90,35 @@ class LeggenAPIClient:
"""Connect to a bank"""
response = self._make_request(
"POST",
"/api/v1/banks/connect",
"/banks/connect",
json={"institution_id": institution_id, "redirect_url": redirect_url},
)
return response.get("data", {})
def get_bank_status(self) -> List[Dict[str, Any]]:
"""Get bank connection status"""
response = self._make_request("GET", "/api/v1/banks/status")
response = self._make_request("GET", "/banks/status")
return response.get("data", [])
def get_supported_countries(self) -> List[Dict[str, Any]]:
"""Get supported countries"""
response = self._make_request("GET", "/api/v1/banks/countries")
response = self._make_request("GET", "/banks/countries")
return response.get("data", [])
# Account endpoints
def get_accounts(self) -> List[Dict[str, Any]]:
"""Get all accounts"""
response = self._make_request("GET", "/api/v1/accounts")
response = self._make_request("GET", "/accounts")
return response.get("data", [])
def get_account_details(self, account_id: str) -> Dict[str, Any]:
"""Get account details"""
response = self._make_request("GET", f"/api/v1/accounts/{account_id}")
response = self._make_request("GET", f"/accounts/{account_id}")
return response.get("data", {})
def get_account_balances(self, account_id: str) -> List[Dict[str, Any]]:
"""Get account balances"""
response = self._make_request("GET", f"/api/v1/accounts/{account_id}/balances")
response = self._make_request("GET", f"/accounts/{account_id}/balances")
return response.get("data", [])
def get_account_transactions(
@@ -107,7 +127,7 @@ class LeggenAPIClient:
"""Get account transactions"""
response = self._make_request(
"GET",
f"/api/v1/accounts/{account_id}/transactions",
f"/accounts/{account_id}/transactions",
params={"limit": limit, "summary_only": summary_only},
)
return response.get("data", [])
@@ -120,7 +140,7 @@ class LeggenAPIClient:
params = {"limit": limit, "summary_only": summary_only}
params.update(filters)
response = self._make_request("GET", "/api/v1/transactions", params=params)
response = self._make_request("GET", "/transactions", params=params)
return response.get("data", [])
def get_transaction_stats(
@@ -131,15 +151,13 @@ class LeggenAPIClient:
if account_id:
params["account_id"] = account_id
response = self._make_request(
"GET", "/api/v1/transactions/stats", params=params
)
response = self._make_request("GET", "/transactions/stats", params=params)
return response.get("data", {})
# Sync endpoints
def get_sync_status(self) -> Dict[str, Any]:
"""Get sync status"""
response = self._make_request("GET", "/api/v1/sync/status")
response = self._make_request("GET", "/sync/status")
return response.get("data", {})
def trigger_sync(
@@ -150,7 +168,7 @@ class LeggenAPIClient:
if account_ids:
data["account_ids"] = account_ids
response = self._make_request("POST", "/api/v1/sync", json=data)
response = self._make_request("POST", "/sync", json=data)
return response.get("data", {})
def sync_now(
@@ -161,12 +179,12 @@ class LeggenAPIClient:
if account_ids:
data["account_ids"] = account_ids
response = self._make_request("POST", "/api/v1/sync/now", json=data)
response = self._make_request("POST", "/sync/now", json=data)
return response.get("data", {})
def get_scheduler_config(self) -> Dict[str, Any]:
"""Get scheduler configuration"""
response = self._make_request("GET", "/api/v1/sync/scheduler")
response = self._make_request("GET", "/sync/scheduler")
return response.get("data", {})
def update_scheduler_config(
@@ -185,5 +203,5 @@ class LeggenAPIClient:
if cron:
data["cron"] = cron
response = self._make_request("PUT", "/api/v1/sync/scheduler", json=data)
response = self._make_request("PUT", "/sync/scheduler", json=data)
return response.get("data", {})

View File

@@ -102,17 +102,19 @@ class BackgroundScheduler:
async def _run_sync(self, retry_count: int = 0):
"""Run sync with enhanced error handling and retry logic"""
try:
logger.info("Starting scheduled sync job")
await self.sync_service.sync_all_accounts()
logger.info("Scheduled sync job completed successfully")
trigger_type = "retry" if retry_count > 0 else "scheduled"
logger.info(f"Starting {trigger_type} sync job")
await self.sync_service.sync_all_accounts(trigger_type=trigger_type)
logger.info(f"{trigger_type.capitalize()} sync job completed successfully")
except Exception as e:
trigger_type = "retry" if retry_count > 0 else "scheduled"
logger.error(
f"Scheduled sync job failed (attempt {retry_count + 1}/{self.max_retries}): {e}"
f"{trigger_type.capitalize()} sync job failed (attempt {retry_count + 1}/{self.max_retries}): {e}"
)
# Send notification about the failure
try:
await self.notification_service.send_expiry_notification(
await self.notification_service.send_sync_failure_notification(
{
"type": "sync_failure",
"error": str(e),
@@ -145,7 +147,7 @@ class BackgroundScheduler:
logger.error("Maximum retries exceeded for sync job")
# Send final failure notification
try:
await self.notification_service.send_expiry_notification(
await self.notification_service.send_sync_failure_notification(
{
"type": "sync_final_failure",
"error": str(e),

View File

@@ -1,5 +1,6 @@
import click
from leggen.api_client import LeggenAPIClient
from leggen.main import cli
from leggen.utils.text import info, success
@@ -15,12 +16,11 @@ def delete(ctx, requisition_id: str):
Check `leggen status` to get the REQUISITION_ID
"""
import requests
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
info(f"Deleting Bank Requisition: {requisition_id}")
api_url = ctx.obj.get("api_url", "http://localhost:8000")
res = requests.delete(f"{api_url}/requisitions/{requisition_id}")
res.raise_for_status()
# Use API client to make the delete request
api_client._make_request("DELETE", f"/requisitions/{requisition_id}")
success(f"Bank Requisition {requisition_id} deleted")

View File

@@ -82,15 +82,6 @@ def create_app() -> FastAPI:
app.include_router(sync.router, prefix="/api/v1", tags=["sync"])
app.include_router(notifications.router, prefix="/api/v1", tags=["notifications"])
@app.get("/")
async def root():
# Get version dynamically
try:
version = metadata.version("leggen")
except metadata.PackageNotFoundError:
version = "unknown"
return {"message": "Leggen API is running", "version": version}
@app.get("/api/v1/health")
async def health():
"""Health check endpoint for API connectivity"""
@@ -99,11 +90,18 @@ def create_app() -> FastAPI:
config_loaded = config._config is not None
# Get version dynamically
try:
version = metadata.version("leggen")
except metadata.PackageNotFoundError:
version = "dev"
return APIResponse(
success=True,
data={
"status": "healthy",
"config_loaded": config_loaded,
"version": version,
"message": "API is running and responsive",
},
message="Health check successful",

View File

@@ -22,8 +22,8 @@ class DiscordNotificationConfig(BaseModel):
class TelegramNotificationConfig(BaseModel):
token: str = Field(..., alias="api-key", description="Telegram bot token")
chat_id: int = Field(..., alias="chat-id", description="Telegram chat ID")
token: str = Field(..., description="Telegram bot token")
chat_id: int = Field(..., description="Telegram chat ID")
enabled: bool = Field(default=True, description="Enable Telegram notifications")
@@ -33,12 +33,8 @@ class NotificationConfig(BaseModel):
class FilterConfig(BaseModel):
case_insensitive: Optional[List[str]] = Field(
default_factory=list, alias="case-insensitive"
)
case_sensitive: Optional[List[str]] = Field(
default_factory=list, alias="case-sensitive"
)
case_insensitive: Optional[List[str]] = Field(default_factory=list)
case_sensitive: Optional[List[str]] = Field(default_factory=list)
class SyncScheduleConfig(BaseModel):
@@ -60,6 +56,3 @@ class Config(BaseModel):
notifications: Optional[NotificationConfig] = None
filters: Optional[FilterConfig] = None
scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig)
class Config:
validate_by_name = True

View File

@@ -55,3 +55,44 @@ def send_transactions_message(ctx: click.Context, transactions: list):
response.raise_for_status()
except Exception as e:
raise Exception(f"Discord notification failed: {e}\n{response.text}") from e
def send_sync_failure_notification(ctx: click.Context, notification: dict):
info("Sending sync failure notification to Discord")
webhook = DiscordWebhook(url=ctx.obj["notifications"]["discord"]["webhook"])
# Determine color and title based on failure type
if notification.get("type") == "sync_final_failure":
color = "ff0000" # Red for final failure
title = "🚨 Sync Final Failure"
description = (
f"Sync failed permanently after {notification['retry_count']} attempts"
)
else:
color = "ffaa00" # Orange for retry
title = "⚠️ Sync Failure"
description = f"Sync failed (attempt {notification['retry_count']}/{notification['max_retries']}). Will retry automatically..."
embed = DiscordEmbed(
title=title,
description=description,
color=color,
)
embed.set_author(
name="Leggen",
url="https://github.com/elisiariocouto/leggen",
)
embed.add_embed_field(
name="Error",
value=notification["error"][:1024], # Discord has field value limits
inline=False,
)
embed.set_footer(text="Sync failure notification")
embed.set_timestamp()
webhook.add_embed(embed)
response = webhook.execute()
try:
response.raise_for_status()
except Exception as e:
raise Exception(f"Discord notification failed: {e}\n{response.text}") from e

View File

@@ -29,8 +29,8 @@ def escape_markdown(text: str) -> str:
def send_expire_notification(ctx: click.Context, notification: dict):
token = ctx.obj["notifications"]["telegram"]["api-key"]
chat_id = ctx.obj["notifications"]["telegram"]["chat-id"]
token = ctx.obj["notifications"]["telegram"]["token"]
chat_id = ctx.obj["notifications"]["telegram"]["chat_id"]
bot_url = f"https://api.telegram.org/bot{token}/sendMessage"
info("Sending expiration notification to Telegram")
message = "*💲 [Leggen](https://github.com/elisiariocouto/leggen)*\n"
@@ -54,8 +54,8 @@ def send_expire_notification(ctx: click.Context, notification: dict):
def send_transaction_message(ctx: click.Context, transactions: list):
token = ctx.obj["notifications"]["telegram"]["api-key"]
chat_id = ctx.obj["notifications"]["telegram"]["chat-id"]
token = ctx.obj["notifications"]["telegram"]["token"]
chat_id = ctx.obj["notifications"]["telegram"]["chat_id"]
bot_url = f"https://api.telegram.org/bot{token}/sendMessage"
info(f"Got {len(transactions)} new transactions, sending message to Telegram")
message = "*💲 [Leggen](https://github.com/elisiariocouto/leggen)*\n"
@@ -79,3 +79,38 @@ def send_transaction_message(ctx: click.Context, transactions: list):
res.raise_for_status()
except Exception as e:
raise Exception(f"Telegram notification failed: {e}\n{res.text}") from e
def send_sync_failure_notification(ctx: click.Context, notification: dict):
token = ctx.obj["notifications"]["telegram"]["token"]
chat_id = ctx.obj["notifications"]["telegram"]["chat_id"]
bot_url = f"https://api.telegram.org/bot{token}/sendMessage"
info("Sending sync failure notification to Telegram")
message = "*🚨 [Leggen](https://github.com/elisiariocouto/leggen)*\n"
message += "*Sync Failed*\n\n"
message += escape_markdown(f"Error: {notification['error']}\n")
if notification.get("type") == "sync_final_failure":
message += escape_markdown(
f"❌ Final failure after {notification['retry_count']} attempts\n"
)
else:
message += escape_markdown(
f"🔄 Attempt {notification['retry_count']}/{notification['max_retries']}\n"
)
message += escape_markdown("Will retry automatically...\n")
res = requests.post(
bot_url,
json={
"chat_id": chat_id,
"text": message,
"parse_mode": "MarkdownV2",
},
)
try:
res.raise_for_status()
except Exception as e:
raise Exception(f"Telegram notification failed: {e}\n{res.text}") from e

View File

@@ -215,6 +215,9 @@ class DatabaseService:
await self._migrate_balance_timestamps_if_needed()
await self._migrate_null_transaction_ids_if_needed()
await self._migrate_to_composite_key_if_needed()
await self._migrate_add_display_name_if_needed()
await self._migrate_add_sync_operations_if_needed()
await self._migrate_add_logo_if_needed()
async def _migrate_balance_timestamps_if_needed(self):
"""Check and migrate balance timestamps if needed"""
@@ -632,6 +635,79 @@ class DatabaseService:
logger.error(f"Composite key migration failed: {e}")
raise
async def _migrate_add_display_name_if_needed(self):
"""Check and add display_name column to accounts table if needed"""
try:
if await self._check_display_name_migration_needed():
logger.info("Display name column migration needed, starting...")
await self._migrate_add_display_name()
logger.info("Display name column migration completed")
else:
logger.info("Display name column already exists")
except Exception as e:
logger.error(f"Display name column migration failed: {e}")
raise
async def _check_display_name_migration_needed(self) -> bool:
"""Check if display_name column needs to be added to accounts table"""
db_path = path_manager.get_database_path()
if not db_path.exists():
return False
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# Check if accounts table exists
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'"
)
if not cursor.fetchone():
conn.close()
return False
# Check if display_name column exists
cursor.execute("PRAGMA table_info(accounts)")
columns = cursor.fetchall()
# Check if display_name column exists
has_display_name = any(col[1] == "display_name" for col in columns)
conn.close()
return not has_display_name
except Exception as e:
logger.error(f"Failed to check display_name migration status: {e}")
return False
async def _migrate_add_display_name(self):
"""Add display_name column to accounts table"""
db_path = path_manager.get_database_path()
if not db_path.exists():
logger.warning("Database file not found, skipping migration")
return
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
logger.info("Adding display_name column to accounts table...")
# Add the display_name column
cursor.execute("""
ALTER TABLE accounts
ADD COLUMN display_name TEXT
""")
conn.commit()
conn.close()
logger.info("Display name column migration completed successfully")
except Exception as e:
logger.error(f"Display name column migration failed: {e}")
raise
def _unix_to_datetime_string(self, unix_timestamp: float) -> str:
"""Convert Unix timestamp to datetime string"""
dt = datetime.fromtimestamp(unix_timestamp)
@@ -783,6 +859,14 @@ class DatabaseService:
for transaction in transactions:
try:
# Check if transaction already exists before insertion
cursor.execute(
"""SELECT COUNT(*) FROM transactions
WHERE accountId = ? AND transactionId = ?""",
(transaction["accountId"], transaction["transactionId"]),
)
exists = cursor.fetchone()[0] > 0
cursor.execute(
insert_sql,
(
@@ -799,7 +883,11 @@ class DatabaseService:
json.dumps(transaction["rawTransaction"]),
),
)
# Only add to new_transactions if it didn't exist before
if not exists:
new_transactions.append(transaction)
except sqlite3.IntegrityError as e:
logger.warning(
f"Failed to insert transaction {transaction.get('transactionId')}: {e}"
@@ -1045,7 +1133,9 @@ class DatabaseService:
currency TEXT,
created DATETIME,
last_accessed DATETIME,
last_updated DATETIME
last_updated DATETIME,
display_name TEXT,
logo TEXT
)"""
)
@@ -1060,6 +1150,16 @@ class DatabaseService:
)
try:
# First, check if account exists and preserve display_name
cursor.execute(
"SELECT display_name FROM accounts WHERE id = ?", (account_data["id"],)
)
existing_row = cursor.fetchone()
existing_display_name = existing_row[0] if existing_row else None
# Use existing display_name if not provided in account_data
display_name = account_data.get("display_name", existing_display_name)
# Insert or replace account data
cursor.execute(
"""INSERT OR REPLACE INTO accounts (
@@ -1071,8 +1171,10 @@ class DatabaseService:
currency,
created,
last_accessed,
last_updated
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
last_updated,
display_name,
logo
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
account_data["id"],
account_data["institution_id"],
@@ -1083,6 +1185,8 @@ class DatabaseService:
account_data["created"],
account_data.get("last_accessed"),
account_data.get("last_updated", account_data["created"]),
display_name,
account_data.get("logo"),
),
)
conn.commit()
@@ -1328,3 +1432,265 @@ class DatabaseService:
except Exception as e:
conn.close()
raise e
async def _check_sync_operations_migration_needed(self) -> bool:
"""Check if sync_operations table needs to be created"""
db_path = path_manager.get_database_path()
if not db_path.exists():
return False
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# Check if sync_operations table exists
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='sync_operations'"
)
table_exists = cursor.fetchone() is not None
conn.close()
return not table_exists
except Exception as e:
logger.error(f"Failed to check sync_operations migration status: {e}")
return False
async def _migrate_add_sync_operations(self):
"""Add sync_operations table"""
db_path = path_manager.get_database_path()
if not db_path.exists():
logger.warning("Database file not found, skipping migration")
return
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
logger.info("Creating sync_operations table...")
# Create the sync_operations table
cursor.execute("""
CREATE TABLE sync_operations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at DATETIME NOT NULL,
completed_at DATETIME,
success BOOLEAN,
accounts_processed INTEGER DEFAULT 0,
transactions_added INTEGER DEFAULT 0,
transactions_updated INTEGER DEFAULT 0,
balances_updated INTEGER DEFAULT 0,
duration_seconds REAL,
errors TEXT,
logs TEXT,
trigger_type TEXT DEFAULT 'manual'
)
""")
# Create indexes for better performance
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_sync_operations_started_at ON sync_operations(started_at)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_sync_operations_success ON sync_operations(success)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_sync_operations_trigger_type ON sync_operations(trigger_type)"
)
conn.commit()
conn.close()
logger.info("Sync operations table migration completed successfully")
except Exception as e:
logger.error(f"Sync operations table migration failed: {e}")
raise
async def _migrate_add_sync_operations_if_needed(self):
"""Check and add sync_operations table if needed"""
try:
if await self._check_sync_operations_migration_needed():
logger.info("Sync operations table migration needed, starting...")
await self._migrate_add_sync_operations()
logger.info("Sync operations table migration completed")
else:
logger.info("Sync operations table already exists")
except Exception as e:
logger.error(f"Sync operations table migration failed: {e}")
raise
async def _migrate_add_logo_if_needed(self):
"""Check and add logo column to accounts table if needed"""
try:
if await self._check_logo_migration_needed():
logger.info("Logo column migration needed, starting...")
await self._migrate_add_logo()
logger.info("Logo column migration completed")
else:
logger.info("Logo column already exists")
except Exception as e:
logger.error(f"Logo column migration failed: {e}")
raise
async def _check_logo_migration_needed(self) -> bool:
"""Check if logo column needs to be added to accounts table"""
db_path = path_manager.get_database_path()
if not db_path.exists():
return False
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# Check if accounts table exists
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'"
)
if not cursor.fetchone():
conn.close()
return False
# Check if logo column exists
cursor.execute("PRAGMA table_info(accounts)")
columns = cursor.fetchall()
# Check if logo column exists
has_logo = any(col[1] == "logo" for col in columns)
conn.close()
return not has_logo
except Exception as e:
logger.error(f"Failed to check logo migration status: {e}")
return False
async def _migrate_add_logo(self):
"""Add logo column to accounts table"""
db_path = path_manager.get_database_path()
if not db_path.exists():
logger.warning("Database file not found, skipping migration")
return
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
logger.info("Adding logo column to accounts table...")
# Add the logo column
cursor.execute("""
ALTER TABLE accounts
ADD COLUMN logo TEXT
""")
conn.commit()
conn.close()
logger.info("Logo column migration completed successfully")
except Exception as e:
logger.error(f"Logo column migration failed: {e}")
raise
async def persist_sync_operation(self, sync_operation: Dict[str, Any]) -> int:
"""Persist sync operation to database and return the ID"""
if not self.sqlite_enabled:
logger.warning("SQLite database disabled, cannot persist sync operation")
return 0
try:
import json
import sqlite3
db_path = path_manager.get_database_path()
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# Insert sync operation
cursor.execute(
"""INSERT INTO sync_operations (
started_at, completed_at, success, accounts_processed,
transactions_added, transactions_updated, balances_updated,
duration_seconds, errors, logs, trigger_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
sync_operation.get("started_at"),
sync_operation.get("completed_at"),
sync_operation.get("success"),
sync_operation.get("accounts_processed", 0),
sync_operation.get("transactions_added", 0),
sync_operation.get("transactions_updated", 0),
sync_operation.get("balances_updated", 0),
sync_operation.get("duration_seconds"),
json.dumps(sync_operation.get("errors", [])),
json.dumps(sync_operation.get("logs", [])),
sync_operation.get("trigger_type", "manual"),
),
)
operation_id = cursor.lastrowid
if operation_id is None:
raise ValueError("Failed to get operation ID after insert")
conn.commit()
conn.close()
logger.debug(f"Persisted sync operation with ID: {operation_id}")
return operation_id
except Exception as e:
logger.error(f"Failed to persist sync operation: {e}")
raise
async def get_sync_operations(
self, limit: int = 50, offset: int = 0
) -> List[Dict[str, Any]]:
"""Get sync operations from database"""
if not self.sqlite_enabled:
logger.warning("SQLite database disabled, cannot get sync operations")
return []
try:
import json
import sqlite3
db_path = path_manager.get_database_path()
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# Get sync operations ordered by started_at descending
cursor.execute(
"""SELECT id, started_at, completed_at, success, accounts_processed,
transactions_added, transactions_updated, balances_updated,
duration_seconds, errors, logs, trigger_type
FROM sync_operations
ORDER BY started_at DESC
LIMIT ? OFFSET ?""",
(limit, offset),
)
operations = []
for row in cursor.fetchall():
operation = {
"id": row[0],
"started_at": row[1],
"completed_at": row[2],
"success": bool(row[3]) if row[3] is not None else None,
"accounts_processed": row[4],
"transactions_added": row[5],
"transactions_updated": row[6],
"balances_updated": row[7],
"duration_seconds": row[8],
"errors": json.loads(row[9]) if row[9] else [],
"logs": json.loads(row[10]) if row[10] else [],
"trigger_type": row[11],
}
operations.append(operation)
conn.close()
return operations
except Exception as e:
logger.error(f"Failed to get sync operations: {e}")
return []

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