Compare commits

...

11 Commits

Author SHA1 Message Date
Elisiário Couto
8228974c0c chore(ci): Bump version to 2025.9.17 2025-09-18 23:45:10 +01:00
Elisiário Couto
848eccb35b chore: Format files. 2025-09-18 23:43:08 +01:00
Elisiário Couto
25747d7d37 fix(api): Prevent duplicate notifications for existing transactions during sync.
The notification system was incorrectly sending notifications for existing
transactions that were being updated during sync operations. This change
modifies the transaction persistence logic to only return genuinely new
transactions, preventing duplicate notifications while maintaining data
integrity through INSERT OR REPLACE.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 23:42:11 +01:00
Elisiário Couto
b7d6cf8128 chore(ci): Bump version to 2025.9.16 2025-09-18 23:29:53 +01:00
copilot-swe-agent[bot]
6589c2dd66 fix(frontend): Add iOS safe area support for PWA sticky header
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-18 23:28:49 +01:00
Elisiário Couto
571072f6ac chore(ci): Bump version to 2025.9.15 2025-09-18 23:03:01 +01:00
Elisiário Couto
be4f7f8cec refactor(frontend): Simplify filter bar UI and remove advanced filters popover.
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 23:01:51 +01:00
Elisiário Couto
056c33b9c5 feat(frontend): Add settings page with account management functionality.
Added comprehensive settings page with account settings component, integrated with existing layout and routing
structure. Updated project documentation with frontend architecture details.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>")
2025-09-18 22:19:36 +01:00
Elisiário Couto
02c4f5c6ef chore(ci): Bump version to 2025.9.14 2025-09-18 11:49:36 +01:00
Elisiário Couto
30d7c2ed4e chore(ci): Prevent double GitHub Actions runs on new releases. 2025-09-18 11:21:04 +01:00
Elisiário Couto
61442a598f fix(config): Remove aliases for configuration keys that were disabling telegram notifications in some cases. 2025-09-18 11:09:43 +01:00
39 changed files with 3466 additions and 625 deletions

View File

@@ -10,46 +10,48 @@ jobs:
test-python: test-python:
name: Test Python name: Test Python
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, 'chore(ci): Bump version to')"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v5
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version-file: "pyproject.toml" python-version-file: "pyproject.toml"
- name: Create config directory for tests - name: Create config directory for tests
run: | run: |
mkdir -p ~/.config/leggen mkdir -p ~/.config/leggen
cp config.example.toml ~/.config/leggen/config.toml cp config.example.toml ~/.config/leggen/config.toml
- name: Run Python tests - name: Run Python tests
run: uv run pytest run: uv run pytest
test-frontend: test-frontend:
name: Test Frontend name: Test Frontend
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, 'chore(ci): Bump version to')"
defaults: defaults:
run: run:
working-directory: ./frontend working-directory: ./frontend
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- 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
run: npm install run: npm install
- name: Run lint - name: Run lint
run: npm run lint run: npm run lint
- name: Run build - name: Run build
run: npm run build run: npm run build

View File

@@ -143,19 +143,19 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install git-cliff - name: Install git-cliff
run: | run: |
wget -qO- https://github.com/orhun/git-cliff/releases/latest/download/git-cliff-2.10.0-x86_64-unknown-linux-gnu.tar.gz | tar xz wget -qO- https://github.com/orhun/git-cliff/releases/latest/download/git-cliff-2.10.0-x86_64-unknown-linux-gnu.tar.gz | tar xz
sudo mv git-cliff-*/git-cliff /usr/local/bin/ sudo mv git-cliff-*/git-cliff /usr/local/bin/
- name: Generate release notes - name: Generate release notes
id: release_notes id: release_notes
run: | run: |
echo "notes<<EOF" >> $GITHUB_OUTPUT echo "notes<<EOF" >> $GITHUB_OUTPUT
git-cliff --current >> $GITHUB_OUTPUT git-cliff --current >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT
- name: Create Release - name: Create Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:

View File

@@ -85,6 +85,29 @@ The command outputs instructions for setting the required environment variable t
- **Data fetching**: @tanstack/react-query with proper error handling - **Data fetching**: @tanstack/react-query with proper error handling
- **Components**: Functional components with hooks, proper TypeScript typing - **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 ### General
- **Formatting**: ruff for Python, ESLint for TypeScript - **Formatting**: ruff for Python, ESLint for TypeScript
- **Commits**: Use conventional commits with optional scopes, run pre-commit hooks before pushing - **Commits**: Use conventional commits with optional scopes, run pre-commit hooks before pushing

View File

@@ -1,4 +1,98 @@
## 2025.9.17 (2025/09/18)
### Bug Fixes
- **api:** Prevent duplicate notifications for existing transactions during sync. ([25747d7d](https://github.com/elisiariocouto/leggen/commit/25747d7d372e291090764a6814f9d8d0b76aea3b))
### Miscellaneous Tasks
- Format files. ([848eccb3](https://github.com/elisiariocouto/leggen/commit/848eccb35b910c8121d15611547dca8da0b12756))
## 2025.9.17 (2025/09/18)
### Bug Fixes
- **api:** Prevent duplicate notifications for existing transactions during sync. ([25747d7d](https://github.com/elisiariocouto/leggen/commit/25747d7d372e291090764a6814f9d8d0b76aea3b))
### Miscellaneous Tasks
- Format files. ([848eccb3](https://github.com/elisiariocouto/leggen/commit/848eccb35b910c8121d15611547dca8da0b12756))
## 2025.9.16 (2025/09/18)
### Bug Fixes
- **frontend:** Add iOS safe area support for PWA sticky header ([6589c2dd](https://github.com/elisiariocouto/leggen/commit/6589c2dd666f8605cf6d1bf9ad7277734d4cd302))
## 2025.9.16 (2025/09/18)
### Bug Fixes
- **frontend:** Add iOS safe area support for PWA sticky header ([6589c2dd](https://github.com/elisiariocouto/leggen/commit/6589c2dd666f8605cf6d1bf9ad7277734d4cd302))
## 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) ## 2025.9.13 (2025/09/17)
### Bug Fixes ### Bug Fixes

View File

@@ -146,8 +146,8 @@ enabled = true
# Optional: Transaction filters for notifications # Optional: Transaction filters for notifications
[filters] [filters]
case-insensitive = ["salary", "utility"] case_insensitive = ["salary", "utility"]
case-sensitive = ["SpecificStore"] case_sensitive = ["SpecificStore"]
``` ```
## 📖 Usage ## 📖 Usage

View File

@@ -9,7 +9,7 @@ sqlite = true
# Optional: Background sync scheduling # Optional: Background sync scheduling
[scheduler.sync] [scheduler.sync]
enabled = true enabled = true
hour = 3 # 3 AM hour = 3 # 3 AM
minute = 0 minute = 0
# cron = "0 3 * * *" # Alternative: use cron expression # cron = "0 3 * * *" # Alternative: use cron expression
@@ -20,11 +20,11 @@ enabled = true
# Optional: Telegram notifications # Optional: Telegram notifications
[notifications.telegram] [notifications.telegram]
api-key = "your-bot-token" token = "your-bot-token"
chat-id = 12345 chat_id = 12345
enabled = true enabled = true
# Optional: Transaction filters for notifications # Optional: Transaction filters for notifications
[filters] [filters]
case-insensitive = ["salary", "utility"] case_insensitive = ["salary", "utility"]
case-sensitive = ["SpecificStore"] case_sensitive = ["SpecificStore"]

File diff suppressed because it is too large Load Diff

View File

@@ -48,6 +48,7 @@
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0", "globals": "^16.3.0",
"shadcn": "^3.3.1",
"sharp": "^0.34.3", "sharp": "^0.34.3",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.39.1", "typescript-eslint": "^8.39.1",

View File

@@ -6,4 +6,4 @@
<TileColor>#3B82F6</TileColor> <TileColor>#3B82F6</TileColor>
</tile> </tile>
</msapplication> </msapplication>
</browserconfig> </browserconfig>

View File

@@ -1,4 +1,4 @@
User-agent: * User-agent: *
Allow: / Allow: /
Sitemap: /sitemap.xml Sitemap: /sitemap.xml

View File

@@ -1,4 +1,4 @@
{ {
"preset": "minimal-2023", "preset": "minimal-2023",
"images": ["public/favicon.svg"] "images": ["public/favicon.svg"]
} }

View 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>
);
}

View File

@@ -9,6 +9,7 @@ const navigation = [
{ name: "Transactions", to: "/transactions" }, { name: "Transactions", to: "/transactions" },
{ name: "Analytics", to: "/analytics" }, { name: "Analytics", to: "/analytics" },
{ name: "Notifications", to: "/notifications" }, { name: "Notifications", to: "/notifications" },
{ name: "Settings", to: "/settings" },
]; ];
interface HeaderProps { interface HeaderProps {
@@ -32,7 +33,7 @@ export default function Header({ setSidebarOpen }: HeaderProps) {
}); });
return ( 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 pt-safe-top">
<div className="flex items-center justify-between h-16 px-6"> <div className="flex items-center justify-between h-16 px-6">
<div className="flex items-center"> <div className="flex items-center">
<button <button

View File

@@ -33,11 +33,11 @@ export function PWAInstallPrompt({ onInstall }: PWAPromptProps) {
try { try {
await deferredPrompt.prompt(); await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice; const { outcome } = await deferredPrompt.userChoice;
if (outcome === "accepted") { if (outcome === "accepted") {
onInstall?.(); onInstall?.();
} }
setDeferredPrompt(null); setDeferredPrompt(null);
setShowPrompt(false); setShowPrompt(false);
} catch (error) { } catch (error) {
@@ -153,4 +153,4 @@ export function PWAUpdatePrompt({ updateAvailable, onUpdate }: PWAUpdatePromptPr
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,24 +1,29 @@
import { Link, useLocation } from "@tanstack/react-router"; import { Link, useLocation } from "@tanstack/react-router";
import { import {
Home,
List, List,
BarChart3, BarChart3,
Bell, Bell,
TrendingUp, TrendingUp,
X, X,
ChevronDown,
ChevronUp,
Settings,
Building2,
} from "lucide-react"; } from "lucide-react";
import { Logo } from "./ui/logo"; import { Logo } from "./ui/logo";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../lib/api"; import { apiClient } from "../lib/api";
import { formatCurrency } from "../lib/utils"; import { formatCurrency } from "../lib/utils";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { useState } from "react";
import type { Account } from "../types/api"; import type { Account } from "../types/api";
const navigation = [ const navigation = [
{ name: "Overview", icon: Home, to: "/" }, { name: "Overview", icon: List, to: "/" },
{ name: "Transactions", icon: List, to: "/transactions" },
{ name: "Analytics", icon: BarChart3, to: "/analytics" }, { name: "Analytics", icon: BarChart3, to: "/analytics" },
{ name: "Notifications", icon: Bell, to: "/notifications" }, { name: "Notifications", icon: Bell, to: "/notifications" },
{ name: "Settings", icon: Settings, to: "/settings" },
]; ];
interface SidebarProps { interface SidebarProps {
@@ -28,6 +33,7 @@ interface SidebarProps {
export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) { export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
const location = useLocation(); const location = useLocation();
const [accountsExpanded, setAccountsExpanded] = useState(false);
const { data: accounts } = useQuery<Account[]>({ const { data: accounts } = useQuery<Account[]>({
queryKey: ["accounts"], queryKey: ["accounts"],
@@ -85,21 +91,68 @@ export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
</div> </div>
</nav> </nav>
{/* Account Summary in Sidebar */} {/* Collapsible Account Summary in Sidebar */}
<div className="px-6 py-4 border-t border-border mt-auto"> <div className="px-6 pt-4 pb-6 border-t border-border mt-auto">
<div className="bg-muted rounded-lg p-4"> <div className="bg-muted rounded-lg">
<div className="flex items-center justify-between"> {/* Collapsible Header */}
<span className="text-sm font-medium text-muted-foreground"> <button
Total Balance onClick={() => setAccountsExpanded(!accountsExpanded)}
</span> className="w-full p-4 flex items-center justify-between hover:bg-muted/80 transition-colors rounded-lg"
<TrendingUp className="h-4 w-4 text-green-500" /> >
<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> </div>
<p className="text-2xl font-bold text-foreground mt-1">
{formatCurrency(totalBalance)} {/* Expanded Account Details */}
</p> {accountsExpanded && accounts && accounts.length > 0 && (
<p className="text-sm text-muted-foreground mt-1"> <div className="border-t border-border/50 max-h-64 overflow-y-auto">
{accounts?.length || 0} accounts {accounts.map((account) => {
</p> 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> </div>
</div> </div>

View File

@@ -28,7 +28,7 @@ import FiltersSkeleton from "./FiltersSkeleton";
import RawTransactionModal from "./RawTransactionModal"; import RawTransactionModal from "./RawTransactionModal";
import { FilterBar, type FilterState } from "./filters"; import { FilterBar, type FilterState } from "./filters";
import { DataTablePagination } from "./ui/data-table-pagination"; 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 { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import type { Account, Transaction, ApiResponse } from "../types/api"; import type { Account, Transaction, ApiResponse } from "../types/api";
@@ -40,8 +40,6 @@ export default function TransactionsTable() {
selectedAccount: "", selectedAccount: "",
startDate: "", startDate: "",
endDate: "", endDate: "",
minAmount: "",
maxAmount: "",
}); });
const [showRawModal, setShowRawModal] = useState(false); const [showRawModal, setShowRawModal] = useState(false);
@@ -73,8 +71,6 @@ export default function TransactionsTable() {
selectedAccount: "", selectedAccount: "",
startDate: "", startDate: "",
endDate: "", endDate: "",
minAmount: "",
maxAmount: "",
}); });
setColumnFilters([]); setColumnFilters([]);
setCurrentPage(1); setCurrentPage(1);
@@ -116,8 +112,6 @@ export default function TransactionsTable() {
currentPage, currentPage,
perPage, perPage,
debouncedSearchTerm, debouncedSearchTerm,
filterState.minAmount,
filterState.maxAmount,
], ],
queryFn: () => queryFn: () =>
apiClient.getTransactions({ apiClient.getTransactions({
@@ -128,12 +122,6 @@ export default function TransactionsTable() {
perPage: perPage, perPage: perPage,
search: debouncedSearchTerm || undefined, search: debouncedSearchTerm || undefined,
summaryOnly: false, 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.selectedAccount,
filterState.startDate, filterState.startDate,
filterState.endDate, filterState.endDate,
filterState.minAmount,
filterState.maxAmount,
]); ]);
const handleViewRaw = (transaction: Transaction) => { const handleViewRaw = (transaction: Transaction) => {
@@ -175,9 +161,7 @@ export default function TransactionsTable() {
filterState.searchTerm || filterState.searchTerm ||
filterState.selectedAccount || filterState.selectedAccount ||
filterState.startDate || filterState.startDate ||
filterState.endDate || filterState.endDate;
filterState.minAmount ||
filterState.maxAmount;
// Define columns // Define columns
@@ -372,38 +356,6 @@ export default function TransactionsTable() {
isSearchLoading={isSearchLoading} 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 */} {/* Responsive Table/Cards */}
<Card> <Card>
{/* Desktop Table View (hidden on mobile) */} {/* Desktop Table View (hidden on mobile) */}

View File

@@ -51,7 +51,7 @@ export function AccountCombobox({
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className="justify-between" className="w-full justify-between"
> >
<div className="flex items-center"> <div className="flex items-center">
<Building2 className="mr-2 h-4 w-4" /> <Building2 className="mr-2 h-4 w-4" />

View File

@@ -8,12 +8,14 @@ import type { Account } from "../../types/api";
export interface ActiveFilterChipsProps { export interface ActiveFilterChipsProps {
filterState: FilterState; filterState: FilterState;
onFilterChange: (key: keyof FilterState, value: string) => void; onFilterChange: (key: keyof FilterState, value: string) => void;
onClearFilters: () => void;
accounts?: Account[]; accounts?: Account[];
} }
export function ActiveFilterChips({ export function ActiveFilterChips({
filterState, filterState,
onFilterChange, onFilterChange,
onClearFilters,
accounts = [], accounts = [],
}: ActiveFilterChipsProps) { }: ActiveFilterChipsProps) {
const chips: Array<{ 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) => { const handleRemoveChip = (key: keyof FilterState) => {
switch (key) { switch (key) {
@@ -100,11 +78,6 @@ export function ActiveFilterChips({
onFilterChange("startDate", ""); onFilterChange("startDate", "");
onFilterChange("endDate", ""); onFilterChange("endDate", "");
break; break;
case "minAmount":
// Clear both min and max amount
onFilterChange("minAmount", "");
onFilterChange("maxAmount", "");
break;
default: default:
onFilterChange(key, ""); onFilterChange(key, "");
} }
@@ -135,6 +108,15 @@ export function ActiveFilterChips({
</Button> </Button>
</Badge> </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> </div>
); );
} }

View File

@@ -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>
);
}

View File

@@ -6,6 +6,7 @@ import type { DateRange } from "react-day-picker";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar"; import { Calendar } from "@/components/ui/calendar";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -26,33 +27,35 @@ interface DatePreset {
const datePresets: DatePreset[] = [ const datePresets: DatePreset[] = [
{ {
label: "Last 7 days", label: "Today",
getValue: () => { getValue: () => {
const endDate = new Date(); const today = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - 7);
return { return {
startDate: startDate.toISOString().split("T")[0], startDate: today.toISOString().split("T")[0],
endDate: endDate.toISOString().split("T")[0], endDate: today.toISOString().split("T")[0],
}; };
}, },
}, },
{ {
label: "This week", label: "Yesterday",
getValue: () => { getValue: () => {
const now = new Date(); const yesterday = new Date();
const dayOfWeek = now.getDay(); yesterday.setDate(yesterday.getDate() - 1);
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);
return { return {
startDate: startOfWeek.toISOString().split("T")[0], startDate: yesterday.toISOString().split("T")[0],
endDate: endOfWeek.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: () => { getValue: () => {
const endDate = new Date(); const endDate = new Date();
const startDate = new Date(); const startDate = new Date();
startDate.setDate(endDate.getDate() - 30); startDate.setDate(endDate.getDate() - 29);
return { return {
startDate: startDate.toISOString().split("T")[0], startDate: startDate.toISOString().split("T")[0],
endDate: endDate.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({ export function DateRangePicker({
@@ -178,34 +168,30 @@ export function DateRangePicker({
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent className="w-auto p-0" align="start">
<div className="flex"> <Card className="w-auto py-4">
{/* Presets */} <CardContent className="px-4">
<div className="border-r p-3 space-y-1"> <Calendar
<div className="text-sm font-medium text-gray-700 mb-2"> mode="range"
Quick select defaultMonth={dateRange?.from}
</div> 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) => ( {datePresets.map((preset) => (
<Button <Button
key={preset.label} key={preset.label}
variant="ghost" variant="outline"
size="sm" size="sm"
className="w-full justify-start text-sm" className="text-xs px-2 h-7"
onClick={() => handlePresetClick(preset)} onClick={() => handlePresetClick(preset)}
> >
{preset.label} {preset.label}
</Button> </Button>
))} ))}
</div> </CardFooter>
{/* Calendar */} </Card>
<Calendar
initialFocus
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={handleDateRangeSelect}
numberOfMonths={2}
/>
</div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div> </div>

View File

@@ -1,11 +1,9 @@
import { Search, X } from "lucide-react"; import { Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DateRangePicker } from "./DateRangePicker"; import { DateRangePicker } from "./DateRangePicker";
import { AccountCombobox } from "./AccountCombobox"; import { AccountCombobox } from "./AccountCombobox";
import { ActiveFilterChips } from "./ActiveFilterChips"; import { ActiveFilterChips } from "./ActiveFilterChips";
import { AdvancedFiltersPopover } from "./AdvancedFiltersPopover";
import type { Account } from "../../types/api"; import type { Account } from "../../types/api";
export interface FilterState { export interface FilterState {
@@ -13,8 +11,6 @@ export interface FilterState {
selectedAccount: string; selectedAccount: string;
startDate: string; startDate: string;
endDate: string; endDate: string;
minAmount: string;
maxAmount: string;
} }
export interface FilterBarProps { export interface FilterBarProps {
@@ -38,9 +34,7 @@ export function FilterBar({
filterState.searchTerm || filterState.searchTerm ||
filterState.selectedAccount || filterState.selectedAccount ||
filterState.startDate || filterState.startDate ||
filterState.endDate || filterState.endDate;
filterState.minAmount ||
filterState.maxAmount;
const handleDateRangeChange = (startDate: string, endDate: string) => { const handleDateRangeChange = (startDate: string, endDate: string) => {
onFilterChange("startDate", startDate); onFilterChange("startDate", startDate);
@@ -58,61 +52,85 @@ export function FilterBar({
</div> </div>
{/* Primary Filters Row */} {/* Primary Filters Row */}
<div className="flex flex-wrap items-center gap-3 mb-4"> <div className="space-y-4 mb-4">
{/* Search Input */} {/* Desktop Layout */}
<div className="relative flex-1 min-w-[240px]"> <div className="hidden lg:flex items-center justify-between gap-6">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> {/* Left Side: Main Filters */}
<Input <div className="flex items-center gap-3 flex-1">
placeholder="Search transactions..." {/* Search Input */}
value={filterState.searchTerm} <div className="relative w-[200px]">
onChange={(e) => onFilterChange("searchTerm", e.target.value)} <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
className="pl-9 pr-8 bg-background" <Input
/> placeholder="Search transactions..."
{isSearchLoading && ( value={filterState.searchTerm}
<div className="absolute right-3 top-1/2 transform -translate-y-1/2"> onChange={(e) => onFilterChange("searchTerm", e.target.value)}
<div className="animate-spin h-4 w-4 border-2 border-border border-t-primary rounded-full"></div> 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> </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> </div>
{/* Account Selection */} {/* Mobile Layout */}
<AccountCombobox <div className="lg:hidden space-y-3">
accounts={accounts} {/* First Row: Search Input (Full Width) */}
selectedAccount={filterState.selectedAccount} <div className="relative">
onAccountChange={(accountId) => <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
onFilterChange("selectedAccount", accountId) <Input
} placeholder="Search..."
className="w-[200px]" 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 */} {/* Second Row: Account Selection (Full Width) */}
<DateRangePicker <AccountCombobox
startDate={filterState.startDate} accounts={accounts}
endDate={filterState.endDate} selectedAccount={filterState.selectedAccount}
onDateRangeChange={handleDateRangeChange} onAccountChange={(accountId) =>
className="w-[240px]" onFilterChange("selectedAccount", accountId)
/> }
className="w-full"
/>
{/* Advanced Filters Button */} {/* Third Row: Date Range */}
<AdvancedFiltersPopover <DateRangePicker
minAmount={filterState.minAmount} startDate={filterState.startDate}
maxAmount={filterState.maxAmount} endDate={filterState.endDate}
onMinAmountChange={(value) => onFilterChange("minAmount", value)} onDateRangeChange={handleDateRangeChange}
onMaxAmountChange={(value) => onFilterChange("maxAmount", value)} className="w-full"
/> />
{/* Clear Filters Button */} </div>
{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 */} {/* Active Filter Chips */}
@@ -120,6 +138,7 @@ export function FilterBar({
<ActiveFilterChips <ActiveFilterChips
filterState={filterState} filterState={filterState}
onFilterChange={onFilterChange} onFilterChange={onFilterChange}
onClearFilters={onClearFilters}
accounts={accounts} accounts={accounts}
/> />
)} )}

View File

@@ -2,5 +2,4 @@ export { FilterBar } from "./FilterBar";
export { DateRangePicker } from "./DateRangePicker"; export { DateRangePicker } from "./DateRangePicker";
export { AccountCombobox } from "./AccountCombobox"; export { AccountCombobox } from "./AccountCombobox";
export { ActiveFilterChips } from "./ActiveFilterChips"; export { ActiveFilterChips } from "./ActiveFilterChips";
export { AdvancedFiltersPopover } from "./AdvancedFiltersPopover";
export type { FilterState, FilterBarProps } from "./FilterBar"; export type { FilterState, FilterBarProps } from "./FilterBar";

View File

@@ -34,4 +34,4 @@ export function usePWA(): PWAUpdate {
updateAvailable, updateAvailable,
updateSW, updateSW,
}; };
} }

View File

@@ -29,6 +29,12 @@
--chart-4: 43 74% 66%; --chart-4: 43 74% 66%;
--chart-5: 27 87% 67%; --chart-5: 27 87% 67%;
--radius: 0.5rem; --radius: 0.5rem;
/* iOS Safe Area Support for PWA */
--safe-area-inset-top: env(safe-area-inset-top, 0px);
--safe-area-inset-right: env(safe-area-inset-right, 0px);
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-inset-left: env(safe-area-inset-left, 0px);
} }
.dark { .dark {
--background: 222.2 84% 4.9%; --background: 222.2 84% 4.9%;

View File

@@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as TransactionsRouteImport } from './routes/transactions' import { Route as TransactionsRouteImport } from './routes/transactions'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as NotificationsRouteImport } from './routes/notifications' import { Route as NotificationsRouteImport } from './routes/notifications'
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'
@@ -19,6 +20,11 @@ const TransactionsRoute = TransactionsRouteImport.update({
path: '/transactions', path: '/transactions',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const SettingsRoute = SettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
const NotificationsRoute = NotificationsRouteImport.update({ const NotificationsRoute = NotificationsRouteImport.update({
id: '/notifications', id: '/notifications',
path: '/notifications', path: '/notifications',
@@ -39,12 +45,14 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/analytics': typeof AnalyticsRoute '/analytics': typeof AnalyticsRoute
'/notifications': typeof NotificationsRoute '/notifications': typeof NotificationsRoute
'/settings': typeof SettingsRoute
'/transactions': typeof TransactionsRoute '/transactions': typeof TransactionsRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/analytics': typeof AnalyticsRoute '/analytics': typeof AnalyticsRoute
'/notifications': typeof NotificationsRoute '/notifications': typeof NotificationsRoute
'/settings': typeof SettingsRoute
'/transactions': typeof TransactionsRoute '/transactions': typeof TransactionsRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
@@ -52,20 +60,33 @@ export interface FileRoutesById {
'/': typeof IndexRoute '/': typeof IndexRoute
'/analytics': typeof AnalyticsRoute '/analytics': typeof AnalyticsRoute
'/notifications': typeof NotificationsRoute '/notifications': typeof NotificationsRoute
'/settings': typeof SettingsRoute
'/transactions': typeof TransactionsRoute '/transactions': typeof TransactionsRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/analytics' | '/notifications' | '/transactions' fullPaths:
| '/'
| '/analytics'
| '/notifications'
| '/settings'
| '/transactions'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/analytics' | '/notifications' | '/transactions' to: '/' | '/analytics' | '/notifications' | '/settings' | '/transactions'
id: '__root__' | '/' | '/analytics' | '/notifications' | '/transactions' id:
| '__root__'
| '/'
| '/analytics'
| '/notifications'
| '/settings'
| '/transactions'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
AnalyticsRoute: typeof AnalyticsRoute AnalyticsRoute: typeof AnalyticsRoute
NotificationsRoute: typeof NotificationsRoute NotificationsRoute: typeof NotificationsRoute
SettingsRoute: typeof SettingsRoute
TransactionsRoute: typeof TransactionsRoute TransactionsRoute: typeof TransactionsRoute
} }
@@ -78,6 +99,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof TransactionsRouteImport preLoaderRoute: typeof TransactionsRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/settings': {
id: '/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/notifications': { '/notifications': {
id: '/notifications' id: '/notifications'
path: '/notifications' path: '/notifications'
@@ -106,6 +134,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
AnalyticsRoute: AnalyticsRoute, AnalyticsRoute: AnalyticsRoute,
NotificationsRoute: NotificationsRoute, NotificationsRoute: NotificationsRoute,
SettingsRoute: SettingsRoute,
TransactionsRoute: TransactionsRoute, TransactionsRoute: TransactionsRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport

View File

@@ -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} /> <Header setSidebarOpen={setSidebarOpen} />
<main className="flex-1 p-6"> <main className="flex-1 p-6 min-w-0">
<Outlet /> <Outlet />
</main> </main>
</div> </div>

View File

@@ -1,6 +1,11 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import AccountsOverview from "../components/AccountsOverview"; import TransactionsTable from "../components/TransactionsTable";
export const Route = createFileRoute("/")({ 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,
}),
}); });

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import AccountSettings from "../components/AccountSettings";
export const Route = createFileRoute("/settings")({
component: AccountSettings,
});

View File

@@ -9,6 +9,12 @@ export default {
md: "calc(var(--radius) - 2px)", md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)", sm: "calc(var(--radius) - 4px)",
}, },
spacing: {
'safe-top': 'var(--safe-area-inset-top)',
'safe-bottom': 'var(--safe-area-inset-bottom)',
'safe-left': 'var(--safe-area-inset-left)',
'safe-right': 'var(--safe-area-inset-right)',
},
colors: { colors: {
background: "hsl(var(--background))", background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))", foreground: "hsl(var(--foreground))",

View File

@@ -37,15 +37,15 @@ async def get_notification_settings() -> APIResponse:
if discord_config.get("webhook") if discord_config.get("webhook")
else None, else None,
telegram=TelegramConfig( telegram=TelegramConfig(
token="***" if telegram_config.get("api-key") else "", token="***" if telegram_config.get("token") else "",
chat_id=telegram_config.get("chat-id", 0), chat_id=telegram_config.get("chat_id", 0),
enabled=telegram_config.get("enabled", True), enabled=telegram_config.get("enabled", True),
) )
if telegram_config.get("api-key") if telegram_config.get("token")
else None, else None,
filters=NotificationFilters( filters=NotificationFilters(
case_insensitive=filters_config.get("case-insensitive", []), case_insensitive=filters_config.get("case_insensitive", []),
case_sensitive=filters_config.get("case-sensitive"), case_sensitive=filters_config.get("case_sensitive"),
), ),
) )
@@ -77,17 +77,17 @@ async def update_notification_settings(settings: NotificationSettings) -> APIRes
if settings.telegram: if settings.telegram:
notifications_config["telegram"] = { notifications_config["telegram"] = {
"api-key": settings.telegram.token, "token": settings.telegram.token,
"chat-id": settings.telegram.chat_id, "chat_id": settings.telegram.chat_id,
"enabled": settings.telegram.enabled, "enabled": settings.telegram.enabled,
} }
# Update filters config # Update filters config
filters_config: Dict[str, Any] = {} filters_config: Dict[str, Any] = {}
if settings.filters.case_insensitive: 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: if settings.filters.case_sensitive:
filters_config["case-sensitive"] = settings.filters.case_sensitive filters_config["case_sensitive"] = settings.filters.case_sensitive
# Save to config # Save to config
if notifications_config: if notifications_config:
@@ -153,12 +153,12 @@ async def get_notification_services() -> APIResponse:
"telegram": { "telegram": {
"name": "Telegram", "name": "Telegram",
"enabled": bool( "enabled": bool(
notifications_config.get("telegram", {}).get("api-key") notifications_config.get("telegram", {}).get("token")
and notifications_config.get("telegram", {}).get("chat-id") and notifications_config.get("telegram", {}).get("chat_id")
), ),
"configured": bool( "configured": bool(
notifications_config.get("telegram", {}).get("api-key") notifications_config.get("telegram", {}).get("token")
and notifications_config.get("telegram", {}).get("chat-id") and notifications_config.get("telegram", {}).get("chat_id")
), ),
"active": notifications_config.get("telegram", {}).get("enabled", True), "active": notifications_config.get("telegram", {}).get("enabled", True),
}, },

View File

@@ -22,8 +22,8 @@ class DiscordNotificationConfig(BaseModel):
class TelegramNotificationConfig(BaseModel): class TelegramNotificationConfig(BaseModel):
token: str = Field(..., alias="api-key", description="Telegram bot token") token: str = Field(..., description="Telegram bot token")
chat_id: int = Field(..., alias="chat-id", description="Telegram chat ID") chat_id: int = Field(..., description="Telegram chat ID")
enabled: bool = Field(default=True, description="Enable Telegram notifications") enabled: bool = Field(default=True, description="Enable Telegram notifications")
@@ -33,12 +33,8 @@ class NotificationConfig(BaseModel):
class FilterConfig(BaseModel): class FilterConfig(BaseModel):
case_insensitive: Optional[List[str]] = Field( case_insensitive: Optional[List[str]] = Field(default_factory=list)
default_factory=list, alias="case-insensitive" case_sensitive: Optional[List[str]] = Field(default_factory=list)
)
case_sensitive: Optional[List[str]] = Field(
default_factory=list, alias="case-sensitive"
)
class SyncScheduleConfig(BaseModel): class SyncScheduleConfig(BaseModel):
@@ -60,6 +56,3 @@ class Config(BaseModel):
notifications: Optional[NotificationConfig] = None notifications: Optional[NotificationConfig] = None
filters: Optional[FilterConfig] = None filters: Optional[FilterConfig] = None
scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig) scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig)
class Config:
validate_by_name = True

View File

@@ -29,8 +29,8 @@ def escape_markdown(text: str) -> str:
def send_expire_notification(ctx: click.Context, notification: dict): def send_expire_notification(ctx: click.Context, notification: dict):
token = ctx.obj["notifications"]["telegram"]["api-key"] token = ctx.obj["notifications"]["telegram"]["token"]
chat_id = ctx.obj["notifications"]["telegram"]["chat-id"] chat_id = ctx.obj["notifications"]["telegram"]["chat_id"]
bot_url = f"https://api.telegram.org/bot{token}/sendMessage" bot_url = f"https://api.telegram.org/bot{token}/sendMessage"
info("Sending expiration notification to Telegram") info("Sending expiration notification to Telegram")
message = "*💲 [Leggen](https://github.com/elisiariocouto/leggen)*\n" 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): def send_transaction_message(ctx: click.Context, transactions: list):
token = ctx.obj["notifications"]["telegram"]["api-key"] token = ctx.obj["notifications"]["telegram"]["token"]
chat_id = ctx.obj["notifications"]["telegram"]["chat-id"] chat_id = ctx.obj["notifications"]["telegram"]["chat_id"]
bot_url = f"https://api.telegram.org/bot{token}/sendMessage" bot_url = f"https://api.telegram.org/bot{token}/sendMessage"
info(f"Got {len(transactions)} new transactions, sending message to Telegram") info(f"Got {len(transactions)} new transactions, sending message to Telegram")
message = "*💲 [Leggen](https://github.com/elisiariocouto/leggen)*\n" message = "*💲 [Leggen](https://github.com/elisiariocouto/leggen)*\n"

View File

@@ -693,7 +693,7 @@ class DatabaseService:
# Add the display_name column # Add the display_name column
cursor.execute(""" cursor.execute("""
ALTER TABLE accounts ALTER TABLE accounts
ADD COLUMN display_name TEXT ADD COLUMN display_name TEXT
""") """)
@@ -857,6 +857,14 @@ class DatabaseService:
for transaction in transactions: for transaction in transactions:
try: try:
# Check if transaction already exists before insertion
cursor.execute(
"""SELECT COUNT(*) FROM transactions
WHERE accountId = ? AND transactionId = ?""",
(transaction["accountId"], transaction["transactionId"]),
)
exists = cursor.fetchone()[0] > 0
cursor.execute( cursor.execute(
insert_sql, insert_sql,
( (
@@ -873,7 +881,11 @@ class DatabaseService:
json.dumps(transaction["rawTransaction"]), json.dumps(transaction["rawTransaction"]),
), ),
) )
new_transactions.append(transaction)
# Only add to new_transactions if it didn't exist before
if not exists:
new_transactions.append(transaction)
except sqlite3.IntegrityError as e: except sqlite3.IntegrityError as e:
logger.warning( logger.warning(
f"Failed to insert transaction {transaction.get('transactionId')}: {e}" f"Failed to insert transaction {transaction.get('transactionId')}: {e}"

View File

@@ -63,8 +63,8 @@ class NotificationService:
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Filter transactions based on notification criteria""" """Filter transactions based on notification criteria"""
matching = [] matching = []
filters_case_insensitive = self.filters_config.get("case-insensitive", []) filters_case_insensitive = self.filters_config.get("case_insensitive", [])
filters_case_sensitive = self.filters_config.get("case-sensitive", []) filters_case_sensitive = self.filters_config.get("case_sensitive", [])
for transaction in transactions: for transaction in transactions:
description = transaction.get("description", "") description = transaction.get("description", "")
@@ -159,8 +159,8 @@ class NotificationService:
ctx.obj = { ctx.obj = {
"notifications": { "notifications": {
"telegram": { "telegram": {
"api-key": telegram_config.get("token"), "token": telegram_config.get("token"),
"chat-id": telegram_config.get("chat_id"), "chat_id": telegram_config.get("chat_id"),
} }
} }
} }
@@ -219,8 +219,8 @@ class NotificationService:
ctx.obj = { ctx.obj = {
"notifications": { "notifications": {
"telegram": { "telegram": {
"api-key": telegram_config.get("token"), "token": telegram_config.get("token"),
"chat-id": telegram_config.get("chat_id"), "chat_id": telegram_config.get("chat_id"),
} }
} }
} }
@@ -277,8 +277,8 @@ class NotificationService:
ctx.obj = { ctx.obj = {
"notifications": { "notifications": {
"telegram": { "telegram": {
"api-key": telegram_config.get("token"), "token": telegram_config.get("token"),
"chat-id": telegram_config.get("chat_id"), "chat_id": telegram_config.get("chat_id"),
} }
} }
} }

View File

@@ -29,7 +29,7 @@ def send_notification(ctx: click.Context, transactions: list):
warning("No filters are enabled, skipping notifications") warning("No filters are enabled, skipping notifications")
return 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 # Add transaction to the list of transactions to be sent as a notification
notification_transactions = [] notification_transactions = []

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "leggen" name = "leggen"
version = "2025.9.13" version = "2025.9.17"
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"

View File

@@ -216,8 +216,8 @@ class TestConfig:
"""Test filters configuration access.""" """Test filters configuration access."""
custom_config = { custom_config = {
"filters": { "filters": {
"case-insensitive": ["salary", "utility"], "case_insensitive": ["salary", "utility"],
"case-sensitive": ["SpecificStore"], "case_sensitive": ["SpecificStore"],
} }
} }
@@ -225,6 +225,6 @@ class TestConfig:
config._config = custom_config config._config = custom_config
filters = config.filters_config filters = config.filters_config
assert "salary" in filters["case-insensitive"] assert "salary" in filters["case_insensitive"]
assert "utility" in filters["case-insensitive"] assert "utility" in filters["case_insensitive"]
assert "SpecificStore" in filters["case-sensitive"] assert "SpecificStore" in filters["case_sensitive"]

View File

@@ -324,6 +324,8 @@ class TestDatabaseService:
with patch("sqlite3.connect") as mock_connect: with patch("sqlite3.connect") as mock_connect:
mock_conn = mock_connect.return_value mock_conn = mock_connect.return_value
mock_cursor = mock_conn.cursor.return_value mock_cursor = mock_conn.cursor.return_value
# Mock fetchone to return (0,) indicating transaction doesn't exist yet
mock_cursor.fetchone.return_value = (0,)
result = await database_service._persist_transactions_sqlite( result = await database_service._persist_transactions_sqlite(
"test-account-123", sample_transactions_db_format "test-account-123", sample_transactions_db_format
@@ -338,6 +340,29 @@ class TestDatabaseService:
mock_conn.commit.assert_called_once() mock_conn.commit.assert_called_once()
mock_conn.close.assert_called_once() mock_conn.close.assert_called_once()
async def test_persist_transactions_sqlite_duplicate_detection(
self, database_service, sample_transactions_db_format
):
"""Test that existing transactions are not returned as new."""
with patch("sqlite3.connect") as mock_connect:
mock_conn = mock_connect.return_value
mock_cursor = mock_conn.cursor.return_value
# Mock fetchone to return (1,) indicating transaction already exists
mock_cursor.fetchone.return_value = (1,)
result = await database_service._persist_transactions_sqlite(
"test-account-123", sample_transactions_db_format
)
# Should return empty list since all transactions already exist
assert len(result) == 0
# Verify database operations still happened (INSERT OR REPLACE executed)
mock_connect.assert_called()
mock_cursor.execute.assert_called()
mock_conn.commit.assert_called_once()
mock_conn.close.assert_called_once()
async def test_persist_transactions_sqlite_error(self, database_service): async def test_persist_transactions_sqlite_error(self, database_service):
"""Test handling error during transaction persistence.""" """Test handling error during transaction persistence."""
with patch("sqlite3.connect") as mock_connect: with patch("sqlite3.connect") as mock_connect:

2
uv.lock generated
View File

@@ -220,7 +220,7 @@ wheels = [
[[package]] [[package]]
name = "leggen" name = "leggen"
version = "2025.9.13" version = "2025.9.17"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "apscheduler" }, { name = "apscheduler" },