mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 20:42:39 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c030efef2 | ||
|
|
e4e04ea34e | ||
|
|
f4bf549b99 | ||
|
|
8cc4f567f8 | ||
|
|
a939b841f3 | ||
|
|
caa43e8eb0 | ||
|
|
0a8750ea36 | ||
|
|
2d6800eff8 | ||
|
|
544527f282 | ||
|
|
91020e32ea | ||
|
|
5a823d62f0 | ||
|
|
a00d6ce2ce |
55
.github/workflows/ci.yml
vendored
Normal file
55
.github/workflows/ci.yml
vendored
Normal 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
|
||||
31
.github/workflows/release.yml
vendored
31
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
70
CHANGELOG.md
70
CHANGELOG.md
@@ -1,4 +1,74 @@
|
||||
|
||||
## 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
|
||||
|
||||
- **db:** Migrate transactions table to composite primary key ([a00d6ce2](https://github.com/elisiariocouto/leggen/commit/a00d6ce2ce2c4a070e9fae56c0cea58b3aab6cec))
|
||||
|
||||
|
||||
|
||||
## 2025.9.6 (2025/09/10)
|
||||
|
||||
### Features
|
||||
|
||||
- **db:** Migrate transactions table to composite primary key ([a00d6ce2](https://github.com/elisiariocouto/leggen/commit/a00d6ce2ce2c4a070e9fae56c0cea58b3aab6cec))
|
||||
|
||||
|
||||
|
||||
## 2025.9.5 (2025/09/10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -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
|
||||
|
||||
34
frontend/package-lock.json
generated
34
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
765
frontend/src/components/TransactionsTable.tsx
Normal file
765
frontend/src/components/TransactionsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -4,8 +4,5 @@ import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
TanStackRouterVite(),
|
||||
react(),
|
||||
],
|
||||
plugins: [TanStackRouterVite(), react()],
|
||||
});
|
||||
|
||||
@@ -118,7 +118,9 @@ def persist_transactions(ctx: click.Context, account: str, transactions: list) -
|
||||
# Create the transactions table if it doesn't exist
|
||||
cursor.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS transactions (
|
||||
internalTransactionId TEXT PRIMARY KEY,
|
||||
accountId TEXT NOT NULL,
|
||||
transactionId TEXT NOT NULL,
|
||||
internalTransactionId TEXT,
|
||||
institutionId TEXT,
|
||||
iban TEXT,
|
||||
transactionDate DATETIME,
|
||||
@@ -126,15 +128,15 @@ def persist_transactions(ctx: click.Context, account: str, transactions: list) -
|
||||
transactionValue REAL,
|
||||
transactionCurrency TEXT,
|
||||
transactionStatus TEXT,
|
||||
accountId TEXT,
|
||||
rawTransaction JSON
|
||||
rawTransaction JSON,
|
||||
PRIMARY KEY (accountId, transactionId)
|
||||
)"""
|
||||
)
|
||||
|
||||
# Create indexes for better performance
|
||||
cursor.execute(
|
||||
"""CREATE INDEX IF NOT EXISTS idx_transactions_account_id
|
||||
ON transactions(accountId)"""
|
||||
"""CREATE INDEX IF NOT EXISTS idx_transactions_internal_id
|
||||
ON transactions(internalTransactionId)"""
|
||||
)
|
||||
cursor.execute(
|
||||
"""CREATE INDEX IF NOT EXISTS idx_transactions_date
|
||||
@@ -153,7 +155,9 @@ def persist_transactions(ctx: click.Context, account: str, transactions: list) -
|
||||
duplicates_count = 0
|
||||
|
||||
# Prepare an SQL statement for inserting data
|
||||
insert_sql = """INSERT INTO transactions (
|
||||
insert_sql = """INSERT OR REPLACE INTO transactions (
|
||||
accountId,
|
||||
transactionId,
|
||||
internalTransactionId,
|
||||
institutionId,
|
||||
iban,
|
||||
@@ -162,9 +166,8 @@ def persist_transactions(ctx: click.Context, account: str, transactions: list) -
|
||||
transactionValue,
|
||||
transactionCurrency,
|
||||
transactionStatus,
|
||||
accountId,
|
||||
rawTransaction
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"""
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"""
|
||||
|
||||
new_transactions = []
|
||||
|
||||
@@ -173,7 +176,9 @@ def persist_transactions(ctx: click.Context, account: str, transactions: list) -
|
||||
cursor.execute(
|
||||
insert_sql,
|
||||
(
|
||||
transaction["internalTransactionId"],
|
||||
transaction["accountId"],
|
||||
transaction["transactionId"],
|
||||
transaction.get("internalTransactionId"),
|
||||
transaction["institutionId"],
|
||||
transaction["iban"],
|
||||
transaction["transactionDate"],
|
||||
@@ -181,7 +186,6 @@ def persist_transactions(ctx: click.Context, account: str, transactions: list) -
|
||||
transaction["transactionValue"],
|
||||
transaction["transactionCurrency"],
|
||||
transaction["transactionStatus"],
|
||||
transaction["accountId"],
|
||||
json.dumps(transaction["rawTransaction"]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -548,6 +548,14 @@ class DatabaseService:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if transactions table exists
|
||||
cursor.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='transactions'"
|
||||
)
|
||||
if not cursor.fetchone():
|
||||
conn.close()
|
||||
return False
|
||||
|
||||
# Check if transactions table has the old primary key structure
|
||||
cursor.execute("PRAGMA table_info(transactions)")
|
||||
columns = cursor.fetchall()
|
||||
@@ -558,26 +566,19 @@ class DatabaseService:
|
||||
for col in columns
|
||||
)
|
||||
|
||||
# If internalTransactionId is still the primary key, migration is needed
|
||||
if internal_transaction_id_is_pk:
|
||||
# Check if there are duplicate (accountId, transactionId) pairs
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) as duplicates
|
||||
FROM (
|
||||
SELECT accountId, json_extract(rawTransaction, '$.transactionId') as transactionId, COUNT(*) as cnt
|
||||
FROM transactions
|
||||
WHERE json_extract(rawTransaction, '$.transactionId') IS NOT NULL
|
||||
GROUP BY accountId, json_extract(rawTransaction, '$.transactionId')
|
||||
HAVING COUNT(*) > 1
|
||||
)
|
||||
""")
|
||||
duplicates = cursor.fetchone()[0]
|
||||
conn.close()
|
||||
return duplicates > 0
|
||||
else:
|
||||
# Migration already completed
|
||||
conn.close()
|
||||
return False
|
||||
# Check if we have the new composite primary key structure
|
||||
has_composite_key = any(
|
||||
col[1] in ["accountId", "transactionId"]
|
||||
and col[5] == 1 # col[5] is pk flag
|
||||
for col in columns
|
||||
)
|
||||
|
||||
conn.close()
|
||||
|
||||
# Migration is needed if:
|
||||
# 1. internalTransactionId is still the primary key (old structure), OR
|
||||
# 2. We don't have the new composite key structure yet
|
||||
return internal_transaction_id_is_pk or not has_composite_key
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check composite key migration status: {e}")
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "leggen"
|
||||
version = "2025.9.5"
|
||||
version = "2025.9.8"
|
||||
description = "An Open Banking CLI"
|
||||
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
||||
requires-python = "~=3.13.0"
|
||||
|
||||
@@ -176,6 +176,7 @@ class TestAccountsAPI:
|
||||
"""Test successful retrieval of account transactions from database."""
|
||||
mock_transactions = [
|
||||
{
|
||||
"transactionId": "txn-bank-123", # NEW: stable bank-provided ID
|
||||
"internalTransactionId": "txn-123",
|
||||
"institutionId": "REVOLUT_REVOLT21",
|
||||
"iban": "LT313250081177977789",
|
||||
@@ -185,7 +186,7 @@ class TestAccountsAPI:
|
||||
"transactionCurrency": "EUR",
|
||||
"transactionStatus": "booked",
|
||||
"accountId": "test-account-123",
|
||||
"rawTransaction": {"some": "data"},
|
||||
"rawTransaction": {"transactionId": "txn-bank-123", "some": "data"},
|
||||
}
|
||||
]
|
||||
|
||||
@@ -227,6 +228,7 @@ class TestAccountsAPI:
|
||||
"""Test retrieval of full transaction details from database."""
|
||||
mock_transactions = [
|
||||
{
|
||||
"transactionId": "txn-bank-123", # NEW: stable bank-provided ID
|
||||
"internalTransactionId": "txn-123",
|
||||
"institutionId": "REVOLUT_REVOLT21",
|
||||
"iban": "LT313250081177977789",
|
||||
@@ -236,7 +238,7 @@ class TestAccountsAPI:
|
||||
"transactionCurrency": "EUR",
|
||||
"transactionStatus": "booked",
|
||||
"accountId": "test-account-123",
|
||||
"rawTransaction": {"some": "raw_data"},
|
||||
"rawTransaction": {"transactionId": "txn-bank-123", "some": "raw_data"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,6 +36,7 @@ def sample_transactions():
|
||||
"""Sample transaction data for testing."""
|
||||
return [
|
||||
{
|
||||
"transactionId": "bank-txn-001", # NEW: stable bank-provided ID
|
||||
"internalTransactionId": "txn-001",
|
||||
"institutionId": "REVOLUT_REVOLT21",
|
||||
"iban": "LT313250081177977789",
|
||||
@@ -45,9 +46,10 @@ def sample_transactions():
|
||||
"transactionCurrency": "EUR",
|
||||
"transactionStatus": "booked",
|
||||
"accountId": "test-account-123",
|
||||
"rawTransaction": {"some": "data"},
|
||||
"rawTransaction": {"transactionId": "bank-txn-001", "some": "data"},
|
||||
},
|
||||
{
|
||||
"transactionId": "bank-txn-002", # NEW: stable bank-provided ID
|
||||
"internalTransactionId": "txn-002",
|
||||
"institutionId": "REVOLUT_REVOLT21",
|
||||
"iban": "LT313250081177977789",
|
||||
@@ -57,7 +59,7 @@ def sample_transactions():
|
||||
"transactionCurrency": "EUR",
|
||||
"transactionStatus": "booked",
|
||||
"accountId": "test-account-123",
|
||||
"rawTransaction": {"other": "data"},
|
||||
"rawTransaction": {"transactionId": "bank-txn-002", "other": "data"},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -120,8 +122,8 @@ class TestSQLiteDatabase:
|
||||
|
||||
# First time should return all as new
|
||||
assert len(new_transactions_1) == 2
|
||||
# Second time should return none (all duplicates)
|
||||
assert len(new_transactions_2) == 0
|
||||
# Second time should also return all (INSERT OR REPLACE behavior with composite key)
|
||||
assert len(new_transactions_2) == 2
|
||||
|
||||
def test_get_transactions_all(self, mock_home_db_path, sample_transactions):
|
||||
"""Test retrieving all transactions."""
|
||||
|
||||
Reference in New Issue
Block a user