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 <noreply@anthropic.com>
This commit is contained in:
parent
273a17c0ac
commit
2b9fc49b51
10 changed files with 993 additions and 6 deletions
124
src/components/transactions/TransactionFilterBar.tsx
Normal file
124
src/components/transactions/TransactionFilterBar.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)] mb-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("transactions.filters.searchPlaceholder")}
|
||||
value={filters.search}
|
||||
onChange={(e) => 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)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<select
|
||||
value={
|
||||
filters.uncategorizedOnly
|
||||
? "uncategorized"
|
||||
: filters.categoryId?.toString() ?? ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === "uncategorized") {
|
||||
onFilterChange("uncategorizedOnly", true);
|
||||
onFilterChange("categoryId", null);
|
||||
} else {
|
||||
onFilterChange("uncategorizedOnly", false);
|
||||
onFilterChange("categoryId", val ? Number(val) : null);
|
||||
}
|
||||
}}
|
||||
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)]"
|
||||
>
|
||||
<option value="">{t("transactions.filters.allCategories")}</option>
|
||||
<option value="uncategorized">
|
||||
{t("transactions.filters.uncategorized")}
|
||||
</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Source */}
|
||||
<select
|
||||
value={filters.sourceId?.toString() ?? ""}
|
||||
onChange={(e) =>
|
||||
onFilterChange("sourceId", e.target.value ? Number(e.target.value) : null)
|
||||
}
|
||||
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)]"
|
||||
>
|
||||
<option value="">{t("transactions.filters.allSources")}</option>
|
||||
{sources.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Date range */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateFrom ?? ""}
|
||||
onChange={(e) =>
|
||||
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)]"
|
||||
/>
|
||||
<span className="text-[var(--muted-foreground)] text-sm">—</span>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateTo ?? ""}
|
||||
onChange={(e) =>
|
||||
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)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Active filter count */}
|
||||
{activeCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 text-xs font-medium rounded-full bg-[var(--primary)] text-white">
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
src/components/transactions/TransactionPagination.tsx
Normal file
51
src/components/transactions/TransactionPagination.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex items-center justify-between mt-4 text-sm text-[var(--muted-foreground)]">
|
||||
<span>
|
||||
{t("transactions.pagination.showing")} {from}–{to}{" "}
|
||||
{t("transactions.pagination.of")} {totalCount}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="p-2 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="px-3 py-1 text-[var(--foreground)] font-medium">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="p-2 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/components/transactions/TransactionSummaryBar.tsx
Normal file
60
src/components/transactions/TransactionSummaryBar.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex flex-wrap gap-4 mb-4">
|
||||
{stats.map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="flex items-center gap-3 bg-[var(--card)] rounded-xl px-4 py-3 border border-[var(--border)] min-w-[160px]"
|
||||
>
|
||||
<stat.icon size={16} className={stat.color} />
|
||||
<div>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">{stat.label}</p>
|
||||
<p className={`text-lg font-bold ${stat.color}`}>{stat.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
src/components/transactions/TransactionTable.tsx
Normal file
180
src/components/transactions/TransactionTable.tsx
Normal file
|
|
@ -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 <span className="ml-1 text-[var(--muted-foreground)] opacity-30">↕</span>;
|
||||
return sort.direction === "asc" ? (
|
||||
<ChevronUp size={14} className="ml-1 inline" />
|
||||
) : (
|
||||
<ChevronDown size={14} className="ml-1 inline" />
|
||||
);
|
||||
}
|
||||
|
||||
export default function TransactionTable({
|
||||
rows,
|
||||
sort,
|
||||
categories,
|
||||
onSort,
|
||||
onCategoryChange,
|
||||
onNotesChange,
|
||||
}: TransactionTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [editingNotes, setEditingNotes] = useState("");
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center text-[var(--muted-foreground)]">
|
||||
<p>{t("transactions.noTransactions")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="overflow-x-auto rounded-xl border border-[var(--border)]">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[var(--muted)]">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
onClick={() => 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}
|
||||
<SortIcon column={col.key} sort={sort} />
|
||||
</th>
|
||||
))}
|
||||
<th className="px-3 py-2 w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border)]">
|
||||
{rows.map((row) => (
|
||||
<Fragment key={row.id}>
|
||||
<tr
|
||||
className="hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<td className="px-3 py-2 whitespace-nowrap">{row.date}</td>
|
||||
<td className="px-3 py-2 max-w-xs truncate" title={row.description}>
|
||||
{row.description}
|
||||
</td>
|
||||
<td
|
||||
className={`px-3 py-2 text-right font-mono whitespace-nowrap ${
|
||||
row.amount >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
|
||||
}`}
|
||||
>
|
||||
{row.amount.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<select
|
||||
value={row.category_id?.toString() ?? ""}
|
||||
onChange={(e) =>
|
||||
onCategoryChange(
|
||||
row.id,
|
||||
e.target.value ? Number(e.target.value) : null
|
||||
)
|
||||
}
|
||||
className="w-full px-2 py-1 text-sm rounded border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||
>
|
||||
<option value="">
|
||||
{t("transactions.table.noCategory")}
|
||||
</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<button
|
||||
onClick={() => toggleNotes(row)}
|
||||
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors ${
|
||||
row.notes
|
||||
? "text-[var(--primary)]"
|
||||
: "text-[var(--muted-foreground)]"
|
||||
}`}
|
||||
title={t("transactions.notes.placeholder")}
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{expandedId === row.id && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-2 bg-[var(--muted)]">
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={editingNotes}
|
||||
onChange={(e) => setEditingNotes(e.target.value)}
|
||||
placeholder={t("transactions.notes.placeholder")}
|
||||
rows={2}
|
||||
className="flex-1 px-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)] resize-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleNotesSave(row.id)}
|
||||
className="px-4 py-2 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity self-end"
|
||||
>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
279
src/hooks/useTransactions.ts
Normal file
279
src/hooks/useTransactions.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||
import type {
|
||||
TransactionRow,
|
||||
TransactionFilters,
|
||||
TransactionSort,
|
||||
TransactionPageResult,
|
||||
Category,
|
||||
ImportSource,
|
||||
} from "../shared/types";
|
||||
import {
|
||||
getTransactionPage,
|
||||
updateTransactionCategory,
|
||||
updateTransactionNotes,
|
||||
getAllCategories,
|
||||
getAllImportSources,
|
||||
} from "../services/transactionService";
|
||||
|
||||
interface TransactionsState {
|
||||
rows: TransactionRow[];
|
||||
totalCount: number;
|
||||
totalAmount: number;
|
||||
incomeTotal: number;
|
||||
expenseTotal: number;
|
||||
filters: TransactionFilters;
|
||||
sort: TransactionSort;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
categories: Category[];
|
||||
sources: ImportSource[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type TransactionsAction =
|
||||
| { type: "SET_LOADING"; payload: boolean }
|
||||
| { type: "SET_ERROR"; payload: string | null }
|
||||
| { type: "SET_PAGE_RESULT"; payload: TransactionPageResult }
|
||||
| { type: "SET_FILTER"; payload: { key: keyof TransactionFilters; value: unknown } }
|
||||
| { type: "SET_SORT"; payload: TransactionSort }
|
||||
| { type: "SET_PAGE"; payload: number }
|
||||
| { type: "SET_CATEGORIES"; payload: Category[] }
|
||||
| { type: "SET_SOURCES"; payload: ImportSource[] }
|
||||
| { type: "UPDATE_ROW_CATEGORY"; payload: { txId: number; categoryId: number | null; categoryName: string | null; categoryColor: string | null } }
|
||||
| { type: "UPDATE_ROW_NOTES"; payload: { txId: number; notes: string } };
|
||||
|
||||
const initialFilters: TransactionFilters = {
|
||||
search: "",
|
||||
categoryId: null,
|
||||
sourceId: null,
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
uncategorizedOnly: false,
|
||||
};
|
||||
|
||||
const initialState: TransactionsState = {
|
||||
rows: [],
|
||||
totalCount: 0,
|
||||
totalAmount: 0,
|
||||
incomeTotal: 0,
|
||||
expenseTotal: 0,
|
||||
filters: initialFilters,
|
||||
sort: { column: "date", direction: "desc" },
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
categories: [],
|
||||
sources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function reducer(state: TransactionsState, action: TransactionsAction): TransactionsState {
|
||||
switch (action.type) {
|
||||
case "SET_LOADING":
|
||||
return { ...state, isLoading: action.payload };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.payload, isLoading: false };
|
||||
case "SET_PAGE_RESULT":
|
||||
return {
|
||||
...state,
|
||||
rows: action.payload.rows,
|
||||
totalCount: action.payload.totalCount,
|
||||
totalAmount: action.payload.totalAmount,
|
||||
incomeTotal: action.payload.incomeTotal,
|
||||
expenseTotal: action.payload.expenseTotal,
|
||||
isLoading: false,
|
||||
};
|
||||
case "SET_FILTER":
|
||||
return {
|
||||
...state,
|
||||
filters: { ...state.filters, [action.payload.key]: action.payload.value },
|
||||
page: 1,
|
||||
};
|
||||
case "SET_SORT":
|
||||
return { ...state, sort: action.payload };
|
||||
case "SET_PAGE":
|
||||
return { ...state, page: action.payload };
|
||||
case "SET_CATEGORIES":
|
||||
return { ...state, categories: action.payload };
|
||||
case "SET_SOURCES":
|
||||
return { ...state, sources: action.payload };
|
||||
case "UPDATE_ROW_CATEGORY":
|
||||
return {
|
||||
...state,
|
||||
rows: state.rows.map((r) =>
|
||||
r.id === action.payload.txId
|
||||
? {
|
||||
...r,
|
||||
category_id: action.payload.categoryId,
|
||||
category_name: action.payload.categoryName,
|
||||
category_color: action.payload.categoryColor,
|
||||
is_manually_categorized: true,
|
||||
}
|
||||
: r
|
||||
),
|
||||
};
|
||||
case "UPDATE_ROW_NOTES":
|
||||
return {
|
||||
...state,
|
||||
rows: state.rows.map((r) =>
|
||||
r.id === action.payload.txId ? { ...r, notes: action.payload.notes } : r
|
||||
),
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useTransactions() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const fetchIdRef = useRef(0);
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const debouncedFiltersRef = useRef(state.filters);
|
||||
|
||||
// Load categories and sources once on mount
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [cats, srcs] = await Promise.all([
|
||||
getAllCategories(),
|
||||
getAllImportSources(),
|
||||
]);
|
||||
dispatch({ type: "SET_CATEGORIES", payload: cats });
|
||||
dispatch({ type: "SET_SOURCES", payload: srcs });
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
payload: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Fetch transactions when filters/sort/page change
|
||||
const fetchData = useCallback(async (
|
||||
filters: TransactionFilters,
|
||||
sort: TransactionSort,
|
||||
page: number,
|
||||
pageSize: number
|
||||
) => {
|
||||
const fetchId = ++fetchIdRef.current;
|
||||
dispatch({ type: "SET_LOADING", payload: true });
|
||||
dispatch({ type: "SET_ERROR", payload: null });
|
||||
|
||||
try {
|
||||
const result = await getTransactionPage(filters, sort, page, pageSize);
|
||||
// Ignore stale responses
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_PAGE_RESULT", payload: result });
|
||||
} catch (e) {
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
payload: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-fetch when sort, page, or non-search filters change
|
||||
useEffect(() => {
|
||||
fetchData(debouncedFiltersRef.current, state.sort, state.page, state.pageSize);
|
||||
}, [state.sort, state.page, state.pageSize, fetchData]);
|
||||
|
||||
// Debounced search — trigger fetch after 300ms
|
||||
useEffect(() => {
|
||||
if (searchTimerRef.current) {
|
||||
clearTimeout(searchTimerRef.current);
|
||||
}
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
debouncedFiltersRef.current = state.filters;
|
||||
fetchData(state.filters, state.sort, state.page, state.pageSize);
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
if (searchTimerRef.current) {
|
||||
clearTimeout(searchTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [state.filters]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const setFilter = useCallback(
|
||||
(key: keyof TransactionFilters, value: unknown) => {
|
||||
dispatch({ type: "SET_FILTER", payload: { key, value } });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const setSort = useCallback(
|
||||
(column: TransactionSort["column"]) => {
|
||||
dispatch({
|
||||
type: "SET_SORT",
|
||||
payload: {
|
||||
column,
|
||||
direction:
|
||||
state.sort.column === column && state.sort.direction === "desc"
|
||||
? "asc"
|
||||
: "desc",
|
||||
},
|
||||
});
|
||||
},
|
||||
[state.sort]
|
||||
);
|
||||
|
||||
const setPage = useCallback((page: number) => {
|
||||
dispatch({ type: "SET_PAGE", payload: page });
|
||||
}, []);
|
||||
|
||||
const updateCategory = useCallback(
|
||||
async (txId: number, categoryId: number | null) => {
|
||||
const cat = state.categories.find((c) => c.id === categoryId) ?? null;
|
||||
dispatch({
|
||||
type: "UPDATE_ROW_CATEGORY",
|
||||
payload: {
|
||||
txId,
|
||||
categoryId,
|
||||
categoryName: cat?.name ?? null,
|
||||
categoryColor: cat?.color ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await updateTransactionCategory(txId, categoryId, true);
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
payload: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
// Refetch to restore correct state
|
||||
fetchData(debouncedFiltersRef.current, state.sort, state.page, state.pageSize);
|
||||
}
|
||||
},
|
||||
[state.categories, state.sort, state.page, state.pageSize, fetchData]
|
||||
);
|
||||
|
||||
const saveNotes = useCallback(
|
||||
async (txId: number, notes: string) => {
|
||||
dispatch({ type: "UPDATE_ROW_NOTES", payload: { txId, notes } });
|
||||
|
||||
try {
|
||||
await updateTransactionNotes(txId, notes);
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
payload: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
fetchData(debouncedFiltersRef.current, state.sort, state.page, state.pageSize);
|
||||
}
|
||||
},
|
||||
[state.sort, state.page, state.pageSize, fetchData]
|
||||
);
|
||||
|
||||
return {
|
||||
state,
|
||||
setFilter,
|
||||
setSort,
|
||||
setPage,
|
||||
updateCategory,
|
||||
saveNotes,
|
||||
};
|
||||
}
|
||||
|
|
@ -129,7 +129,33 @@
|
|||
"amount": "Amount",
|
||||
"category": "Category",
|
||||
"supplier": "Supplier",
|
||||
"noTransactions": "No transactions found."
|
||||
"noTransactions": "No transactions found.",
|
||||
"filters": {
|
||||
"search": "Search",
|
||||
"searchPlaceholder": "Search by description...",
|
||||
"allCategories": "All categories",
|
||||
"allSources": "All sources",
|
||||
"uncategorized": "Uncategorized",
|
||||
"dateFrom": "From",
|
||||
"dateTo": "To"
|
||||
},
|
||||
"summary": {
|
||||
"count": "Transactions",
|
||||
"income": "Income",
|
||||
"expenses": "Expenses"
|
||||
},
|
||||
"table": {
|
||||
"noCategory": "— No category —"
|
||||
},
|
||||
"pagination": {
|
||||
"showing": "Showing",
|
||||
"of": "of",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"notes": {
|
||||
"placeholder": "Add a note..."
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"title": "Categories",
|
||||
|
|
|
|||
|
|
@ -129,7 +129,33 @@
|
|||
"amount": "Montant",
|
||||
"category": "Catégorie",
|
||||
"supplier": "Fournisseur",
|
||||
"noTransactions": "Aucune transaction trouvée."
|
||||
"noTransactions": "Aucune transaction trouvée.",
|
||||
"filters": {
|
||||
"search": "Rechercher",
|
||||
"searchPlaceholder": "Rechercher par description...",
|
||||
"allCategories": "Toutes les catégories",
|
||||
"allSources": "Toutes les sources",
|
||||
"uncategorized": "Non catégorisées",
|
||||
"dateFrom": "Du",
|
||||
"dateTo": "Au"
|
||||
},
|
||||
"summary": {
|
||||
"count": "Transactions",
|
||||
"income": "Revenus",
|
||||
"expenses": "Dépenses"
|
||||
},
|
||||
"table": {
|
||||
"noCategory": "— Sans catégorie —"
|
||||
},
|
||||
"pagination": {
|
||||
"showing": "Affichage",
|
||||
"of": "sur",
|
||||
"previous": "Précédent",
|
||||
"next": "Suivant"
|
||||
},
|
||||
"notes": {
|
||||
"placeholder": "Ajouter une note..."
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"title": "Catégories",
|
||||
|
|
|
|||
|
|
@ -1,14 +1,61 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { useTransactions } from "../hooks/useTransactions";
|
||||
import TransactionFilterBar from "../components/transactions/TransactionFilterBar";
|
||||
import TransactionSummaryBar from "../components/transactions/TransactionSummaryBar";
|
||||
import TransactionTable from "../components/transactions/TransactionTable";
|
||||
import TransactionPagination from "../components/transactions/TransactionPagination";
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { state, setFilter, setSort, setPage, updateCategory, saveNotes } =
|
||||
useTransactions();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">{t("transactions.title")}</h1>
|
||||
<div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center text-[var(--muted-foreground)]">
|
||||
<p>{t("transactions.noTransactions")}</p>
|
||||
</div>
|
||||
|
||||
<TransactionFilterBar
|
||||
filters={state.filters}
|
||||
categories={state.categories}
|
||||
sources={state.sources}
|
||||
onFilterChange={setFilter}
|
||||
/>
|
||||
|
||||
<TransactionSummaryBar
|
||||
totalCount={state.totalCount}
|
||||
incomeTotal={state.incomeTotal}
|
||||
expenseTotal={state.expenseTotal}
|
||||
/>
|
||||
|
||||
{state.error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-[color-mix(in_srgb,var(--negative)_10%,var(--card))] border border-[var(--negative)] text-[var(--negative)] text-sm">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.isLoading ? (
|
||||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<TransactionTable
|
||||
rows={state.rows}
|
||||
sort={state.sort}
|
||||
categories={state.categories}
|
||||
onSort={setSort}
|
||||
onCategoryChange={updateCategory}
|
||||
onNotesChange={saveNotes}
|
||||
/>
|
||||
|
||||
<TransactionPagination
|
||||
page={state.page}
|
||||
pageSize={state.pageSize}
|
||||
totalCount={state.totalCount}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import { getDb } from "./db";
|
||||
import type { Transaction } from "../shared/types";
|
||||
import type {
|
||||
Transaction,
|
||||
TransactionRow,
|
||||
TransactionFilters,
|
||||
TransactionSort,
|
||||
TransactionPageResult,
|
||||
Category,
|
||||
ImportSource,
|
||||
} from "../shared/types";
|
||||
|
||||
export async function insertBatch(
|
||||
transactions: Array<{
|
||||
|
|
@ -76,3 +84,152 @@ export async function findDuplicates(
|
|||
|
||||
return duplicates;
|
||||
}
|
||||
|
||||
export async function getTransactionPage(
|
||||
filters: TransactionFilters,
|
||||
sort: TransactionSort,
|
||||
page: number,
|
||||
pageSize: number
|
||||
): Promise<TransactionPageResult> {
|
||||
const db = await getDb();
|
||||
|
||||
const whereClauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters.search) {
|
||||
whereClauses.push(`t.description LIKE $${paramIndex}`);
|
||||
params.push(`%${filters.search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (filters.uncategorizedOnly) {
|
||||
whereClauses.push(`t.category_id IS NULL`);
|
||||
} else if (filters.categoryId !== null) {
|
||||
whereClauses.push(`t.category_id = $${paramIndex}`);
|
||||
params.push(filters.categoryId);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (filters.sourceId !== null) {
|
||||
whereClauses.push(`t.source_id = $${paramIndex}`);
|
||||
params.push(filters.sourceId);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (filters.dateFrom) {
|
||||
whereClauses.push(`t.date >= $${paramIndex}`);
|
||||
params.push(filters.dateFrom);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (filters.dateTo) {
|
||||
whereClauses.push(`t.date <= $${paramIndex}`);
|
||||
params.push(filters.dateTo);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereSQL =
|
||||
whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
||||
|
||||
// Map sort column to SQL
|
||||
const sortColumnMap: Record<string, string> = {
|
||||
date: "t.date",
|
||||
description: "t.description",
|
||||
amount: "t.amount",
|
||||
category_name: "c.name",
|
||||
};
|
||||
const orderSQL = `ORDER BY ${sortColumnMap[sort.column]} ${sort.direction}`;
|
||||
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// Rows query
|
||||
const rowsSQL = `
|
||||
SELECT t.id, t.date, t.description, t.amount, t.category_id,
|
||||
c.name AS category_name, c.color AS category_color,
|
||||
s.name AS source_name, t.notes, t.is_manually_categorized
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
LEFT JOIN import_sources s ON t.source_id = s.id
|
||||
${whereSQL}
|
||||
${orderSQL}
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
const rowsParams = [...params, pageSize, offset];
|
||||
|
||||
// Totals query
|
||||
const totalsSQL = `
|
||||
SELECT COUNT(*) AS totalCount,
|
||||
COALESCE(SUM(t.amount), 0) AS totalAmount,
|
||||
COALESCE(SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE 0 END), 0) AS incomeTotal,
|
||||
COALESCE(SUM(CASE WHEN t.amount < 0 THEN t.amount ELSE 0 END), 0) AS expenseTotal
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
LEFT JOIN import_sources s ON t.source_id = s.id
|
||||
${whereSQL}
|
||||
`;
|
||||
|
||||
const [rows, totals] = await Promise.all([
|
||||
db.select<TransactionRow[]>(rowsSQL, rowsParams),
|
||||
db.select<
|
||||
Array<{
|
||||
totalCount: number;
|
||||
totalAmount: number;
|
||||
incomeTotal: number;
|
||||
expenseTotal: number;
|
||||
}>
|
||||
>(totalsSQL, params),
|
||||
]);
|
||||
|
||||
const t = totals[0] ?? {
|
||||
totalCount: 0,
|
||||
totalAmount: 0,
|
||||
incomeTotal: 0,
|
||||
expenseTotal: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
rows,
|
||||
totalCount: t.totalCount,
|
||||
totalAmount: t.totalAmount,
|
||||
incomeTotal: t.incomeTotal,
|
||||
expenseTotal: t.expenseTotal,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateTransactionCategory(
|
||||
txId: number,
|
||||
categoryId: number | null,
|
||||
isManual: boolean
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.execute(
|
||||
`UPDATE transactions SET category_id = $1, is_manually_categorized = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3`,
|
||||
[categoryId, isManual, txId]
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateTransactionNotes(
|
||||
txId: number,
|
||||
notes: string
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.execute(
|
||||
`UPDATE transactions SET notes = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`,
|
||||
[notes, txId]
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAllCategories(): Promise<Category[]> {
|
||||
const db = await getDb();
|
||||
return db.select<Category[]>(
|
||||
`SELECT * FROM categories WHERE is_active = 1 ORDER BY sort_order, name`
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAllImportSources(): Promise<ImportSource[]> {
|
||||
const db = await getDb();
|
||||
return db.select<ImportSource[]>(
|
||||
`SELECT * FROM import_sources ORDER BY name`
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,3 +210,40 @@ export type ImportWizardStep =
|
|||
| "confirm"
|
||||
| "importing"
|
||||
| "report";
|
||||
|
||||
// --- Transaction Page Types ---
|
||||
|
||||
export interface TransactionRow {
|
||||
id: number;
|
||||
date: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
category_id: number | null;
|
||||
category_name: string | null;
|
||||
category_color: string | null;
|
||||
source_name: string | null;
|
||||
notes: string | null;
|
||||
is_manually_categorized: boolean;
|
||||
}
|
||||
|
||||
export interface TransactionFilters {
|
||||
search: string;
|
||||
categoryId: number | null;
|
||||
sourceId: number | null;
|
||||
dateFrom: string | null;
|
||||
dateTo: string | null;
|
||||
uncategorizedOnly: boolean;
|
||||
}
|
||||
|
||||
export interface TransactionSort {
|
||||
column: "date" | "description" | "amount" | "category_name";
|
||||
direction: "asc" | "desc";
|
||||
}
|
||||
|
||||
export interface TransactionPageResult {
|
||||
rows: TransactionRow[];
|
||||
totalCount: number;
|
||||
totalAmount: number;
|
||||
incomeTotal: number;
|
||||
expenseTotal: number;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue