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",
|
"amount": "Amount",
|
||||||
"category": "Category",
|
"category": "Category",
|
||||||
"supplier": "Supplier",
|
"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": {
|
"categories": {
|
||||||
"title": "Categories",
|
"title": "Categories",
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,33 @@
|
||||||
"amount": "Montant",
|
"amount": "Montant",
|
||||||
"category": "Catégorie",
|
"category": "Catégorie",
|
||||||
"supplier": "Fournisseur",
|
"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": {
|
"categories": {
|
||||||
"title": "Catégories",
|
"title": "Catégories",
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,61 @@
|
||||||
import { useTranslation } from "react-i18next";
|
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() {
|
export default function TransactionsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { state, setFilter, setSort, setPage, updateCategory, saveNotes } =
|
||||||
|
useTransactions();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold mb-6">{t("transactions.title")}</h1>
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import { getDb } from "./db";
|
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(
|
export async function insertBatch(
|
||||||
transactions: Array<{
|
transactions: Array<{
|
||||||
|
|
@ -76,3 +84,152 @@ export async function findDuplicates(
|
||||||
|
|
||||||
return duplicates;
|
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"
|
| "confirm"
|
||||||
| "importing"
|
| "importing"
|
||||||
| "report";
|
| "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