Files
family_budget/frontend/src/pages/HistoryPage.tsx
vakabunga 8b57dd987e Revert SSE streaming for PDF import, use synchronous flow
SSE streaming added unnecessary complexity and latency due to
buffering issues across Node.js event loop, Nginx proxy, and
Docker layers. Reverted to a simple synchronous request/response
for PDF conversion. Kept extractLlmErrorMessage for user-friendly
LLM errors, lazy-loaded pdf-parse, and extended Nginx timeout.
2026-03-14 20:12:27 +03:00

272 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import type {
Transaction,
Account,
Category,
PaginatedResponse,
GetTransactionsParams,
} from '@family-budget/shared';
import { getTransactions } from '../api/transactions';
import { getAccounts } from '../api/accounts';
import { getCategories } from '../api/categories';
import {
TransactionFilters,
type FiltersState,
} from '../components/TransactionFilters';
import { TransactionTable } from '../components/TransactionTable';
import { Pagination } from '../components/Pagination';
import { EditTransactionModal } from '../components/EditTransactionModal';
import { ImportModal } from '../components/ImportModal';
import { toISODate } from '../utils/format';
const PARAM_KEYS = [
'from',
'to',
'accountId',
'direction',
'categoryId',
'search',
'amountMin',
'amountMax',
'onlyUnconfirmed',
'sortBy',
'sortOrder',
'periodMode',
] as const;
function getDefaultFilters(): FiltersState {
const now = new Date();
const from = new Date(now.getFullYear(), now.getMonth(), 1);
return {
periodMode: 'month',
from: toISODate(from),
to: toISODate(now),
accountId: '',
direction: '',
categoryId: '',
search: '',
amountMin: '',
amountMax: '',
onlyUnconfirmed: false,
sortBy: 'date',
sortOrder: 'desc',
};
}
function filtersFromParams(params: URLSearchParams): Partial<FiltersState> {
const out: Partial<FiltersState> = {};
for (const key of PARAM_KEYS) {
const v = params.get(key);
if (v !== null) {
if (key === 'onlyUnconfirmed') {
out[key] = v === '1' || v === 'true';
} else {
(out as Record<string, unknown>)[key] = v;
}
}
}
return out;
}
function paramsFromUrl(params: URLSearchParams): {
page: number;
pageSize: number;
} {
const p = params.get('page');
const ps = params.get('pageSize');
return {
page: p ? Math.max(1, parseInt(p, 10) || 1) : 1,
pageSize: ps ? parseInt(ps, 10) || 50 : 50,
};
}
function filtersToParams(
f: FiltersState,
pageNum: number,
pageSizeNum: number,
): URLSearchParams {
const p = new URLSearchParams();
if (f.from) p.set('from', f.from);
if (f.to) p.set('to', f.to);
if (f.accountId) p.set('accountId', f.accountId);
if (f.direction) p.set('direction', f.direction);
if (f.categoryId) p.set('categoryId', f.categoryId);
if (f.search) p.set('search', f.search);
if (f.amountMin) p.set('amountMin', f.amountMin);
if (f.amountMax) p.set('amountMax', f.amountMax);
if (f.onlyUnconfirmed) p.set('onlyUnconfirmed', '1');
if (f.sortBy) p.set('sortBy', f.sortBy);
if (f.sortOrder) p.set('sortOrder', f.sortOrder);
if (f.periodMode) p.set('periodMode', f.periodMode);
if (pageNum > 1) p.set('page', String(pageNum));
if (pageSizeNum !== 50) p.set('pageSize', String(pageSizeNum));
return p;
}
export function HistoryPage() {
const [searchParams, setSearchParams] = useSearchParams();
const urlFilters = filtersFromParams(searchParams);
const { page: urlPage, pageSize: urlPageSize } = paramsFromUrl(searchParams);
const defaultFilters = getDefaultFilters();
const [filters, setFilters] = useState<FiltersState>(() => ({
...defaultFilters,
...urlFilters,
periodMode:
(urlFilters.periodMode as FiltersState['periodMode']) ??
defaultFilters.periodMode,
}));
const [page, setPage] = useState(urlPage);
const [pageSize, setPageSize] = useState(urlPageSize);
const [data, setData] = useState<PaginatedResponse<Transaction> | null>(
null,
);
const [loading, setLoading] = useState(false);
const [accounts, setAccounts] = useState<Account[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [editingTx, setEditingTx] = useState<Transaction | null>(null);
const [showImport, setShowImport] = useState(false);
useEffect(() => {
getAccounts().then(setAccounts).catch(() => {});
getCategories().then(setCategories).catch(() => {});
}, []);
useEffect(() => {
const urlFilters = filtersFromParams(searchParams);
const { page: p, pageSize: ps } = paramsFromUrl(searchParams);
if (Object.keys(urlFilters).length > 0) {
setFilters((prev) => ({
...getDefaultFilters(),
...urlFilters,
periodMode:
(urlFilters.periodMode as FiltersState['periodMode']) ??
prev.periodMode,
}));
}
setPage(p);
setPageSize(ps);
}, [searchParams]);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: GetTransactionsParams = {
page,
pageSize,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
};
if (filters.from) params.from = filters.from;
if (filters.to) params.to = filters.to;
if (filters.accountId) params.accountId = Number(filters.accountId);
if (filters.direction) params.direction = filters.direction;
if (filters.categoryId) params.categoryId = Number(filters.categoryId);
if (filters.search) params.search = filters.search;
if (filters.amountMin)
params.amountMin = Math.round(Number(filters.amountMin) * 100);
if (filters.amountMax)
params.amountMax = Math.round(Number(filters.amountMax) * 100);
if (filters.onlyUnconfirmed) params.onlyUnconfirmed = true;
const result = await getTransactions(params);
setData(result);
} catch {
// 401 handled by interceptor
} finally {
setLoading(false);
}
}, [filters, page, pageSize]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleFiltersChange = (newFilters: FiltersState) => {
setFilters(newFilters);
setPage(1);
setSearchParams(filtersToParams(newFilters, 1, pageSize), {
replace: true,
});
};
const handleEditSave = () => {
setEditingTx(null);
fetchData();
};
const handleImportDone = () => {
setShowImport(false);
fetchData();
getAccounts().then(setAccounts).catch(() => {});
};
return (
<div className="page">
<div className="page-header">
<h1>История операций</h1>
<button
className="btn btn-primary"
onClick={() => setShowImport(true)}
>
Импорт выписки
</button>
</div>
<TransactionFilters
filters={filters}
onChange={handleFiltersChange}
accounts={accounts}
categories={categories}
/>
<TransactionTable
transactions={data?.items ?? []}
loading={loading}
onEdit={setEditingTx}
/>
{data && (
<Pagination
page={data.page}
pageSize={data.pageSize}
totalItems={data.totalItems}
totalPages={data.totalPages}
onPageChange={(p) => {
setPage(p);
setSearchParams(
filtersToParams(filters, p, pageSize),
{ replace: true },
);
}}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
setSearchParams(
filtersToParams(filters, 1, size),
{ replace: true },
);
}}
/>
)}
{editingTx && (
<EditTransactionModal
transaction={editingTx}
categories={categories}
onClose={() => setEditingTx(null)}
onSave={handleEditSave}
/>
)}
{showImport && (
<ImportModal
onClose={() => setShowImport(false)}
onDone={handleImportDone}
/>
)}
</div>
);
}