Compare commits

..

10 Commits

Author SHA1 Message Date
Elisiário Couto
0c030efef2 chore(ci): Bump version to 2025.9.8 2025-09-11 18:50:09 +01:00
copilot-swe-agent[bot]
e4e04ea34e feat: update CI workflow to use Node.js 20 instead of 18
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-11 18:48:01 +01:00
copilot-swe-agent[bot]
f4bf549b99 fix: change branch name from develop to dev in CI workflow
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-11 18:48:01 +01:00
copilot-swe-agent[bot]
8cc4f567f8 Update README with CI/CD pipeline information
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-11 18:48:01 +01:00
copilot-swe-agent[bot]
a939b841f3 Add GitHub Actions CI workflow and enhance release workflow
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-11 18:48:01 +01:00
Elisiário Couto
caa43e8eb0 chore(ci): Bump version to 2025.9.7 2025-09-11 14:26:40 +01:00
Elisiário Couto
0a8750ea36 Fix tests. 2025-09-11 14:26:20 +01:00
Elisiário Couto
2d6800eff8 feat: improve transactions API pagination and search
- Update backend /transactions endpoint to use PaginatedResponse
- Change from limit/offset to page/per_page parameters for consistency
- Implement server-side pagination with proper metadata
- Add search debouncing to prevent excessive API calls (300ms delay)
- Add First/Last page buttons to pagination controls
- Fix pagination state reset when filters return 0 results
- Reset pagination to page 1 when filters are applied
- Add visual loading indicator during search debouncing
- Update frontend types and API client to handle new response structure
- Fix TypeScript errors and improve type safety
2025-09-11 14:13:58 +01:00
Elisiário Couto
544527f282 feat(frontend): implement TanStack Table for transactions view
- Add @tanstack/react-table package for advanced table functionality
- Create new TransactionsTable component with sorting, pagination, and filtering
- Implement column sorting for description, amount, and date
- Add pagination with configurable page sizes (10, 25, 50, 100)
- Implement global search across multiple fields (description, creditor, debtor, reference)
- Add quick date filters (Last 7 days, Last 30 days, This month)
- Add amount range filtering (min/max)
- Ensure mobile responsiveness with proper table layout
- Integrate RawTransactionModal with table actions
- Replace TransactionsList with TransactionsTable in routes
- Fix table freezing issue by removing conflicting filtering logic
- Optimize performance with TanStack Table's built-in state management
2025-09-11 12:39:42 +01:00
Elisiário Couto
91020e32ea fix: Simplify notification settings and fix notification test on dashboard. 2025-09-11 12:16:47 +01:00
20 changed files with 1054 additions and 113 deletions

55
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,55 @@
name: CI
on:
push:
branches: [ "main", "dev" ]
pull_request:
branches: [ "main", "dev" ]
jobs:
test-python:
name: Test Python
runs-on: ubuntu-latest
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
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

View File

@@ -133,3 +133,34 @@ jobs:
push: true
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
create-github-release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs: [build, publish-to-pypi, push-docker-backend, push-docker-frontend]
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install git-cliff
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
sudo mv git-cliff-*/git-cliff /usr/local/bin/
- name: Generate release notes
id: release_notes
run: |
echo "notes<<EOF" >> $GITHUB_OUTPUT
git-cliff --current >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }}
body: ${{ steps.release_notes.outputs.notes }}
draft: false
prerelease: false

View File

@@ -1,4 +1,58 @@
## 2025.9.8 (2025/09/11)
### Bug Fixes
- Change branch name from develop to dev in CI workflow ([f4bf549b](https://github.com/elisiariocouto/leggen/commit/f4bf549b99197d70104abf5731ab1ccb67cc9a69))
### Features
- Update CI workflow to use Node.js 20 instead of 18 ([e4e04ea3](https://github.com/elisiariocouto/leggen/commit/e4e04ea34ea568c08292562243b6e6c08234d918))
## 2025.9.8 (2025/09/11)
### Bug Fixes
- Change branch name from develop to dev in CI workflow ([f4bf549b](https://github.com/elisiariocouto/leggen/commit/f4bf549b99197d70104abf5731ab1ccb67cc9a69))
### Features
- Update CI workflow to use Node.js 20 instead of 18 ([e4e04ea3](https://github.com/elisiariocouto/leggen/commit/e4e04ea34ea568c08292562243b6e6c08234d918))
## 2025.9.7 (2025/09/11)
### Bug Fixes
- Simplify notification settings and fix notification test on dashboard. ([91020e32](https://github.com/elisiariocouto/leggen/commit/91020e32ea836ee8af4aeaf5d49525c24b566aed))
### Features
- **frontend:** Implement TanStack Table for transactions view ([544527f2](https://github.com/elisiariocouto/leggen/commit/544527f28284fb9644bec6e721fa5da8ce10739f))
- Improve transactions API pagination and search ([2d6800ef](https://github.com/elisiariocouto/leggen/commit/2d6800eff8e484d3d175225f94d854706584a773))
## 2025.9.7 (2025/09/11)
### Bug Fixes
- Simplify notification settings and fix notification test on dashboard. ([91020e32](https://github.com/elisiariocouto/leggen/commit/91020e32ea836ee8af4aeaf5d49525c24b566aed))
### Features
- **frontend:** Implement TanStack Table for transactions view ([544527f2](https://github.com/elisiariocouto/leggen/commit/544527f28284fb9644bec6e721fa5da8ce10739f))
- Improve transactions API pagination and search ([2d6800ef](https://github.com/elisiariocouto/leggen/commit/2d6800eff8e484d3d175225f94d854706584a773))
## 2025.9.6 (2025/09/10)
### Features

View File

@@ -357,6 +357,10 @@ tests/ # Test suite
3. Make your changes with tests
4. Submit a pull request
The repository uses GitHub Actions for CI/CD:
- **CI**: Runs Python tests (`uv run pytest`) and frontend linting/build on every push
- **Release**: Creates GitHub releases with changelog when tags are pushed
## ⚠️ Notes
- This project is in active development
- GoCardless API rate limits apply

View File

@@ -11,6 +11,7 @@
"@tailwindcss/forms": "^0.5.10",
"@tanstack/react-query": "^5.87.1",
"@tanstack/react-router": "^1.131.36",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-cli": "^1.131.36",
"autoprefixer": "^10.4.21",
"axios": "^1.11.0",
@@ -1609,6 +1610,26 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/router-cli": {
"version": "1.131.36",
"resolved": "https://registry.npmjs.org/@tanstack/router-cli/-/router-cli-1.131.36.tgz",
@@ -1777,6 +1798,19 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-file-routes": {
"version": "1.131.2",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.131.2.tgz",

View File

@@ -13,6 +13,7 @@
"@tailwindcss/forms": "^0.5.10",
"@tanstack/react-query": "^5.87.1",
"@tanstack/react-router": "^1.131.36",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-cli": "^1.131.36",
"autoprefixer": "^10.4.21",
"axios": "^1.11.0",

View File

@@ -102,7 +102,7 @@ export default function Notifications() {
if (!testService) return;
testMutation.mutate({
service: testService,
service: testService.toLowerCase(),
message: testMessage,
});
};
@@ -113,7 +113,7 @@ export default function Notifications() {
`Are you sure you want to delete the ${serviceName} notification service?`,
)
) {
deleteServiceMutation.mutate(serviceName);
deleteServiceMutation.mutate(serviceName.toLowerCase());
}
};

View File

@@ -15,7 +15,7 @@ import { apiClient } from "../lib/api";
import { formatCurrency, formatDate } from "../lib/utils";
import LoadingSpinner from "./LoadingSpinner";
import RawTransactionModal from "./RawTransactionModal";
import type { Account, Transaction } from "../types/api";
import type { Account, Transaction, ApiResponse } from "../types/api";
export default function TransactionsList() {
const [searchTerm, setSearchTerm] = useState("");
@@ -33,11 +33,11 @@ export default function TransactionsList() {
});
const {
data: transactions,
data: transactionsResponse,
isLoading: transactionsLoading,
error: transactionsError,
refetch: refetchTransactions,
} = useQuery<Transaction[]>({
} = useQuery<ApiResponse<Transaction[]>>({
queryKey: ["transactions", selectedAccount, startDate, endDate],
queryFn: () =>
apiClient.getTransactions({
@@ -48,30 +48,34 @@ export default function TransactionsList() {
}),
});
const filteredTransactions = (transactions || []).filter((transaction) => {
// Additional validation (API client should have already filtered out invalid ones)
if (!transaction || !transaction.account_id) {
console.warn(
"Invalid transaction found after API filtering:",
transaction,
);
return false;
}
const transactions = transactionsResponse?.data || [];
const description = transaction.description || "";
const creditorName = transaction.creditor_name || "";
const debtorName = transaction.debtor_name || "";
const reference = transaction.reference || "";
const filteredTransactions = (transactions || []).filter(
(transaction: Transaction) => {
// Additional validation (API client should have already filtered out invalid ones)
if (!transaction || !transaction.account_id) {
console.warn(
"Invalid transaction found after API filtering:",
transaction,
);
return false;
}
const matchesSearch =
searchTerm === "" ||
description.toLowerCase().includes(searchTerm.toLowerCase()) ||
creditorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
debtorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
reference.toLowerCase().includes(searchTerm.toLowerCase());
const description = transaction.description || "";
const creditorName = transaction.creditor_name || "";
const debtorName = transaction.debtor_name || "";
const reference = transaction.reference || "";
return matchesSearch;
});
const matchesSearch =
searchTerm === "" ||
description.toLowerCase().includes(searchTerm.toLowerCase()) ||
creditorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
debtorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
reference.toLowerCase().includes(searchTerm.toLowerCase());
return matchesSearch;
},
);
const clearFilters = () => {
setSearchTerm("");
@@ -260,7 +264,7 @@ export default function TransactionsList() {
</div>
) : (
<div className="bg-white rounded-lg shadow divide-y divide-gray-200">
{filteredTransactions.map((transaction) => {
{filteredTransactions.map((transaction: Transaction) => {
const account = accounts?.find(
(acc) => acc.id === transaction.account_id,
);

View File

@@ -0,0 +1,765 @@
import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
} from "@tanstack/react-table";
import type {
ColumnDef,
SortingState,
ColumnFiltersState,
} from "@tanstack/react-table";
import {
Filter,
Search,
TrendingUp,
TrendingDown,
Calendar,
RefreshCw,
AlertCircle,
X,
Eye,
ChevronUp,
ChevronDown,
} from "lucide-react";
import { apiClient } from "../lib/api";
import { formatCurrency, formatDate } from "../lib/utils";
import LoadingSpinner from "./LoadingSpinner";
import RawTransactionModal from "./RawTransactionModal";
import type { Account, Transaction, ApiResponse } from "../types/api";
export default function TransactionsTable() {
const [searchTerm, setSearchTerm] = useState("");
const [selectedAccount, setSelectedAccount] = useState<string>("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [minAmount, setMinAmount] = useState("");
const [maxAmount, setMaxAmount] = useState("");
const [showFilters, setShowFilters] = useState(false);
const [showRawModal, setShowRawModal] = useState(false);
const [selectedTransaction, setSelectedTransaction] =
useState<Transaction | null>(null);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [perPage, setPerPage] = useState(50);
// Debounced search state
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm);
// Table state (remove pagination from table)
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
// Debounce search term to prevent excessive API calls
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
}, 300); // 300ms delay
return () => clearTimeout(timer);
}, [searchTerm]);
// Reset pagination when search term changes
useEffect(() => {
if (debouncedSearchTerm !== searchTerm) {
setCurrentPage(1);
}
}, [debouncedSearchTerm, searchTerm]);
const { data: accounts } = useQuery<Account[]>({
queryKey: ["accounts"],
queryFn: apiClient.getAccounts,
});
const {
data: transactionsResponse,
isLoading: transactionsLoading,
error: transactionsError,
refetch: refetchTransactions,
} = useQuery<ApiResponse<Transaction[]>>({
queryKey: [
"transactions",
selectedAccount,
startDate,
endDate,
currentPage,
perPage,
debouncedSearchTerm,
],
queryFn: () =>
apiClient.getTransactions({
accountId: selectedAccount || undefined,
startDate: startDate || undefined,
endDate: endDate || undefined,
page: currentPage,
perPage: perPage,
search: debouncedSearchTerm || undefined,
summaryOnly: false,
}),
});
const transactions = transactionsResponse?.data || [];
const pagination = transactionsResponse?.pagination;
// Check if search is currently debouncing
const isSearchLoading = searchTerm !== debouncedSearchTerm;
// Reset pagination when total becomes 0 (no results)
useEffect(() => {
if (pagination && pagination.total === 0 && currentPage > 1) {
setCurrentPage(1);
}
}, [pagination, currentPage]);
const clearFilters = () => {
setSearchTerm("");
setSelectedAccount("");
setStartDate("");
setEndDate("");
setMinAmount("");
setMaxAmount("");
setColumnFilters([]);
setCurrentPage(1); // Reset to first page when clearing filters
};
const setQuickDateFilter = (days: number) => {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - days);
setStartDate(startDate.toISOString().split("T")[0]);
setEndDate(endDate.toISOString().split("T")[0]);
setCurrentPage(1); // Reset to first page when changing date filters
};
const setThisMonthFilter = () => {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
setStartDate(startOfMonth.toISOString().split("T")[0]);
setEndDate(endOfMonth.toISOString().split("T")[0]);
setCurrentPage(1); // Reset to first page when changing date filters
};
// Reset pagination when account filter changes
useEffect(() => {
setCurrentPage(1);
}, [selectedAccount]);
// Reset pagination when date filters change
useEffect(() => {
setCurrentPage(1);
}, [startDate, endDate]);
const handleViewRaw = (transaction: Transaction) => {
setSelectedTransaction(transaction);
setShowRawModal(true);
};
const handleCloseModal = () => {
setShowRawModal(false);
setSelectedTransaction(null);
};
const hasActiveFilters =
searchTerm ||
selectedAccount ||
startDate ||
endDate ||
minAmount ||
maxAmount;
// Define columns
const columns: ColumnDef<Transaction>[] = [
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => {
const transaction = row.original;
const account = accounts?.find(
(acc) => acc.id === transaction.account_id,
);
const isPositive = transaction.transaction_value > 0;
return (
<div className="flex items-start space-x-3">
<div
className={`p-2 rounded-full ${
isPositive ? "bg-green-100" : "bg-red-100"
}`}
>
{isPositive ? (
<TrendingUp className="h-4 w-4 text-green-600" />
) : (
<TrendingDown className="h-4 w-4 text-red-600" />
)}
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-gray-900 truncate">
{transaction.description}
</h4>
<div className="text-xs text-gray-500 space-y-1">
{account && (
<p className="truncate">
{account.name || "Unnamed Account"} {" "}
{account.institution_id}
</p>
)}
{(transaction.creditor_name || transaction.debtor_name) && (
<p className="truncate">
{isPositive ? "From: " : "To: "}
{transaction.creditor_name || transaction.debtor_name}
</p>
)}
{transaction.reference && (
<p className="truncate">Ref: {transaction.reference}</p>
)}
</div>
</div>
</div>
);
},
},
{
accessorKey: "transaction_value",
header: "Amount",
cell: ({ row }) => {
const transaction = row.original;
const isPositive = transaction.transaction_value > 0;
return (
<div className="text-right">
<p
className={`text-lg font-semibold ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{isPositive ? "+" : ""}
{formatCurrency(
transaction.transaction_value,
transaction.transaction_currency,
)}
</p>
</div>
);
},
sortingFn: "basic",
},
{
accessorKey: "transaction_date",
header: "Date",
cell: ({ row }) => {
const transaction = row.original;
return (
<div className="text-sm text-gray-900">
{transaction.transaction_date
? formatDate(transaction.transaction_date)
: "No date"}
{transaction.booking_date &&
transaction.booking_date !== transaction.transaction_date && (
<p className="text-xs text-gray-400">
Booked: {formatDate(transaction.booking_date)}
</p>
)}
</div>
);
},
sortingFn: "datetime",
},
{
id: "actions",
header: "",
cell: ({ row }) => {
const transaction = row.original;
return (
<button
onClick={() => handleViewRaw(transaction)}
className="inline-flex items-center px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
title="View raw transaction data"
>
<Eye className="h-3 w-3 mr-1" />
Raw
</button>
);
},
},
];
const table = useReactTable({
data: transactions,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
state: {
sorting,
columnFilters,
globalFilter: searchTerm,
},
onGlobalFilterChange: setSearchTerm,
globalFilterFn: (row, _columnId, filterValue) => {
// Custom global filter that searches multiple fields
const transaction = row.original;
const searchLower = filterValue.toLowerCase();
const description = transaction.description || "";
const creditorName = transaction.creditor_name || "";
const debtorName = transaction.debtor_name || "";
const reference = transaction.reference || "";
return (
description.toLowerCase().includes(searchLower) ||
creditorName.toLowerCase().includes(searchLower) ||
debtorName.toLowerCase().includes(searchLower) ||
reference.toLowerCase().includes(searchLower)
);
},
});
if (transactionsLoading) {
return (
<div className="bg-white rounded-lg shadow">
<LoadingSpinner message="Loading transactions..." />
</div>
);
}
if (transactionsError) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-center text-center">
<div>
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Failed to load transactions
</h3>
<p className="text-gray-600 mb-4">
Unable to fetch transactions from the Leggen API.
</p>
<button
onClick={() => refetchTransactions()}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</button>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Filters */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900">Transactions</h3>
<div className="flex items-center space-x-2">
{hasActiveFilters && (
<button
onClick={clearFilters}
className="inline-flex items-center px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-full hover:bg-gray-200 transition-colors"
>
<X className="h-3 w-3 mr-1" />
Clear filters
</button>
)}
<button
onClick={() => setShowFilters(!showFilters)}
className="inline-flex items-center px-3 py-2 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 transition-colors"
>
<Filter className="h-4 w-4 mr-2" />
Filters
</button>
</div>
</div>
</div>
{showFilters && (
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
{/* Quick Date Filters */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Quick Filters
</label>
<div className="flex flex-wrap gap-2">
<button
onClick={() => setQuickDateFilter(7)}
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors"
>
Last 7 days
</button>
<button
onClick={() => setQuickDateFilter(30)}
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors"
>
Last 30 days
</button>
<button
onClick={setThisMonthFilter}
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors"
>
This month
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Search */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Search
</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Description, name, reference..."
className="pl-10 pr-3 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
{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-gray-300 border-t-blue-500 rounded-full"></div>
</div>
)}
</div>
</div>
{/* Account Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Account
</label>
<select
value={selectedAccount}
onChange={(e) => setSelectedAccount(e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">All accounts</option>
{accounts?.map((account) => (
<option key={account.id} value={account.id}>
{account.name || "Unnamed Account"} (
{account.institution_id})
</option>
))}
</select>
</div>
{/* Start Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Start Date
</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="pl-10 pr-3 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
{/* End Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
End Date
</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="pl-10 pr-3 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
{/* Amount Range Filters */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Min Amount
</label>
<input
type="number"
value={minAmount}
onChange={(e) => setMinAmount(e.target.value)}
placeholder="0.00"
step="0.01"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Max Amount
</label>
<input
type="number"
value={maxAmount}
onChange={(e) => setMaxAmount(e.target.value)}
placeholder="1000.00"
step="0.01"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
)}
{/* Results Summary */}
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200">
<p className="text-sm text-gray-600">
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..."
)}
)
{selectedAccount && accounts && (
<span className="ml-1">
for {accounts.find((acc) => acc.id === selectedAccount)?.name}
</span>
)}
</p>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
onClick={header.column.getToggleSortingHandler()}
>
<div className="flex items-center space-x-1">
<span>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</span>
{header.column.getCanSort() && (
<div className="flex flex-col">
<ChevronUp
className={`h-3 w-3 ${
header.column.getIsSorted() === "asc"
? "text-blue-600"
: "text-gray-400"
}`}
/>
<ChevronDown
className={`h-3 w-3 -mt-1 ${
header.column.getIsSorted() === "desc"
? "text-blue-600"
: "text-gray-400"
}`}
/>
</div>
)}
</div>
</th>
))}
</tr>
))}
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{table.getRowModel().rows.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className="px-6 py-12 text-center"
>
<div className="text-gray-400 mb-4">
<TrendingUp className="h-12 w-12 mx-auto" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
No transactions found
</h3>
<p className="text-gray-600">
{hasActiveFilters
? "Try adjusting your filters to see more results."
: "No transactions are available for the selected criteria."}
</p>
</td>
</tr>
) : (
table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-gray-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4 whitespace-nowrap">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{pagination && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="flex-1 flex justify-between sm:hidden">
<div className="flex space-x-2">
<button
onClick={() => setCurrentPage(1)}
disabled={pagination.page === 1}
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
First
</button>
<button
onClick={() =>
setCurrentPage((prev) => Math.max(1, prev - 1))
}
disabled={!pagination.has_prev}
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
</div>
<div className="flex space-x-2">
<button
onClick={() => setCurrentPage((prev) => prev + 1)}
disabled={!pagination.has_next}
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
<button
onClick={() => setCurrentPage(pagination.total_pages)}
disabled={pagination.page === pagination.total_pages}
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Last
</button>
</div>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div className="flex items-center space-x-2">
<p className="text-sm text-gray-700">
Showing{" "}
<span className="font-medium">
{(pagination.page - 1) * pagination.per_page + 1}
</span>{" "}
to{" "}
<span className="font-medium">
{Math.min(
pagination.page * pagination.per_page,
pagination.total,
)}
</span>{" "}
of <span className="font-medium">{pagination.total}</span>{" "}
results
</p>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<label className="text-sm text-gray-700">
Rows per page:
</label>
<select
value={perPage}
onChange={(e) => {
setPerPage(Number(e.target.value));
setCurrentPage(1); // Reset to first page when changing page size
}}
className="border border-gray-300 rounded px-2 py-1 text-sm"
>
{[10, 25, 50, 100].map((pageSize) => (
<option key={pageSize} value={pageSize}>
{pageSize}
</option>
))}
</select>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setCurrentPage(1)}
disabled={pagination.page === 1}
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
First
</button>
<button
onClick={() =>
setCurrentPage((prev) => Math.max(1, prev - 1))
}
disabled={!pagination.has_prev}
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-gray-700">
Page <span className="font-medium">{pagination.page}</span>{" "}
of{" "}
<span className="font-medium">
{pagination.total_pages}
</span>
</span>
<button
onClick={() => setCurrentPage((prev) => prev + 1)}
disabled={!pagination.has_next}
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
<button
onClick={() => setCurrentPage(pagination.total_pages)}
disabled={pagination.page === pagination.total_pages}
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Last
</button>
</div>
</div>
</div>
</div>
)}
</div>
{/* Raw Transaction Modal */}
<RawTransactionModal
isOpen={showRawModal}
onClose={handleCloseModal}
rawTransaction={selectedTransaction?.raw_transaction}
transactionId={selectedTransaction?.transaction_id || "unknown"}
/>
</div>
);
}

View File

@@ -70,12 +70,12 @@ export const apiClient = {
perPage?: number;
search?: string;
summaryOnly?: boolean;
}): Promise<Transaction[]> => {
}): Promise<ApiResponse<Transaction[]>> => {
const queryParams = new URLSearchParams();
if (params?.accountId) queryParams.append("account_id", params.accountId);
if (params?.startDate) queryParams.append("start_date", params.startDate);
if (params?.endDate) queryParams.append("end_date", params.endDate);
if (params?.startDate) queryParams.append("date_from", params.startDate);
if (params?.endDate) queryParams.append("date_to", params.endDate);
if (params?.page) queryParams.append("page", params.page.toString());
if (params?.perPage)
queryParams.append("per_page", params.perPage.toString());
@@ -87,7 +87,7 @@ export const apiClient = {
const response = await api.get<ApiResponse<Transaction[]>>(
`/transactions?${queryParams.toString()}`,
);
return response.data.data;
return response.data;
},
// Get transaction by ID

View File

@@ -3,29 +3,31 @@ import { useState } from "react";
import Sidebar from "../components/Sidebar";
import Header from "../components/Header";
export const Route = createRootRoute({
component: () => {
const [sidebarOpen, setSidebarOpen] = useState(false);
function RootLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex h-screen bg-gray-100">
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
return (
<div className="flex h-screen bg-gray-100">
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className="flex flex-col flex-1 overflow-hidden">
<Header setSidebarOpen={setSidebarOpen} />
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
<div className="flex flex-col flex-1 overflow-hidden">
<Header setSidebarOpen={setSidebarOpen} />
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
);
},
</div>
);
}
export const Route = createRootRoute({
component: RootLayout,
});

View File

@@ -1,8 +1,8 @@
import { createFileRoute } from "@tanstack/react-router";
import TransactionsList from "../components/TransactionsList";
import TransactionsTable from "../components/TransactionsTable";
export const Route = createFileRoute("/transactions")({
component: TransactionsList,
component: TransactionsTable,
validateSearch: (search) => ({
accountId: search.accountId as string | undefined,
startDate: search.startDate as string | undefined,

View File

@@ -124,6 +124,14 @@ export interface ApiResponse<T> {
data: T;
message?: string;
success: boolean;
pagination?: {
total: number;
page: number;
per_page: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
};
}
export interface PaginatedResponse<T> {

View File

@@ -4,8 +4,5 @@ import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
// https://vite.dev/config/
export default defineConfig({
plugins: [
TanStackRouterVite(),
react(),
],
plugins: [TanStackRouterVite(), react()],
});

View File

@@ -36,14 +36,11 @@ async def get_notification_settings() -> APIResponse:
if discord_config.get("webhook")
else None,
telegram=TelegramConfig(
token="***"
if (telegram_config.get("token") or telegram_config.get("api-key"))
else "",
chat_id=telegram_config.get("chat_id")
or telegram_config.get("chat-id", 0),
token="***" if telegram_config.get("api-key") else "",
chat_id=telegram_config.get("chat-id", 0),
enabled=telegram_config.get("enabled", True),
)
if (telegram_config.get("token") or telegram_config.get("api-key"))
if telegram_config.get("api-key")
else None,
filters=NotificationFilters(
case_insensitive=filters_config.get("case-insensitive", []),
@@ -79,8 +76,8 @@ async def update_notification_settings(settings: NotificationSettings) -> APIRes
if settings.telegram:
notifications_config["telegram"] = {
"token": settings.telegram.token,
"chat_id": settings.telegram.chat_id,
"api-key": settings.telegram.token,
"chat-id": settings.telegram.chat_id,
"enabled": settings.telegram.enabled,
}
@@ -155,24 +152,12 @@ async def get_notification_services() -> APIResponse:
"telegram": {
"name": "Telegram",
"enabled": bool(
(
notifications_config.get("telegram", {}).get("token")
or notifications_config.get("telegram", {}).get("api-key")
)
and (
notifications_config.get("telegram", {}).get("chat_id")
or notifications_config.get("telegram", {}).get("chat-id")
)
notifications_config.get("telegram", {}).get("api-key")
and notifications_config.get("telegram", {}).get("chat-id")
),
"configured": bool(
(
notifications_config.get("telegram", {}).get("token")
or notifications_config.get("telegram", {}).get("api-key")
)
and (
notifications_config.get("telegram", {}).get("chat_id")
or notifications_config.get("telegram", {}).get("chat-id")
)
notifications_config.get("telegram", {}).get("api-key")
and notifications_config.get("telegram", {}).get("chat-id")
),
"active": notifications_config.get("telegram", {}).get("enabled", True),
},

View File

@@ -3,7 +3,7 @@ from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
from leggend.api.models.common import APIResponse
from leggend.api.models.common import APIResponse, PaginatedResponse
from leggend.api.models.accounts import Transaction, TransactionSummary
from leggend.services.database_service import DatabaseService
@@ -11,10 +11,10 @@ router = APIRouter()
database_service = DatabaseService()
@router.get("/transactions", response_model=APIResponse)
@router.get("/transactions", response_model=PaginatedResponse)
async def get_all_transactions(
limit: Optional[int] = Query(default=100, le=500),
offset: Optional[int] = Query(default=0, ge=0),
page: int = Query(default=1, ge=1, description="Page number (1-based)"),
per_page: int = Query(default=50, le=500, description="Items per page"),
summary_only: bool = Query(
default=True, description="Return transaction summaries only"
),
@@ -34,9 +34,13 @@ async def get_all_transactions(
default=None, description="Search in transaction descriptions"
),
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
) -> APIResponse:
) -> PaginatedResponse:
"""Get all transactions from database with filtering options"""
try:
# Calculate offset from page and per_page
offset = (page - 1) * per_page
limit = per_page
# Get transactions from database instead of GoCardless API
db_transactions = await database_service.get_transactions_from_db(
account_id=account_id,
@@ -59,16 +63,6 @@ async def get_all_transactions(
search=search,
)
# Get total count for pagination info
total_transactions = await database_service.get_transaction_count_from_db(
account_id=account_id,
date_from=date_from,
date_to=date_to,
min_amount=min_amount,
max_amount=max_amount,
search=search,
)
data: Union[List[TransactionSummary], List[Transaction]]
if summary_only:
@@ -105,11 +99,19 @@ async def get_all_transactions(
for txn in db_transactions
]
actual_offset = offset or 0
return APIResponse(
total_pages = (total_transactions + per_page - 1) // per_page
return PaginatedResponse(
success=True,
data=data,
message=f"Retrieved {len(data)} transactions (showing {actual_offset + 1}-{actual_offset + len(data)} of {total_transactions})",
pagination={
"total": total_transactions,
"page": page,
"per_page": per_page,
"total_pages": total_pages,
"has_next": page < total_pages,
"has_prev": page > 1,
},
)
except Exception as e:

View File

@@ -109,9 +109,8 @@ class NotificationService:
"""Check if Telegram notifications are enabled"""
telegram_config = self.notifications_config.get("telegram", {})
return bool(
telegram_config.get("token")
or telegram_config.get("api-key")
and (telegram_config.get("chat_id") or telegram_config.get("chat-id"))
telegram_config.get("api-key")
and telegram_config.get("chat-id")
and telegram_config.get("enabled", True)
)
@@ -174,10 +173,8 @@ class NotificationService:
ctx.obj = {
"notifications": {
"telegram": {
"api-key": telegram_config.get("token")
or telegram_config.get("api-key"),
"chat-id": telegram_config.get("chat_id")
or telegram_config.get("chat-id"),
"api-key": telegram_config.get("api-key"),
"chat-id": telegram_config.get("chat-id"),
}
}
}

View File

@@ -1,6 +1,6 @@
[project]
name = "leggen"
version = "2025.9.6"
version = "2025.9.8"
description = "An Open Banking CLI"
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
requires-python = "~=3.13.0"

View File

@@ -153,8 +153,8 @@ class TestTransactionsAPI:
"min_amount=-50.0&"
"max_amount=0.0&"
"search=Coffee&"
"limit=10&"
"offset=5"
"page=2&"
"per_page=10"
)
assert response.status_code == 200
@@ -165,7 +165,7 @@ class TestTransactionsAPI:
mock_get_transactions.assert_called_once_with(
account_id="test-account-123",
limit=10,
offset=5,
offset=10, # (page-1) * per_page = (2-1) * 10 = 10
date_from="2025-09-01",
date_to="2025-09-02",
min_amount=-50.0,
@@ -194,7 +194,9 @@ class TestTransactionsAPI:
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 0
assert "0 transactions" in data["message"]
assert data["pagination"]["total"] == 0
assert data["pagination"]["page"] == 1
assert data["pagination"]["total_pages"] == 0
def test_get_transactions_database_error(
self, api_client, mock_config, mock_auth_token

2
uv.lock generated
View File

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