mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 23:12:16 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
571072f6ac | ||
|
|
be4f7f8cec | ||
|
|
056c33b9c5 | ||
|
|
02c4f5c6ef | ||
|
|
30d7c2ed4e | ||
|
|
61442a598f |
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@@ -10,46 +10,48 @@ jobs:
|
||||
test-python:
|
||||
name: Test Python
|
||||
runs-on: ubuntu-latest
|
||||
if: "!contains(github.event.head_commit.message, 'chore(ci): Bump version to')"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
|
||||
|
||||
- name: Create config directory for tests
|
||||
run: |
|
||||
mkdir -p ~/.config/leggen
|
||||
cp config.example.toml ~/.config/leggen/config.toml
|
||||
|
||||
|
||||
- name: Run Python tests
|
||||
run: uv run pytest
|
||||
|
||||
test-frontend:
|
||||
name: Test Frontend
|
||||
runs-on: ubuntu-latest
|
||||
if: "!contains(github.event.head_commit.message, 'chore(ci): Bump version to')"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
|
||||
- name: Run lint
|
||||
run: npm run lint
|
||||
|
||||
|
||||
- name: Run build
|
||||
run: npm run build
|
||||
run: npm run build
|
||||
|
||||
23
AGENTS.md
23
AGENTS.md
@@ -85,6 +85,29 @@ The command outputs instructions for setting the required environment variable t
|
||||
- **Data fetching**: @tanstack/react-query with proper error handling
|
||||
- **Components**: Functional components with hooks, proper TypeScript typing
|
||||
|
||||
## Frontend Structure
|
||||
|
||||
### Layout Architecture
|
||||
- **Root Layout**: `frontend/src/routes/__root.tsx` - Contains main app structure with Sidebar and Header
|
||||
- **Header/Navbar**: `frontend/src/components/Header.tsx` - Top navigation bar (sticky on mobile only)
|
||||
- **Sidebar**: `frontend/src/components/Sidebar.tsx` - Left navigation sidebar
|
||||
- **Routes**: `frontend/src/routes/` - TanStack Router file-based routing
|
||||
|
||||
### Key Components Location
|
||||
- **UI Components**: `frontend/src/components/ui/` - Reusable UI primitives
|
||||
- **Feature Components**: `frontend/src/components/` - Main app components
|
||||
- **Pages**: `frontend/src/routes/` - Route components (index.tsx, transactions.tsx, etc.)
|
||||
- **Hooks**: `frontend/src/hooks/` - Custom React hooks
|
||||
- **API**: `frontend/src/lib/api.ts` - API client configuration
|
||||
- **Context**: `frontend/src/contexts/` - React contexts (ThemeContext, etc.)
|
||||
|
||||
### Routing Structure
|
||||
- `/` - Overview/Dashboard (TransactionsTable component)
|
||||
- `/transactions` - Transactions page
|
||||
- `/analytics` - Analytics page
|
||||
- `/notifications` - Notifications page
|
||||
- `/settings` - Settings page
|
||||
|
||||
### General
|
||||
- **Formatting**: ruff for Python, ESLint for TypeScript
|
||||
- **Commits**: Use conventional commits with optional scopes, run pre-commit hooks before pushing
|
||||
|
||||
52
CHANGELOG.md
52
CHANGELOG.md
@@ -1,4 +1,56 @@
|
||||
|
||||
## 2025.9.15 (2025/09/18)
|
||||
|
||||
### Features
|
||||
|
||||
- **frontend:** Add settings page with account management functionality. ([056c33b9](https://github.com/elisiariocouto/leggen/commit/056c33b9c5cfbc2842cc2dd4ca8c4e3959a2be80))
|
||||
|
||||
|
||||
### Refactor
|
||||
|
||||
- **frontend:** Simplify filter bar UI and remove advanced filters popover. ([be4f7f8c](https://github.com/elisiariocouto/leggen/commit/be4f7f8cecfe2564abdf0ce1be08497e5a6d7b68))
|
||||
|
||||
|
||||
|
||||
## 2025.9.15 (2025/09/18)
|
||||
|
||||
### Features
|
||||
|
||||
- **frontend:** Add settings page with account management functionality. ([056c33b9](https://github.com/elisiariocouto/leggen/commit/056c33b9c5cfbc2842cc2dd4ca8c4e3959a2be80))
|
||||
|
||||
|
||||
### Refactor
|
||||
|
||||
- **frontend:** Simplify filter bar UI and remove advanced filters popover. ([be4f7f8c](https://github.com/elisiariocouto/leggen/commit/be4f7f8cecfe2564abdf0ce1be08497e5a6d7b68))
|
||||
|
||||
|
||||
|
||||
## 2025.9.14 (2025/09/18)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **config:** Remove aliases for configuration keys that were disabling telegram notifications in some cases. ([61442a59](https://github.com/elisiariocouto/leggen/commit/61442a598fa7f38c568e3df7e1d924ed85df7491))
|
||||
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- **ci:** Prevent double GitHub Actions runs on new releases. ([30d7c2ed](https://github.com/elisiariocouto/leggen/commit/30d7c2ed4e9aff144837a1f0ed67a8ded0b5d72a))
|
||||
|
||||
|
||||
|
||||
## 2025.9.14 (2025/09/18)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **config:** Remove aliases for configuration keys that were disabling telegram notifications in some cases. ([61442a59](https://github.com/elisiariocouto/leggen/commit/61442a598fa7f38c568e3df7e1d924ed85df7491))
|
||||
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- **ci:** Prevent double GitHub Actions runs on new releases. ([30d7c2ed](https://github.com/elisiariocouto/leggen/commit/30d7c2ed4e9aff144837a1f0ed67a8ded0b5d72a))
|
||||
|
||||
|
||||
|
||||
## 2025.9.13 (2025/09/17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -146,8 +146,8 @@ enabled = true
|
||||
|
||||
# Optional: Transaction filters for notifications
|
||||
[filters]
|
||||
case-insensitive = ["salary", "utility"]
|
||||
case-sensitive = ["SpecificStore"]
|
||||
case_insensitive = ["salary", "utility"]
|
||||
case_sensitive = ["SpecificStore"]
|
||||
```
|
||||
|
||||
## 📖 Usage
|
||||
|
||||
@@ -9,7 +9,7 @@ sqlite = true
|
||||
# Optional: Background sync scheduling
|
||||
[scheduler.sync]
|
||||
enabled = true
|
||||
hour = 3 # 3 AM
|
||||
hour = 3 # 3 AM
|
||||
minute = 0
|
||||
# cron = "0 3 * * *" # Alternative: use cron expression
|
||||
|
||||
@@ -20,11 +20,11 @@ enabled = true
|
||||
|
||||
# Optional: Telegram notifications
|
||||
[notifications.telegram]
|
||||
api-key = "your-bot-token"
|
||||
chat-id = 12345
|
||||
token = "your-bot-token"
|
||||
chat_id = 12345
|
||||
enabled = true
|
||||
|
||||
# Optional: Transaction filters for notifications
|
||||
[filters]
|
||||
case-insensitive = ["salary", "utility"]
|
||||
case-sensitive = ["SpecificStore"]
|
||||
case_insensitive = ["salary", "utility"]
|
||||
case_sensitive = ["SpecificStore"]
|
||||
|
||||
2849
frontend/package-lock.json
generated
2849
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -48,6 +48,7 @@
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"shadcn": "^3.3.1",
|
||||
"sharp": "^0.34.3",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
|
||||
347
frontend/src/components/AccountSettings.tsx
Normal file
347
frontend/src/components/AccountSettings.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CreditCard,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Building2,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Edit2,
|
||||
Check,
|
||||
X,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "../lib/api";
|
||||
import { formatCurrency, formatDate } from "../lib/utils";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "./ui/card";
|
||||
import { Button } from "./ui/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import AccountsSkeleton from "./AccountsSkeleton";
|
||||
import type { Account, Balance } from "../types/api";
|
||||
|
||||
// Helper function to get status indicator color and styles
|
||||
const getStatusIndicator = (status: string) => {
|
||||
const statusLower = status.toLowerCase();
|
||||
|
||||
switch (statusLower) {
|
||||
case "ready":
|
||||
return {
|
||||
color: "bg-green-500",
|
||||
tooltip: "Ready",
|
||||
};
|
||||
case "pending":
|
||||
return {
|
||||
color: "bg-amber-500",
|
||||
tooltip: "Pending",
|
||||
};
|
||||
case "error":
|
||||
case "failed":
|
||||
return {
|
||||
color: "bg-destructive",
|
||||
tooltip: "Error",
|
||||
};
|
||||
case "inactive":
|
||||
return {
|
||||
color: "bg-muted-foreground",
|
||||
tooltip: "Inactive",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: "bg-primary",
|
||||
tooltip: status,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default function AccountSettings() {
|
||||
const {
|
||||
data: accounts,
|
||||
isLoading: accountsLoading,
|
||||
error: accountsError,
|
||||
refetch: refetchAccounts,
|
||||
} = useQuery<Account[]>({
|
||||
queryKey: ["accounts"],
|
||||
queryFn: apiClient.getAccounts,
|
||||
});
|
||||
|
||||
const { data: balances } = useQuery<Balance[]>({
|
||||
queryKey: ["balances"],
|
||||
queryFn: () => apiClient.getBalances(),
|
||||
});
|
||||
|
||||
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateAccountMutation = useMutation({
|
||||
mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
|
||||
apiClient.updateAccount(id, { display_name }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
||||
setEditingAccountId(null);
|
||||
setEditingName("");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to update account:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleEditStart = (account: Account) => {
|
||||
setEditingAccountId(account.id);
|
||||
// Use display_name if available, otherwise fall back to name
|
||||
setEditingName(account.display_name || account.name || "");
|
||||
};
|
||||
|
||||
const handleEditSave = () => {
|
||||
if (editingAccountId && editingName.trim()) {
|
||||
updateAccountMutation.mutate({
|
||||
id: editingAccountId,
|
||||
display_name: editingName.trim(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditCancel = () => {
|
||||
setEditingAccountId(null);
|
||||
setEditingName("");
|
||||
};
|
||||
|
||||
if (accountsLoading) {
|
||||
return <AccountsSkeleton />;
|
||||
}
|
||||
|
||||
if (accountsError) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Failed to load accounts</AlertTitle>
|
||||
<AlertDescription className="space-y-3">
|
||||
<p>
|
||||
Unable to connect to the Leggen API. Please check your configuration
|
||||
and ensure the API server is running.
|
||||
</p>
|
||||
<Button onClick={() => refetchAccounts()} variant="outline" size="sm">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Account Management Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account Management</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your connected bank accounts and customize their display names
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{!accounts || accounts.length === 0 ? (
|
||||
<CardContent className="p-6 text-center">
|
||||
<CreditCard className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
No accounts found
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Connect your first bank account to get started with Leggen.
|
||||
</p>
|
||||
<Button disabled className="flex items-center space-x-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Add Bank Account</span>
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Coming soon: Add new bank connections
|
||||
</p>
|
||||
</CardContent>
|
||||
) : (
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-border">
|
||||
{accounts.map((account) => {
|
||||
// Get balance from account's balances array or fallback to balances query
|
||||
const accountBalance = account.balances?.[0];
|
||||
const fallbackBalance = balances?.find(
|
||||
(b) => b.account_id === account.id,
|
||||
);
|
||||
const balance =
|
||||
accountBalance?.amount ||
|
||||
fallbackBalance?.balance_amount ||
|
||||
0;
|
||||
const currency =
|
||||
accountBalance?.currency ||
|
||||
fallbackBalance?.currency ||
|
||||
account.currency ||
|
||||
"EUR";
|
||||
const isPositive = balance >= 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={account.id}
|
||||
className="p-4 sm:p-6 hover:bg-accent transition-colors"
|
||||
>
|
||||
{/* Mobile layout - stack vertically */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||
<div className="flex-shrink-0 p-2 sm:p-3 bg-muted rounded-full">
|
||||
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingAccountId === account.id ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editingName}
|
||||
onChange={(e) =>
|
||||
setEditingName(e.target.value)
|
||||
}
|
||||
className="flex-1 px-3 py-1 text-base sm:text-lg font-medium border border-input rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||
placeholder="Custom account name"
|
||||
name="search"
|
||||
autoComplete="off"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleEditSave();
|
||||
if (e.key === "Escape") handleEditCancel();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleEditSave}
|
||||
disabled={
|
||||
!editingName.trim() ||
|
||||
updateAccountMutation.isPending
|
||||
}
|
||||
className="p-1 text-green-600 hover:text-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Save changes"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEditCancel}
|
||||
className="p-1 text-gray-600 hover:text-gray-700"
|
||||
title="Cancel editing"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{account.institution_id}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 min-w-0">
|
||||
<h4 className="text-base sm:text-lg font-medium text-foreground truncate">
|
||||
{account.display_name ||
|
||||
account.name ||
|
||||
"Unnamed Account"}
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => handleEditStart(account)}
|
||||
className="flex-shrink-0 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Edit account name"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{account.institution_id}
|
||||
</p>
|
||||
{account.iban && (
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono break-all sm:break-normal">
|
||||
IBAN: {account.iban}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance and date section */}
|
||||
<div className="flex items-center justify-between sm:flex-col sm:items-end sm:text-right flex-shrink-0">
|
||||
{/* Mobile: date/status on left, balance on right */}
|
||||
{/* Desktop: balance on top, date/status on bottom */}
|
||||
|
||||
{/* Date and status indicator - left on mobile, bottom on desktop */}
|
||||
<div className="flex items-center space-x-2 order-1 sm:order-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${getStatusIndicator(account.status).color} relative group cursor-help`}
|
||||
role="img"
|
||||
aria-label={`Account status: ${getStatusIndicator(account.status).tooltip}`}
|
||||
>
|
||||
{/* Tooltip */}
|
||||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 hidden group-hover:block bg-gray-900 text-white text-xs rounded py-1 px-2 whitespace-nowrap z-10">
|
||||
{getStatusIndicator(account.status).tooltip}
|
||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-2 border-transparent border-t-gray-900"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
|
||||
Updated{" "}
|
||||
{formatDate(
|
||||
account.last_accessed || account.created,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Balance - right on mobile, top on desktop */}
|
||||
<div className="flex items-center space-x-2 order-2 sm:order-1">
|
||||
{isPositive ? (
|
||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
<p
|
||||
className={`text-base sm:text-lg font-semibold ${
|
||||
isPositive ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{formatCurrency(balance, currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Add Bank Section (Future Feature) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Add New Bank Account</CardTitle>
|
||||
<CardDescription>
|
||||
Connect additional bank accounts to track all your finances in one place
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<Plus className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bank connection functionality is coming soon. Stay tuned for updates!
|
||||
</p>
|
||||
</div>
|
||||
<Button disabled variant="outline">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Connect Bank Account
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ const navigation = [
|
||||
{ name: "Transactions", to: "/transactions" },
|
||||
{ name: "Analytics", to: "/analytics" },
|
||||
{ name: "Notifications", to: "/notifications" },
|
||||
{ name: "Settings", to: "/settings" },
|
||||
];
|
||||
|
||||
interface HeaderProps {
|
||||
@@ -32,7 +33,7 @@ export default function Header({ setSidebarOpen }: HeaderProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<header className="bg-card shadow-sm border-b border-border">
|
||||
<header className="lg:static sticky top-0 z-50 bg-card shadow-sm border-b border-border">
|
||||
<div className="flex items-center justify-between h-16 px-6">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import { Link, useLocation } from "@tanstack/react-router";
|
||||
import {
|
||||
Home,
|
||||
List,
|
||||
BarChart3,
|
||||
Bell,
|
||||
TrendingUp,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Settings,
|
||||
Building2,
|
||||
} from "lucide-react";
|
||||
import { Logo } from "./ui/logo";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "../lib/api";
|
||||
import { formatCurrency } from "../lib/utils";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useState } from "react";
|
||||
import type { Account } from "../types/api";
|
||||
|
||||
|
||||
const navigation = [
|
||||
{ name: "Overview", icon: Home, to: "/" },
|
||||
{ name: "Transactions", icon: List, to: "/transactions" },
|
||||
{ name: "Overview", icon: List, to: "/" },
|
||||
{ name: "Analytics", icon: BarChart3, to: "/analytics" },
|
||||
{ name: "Notifications", icon: Bell, to: "/notifications" },
|
||||
{ name: "Settings", icon: Settings, to: "/settings" },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -28,6 +33,7 @@ interface SidebarProps {
|
||||
|
||||
export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
|
||||
const location = useLocation();
|
||||
const [accountsExpanded, setAccountsExpanded] = useState(false);
|
||||
|
||||
const { data: accounts } = useQuery<Account[]>({
|
||||
queryKey: ["accounts"],
|
||||
@@ -85,21 +91,68 @@ export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Account Summary in Sidebar */}
|
||||
<div className="px-6 py-4 border-t border-border mt-auto">
|
||||
<div className="bg-muted rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Total Balance
|
||||
</span>
|
||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||
{/* Collapsible Account Summary in Sidebar */}
|
||||
<div className="px-6 pt-4 pb-6 border-t border-border mt-auto">
|
||||
<div className="bg-muted rounded-lg">
|
||||
{/* Collapsible Header */}
|
||||
<button
|
||||
onClick={() => setAccountsExpanded(!accountsExpanded)}
|
||||
className="w-full p-4 flex items-center justify-between hover:bg-muted/80 transition-colors rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Total Balance
|
||||
</span>
|
||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
{accountsExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="px-4 pb-2">
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{formatCurrency(totalBalance)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{accounts?.length || 0} accounts
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">
|
||||
{formatCurrency(totalBalance)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{accounts?.length || 0} accounts
|
||||
</p>
|
||||
|
||||
{/* Expanded Account Details */}
|
||||
{accountsExpanded && accounts && accounts.length > 0 && (
|
||||
<div className="border-t border-border/50 max-h-64 overflow-y-auto">
|
||||
{accounts.map((account) => {
|
||||
const primaryBalance = account.balances?.[0]?.amount || 0;
|
||||
const currency = account.balances?.[0]?.currency || account.currency || "EUR";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={account.id}
|
||||
className="p-3 border-b border-border/30 last:border-b-0 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start space-x-2">
|
||||
<div className="flex-shrink-0 p-1 bg-background rounded">
|
||||
<Building2 className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1 min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{account.display_name || account.name || "Unnamed Account"}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{formatCurrency(primaryBalance, currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ import FiltersSkeleton from "./FiltersSkeleton";
|
||||
import RawTransactionModal from "./RawTransactionModal";
|
||||
import { FilterBar, type FilterState } from "./filters";
|
||||
import { DataTablePagination } from "./ui/data-table-pagination";
|
||||
import { Card, CardContent } from "./ui/card";
|
||||
import { Card } from "./ui/card";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import { Button } from "./ui/button";
|
||||
import type { Account, Transaction, ApiResponse } from "../types/api";
|
||||
@@ -40,8 +40,6 @@ export default function TransactionsTable() {
|
||||
selectedAccount: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
minAmount: "",
|
||||
maxAmount: "",
|
||||
});
|
||||
|
||||
const [showRawModal, setShowRawModal] = useState(false);
|
||||
@@ -73,8 +71,6 @@ export default function TransactionsTable() {
|
||||
selectedAccount: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
minAmount: "",
|
||||
maxAmount: "",
|
||||
});
|
||||
setColumnFilters([]);
|
||||
setCurrentPage(1);
|
||||
@@ -116,8 +112,6 @@ export default function TransactionsTable() {
|
||||
currentPage,
|
||||
perPage,
|
||||
debouncedSearchTerm,
|
||||
filterState.minAmount,
|
||||
filterState.maxAmount,
|
||||
],
|
||||
queryFn: () =>
|
||||
apiClient.getTransactions({
|
||||
@@ -128,12 +122,6 @@ export default function TransactionsTable() {
|
||||
perPage: perPage,
|
||||
search: debouncedSearchTerm || undefined,
|
||||
summaryOnly: false,
|
||||
minAmount: filterState.minAmount
|
||||
? parseFloat(filterState.minAmount)
|
||||
: undefined,
|
||||
maxAmount: filterState.maxAmount
|
||||
? parseFloat(filterState.maxAmount)
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -157,8 +145,6 @@ export default function TransactionsTable() {
|
||||
filterState.selectedAccount,
|
||||
filterState.startDate,
|
||||
filterState.endDate,
|
||||
filterState.minAmount,
|
||||
filterState.maxAmount,
|
||||
]);
|
||||
|
||||
const handleViewRaw = (transaction: Transaction) => {
|
||||
@@ -175,9 +161,7 @@ export default function TransactionsTable() {
|
||||
filterState.searchTerm ||
|
||||
filterState.selectedAccount ||
|
||||
filterState.startDate ||
|
||||
filterState.endDate ||
|
||||
filterState.minAmount ||
|
||||
filterState.maxAmount;
|
||||
filterState.endDate;
|
||||
|
||||
|
||||
// Define columns
|
||||
@@ -372,38 +356,6 @@ export default function TransactionsTable() {
|
||||
isSearchLoading={isSearchLoading}
|
||||
/>
|
||||
|
||||
{/* Results Summary */}
|
||||
<Card>
|
||||
<CardContent className="px-6 py-3 bg-muted/30 border-b border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {transactions.length} transaction
|
||||
{transactions.length !== 1 ? "s" : ""} (
|
||||
{pagination ? (
|
||||
<>
|
||||
{(pagination.page - 1) * pagination.per_page + 1}-
|
||||
{Math.min(
|
||||
pagination.page * pagination.per_page,
|
||||
pagination.total,
|
||||
)}{" "}
|
||||
of {pagination.total}
|
||||
</>
|
||||
) : (
|
||||
"loading..."
|
||||
)}
|
||||
)
|
||||
{filterState.selectedAccount && accounts && (
|
||||
<span className="ml-1">
|
||||
for{" "}
|
||||
{
|
||||
accounts.find((acc) => acc.id === filterState.selectedAccount)
|
||||
?.name
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Responsive Table/Cards */}
|
||||
<Card>
|
||||
{/* Desktop Table View (hidden on mobile) */}
|
||||
|
||||
@@ -51,7 +51,7 @@ export function AccountCombobox({
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="justify-between"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Building2 className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -8,12 +8,14 @@ import type { Account } from "../../types/api";
|
||||
export interface ActiveFilterChipsProps {
|
||||
filterState: FilterState;
|
||||
onFilterChange: (key: keyof FilterState, value: string) => void;
|
||||
onClearFilters: () => void;
|
||||
accounts?: Account[];
|
||||
}
|
||||
|
||||
export function ActiveFilterChips({
|
||||
filterState,
|
||||
onFilterChange,
|
||||
onClearFilters,
|
||||
accounts = [],
|
||||
}: ActiveFilterChipsProps) {
|
||||
const chips: Array<{
|
||||
@@ -68,30 +70,6 @@ export function ActiveFilterChips({
|
||||
});
|
||||
}
|
||||
|
||||
// Amount range chips
|
||||
if (filterState.minAmount || filterState.maxAmount) {
|
||||
let amountLabel = "Amount: ";
|
||||
const minAmount = filterState.minAmount
|
||||
? parseFloat(filterState.minAmount)
|
||||
: null;
|
||||
const maxAmount = filterState.maxAmount
|
||||
? parseFloat(filterState.maxAmount)
|
||||
: null;
|
||||
|
||||
if (minAmount && maxAmount) {
|
||||
amountLabel += `€${minAmount} - €${maxAmount}`;
|
||||
} else if (minAmount) {
|
||||
amountLabel += `≥ €${minAmount}`;
|
||||
} else if (maxAmount) {
|
||||
amountLabel += `≤ €${maxAmount}`;
|
||||
}
|
||||
|
||||
chips.push({
|
||||
key: "minAmount", // We'll clear both min and max when removing this chip
|
||||
label: amountLabel,
|
||||
value: `${filterState.minAmount}-${filterState.maxAmount}`,
|
||||
});
|
||||
}
|
||||
|
||||
const handleRemoveChip = (key: keyof FilterState) => {
|
||||
switch (key) {
|
||||
@@ -100,11 +78,6 @@ export function ActiveFilterChips({
|
||||
onFilterChange("startDate", "");
|
||||
onFilterChange("endDate", "");
|
||||
break;
|
||||
case "minAmount":
|
||||
// Clear both min and max amount
|
||||
onFilterChange("minAmount", "");
|
||||
onFilterChange("maxAmount", "");
|
||||
break;
|
||||
default:
|
||||
onFilterChange(key, "");
|
||||
}
|
||||
@@ -135,6 +108,15 @@ export function ActiveFilterChips({
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
<Button
|
||||
onClick={onClearFilters}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-muted-foreground ml-2"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { MoreHorizontal, Euro } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
export interface AdvancedFiltersPopoverProps {
|
||||
minAmount: string;
|
||||
maxAmount: string;
|
||||
onMinAmountChange: (value: string) => void;
|
||||
onMaxAmountChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function AdvancedFiltersPopover({
|
||||
minAmount,
|
||||
maxAmount,
|
||||
onMinAmountChange,
|
||||
onMaxAmountChange,
|
||||
}: AdvancedFiltersPopoverProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const hasAdvancedFilters = minAmount || maxAmount;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={hasAdvancedFilters ? "default" : "outline"}
|
||||
size="default"
|
||||
className="relative"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4 mr-2" />
|
||||
More
|
||||
{hasAdvancedFilters && (
|
||||
<div className="absolute -top-1 -right-1 h-2 w-2 bg-blue-600 rounded-full" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80" align="end">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium leading-none">Advanced Filters</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Additional filters for more precise results
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
Amount Range
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Minimum
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Euro className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
value={minAmount}
|
||||
onChange={(e) => onMinAmountChange(e.target.value)}
|
||||
className="pl-8"
|
||||
step="0.01"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Maximum
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Euro className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1000.00"
|
||||
value={maxAmount}
|
||||
onChange={(e) => onMaxAmountChange(e.target.value)}
|
||||
className="pl-8"
|
||||
step="0.01"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty for no limit
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Future: Add transaction status filter */}
|
||||
<div className="pt-2 border-t">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
More filters coming soon: transaction status, categories, and
|
||||
more.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear advanced filters */}
|
||||
{hasAdvancedFilters && (
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onMinAmountChange("");
|
||||
onMaxAmountChange("");
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Clear Advanced Filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { DateRange } from "react-day-picker";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -26,33 +27,35 @@ interface DatePreset {
|
||||
|
||||
const datePresets: DatePreset[] = [
|
||||
{
|
||||
label: "Last 7 days",
|
||||
label: "Today",
|
||||
getValue: () => {
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setDate(endDate.getDate() - 7);
|
||||
const today = new Date();
|
||||
return {
|
||||
startDate: startDate.toISOString().split("T")[0],
|
||||
endDate: endDate.toISOString().split("T")[0],
|
||||
startDate: today.toISOString().split("T")[0],
|
||||
endDate: today.toISOString().split("T")[0],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "This week",
|
||||
label: "Yesterday",
|
||||
getValue: () => {
|
||||
const now = new Date();
|
||||
const dayOfWeek = now.getDay();
|
||||
const startOfWeek = new Date(now);
|
||||
startOfWeek.setDate(
|
||||
now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1),
|
||||
); // Monday as start
|
||||
|
||||
const endOfWeek = new Date(startOfWeek);
|
||||
endOfWeek.setDate(startOfWeek.getDate() + 6);
|
||||
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return {
|
||||
startDate: startOfWeek.toISOString().split("T")[0],
|
||||
endDate: endOfWeek.toISOString().split("T")[0],
|
||||
startDate: yesterday.toISOString().split("T")[0],
|
||||
endDate: yesterday.toISOString().split("T")[0],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Last 7 days",
|
||||
getValue: () => {
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setDate(endDate.getDate() - 6);
|
||||
return {
|
||||
startDate: startDate.toISOString().split("T")[0],
|
||||
endDate: endDate.toISOString().split("T")[0],
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -61,7 +64,7 @@ const datePresets: DatePreset[] = [
|
||||
getValue: () => {
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setDate(endDate.getDate() - 30);
|
||||
startDate.setDate(endDate.getDate() - 29);
|
||||
return {
|
||||
startDate: startDate.toISOString().split("T")[0],
|
||||
endDate: endDate.toISOString().split("T")[0],
|
||||
@@ -81,19 +84,6 @@ const datePresets: DatePreset[] = [
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "This year",
|
||||
getValue: () => {
|
||||
const now = new Date();
|
||||
const startOfYear = new Date(now.getFullYear(), 0, 1);
|
||||
const endOfYear = new Date(now.getFullYear(), 11, 31);
|
||||
|
||||
return {
|
||||
startDate: startOfYear.toISOString().split("T")[0],
|
||||
endDate: endOfYear.toISOString().split("T")[0],
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function DateRangePicker({
|
||||
@@ -178,34 +168,30 @@ export function DateRangePicker({
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<div className="flex">
|
||||
{/* Presets */}
|
||||
<div className="border-r p-3 space-y-1">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">
|
||||
Quick select
|
||||
</div>
|
||||
<Card className="w-auto py-4">
|
||||
<CardContent className="px-4">
|
||||
<Calendar
|
||||
mode="range"
|
||||
defaultMonth={dateRange?.from}
|
||||
selected={dateRange}
|
||||
onSelect={handleDateRangeSelect}
|
||||
className="bg-transparent p-0"
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="grid grid-cols-2 gap-1 border-t px-4 !pt-4">
|
||||
{datePresets.map((preset) => (
|
||||
<Button
|
||||
key={preset.label}
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start text-sm"
|
||||
className="text-xs px-2 h-7"
|
||||
onClick={() => handlePresetClick(preset)}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{/* Calendar */}
|
||||
<Calendar
|
||||
initialFocus
|
||||
mode="range"
|
||||
defaultMonth={dateRange?.from}
|
||||
selected={dateRange}
|
||||
onSelect={handleDateRangeSelect}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Search, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DateRangePicker } from "./DateRangePicker";
|
||||
import { AccountCombobox } from "./AccountCombobox";
|
||||
import { ActiveFilterChips } from "./ActiveFilterChips";
|
||||
import { AdvancedFiltersPopover } from "./AdvancedFiltersPopover";
|
||||
import type { Account } from "../../types/api";
|
||||
|
||||
export interface FilterState {
|
||||
@@ -13,8 +11,6 @@ export interface FilterState {
|
||||
selectedAccount: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
minAmount: string;
|
||||
maxAmount: string;
|
||||
}
|
||||
|
||||
export interface FilterBarProps {
|
||||
@@ -38,9 +34,7 @@ export function FilterBar({
|
||||
filterState.searchTerm ||
|
||||
filterState.selectedAccount ||
|
||||
filterState.startDate ||
|
||||
filterState.endDate ||
|
||||
filterState.minAmount ||
|
||||
filterState.maxAmount;
|
||||
filterState.endDate;
|
||||
|
||||
const handleDateRangeChange = (startDate: string, endDate: string) => {
|
||||
onFilterChange("startDate", startDate);
|
||||
@@ -58,61 +52,85 @@ export function FilterBar({
|
||||
</div>
|
||||
|
||||
{/* Primary Filters Row */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
{/* Search Input */}
|
||||
<div className="relative flex-1 min-w-[240px]">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search transactions..."
|
||||
value={filterState.searchTerm}
|
||||
onChange={(e) => onFilterChange("searchTerm", e.target.value)}
|
||||
className="pl-9 pr-8 bg-background"
|
||||
/>
|
||||
{isSearchLoading && (
|
||||
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
<div className="animate-spin h-4 w-4 border-2 border-border border-t-primary rounded-full"></div>
|
||||
<div className="space-y-4 mb-4">
|
||||
{/* Desktop Layout */}
|
||||
<div className="hidden lg:flex items-center justify-between gap-6">
|
||||
{/* Left Side: Main Filters */}
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
{/* Search Input */}
|
||||
<div className="relative w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search transactions..."
|
||||
value={filterState.searchTerm}
|
||||
onChange={(e) => onFilterChange("searchTerm", e.target.value)}
|
||||
className="pl-9 pr-8 bg-background"
|
||||
/>
|
||||
{isSearchLoading && (
|
||||
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
<div className="animate-spin h-4 w-4 border-2 border-border border-t-primary rounded-full"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account Selection */}
|
||||
<AccountCombobox
|
||||
accounts={accounts}
|
||||
selectedAccount={filterState.selectedAccount}
|
||||
onAccountChange={(accountId) =>
|
||||
onFilterChange("selectedAccount", accountId)
|
||||
}
|
||||
className="w-[180px]"
|
||||
/>
|
||||
|
||||
{/* Date Range Picker */}
|
||||
<DateRangePicker
|
||||
startDate={filterState.startDate}
|
||||
endDate={filterState.endDate}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
className="w-[220px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Account Selection */}
|
||||
<AccountCombobox
|
||||
accounts={accounts}
|
||||
selectedAccount={filterState.selectedAccount}
|
||||
onAccountChange={(accountId) =>
|
||||
onFilterChange("selectedAccount", accountId)
|
||||
}
|
||||
className="w-[200px]"
|
||||
/>
|
||||
{/* Mobile Layout */}
|
||||
<div className="lg:hidden space-y-3">
|
||||
{/* First Row: Search Input (Full Width) */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={filterState.searchTerm}
|
||||
onChange={(e) => onFilterChange("searchTerm", e.target.value)}
|
||||
className="pl-9 pr-8 bg-background w-full"
|
||||
/>
|
||||
{isSearchLoading && (
|
||||
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
<div className="animate-spin h-4 w-4 border-2 border-border border-t-primary rounded-full"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date Range Picker */}
|
||||
<DateRangePicker
|
||||
startDate={filterState.startDate}
|
||||
endDate={filterState.endDate}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
className="w-[240px]"
|
||||
/>
|
||||
{/* Second Row: Account Selection (Full Width) */}
|
||||
<AccountCombobox
|
||||
accounts={accounts}
|
||||
selectedAccount={filterState.selectedAccount}
|
||||
onAccountChange={(accountId) =>
|
||||
onFilterChange("selectedAccount", accountId)
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{/* Advanced Filters Button */}
|
||||
<AdvancedFiltersPopover
|
||||
minAmount={filterState.minAmount}
|
||||
maxAmount={filterState.maxAmount}
|
||||
onMinAmountChange={(value) => onFilterChange("minAmount", value)}
|
||||
onMaxAmountChange={(value) => onFilterChange("maxAmount", value)}
|
||||
/>
|
||||
{/* Third Row: Date Range */}
|
||||
<DateRangePicker
|
||||
startDate={filterState.startDate}
|
||||
endDate={filterState.endDate}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{/* Clear Filters Button */}
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
onClick={onClearFilters}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Filter Chips */}
|
||||
@@ -120,6 +138,7 @@ export function FilterBar({
|
||||
<ActiveFilterChips
|
||||
filterState={filterState}
|
||||
onFilterChange={onFilterChange}
|
||||
onClearFilters={onClearFilters}
|
||||
accounts={accounts}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,5 +2,4 @@ export { FilterBar } from "./FilterBar";
|
||||
export { DateRangePicker } from "./DateRangePicker";
|
||||
export { AccountCombobox } from "./AccountCombobox";
|
||||
export { ActiveFilterChips } from "./ActiveFilterChips";
|
||||
export { AdvancedFiltersPopover } from "./AdvancedFiltersPopover";
|
||||
export type { FilterState, FilterBarProps } from "./FilterBar";
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as TransactionsRouteImport } from './routes/transactions'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as NotificationsRouteImport } from './routes/notifications'
|
||||
import { Route as AnalyticsRouteImport } from './routes/analytics'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
@@ -19,6 +20,11 @@ const TransactionsRoute = TransactionsRouteImport.update({
|
||||
path: '/transactions',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SettingsRoute = SettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const NotificationsRoute = NotificationsRouteImport.update({
|
||||
id: '/notifications',
|
||||
path: '/notifications',
|
||||
@@ -39,12 +45,14 @@ export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/analytics': typeof AnalyticsRoute
|
||||
'/notifications': typeof NotificationsRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/transactions': typeof TransactionsRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/analytics': typeof AnalyticsRoute
|
||||
'/notifications': typeof NotificationsRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/transactions': typeof TransactionsRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
@@ -52,20 +60,33 @@ export interface FileRoutesById {
|
||||
'/': typeof IndexRoute
|
||||
'/analytics': typeof AnalyticsRoute
|
||||
'/notifications': typeof NotificationsRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/transactions': typeof TransactionsRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/analytics' | '/notifications' | '/transactions'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/analytics'
|
||||
| '/notifications'
|
||||
| '/settings'
|
||||
| '/transactions'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/analytics' | '/notifications' | '/transactions'
|
||||
id: '__root__' | '/' | '/analytics' | '/notifications' | '/transactions'
|
||||
to: '/' | '/analytics' | '/notifications' | '/settings' | '/transactions'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/analytics'
|
||||
| '/notifications'
|
||||
| '/settings'
|
||||
| '/transactions'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AnalyticsRoute: typeof AnalyticsRoute
|
||||
NotificationsRoute: typeof NotificationsRoute
|
||||
SettingsRoute: typeof SettingsRoute
|
||||
TransactionsRoute: typeof TransactionsRoute
|
||||
}
|
||||
|
||||
@@ -78,6 +99,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof TransactionsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/settings': {
|
||||
id: '/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/settings'
|
||||
preLoaderRoute: typeof SettingsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/notifications': {
|
||||
id: '/notifications'
|
||||
path: '/notifications'
|
||||
@@ -106,6 +134,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AnalyticsRoute: AnalyticsRoute,
|
||||
NotificationsRoute: NotificationsRoute,
|
||||
SettingsRoute: SettingsRoute,
|
||||
TransactionsRoute: TransactionsRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
|
||||
@@ -34,9 +34,9 @@ function RootLayout() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Header setSidebarOpen={setSidebarOpen} />
|
||||
<main className="flex-1 p-6">
|
||||
<main className="flex-1 p-6 min-w-0">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import AccountsOverview from "../components/AccountsOverview";
|
||||
import TransactionsTable from "../components/TransactionsTable";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: AccountsOverview,
|
||||
component: TransactionsTable,
|
||||
validateSearch: (search) => ({
|
||||
accountId: search.accountId as string | undefined,
|
||||
startDate: search.startDate as string | undefined,
|
||||
endDate: search.endDate as string | undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
6
frontend/src/routes/settings.tsx
Normal file
6
frontend/src/routes/settings.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import AccountSettings from "../components/AccountSettings";
|
||||
|
||||
export const Route = createFileRoute("/settings")({
|
||||
component: AccountSettings,
|
||||
});
|
||||
@@ -37,15 +37,15 @@ async def get_notification_settings() -> APIResponse:
|
||||
if discord_config.get("webhook")
|
||||
else None,
|
||||
telegram=TelegramConfig(
|
||||
token="***" if telegram_config.get("api-key") else "",
|
||||
chat_id=telegram_config.get("chat-id", 0),
|
||||
token="***" if telegram_config.get("token") else "",
|
||||
chat_id=telegram_config.get("chat_id", 0),
|
||||
enabled=telegram_config.get("enabled", True),
|
||||
)
|
||||
if telegram_config.get("api-key")
|
||||
if telegram_config.get("token")
|
||||
else None,
|
||||
filters=NotificationFilters(
|
||||
case_insensitive=filters_config.get("case-insensitive", []),
|
||||
case_sensitive=filters_config.get("case-sensitive"),
|
||||
case_insensitive=filters_config.get("case_insensitive", []),
|
||||
case_sensitive=filters_config.get("case_sensitive"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -77,17 +77,17 @@ async def update_notification_settings(settings: NotificationSettings) -> APIRes
|
||||
|
||||
if settings.telegram:
|
||||
notifications_config["telegram"] = {
|
||||
"api-key": settings.telegram.token,
|
||||
"chat-id": settings.telegram.chat_id,
|
||||
"token": settings.telegram.token,
|
||||
"chat_id": settings.telegram.chat_id,
|
||||
"enabled": settings.telegram.enabled,
|
||||
}
|
||||
|
||||
# Update filters config
|
||||
filters_config: Dict[str, Any] = {}
|
||||
if settings.filters.case_insensitive:
|
||||
filters_config["case-insensitive"] = settings.filters.case_insensitive
|
||||
filters_config["case_insensitive"] = settings.filters.case_insensitive
|
||||
if settings.filters.case_sensitive:
|
||||
filters_config["case-sensitive"] = settings.filters.case_sensitive
|
||||
filters_config["case_sensitive"] = settings.filters.case_sensitive
|
||||
|
||||
# Save to config
|
||||
if notifications_config:
|
||||
@@ -153,12 +153,12 @@ async def get_notification_services() -> APIResponse:
|
||||
"telegram": {
|
||||
"name": "Telegram",
|
||||
"enabled": bool(
|
||||
notifications_config.get("telegram", {}).get("api-key")
|
||||
and notifications_config.get("telegram", {}).get("chat-id")
|
||||
notifications_config.get("telegram", {}).get("token")
|
||||
and notifications_config.get("telegram", {}).get("chat_id")
|
||||
),
|
||||
"configured": bool(
|
||||
notifications_config.get("telegram", {}).get("api-key")
|
||||
and notifications_config.get("telegram", {}).get("chat-id")
|
||||
notifications_config.get("telegram", {}).get("token")
|
||||
and notifications_config.get("telegram", {}).get("chat_id")
|
||||
),
|
||||
"active": notifications_config.get("telegram", {}).get("enabled", True),
|
||||
},
|
||||
|
||||
@@ -22,8 +22,8 @@ class DiscordNotificationConfig(BaseModel):
|
||||
|
||||
|
||||
class TelegramNotificationConfig(BaseModel):
|
||||
token: str = Field(..., alias="api-key", description="Telegram bot token")
|
||||
chat_id: int = Field(..., alias="chat-id", description="Telegram chat ID")
|
||||
token: str = Field(..., description="Telegram bot token")
|
||||
chat_id: int = Field(..., description="Telegram chat ID")
|
||||
enabled: bool = Field(default=True, description="Enable Telegram notifications")
|
||||
|
||||
|
||||
@@ -33,12 +33,8 @@ class NotificationConfig(BaseModel):
|
||||
|
||||
|
||||
class FilterConfig(BaseModel):
|
||||
case_insensitive: Optional[List[str]] = Field(
|
||||
default_factory=list, alias="case-insensitive"
|
||||
)
|
||||
case_sensitive: Optional[List[str]] = Field(
|
||||
default_factory=list, alias="case-sensitive"
|
||||
)
|
||||
case_insensitive: Optional[List[str]] = Field(default_factory=list)
|
||||
case_sensitive: Optional[List[str]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SyncScheduleConfig(BaseModel):
|
||||
@@ -60,6 +56,3 @@ class Config(BaseModel):
|
||||
notifications: Optional[NotificationConfig] = None
|
||||
filters: Optional[FilterConfig] = None
|
||||
scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig)
|
||||
|
||||
class Config:
|
||||
validate_by_name = True
|
||||
|
||||
@@ -29,8 +29,8 @@ def escape_markdown(text: str) -> str:
|
||||
|
||||
|
||||
def send_expire_notification(ctx: click.Context, notification: dict):
|
||||
token = ctx.obj["notifications"]["telegram"]["api-key"]
|
||||
chat_id = ctx.obj["notifications"]["telegram"]["chat-id"]
|
||||
token = ctx.obj["notifications"]["telegram"]["token"]
|
||||
chat_id = ctx.obj["notifications"]["telegram"]["chat_id"]
|
||||
bot_url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||
info("Sending expiration notification to Telegram")
|
||||
message = "*💲 [Leggen](https://github.com/elisiariocouto/leggen)*\n"
|
||||
@@ -54,8 +54,8 @@ def send_expire_notification(ctx: click.Context, notification: dict):
|
||||
|
||||
|
||||
def send_transaction_message(ctx: click.Context, transactions: list):
|
||||
token = ctx.obj["notifications"]["telegram"]["api-key"]
|
||||
chat_id = ctx.obj["notifications"]["telegram"]["chat-id"]
|
||||
token = ctx.obj["notifications"]["telegram"]["token"]
|
||||
chat_id = ctx.obj["notifications"]["telegram"]["chat_id"]
|
||||
bot_url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||
info(f"Got {len(transactions)} new transactions, sending message to Telegram")
|
||||
message = "*💲 [Leggen](https://github.com/elisiariocouto/leggen)*\n"
|
||||
|
||||
@@ -63,8 +63,8 @@ class NotificationService:
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Filter transactions based on notification criteria"""
|
||||
matching = []
|
||||
filters_case_insensitive = self.filters_config.get("case-insensitive", [])
|
||||
filters_case_sensitive = self.filters_config.get("case-sensitive", [])
|
||||
filters_case_insensitive = self.filters_config.get("case_insensitive", [])
|
||||
filters_case_sensitive = self.filters_config.get("case_sensitive", [])
|
||||
|
||||
for transaction in transactions:
|
||||
description = transaction.get("description", "")
|
||||
@@ -159,8 +159,8 @@ class NotificationService:
|
||||
ctx.obj = {
|
||||
"notifications": {
|
||||
"telegram": {
|
||||
"api-key": telegram_config.get("token"),
|
||||
"chat-id": telegram_config.get("chat_id"),
|
||||
"token": telegram_config.get("token"),
|
||||
"chat_id": telegram_config.get("chat_id"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,8 +219,8 @@ class NotificationService:
|
||||
ctx.obj = {
|
||||
"notifications": {
|
||||
"telegram": {
|
||||
"api-key": telegram_config.get("token"),
|
||||
"chat-id": telegram_config.get("chat_id"),
|
||||
"token": telegram_config.get("token"),
|
||||
"chat_id": telegram_config.get("chat_id"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,8 +277,8 @@ class NotificationService:
|
||||
ctx.obj = {
|
||||
"notifications": {
|
||||
"telegram": {
|
||||
"api-key": telegram_config.get("token"),
|
||||
"chat-id": telegram_config.get("chat_id"),
|
||||
"token": telegram_config.get("token"),
|
||||
"chat_id": telegram_config.get("chat_id"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ def send_notification(ctx: click.Context, transactions: list):
|
||||
warning("No filters are enabled, skipping notifications")
|
||||
return
|
||||
|
||||
filters_case_insensitive = ctx.obj.get("filters", {}).get("case-insensitive", {})
|
||||
filters_case_insensitive = ctx.obj.get("filters", {}).get("case_insensitive", {})
|
||||
|
||||
# Add transaction to the list of transactions to be sent as a notification
|
||||
notification_transactions = []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "leggen"
|
||||
version = "2025.9.13"
|
||||
version = "2025.9.15"
|
||||
description = "An Open Banking CLI"
|
||||
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
||||
requires-python = "~=3.13.0"
|
||||
|
||||
@@ -216,8 +216,8 @@ class TestConfig:
|
||||
"""Test filters configuration access."""
|
||||
custom_config = {
|
||||
"filters": {
|
||||
"case-insensitive": ["salary", "utility"],
|
||||
"case-sensitive": ["SpecificStore"],
|
||||
"case_insensitive": ["salary", "utility"],
|
||||
"case_sensitive": ["SpecificStore"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +225,6 @@ class TestConfig:
|
||||
config._config = custom_config
|
||||
|
||||
filters = config.filters_config
|
||||
assert "salary" in filters["case-insensitive"]
|
||||
assert "utility" in filters["case-insensitive"]
|
||||
assert "SpecificStore" in filters["case-sensitive"]
|
||||
assert "salary" in filters["case_insensitive"]
|
||||
assert "utility" in filters["case_insensitive"]
|
||||
assert "SpecificStore" in filters["case_sensitive"]
|
||||
|
||||
Reference in New Issue
Block a user