Compare commits

...

6 Commits

Author SHA1 Message Date
Elisiário Couto
44c1fa22e0 chore(ci): Fix workflow permissions. 2025-12-08 20:21:04 +00:00
Elisiário Couto
7a81f9ff9c fix(frontend): Prevent full transactions page reload on search. 2025-12-08 16:06:29 +00:00
Elisiário Couto
362410c29b fix(frontend): Blur balances in transactions page cards. 2025-12-08 15:56:18 +00:00
copilot-swe-agent[bot]
6368b5c62c refactor(frontend): Address code review feedback on focus and currency handling.
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-12-07 19:45:50 +00:00
copilot-swe-agent[bot]
300b4e7db7 feat(frontend): Fix search focus issue and add transaction statistics.
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-12-07 19:45:49 +00:00
copilot-swe-agent[bot]
19814121de Initial plan 2025-12-07 19:43:56 +00:00
4 changed files with 149 additions and 2 deletions

View File

@@ -6,6 +6,9 @@ on:
pull_request:
branches: ["main", "dev"]
permissions:
contents: read
jobs:
test-python:
name: Test Python

View File

@@ -5,6 +5,11 @@ on:
tags:
- "**"
permissions:
contents: write
packages: write
id-token: write
jobs:
build:
runs-on: ubuntu-latest
@@ -44,6 +49,9 @@ jobs:
push-docker-backend:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -90,6 +98,9 @@ jobs:
push-docker-frontend:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -137,6 +148,8 @@ jobs:
create-github-release:
name: Create GitHub Release
runs-on: ubuntu-latest
permissions:
contents: write
needs: [build, publish-to-pypi, push-docker-backend, push-docker-frontend]
steps:
- name: Checkout

View File

@@ -123,6 +123,7 @@ export default function TransactionsTable() {
search: debouncedSearchTerm || undefined,
summaryOnly: false,
}),
placeholderData: (previousData) => previousData,
});
const transactions = transactionsResponse?.data || [];
@@ -355,6 +356,28 @@ export default function TransactionsTable() {
);
}
// Calculate stats from current page transactions
const totalIncome = transactions
.filter((t: Transaction) => t.transaction_value > 0)
.reduce((sum: number, t: Transaction) => sum + t.transaction_value, 0);
const totalExpenses = Math.abs(
transactions
.filter((t: Transaction) => t.transaction_value < 0)
.reduce((sum: number, t: Transaction) => sum + t.transaction_value, 0)
);
// Get currency from first transaction, fallback to EUR
const displayCurrency = transactions.length > 0 ? transactions[0].transaction_currency : "EUR";
const stats = {
totalCount: pagination?.total || 0,
pageCount: transactions.length,
totalIncome,
totalExpenses,
netChange: totalIncome - totalExpenses,
};
return (
<div className="space-y-6 max-w-full">
{/* New FilterBar */}
@@ -366,6 +389,78 @@ export default function TransactionsTable() {
isSearchLoading={isSearchLoading}
/>
{/* Transaction Statistics */}
{transactions.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Showing
</p>
<p className="text-2xl font-bold text-foreground mt-1">
{stats.pageCount}
</p>
<p className="text-xs text-muted-foreground mt-1">
of {stats.totalCount} total
</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Income
</p>
<BlurredValue className="text-2xl font-bold text-green-600 mt-1 block">
+{formatCurrency(stats.totalIncome, displayCurrency)}
</BlurredValue>
</div>
<TrendingUp className="h-8 w-8 text-green-600 opacity-50" />
</div>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Expenses
</p>
<BlurredValue className="text-2xl font-bold text-red-600 mt-1 block">
-{formatCurrency(stats.totalExpenses, displayCurrency)}
</BlurredValue>
</div>
<TrendingDown className="h-8 w-8 text-red-600 opacity-50" />
</div>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Net Change
</p>
<BlurredValue
className={`text-2xl font-bold mt-1 block ${
stats.netChange >= 0 ? "text-green-600" : "text-red-600"
}`}
>
{stats.netChange >= 0 ? "+" : ""}
{formatCurrency(stats.netChange, displayCurrency)}
</BlurredValue>
</div>
{stats.netChange >= 0 ? (
<TrendingUp className="h-8 w-8 text-green-600 opacity-50" />
) : (
<TrendingDown className="h-8 w-8 text-red-600 opacity-50" />
)}
</div>
</Card>
</div>
)}
{/* Responsive Table/Cards */}
<Card>
{/* Desktop Table View (hidden on mobile) */}

View File

@@ -1,3 +1,4 @@
import { useRef, useEffect } from "react";
import { Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
@@ -30,6 +31,21 @@ export function FilterBar({
isSearchLoading = false,
className,
}: FilterBarProps) {
const searchInputRef = useRef<HTMLInputElement>(null);
const cursorPositionRef = useRef<number | null>(null);
// Maintain focus and cursor position on search input during re-renders
useEffect(() => {
const currentInput = searchInputRef.current;
if (!currentInput) return;
// Restore focus and cursor position after data fetches complete
if (cursorPositionRef.current !== null && document.activeElement !== currentInput) {
currentInput.focus();
currentInput.setSelectionRange(cursorPositionRef.current, cursorPositionRef.current);
}
});
const hasActiveFilters =
filterState.searchTerm ||
filterState.selectedAccount ||
@@ -61,9 +77,19 @@ export function FilterBar({
<div className="relative w-[200px]">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchInputRef}
placeholder="Search transactions..."
value={filterState.searchTerm}
onChange={(e) => onFilterChange("searchTerm", e.target.value)}
onChange={(e) => {
cursorPositionRef.current = e.target.selectionStart;
onFilterChange("searchTerm", e.target.value);
}}
onFocus={() => {
cursorPositionRef.current = searchInputRef.current?.selectionStart ?? null;
}}
onBlur={() => {
cursorPositionRef.current = null;
}}
className="pl-9 pr-8 bg-background"
/>
{isSearchLoading && (
@@ -99,9 +125,19 @@ export function FilterBar({
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchInputRef}
placeholder="Search..."
value={filterState.searchTerm}
onChange={(e) => onFilterChange("searchTerm", e.target.value)}
onChange={(e) => {
cursorPositionRef.current = e.target.selectionStart;
onFilterChange("searchTerm", e.target.value);
}}
onFocus={() => {
cursorPositionRef.current = searchInputRef.current?.selectionStart ?? null;
}}
onBlur={() => {
cursorPositionRef.current = null;
}}
className="pl-9 pr-8 bg-background w-full"
/>
{isSearchLoading && (