From 65404848aa27cfcb11a371c194ca533b17cb08ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elisi=C3=A1rio=20Couto?= Date: Mon, 22 Sep 2025 22:58:49 +0100 Subject: [PATCH] refactor(frontend): Reorganize pages with tabbed Settings and focused System page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/components/AppSidebar.tsx | 2 +- frontend/src/components/Settings.tsx | 649 +++++++++++++++++++++++++ frontend/src/components/SiteHeader.tsx | 2 +- frontend/src/components/System.tsx | 205 ++++++++ frontend/src/components/ui/tabs.tsx | 53 ++ frontend/src/routeTree.gen.ts | 28 +- frontend/src/routes/notifications.tsx | 4 +- frontend/src/routes/settings.tsx | 4 +- frontend/src/routes/system.tsx | 6 + leggen/services/database_service.py | 9 +- 10 files changed, 953 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/Settings.tsx create mode 100644 frontend/src/components/System.tsx create mode 100644 frontend/src/components/ui/tabs.tsx create mode 100644 frontend/src/routes/system.tsx diff --git a/frontend/src/components/AppSidebar.tsx b/frontend/src/components/AppSidebar.tsx index d5b615e..d8f8a6d 100644 --- a/frontend/src/components/AppSidebar.tsx +++ b/frontend/src/components/AppSidebar.tsx @@ -33,7 +33,7 @@ import { const navigation = [ { name: "Overview", icon: List, to: "/" }, { name: "Analytics", icon: BarChart3, to: "/analytics" }, - { name: "System Status", icon: Activity, to: "/notifications" }, + { name: "System", icon: Activity, to: "/system" }, { name: "Settings", icon: Settings, to: "/settings" }, ]; diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx new file mode 100644 index 0000000..3702d71 --- /dev/null +++ b/frontend/src/components/Settings.tsx @@ -0,0 +1,649 @@ +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, + TestTube, + Settings as SettingsIcon, + User, + CheckCircle, +} 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 { Input } from "./ui/input"; +import { Label } from "./ui/label"; +import { Badge } from "./ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; +import AccountsSkeleton from "./AccountsSkeleton"; +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(null); + const [editingName, setEditingName] = useState(""); + const [testService, setTestService] = useState(""); + const [testMessage, setTestMessage] = useState("Test notification from Leggen"); + + const queryClient = useQueryClient(); + + // Account queries + const { + data: accounts, + isLoading: accountsLoading, + error: accountsError, + refetch: refetchAccounts, + } = useQuery({ + queryKey: ["accounts"], + queryFn: apiClient.getAccounts, + }); + + const { data: balances } = useQuery({ + queryKey: ["balances"], + queryFn: () => apiClient.getBalances(), + }); + + // Notification queries + const { + data: notificationSettings, + isLoading: settingsLoading, + error: settingsError, + refetch: refetchSettings, + } = useQuery({ + queryKey: ["notificationSettings"], + queryFn: apiClient.getNotificationSettings, + }); + + const { + data: services, + isLoading: servicesLoading, + error: servicesError, + refetch: refetchServices, + } = useQuery({ + 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 testMutation = useMutation({ + mutationFn: apiClient.testNotification, + onSuccess: () => { + console.log("Test notification sent successfully"); + }, + onError: (error) => { + console.error("Failed to send test notification:", error); + }, + }); + + 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 handleTestNotification = () => { + if (!testService) return; + + testMutation.mutate({ + service: testService.toLowerCase(), + message: testMessage, + }); + }; + + 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 ; + } + + if (hasError) { + return ( + + + Failed to load settings + +

+ Unable to connect to the Leggen API. Please check your configuration + and ensure the API server is running. +

+ +
+
+ ); + } + + return ( +
+ + + + + Accounts + + + + Notifications + + + + + {/* Account Management Section */} + + + Account Management + + Manage your connected bank accounts and customize their display names + + + + {!accounts || accounts.length === 0 ? ( + + +

+ No accounts found +

+

+ Connect your first bank account to get started with Leggen. +

+ +

+ Coming soon: Add new bank connections +

+
+ ) : ( + +
+ {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 ( +
+ {/* Mobile layout - stack vertically */} +
+
+
+ +
+
+ {editingAccountId === account.id ? ( +
+
+ + 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 + /> + + +
+

+ {account.institution_id} +

+
+ ) : ( +
+
+

+ {account.display_name || + account.name || + "Unnamed Account"} +

+ +
+

+ {account.institution_id} +

+ {account.iban && ( +

+ IBAN: {account.iban} +

+ )} +
+ )} +
+
+ + {/* Balance and date section */} +
+ {/* Date and status indicator - left on mobile, bottom on desktop */} +
+
+ {/* Tooltip */} +
+ {getStatusIndicator(account.status).tooltip} +
+
+
+

+ Updated{" "} + {formatDate( + account.last_accessed || account.created, + )} +

+
+ + {/* Balance - right on mobile, top on desktop */} +
+ {isPositive ? ( + + ) : ( + + )} +

+ {formatCurrency(balance, currency)} +

+
+
+
+
+ ); + })} +
+
+ )} +
+ + {/* Add Bank Section (Future Feature) */} + + + Add New Bank Account + + Connect additional bank accounts to track all your finances in one place + + + +
+
+ +

+ Bank connection functionality is coming soon. Stay tuned for updates! +

+
+ +
+
+
+
+ + + {/* Test Notification Section */} + + + + + Test Notifications + + + +
+
+ + +
+ +
+ + setTestMessage(e.target.value)} + placeholder="Test message..." + /> +
+
+ +
+ +
+
+
+ + {/* Notification Services */} + + + + + Notification Services + + Manage your notification services + + + {!services || services.length === 0 ? ( + + +

+ No notification services configured +

+

+ Configure notification services in your backend to receive alerts. +

+
+ ) : ( + +
+ {services.map((service) => ( +
+
+
+
+ {service.name.toLowerCase().includes("discord") ? ( + + ) : service.name.toLowerCase().includes("telegram") ? ( + + ) : ( + + )} +
+
+

+ {service.name} +

+
+ + {service.enabled ? ( + + ) : ( + + )} + {service.enabled ? "Enabled" : "Disabled"} + + + {service.configured + ? "Configured" + : "Not Configured"} + +
+
+
+ + +
+
+ ))} +
+
+ )} +
+ + {/* Notification Settings */} + + + + + Notification Settings + + + + {notificationSettings && ( +
+
+

+ Filters +

+
+
+
+ +

+ {notificationSettings.filters.case_insensitive.length > 0 + ? notificationSettings.filters.case_insensitive.join(", ") + : "None"} +

+
+
+ +

+ {notificationSettings.filters.case_sensitive && + notificationSettings.filters.case_sensitive.length > 0 + ? notificationSettings.filters.case_sensitive.join(", ") + : "None"} +

+
+
+
+
+ +
+

+ Configure notification settings through your backend API to + customize filters and service configurations. +

+
+
+ )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/SiteHeader.tsx b/frontend/src/components/SiteHeader.tsx index d602cfa..2cc1700 100644 --- a/frontend/src/components/SiteHeader.tsx +++ b/frontend/src/components/SiteHeader.tsx @@ -10,7 +10,7 @@ const navigation = [ { name: "Overview", to: "/" }, { name: "Transactions", to: "/transactions" }, { name: "Analytics", to: "/analytics" }, - { name: "Notifications", to: "/notifications" }, + { name: "System", to: "/system" }, { name: "Settings", to: "/settings" }, ]; diff --git a/frontend/src/components/System.tsx b/frontend/src/components/System.tsx new file mode 100644 index 0000000..0ac20f5 --- /dev/null +++ b/frontend/src/components/System.tsx @@ -0,0 +1,205 @@ +import { useQuery } from "@tanstack/react-query"; +import { + RefreshCw, + AlertCircle, + CheckCircle, + Activity, + Clock, + TrendingUp, + User, +} 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 type { SyncOperationsResponse } from "../types/api"; + +export default function System() { + const { + data: syncOperations, + isLoading: syncOperationsLoading, + error: syncOperationsError, + refetch: refetchSyncOperations, + } = useQuery({ + queryKey: ["syncOperations"], + queryFn: () => apiClient.getSyncOperations(10, 0), // Get latest 10 operations + }); + + if (syncOperationsLoading) { + return ( +
+ + +
+ + Loading system status... +
+
+
+
+ ); + } + + if (syncOperationsError) { + return ( +
+ + + Failed to load system data + +

+ Unable to connect to the Leggen API. Please check your configuration + and ensure the API server is running. +

+ +
+
+
+ ); + } + + return ( +
+ {/* Sync Operations Section */} + + + + + Recent Sync Operations + + Latest synchronization activities and their status + + + {!syncOperations || syncOperations.operations.length === 0 ? ( +
+ +

+ No sync operations yet +

+

+ Sync operations will appear here once you start syncing your accounts. +

+
+ ) : ( +
+ {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 ( +
+
+
+ {isRunning ? ( + + ) : operation.success ? ( + + ) : ( + + )} +
+
+
+

+ {isRunning ? 'Sync Running' : operation.success ? 'Sync Completed' : 'Sync Failed'} +

+ + {operation.trigger_type} + +
+
+ + + {startedAt.toLocaleDateString()} {startedAt.toLocaleTimeString()} + + {duration && ( + Duration: {duration} + )} +
+
+
+
+
+ + {operation.accounts_processed} accounts +
+
+ + {operation.transactions_added} new transactions +
+ {operation.errors.length > 0 && ( +
+ + {operation.errors.length} errors +
+ )} +
+
+ ); + })} +
+ )} +
+
+ + {/* System Health Summary Card */} + + + + + System Health + + Overall system status and performance + + +
+
+
+ {syncOperations?.operations.filter(op => op.success).length || 0} +
+
Successful Syncs
+
+
+
+ {syncOperations?.operations.filter(op => !op.success && op.completed_at).length || 0} +
+
Failed Syncs
+
+
+
+ {syncOperations?.operations.filter(op => !op.completed_at).length || 0} +
+
Running Operations
+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..85d83be --- /dev/null +++ b/frontend/src/components/ui/tabs.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 7add156..a3e0f3e 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as TransactionsRouteImport } from './routes/transactions' +import { Route as SystemRouteImport } from './routes/system' import { Route as SettingsRouteImport } from './routes/settings' import { Route as NotificationsRouteImport } from './routes/notifications' import { Route as AnalyticsRouteImport } from './routes/analytics' @@ -20,6 +21,11 @@ const TransactionsRoute = TransactionsRouteImport.update({ path: '/transactions', getParentRoute: () => rootRouteImport, } as any) +const SystemRoute = SystemRouteImport.update({ + id: '/system', + path: '/system', + getParentRoute: () => rootRouteImport, +} as any) const SettingsRoute = SettingsRouteImport.update({ id: '/settings', path: '/settings', @@ -46,6 +52,7 @@ export interface FileRoutesByFullPath { '/analytics': typeof AnalyticsRoute '/notifications': typeof NotificationsRoute '/settings': typeof SettingsRoute + '/system': typeof SystemRoute '/transactions': typeof TransactionsRoute } export interface FileRoutesByTo { @@ -53,6 +60,7 @@ export interface FileRoutesByTo { '/analytics': typeof AnalyticsRoute '/notifications': typeof NotificationsRoute '/settings': typeof SettingsRoute + '/system': typeof SystemRoute '/transactions': typeof TransactionsRoute } export interface FileRoutesById { @@ -61,6 +69,7 @@ export interface FileRoutesById { '/analytics': typeof AnalyticsRoute '/notifications': typeof NotificationsRoute '/settings': typeof SettingsRoute + '/system': typeof SystemRoute '/transactions': typeof TransactionsRoute } export interface FileRouteTypes { @@ -70,15 +79,23 @@ export interface FileRouteTypes { | '/analytics' | '/notifications' | '/settings' + | '/system' | '/transactions' fileRoutesByTo: FileRoutesByTo - to: '/' | '/analytics' | '/notifications' | '/settings' | '/transactions' + to: + | '/' + | '/analytics' + | '/notifications' + | '/settings' + | '/system' + | '/transactions' id: | '__root__' | '/' | '/analytics' | '/notifications' | '/settings' + | '/system' | '/transactions' fileRoutesById: FileRoutesById } @@ -87,6 +104,7 @@ export interface RootRouteChildren { AnalyticsRoute: typeof AnalyticsRoute NotificationsRoute: typeof NotificationsRoute SettingsRoute: typeof SettingsRoute + SystemRoute: typeof SystemRoute TransactionsRoute: typeof TransactionsRoute } @@ -99,6 +117,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof TransactionsRouteImport parentRoute: typeof rootRouteImport } + '/system': { + id: '/system' + path: '/system' + fullPath: '/system' + preLoaderRoute: typeof SystemRouteImport + parentRoute: typeof rootRouteImport + } '/settings': { id: '/settings' path: '/settings' @@ -135,6 +160,7 @@ const rootRouteChildren: RootRouteChildren = { AnalyticsRoute: AnalyticsRoute, NotificationsRoute: NotificationsRoute, SettingsRoute: SettingsRoute, + SystemRoute: SystemRoute, TransactionsRoute: TransactionsRoute, } export const routeTree = rootRouteImport diff --git a/frontend/src/routes/notifications.tsx b/frontend/src/routes/notifications.tsx index 03a92ce..1245ce2 100644 --- a/frontend/src/routes/notifications.tsx +++ b/frontend/src/routes/notifications.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import Notifications from "../components/Notifications"; +import System from "../components/System"; export const Route = createFileRoute("/notifications")({ - component: Notifications, + component: System, }); diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx index d1df6dd..217470e 100644 --- a/frontend/src/routes/settings.tsx +++ b/frontend/src/routes/settings.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import AccountSettings from "../components/AccountSettings"; +import Settings from "../components/Settings"; export const Route = createFileRoute("/settings")({ - component: AccountSettings, + component: Settings, }); diff --git a/frontend/src/routes/system.tsx b/frontend/src/routes/system.tsx new file mode 100644 index 0000000..a490ffb --- /dev/null +++ b/frontend/src/routes/system.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import System from "../components/System"; + +export const Route = createFileRoute("/system")({ + component: System, +}); diff --git a/leggen/services/database_service.py b/leggen/services/database_service.py index 8f527d4..73b533f 100644 --- a/leggen/services/database_service.py +++ b/leggen/services/database_service.py @@ -1553,6 +1553,9 @@ class DatabaseService: ) operation_id = cursor.lastrowid + if operation_id is None: + raise ValueError("Failed to get operation ID after insert") + conn.commit() conn.close() @@ -1563,7 +1566,9 @@ class DatabaseService: 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]]: + 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") @@ -1611,4 +1616,4 @@ class DatabaseService: except Exception as e: logger.error(f"Failed to get sync operations: {e}") - return [] \ No newline at end of file + return []