mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 20:42:39 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d211a14703 | ||
|
|
c332642e64 | ||
|
|
27f3f2dbba |
26
CHANGELOG.md
26
CHANGELOG.md
@@ -1,4 +1,30 @@
|
||||
|
||||
## 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
|
||||
|
||||
30
frontend/package-lock.json
generated
30
frontend/package-lock.json
generated
@@ -22,6 +22,7 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
@@ -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": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
|
||||
181
frontend/src/components/DiscordConfigDrawer.tsx
Normal file
181
frontend/src/components/DiscordConfigDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
225
frontend/src/components/NotificationFiltersDrawer.tsx
Normal file
225
frontend/src/components/NotificationFiltersDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -15,10 +15,8 @@ import {
|
||||
MessageSquare,
|
||||
Send,
|
||||
Trash2,
|
||||
TestTube,
|
||||
Settings as SettingsIcon,
|
||||
User,
|
||||
CheckCircle,
|
||||
Filter,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "../lib/api";
|
||||
import { formatCurrency, formatDate } from "../lib/utils";
|
||||
@@ -31,18 +29,12 @@ import {
|
||||
} 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 NotificationFiltersDrawer from "./NotificationFiltersDrawer";
|
||||
import DiscordConfigDrawer from "./DiscordConfigDrawer";
|
||||
import TelegramConfigDrawer from "./TelegramConfigDrawer";
|
||||
import type {
|
||||
Account,
|
||||
Balance,
|
||||
@@ -87,10 +79,6 @@ const getStatusIndicator = (status: string) => {
|
||||
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();
|
||||
|
||||
@@ -146,16 +134,6 @@ export default function Settings() {
|
||||
});
|
||||
|
||||
// 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: () => {
|
||||
@@ -185,15 +163,6 @@ export default function Settings() {
|
||||
};
|
||||
|
||||
// Notification handlers
|
||||
const handleTestNotification = () => {
|
||||
if (!testService) return;
|
||||
|
||||
testMutation.mutate({
|
||||
service: testService.toLowerCase(),
|
||||
message: testMessage,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteService = (serviceName: string) => {
|
||||
if (
|
||||
confirm(
|
||||
@@ -463,63 +432,6 @@ export default function Settings() {
|
||||
</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>
|
||||
@@ -564,45 +476,48 @@ export default function Settings() {
|
||||
<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 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>
|
||||
|
||||
<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 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>
|
||||
))}
|
||||
@@ -611,60 +526,78 @@ export default function Settings() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Notification Settings */}
|
||||
{/* Notification Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<SettingsIcon className="h-5 w-5 text-primary" />
|
||||
<span>Notification Settings</span>
|
||||
</CardTitle>
|
||||
<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 && (
|
||||
{notificationSettings?.filters ? (
|
||||
<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 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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>
|
||||
Configure notification settings through your backend API
|
||||
to customize filters and service configurations.
|
||||
</p>
|
||||
</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>
|
||||
|
||||
198
frontend/src/components/TelegramConfigDrawer.tsx
Normal file
198
frontend/src/components/TelegramConfigDrawer.tsx
Normal 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<token>/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>
|
||||
);
|
||||
}
|
||||
116
frontend/src/components/ui/drawer.tsx
Normal file
116
frontend/src/components/ui/drawer.tsx
Normal 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,
|
||||
}
|
||||
39
frontend/src/components/ui/edit-button.tsx
Normal file
39
frontend/src/components/ui/edit-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
frontend/src/components/ui/switch.tsx
Normal file
27
frontend/src/components/ui/switch.tsx
Normal 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 }
|
||||
@@ -44,7 +44,7 @@ function AnalyticsDashboard() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="space-y-8">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-muted rounded w-48 mb-6"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
@@ -62,7 +62,7 @@ function AnalyticsDashboard() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-8">
|
||||
<div className="space-y-8">
|
||||
{/* Time Period Filter */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "leggen"
|
||||
version = "2025.9.20"
|
||||
version = "2025.9.21"
|
||||
description = "An Open Banking CLI"
|
||||
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
||||
requires-python = "~=3.13.0"
|
||||
|
||||
Reference in New Issue
Block a user