From 2b9fc49b516be3aa3c11d49b0392815ffd60a1a2 Mon Sep 17 00:00:00 2001 From: Le-King-Fu Date: Sun, 8 Feb 2026 22:54:18 +0000 Subject: [PATCH] feat: implement transactions page with filters, sorting, and inline editing Add paginated transaction list with search, category/source/date filters, sortable columns, inline category dropdown, expandable notes, and summary stats. Co-Authored-By: Claude Opus 4.6 --- .../transactions/TransactionFilterBar.tsx | 124 ++++++++ .../transactions/TransactionPagination.tsx | 51 ++++ .../transactions/TransactionSummaryBar.tsx | 60 ++++ .../transactions/TransactionTable.tsx | 180 +++++++++++ src/hooks/useTransactions.ts | 279 ++++++++++++++++++ src/i18n/locales/en.json | 28 +- src/i18n/locales/fr.json | 28 +- src/pages/TransactionsPage.tsx | 53 +++- src/services/transactionService.ts | 159 +++++++++- src/shared/types/index.ts | 37 +++ 10 files changed, 993 insertions(+), 6 deletions(-) create mode 100644 src/components/transactions/TransactionFilterBar.tsx create mode 100644 src/components/transactions/TransactionPagination.tsx create mode 100644 src/components/transactions/TransactionSummaryBar.tsx create mode 100644 src/components/transactions/TransactionTable.tsx create mode 100644 src/hooks/useTransactions.ts diff --git a/src/components/transactions/TransactionFilterBar.tsx b/src/components/transactions/TransactionFilterBar.tsx new file mode 100644 index 0000000..817346c --- /dev/null +++ b/src/components/transactions/TransactionFilterBar.tsx @@ -0,0 +1,124 @@ +import { useTranslation } from "react-i18next"; +import { Search } from "lucide-react"; +import type { TransactionFilters, Category, ImportSource } from "../../shared/types"; + +interface TransactionFilterBarProps { + filters: TransactionFilters; + categories: Category[]; + sources: ImportSource[]; + onFilterChange: (key: keyof TransactionFilters, value: unknown) => void; +} + +export default function TransactionFilterBar({ + filters, + categories, + sources, + onFilterChange, +}: TransactionFilterBarProps) { + const { t } = useTranslation(); + + const activeCount = [ + filters.search, + filters.categoryId !== null || filters.uncategorizedOnly, + filters.sourceId !== null, + filters.dateFrom, + filters.dateTo, + ].filter(Boolean).length; + + return ( +
+
+ {/* Search */} +
+ + onFilterChange("search", e.target.value)} + className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]" + /> +
+ + {/* Category */} + + + {/* Source */} + + + {/* Date range */} +
+ + onFilterChange("dateFrom", e.target.value || null) + } + placeholder={t("transactions.filters.dateFrom")} + className="px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]" + /> + + + onFilterChange("dateTo", e.target.value || null) + } + placeholder={t("transactions.filters.dateTo")} + className="px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]" + /> +
+ + {/* Active filter count */} + {activeCount > 0 && ( + + {activeCount} + + )} +
+
+ ); +} diff --git a/src/components/transactions/TransactionPagination.tsx b/src/components/transactions/TransactionPagination.tsx new file mode 100644 index 0000000..3b190fa --- /dev/null +++ b/src/components/transactions/TransactionPagination.tsx @@ -0,0 +1,51 @@ +import { useTranslation } from "react-i18next"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +interface TransactionPaginationProps { + page: number; + pageSize: number; + totalCount: number; + onPageChange: (page: number) => void; +} + +export default function TransactionPagination({ + page, + pageSize, + totalCount, + onPageChange, +}: TransactionPaginationProps) { + const { t } = useTranslation(); + + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + const from = totalCount === 0 ? 0 : (page - 1) * pageSize + 1; + const to = Math.min(page * pageSize, totalCount); + + return ( +
+ + {t("transactions.pagination.showing")} {from}–{to}{" "} + {t("transactions.pagination.of")} {totalCount} + + +
+ + + {page} / {totalPages} + + +
+
+ ); +} diff --git a/src/components/transactions/TransactionSummaryBar.tsx b/src/components/transactions/TransactionSummaryBar.tsx new file mode 100644 index 0000000..142b3f9 --- /dev/null +++ b/src/components/transactions/TransactionSummaryBar.tsx @@ -0,0 +1,60 @@ +import { useTranslation } from "react-i18next"; +import { Hash, TrendingUp, TrendingDown } from "lucide-react"; + +interface TransactionSummaryBarProps { + totalCount: number; + incomeTotal: number; + expenseTotal: number; +} + +export default function TransactionSummaryBar({ + totalCount, + incomeTotal, + expenseTotal, +}: TransactionSummaryBarProps) { + const { t } = useTranslation(); + + const stats = [ + { + icon: Hash, + label: t("transactions.summary.count"), + value: totalCount.toLocaleString(), + color: "text-[var(--foreground)]", + }, + { + icon: TrendingUp, + label: t("transactions.summary.income"), + value: incomeTotal.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), + color: "text-[var(--positive)]", + }, + { + icon: TrendingDown, + label: t("transactions.summary.expenses"), + value: expenseTotal.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), + color: "text-[var(--negative)]", + }, + ]; + + return ( +
+ {stats.map((stat) => ( +
+ +
+

{stat.label}

+

{stat.value}

+
+
+ ))} +
+ ); +} diff --git a/src/components/transactions/TransactionTable.tsx b/src/components/transactions/TransactionTable.tsx new file mode 100644 index 0000000..080e963 --- /dev/null +++ b/src/components/transactions/TransactionTable.tsx @@ -0,0 +1,180 @@ +import { Fragment, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronUp, ChevronDown, MessageSquare } from "lucide-react"; +import type { + TransactionRow, + TransactionSort, + Category, +} from "../../shared/types"; + +interface TransactionTableProps { + rows: TransactionRow[]; + sort: TransactionSort; + categories: Category[]; + onSort: (column: TransactionSort["column"]) => void; + onCategoryChange: (txId: number, categoryId: number | null) => void; + onNotesChange: (txId: number, notes: string) => void; +} + +function SortIcon({ + column, + sort, +}: { + column: TransactionSort["column"]; + sort: TransactionSort; +}) { + if (sort.column !== column) + return ; + return sort.direction === "asc" ? ( + + ) : ( + + ); +} + +export default function TransactionTable({ + rows, + sort, + categories, + onSort, + onCategoryChange, + onNotesChange, +}: TransactionTableProps) { + const { t } = useTranslation(); + const [expandedId, setExpandedId] = useState(null); + const [editingNotes, setEditingNotes] = useState(""); + + if (rows.length === 0) { + return ( +
+

{t("transactions.noTransactions")}

+
+ ); + } + + const columns: Array<{ + key: TransactionSort["column"]; + label: string; + align: string; + }> = [ + { key: "date", label: t("transactions.date"), align: "text-left" }, + { key: "description", label: t("transactions.description"), align: "text-left" }, + { key: "amount", label: t("transactions.amount"), align: "text-right" }, + { key: "category_name", label: t("transactions.category"), align: "text-left" }, + ]; + + const toggleNotes = (row: TransactionRow) => { + if (expandedId === row.id) { + setExpandedId(null); + } else { + setExpandedId(row.id); + setEditingNotes(row.notes ?? ""); + } + }; + + const handleNotesSave = (txId: number) => { + onNotesChange(txId, editingNotes); + setExpandedId(null); + }; + + return ( +
+ + + + {columns.map((col) => ( + + ))} + + + + {rows.map((row) => ( + + + + + + + + + {expandedId === row.id && ( + +
onSort(col.key)} + className={`px-3 py-2 ${col.align} text-xs font-medium text-[var(--muted-foreground)] cursor-pointer select-none hover:text-[var(--foreground)] transition-colors`} + > + {col.label} + + +
{row.date} + {row.description} + = 0 ? "text-[var(--positive)]" : "text-[var(--negative)]" + }`} + > + {row.amount.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + + + +
+
+