diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 988d98f..157a81d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 98bf944..b05f2f0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/DiscordConfigDrawer.tsx b/frontend/src/components/DiscordConfigDrawer.tsx new file mode 100644 index 0000000..8828068 --- /dev/null +++ b/frontend/src/components/DiscordConfigDrawer.tsx @@ -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({ + 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 ( + + + {trigger || } + + +
+ + + + Discord Configuration + + + Configure Discord webhook notifications for transaction alerts + + + +
+ {/* Enable/Disable Toggle */} +
+ + setConfig({ ...config, enabled })} + /> +
+ + {/* Webhook URL */} +
+ + setConfig({ ...config, webhook: e.target.value })} + disabled={!config.enabled} + /> +

+ Create a webhook in your Discord server settings under Integrations → Webhooks +

+
+ + {/* Configuration Status */} + {config.enabled && ( +
+
+
+ + {isConfigValid ? 'Configuration Valid' : 'Invalid Webhook URL'} + +
+ {!isConfigValid && config.webhook.trim().length > 0 && ( +

+ Please enter a valid Discord webhook URL +

+ )} +
+ )} + + +
+ + {config.enabled && isConfigValid && ( + + )} +
+ + + +
+ +
+ + + ); +} diff --git a/frontend/src/components/NotificationFiltersDrawer.tsx b/frontend/src/components/NotificationFiltersDrawer.tsx new file mode 100644 index 0000000..114f3e2 --- /dev/null +++ b/frontend/src/components/NotificationFiltersDrawer.tsx @@ -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({ + 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 ( + + + {trigger || } + + +
+ + Notification Filters + + Configure which transaction descriptions should trigger notifications + + + +
+ {/* Case Insensitive Filters */} +
+ +

+ Filters that match regardless of capitalization (e.g., "AMAZON" matches "amazon") +

+ +
+ setNewCaseInsensitive(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addCaseInsensitiveFilter(); + } + }} + /> + +
+ +
+ {filters.case_insensitive.length > 0 ? ( + filters.case_insensitive.map((filter, index) => ( +
+ {filter} + +
+ )) + ) : ( + No filters added + )} +
+
+ + {/* Case Sensitive Filters */} +
+ +

+ Filters that match exactly as typed (e.g., "AMAZON" only matches "AMAZON") +

+ +
+ setNewCaseSensitive(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addCaseSensitiveFilter(); + } + }} + /> + +
+ +
+ {filters.case_sensitive && filters.case_sensitive.length > 0 ? ( + filters.case_sensitive.map((filter, index) => ( +
+ {filter} + +
+ )) + ) : ( + No filters added + )} +
+
+ + + + + + + +
+
+
+
+ ); +} diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx index 9ed7561..ebc8d8f 100644 --- a/frontend/src/components/Settings.tsx +++ b/frontend/src/components/Settings.tsx @@ -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(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() { - {/* Test Notification Section */} - - - - - Test Notifications - - - -
-
- - -
- -
- - setTestMessage(e.target.value)} - placeholder="Test message..." - /> -
-
- -
- -
-
-
- {/* Notification Services */} @@ -564,45 +476,48 @@ export default function Settings() { )}
-
-

- {service.name} -

-
- - {service.enabled ? ( - - ) : ( - - )} - {service.enabled ? "Enabled" : "Disabled"} - - - {service.configured - ? "Configured" - : "Not Configured"} - +
+
+

+ {service.name} +

+
+
+ + {service.enabled && service.configured + ? 'Active' + : service.enabled + ? 'Needs Configuration' + : 'Disabled'} + +
- +
+ {service.name.toLowerCase().includes("discord") ? ( + + ) : service.name.toLowerCase().includes("telegram") ? ( + + ) : null} + + +
))} @@ -611,60 +526,78 @@ export default function Settings() { )} - {/* Notification Settings */} + {/* Notification Filters */} - - - Notification Settings - +
+ + + Notification Filters + + +
- {notificationSettings && ( + {notificationSettings?.filters ? (
-
-

- Filters -

-
-
-
- -

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

+
+
+
+ +
+ {notificationSettings.filters.case_insensitive.length > 0 ? ( + notificationSettings.filters.case_insensitive.map((filter, index) => ( + + {filter} + + )) + ) : ( +

None

+ )}
-
- -

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

+
+
+ +
+ {notificationSettings.filters.case_sensitive && + notificationSettings.filters.case_sensitive.length > 0 ? ( + notificationSettings.filters.case_sensitive.map((filter, index) => ( + + {filter} + + )) + ) : ( +

None

+ )}
- -
-

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

-
+

+ Filters determine which transaction descriptions will trigger notifications. + Add terms to exclude transactions containing those words. +

+
+ ) : ( +
+ +

+ No notification filters configured +

+

+ Set up filters to control which transactions trigger notifications. +

+
)} diff --git a/frontend/src/components/TelegramConfigDrawer.tsx b/frontend/src/components/TelegramConfigDrawer.tsx new file mode 100644 index 0000000..ea77988 --- /dev/null +++ b/frontend/src/components/TelegramConfigDrawer.tsx @@ -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({ + 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 ( + + + {trigger || } + + +
+ + + + Telegram Configuration + + + Configure Telegram bot notifications for transaction alerts + + + +
+ {/* Enable/Disable Toggle */} +
+ + setConfig({ ...config, enabled })} + /> +
+ + {/* Bot Token */} +
+ + setConfig({ ...config, token: e.target.value })} + disabled={!config.enabled} + /> +

+ Create a bot using @BotFather on Telegram to get your token +

+
+ + {/* Chat ID */} +
+ + setConfig({ ...config, chat_id: parseInt(e.target.value) || 0 })} + disabled={!config.enabled} + /> +

+ Send a message to your bot and visit https://api.telegram.org/bot<token>/getUpdates to find your chat ID +

+
+ + {/* Configuration Status */} + {config.enabled && ( +
+
+
+ + {isConfigValid ? 'Configuration Valid' : 'Missing Token or Chat ID'} + +
+ {!isConfigValid && (config.token.trim().length > 0 || config.chat_id !== 0) && ( +

+ Both bot token and chat ID are required +

+ )} +
+ )} + + +
+ + {config.enabled && isConfigValid && ( + + )} +
+ + + +
+ +
+ + + ); +} diff --git a/frontend/src/components/ui/drawer.tsx b/frontend/src/components/ui/drawer.tsx new file mode 100644 index 0000000..c17b0cc --- /dev/null +++ b/frontend/src/components/ui/drawer.tsx @@ -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) => ( + +) +Drawer.displayName = "Drawer" + +const DrawerTrigger = DrawerPrimitive.Trigger + +const DrawerPortal = DrawerPrimitive.Portal + +const DrawerClose = DrawerPrimitive.Close + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName + +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + +
+ {children} + + +)) +DrawerContent.displayName = "DrawerContent" + +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DrawerHeader.displayName = "DrawerHeader" + +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DrawerFooter.displayName = "DrawerFooter" + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerTitle.displayName = DrawerPrimitive.Title.displayName + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerDescription.displayName = DrawerPrimitive.Description.displayName + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/frontend/src/components/ui/edit-button.tsx b/frontend/src/components/ui/edit-button.tsx new file mode 100644 index 0000000..c44ff14 --- /dev/null +++ b/frontend/src/components/ui/edit-button.tsx @@ -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 ( + + ); +} diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx new file mode 100644 index 0000000..455c23b --- /dev/null +++ b/frontend/src/components/ui/switch.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch }