mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 11:22:21 +00:00
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>
This commit is contained in:
committed by
Elisiário Couto
parent
3f2ff21eac
commit
65404848aa
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
649
frontend/src/components/Settings.tsx
Normal file
649
frontend/src/components/Settings.tsx
Normal file
@@ -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<string | null>(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<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 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 <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 p-2 sm:p-3 bg-muted rounded-full">
|
||||
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingAccountId === account.id ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editingName}
|
||||
onChange={(e) =>
|
||||
setEditingName(e.target.value)
|
||||
}
|
||||
className="flex-1 px-3 py-1 text-base sm:text-lg font-medium border border-input rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||
placeholder="Custom account name"
|
||||
name="search"
|
||||
autoComplete="off"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleEditSave();
|
||||
if (e.key === "Escape") handleEditCancel();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleEditSave}
|
||||
disabled={
|
||||
!editingName.trim() ||
|
||||
updateAccountMutation.isPending
|
||||
}
|
||||
className="p-1 text-green-600 hover:text-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Save changes"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEditCancel}
|
||||
className="p-1 text-gray-600 hover:text-gray-700"
|
||||
title="Cancel editing"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{account.institution_id}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 min-w-0">
|
||||
<h4 className="text-base sm:text-lg font-medium text-foreground truncate">
|
||||
{account.display_name ||
|
||||
account.name ||
|
||||
"Unnamed Account"}
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => handleEditStart(account)}
|
||||
className="flex-shrink-0 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Edit account name"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{account.institution_id}
|
||||
</p>
|
||||
{account.iban && (
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono break-all sm:break-normal">
|
||||
IBAN: {account.iban}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance and date section */}
|
||||
<div className="flex items-center justify-between sm:flex-col sm:items-end sm:text-right flex-shrink-0">
|
||||
{/* 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">
|
||||
{/* Test Notification Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<TestTube className="h-5 w-5 text-primary" />
|
||||
<span>Test Notifications</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="service" className="text-foreground">
|
||||
Service
|
||||
</Label>
|
||||
<Select value={testService} onValueChange={setTestService}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a service..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{services?.map((service) => (
|
||||
<SelectItem key={service.name} value={service.name}>
|
||||
{service.name}{" "}
|
||||
{service.enabled ? "(Enabled)" : "(Disabled)"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="message" className="text-foreground">
|
||||
Message
|
||||
</Label>
|
||||
<Input
|
||||
id="message"
|
||||
type="text"
|
||||
value={testMessage}
|
||||
onChange={(e) => setTestMessage(e.target.value)}
|
||||
placeholder="Test message..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
onClick={handleTestNotification}
|
||||
disabled={!testService || testMutation.isPending}
|
||||
>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
{testMutation.isPending ? "Sending..." : "Send Test Notification"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notification Services */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
<span>Notification Services</span>
|
||||
</CardTitle>
|
||||
<CardDescription>Manage your notification services</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{!services || services.length === 0 ? (
|
||||
<CardContent className="text-center">
|
||||
<Bell className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
No notification services configured
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Configure notification services in your backend to receive alerts.
|
||||
</p>
|
||||
</CardContent>
|
||||
) : (
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-border">
|
||||
{services.map((service) => (
|
||||
<div
|
||||
key={service.name}
|
||||
className="p-6 hover:bg-accent transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3 bg-muted rounded-full">
|
||||
{service.name.toLowerCase().includes("discord") ? (
|
||||
<MessageSquare className="h-6 w-6 text-muted-foreground" />
|
||||
) : service.name.toLowerCase().includes("telegram") ? (
|
||||
<Send className="h-6 w-6 text-muted-foreground" />
|
||||
) : (
|
||||
<Bell className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-medium text-foreground capitalize">
|
||||
{service.name}
|
||||
</h4>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<Badge
|
||||
variant={
|
||||
service.enabled ? "default" : "destructive"
|
||||
}
|
||||
>
|
||||
{service.enabled ? (
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
) : (
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{service.enabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={
|
||||
service.configured ? "secondary" : "outline"
|
||||
}
|
||||
>
|
||||
{service.configured
|
||||
? "Configured"
|
||||
: "Not Configured"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => handleDeleteService(service.name)}
|
||||
disabled={deleteServiceMutation.isPending}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Notification Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<SettingsIcon className="h-5 w-5 text-primary" />
|
||||
<span>Notification Settings</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{notificationSettings && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground mb-2">
|
||||
Filters
|
||||
</h4>
|
||||
<div className="bg-muted rounded-md p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
Case Insensitive Filters
|
||||
</Label>
|
||||
<p className="text-sm text-foreground">
|
||||
{notificationSettings.filters.case_insensitive.length > 0
|
||||
? notificationSettings.filters.case_insensitive.join(", ")
|
||||
: "None"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
Case Sensitive Filters
|
||||
</Label>
|
||||
<p className="text-sm text-foreground">
|
||||
{notificationSettings.filters.case_sensitive &&
|
||||
notificationSettings.filters.case_sensitive.length > 0
|
||||
? notificationSettings.filters.case_sensitive.join(", ")
|
||||
: "None"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>
|
||||
Configure notification settings through your backend API to
|
||||
customize filters and service configurations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
205
frontend/src/components/System.tsx
Normal file
205
frontend/src/components/System.tsx
Normal file
@@ -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<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="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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
53
frontend/src/components/ui/tabs.tsx
Normal file
53
frontend/src/components/ui/tabs.tsx
Normal 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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
6
frontend/src/routes/system.tsx
Normal file
6
frontend/src/routes/system.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import System from "../components/System";
|
||||
|
||||
export const Route = createFileRoute("/system")({
|
||||
component: System,
|
||||
});
|
||||
@@ -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 []
|
||||
return []
|
||||
|
||||
Reference in New Issue
Block a user