fix: 404 при обновлении, стрелки периода, фильтры в URL, авто-категории и очистка истории

- Nginx: проксирование /api на backend (единая точка входа)
- История: стрелки ← → для переключения недель/месяцев/годов
- История: сохранение фильтров и пагинации в URL при F5
- Импорт: миграция 003 — дефолтные правила категорий (PYATEROCHK, AUCHAN и др.)
- Настройки: вкладка «Данные» с кнопкой «Очистить историю»
- Backend: DELETE /api/transactions для удаления всех транзакций
- ClearHistoryModal: подтверждение чекбоксами и вводом «УДАЛИТЬ»
This commit is contained in:
vakabunga
2026-03-10 06:53:56 +03:00
parent 792b4ca4ad
commit a895bb4b2f
23 changed files with 691 additions and 52 deletions

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import type {
Transaction,
Account,
@@ -19,10 +20,26 @@ 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: '',
@@ -37,10 +54,70 @@ function getDefaultFilters(): FiltersState {
};
}
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 [filters, setFilters] = useState<FiltersState>(getDefaultFilters);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
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,
);
@@ -55,6 +132,22 @@ export function HistoryPage() {
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 {
@@ -92,8 +185,12 @@ export function HistoryPage() {
const handleFiltersChange = (newFilters: FiltersState) => {
setFilters(newFilters);
setPage(1);
setSearchParams(filtersToParams(newFilters, 1, pageSize), {
replace: true,
});
};
const handleEditSave = () => {
setEditingTx(null);
fetchData();
@@ -136,10 +233,20 @@ export function HistoryPage() {
pageSize={data.pageSize}
totalItems={data.totalItems}
totalPages={data.totalPages}
onPageChange={setPage}
onPageChange={(p) => {
setPage(p);
setSearchParams(
filtersToParams(filters, p, pageSize),
{ replace: true },
);
}}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
setSearchParams(
filtersToParams(filters, 1, size),
{ replace: true },
);
}}
/>
)}