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:
Le-King-Fu 2026-02-08 22:54:18 +00:00
parent 273a17c0ac
commit 2b9fc49b51
10 changed files with 993 additions and 6 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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">&#8597;</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>
);
}

View 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,
};
}

View file

@ -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",

View file

@ -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",

View file

@ -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>
<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>
);
}

View file

@ -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`
);
}

View file

@ -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;
}