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.
272 lines
7.5 KiB
TypeScript
272 lines
7.5 KiB
TypeScript
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>
|
||
);
|
||
}
|