mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 23:12:16 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
704c3d4cb7 | ||
|
|
ef7c026db9 | ||
|
|
dc3522220a | ||
|
|
1693b3a50d | ||
|
|
460c5af6ea | ||
|
|
5a8614e019 | ||
|
|
ae5d034d4b | ||
|
|
d4edf69f2c |
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -2,9 +2,9 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "main", "dev" ]
|
branches: ["main", "dev"]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main", "dev" ]
|
branches: ["main", "dev"]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-python:
|
test-python:
|
||||||
@@ -43,8 +43,8 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: "20"
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
@@ -2,16 +2,11 @@
|
|||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"shadcn": {
|
"shadcn": {
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": [
|
"args": ["shadcn@latest", "mcp"]
|
||||||
"shadcn@latest",
|
|
||||||
"mcp"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"playwright": {
|
"playwright": {
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": [
|
"args": ["@playwright/mcp@latest"]
|
||||||
"@playwright/mcp@latest"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
CHANGELOG.md
44
CHANGELOG.md
@@ -1,4 +1,48 @@
|
|||||||
|
|
||||||
|
## 2025.9.24 (2025/09/25)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **frontend:** Add comprehensive bank account management system. ([ef7c026d](https://github.com/elisiariocouto/leggen/commit/ef7c026db9911cc3be8d5f48e42a4d7beb4b9d0a))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.24 (2025/09/25)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **frontend:** Add comprehensive bank account management system. ([ef7c026d](https://github.com/elisiariocouto/leggen/commit/ef7c026db9911cc3be8d5f48e42a4d7beb4b9d0a))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.23 (2025/09/24)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **cli:** Fix API URL handling for subpaths and improve client robustness. ([ae5d034d](https://github.com/elisiariocouto/leggen/commit/ae5d034d4b1da785e3dc240c1d60c2cae7de8010))
|
||||||
|
- Correct sync trigger types from manual to scheduled/retry. ([460c5af6](https://github.com/elisiariocouto/leggen/commit/460c5af6ea343ef5685b716413d01d7a30fa9acf))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **frontend:** Add version-based cache invalidation for PWA updates ([d4edf69f](https://github.com/elisiariocouto/leggen/commit/d4edf69f2cea2515a00435ee974116948057148d))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.23 (2025/09/24)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **cli:** Fix API URL handling for subpaths and improve client robustness. ([ae5d034d](https://github.com/elisiariocouto/leggen/commit/ae5d034d4b1da785e3dc240c1d60c2cae7de8010))
|
||||||
|
- Correct sync trigger types from manual to scheduled/retry. ([460c5af6](https://github.com/elisiariocouto/leggen/commit/460c5af6ea343ef5685b716413d01d7a30fa9acf))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **frontend:** Add version-based cache invalidation for PWA updates ([d4edf69f](https://github.com/elisiariocouto/leggen/commit/d4edf69f2cea2515a00435ee974116948057148d))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 2025.9.22 (2025/09/24)
|
## 2025.9.22 (2025/09/24)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -202,8 +202,12 @@ export default function AccountSettings() {
|
|||||||
alt={`${account.institution_id} logo`}
|
alt={`${account.institution_id} logo`}
|
||||||
className="w-full h-full object-contain"
|
className="w-full h-full object-contain"
|
||||||
onError={() => {
|
onError={() => {
|
||||||
console.warn(`Failed to load bank logo for ${account.institution_id}: ${account.logo}`);
|
console.warn(
|
||||||
setFailedImages(prev => new Set([...prev, account.id]));
|
`Failed to load bank logo for ${account.institution_id}: ${account.logo}`,
|
||||||
|
);
|
||||||
|
setFailedImages(
|
||||||
|
(prev) => new Set([...prev, account.id]),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
203
frontend/src/components/AddBankAccountDrawer.tsx
Normal file
203
frontend/src/components/AddBankAccountDrawer.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import { Plus, Building2, ExternalLink } from "lucide-react";
|
||||||
|
import { apiClient } from "../lib/api";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Label } from "./ui/label";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger,
|
||||||
|
} from "./ui/drawer";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "./ui/select";
|
||||||
|
import { Alert, AlertDescription } from "./ui/alert";
|
||||||
|
|
||||||
|
export default function AddBankAccountDrawer() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selectedCountry, setSelectedCountry] = useState<string>("");
|
||||||
|
const [selectedBank, setSelectedBank] = useState<string>("");
|
||||||
|
|
||||||
|
const { data: countries } = useQuery({
|
||||||
|
queryKey: ["supportedCountries"],
|
||||||
|
queryFn: apiClient.getSupportedCountries,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: banks, isLoading: banksLoading } = useQuery({
|
||||||
|
queryKey: ["bankInstitutions", selectedCountry],
|
||||||
|
queryFn: () => apiClient.getBankInstitutions(selectedCountry),
|
||||||
|
enabled: !!selectedCountry,
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectBankMutation = useMutation({
|
||||||
|
mutationFn: (institutionId: string) =>
|
||||||
|
apiClient.createBankConnection(institutionId),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
// Redirect to the bank's authorization link
|
||||||
|
if (data.link) {
|
||||||
|
window.open(data.link, "_blank");
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Failed to create bank connection:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCountryChange = (country: string) => {
|
||||||
|
setSelectedCountry(country);
|
||||||
|
setSelectedBank("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConnect = () => {
|
||||||
|
if (selectedBank) {
|
||||||
|
connectBankMutation.mutate(selectedBank);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setSelectedCountry("");
|
||||||
|
setSelectedBank("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
setOpen(isOpen);
|
||||||
|
if (!isOpen) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
<Button size="sm">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add New Account
|
||||||
|
</Button>
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent className="max-h-[80vh]">
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle>Connect Bank Account</DrawerTitle>
|
||||||
|
<DrawerDescription>
|
||||||
|
Select your country and bank to connect your account to Leggen
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
|
||||||
|
<div className="px-6 space-y-6 overflow-y-auto">
|
||||||
|
{/* Country Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="country">Country</Label>
|
||||||
|
<Select value={selectedCountry} onValueChange={handleCountryChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select your country" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{countries?.map((country) => (
|
||||||
|
<SelectItem key={country.code} value={country.code}>
|
||||||
|
{country.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bank Selection */}
|
||||||
|
{selectedCountry && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bank">Bank</Label>
|
||||||
|
{banksLoading ? (
|
||||||
|
<div className="p-4 text-center text-muted-foreground">
|
||||||
|
Loading banks...
|
||||||
|
</div>
|
||||||
|
) : banks && banks.length > 0 ? (
|
||||||
|
<Select value={selectedBank} onValueChange={setSelectedBank}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select your bank" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{banks.map((bank) => (
|
||||||
|
<SelectItem key={bank.id} value={bank.id}>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{bank.logo ? (
|
||||||
|
<img
|
||||||
|
src={bank.logo}
|
||||||
|
alt={`${bank.name} logo`}
|
||||||
|
className="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Building2 className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span>{bank.name}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
No banks available for the selected country.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
{selectedBank && (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
You'll be redirected to your bank's website to authorize the
|
||||||
|
connection. After approval, you'll return to Leggen and your
|
||||||
|
account will start syncing.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{connectBankMutation.isError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
Failed to create bank connection. Please try again.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DrawerFooter>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleConnect}
|
||||||
|
disabled={!selectedBank || connectBankMutation.isPending}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
{connectBankMutation.isPending
|
||||||
|
? "Connecting..."
|
||||||
|
: "Open Bank Authorization"}
|
||||||
|
</Button>
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={connectBankMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DrawerClose>
|
||||||
|
</div>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,7 +47,10 @@ export default function DiscordConfigDrawer({
|
|||||||
apiClient.updateNotificationSettings({
|
apiClient.updateNotificationSettings({
|
||||||
...settings,
|
...settings,
|
||||||
discord: discordConfig,
|
discord: discordConfig,
|
||||||
filters: settings?.filters || { case_insensitive: [], case_sensitive: [] },
|
filters: settings?.filters || {
|
||||||
|
case_insensitive: [],
|
||||||
|
case_sensitive: [],
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
|
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
|
||||||
@@ -60,10 +63,12 @@ export default function DiscordConfigDrawer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const testMutation = useMutation({
|
const testMutation = useMutation({
|
||||||
mutationFn: () => apiClient.testNotification({
|
mutationFn: () =>
|
||||||
service: "discord",
|
apiClient.testNotification({
|
||||||
message: "Test notification from Leggen - Discord configuration is working!"
|
service: "discord",
|
||||||
}),
|
message:
|
||||||
|
"Test notification from Leggen - Discord configuration is working!",
|
||||||
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
console.log("Test Discord notification sent successfully");
|
console.log("Test Discord notification sent successfully");
|
||||||
},
|
},
|
||||||
@@ -81,13 +86,13 @@ export default function DiscordConfigDrawer({
|
|||||||
testMutation.mutate();
|
testMutation.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
const isConfigValid = config.webhook.trim().length > 0 && config.webhook.includes('discord.com/api/webhooks');
|
const isConfigValid =
|
||||||
|
config.webhook.trim().length > 0 &&
|
||||||
|
config.webhook.includes("discord.com/api/webhooks");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer open={open} onOpenChange={setOpen}>
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
<DrawerTrigger asChild>
|
<DrawerTrigger asChild>{trigger || <EditButton />}</DrawerTrigger>
|
||||||
{trigger || <EditButton />}
|
|
||||||
</DrawerTrigger>
|
|
||||||
<DrawerContent>
|
<DrawerContent>
|
||||||
<div className="mx-auto w-full max-w-md">
|
<div className="mx-auto w-full max-w-md">
|
||||||
<DrawerHeader>
|
<DrawerHeader>
|
||||||
@@ -103,7 +108,9 @@ export default function DiscordConfigDrawer({
|
|||||||
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
||||||
{/* Enable/Disable Toggle */}
|
{/* Enable/Disable Toggle */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-base font-medium">Enable Discord Notifications</Label>
|
<Label className="text-base font-medium">
|
||||||
|
Enable Discord Notifications
|
||||||
|
</Label>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config.enabled}
|
checked={config.enabled}
|
||||||
onCheckedChange={(enabled) => setConfig({ ...config, enabled })}
|
onCheckedChange={(enabled) => setConfig({ ...config, enabled })}
|
||||||
@@ -118,11 +125,14 @@ export default function DiscordConfigDrawer({
|
|||||||
type="url"
|
type="url"
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
value={config.webhook}
|
value={config.webhook}
|
||||||
onChange={(e) => setConfig({ ...config, webhook: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setConfig({ ...config, webhook: e.target.value })
|
||||||
|
}
|
||||||
disabled={!config.enabled}
|
disabled={!config.enabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Create a webhook in your Discord server settings under Integrations → Webhooks
|
Create a webhook in your Discord server settings under
|
||||||
|
Integrations → Webhooks
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -130,9 +140,13 @@ export default function DiscordConfigDrawer({
|
|||||||
{config.enabled && (
|
{config.enabled && (
|
||||||
<div className="p-3 bg-muted rounded-md">
|
<div className="p-3 bg-muted rounded-md">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className={`w-2 h-2 rounded-full ${isConfigValid ? 'bg-green-500' : 'bg-red-500'}`} />
|
<div
|
||||||
|
className={`w-2 h-2 rounded-full ${isConfigValid ? "bg-green-500" : "bg-red-500"}`}
|
||||||
|
/>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{isConfigValid ? 'Configuration Valid' : 'Invalid Webhook URL'}
|
{isConfigValid
|
||||||
|
? "Configuration Valid"
|
||||||
|
: "Invalid Webhook URL"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!isConfigValid && config.webhook.trim().length > 0 && (
|
{!isConfigValid && config.webhook.trim().length > 0 && (
|
||||||
@@ -145,8 +159,13 @@ export default function DiscordConfigDrawer({
|
|||||||
|
|
||||||
<DrawerFooter className="px-0">
|
<DrawerFooter className="px-0">
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Button type="submit" disabled={updateMutation.isPending || !config.enabled}>
|
<Button
|
||||||
{updateMutation.isPending ? "Saving..." : "Save Configuration"}
|
type="submit"
|
||||||
|
disabled={updateMutation.isPending || !config.enabled}
|
||||||
|
>
|
||||||
|
{updateMutation.isPending
|
||||||
|
? "Saving..."
|
||||||
|
: "Save Configuration"}
|
||||||
</Button>
|
</Button>
|
||||||
{config.enabled && isConfigValid && (
|
{config.enabled && isConfigValid && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -67,20 +67,32 @@ export default function NotificationFiltersDrawer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addCaseInsensitiveFilter = () => {
|
const addCaseInsensitiveFilter = () => {
|
||||||
if (newCaseInsensitive.trim() && !filters.case_insensitive.includes(newCaseInsensitive.trim())) {
|
if (
|
||||||
|
newCaseInsensitive.trim() &&
|
||||||
|
!filters.case_insensitive.includes(newCaseInsensitive.trim())
|
||||||
|
) {
|
||||||
setFilters({
|
setFilters({
|
||||||
...filters,
|
...filters,
|
||||||
case_insensitive: [...filters.case_insensitive, newCaseInsensitive.trim()],
|
case_insensitive: [
|
||||||
|
...filters.case_insensitive,
|
||||||
|
newCaseInsensitive.trim(),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
setNewCaseInsensitive("");
|
setNewCaseInsensitive("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addCaseSensitiveFilter = () => {
|
const addCaseSensitiveFilter = () => {
|
||||||
if (newCaseSensitive.trim() && !filters.case_sensitive?.includes(newCaseSensitive.trim())) {
|
if (
|
||||||
|
newCaseSensitive.trim() &&
|
||||||
|
!filters.case_sensitive?.includes(newCaseSensitive.trim())
|
||||||
|
) {
|
||||||
setFilters({
|
setFilters({
|
||||||
...filters,
|
...filters,
|
||||||
case_sensitive: [...(filters.case_sensitive || []), newCaseSensitive.trim()],
|
case_sensitive: [
|
||||||
|
...(filters.case_sensitive || []),
|
||||||
|
newCaseSensitive.trim(),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
setNewCaseSensitive("");
|
setNewCaseSensitive("");
|
||||||
}
|
}
|
||||||
@@ -96,30 +108,33 @@ export default function NotificationFiltersDrawer({
|
|||||||
const removeCaseSensitiveFilter = (index: number) => {
|
const removeCaseSensitiveFilter = (index: number) => {
|
||||||
setFilters({
|
setFilters({
|
||||||
...filters,
|
...filters,
|
||||||
case_sensitive: filters.case_sensitive?.filter((_, i) => i !== index) || [],
|
case_sensitive:
|
||||||
|
filters.case_sensitive?.filter((_, i) => i !== index) || [],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer open={open} onOpenChange={setOpen}>
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
<DrawerTrigger asChild>
|
<DrawerTrigger asChild>{trigger || <EditButton />}</DrawerTrigger>
|
||||||
{trigger || <EditButton />}
|
|
||||||
</DrawerTrigger>
|
|
||||||
<DrawerContent>
|
<DrawerContent>
|
||||||
<div className="mx-auto w-full max-w-2xl">
|
<div className="mx-auto w-full max-w-2xl">
|
||||||
<DrawerHeader>
|
<DrawerHeader>
|
||||||
<DrawerTitle>Notification Filters</DrawerTitle>
|
<DrawerTitle>Notification Filters</DrawerTitle>
|
||||||
<DrawerDescription>
|
<DrawerDescription>
|
||||||
Configure which transaction descriptions should trigger notifications
|
Configure which transaction descriptions should trigger
|
||||||
|
notifications
|
||||||
</DrawerDescription>
|
</DrawerDescription>
|
||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
||||||
{/* Case Insensitive Filters */}
|
{/* Case Insensitive Filters */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-base font-medium">Case Insensitive Filters</Label>
|
<Label className="text-base font-medium">
|
||||||
|
Case Insensitive Filters
|
||||||
|
</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Filters that match regardless of capitalization (e.g., "AMAZON" matches "amazon")
|
Filters that match regardless of capitalization (e.g., "AMAZON"
|
||||||
|
matches "amazon")
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
@@ -134,7 +149,11 @@ export default function NotificationFiltersDrawer({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button type="button" onClick={addCaseInsensitiveFilter} size="sm">
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={addCaseInsensitiveFilter}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,16 +176,21 @@ export default function NotificationFiltersDrawer({
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground text-sm">No filters added</span>
|
<span className="text-muted-foreground text-sm">
|
||||||
|
No filters added
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Case Sensitive Filters */}
|
{/* Case Sensitive Filters */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-base font-medium">Case Sensitive Filters</Label>
|
<Label className="text-base font-medium">
|
||||||
|
Case Sensitive Filters
|
||||||
|
</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Filters that match exactly as typed (e.g., "AMAZON" only matches "AMAZON")
|
Filters that match exactly as typed (e.g., "AMAZON" only matches
|
||||||
|
"AMAZON")
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
@@ -181,7 +205,11 @@ export default function NotificationFiltersDrawer({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button type="button" onClick={addCaseSensitiveFilter} size="sm">
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={addCaseSensitiveFilter}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,7 +232,9 @@ export default function NotificationFiltersDrawer({
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground text-sm">No filters added</span>
|
<span className="text-muted-foreground text-sm">
|
||||||
|
No filters added
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
Edit2,
|
Edit2,
|
||||||
Check,
|
Check,
|
||||||
X,
|
X,
|
||||||
Plus,
|
|
||||||
Bell,
|
Bell,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Send,
|
Send,
|
||||||
@@ -35,6 +34,7 @@ import AccountsSkeleton from "./AccountsSkeleton";
|
|||||||
import NotificationFiltersDrawer from "./NotificationFiltersDrawer";
|
import NotificationFiltersDrawer from "./NotificationFiltersDrawer";
|
||||||
import DiscordConfigDrawer from "./DiscordConfigDrawer";
|
import DiscordConfigDrawer from "./DiscordConfigDrawer";
|
||||||
import TelegramConfigDrawer from "./TelegramConfigDrawer";
|
import TelegramConfigDrawer from "./TelegramConfigDrawer";
|
||||||
|
import AddBankAccountDrawer from "./AddBankAccountDrawer";
|
||||||
import type {
|
import type {
|
||||||
Account,
|
Account,
|
||||||
Balance,
|
Balance,
|
||||||
@@ -120,6 +120,11 @@ export default function Settings() {
|
|||||||
queryFn: apiClient.getNotificationServices,
|
queryFn: apiClient.getNotificationServices,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: bankConnections } = useQuery({
|
||||||
|
queryKey: ["bankConnections"],
|
||||||
|
queryFn: apiClient.getBankConnectionsStatus,
|
||||||
|
});
|
||||||
|
|
||||||
// Account mutations
|
// Account mutations
|
||||||
const updateAccountMutation = useMutation({
|
const updateAccountMutation = useMutation({
|
||||||
mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
|
mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
|
||||||
@@ -143,6 +148,16 @@ export default function Settings() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bank connection mutations
|
||||||
|
const deleteBankConnectionMutation = useMutation({
|
||||||
|
mutationFn: apiClient.deleteBankConnection,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["bankConnections"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["balances"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Account handlers
|
// Account handlers
|
||||||
const handleEditStart = (account: Account) => {
|
const handleEditStart = (account: Account) => {
|
||||||
setEditingAccountId(account.id);
|
setEditingAccountId(account.id);
|
||||||
@@ -245,13 +260,6 @@ export default function Settings() {
|
|||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
Connect your first bank account to get started with Leggen.
|
Connect your first bank account to get started with Leggen.
|
||||||
</p>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
@@ -288,8 +296,12 @@ export default function Settings() {
|
|||||||
alt={`${account.institution_id} logo`}
|
alt={`${account.institution_id} logo`}
|
||||||
className="w-6 h-6 sm:w-8 sm:h-8 object-contain"
|
className="w-6 h-6 sm:w-8 sm:h-8 object-contain"
|
||||||
onError={() => {
|
onError={() => {
|
||||||
console.warn(`Failed to load bank logo for ${account.institution_id}: ${account.logo}`);
|
console.warn(
|
||||||
setFailedImages(prev => new Set([...prev, account.id]));
|
`Failed to load bank logo for ${account.institution_id}: ${account.logo}`,
|
||||||
|
);
|
||||||
|
setFailedImages(
|
||||||
|
(prev) => new Set([...prev, account.id]),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -417,30 +429,110 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Add Bank Section (Future Feature) */}
|
{/* Bank Connections Status */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Add New Bank Account</CardTitle>
|
<div className="flex items-center justify-between">
|
||||||
<CardDescription>
|
<div>
|
||||||
Connect additional bank accounts to track all your finances in
|
<CardTitle>Bank Connections</CardTitle>
|
||||||
one place
|
<CardDescription>
|
||||||
</CardDescription>
|
Status of all bank connection requests and their
|
||||||
</CardHeader>
|
authorization state
|
||||||
<CardContent className="p-6">
|
</CardDescription>
|
||||||
<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>
|
</div>
|
||||||
<Button disabled variant="outline">
|
<AddBankAccountDrawer />
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Connect Bank Account
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardHeader>
|
||||||
|
|
||||||
|
{!bankConnections || bankConnections.length === 0 ? (
|
||||||
|
<CardContent className="p-6 text-center">
|
||||||
|
<Building2 className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
|
No bank connections found
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Bank connection requests will appear here after you connect
|
||||||
|
accounts.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
) : (
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{bankConnections.map((connection) => {
|
||||||
|
const statusColor =
|
||||||
|
connection.status.toLowerCase() === "ln"
|
||||||
|
? "bg-green-500"
|
||||||
|
: connection.status.toLowerCase() === "cr"
|
||||||
|
? "bg-amber-500"
|
||||||
|
: connection.status.toLowerCase() === "ex"
|
||||||
|
? "bg-red-500"
|
||||||
|
: "bg-muted-foreground";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={connection.requisition_id}
|
||||||
|
className="p-4 sm:p-6 hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4 min-w-0 flex-1">
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-muted flex items-center justify-center">
|
||||||
|
<Building2 className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<h4 className="text-base font-medium text-foreground truncate">
|
||||||
|
{connection.bank_name}
|
||||||
|
</h4>
|
||||||
|
<div
|
||||||
|
className={`w-3 h-3 rounded-full ${statusColor}`}
|
||||||
|
title={connection.status_display}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{connection.status_display} •{" "}
|
||||||
|
{connection.accounts_count} account
|
||||||
|
{connection.accounts_count !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground font-mono">
|
||||||
|
ID: {connection.requisition_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 flex-shrink-0">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Created {formatDate(connection.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const isWorking =
|
||||||
|
connection.status.toLowerCase() === "ln";
|
||||||
|
const message = isWorking
|
||||||
|
? `Are you sure you want to disconnect "${connection.bank_name}"? This will stop syncing new transactions but keep your existing transaction history.`
|
||||||
|
: `Delete connection to ${connection.bank_name}?`;
|
||||||
|
|
||||||
|
if (confirm(message)) {
|
||||||
|
deleteBankConnectionMutation.mutate(
|
||||||
|
connection.requisition_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={deleteBankConnectionMutation.isPending}
|
||||||
|
className="p-1 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Delete connection"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -495,19 +587,21 @@ export default function Settings() {
|
|||||||
{service.name}
|
{service.name}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className={`w-2 h-2 rounded-full ${
|
<div
|
||||||
service.enabled && service.configured
|
className={`w-2 h-2 rounded-full ${
|
||||||
? 'bg-green-500'
|
service.enabled && service.configured
|
||||||
: service.enabled
|
? "bg-green-500"
|
||||||
? 'bg-amber-500'
|
: service.enabled
|
||||||
: 'bg-muted-foreground'
|
? "bg-amber-500"
|
||||||
}`} />
|
: "bg-muted-foreground"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{service.enabled && service.configured
|
{service.enabled && service.configured
|
||||||
? 'Active'
|
? "Active"
|
||||||
: service.enabled
|
: service.enabled
|
||||||
? 'Needs Configuration'
|
? "Needs Configuration"
|
||||||
: 'Disabled'}
|
: "Disabled"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -516,9 +610,15 @@ export default function Settings() {
|
|||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
{service.name.toLowerCase().includes("discord") ? (
|
{service.name.toLowerCase().includes("discord") ? (
|
||||||
<DiscordConfigDrawer settings={notificationSettings} />
|
<DiscordConfigDrawer
|
||||||
) : service.name.toLowerCase().includes("telegram") ? (
|
settings={notificationSettings}
|
||||||
<TelegramConfigDrawer settings={notificationSettings} />
|
/>
|
||||||
|
) : service.name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes("telegram") ? (
|
||||||
|
<TelegramConfigDrawer
|
||||||
|
settings={notificationSettings}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -560,17 +660,22 @@ export default function Settings() {
|
|||||||
Case Insensitive Filters
|
Case Insensitive Filters
|
||||||
</Label>
|
</Label>
|
||||||
<div className="min-h-[2rem] flex flex-wrap gap-1">
|
<div className="min-h-[2rem] flex flex-wrap gap-1">
|
||||||
{notificationSettings.filters.case_insensitive.length > 0 ? (
|
{notificationSettings.filters.case_insensitive
|
||||||
notificationSettings.filters.case_insensitive.map((filter, index) => (
|
.length > 0 ? (
|
||||||
<span
|
notificationSettings.filters.case_insensitive.map(
|
||||||
key={index}
|
(filter, index) => (
|
||||||
className="inline-flex items-center px-2 py-1 bg-secondary text-secondary-foreground rounded text-xs"
|
<span
|
||||||
>
|
key={index}
|
||||||
{filter}
|
className="inline-flex items-center px-2 py-1 bg-secondary text-secondary-foreground rounded text-xs"
|
||||||
</span>
|
>
|
||||||
))
|
{filter}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">None</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
None
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -580,25 +685,31 @@ export default function Settings() {
|
|||||||
</Label>
|
</Label>
|
||||||
<div className="min-h-[2rem] flex flex-wrap gap-1">
|
<div className="min-h-[2rem] flex flex-wrap gap-1">
|
||||||
{notificationSettings.filters.case_sensitive &&
|
{notificationSettings.filters.case_sensitive &&
|
||||||
notificationSettings.filters.case_sensitive.length > 0 ? (
|
notificationSettings.filters.case_sensitive.length >
|
||||||
notificationSettings.filters.case_sensitive.map((filter, index) => (
|
0 ? (
|
||||||
<span
|
notificationSettings.filters.case_sensitive.map(
|
||||||
key={index}
|
(filter, index) => (
|
||||||
className="inline-flex items-center px-2 py-1 bg-secondary text-secondary-foreground rounded text-xs"
|
<span
|
||||||
>
|
key={index}
|
||||||
{filter}
|
className="inline-flex items-center px-2 py-1 bg-secondary text-secondary-foreground rounded text-xs"
|
||||||
</span>
|
>
|
||||||
))
|
{filter}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">None</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
None
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Filters determine which transaction descriptions will trigger notifications.
|
Filters determine which transaction descriptions will
|
||||||
Add terms to exclude transactions containing those words.
|
trigger notifications. Add terms to exclude transactions
|
||||||
|
containing those words.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -608,7 +719,8 @@ export default function Settings() {
|
|||||||
No notification filters configured
|
No notification filters configured
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
Set up filters to control which transactions trigger notifications.
|
Set up filters to control which transactions trigger
|
||||||
|
notifications.
|
||||||
</p>
|
</p>
|
||||||
<NotificationFiltersDrawer settings={notificationSettings} />
|
<NotificationFiltersDrawer settings={notificationSettings} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ export function SiteHeader() {
|
|||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear pt-safe-top">
|
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear pt-safe-top">
|
||||||
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
||||||
|
|||||||
@@ -210,7 +210,8 @@ export default function System() {
|
|||||||
: "Sync Failed"}
|
: "Sync Failed"}
|
||||||
</h4>
|
</h4>
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{operation.trigger_type}
|
{operation.trigger_type.charAt(0).toUpperCase() +
|
||||||
|
operation.trigger_type.slice(1)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4 mt-1 text-xs text-muted-foreground">
|
<div className="flex items-center space-x-4 mt-1 text-xs text-muted-foreground">
|
||||||
@@ -272,7 +273,8 @@ export default function System() {
|
|||||||
: "Sync Failed"}
|
: "Sync Failed"}
|
||||||
</h4>
|
</h4>
|
||||||
<Badge variant="outline" className="text-xs mt-1">
|
<Badge variant="outline" className="text-xs mt-1">
|
||||||
{operation.trigger_type}
|
{operation.trigger_type.charAt(0).toUpperCase() +
|
||||||
|
operation.trigger_type.slice(1)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -286,7 +288,9 @@ export default function System() {
|
|||||||
{startedAt.toLocaleDateString()}{" "}
|
{startedAt.toLocaleDateString()}{" "}
|
||||||
{startedAt.toLocaleTimeString()}
|
{startedAt.toLocaleTimeString()}
|
||||||
</span>
|
</span>
|
||||||
{duration && <span className="ml-2">• {duration}</span>}
|
{duration && (
|
||||||
|
<span className="ml-2">• {duration}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
@@ -296,7 +300,9 @@ export default function System() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<TrendingUp className="h-3 w-3" />
|
<TrendingUp className="h-3 w-3" />
|
||||||
<span>{operation.transactions_added} new transactions</span>
|
<span>
|
||||||
|
{operation.transactions_added} new transactions
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ export default function TelegramConfigDrawer({
|
|||||||
apiClient.updateNotificationSettings({
|
apiClient.updateNotificationSettings({
|
||||||
...settings,
|
...settings,
|
||||||
telegram: telegramConfig,
|
telegram: telegramConfig,
|
||||||
filters: settings?.filters || { case_insensitive: [], case_sensitive: [] },
|
filters: settings?.filters || {
|
||||||
|
case_insensitive: [],
|
||||||
|
case_sensitive: [],
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
|
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
|
||||||
@@ -61,10 +64,12 @@ export default function TelegramConfigDrawer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const testMutation = useMutation({
|
const testMutation = useMutation({
|
||||||
mutationFn: () => apiClient.testNotification({
|
mutationFn: () =>
|
||||||
service: "telegram",
|
apiClient.testNotification({
|
||||||
message: "Test notification from Leggen - Telegram configuration is working!"
|
service: "telegram",
|
||||||
}),
|
message:
|
||||||
|
"Test notification from Leggen - Telegram configuration is working!",
|
||||||
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
console.log("Test Telegram notification sent successfully");
|
console.log("Test Telegram notification sent successfully");
|
||||||
},
|
},
|
||||||
@@ -86,9 +91,7 @@ export default function TelegramConfigDrawer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer open={open} onOpenChange={setOpen}>
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
<DrawerTrigger asChild>
|
<DrawerTrigger asChild>{trigger || <EditButton />}</DrawerTrigger>
|
||||||
{trigger || <EditButton />}
|
|
||||||
</DrawerTrigger>
|
|
||||||
<DrawerContent>
|
<DrawerContent>
|
||||||
<div className="mx-auto w-full max-w-md">
|
<div className="mx-auto w-full max-w-md">
|
||||||
<DrawerHeader>
|
<DrawerHeader>
|
||||||
@@ -104,7 +107,9 @@ export default function TelegramConfigDrawer({
|
|||||||
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
||||||
{/* Enable/Disable Toggle */}
|
{/* Enable/Disable Toggle */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-base font-medium">Enable Telegram Notifications</Label>
|
<Label className="text-base font-medium">
|
||||||
|
Enable Telegram Notifications
|
||||||
|
</Label>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config.enabled}
|
checked={config.enabled}
|
||||||
onCheckedChange={(enabled) => setConfig({ ...config, enabled })}
|
onCheckedChange={(enabled) => setConfig({ ...config, enabled })}
|
||||||
@@ -119,7 +124,9 @@ export default function TelegramConfigDrawer({
|
|||||||
type="password"
|
type="password"
|
||||||
placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||||
value={config.token}
|
value={config.token}
|
||||||
onChange={(e) => setConfig({ ...config, token: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setConfig({ ...config, token: e.target.value })
|
||||||
|
}
|
||||||
disabled={!config.enabled}
|
disabled={!config.enabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -135,11 +142,18 @@ export default function TelegramConfigDrawer({
|
|||||||
type="number"
|
type="number"
|
||||||
placeholder="123456789"
|
placeholder="123456789"
|
||||||
value={config.chat_id || ""}
|
value={config.chat_id || ""}
|
||||||
onChange={(e) => setConfig({ ...config, chat_id: parseInt(e.target.value) || 0 })}
|
onChange={(e) =>
|
||||||
|
setConfig({
|
||||||
|
...config,
|
||||||
|
chat_id: parseInt(e.target.value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
disabled={!config.enabled}
|
disabled={!config.enabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<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
|
Send a message to your bot and visit
|
||||||
|
https://api.telegram.org/bot<token>/getUpdates to find
|
||||||
|
your chat ID
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -147,23 +161,33 @@ export default function TelegramConfigDrawer({
|
|||||||
{config.enabled && (
|
{config.enabled && (
|
||||||
<div className="p-3 bg-muted rounded-md">
|
<div className="p-3 bg-muted rounded-md">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className={`w-2 h-2 rounded-full ${isConfigValid ? 'bg-green-500' : 'bg-red-500'}`} />
|
<div
|
||||||
|
className={`w-2 h-2 rounded-full ${isConfigValid ? "bg-green-500" : "bg-red-500"}`}
|
||||||
|
/>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{isConfigValid ? 'Configuration Valid' : 'Missing Token or Chat ID'}
|
{isConfigValid
|
||||||
|
? "Configuration Valid"
|
||||||
|
: "Missing Token or Chat ID"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!isConfigValid && (config.token.trim().length > 0 || config.chat_id !== 0) && (
|
{!isConfigValid &&
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
(config.token.trim().length > 0 || config.chat_id !== 0) && (
|
||||||
Both bot token and chat ID are required
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
</p>
|
Both bot token and chat ID are required
|
||||||
)}
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DrawerFooter className="px-0">
|
<DrawerFooter className="px-0">
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Button type="submit" disabled={updateMutation.isPending || !config.enabled}>
|
<Button
|
||||||
{updateMutation.isPending ? "Saving..." : "Save Configuration"}
|
type="submit"
|
||||||
|
disabled={updateMutation.isPending || !config.enabled}
|
||||||
|
>
|
||||||
|
{updateMutation.isPending
|
||||||
|
? "Saving..."
|
||||||
|
: "Save Configuration"}
|
||||||
</Button>
|
</Button>
|
||||||
{config.enabled && isConfigValid && (
|
{config.enabled && isConfigValid && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ export default function TimePeriodFilter({
|
|||||||
className = "",
|
className = "",
|
||||||
}: TimePeriodFilterProps) {
|
}: TimePeriodFilterProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col sm:flex-row sm:items-center gap-4 ${className}`}>
|
<div
|
||||||
|
className={`flex flex-col sm:flex-row sm:items-center gap-4 ${className}`}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2 text-foreground">
|
<div className="flex items-center gap-2 text-foreground">
|
||||||
<Calendar size={20} />
|
<Calendar size={20} />
|
||||||
<span className="font-medium">Time Period:</span>
|
<span className="font-medium">Time Period:</span>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Drawer as DrawerPrimitive } from "vaul"
|
import { Drawer as DrawerPrimitive } from "vaul";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Drawer = ({
|
const Drawer = ({
|
||||||
shouldScaleBackground = true,
|
shouldScaleBackground = true,
|
||||||
@@ -11,14 +11,14 @@ const Drawer = ({
|
|||||||
shouldScaleBackground={shouldScaleBackground}
|
shouldScaleBackground={shouldScaleBackground}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
Drawer.displayName = "Drawer"
|
Drawer.displayName = "Drawer";
|
||||||
|
|
||||||
const DrawerTrigger = DrawerPrimitive.Trigger
|
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||||
|
|
||||||
const DrawerPortal = DrawerPrimitive.Portal
|
const DrawerPortal = DrawerPrimitive.Portal;
|
||||||
|
|
||||||
const DrawerClose = DrawerPrimitive.Close
|
const DrawerClose = DrawerPrimitive.Close;
|
||||||
|
|
||||||
const DrawerOverlay = React.forwardRef<
|
const DrawerOverlay = React.forwardRef<
|
||||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||||
@@ -29,8 +29,8 @@ const DrawerOverlay = React.forwardRef<
|
|||||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const DrawerContent = React.forwardRef<
|
const DrawerContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||||
@@ -42,7 +42,7 @@ const DrawerContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -50,8 +50,8 @@ const DrawerContent = React.forwardRef<
|
|||||||
{children}
|
{children}
|
||||||
</DrawerPrimitive.Content>
|
</DrawerPrimitive.Content>
|
||||||
</DrawerPortal>
|
</DrawerPortal>
|
||||||
))
|
));
|
||||||
DrawerContent.displayName = "DrawerContent"
|
DrawerContent.displayName = "DrawerContent";
|
||||||
|
|
||||||
const DrawerHeader = ({
|
const DrawerHeader = ({
|
||||||
className,
|
className,
|
||||||
@@ -61,8 +61,8 @@ const DrawerHeader = ({
|
|||||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
DrawerHeader.displayName = "DrawerHeader"
|
DrawerHeader.displayName = "DrawerHeader";
|
||||||
|
|
||||||
const DrawerFooter = ({
|
const DrawerFooter = ({
|
||||||
className,
|
className,
|
||||||
@@ -72,8 +72,8 @@ const DrawerFooter = ({
|
|||||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
DrawerFooter.displayName = "DrawerFooter"
|
DrawerFooter.displayName = "DrawerFooter";
|
||||||
|
|
||||||
const DrawerTitle = React.forwardRef<
|
const DrawerTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||||
@@ -83,12 +83,12 @@ const DrawerTitle = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-lg font-semibold leading-none tracking-tight",
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||||
|
|
||||||
const DrawerDescription = React.forwardRef<
|
const DrawerDescription = React.forwardRef<
|
||||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||||
@@ -99,8 +99,8 @@ const DrawerDescription = React.forwardRef<
|
|||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -113,4 +113,4 @@ export {
|
|||||||
DrawerFooter,
|
DrawerFooter,
|
||||||
DrawerTitle,
|
DrawerTitle,
|
||||||
DrawerDescription,
|
DrawerDescription,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ interface EditButtonProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
size?: "default" | "sm" | "lg" | "icon";
|
size?: "default" | "sm" | "lg" | "icon";
|
||||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
variant?:
|
||||||
|
| "default"
|
||||||
|
| "destructive"
|
||||||
|
| "outline"
|
||||||
|
| "secondary"
|
||||||
|
| "ghost"
|
||||||
|
| "link";
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +34,7 @@ export function EditButton({
|
|||||||
variant={variant}
|
variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 px-3 text-muted-foreground hover:text-foreground transition-colors",
|
"h-8 px-3 text-muted-foreground hover:text-foreground transition-colors",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const ScrollArea = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative overflow-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100",
|
"relative overflow-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Switch = React.forwardRef<
|
const Switch = React.forwardRef<
|
||||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
@@ -10,18 +10,18 @@ const Switch = React.forwardRef<
|
|||||||
<SwitchPrimitives.Root
|
<SwitchPrimitives.Root
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
<SwitchPrimitives.Thumb
|
<SwitchPrimitives.Thumb
|
||||||
className={cn(
|
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"
|
"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>
|
</SwitchPrimitives.Root>
|
||||||
))
|
));
|
||||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||||
|
|
||||||
export { Switch }
|
export { Switch };
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
|
|||||||
interface PWAUpdate {
|
interface PWAUpdate {
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
updateSW: () => Promise<void>;
|
updateSW: () => Promise<void>;
|
||||||
|
forceReload: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePWA(): PWAUpdate {
|
export function usePWA(): PWAUpdate {
|
||||||
@@ -11,6 +12,35 @@ export function usePWA(): PWAUpdate {
|
|||||||
() => async () => {},
|
() => async () => {},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const forceReload = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// Clear all caches
|
||||||
|
if ("caches" in window) {
|
||||||
|
const cacheNames = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
cacheNames.map((cacheName) => caches.delete(cacheName)),
|
||||||
|
);
|
||||||
|
console.log("All caches cleared");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister service worker
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
|
await Promise.all(
|
||||||
|
registrations.map((registration) => registration.unregister()),
|
||||||
|
);
|
||||||
|
console.log("All service workers unregistered");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force reload
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during force reload:", error);
|
||||||
|
// Fallback: just reload the page
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if SW registration is available
|
// Check if SW registration is available
|
||||||
if ("serviceWorker" in navigator) {
|
if ("serviceWorker" in navigator) {
|
||||||
@@ -37,5 +67,6 @@ export function usePWA(): PWAUpdate {
|
|||||||
return {
|
return {
|
||||||
updateAvailable,
|
updateAvailable,
|
||||||
updateSW,
|
updateSW,
|
||||||
|
forceReload,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
39
frontend/src/hooks/useVersionCheck.ts
Normal file
39
frontend/src/hooks/useVersionCheck.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { apiClient } from "../lib/api";
|
||||||
|
|
||||||
|
const VERSION_STORAGE_KEY = "leggen_app_version";
|
||||||
|
|
||||||
|
export function useVersionCheck(forceReload: () => Promise<void>) {
|
||||||
|
const { data: healthStatus, isSuccess: healthSuccess } = useQuery({
|
||||||
|
queryKey: ["health"],
|
||||||
|
queryFn: apiClient.getHealth,
|
||||||
|
refetchInterval: 30000,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 0, // Always consider data stale to ensure fresh version checks
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (healthSuccess && healthStatus?.version) {
|
||||||
|
const currentVersion = healthStatus.version;
|
||||||
|
const storedVersion = localStorage.getItem(VERSION_STORAGE_KEY);
|
||||||
|
|
||||||
|
if (storedVersion && storedVersion !== currentVersion) {
|
||||||
|
console.log(
|
||||||
|
`Version mismatch detected: stored=${storedVersion}, current=${currentVersion}`,
|
||||||
|
);
|
||||||
|
console.log("Clearing cache and reloading...");
|
||||||
|
|
||||||
|
// Update stored version first
|
||||||
|
localStorage.setItem(VERSION_STORAGE_KEY, currentVersion);
|
||||||
|
|
||||||
|
// Force reload to clear cache
|
||||||
|
forceReload();
|
||||||
|
} else if (!storedVersion) {
|
||||||
|
// First time loading, store the version
|
||||||
|
localStorage.setItem(VERSION_STORAGE_KEY, currentVersion);
|
||||||
|
console.log(`Version stored: ${currentVersion}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [healthSuccess, healthStatus?.version, forceReload]);
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@ import type {
|
|||||||
AccountUpdate,
|
AccountUpdate,
|
||||||
TransactionStats,
|
TransactionStats,
|
||||||
SyncOperationsResponse,
|
SyncOperationsResponse,
|
||||||
|
BankInstitution,
|
||||||
|
BankConnectionStatus,
|
||||||
|
BankRequisition,
|
||||||
|
Country,
|
||||||
} from "../types/api";
|
} from "../types/api";
|
||||||
|
|
||||||
// Use VITE_API_URL for development, relative URLs for production
|
// Use VITE_API_URL for development, relative URLs for production
|
||||||
@@ -168,8 +172,6 @@ export const apiClient = {
|
|||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Analytics endpoints
|
// Analytics endpoints
|
||||||
getTransactionStats: async (days?: number): Promise<TransactionStats> => {
|
getTransactionStats: async (days?: number): Promise<TransactionStats> => {
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
@@ -231,6 +233,47 @@ export const apiClient = {
|
|||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Bank management endpoints
|
||||||
|
getBankInstitutions: async (country: string): Promise<BankInstitution[]> => {
|
||||||
|
const response = await api.get<ApiResponse<BankInstitution[]>>(
|
||||||
|
`/banks/institutions?country=${country}`,
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getBankConnectionsStatus: async (): Promise<BankConnectionStatus[]> => {
|
||||||
|
const response =
|
||||||
|
await api.get<ApiResponse<BankConnectionStatus[]>>("/banks/status");
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createBankConnection: async (
|
||||||
|
institutionId: string,
|
||||||
|
redirectUrl?: string,
|
||||||
|
): Promise<BankRequisition> => {
|
||||||
|
// If no redirect URL provided, construct it from current location
|
||||||
|
const finalRedirectUrl =
|
||||||
|
redirectUrl || `${window.location.origin}/bank-connected`;
|
||||||
|
|
||||||
|
const response = await api.post<ApiResponse<BankRequisition>>(
|
||||||
|
"/banks/connect",
|
||||||
|
{
|
||||||
|
institution_id: institutionId,
|
||||||
|
redirect_url: finalRedirectUrl,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteBankConnection: async (requisitionId: string): Promise<void> => {
|
||||||
|
await api.delete(`/banks/connections/${requisitionId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSupportedCountries: async (): Promise<Country[]> => {
|
||||||
|
const response = await api.get<ApiResponse<Country[]>>("/banks/countries");
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default apiClient;
|
export default apiClient;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Route as TransactionsRouteImport } from './routes/transactions'
|
|||||||
import { Route as SystemRouteImport } from './routes/system'
|
import { Route as SystemRouteImport } from './routes/system'
|
||||||
import { Route as SettingsRouteImport } from './routes/settings'
|
import { Route as SettingsRouteImport } from './routes/settings'
|
||||||
import { Route as NotificationsRouteImport } from './routes/notifications'
|
import { Route as NotificationsRouteImport } from './routes/notifications'
|
||||||
|
import { Route as BankConnectedRouteImport } from './routes/bank-connected'
|
||||||
import { Route as AnalyticsRouteImport } from './routes/analytics'
|
import { Route as AnalyticsRouteImport } from './routes/analytics'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
|
||||||
@@ -36,6 +37,11 @@ const NotificationsRoute = NotificationsRouteImport.update({
|
|||||||
path: '/notifications',
|
path: '/notifications',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const BankConnectedRoute = BankConnectedRouteImport.update({
|
||||||
|
id: '/bank-connected',
|
||||||
|
path: '/bank-connected',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AnalyticsRoute = AnalyticsRouteImport.update({
|
const AnalyticsRoute = AnalyticsRouteImport.update({
|
||||||
id: '/analytics',
|
id: '/analytics',
|
||||||
path: '/analytics',
|
path: '/analytics',
|
||||||
@@ -50,6 +56,7 @@ const IndexRoute = IndexRouteImport.update({
|
|||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/analytics': typeof AnalyticsRoute
|
'/analytics': typeof AnalyticsRoute
|
||||||
|
'/bank-connected': typeof BankConnectedRoute
|
||||||
'/notifications': typeof NotificationsRoute
|
'/notifications': typeof NotificationsRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
'/system': typeof SystemRoute
|
'/system': typeof SystemRoute
|
||||||
@@ -58,6 +65,7 @@ export interface FileRoutesByFullPath {
|
|||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/analytics': typeof AnalyticsRoute
|
'/analytics': typeof AnalyticsRoute
|
||||||
|
'/bank-connected': typeof BankConnectedRoute
|
||||||
'/notifications': typeof NotificationsRoute
|
'/notifications': typeof NotificationsRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
'/system': typeof SystemRoute
|
'/system': typeof SystemRoute
|
||||||
@@ -67,6 +75,7 @@ export interface FileRoutesById {
|
|||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/analytics': typeof AnalyticsRoute
|
'/analytics': typeof AnalyticsRoute
|
||||||
|
'/bank-connected': typeof BankConnectedRoute
|
||||||
'/notifications': typeof NotificationsRoute
|
'/notifications': typeof NotificationsRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
'/system': typeof SystemRoute
|
'/system': typeof SystemRoute
|
||||||
@@ -77,6 +86,7 @@ export interface FileRouteTypes {
|
|||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
| '/analytics'
|
| '/analytics'
|
||||||
|
| '/bank-connected'
|
||||||
| '/notifications'
|
| '/notifications'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/system'
|
| '/system'
|
||||||
@@ -85,6 +95,7 @@ export interface FileRouteTypes {
|
|||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
| '/analytics'
|
| '/analytics'
|
||||||
|
| '/bank-connected'
|
||||||
| '/notifications'
|
| '/notifications'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/system'
|
| '/system'
|
||||||
@@ -93,6 +104,7 @@ export interface FileRouteTypes {
|
|||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/analytics'
|
| '/analytics'
|
||||||
|
| '/bank-connected'
|
||||||
| '/notifications'
|
| '/notifications'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/system'
|
| '/system'
|
||||||
@@ -102,6 +114,7 @@ export interface FileRouteTypes {
|
|||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AnalyticsRoute: typeof AnalyticsRoute
|
AnalyticsRoute: typeof AnalyticsRoute
|
||||||
|
BankConnectedRoute: typeof BankConnectedRoute
|
||||||
NotificationsRoute: typeof NotificationsRoute
|
NotificationsRoute: typeof NotificationsRoute
|
||||||
SettingsRoute: typeof SettingsRoute
|
SettingsRoute: typeof SettingsRoute
|
||||||
SystemRoute: typeof SystemRoute
|
SystemRoute: typeof SystemRoute
|
||||||
@@ -138,6 +151,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof NotificationsRouteImport
|
preLoaderRoute: typeof NotificationsRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/bank-connected': {
|
||||||
|
id: '/bank-connected'
|
||||||
|
path: '/bank-connected'
|
||||||
|
fullPath: '/bank-connected'
|
||||||
|
preLoaderRoute: typeof BankConnectedRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/analytics': {
|
'/analytics': {
|
||||||
id: '/analytics'
|
id: '/analytics'
|
||||||
path: '/analytics'
|
path: '/analytics'
|
||||||
@@ -158,6 +178,7 @@ declare module '@tanstack/react-router' {
|
|||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AnalyticsRoute: AnalyticsRoute,
|
AnalyticsRoute: AnalyticsRoute,
|
||||||
|
BankConnectedRoute: BankConnectedRoute,
|
||||||
NotificationsRoute: NotificationsRoute,
|
NotificationsRoute: NotificationsRoute,
|
||||||
SettingsRoute: SettingsRoute,
|
SettingsRoute: SettingsRoute,
|
||||||
SystemRoute: SystemRoute,
|
SystemRoute: SystemRoute,
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import { AppSidebar } from "../components/AppSidebar";
|
|||||||
import { SiteHeader } from "../components/SiteHeader";
|
import { SiteHeader } from "../components/SiteHeader";
|
||||||
import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
|
import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
|
||||||
import { usePWA } from "../hooks/usePWA";
|
import { usePWA } from "../hooks/usePWA";
|
||||||
|
import { useVersionCheck } from "../hooks/useVersionCheck";
|
||||||
import { SidebarInset, SidebarProvider } from "../components/ui/sidebar";
|
import { SidebarInset, SidebarProvider } from "../components/ui/sidebar";
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
const { updateAvailable, updateSW } = usePWA();
|
const { updateAvailable, updateSW, forceReload } = usePWA();
|
||||||
|
|
||||||
|
// Check for version mismatches and force reload if needed
|
||||||
|
useVersionCheck(forceReload);
|
||||||
|
|
||||||
const handlePWAInstall = () => {
|
const handlePWAInstall = () => {
|
||||||
console.log("PWA installed successfully");
|
console.log("PWA installed successfully");
|
||||||
|
|||||||
57
frontend/src/routes/bank-connected.tsx
Normal file
57
frontend/src/routes/bank-connected.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { createFileRoute, useSearch } from "@tanstack/react-router";
|
||||||
|
import { CheckCircle, ArrowLeft } from "lucide-react";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../components/ui/card";
|
||||||
|
|
||||||
|
function BankConnected() {
|
||||||
|
const search = useSearch({ from: "/bank-connected" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center pb-2">
|
||||||
|
<div className="mx-auto mb-4">
|
||||||
|
<CheckCircle className="h-16 w-16 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">Account Connected!</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-center space-y-4">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Your bank account has been successfully connected to Leggen. We'll
|
||||||
|
start syncing your transactions shortly.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{search?.bank && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Connected to: <strong>{search.bank}</strong>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => (window.location.href = "/settings")}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Go to Settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/bank-connected")({
|
||||||
|
component: BankConnected,
|
||||||
|
validateSearch: (search: Record<string, unknown>) => {
|
||||||
|
return {
|
||||||
|
bank: (search.bank as string) || undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -242,3 +242,38 @@ export interface SyncOperationsResponse {
|
|||||||
operations: SyncOperation[];
|
operations: SyncOperation[];
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bank-related types
|
||||||
|
export interface BankInstitution {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
bic?: string;
|
||||||
|
transaction_total_days: number;
|
||||||
|
countries: string[];
|
||||||
|
logo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankRequisition {
|
||||||
|
id: string;
|
||||||
|
institution_id: string;
|
||||||
|
status: string;
|
||||||
|
status_display?: string;
|
||||||
|
created: string;
|
||||||
|
link: string;
|
||||||
|
accounts: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankConnectionStatus {
|
||||||
|
bank_id: string;
|
||||||
|
bank_name: string;
|
||||||
|
status: string;
|
||||||
|
status_display: string;
|
||||||
|
created_at: string;
|
||||||
|
requisition_id: string;
|
||||||
|
accounts_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Country {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
|
import { tanstackRouter } from "@tanstack/router-vite-plugin";
|
||||||
import { VitePWA } from "vite-plugin-pwa";
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
TanStackRouterVite(),
|
tanstackRouter(),
|
||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: "autoUpdate",
|
registerType: "autoUpdate",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class SyncOperation(BaseModel):
|
|||||||
duration_seconds: Optional[float] = None
|
duration_seconds: Optional[float] = None
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
logs: list[str] = []
|
logs: list[str] = []
|
||||||
trigger_type: str = "manual" # manual, scheduled, api
|
trigger_type: str = "manual" # manual, scheduled, retry, api
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
json_encoders = {datetime: lambda v: v.isoformat() if v else None}
|
json_encoders = {datetime: lambda v: v.isoformat() if v else None}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -22,7 +23,11 @@ async def get_bank_institutions(
|
|||||||
"""Get available bank institutions for a country"""
|
"""Get available bank institutions for a country"""
|
||||||
try:
|
try:
|
||||||
institutions_response = await gocardless_service.get_institutions(country)
|
institutions_response = await gocardless_service.get_institutions(country)
|
||||||
institutions_data = institutions_response.get("results", [])
|
# Handle both list and dict responses
|
||||||
|
if isinstance(institutions_response, list):
|
||||||
|
institutions_data = institutions_response
|
||||||
|
else:
|
||||||
|
institutions_data = institutions_response.get("results", [])
|
||||||
|
|
||||||
institutions = [
|
institutions = [
|
||||||
BankInstitution(
|
BankInstitution(
|
||||||
@@ -122,13 +127,36 @@ async def get_bank_connections_status() -> APIResponse:
|
|||||||
async def delete_bank_connection(requisition_id: str) -> APIResponse:
|
async def delete_bank_connection(requisition_id: str) -> APIResponse:
|
||||||
"""Delete a bank connection"""
|
"""Delete a bank connection"""
|
||||||
try:
|
try:
|
||||||
# This would need to be implemented in GoCardlessService
|
# Delete the requisition from GoCardless
|
||||||
# For now, return success
|
result = await gocardless_service.delete_requisition(requisition_id)
|
||||||
|
|
||||||
|
# GoCardless returns different responses for successful deletes
|
||||||
|
# We should check if the operation was actually successful
|
||||||
|
logger.info(f"GoCardless delete response for {requisition_id}: {result}")
|
||||||
|
|
||||||
return APIResponse(
|
return APIResponse(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"Bank connection {requisition_id} deleted successfully",
|
message=f"Bank connection {requisition_id} deleted successfully",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as http_err:
|
||||||
|
logger.error(
|
||||||
|
f"HTTP error deleting bank connection {requisition_id}: {http_err}"
|
||||||
|
)
|
||||||
|
if http_err.response.status_code == 404:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"Bank connection {requisition_id} not found"
|
||||||
|
) from http_err
|
||||||
|
elif http_err.response.status_code == 400:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid request to delete connection {requisition_id}",
|
||||||
|
) from http_err
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=http_err.response.status_code,
|
||||||
|
detail=f"GoCardless API error: {http_err}",
|
||||||
|
) from http_err
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete bank connection {requisition_id}: {e}")
|
logger.error(f"Failed to delete bank connection {requisition_id}: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
from typing import Any, Dict, List, Optional, Union
|
from typing import Any, Dict, List, Optional, Union
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
@@ -13,11 +13,22 @@ class LeggenAPIClient:
|
|||||||
base_url: str
|
base_url: str
|
||||||
|
|
||||||
def __init__(self, base_url: Optional[str] = None):
|
def __init__(self, base_url: Optional[str] = None):
|
||||||
self.base_url = (
|
raw_url = (
|
||||||
base_url
|
base_url
|
||||||
or os.environ.get("LEGGEN_API_URL", "http://localhost:8000")
|
or os.environ.get("LEGGEN_API_URL", "http://localhost:8000")
|
||||||
or "http://localhost:8000"
|
or "http://localhost:8000"
|
||||||
)
|
)
|
||||||
|
# Ensure base_url includes /api/v1 path if not already present
|
||||||
|
parsed = urlparse(raw_url)
|
||||||
|
if not parsed.path or parsed.path == "/":
|
||||||
|
# No path or just root, add /api/v1
|
||||||
|
self.base_url = f"{raw_url.rstrip('/')}/api/v1"
|
||||||
|
elif not parsed.path.startswith("/api/v1"):
|
||||||
|
# Has a path but not /api/v1, add it
|
||||||
|
self.base_url = f"{raw_url.rstrip('/')}/api/v1"
|
||||||
|
else:
|
||||||
|
# Already has /api/v1 path
|
||||||
|
self.base_url = raw_url.rstrip("/")
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self.session.headers.update(
|
self.session.headers.update(
|
||||||
{"Content-Type": "application/json", "Accept": "application/json"}
|
{"Content-Type": "application/json", "Accept": "application/json"}
|
||||||
@@ -25,7 +36,14 @@ class LeggenAPIClient:
|
|||||||
|
|
||||||
def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
|
def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
|
||||||
"""Make HTTP request to the API"""
|
"""Make HTTP request to the API"""
|
||||||
url = urljoin(self.base_url, endpoint)
|
# Construct URL by joining base_url with endpoint
|
||||||
|
# Handle both relative endpoints (starting with /) and paths
|
||||||
|
if endpoint.startswith("/"):
|
||||||
|
# Absolute endpoint path - append to base_url
|
||||||
|
url = f"{self.base_url}{endpoint}"
|
||||||
|
else:
|
||||||
|
# Relative endpoint, use urljoin
|
||||||
|
url = urljoin(f"{self.base_url}/", endpoint)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = self.session.request(method, url, **kwargs)
|
response = self.session.request(method, url, **kwargs)
|
||||||
@@ -52,7 +70,9 @@ class LeggenAPIClient:
|
|||||||
"""Check if the leggen server is healthy"""
|
"""Check if the leggen server is healthy"""
|
||||||
try:
|
try:
|
||||||
response = self._make_request("GET", "/health")
|
response = self._make_request("GET", "/health")
|
||||||
return response.get("status") == "healthy"
|
# The API now returns nested data structure
|
||||||
|
data = response.get("data", {})
|
||||||
|
return data.get("status") == "healthy"
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -60,7 +80,7 @@ class LeggenAPIClient:
|
|||||||
def get_institutions(self, country: str = "PT") -> List[Dict[str, Any]]:
|
def get_institutions(self, country: str = "PT") -> List[Dict[str, Any]]:
|
||||||
"""Get bank institutions for a country"""
|
"""Get bank institutions for a country"""
|
||||||
response = self._make_request(
|
response = self._make_request(
|
||||||
"GET", "/api/v1/banks/institutions", params={"country": country}
|
"GET", "/banks/institutions", params={"country": country}
|
||||||
)
|
)
|
||||||
return response.get("data", [])
|
return response.get("data", [])
|
||||||
|
|
||||||
@@ -70,35 +90,35 @@ class LeggenAPIClient:
|
|||||||
"""Connect to a bank"""
|
"""Connect to a bank"""
|
||||||
response = self._make_request(
|
response = self._make_request(
|
||||||
"POST",
|
"POST",
|
||||||
"/api/v1/banks/connect",
|
"/banks/connect",
|
||||||
json={"institution_id": institution_id, "redirect_url": redirect_url},
|
json={"institution_id": institution_id, "redirect_url": redirect_url},
|
||||||
)
|
)
|
||||||
return response.get("data", {})
|
return response.get("data", {})
|
||||||
|
|
||||||
def get_bank_status(self) -> List[Dict[str, Any]]:
|
def get_bank_status(self) -> List[Dict[str, Any]]:
|
||||||
"""Get bank connection status"""
|
"""Get bank connection status"""
|
||||||
response = self._make_request("GET", "/api/v1/banks/status")
|
response = self._make_request("GET", "/banks/status")
|
||||||
return response.get("data", [])
|
return response.get("data", [])
|
||||||
|
|
||||||
def get_supported_countries(self) -> List[Dict[str, Any]]:
|
def get_supported_countries(self) -> List[Dict[str, Any]]:
|
||||||
"""Get supported countries"""
|
"""Get supported countries"""
|
||||||
response = self._make_request("GET", "/api/v1/banks/countries")
|
response = self._make_request("GET", "/banks/countries")
|
||||||
return response.get("data", [])
|
return response.get("data", [])
|
||||||
|
|
||||||
# Account endpoints
|
# Account endpoints
|
||||||
def get_accounts(self) -> List[Dict[str, Any]]:
|
def get_accounts(self) -> List[Dict[str, Any]]:
|
||||||
"""Get all accounts"""
|
"""Get all accounts"""
|
||||||
response = self._make_request("GET", "/api/v1/accounts")
|
response = self._make_request("GET", "/accounts")
|
||||||
return response.get("data", [])
|
return response.get("data", [])
|
||||||
|
|
||||||
def get_account_details(self, account_id: str) -> Dict[str, Any]:
|
def get_account_details(self, account_id: str) -> Dict[str, Any]:
|
||||||
"""Get account details"""
|
"""Get account details"""
|
||||||
response = self._make_request("GET", f"/api/v1/accounts/{account_id}")
|
response = self._make_request("GET", f"/accounts/{account_id}")
|
||||||
return response.get("data", {})
|
return response.get("data", {})
|
||||||
|
|
||||||
def get_account_balances(self, account_id: str) -> List[Dict[str, Any]]:
|
def get_account_balances(self, account_id: str) -> List[Dict[str, Any]]:
|
||||||
"""Get account balances"""
|
"""Get account balances"""
|
||||||
response = self._make_request("GET", f"/api/v1/accounts/{account_id}/balances")
|
response = self._make_request("GET", f"/accounts/{account_id}/balances")
|
||||||
return response.get("data", [])
|
return response.get("data", [])
|
||||||
|
|
||||||
def get_account_transactions(
|
def get_account_transactions(
|
||||||
@@ -107,7 +127,7 @@ class LeggenAPIClient:
|
|||||||
"""Get account transactions"""
|
"""Get account transactions"""
|
||||||
response = self._make_request(
|
response = self._make_request(
|
||||||
"GET",
|
"GET",
|
||||||
f"/api/v1/accounts/{account_id}/transactions",
|
f"/accounts/{account_id}/transactions",
|
||||||
params={"limit": limit, "summary_only": summary_only},
|
params={"limit": limit, "summary_only": summary_only},
|
||||||
)
|
)
|
||||||
return response.get("data", [])
|
return response.get("data", [])
|
||||||
@@ -120,7 +140,7 @@ class LeggenAPIClient:
|
|||||||
params = {"limit": limit, "summary_only": summary_only}
|
params = {"limit": limit, "summary_only": summary_only}
|
||||||
params.update(filters)
|
params.update(filters)
|
||||||
|
|
||||||
response = self._make_request("GET", "/api/v1/transactions", params=params)
|
response = self._make_request("GET", "/transactions", params=params)
|
||||||
return response.get("data", [])
|
return response.get("data", [])
|
||||||
|
|
||||||
def get_transaction_stats(
|
def get_transaction_stats(
|
||||||
@@ -131,15 +151,13 @@ class LeggenAPIClient:
|
|||||||
if account_id:
|
if account_id:
|
||||||
params["account_id"] = account_id
|
params["account_id"] = account_id
|
||||||
|
|
||||||
response = self._make_request(
|
response = self._make_request("GET", "/transactions/stats", params=params)
|
||||||
"GET", "/api/v1/transactions/stats", params=params
|
|
||||||
)
|
|
||||||
return response.get("data", {})
|
return response.get("data", {})
|
||||||
|
|
||||||
# Sync endpoints
|
# Sync endpoints
|
||||||
def get_sync_status(self) -> Dict[str, Any]:
|
def get_sync_status(self) -> Dict[str, Any]:
|
||||||
"""Get sync status"""
|
"""Get sync status"""
|
||||||
response = self._make_request("GET", "/api/v1/sync/status")
|
response = self._make_request("GET", "/sync/status")
|
||||||
return response.get("data", {})
|
return response.get("data", {})
|
||||||
|
|
||||||
def trigger_sync(
|
def trigger_sync(
|
||||||
@@ -150,7 +168,7 @@ class LeggenAPIClient:
|
|||||||
if account_ids:
|
if account_ids:
|
||||||
data["account_ids"] = account_ids
|
data["account_ids"] = account_ids
|
||||||
|
|
||||||
response = self._make_request("POST", "/api/v1/sync", json=data)
|
response = self._make_request("POST", "/sync", json=data)
|
||||||
return response.get("data", {})
|
return response.get("data", {})
|
||||||
|
|
||||||
def sync_now(
|
def sync_now(
|
||||||
@@ -161,12 +179,12 @@ class LeggenAPIClient:
|
|||||||
if account_ids:
|
if account_ids:
|
||||||
data["account_ids"] = account_ids
|
data["account_ids"] = account_ids
|
||||||
|
|
||||||
response = self._make_request("POST", "/api/v1/sync/now", json=data)
|
response = self._make_request("POST", "/sync/now", json=data)
|
||||||
return response.get("data", {})
|
return response.get("data", {})
|
||||||
|
|
||||||
def get_scheduler_config(self) -> Dict[str, Any]:
|
def get_scheduler_config(self) -> Dict[str, Any]:
|
||||||
"""Get scheduler configuration"""
|
"""Get scheduler configuration"""
|
||||||
response = self._make_request("GET", "/api/v1/sync/scheduler")
|
response = self._make_request("GET", "/sync/scheduler")
|
||||||
return response.get("data", {})
|
return response.get("data", {})
|
||||||
|
|
||||||
def update_scheduler_config(
|
def update_scheduler_config(
|
||||||
@@ -185,5 +203,5 @@ class LeggenAPIClient:
|
|||||||
if cron:
|
if cron:
|
||||||
data["cron"] = cron
|
data["cron"] = cron
|
||||||
|
|
||||||
response = self._make_request("PUT", "/api/v1/sync/scheduler", json=data)
|
response = self._make_request("PUT", "/sync/scheduler", json=data)
|
||||||
return response.get("data", {})
|
return response.get("data", {})
|
||||||
|
|||||||
@@ -102,12 +102,14 @@ class BackgroundScheduler:
|
|||||||
async def _run_sync(self, retry_count: int = 0):
|
async def _run_sync(self, retry_count: int = 0):
|
||||||
"""Run sync with enhanced error handling and retry logic"""
|
"""Run sync with enhanced error handling and retry logic"""
|
||||||
try:
|
try:
|
||||||
logger.info("Starting scheduled sync job")
|
trigger_type = "retry" if retry_count > 0 else "scheduled"
|
||||||
await self.sync_service.sync_all_accounts()
|
logger.info(f"Starting {trigger_type} sync job")
|
||||||
logger.info("Scheduled sync job completed successfully")
|
await self.sync_service.sync_all_accounts(trigger_type=trigger_type)
|
||||||
|
logger.info(f"{trigger_type.capitalize()} sync job completed successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
trigger_type = "retry" if retry_count > 0 else "scheduled"
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Scheduled sync job failed (attempt {retry_count + 1}/{self.max_retries}): {e}"
|
f"{trigger_type.capitalize()} sync job failed (attempt {retry_count + 1}/{self.max_retries}): {e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Send notification about the failure
|
# Send notification about the failure
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
|
from leggen.api_client import LeggenAPIClient
|
||||||
from leggen.main import cli
|
from leggen.main import cli
|
||||||
from leggen.utils.text import info, success
|
from leggen.utils.text import info, success
|
||||||
|
|
||||||
@@ -15,12 +16,11 @@ def delete(ctx, requisition_id: str):
|
|||||||
|
|
||||||
Check `leggen status` to get the REQUISITION_ID
|
Check `leggen status` to get the REQUISITION_ID
|
||||||
"""
|
"""
|
||||||
import requests
|
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||||
|
|
||||||
info(f"Deleting Bank Requisition: {requisition_id}")
|
info(f"Deleting Bank Requisition: {requisition_id}")
|
||||||
|
|
||||||
api_url = ctx.obj.get("api_url", "http://localhost:8000")
|
# Use API client to make the delete request
|
||||||
res = requests.delete(f"{api_url}/requisitions/{requisition_id}")
|
api_client._make_request("DELETE", f"/requisitions/{requisition_id}")
|
||||||
res.raise_for_status()
|
|
||||||
|
|
||||||
success(f"Bank Requisition {requisition_id} deleted")
|
success(f"Bank Requisition {requisition_id} deleted")
|
||||||
|
|||||||
@@ -144,6 +144,12 @@ class GoCardlessService:
|
|||||||
"GET", f"{self.base_url}/requisitions/"
|
"GET", f"{self.base_url}/requisitions/"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def delete_requisition(self, requisition_id: str) -> Dict[str, Any]:
|
||||||
|
"""Delete a requisition"""
|
||||||
|
return await self._make_authenticated_request(
|
||||||
|
"DELETE", f"{self.base_url}/requisitions/{requisition_id}/"
|
||||||
|
)
|
||||||
|
|
||||||
async def get_account_details(self, account_id: str) -> Dict[str, Any]:
|
async def get_account_details(self, account_id: str) -> Dict[str, Any]:
|
||||||
"""Get account details"""
|
"""Get account details"""
|
||||||
return await self._make_authenticated_request(
|
return await self._make_authenticated_request(
|
||||||
|
|||||||
@@ -39,13 +39,11 @@ class Config:
|
|||||||
try:
|
try:
|
||||||
with open(config_path, "rb") as f:
|
with open(config_path, "rb") as f:
|
||||||
raw_config = tomllib.load(f)
|
raw_config = tomllib.load(f)
|
||||||
logger.info(f"Configuration loaded from {config_path}")
|
|
||||||
|
|
||||||
# Validate configuration using Pydantic
|
# Validate configuration using Pydantic
|
||||||
try:
|
try:
|
||||||
self._config_model = ConfigModel(**raw_config)
|
self._config_model = ConfigModel(**raw_config)
|
||||||
self._config = self._config_model.dict(by_alias=True, exclude_none=True)
|
self._config = self._config_model.dict(by_alias=True, exclude_none=True)
|
||||||
logger.info("Configuration validation successful")
|
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
logger.error(f"Configuration validation failed: {e}")
|
logger.error(f"Configuration validation failed: {e}")
|
||||||
raise ValueError(f"Invalid configuration: {e}") from e
|
raise ValueError(f"Invalid configuration: {e}") from e
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "leggen"
|
name = "leggen"
|
||||||
version = "2025.9.22"
|
version = "2025.9.24"
|
||||||
description = "An Open Banking CLI"
|
description = "An Open Banking CLI"
|
||||||
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
||||||
requires-python = "~=3.13.0"
|
requires-python = "~=3.13.0"
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ class TestLeggenAPIClient:
|
|||||||
client = LeggenAPIClient("http://localhost:8000")
|
client = LeggenAPIClient("http://localhost:8000")
|
||||||
|
|
||||||
with requests_mock.Mocker() as m:
|
with requests_mock.Mocker() as m:
|
||||||
m.get("http://localhost:8000/health", json={"status": "healthy"})
|
m.get(
|
||||||
|
"http://localhost:8000/api/v1/health",
|
||||||
|
json={"data": {"status": "healthy"}},
|
||||||
|
)
|
||||||
|
|
||||||
result = client.health_check()
|
result = client.health_check()
|
||||||
assert result is True
|
assert result is True
|
||||||
@@ -112,13 +115,13 @@ class TestLeggenAPIClient:
|
|||||||
custom_url = "http://custom-host:9000"
|
custom_url = "http://custom-host:9000"
|
||||||
client = LeggenAPIClient(custom_url)
|
client = LeggenAPIClient(custom_url)
|
||||||
|
|
||||||
assert client.base_url == custom_url
|
assert client.base_url == f"{custom_url}/api/v1"
|
||||||
|
|
||||||
def test_environment_variable_url(self):
|
def test_environment_variable_url(self):
|
||||||
"""Test using environment variable for API URL."""
|
"""Test using environment variable for API URL."""
|
||||||
with patch.dict("os.environ", {"LEGGEN_API_URL": "http://env-host:7000"}):
|
with patch.dict("os.environ", {"LEGGEN_API_URL": "http://env-host:7000"}):
|
||||||
client = LeggenAPIClient()
|
client = LeggenAPIClient()
|
||||||
assert client.base_url == "http://env-host:7000"
|
assert client.base_url == "http://env-host:7000/api/v1"
|
||||||
|
|
||||||
def test_sync_with_options(self):
|
def test_sync_with_options(self):
|
||||||
"""Test sync with various options."""
|
"""Test sync with various options."""
|
||||||
|
|||||||
2
uv.lock
generated
2
uv.lock
generated
@@ -220,7 +220,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "leggen"
|
name = "leggen"
|
||||||
version = "2025.9.22"
|
version = "2025.9.24"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "apscheduler" },
|
{ name = "apscheduler" },
|
||||||
|
|||||||
Reference in New Issue
Block a user