Compare commits

..

21 Commits

Author SHA1 Message Date
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
67 changed files with 3364 additions and 557 deletions

View File

@@ -144,23 +144,20 @@ jobs:
with: with:
fetch-depth: 0 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 - name: Generate release notes
uses: orhun/git-cliff-action@v4
id: release_notes id: release_notes
run: | with:
echo "notes<<EOF" >> $GITHUB_OUTPUT config: cliff.toml
git-cliff --current >> $GITHUB_OUTPUT args: --current
echo "EOF" >> $GITHUB_OUTPUT env:
GITHUB_REPO: ${{ github.repository }}
- name: Create Release - name: Create Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }} name: Release ${{ github.ref_name }}
body: ${{ steps.release_notes.outputs.notes }} body: ${{ steps.release_notes.outputs.content }}
draft: false draft: false
prerelease: false prerelease: false

1
.gitignore vendored
View File

@@ -165,3 +165,4 @@ leggen.db
*.db *.db
config.toml config.toml
.claude/ .claude/
.playwright-mcp/

View File

@@ -7,10 +7,10 @@
"mcp" "mcp"
] ]
}, },
"browsermcp": { "playwright": {
"command": "npx", "command": "npx",
"args": [ "args": [
"@browsermcp/mcp@latest" "@playwright/mcp@latest"
] ]
} }
} }

View File

@@ -138,3 +138,4 @@ This repository follows conventional changelog practices. Refer to `CONTRIBUTING
- Commit message format and scoping - Commit message format and scoping
- Release process using `scripts/release.sh` - Release process using `scripts/release.sh`
- Pre-commit hooks setup with `pre-commit install` - Pre-commit hooks setup with `pre-commit install`
- When the pre-commit fails, the commit is canceled

View File

@@ -1,4 +1,130 @@
## 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) ## 2025.9.18 (2025/09/19)
### Documentation ### Documentation

View File

@@ -4,11 +4,17 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" sizes="48x48" /> <link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" /> <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" /> <meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<title>Leggen</title> <title>Leggen</title>
<!-- PWA Meta Tags --> <!-- PWA Meta Tags -->
<meta name="description" content="Personal finance management application" /> <meta
name="description"
content="Personal finance management application"
/>
<meta name="application-name" content="Leggen" /> <meta name="application-name" content="Leggen" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <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-status-bar-style" content="default" />
@@ -21,8 +27,16 @@
<!-- Dynamic theme-color - will be updated by JavaScript --> <!-- Dynamic theme-color - will be updated by JavaScript -->
<meta name="theme-color" content="#0b74de" id="theme-color-meta" /> <meta name="theme-color" content="#0b74de" id="theme-color-meta" />
<meta name="msapplication-navbutton-color" content="#0b74de" id="ms-theme-color-meta" /> <meta
<meta name="apple-mobile-web-app-status-bar-style" content="default" id="apple-status-bar-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 --> <!-- Icons -->
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />

View File

@@ -22,6 +22,7 @@
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle-group": "^1.1.11",
@@ -4195,6 +4196,35 @@
} }
} }
}, },
"node_modules/@radix-ui/react-switch": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs": { "node_modules/@radix-ui/react-tabs": {
"version": "1.1.13", "version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",

View File

@@ -24,6 +24,7 @@
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle-group": "^1.1.11",

View File

@@ -78,6 +78,7 @@ export default function AccountSettings() {
const [editingAccountId, setEditingAccountId] = useState<string | null>(null); const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
const [editingName, setEditingName] = useState(""); const [editingName, setEditingName] = useState("");
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -144,7 +145,8 @@ export default function AccountSettings() {
<CardHeader> <CardHeader>
<CardTitle>Account Management</CardTitle> <CardTitle>Account Management</CardTitle>
<CardDescription> <CardDescription>
Manage your connected bank accounts and customize their display names Manage your connected bank accounts and customize their display
names
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@@ -193,8 +195,20 @@ export default function AccountSettings() {
{/* Mobile layout - stack vertically */} {/* Mobile layout - stack vertically */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1"> <div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
<div className="flex-shrink-0 p-2 sm:p-3 bg-muted rounded-full"> <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">
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" /> {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>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{editingAccountId === account.id ? ( {editingAccountId === account.id ? (
@@ -324,7 +338,8 @@ export default function AccountSettings() {
<CardHeader> <CardHeader>
<CardTitle>Add New Bank Account</CardTitle> <CardTitle>Add New Bank Account</CardTitle>
<CardDescription> <CardDescription>
Connect additional bank accounts to track all your finances in one place Connect additional bank accounts to track all your finances in one
place
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="p-6"> <CardContent className="p-6">
@@ -332,7 +347,8 @@ export default function AccountSettings() {
<div className="p-4 bg-muted rounded-lg"> <div className="p-4 bg-muted rounded-lg">
<Plus className="h-8 w-8 text-muted-foreground mx-auto mb-2" /> <Plus className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Bank connection functionality is coming soon. Stay tuned for updates! Bank connection functionality is coming soon. Stay tuned for
updates!
</p> </p>
</div> </div>
<Button disabled variant="outline"> <Button disabled variant="outline">

View File

@@ -3,7 +3,7 @@ import { Link, useLocation } from "@tanstack/react-router";
import { import {
List, List,
BarChart3, BarChart3,
Bell, Activity,
Settings, Settings,
Building2, Building2,
TrendingUp, TrendingUp,
@@ -27,18 +27,20 @@ import {
SidebarGroup, SidebarGroup,
SidebarGroupContent, SidebarGroupContent,
SidebarGroupLabel, SidebarGroupLabel,
useSidebar,
} from "./ui/sidebar"; } from "./ui/sidebar";
const navigation = [ const navigation = [
{ name: "Overview", icon: List, to: "/" }, { name: "Overview", icon: List, to: "/" },
{ name: "Analytics", icon: BarChart3, to: "/analytics" }, { name: "Analytics", icon: BarChart3, to: "/analytics" },
{ name: "Notifications", icon: Bell, to: "/notifications" }, { name: "System", icon: Activity, to: "/system" },
{ name: "Settings", icon: Settings, to: "/settings" }, { name: "Settings", icon: Settings, to: "/settings" },
]; ];
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const location = useLocation(); const location = useLocation();
const [accountsExpanded, setAccountsExpanded] = useState(false); const [accountsExpanded, setAccountsExpanded] = useState(false);
const { isMobile, setOpenMobile } = useSidebar();
const { data: accounts } = useQuery<Account[]>({ const { data: accounts } = useQuery<Account[]>({
queryKey: ["accounts"], queryKey: ["accounts"],
@@ -51,6 +53,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return sum + primaryBalance; return sum + primaryBalance;
}, 0) || 0; }, 0) || 0;
// Handler to close mobile sidebar when navigation item is clicked
const handleNavigationClick = () => {
if (isMobile) {
setOpenMobile(false);
}
};
return ( return (
<Sidebar collapsible="icon" className="pt-safe-top pl-safe-left" {...props}> <Sidebar collapsible="icon" className="pt-safe-top pl-safe-left" {...props}>
<SidebarHeader> <SidebarHeader>
@@ -60,7 +69,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
asChild asChild
className="data-[slot=sidebar-menu-button]:!p-1.5" className="data-[slot=sidebar-menu-button]:!p-1.5"
> >
<Link to="/" className="flex items-center space-x-2"> <Link
to="/"
className="flex items-center space-x-2"
onClick={handleNavigationClick}
>
<Logo size={24} /> <Logo size={24} />
<span className="text-base font-semibold">Leggen</span> <span className="text-base font-semibold">Leggen</span>
</Link> </Link>
@@ -80,7 +93,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
tooltip={item.name} tooltip={item.name}
isActive={location.pathname === item.to} isActive={location.pathname === item.to}
> >
<Link to={item.to}> <Link to={item.to} onClick={handleNavigationClick}>
<item.icon className="h-5 w-5" /> <item.icon className="h-5 w-5" />
<span>{item.name}</span> <span>{item.name}</span>
</Link> </Link>
@@ -129,7 +142,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<div className="border-t border-border/50 max-h-48 overflow-y-auto"> <div className="border-t border-border/50 max-h-48 overflow-y-auto">
{accounts.map((account) => { {accounts.map((account) => {
const primaryBalance = account.balances?.[0]?.amount || 0; const primaryBalance = account.balances?.[0]?.amount || 0;
const currency = account.balances?.[0]?.currency || account.currency || "EUR"; const currency =
account.balances?.[0]?.currency ||
account.currency ||
"EUR";
return ( return (
<div <div
@@ -142,7 +158,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</div> </div>
<div className="space-y-1 min-w-0 flex-1"> <div className="space-y-1 min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate"> <p className="text-xs font-medium text-foreground truncate">
{account.display_name || account.name || "Unnamed Account"} {account.display_name ||
account.name ||
"Unnamed Account"}
</p> </p>
<p className="text-xs font-semibold text-foreground"> <p className="text-xs font-semibold text-foreground">
{formatCurrency(primaryBalance, currency)} {formatCurrency(primaryBalance, currency)}

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

@@ -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,6 +10,10 @@ import {
CheckCircle, CheckCircle,
Settings, Settings,
TestTube, TestTube,
Activity,
Clock,
TrendingUp,
User,
} from "lucide-react"; } from "lucide-react";
import { apiClient } from "../lib/api"; import { apiClient } from "../lib/api";
import NotificationsSkeleton from "./NotificationsSkeleton"; import NotificationsSkeleton from "./NotificationsSkeleton";
@@ -32,7 +36,11 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "./ui/select"; } from "./ui/select";
import type { NotificationSettings, NotificationService } from "../types/api"; import type {
NotificationSettings,
NotificationService,
SyncOperationsResponse,
} from "../types/api";
export default function Notifications() { export default function Notifications() {
const [testService, setTestService] = useState(""); const [testService, setTestService] = useState("");
@@ -61,6 +69,16 @@ export default function Notifications() {
queryFn: apiClient.getNotificationServices, 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({ const testMutation = useMutation({
mutationFn: apiClient.testNotification, mutationFn: apiClient.testNotification,
onSuccess: () => { onSuccess: () => {
@@ -80,15 +98,15 @@ export default function Notifications() {
}, },
}); });
if (settingsLoading || servicesLoading) { if (settingsLoading || servicesLoading || syncOperationsLoading) {
return <NotificationsSkeleton />; return <NotificationsSkeleton />;
} }
if (settingsError || servicesError) { if (settingsError || servicesError || syncOperationsError) {
return ( return (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertTitle>Failed to load notifications</AlertTitle> <AlertTitle>Failed to load system data</AlertTitle>
<AlertDescription className="space-y-3"> <AlertDescription className="space-y-3">
<p> <p>
Unable to connect to the Leggen API. Please check your configuration Unable to connect to the Leggen API. Please check your configuration
@@ -98,6 +116,7 @@ export default function Notifications() {
onClick={() => { onClick={() => {
refetchSettings(); refetchSettings();
refetchServices(); refetchServices();
refetchSyncOperations();
}} }}
variant="outline" variant="outline"
size="sm" size="sm"
@@ -131,6 +150,110 @@ export default function Notifications() {
return ( return (
<div className="space-y-6"> <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 */} {/* Test Notification Section */}
<Card> <Card>
<CardHeader> <CardHeader>

View File

@@ -3,7 +3,7 @@ import { X, Download, RotateCcw } from "lucide-react";
interface BeforeInstallPromptEvent extends Event { interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>; prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
} }
interface PWAPromptProps { interface PWAPromptProps {
@@ -11,7 +11,8 @@ interface PWAPromptProps {
} }
export function PWAInstallPrompt({ onInstall }: PWAPromptProps) { export function PWAInstallPrompt({ onInstall }: PWAPromptProps) {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null); const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [showPrompt, setShowPrompt] = useState(false); const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => { useEffect(() => {
@@ -96,7 +97,10 @@ interface PWAUpdatePromptProps {
onUpdate: () => void; onUpdate: () => void;
} }
export function PWAUpdatePrompt({ updateAvailable, onUpdate }: PWAUpdatePromptProps) { export function PWAUpdatePrompt({
updateAvailable,
onUpdate,
}: PWAUpdatePromptProps) {
const [showPrompt, setShowPrompt] = useState(false); const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => { useEffect(() => {

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

@@ -10,7 +10,7 @@ const navigation = [
{ name: "Overview", to: "/" }, { name: "Overview", to: "/" },
{ name: "Transactions", to: "/transactions" }, { name: "Transactions", to: "/transactions" },
{ name: "Analytics", to: "/analytics" }, { name: "Analytics", to: "/analytics" },
{ name: "Notifications", to: "/notifications" }, { name: "System", to: "/system" },
{ name: "Settings", to: "/settings" }, { name: "Settings", to: "/settings" },
]; ];
@@ -30,6 +30,8 @@ export function SiteHeader() {
refetchInterval: 30000, refetchInterval: 30000,
}); });
return ( return (
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear pt-safe-top"> <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"> <div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
@@ -43,6 +45,20 @@ export function SiteHeader() {
</h1> </h1>
<div className="ml-auto flex items-center space-x-3"> <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"> <div className="flex items-center space-x-1">
{healthLoading ? ( {healthLoading ? (
<> <>

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

@@ -97,7 +97,6 @@ export default function TransactionsTable() {
queryFn: apiClient.getAccounts, queryFn: apiClient.getAccounts,
}); });
const { const {
data: transactionsResponse, data: transactionsResponse,
isLoading: transactionsLoading, isLoading: transactionsLoading,
@@ -141,11 +140,7 @@ export default function TransactionsTable() {
// Reset pagination when filters change // Reset pagination when filters change
useEffect(() => { useEffect(() => {
setCurrentPage(1); setCurrentPage(1);
}, [ }, [filterState.selectedAccount, filterState.startDate, filterState.endDate]);
filterState.selectedAccount,
filterState.startDate,
filterState.endDate,
]);
const handleViewRaw = (transaction: Transaction) => { const handleViewRaw = (transaction: Transaction) => {
setSelectedTransaction(transaction); setSelectedTransaction(transaction);
@@ -163,7 +158,6 @@ export default function TransactionsTable() {
filterState.startDate || filterState.startDate ||
filterState.endDate; filterState.endDate;
// Define columns // Define columns
const columns: ColumnDef<Transaction>[] = [ const columns: ColumnDef<Transaction>[] = [
{ {
@@ -196,8 +190,7 @@ export default function TransactionsTable() {
<div className="text-xs text-muted-foreground space-y-1"> <div className="text-xs text-muted-foreground space-y-1">
{account && ( {account && (
<p className="truncate"> <p className="truncate">
{account.name || "Unnamed Account"} {" "} {account.display_name || "Unnamed Account"}
{account.institution_id}
</p> </p>
)} )}
{(transaction.creditor_name || transaction.debtor_name) && ( {(transaction.creditor_name || transaction.debtor_name) && (
@@ -427,10 +420,7 @@ export default function TransactionsTable() {
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-muted/50"> <tr key={row.id} className="hover:bg-muted/50">
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td <td key={cell.id} className="px-6 py-4 whitespace-nowrap">
key={cell.id}
className="px-6 py-4 whitespace-nowrap"
>
{flexRender( {flexRender(
cell.column.columnDef.cell, cell.column.columnDef.cell,
cell.getContext(), cell.getContext(),
@@ -495,8 +485,7 @@ export default function TransactionsTable() {
<div className="text-xs text-muted-foreground space-y-1 mt-1"> <div className="text-xs text-muted-foreground space-y-1 mt-1">
{account && ( {account && (
<p className="break-words"> <p className="break-words">
{account.name || "Unnamed Account"} {" "} {account.display_name || "Unnamed Account"}
{account.institution_id}
</p> </p>
)} )}
{(transaction.creditor_name || {(transaction.creditor_name ||

View File

@@ -29,13 +29,9 @@ export default function StatCard({
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-muted-foreground"> <p className="text-sm font-medium text-muted-foreground">{title}</p>
{title}
</p>
<div className="flex items-baseline"> <div className="flex items-baseline">
<p className="text-2xl font-bold text-foreground"> <p className="text-2xl font-bold text-foreground">{value}</p>
{value}
</p>
{trend && ( {trend && (
<div <div
className={cn( className={cn(
@@ -51,29 +47,31 @@ export default function StatCard({
)} )}
</div> </div>
{subtitle && ( {subtitle && (
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">{subtitle}</p>
{subtitle}
</p>
)} )}
</div> </div>
<div className={cn( <div
"p-3 rounded-full", className={cn(
iconColor === "green" && "bg-green-100 dark:bg-green-900/20", "p-3 rounded-full",
iconColor === "blue" && "bg-blue-100 dark:bg-blue-900/20", iconColor === "green" && "bg-green-100 dark:bg-green-900/20",
iconColor === "red" && "bg-red-100 dark:bg-red-900/20", iconColor === "blue" && "bg-blue-100 dark:bg-blue-900/20",
iconColor === "purple" && "bg-purple-100 dark:bg-purple-900/20", iconColor === "red" && "bg-red-100 dark:bg-red-900/20",
iconColor === "orange" && "bg-orange-100 dark:bg-orange-900/20", iconColor === "purple" && "bg-purple-100 dark:bg-purple-900/20",
iconColor === "default" && "bg-muted" 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", <Icon
iconColor === "blue" && "text-blue-600", className={cn(
iconColor === "red" && "text-red-600", "h-6 w-6",
iconColor === "purple" && "text-purple-600", iconColor === "green" && "text-green-600",
iconColor === "orange" && "text-orange-600", iconColor === "blue" && "text-blue-600",
iconColor === "default" && "text-muted-foreground" iconColor === "red" && "text-red-600",
)} /> iconColor === "purple" && "text-purple-600",
iconColor === "orange" && "text-orange-600",
iconColor === "default" && "text-muted-foreground",
)}
/>
</div> </div>
</div> </div>
</CardContent> </CardContent>

View File

@@ -15,12 +15,12 @@ export default function TimePeriodFilter({
className = "", className = "",
}: TimePeriodFilterProps) { }: TimePeriodFilterProps) {
return ( 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"> <div className="flex items-center gap-2 text-foreground">
<Calendar size={20} /> <Calendar size={20} />
<span className="font-medium">Time Period:</span> <span className="font-medium">Time Period:</span>
</div> </div>
<div className="flex gap-2"> <div className="flex flex-wrap gap-2">
{TIME_PERIODS.map((period) => ( {TIME_PERIODS.map((period) => (
<Button <Button
key={period.value} key={period.value}

View File

@@ -70,7 +70,6 @@ export function ActiveFilterChips({
}); });
} }
const handleRemoveChip = (key: keyof FilterState) => { const handleRemoveChip = (key: keyof FilterState) => {
switch (key) { switch (key) {
case "startDate": case "startDate":

View File

@@ -91,7 +91,6 @@ export function FilterBar({
className="w-[220px]" className="w-[220px]"
/> />
</div> </div>
</div> </div>
{/* Mobile Layout */} {/* Mobile Layout */}
@@ -129,7 +128,6 @@ export function FilterBar({
onDateRangeChange={handleDateRangeChange} onDateRangeChange={handleDateRangeChange}
className="w-full" className="w-full"
/> />
</div> </div>
</div> </div>

View File

@@ -1,8 +1,8 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
@@ -31,27 +31,27 @@ const buttonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean;
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => { ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
ref={ref} ref={ref}
{...props} {...props}
/> />
) );
} },
) );
Button.displayName = "Button" Button.displayName = "Button";
export { Button, buttonVariants } export { Button, buttonVariants };

View File

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

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

View File

@@ -15,7 +15,9 @@ export function Logo({ className = "", size = 32 }: LogoProps) {
aria-labelledby="logo-title logo-desc" aria-labelledby="logo-title logo-desc"
> >
<title id="logo-title">leggen stylized italic L</title> <title id="logo-title">leggen stylized italic L</title>
<desc id="logo-desc">Square gradient background with italic white L.</desc> <desc id="logo-desc">
Square gradient background with italic white L.
</desc>
<defs> <defs>
<linearGradient id="logo-bg" x1="0" y1="0" x2="1" y2="1"> <linearGradient id="logo-bg" x1="0" y1="0" x2="1" y2="1">

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

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

View File

@@ -1,19 +1,19 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog" import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react" import { X } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Sheet = SheetPrimitive.Root const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef< const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>, React.ElementRef<typeof SheetPrimitive.Overlay>,
@@ -22,13 +22,13 @@ const SheetOverlay = React.forwardRef<
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className,
)} )}
{...props} {...props}
ref={ref} ref={ref}
/> />
)) ));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva( 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", "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",
@@ -46,8 +46,8 @@ const sheetVariants = cva(
defaultVariants: { defaultVariants: {
side: "right", side: "right",
}, },
} },
) );
interface SheetContentProps interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
@@ -71,8 +71,8 @@ const SheetContent = React.forwardRef<
{children} {children}
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
)) ));
SheetContent.displayName = SheetPrimitive.Content.displayName SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ const SheetHeader = ({
className, className,
@@ -81,12 +81,12 @@ const SheetHeader = ({
<div <div
className={cn( className={cn(
"flex flex-col space-y-2 text-center sm:text-left", "flex flex-col space-y-2 text-center sm:text-left",
className className,
)} )}
{...props} {...props}
/> />
) );
SheetHeader.displayName = "SheetHeader" SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({ const SheetFooter = ({
className, className,
@@ -95,12 +95,12 @@ const SheetFooter = ({
<div <div
className={cn( className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className className,
)} )}
{...props} {...props}
/> />
) );
SheetFooter.displayName = "SheetFooter" SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef< const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>, React.ElementRef<typeof SheetPrimitive.Title>,
@@ -111,8 +111,8 @@ const SheetTitle = React.forwardRef<
className={cn("text-lg font-semibold text-foreground", className)} className={cn("text-lg font-semibold text-foreground", className)}
{...props} {...props}
/> />
)) ));
SheetTitle.displayName = SheetPrimitive.Title.displayName SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef< const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>, React.ElementRef<typeof SheetPrimitive.Description>,
@@ -123,8 +123,8 @@ const SheetDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
SheetDescription.displayName = SheetPrimitive.Description.displayName SheetDescription.displayName = SheetPrimitive.Description.displayName;
export { export {
Sheet, Sheet,
@@ -137,4 +137,4 @@ export {
SheetFooter, SheetFooter,
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
} };

View File

@@ -1,62 +1,62 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeft } from "lucide-react" import { PanelLeft } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile" import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetDescription, SheetDescription,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from "@/components/ui/sheet" } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state" const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem" const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem" const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b" const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = { type SidebarContextProps = {
state: "expanded" | "collapsed" state: "expanded" | "collapsed";
open: boolean open: boolean;
setOpen: (open: boolean) => void setOpen: (open: boolean) => void;
openMobile: boolean openMobile: boolean;
setOpenMobile: (open: boolean) => void setOpenMobile: (open: boolean) => void;
isMobile: boolean isMobile: boolean;
toggleSidebar: () => void toggleSidebar: () => void;
} };
const SidebarContext = React.createContext<SidebarContextProps | null>(null) const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() { function useSidebar() {
const context = React.useContext(SidebarContext) const context = React.useContext(SidebarContext);
if (!context) { if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.") throw new Error("useSidebar must be used within a SidebarProvider.");
} }
return context return context;
} }
const SidebarProvider = React.forwardRef< const SidebarProvider = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> & { React.ComponentProps<"div"> & {
defaultOpen?: boolean defaultOpen?: boolean;
open?: boolean open?: boolean;
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void;
} }
>( >(
( (
@@ -69,36 +69,36 @@ const SidebarProvider = React.forwardRef<
children, children,
...props ...props
}, },
ref ref,
) => { ) => {
const isMobile = useIsMobile() const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false) const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar. // This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component. // We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen) const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open const open = openProp ?? _open;
const setOpen = React.useCallback( const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => { (value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) { if (setOpenProp) {
setOpenProp(openState) setOpenProp(openState);
} else { } else {
_setOpen(openState) _setOpen(openState);
} }
// This sets the cookie to keep the sidebar state. // This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
}, },
[setOpenProp, open] [setOpenProp, open],
) );
// Helper to toggle the sidebar. // Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => { const toggleSidebar = React.useCallback(() => {
return isMobile return isMobile
? setOpenMobile((open) => !open) ? setOpenMobile((open) => !open)
: setOpen((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]) }, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar. // Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => { React.useEffect(() => {
@@ -107,18 +107,18 @@ const SidebarProvider = React.forwardRef<
event.key === SIDEBAR_KEYBOARD_SHORTCUT && event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey) (event.metaKey || event.ctrlKey)
) { ) {
event.preventDefault() event.preventDefault();
toggleSidebar() toggleSidebar();
} }
} };
window.addEventListener("keydown", handleKeyDown) window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]) }, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed". // 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. // This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed" const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>( const contextValue = React.useMemo<SidebarContextProps>(
() => ({ () => ({
@@ -130,8 +130,16 @@ const SidebarProvider = React.forwardRef<
setOpenMobile, setOpenMobile,
toggleSidebar, toggleSidebar,
}), }),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] [
) state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
],
);
return ( return (
<SidebarContext.Provider value={contextValue}> <SidebarContext.Provider value={contextValue}>
@@ -146,7 +154,7 @@ const SidebarProvider = React.forwardRef<
} }
className={cn( className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar", "group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className className,
)} )}
ref={ref} ref={ref}
{...props} {...props}
@@ -155,17 +163,17 @@ const SidebarProvider = React.forwardRef<
</div> </div>
</TooltipProvider> </TooltipProvider>
</SidebarContext.Provider> </SidebarContext.Provider>
) );
} },
) );
SidebarProvider.displayName = "SidebarProvider" SidebarProvider.displayName = "SidebarProvider";
const Sidebar = React.forwardRef< const Sidebar = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> & { React.ComponentProps<"div"> & {
side?: "left" | "right" side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset" variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none" collapsible?: "offcanvas" | "icon" | "none";
} }
>( >(
( (
@@ -177,23 +185,23 @@ const Sidebar = React.forwardRef<
children, children,
...props ...props
}, },
ref ref,
) => { ) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar() const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") { if (collapsible === "none") {
return ( return (
<div <div
className={cn( className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", "flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className className,
)} )}
ref={ref} ref={ref}
{...props} {...props}
> >
{children} {children}
</div> </div>
) );
} }
if (isMobile) { if (isMobile) {
@@ -217,7 +225,7 @@ const Sidebar = React.forwardRef<
<div className="flex h-full w-full flex-col">{children}</div> <div className="flex h-full w-full flex-col">{children}</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
) );
} }
return ( return (
@@ -237,7 +245,7 @@ const Sidebar = React.forwardRef<
"group-data-[side=right]:rotate-180", "group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset" variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]" ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]" : "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
)} )}
/> />
<div <div
@@ -250,7 +258,7 @@ const Sidebar = React.forwardRef<
variant === "floating" || variant === "inset" variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]" ? "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", : "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className className,
)} )}
{...props} {...props}
> >
@@ -262,16 +270,16 @@ const Sidebar = React.forwardRef<
</div> </div>
</div> </div>
</div> </div>
) );
} },
) );
Sidebar.displayName = "Sidebar" Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef< const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>, React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button> React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => { >(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar() const { toggleSidebar } = useSidebar();
return ( return (
<Button <Button
@@ -281,23 +289,23 @@ const SidebarTrigger = React.forwardRef<
size="icon" size="icon"
className={cn("h-7 w-7", className)} className={cn("h-7 w-7", className)}
onClick={(event) => { onClick={(event) => {
onClick?.(event) onClick?.(event);
toggleSidebar() toggleSidebar();
}} }}
{...props} {...props}
> >
<PanelLeft /> <PanelLeft />
<span className="sr-only">Toggle Sidebar</span> <span className="sr-only">Toggle Sidebar</span>
</Button> </Button>
) );
}) });
SidebarTrigger.displayName = "SidebarTrigger" SidebarTrigger.displayName = "SidebarTrigger";
const SidebarRail = React.forwardRef< const SidebarRail = React.forwardRef<
HTMLButtonElement, HTMLButtonElement,
React.ComponentProps<"button"> React.ComponentProps<"button">
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar() const { toggleSidebar } = useSidebar();
return ( return (
<button <button
@@ -314,13 +322,13 @@ const SidebarRail = React.forwardRef<
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar", "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=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className className,
)} )}
{...props} {...props}
/> />
) );
}) });
SidebarRail.displayName = "SidebarRail" SidebarRail.displayName = "SidebarRail";
const SidebarInset = React.forwardRef< const SidebarInset = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@@ -332,13 +340,13 @@ const SidebarInset = React.forwardRef<
className={cn( className={cn(
"relative flex w-full flex-1 flex-col bg-background", "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", "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 className,
)} )}
{...props} {...props}
/> />
) );
}) });
SidebarInset.displayName = "SidebarInset" SidebarInset.displayName = "SidebarInset";
const SidebarInput = React.forwardRef< const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>, React.ElementRef<typeof Input>,
@@ -350,13 +358,13 @@ const SidebarInput = React.forwardRef<
data-sidebar="input" data-sidebar="input"
className={cn( className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring", "h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className className,
)} )}
{...props} {...props}
/> />
) );
}) });
SidebarInput.displayName = "SidebarInput" SidebarInput.displayName = "SidebarInput";
const SidebarHeader = React.forwardRef< const SidebarHeader = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@@ -369,9 +377,9 @@ const SidebarHeader = React.forwardRef<
className={cn("flex flex-col gap-2 p-2", className)} className={cn("flex flex-col gap-2 p-2", className)}
{...props} {...props}
/> />
) );
}) });
SidebarHeader.displayName = "SidebarHeader" SidebarHeader.displayName = "SidebarHeader";
const SidebarFooter = React.forwardRef< const SidebarFooter = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@@ -384,9 +392,9 @@ const SidebarFooter = React.forwardRef<
className={cn("flex flex-col gap-2 p-2", className)} className={cn("flex flex-col gap-2 p-2", className)}
{...props} {...props}
/> />
) );
}) });
SidebarFooter.displayName = "SidebarFooter" SidebarFooter.displayName = "SidebarFooter";
const SidebarSeparator = React.forwardRef< const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>, React.ElementRef<typeof Separator>,
@@ -399,9 +407,9 @@ const SidebarSeparator = React.forwardRef<
className={cn("mx-2 w-auto bg-sidebar-border", className)} className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props} {...props}
/> />
) );
}) });
SidebarSeparator.displayName = "SidebarSeparator" SidebarSeparator.displayName = "SidebarSeparator";
const SidebarContent = React.forwardRef< const SidebarContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@@ -413,13 +421,13 @@ const SidebarContent = React.forwardRef<
data-sidebar="content" data-sidebar="content"
className={cn( className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
}) });
SidebarContent.displayName = "SidebarContent" SidebarContent.displayName = "SidebarContent";
const SidebarGroup = React.forwardRef< const SidebarGroup = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@@ -432,15 +440,15 @@ const SidebarGroup = React.forwardRef<
className={cn("relative flex w-full min-w-0 flex-col p-2", className)} className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props} {...props}
/> />
) );
}) });
SidebarGroup.displayName = "SidebarGroup" SidebarGroup.displayName = "SidebarGroup";
const SidebarGroupLabel = React.forwardRef< const SidebarGroupLabel = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean } React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => { >(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div" const Comp = asChild ? Slot : "div";
return ( return (
<Comp <Comp
@@ -449,19 +457,19 @@ const SidebarGroupLabel = React.forwardRef<
className={cn( 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", "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", "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className className,
)} )}
{...props} {...props}
/> />
) );
}) });
SidebarGroupLabel.displayName = "SidebarGroupLabel" SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupAction = React.forwardRef< const SidebarGroupAction = React.forwardRef<
HTMLButtonElement, HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean } React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => { >(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
@@ -472,13 +480,13 @@ const SidebarGroupAction = React.forwardRef<
// Increases the hit area of the button on mobile. // Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden", "after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
}) });
SidebarGroupAction.displayName = "SidebarGroupAction" SidebarGroupAction.displayName = "SidebarGroupAction";
const SidebarGroupContent = React.forwardRef< const SidebarGroupContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@@ -490,8 +498,8 @@ const SidebarGroupContent = React.forwardRef<
className={cn("w-full text-sm", className)} className={cn("w-full text-sm", className)}
{...props} {...props}
/> />
)) ));
SidebarGroupContent.displayName = "SidebarGroupContent" SidebarGroupContent.displayName = "SidebarGroupContent";
const SidebarMenu = React.forwardRef< const SidebarMenu = React.forwardRef<
HTMLUListElement, HTMLUListElement,
@@ -503,8 +511,8 @@ const SidebarMenu = React.forwardRef<
className={cn("flex w-full min-w-0 flex-col gap-1", className)} className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props} {...props}
/> />
)) ));
SidebarMenu.displayName = "SidebarMenu" SidebarMenu.displayName = "SidebarMenu";
const SidebarMenuItem = React.forwardRef< const SidebarMenuItem = React.forwardRef<
HTMLLIElement, HTMLLIElement,
@@ -516,8 +524,8 @@ const SidebarMenuItem = React.forwardRef<
className={cn("group/menu-item relative", className)} className={cn("group/menu-item relative", className)}
{...props} {...props}
/> />
)) ));
SidebarMenuItem.displayName = "SidebarMenuItem" SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva( 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", "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",
@@ -538,15 +546,15 @@ const sidebarMenuButtonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
const SidebarMenuButton = React.forwardRef< const SidebarMenuButton = React.forwardRef<
HTMLButtonElement, HTMLButtonElement,
React.ComponentProps<"button"> & { React.ComponentProps<"button"> & {
asChild?: boolean asChild?: boolean;
isActive?: boolean isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent> tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants> } & VariantProps<typeof sidebarMenuButtonVariants>
>( >(
( (
@@ -559,10 +567,10 @@ const SidebarMenuButton = React.forwardRef<
className, className,
...props ...props
}, },
ref ref,
) => { ) => {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar() const { isMobile, state } = useSidebar();
const button = ( const button = (
<Comp <Comp
@@ -573,16 +581,16 @@ const SidebarMenuButton = React.forwardRef<
className={cn(sidebarMenuButtonVariants({ variant, size }), className)} className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props} {...props}
/> />
) );
if (!tooltip) { if (!tooltip) {
return button return button;
} }
if (typeof tooltip === "string") { if (typeof tooltip === "string") {
tooltip = { tooltip = {
children: tooltip, children: tooltip,
} };
} }
return ( return (
@@ -595,19 +603,19 @@ const SidebarMenuButton = React.forwardRef<
{...tooltip} {...tooltip}
/> />
</Tooltip> </Tooltip>
) );
} },
) );
SidebarMenuButton.displayName = "SidebarMenuButton" SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef< const SidebarMenuAction = React.forwardRef<
HTMLButtonElement, HTMLButtonElement,
React.ComponentProps<"button"> & { React.ComponentProps<"button"> & {
asChild?: boolean asChild?: boolean;
showOnHover?: boolean showOnHover?: boolean;
} }
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => { >(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
@@ -623,13 +631,13 @@ const SidebarMenuAction = React.forwardRef<
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
showOnHover && 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", "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 className,
)} )}
{...props} {...props}
/> />
) );
}) });
SidebarMenuAction.displayName = "SidebarMenuAction" SidebarMenuAction.displayName = "SidebarMenuAction";
const SidebarMenuBadge = React.forwardRef< const SidebarMenuBadge = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@@ -645,23 +653,23 @@ const SidebarMenuBadge = React.forwardRef<
"peer-data-[size=default]/menu-button:top-1.5", "peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5", "peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
)) ));
SidebarMenuBadge.displayName = "SidebarMenuBadge" SidebarMenuBadge.displayName = "SidebarMenuBadge";
const SidebarMenuSkeleton = React.forwardRef< const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> & { React.ComponentProps<"div"> & {
showIcon?: boolean showIcon?: boolean;
} }
>(({ className, showIcon = false, ...props }, ref) => { >(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%. // Random width between 50 to 90%.
const width = React.useMemo(() => { const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%` return `${Math.floor(Math.random() * 40) + 50}%`;
}, []) }, []);
return ( return (
<div <div
@@ -686,9 +694,9 @@ const SidebarMenuSkeleton = React.forwardRef<
} }
/> />
</div> </div>
) );
}) });
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton" SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
const SidebarMenuSub = React.forwardRef< const SidebarMenuSub = React.forwardRef<
HTMLUListElement, HTMLUListElement,
@@ -700,28 +708,28 @@ const SidebarMenuSub = React.forwardRef<
className={cn( 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", "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", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
)) ));
SidebarMenuSub.displayName = "SidebarMenuSub" SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuSubItem = React.forwardRef< const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement, HTMLLIElement,
React.ComponentProps<"li"> React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />) >(({ ...props }, ref) => <li ref={ref} {...props} />);
SidebarMenuSubItem.displayName = "SidebarMenuSubItem" SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
const SidebarMenuSubButton = React.forwardRef< const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement, HTMLAnchorElement,
React.ComponentProps<"a"> & { React.ComponentProps<"a"> & {
asChild?: boolean asChild?: boolean;
size?: "sm" | "md" size?: "sm" | "md";
isActive?: boolean isActive?: boolean;
} }
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => { >(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a" const Comp = asChild ? Slot : "a";
return ( return (
<Comp <Comp
@@ -735,13 +743,13 @@ const SidebarMenuSubButton = React.forwardRef<
size === "sm" && "text-xs", size === "sm" && "text-xs",
size === "md" && "text-sm", size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
}) });
SidebarMenuSubButton.displayName = "SidebarMenuSubButton" SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export { export {
Sidebar, Sidebar,
@@ -768,4 +776,4 @@ export {
SidebarSeparator, SidebarSeparator,
SidebarTrigger, SidebarTrigger,
useSidebar, useSidebar,
} };

View File

@@ -1,4 +1,4 @@
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Skeleton({ function Skeleton({
className, className,
@@ -9,7 +9,7 @@ function Skeleton({
className={cn("animate-pulse rounded-md bg-primary/10", className)} className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props} {...props}
/> />
) );
} }
export { Skeleton } export { Skeleton };

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

@@ -1,13 +1,13 @@
import * as React from "react" import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip" import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef< const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>, React.ElementRef<typeof TooltipPrimitive.Content>,
@@ -19,12 +19,12 @@ const TooltipContent = React.forwardRef<
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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]", "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 className,
)} )}
{...props} {...props}
/> />
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
)) ));
TooltipContent.displayName = TooltipPrimitive.Content.displayName TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -51,22 +51,29 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
const themeColor = THEME_COLORS[resolvedTheme]; const themeColor = THEME_COLORS[resolvedTheme];
// Update theme-color meta tag // Update theme-color meta tag
const themeColorMeta = document.getElementById("theme-color-meta") as HTMLMetaElement; const themeColorMeta = document.getElementById(
"theme-color-meta",
) as HTMLMetaElement;
if (themeColorMeta) { if (themeColorMeta) {
themeColorMeta.content = themeColor; themeColorMeta.content = themeColor;
} }
// Update Microsoft tile color // Update Microsoft tile color
const msThemeColorMeta = document.getElementById("ms-theme-color-meta") as HTMLMetaElement; const msThemeColorMeta = document.getElementById(
"ms-theme-color-meta",
) as HTMLMetaElement;
if (msThemeColorMeta) { if (msThemeColorMeta) {
msThemeColorMeta.content = themeColor; msThemeColorMeta.content = themeColor;
} }
// Update Apple status bar style for better iOS integration // Update Apple status bar style for better iOS integration
const appleStatusBarMeta = document.getElementById("apple-status-bar-meta") as HTMLMetaElement; const appleStatusBarMeta = document.getElementById(
"apple-status-bar-meta",
) as HTMLMetaElement;
if (appleStatusBarMeta) { if (appleStatusBarMeta) {
// Use 'black-translucent' for dark theme, 'default' for light theme // Use 'black-translucent' for dark theme, 'default' for light theme
appleStatusBarMeta.content = resolvedTheme === "dark" ? "black-translucent" : "default"; appleStatusBarMeta.content =
resolvedTheme === "dark" ? "black-translucent" : "default";
} }
}; };

View File

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

View File

@@ -7,26 +7,30 @@ interface PWAUpdate {
export function usePWA(): PWAUpdate { export function usePWA(): PWAUpdate {
const [updateAvailable, setUpdateAvailable] = useState(false); const [updateAvailable, setUpdateAvailable] = useState(false);
const [updateSW, setUpdateSW] = useState<() => Promise<void>>(() => async () => {}); const [updateSW, setUpdateSW] = useState<() => Promise<void>>(
() => async () => {},
);
useEffect(() => { useEffect(() => {
// Check if SW registration is available // Check if SW registration is available
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
// Import the registerSW function // Import the registerSW function
import("virtual:pwa-register").then(({ registerSW }) => { import("virtual:pwa-register")
const updateSWFunction = registerSW({ .then(({ registerSW }) => {
onNeedRefresh() { const updateSWFunction = registerSW({
setUpdateAvailable(true); onNeedRefresh() {
setUpdateSW(() => updateSWFunction); setUpdateAvailable(true);
}, setUpdateSW(() => updateSWFunction);
onOfflineReady() { },
console.log("App ready to work offline"); onOfflineReady() {
}, console.log("App ready to work offline");
},
});
})
.catch(() => {
// PWA not available in development mode or when disabled
console.log("PWA registration not available");
}); });
}).catch(() => {
// PWA not available in development mode or when disabled
console.log("PWA registration not available");
});
} }
}, []); }, []);

View File

@@ -12,6 +12,7 @@ import type {
HealthData, HealthData,
AccountUpdate, AccountUpdate,
TransactionStats, TransactionStats,
SyncOperationsResponse,
} from "../types/api"; } from "../types/api";
// Use VITE_API_URL for development, relative URLs for production // Use VITE_API_URL for development, relative URLs for production
@@ -167,6 +168,8 @@ export const apiClient = {
return response.data.data; return response.data.data;
}, },
// Analytics endpoints // Analytics endpoints
getTransactionStats: async (days?: number): Promise<TransactionStats> => { getTransactionStats: async (days?: number): Promise<TransactionStats> => {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
@@ -217,6 +220,17 @@ export const apiClient = {
>(`/transactions/monthly-stats?${queryParams.toString()}`); >(`/transactions/monthly-stats?${queryParams.toString()}`);
return response.data.data; 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; export default apiClient;

View File

@@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as TransactionsRouteImport } from './routes/transactions' import { Route as TransactionsRouteImport } from './routes/transactions'
import { Route as SystemRouteImport } from './routes/system'
import { Route as SettingsRouteImport } from './routes/settings' import { Route as SettingsRouteImport } from './routes/settings'
import { Route as NotificationsRouteImport } from './routes/notifications' import { Route as NotificationsRouteImport } from './routes/notifications'
import { Route as AnalyticsRouteImport } from './routes/analytics' import { Route as AnalyticsRouteImport } from './routes/analytics'
@@ -20,6 +21,11 @@ const TransactionsRoute = TransactionsRouteImport.update({
path: '/transactions', path: '/transactions',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const SystemRoute = SystemRouteImport.update({
id: '/system',
path: '/system',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsRoute = SettingsRouteImport.update({ const SettingsRoute = SettingsRouteImport.update({
id: '/settings', id: '/settings',
path: '/settings', path: '/settings',
@@ -46,6 +52,7 @@ export interface FileRoutesByFullPath {
'/analytics': typeof AnalyticsRoute '/analytics': typeof AnalyticsRoute
'/notifications': typeof NotificationsRoute '/notifications': typeof NotificationsRoute
'/settings': typeof SettingsRoute '/settings': typeof SettingsRoute
'/system': typeof SystemRoute
'/transactions': typeof TransactionsRoute '/transactions': typeof TransactionsRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
@@ -53,6 +60,7 @@ export interface FileRoutesByTo {
'/analytics': typeof AnalyticsRoute '/analytics': typeof AnalyticsRoute
'/notifications': typeof NotificationsRoute '/notifications': typeof NotificationsRoute
'/settings': typeof SettingsRoute '/settings': typeof SettingsRoute
'/system': typeof SystemRoute
'/transactions': typeof TransactionsRoute '/transactions': typeof TransactionsRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
@@ -61,6 +69,7 @@ export interface FileRoutesById {
'/analytics': typeof AnalyticsRoute '/analytics': typeof AnalyticsRoute
'/notifications': typeof NotificationsRoute '/notifications': typeof NotificationsRoute
'/settings': typeof SettingsRoute '/settings': typeof SettingsRoute
'/system': typeof SystemRoute
'/transactions': typeof TransactionsRoute '/transactions': typeof TransactionsRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
@@ -70,15 +79,23 @@ export interface FileRouteTypes {
| '/analytics' | '/analytics'
| '/notifications' | '/notifications'
| '/settings' | '/settings'
| '/system'
| '/transactions' | '/transactions'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/analytics' | '/notifications' | '/settings' | '/transactions' to:
| '/'
| '/analytics'
| '/notifications'
| '/settings'
| '/system'
| '/transactions'
id: id:
| '__root__' | '__root__'
| '/' | '/'
| '/analytics' | '/analytics'
| '/notifications' | '/notifications'
| '/settings' | '/settings'
| '/system'
| '/transactions' | '/transactions'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
@@ -87,6 +104,7 @@ export interface RootRouteChildren {
AnalyticsRoute: typeof AnalyticsRoute AnalyticsRoute: typeof AnalyticsRoute
NotificationsRoute: typeof NotificationsRoute NotificationsRoute: typeof NotificationsRoute
SettingsRoute: typeof SettingsRoute SettingsRoute: typeof SettingsRoute
SystemRoute: typeof SystemRoute
TransactionsRoute: typeof TransactionsRoute TransactionsRoute: typeof TransactionsRoute
} }
@@ -99,6 +117,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof TransactionsRouteImport preLoaderRoute: typeof TransactionsRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/system': {
id: '/system'
path: '/system'
fullPath: '/system'
preLoaderRoute: typeof SystemRouteImport
parentRoute: typeof rootRouteImport
}
'/settings': { '/settings': {
id: '/settings' id: '/settings'
path: '/settings' path: '/settings'
@@ -135,6 +160,7 @@ const rootRouteChildren: RootRouteChildren = {
AnalyticsRoute: AnalyticsRoute, AnalyticsRoute: AnalyticsRoute,
NotificationsRoute: NotificationsRoute, NotificationsRoute: NotificationsRoute,
SettingsRoute: SettingsRoute, SettingsRoute: SettingsRoute,
SystemRoute: SystemRoute,
TransactionsRoute: TransactionsRoute, TransactionsRoute: TransactionsRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport

View File

@@ -3,10 +3,7 @@ import { AppSidebar } from "../components/AppSidebar";
import { SiteHeader } from "../components/SiteHeader"; import { SiteHeader } from "../components/SiteHeader";
import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts"; import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
import { usePWA } from "../hooks/usePWA"; import { usePWA } from "../hooks/usePWA";
import { import { SidebarInset, SidebarProvider } from "../components/ui/sidebar";
SidebarInset,
SidebarProvider,
} from "../components/ui/sidebar";
function RootLayout() { function RootLayout() {
const { updateAvailable, updateSW } = usePWA(); const { updateAvailable, updateSW } = usePWA();

View File

@@ -44,7 +44,7 @@ function AnalyticsDashboard() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="p-6"> <div className="space-y-8">
<div className="animate-pulse"> <div className="animate-pulse">
<div className="h-8 bg-muted rounded w-48 mb-6"></div> <div className="h-8 bg-muted rounded w-48 mb-6"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
@@ -62,7 +62,7 @@ function AnalyticsDashboard() {
} }
return ( return (
<div className="p-6 space-y-8"> <div className="space-y-8">
{/* Time Period Filter */} {/* Time Period Filter */}
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">

View File

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

View File

@@ -1,6 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import AccountSettings from "../components/AccountSettings"; import Settings from "../components/Settings";
export const Route = createFileRoute("/settings")({ export const Route = createFileRoute("/settings")({
component: AccountSettings, 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

@@ -13,6 +13,7 @@ export interface Account {
name?: string; name?: string;
display_name?: string; display_name?: string;
currency?: string; currency?: string;
logo?: string;
created: string; created: string;
last_accessed?: string; last_accessed?: string;
balances: AccountBalance[]; balances: AccountBalance[];
@@ -197,10 +198,17 @@ export interface NotificationServicesResponse {
export interface HealthData { export interface HealthData {
status: string; status: string;
config_loaded?: boolean; config_loaded?: boolean;
version?: string;
message?: string; message?: string;
error?: string; error?: string;
} }
// Version information from root endpoint
export interface VersionData {
message: string;
version: string;
}
// Analytics data types // Analytics data types
export interface TransactionStats { export interface TransactionStats {
period_days: number; period_days: number;
@@ -213,3 +221,24 @@ export interface TransactionStats {
average_transaction: number; average_transaction: number;
accounts_included: 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

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

View File

@@ -10,7 +10,12 @@ export default defineConfig({
react(), react(),
VitePWA({ VitePWA({
registerType: "autoUpdate", registerType: "autoUpdate",
includeAssets: ["favicon.ico", "apple-touch-icon-180x180.png", "maskable-icon-512x512.png", "robots.txt"], includeAssets: [
"favicon.ico",
"apple-touch-icon-180x180.png",
"maskable-icon-512x512.png",
"robots.txt",
],
manifest: { manifest: {
name: "Leggen", name: "Leggen",
short_name: "Leggen", short_name: "Leggen",
@@ -28,38 +33,38 @@ export default defineConfig({
short_name: "Transactions", short_name: "Transactions",
description: "View and manage transactions", description: "View and manage transactions",
url: "/transactions", url: "/transactions",
icons: [{ src: "/pwa-192x192.png", sizes: "192x192" }] icons: [{ src: "/pwa-192x192.png", sizes: "192x192" }],
}, },
{ {
name: "Analytics", name: "Analytics",
short_name: "Analytics", short_name: "Analytics",
description: "View financial analytics", description: "View financial analytics",
url: "/analytics", url: "/analytics",
icons: [{ src: "/pwa-192x192.png", sizes: "192x192" }] icons: [{ src: "/pwa-192x192.png", sizes: "192x192" }],
} },
], ],
icons: [ icons: [
{ {
src: "pwa-64x64.png", src: "pwa-64x64.png",
sizes: "64x64", sizes: "64x64",
type: "image/png" type: "image/png",
}, },
{ {
src: "pwa-192x192.png", src: "pwa-192x192.png",
sizes: "192x192", sizes: "192x192",
type: "image/png" type: "image/png",
}, },
{ {
src: "pwa-512x512.png", src: "pwa-512x512.png",
sizes: "512x512", sizes: "512x512",
type: "image/png" type: "image/png",
}, },
{ {
src: "maskable-icon-512x512.png", src: "maskable-icon-512x512.png",
sizes: "512x512", sizes: "512x512",
type: "image/png", type: "image/png",
purpose: "maskable" purpose: "maskable",
} },
], ],
}, },
workbox: { workbox: {

View File

@@ -26,6 +26,7 @@ class AccountDetails(BaseModel):
name: Optional[str] = None name: Optional[str] = None
display_name: Optional[str] = None display_name: Optional[str] = None
currency: Optional[str] = None currency: Optional[str] = None
logo: Optional[str] = None
created: datetime created: datetime
last_accessed: Optional[datetime] = None last_accessed: Optional[datetime] = None
balances: List[AccountBalance] = [] balances: List[AccountBalance] = []

View File

@@ -4,6 +4,26 @@ from typing import Optional
from pydantic import BaseModel 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, api
class Config:
json_encoders = {datetime: lambda v: v.isoformat() if v else None}
class SyncRequest(BaseModel): class SyncRequest(BaseModel):
"""Request to trigger a sync""" """Request to trigger a sync"""

View File

@@ -55,6 +55,7 @@ async def get_all_accounts() -> APIResponse:
name=db_account.get("name"), name=db_account.get("name"),
display_name=db_account.get("display_name"), display_name=db_account.get("display_name"),
currency=db_account.get("currency"), currency=db_account.get("currency"),
logo=db_account.get("logo"),
created=db_account["created"], created=db_account["created"],
last_accessed=db_account.get("last_accessed"), last_accessed=db_account.get("last_accessed"),
balances=balances, balances=balances,
@@ -115,6 +116,7 @@ async def get_account_details(account_id: str) -> APIResponse:
name=db_account.get("name"), name=db_account.get("name"),
display_name=db_account.get("display_name"), display_name=db_account.get("display_name"),
currency=db_account.get("currency"), currency=db_account.get("currency"),
logo=db_account.get("logo"),
created=db_account["created"], created=db_account["created"],
last_accessed=db_account.get("last_accessed"), last_accessed=db_account.get("last_accessed"),
balances=balances, balances=balances,

View File

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

View File

@@ -56,6 +56,7 @@ async def trigger_sync(
sync_service.sync_specific_accounts, sync_service.sync_specific_accounts,
sync_request.account_ids, sync_request.account_ids,
sync_request.force if sync_request else False, sync_request.force if sync_request else False,
"api", # trigger_type
) )
message = ( message = (
f"Started sync for {len(sync_request.account_ids)} specific accounts" f"Started sync for {len(sync_request.account_ids)} specific accounts"
@@ -65,6 +66,7 @@ async def trigger_sync(
background_tasks.add_task( background_tasks.add_task(
sync_service.sync_all_accounts, sync_service.sync_all_accounts,
sync_request.force if sync_request else False, sync_request.force if sync_request else False,
"api", # trigger_type
) )
message = "Started sync for all accounts" message = "Started sync for all accounts"
@@ -90,11 +92,11 @@ async def sync_now(sync_request: Optional[SyncRequest] = None) -> APIResponse:
try: try:
if sync_request and sync_request.account_ids: if sync_request and sync_request.account_ids:
result = await sync_service.sync_specific_accounts( result = await sync_service.sync_specific_accounts(
sync_request.account_ids, sync_request.force sync_request.account_ids, sync_request.force, "api"
) )
else: else:
result = await sync_service.sync_all_accounts( 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( return APIResponse(
@@ -211,3 +213,24 @@ async def stop_scheduler() -> APIResponse:
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to stop scheduler: {str(e)}" status_code=500, detail=f"Failed to stop scheduler: {str(e)}"
) from 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

@@ -112,7 +112,7 @@ class BackgroundScheduler:
# Send notification about the failure # Send notification about the failure
try: try:
await self.notification_service.send_expiry_notification( await self.notification_service.send_sync_failure_notification(
{ {
"type": "sync_failure", "type": "sync_failure",
"error": str(e), "error": str(e),
@@ -145,7 +145,7 @@ class BackgroundScheduler:
logger.error("Maximum retries exceeded for sync job") logger.error("Maximum retries exceeded for sync job")
# Send final failure notification # Send final failure notification
try: try:
await self.notification_service.send_expiry_notification( await self.notification_service.send_sync_failure_notification(
{ {
"type": "sync_final_failure", "type": "sync_final_failure",
"error": str(e), "error": str(e),

View File

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

View File

@@ -55,3 +55,44 @@ def send_transactions_message(ctx: click.Context, transactions: list):
response.raise_for_status() response.raise_for_status()
except Exception as e: except Exception as e:
raise Exception(f"Discord notification failed: {e}\n{response.text}") from 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

@@ -79,3 +79,38 @@ def send_transaction_message(ctx: click.Context, transactions: list):
res.raise_for_status() res.raise_for_status()
except Exception as e: except Exception as e:
raise Exception(f"Telegram notification failed: {e}\n{res.text}") from 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

@@ -216,6 +216,8 @@ class DatabaseService:
await self._migrate_null_transaction_ids_if_needed() await self._migrate_null_transaction_ids_if_needed()
await self._migrate_to_composite_key_if_needed() await self._migrate_to_composite_key_if_needed()
await self._migrate_add_display_name_if_needed() 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): async def _migrate_balance_timestamps_if_needed(self):
"""Check and migrate balance timestamps if needed""" """Check and migrate balance timestamps if needed"""
@@ -1132,7 +1134,8 @@ class DatabaseService:
created DATETIME, created DATETIME,
last_accessed DATETIME, last_accessed DATETIME,
last_updated DATETIME, last_updated DATETIME,
display_name TEXT display_name TEXT,
logo TEXT
)""" )"""
) )
@@ -1169,8 +1172,9 @@ class DatabaseService:
created, created,
last_accessed, last_accessed,
last_updated, last_updated,
display_name display_name,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", logo
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
( (
account_data["id"], account_data["id"],
account_data["institution_id"], account_data["institution_id"],
@@ -1182,6 +1186,7 @@ class DatabaseService:
account_data.get("last_accessed"), account_data.get("last_accessed"),
account_data.get("last_updated", account_data["created"]), account_data.get("last_updated", account_data["created"]),
display_name, display_name,
account_data.get("logo"),
), ),
) )
conn.commit() conn.commit()
@@ -1427,3 +1432,265 @@ class DatabaseService:
except Exception as e: except Exception as e:
conn.close() conn.close()
raise e 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 []

View File

@@ -1,6 +1,6 @@
import json import json
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List from typing import Any, Dict
import httpx import httpx
from loguru import logger from loguru import logger
@@ -11,14 +11,13 @@ from leggen.utils.paths import path_manager
def _log_rate_limits(response): def _log_rate_limits(response):
"""Log GoCardless API rate limit headers""" """Log GoCardless API rate limit headers"""
limit = response.headers.get("X-RateLimit-Limit") limit = response.headers.get("http_x_ratelimit_limit")
remaining = response.headers.get("X-RateLimit-Remaining") remaining = response.headers.get("http_x_ratelimit_remaining")
reset = response.headers.get("X-RateLimit-Reset") reset = response.headers.get("http_x_ratelimit_reset")
account_success_reset = response.headers.get("X-RateLimit-Account-Success-Reset")
if limit or remaining or reset or account_success_reset: if limit or remaining or reset:
logger.info( logger.info(
f"GoCardless rate limits - Limit: {limit}, Remaining: {remaining}, Reset: {reset}s, Account Success Reset: {account_success_reset}" f"GoCardless rate limits - Limit: {limit}, Remaining: {remaining}, Reset: {reset}s"
) )
@@ -30,6 +29,27 @@ class GoCardlessService:
) )
self._token = None self._token = None
async def _make_authenticated_request(
self, method: str, url: str, **kwargs
) -> Dict[str, Any]:
"""Make authenticated request with automatic token refresh on 401"""
headers = await self._get_auth_headers()
async with httpx.AsyncClient() as client:
response = await client.request(method, url, headers=headers, **kwargs)
_log_rate_limits(response)
# If we get 401, clear token cache and retry once
if response.status_code == 401:
logger.warning("Got 401, clearing token cache and retrying")
self._token = None
headers = await self._get_auth_headers()
response = await client.request(method, url, headers=headers, **kwargs)
_log_rate_limits(response)
response.raise_for_status()
return response.json()
async def _get_auth_headers(self) -> Dict[str, str]: async def _get_auth_headers(self) -> Dict[str, str]:
"""Get authentication headers for GoCardless API""" """Get authentication headers for GoCardless API"""
token = await self._get_token() token = await self._get_token()
@@ -102,74 +122,48 @@ class GoCardlessService:
with open(auth_file, "w") as f: with open(auth_file, "w") as f:
json.dump(auth_data, f) json.dump(auth_data, f)
async def get_institutions(self, country: str = "PT") -> List[Dict[str, Any]]: async def get_institutions(self, country: str = "PT") -> Dict[str, Any]:
"""Get available bank institutions for a country""" """Get available bank institutions for a country"""
headers = await self._get_auth_headers() return await self._make_authenticated_request(
async with httpx.AsyncClient() as client: "GET", f"{self.base_url}/institutions/", params={"country": country}
response = await client.get( )
f"{self.base_url}/institutions/",
headers=headers,
params={"country": country},
)
_log_rate_limits(response)
response.raise_for_status()
return response.json()
async def create_requisition( async def create_requisition(
self, institution_id: str, redirect_url: str self, institution_id: str, redirect_url: str
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Create a bank connection requisition""" """Create a bank connection requisition"""
headers = await self._get_auth_headers() return await self._make_authenticated_request(
async with httpx.AsyncClient() as client: "POST",
response = await client.post( f"{self.base_url}/requisitions/",
f"{self.base_url}/requisitions/", json={"institution_id": institution_id, "redirect": redirect_url},
headers=headers, )
json={"institution_id": institution_id, "redirect": redirect_url},
)
_log_rate_limits(response)
response.raise_for_status()
return response.json()
async def get_requisitions(self) -> Dict[str, Any]: async def get_requisitions(self) -> Dict[str, Any]:
"""Get all requisitions""" """Get all requisitions"""
headers = await self._get_auth_headers() return await self._make_authenticated_request(
async with httpx.AsyncClient() as client: "GET", f"{self.base_url}/requisitions/"
response = await client.get( )
f"{self.base_url}/requisitions/", headers=headers
)
_log_rate_limits(response)
response.raise_for_status()
return response.json()
async def get_account_details(self, account_id: str) -> Dict[str, Any]: async def get_account_details(self, account_id: str) -> Dict[str, Any]:
"""Get account details""" """Get account details"""
headers = await self._get_auth_headers() return await self._make_authenticated_request(
async with httpx.AsyncClient() as client: "GET", f"{self.base_url}/accounts/{account_id}/"
response = await client.get( )
f"{self.base_url}/accounts/{account_id}/", headers=headers
)
_log_rate_limits(response)
response.raise_for_status()
return response.json()
async def get_account_balances(self, account_id: str) -> Dict[str, Any]: async def get_account_balances(self, account_id: str) -> Dict[str, Any]:
"""Get account balances""" """Get account balances"""
headers = await self._get_auth_headers() return await self._make_authenticated_request(
async with httpx.AsyncClient() as client: "GET", f"{self.base_url}/accounts/{account_id}/balances/"
response = await client.get( )
f"{self.base_url}/accounts/{account_id}/balances/", headers=headers
)
_log_rate_limits(response)
response.raise_for_status()
return response.json()
async def get_account_transactions(self, account_id: str) -> Dict[str, Any]: async def get_account_transactions(self, account_id: str) -> Dict[str, Any]:
"""Get account transactions""" """Get account transactions"""
headers = await self._get_auth_headers() return await self._make_authenticated_request(
async with httpx.AsyncClient() as client: "GET", f"{self.base_url}/accounts/{account_id}/transactions/"
response = await client.get( )
f"{self.base_url}/accounts/{account_id}/transactions/", headers=headers
) async def get_institution_details(self, institution_id: str) -> Dict[str, Any]:
_log_rate_limits(response) """Get institution details by ID"""
response.raise_for_status() return await self._make_authenticated_request(
return response.json() "GET", f"{self.base_url}/institutions/{institution_id}/"
)

View File

@@ -289,3 +289,69 @@ class NotificationService:
except Exception as e: except Exception as e:
logger.error(f"Failed to send Telegram expiry notification: {e}") logger.error(f"Failed to send Telegram expiry notification: {e}")
raise raise
async def send_sync_failure_notification(
self, notification_data: Dict[str, Any]
) -> None:
"""Send notification about sync failure"""
if self._is_discord_enabled():
await self._send_discord_sync_failure(notification_data)
if self._is_telegram_enabled():
await self._send_telegram_sync_failure(notification_data)
async def _send_discord_sync_failure(
self, notification_data: Dict[str, Any]
) -> None:
"""Send Discord sync failure notification"""
try:
import click
from leggen.notifications.discord import send_sync_failure_notification
# Create a mock context with the webhook
ctx = click.Context(click.Command("sync_failure"))
ctx.obj = {
"notifications": {
"discord": {
"webhook": self.notifications_config.get("discord", {}).get(
"webhook"
)
}
}
}
# Send sync failure notification using the actual implementation
send_sync_failure_notification(ctx, notification_data)
logger.info(f"Sent Discord sync failure notification: {notification_data}")
except Exception as e:
logger.error(f"Failed to send Discord sync failure notification: {e}")
raise
async def _send_telegram_sync_failure(
self, notification_data: Dict[str, Any]
) -> None:
"""Send Telegram sync failure notification"""
try:
import click
from leggen.notifications.telegram import send_sync_failure_notification
# Create a mock context with the telegram config
ctx = click.Context(click.Command("sync_failure"))
telegram_config = self.notifications_config.get("telegram", {})
ctx.obj = {
"notifications": {
"telegram": {
"token": telegram_config.get("token"),
"chat_id": telegram_config.get("chat_id"),
}
}
}
# Send sync failure notification using the actual implementation
send_sync_failure_notification(ctx, notification_data)
logger.info(f"Sent Telegram sync failure notification: {notification_data}")
except Exception as e:
logger.error(f"Failed to send Telegram sync failure notification: {e}")
raise

View File

@@ -15,12 +15,15 @@ class SyncService:
self.database = DatabaseService() self.database = DatabaseService()
self.notifications = NotificationService() self.notifications = NotificationService()
self._sync_status = SyncStatus(is_running=False) self._sync_status = SyncStatus(is_running=False)
self._institution_logos = {} # Cache for institution logos
async def get_sync_status(self) -> SyncStatus: async def get_sync_status(self) -> SyncStatus:
"""Get current sync status""" """Get current sync status"""
return self._sync_status return self._sync_status
async def sync_all_accounts(self, force: bool = False) -> SyncResult: async def sync_all_accounts(
self, force: bool = False, trigger_type: str = "manual"
) -> SyncResult:
"""Sync all connected accounts""" """Sync all connected accounts"""
if self._sync_status.is_running and not force: if self._sync_status.is_running and not force:
raise Exception("Sync is already running") raise Exception("Sync is already running")
@@ -34,9 +37,25 @@ class SyncService:
transactions_updated = 0 transactions_updated = 0
balances_updated = 0 balances_updated = 0
errors = [] errors = []
logs = [f"Sync started at {start_time.isoformat()}"]
# Initialize sync operation record
sync_operation = {
"started_at": start_time.isoformat(),
"trigger_type": trigger_type,
"accounts_processed": 0,
"transactions_added": 0,
"transactions_updated": 0,
"balances_updated": 0,
"errors": [],
"logs": logs,
}
operation_id = None
try: try:
logger.info("Starting sync of all accounts") logger.info("Starting sync of all accounts")
logs.append("Starting sync of all accounts")
# Get all requisitions and accounts # Get all requisitions and accounts
requisitions = await self.gocardless.get_requisitions() requisitions = await self.gocardless.get_requisitions()
@@ -46,6 +65,7 @@ class SyncService:
all_accounts.update(req.get("accounts", [])) all_accounts.update(req.get("accounts", []))
self._sync_status.total_accounts = len(all_accounts) self._sync_status.total_accounts = len(all_accounts)
logs.append(f"Found {len(all_accounts)} accounts to sync")
# Process each account # Process each account
for account_id in all_accounts: for account_id in all_accounts:
@@ -58,7 +78,7 @@ class SyncService:
# Get balances to extract currency information # Get balances to extract currency information
balances = await self.gocardless.get_account_balances(account_id) balances = await self.gocardless.get_account_balances(account_id)
# Enrich account details with currency and persist # Enrich account details with currency and institution logo
if account_details and balances: if account_details and balances:
enriched_account_details = account_details.copy() enriched_account_details = account_details.copy()
@@ -71,6 +91,26 @@ class SyncService:
if currency: if currency:
enriched_account_details["currency"] = currency enriched_account_details["currency"] = currency
# Get institution details to fetch logo
institution_id = enriched_account_details.get("institution_id")
if institution_id:
try:
institution_details = (
await self.gocardless.get_institution_details(
institution_id
)
)
enriched_account_details["logo"] = (
institution_details.get("logo", "")
)
logger.info(
f"Fetched logo for institution {institution_id}: {enriched_account_details.get('logo', 'No logo')}"
)
except Exception as e:
logger.warning(
f"Failed to fetch institution details for {institution_id}: {e}"
)
# Persist enriched account details to database # Persist enriched account details to database
await self.database.persist_account_details( await self.database.persist_account_details(
enriched_account_details enriched_account_details
@@ -118,17 +158,43 @@ class SyncService:
self._sync_status.accounts_synced = accounts_processed self._sync_status.accounts_synced = accounts_processed
logger.info(f"Synced account {account_id} successfully") logger.info(f"Synced account {account_id} successfully")
logs.append(f"Synced account {account_id} successfully")
except Exception as e: except Exception as e:
error_msg = f"Failed to sync account {account_id}: {str(e)}" error_msg = f"Failed to sync account {account_id}: {str(e)}"
errors.append(error_msg) errors.append(error_msg)
logger.error(error_msg) logger.error(error_msg)
logs.append(error_msg)
end_time = datetime.now() end_time = datetime.now()
duration = (end_time - start_time).total_seconds() duration = (end_time - start_time).total_seconds()
self._sync_status.last_sync = end_time self._sync_status.last_sync = end_time
# Update sync operation with final results
sync_operation.update(
{
"completed_at": end_time.isoformat(),
"success": len(errors) == 0,
"accounts_processed": accounts_processed,
"transactions_added": transactions_added,
"transactions_updated": transactions_updated,
"balances_updated": balances_updated,
"duration_seconds": duration,
"errors": errors,
"logs": logs,
}
)
# Persist sync operation to database
try:
operation_id = await self.database.persist_sync_operation(
sync_operation
)
logger.debug(f"Saved sync operation with ID: {operation_id}")
except Exception as e:
logger.error(f"Failed to persist sync operation: {e}")
result = SyncResult( result = SyncResult(
success=len(errors) == 0, success=len(errors) == 0,
accounts_processed=accounts_processed, accounts_processed=accounts_processed,
@@ -144,44 +210,67 @@ class SyncService:
logger.info( logger.info(
f"Sync completed: {accounts_processed} accounts, {transactions_added} new transactions" f"Sync completed: {accounts_processed} accounts, {transactions_added} new transactions"
) )
logs.append(
f"Sync completed: {accounts_processed} accounts, {transactions_added} new transactions"
)
return result return result
except Exception as e: except Exception as e:
error_msg = f"Sync failed: {str(e)}" error_msg = f"Sync failed: {str(e)}"
errors.append(error_msg) errors.append(error_msg)
logs.append(error_msg)
logger.error(error_msg) logger.error(error_msg)
# Save failed sync operation
end_time = datetime.now()
duration = (end_time - start_time).total_seconds()
sync_operation.update(
{
"completed_at": end_time.isoformat(),
"success": False,
"accounts_processed": accounts_processed,
"transactions_added": transactions_added,
"transactions_updated": transactions_updated,
"balances_updated": balances_updated,
"duration_seconds": duration,
"errors": errors,
"logs": logs,
}
)
try:
operation_id = await self.database.persist_sync_operation(
sync_operation
)
logger.debug(f"Saved failed sync operation with ID: {operation_id}")
except Exception as persist_error:
logger.error(
f"Failed to persist failed sync operation: {persist_error}"
)
raise raise
finally: finally:
self._sync_status.is_running = False self._sync_status.is_running = False
async def sync_specific_accounts( async def sync_specific_accounts(
self, account_ids: List[str], force: bool = False self, account_ids: List[str], force: bool = False, trigger_type: str = "manual"
) -> SyncResult: ) -> SyncResult:
"""Sync specific accounts""" """Sync specific accounts"""
if self._sync_status.is_running and not force: if self._sync_status.is_running and not force:
raise Exception("Sync is already running") raise Exception("Sync is already running")
# Similar implementation but only for specified accounts
# For brevity, implementing a simplified version
start_time = datetime.now()
self._sync_status.is_running = True self._sync_status.is_running = True
try: try:
# Process only specified accounts # For now, delegate to sync_all_accounts but with specific filtering
# Implementation would be similar to sync_all_accounts # This could be optimized later to only process specified accounts
# but filtered to only the specified account_ids result = await self.sync_all_accounts(
force=force, trigger_type=trigger_type
end_time = datetime.now()
return SyncResult(
success=True,
accounts_processed=len(account_ids),
transactions_added=0,
transactions_updated=0,
balances_updated=0,
duration_seconds=(end_time - start_time).total_seconds(),
errors=[],
started_at=start_time,
completed_at=end_time,
) )
# Filter results to only specified accounts if needed
# For simplicity, we'll return the full result for now
return result
finally: finally:
self._sync_status.is_running = False self._sync_status.is_running = False

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "leggen" name = "leggen"
version = "2025.9.18" version = "2025.9.22"
description = "An Open Banking CLI" description = "An Open Banking CLI"
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }] authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
requires-python = "~=3.13.0" requires-python = "~=3.13.0"

View File

@@ -103,22 +103,24 @@ def mock_db_path(temp_db_path):
@pytest.fixture @pytest.fixture
def sample_bank_data(): def sample_bank_data():
"""Sample bank/institution data for testing.""" """Sample bank/institution data for testing."""
return [ return {
{ "results": [
"id": "REVOLUT_REVOLT21", {
"name": "Revolut", "id": "REVOLUT_REVOLT21",
"bic": "REVOLT21", "name": "Revolut",
"transaction_total_days": 90, "bic": "REVOLT21",
"countries": ["GB", "LT"], "transaction_total_days": 90,
}, "countries": ["GB", "LT"],
{ },
"id": "BANCOBPI_BBPIPTPL", {
"name": "Banco BPI", "id": "BANCOBPI_BBPIPTPL",
"bic": "BBPIPTPL", "name": "Banco BPI",
"transaction_total_days": 90, "bic": "BBPIPTPL",
"countries": ["PT"], "transaction_total_days": 90,
}, "countries": ["PT"],
] },
]
}
@pytest.fixture @pytest.fixture

View File

@@ -50,7 +50,7 @@ class TestBanksAPI:
# Mock empty institutions response for invalid country # Mock empty institutions response for invalid country
respx.get("https://bankaccountdata.gocardless.com/api/v2/institutions/").mock( respx.get("https://bankaccountdata.gocardless.com/api/v2/institutions/").mock(
return_value=httpx.Response(200, json=[]) return_value=httpx.Response(200, json={"results": []})
) )
with patch("leggen.utils.config.config", mock_config): with patch("leggen.utils.config.config", mock_config):

View File

@@ -37,9 +37,12 @@ class TestLeggenAPIClient:
"""Test getting institutions via API client.""" """Test getting institutions via API client."""
client = LeggenAPIClient("http://localhost:8000") client = LeggenAPIClient("http://localhost:8000")
# The API returns processed institutions, not raw GoCardless data
processed_institutions = sample_bank_data["results"]
api_response = { api_response = {
"success": True, "success": True,
"data": sample_bank_data, "data": processed_institutions,
"message": "Found 2 institutions for PT", "message": "Found 2 institutions for PT",
} }

2
uv.lock generated
View File

@@ -220,7 +220,7 @@ wheels = [
[[package]] [[package]]
name = "leggen" name = "leggen"
version = "2025.9.18" version = "2025.9.22"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "apscheduler" }, { name = "apscheduler" },