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

2
frontend/dist-node/vite.config.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});

23
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,23 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# API — проксируем на backend (сервис из docker-compose)
location /api {
proxy_pass http://family-budget-backend:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cookie_path / /;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
}
# SPA — все остальные пути отдаём index.html
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -26,8 +26,16 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
});
if (res.status === 401) {
onUnauthorized?.();
throw new ApiException(401, { error: 'UNAUTHORIZED', message: 'Сессия истекла' });
let body: ApiError;
try {
body = await res.json();
} catch {
body = { error: 'UNAUTHORIZED', message: 'Сессия истекла' };
}
if (!url.includes('/api/auth/login')) {
onUnauthorized?.();
}
throw new ApiException(401, body);
}
if (!res.ok) {
@@ -59,4 +67,7 @@ export const api = {
patch: <T>(url: string, body: unknown) =>
request<T>(url, { method: 'PATCH', body: JSON.stringify(body) }),
delete: <T>(url: string) =>
request<T>(url, { method: 'DELETE' }),
};

View File

@@ -25,3 +25,7 @@ export async function updateTransaction(
): Promise<Transaction> {
return api.put<Transaction>(`/api/transactions/${id}`, data);
}
export async function clearAllTransactions(): Promise<{ deleted: number }> {
return api.delete<{ deleted: number }>('/api/transactions');
}

View File

@@ -0,0 +1,110 @@
import { useState } from 'react';
import { clearAllTransactions } from '../api/transactions';
const CONFIRM_WORD = 'УДАЛИТЬ';
interface Props {
onClose: () => void;
onDone: () => void;
}
export function ClearHistoryModal({ onClose, onDone }: Props) {
const [check1, setCheck1] = useState(false);
const [confirmInput, setConfirmInput] = useState('');
const [check2, setCheck2] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const canConfirm =
check1 &&
confirmInput.trim().toUpperCase() === CONFIRM_WORD &&
check2;
const handleConfirm = async () => {
if (!canConfirm || loading) return;
setLoading(true);
setError('');
try {
await clearAllTransactions();
onDone();
} catch (e) {
setError(
e instanceof Error ? e.message : 'Ошибка при очистке истории',
);
} finally {
setLoading(false);
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Очистить историю операций</h2>
<button className="btn-close" onClick={onClose}>
&times;
</button>
</div>
<div className="modal-body">
<p className="clear-history-warn">
Все транзакции будут безвозвратно удалены. Счета и категории
сохранятся.
</p>
{error && <div className="alert alert-error">{error}</div>}
<div className="form-group form-group-checkbox clear-history-check">
<label>
<input
type="checkbox"
checked={check1}
onChange={(e) => setCheck1(e.target.checked)}
/>
Я хочу очистить историю операций
</label>
</div>
<div className="form-group">
<label>
Введите <strong>{CONFIRM_WORD}</strong> для подтверждения
</label>
<input
type="text"
value={confirmInput}
onChange={(e) => setConfirmInput(e.target.value)}
placeholder={CONFIRM_WORD}
className={confirmInput && confirmInput.trim().toUpperCase() !== CONFIRM_WORD ? 'input-error' : ''}
autoComplete="off"
/>
</div>
<div className="form-group form-group-checkbox clear-history-check">
<label>
<input
type="checkbox"
checked={check2}
onChange={(e) => setCheck2(e.target.checked)}
/>
Я понимаю, что действие необратимо и все данные об операциях
будут потеряны навсегда
</label>
</div>
</div>
<div className="modal-footer">
<button
className="btn btn-danger"
onClick={handleConfirm}
disabled={!canConfirm || loading}
>
{loading ? 'Удаление…' : 'Удалить всё'}
</button>
<button className="btn btn-secondary" onClick={onClose}>
Отмена
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ClearHistoryModal } from './ClearHistoryModal';
export function DataSection() {
const [showClearModal, setShowClearModal] = useState(false);
const navigate = useNavigate();
return (
<div className="data-section">
<div className="section-block">
<h3>Очистка данных</h3>
<p className="section-desc">
Очистить историю операций (все транзакции). Счета, категории и
правила сохранятся.
</p>
<button
type="button"
className="btn btn-danger"
onClick={() => setShowClearModal(true)}
>
Очистить историю
</button>
</div>
{showClearModal && (
<ClearHistoryModal
onClose={() => setShowClearModal(false)}
onDone={() => {
setShowClearModal(false);
navigate('/history');
}}
/>
)}
</div>
);
}

View File

@@ -6,7 +6,10 @@ import type {
} from '@family-budget/shared';
import { toISODate } from '../utils/format';
export type PeriodMode = 'week' | 'month' | 'year' | 'custom';
export interface FiltersState {
periodMode: PeriodMode;
from: string;
to: string;
accountId: string;
@@ -58,7 +61,57 @@ export function TransactionFilters({
from = new Date(now.getFullYear(), 0, 1);
break;
}
onChange({ ...filters, from: toISODate(from), to: toISODate(now) });
onChange({
...filters,
periodMode: preset,
from: toISODate(from),
to: toISODate(now),
});
};
const navigate = (direction: -1 | 1) => {
const fromDate = new Date(filters.from);
let newFrom: Date;
let newTo: Date;
switch (filters.periodMode) {
case 'week':
newFrom = new Date(fromDate);
newFrom.setDate(fromDate.getDate() + 7 * direction);
newTo = new Date(newFrom);
newTo.setDate(newFrom.getDate() + 6);
break;
case 'month':
newFrom = new Date(
fromDate.getFullYear(),
fromDate.getMonth() + direction,
1,
);
newTo = new Date(
newFrom.getFullYear(),
newFrom.getMonth() + 1,
0,
);
break;
case 'year':
newFrom = new Date(fromDate.getFullYear() + direction, 0, 1);
newTo = new Date(newFrom.getFullYear(), 11, 31);
break;
default:
return;
}
onChange({
...filters,
from: toISODate(newFrom),
to: toISODate(newTo),
});
};
const handleDateChange = (field: 'from' | 'to', value: string) => {
onChange({
...filters,
periodMode: 'custom',
[field]: value,
});
};
return (
@@ -66,34 +119,56 @@ export function TransactionFilters({
<div className="filters-row">
<div className="filter-group">
<label>Период</label>
<div className="filter-dates">
<input
type="date"
value={filters.from}
onChange={(e) => set('from', e.target.value)}
/>
<span className="filter-separator">&mdash;</span>
<input
type="date"
value={filters.to}
onChange={(e) => set('to', e.target.value)}
/>
<div className="filter-dates-wrap">
{filters.periodMode !== 'custom' && (
<button
type="button"
className="btn-page"
onClick={() => navigate(-1)}
title="Предыдущий период"
>
&larr;
</button>
)}
<div className="filter-dates">
<input
type="date"
value={filters.from}
onChange={(e) => handleDateChange('from', e.target.value)}
/>
<span className="filter-separator">&mdash;</span>
<input
type="date"
value={filters.to}
onChange={(e) => handleDateChange('to', e.target.value)}
/>
</div>
{filters.periodMode !== 'custom' && (
<button
type="button"
className="btn-page"
onClick={() => navigate(1)}
title="Следующий период"
>
&rarr;
</button>
)}
</div>
<div className="filter-presets">
<button
className="btn-preset"
className={`btn-preset ${filters.periodMode === 'week' ? 'active' : ''}`}
onClick={() => applyPreset('week')}
>
Неделя
</button>
<button
className="btn-preset"
className={`btn-preset ${filters.periodMode === 'month' ? 'active' : ''}`}
onClick={() => applyPreset('month')}
>
Месяц
</button>
<button
className="btn-preset"
className={`btn-preset ${filters.periodMode === 'year' ? 'active' : ''}`}
onClick={() => applyPreset('year')}
>
Год

View File

@@ -43,8 +43,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const me = await getMe();
setUser({ login: me.login });
} catch (e: unknown) {
const msg =
e instanceof Error ? e.message : 'Ошибка входа';
const msg = e instanceof Error && e.message === 'Failed to fetch'
? 'Сервер недоступен. Проверьте, что backend запущен и NPM проксирует /api на порт 3000.'
: e instanceof Error
? e.message
: 'Ошибка входа';
setError(msg);
throw e;
}

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 },
);
}}
/>
)}

View File

@@ -2,8 +2,9 @@ import { useState } from 'react';
import { AccountsList } from '../components/AccountsList';
import { CategoriesList } from '../components/CategoriesList';
import { RulesList } from '../components/RulesList';
import { DataSection } from '../components/DataSection';
type Tab = 'accounts' | 'categories' | 'rules';
type Tab = 'accounts' | 'categories' | 'rules' | 'data';
export function SettingsPage() {
const [tab, setTab] = useState<Tab>('accounts');
@@ -33,12 +34,19 @@ export function SettingsPage() {
>
Правила
</button>
<button
className={`tab ${tab === 'data' ? 'active' : ''}`}
onClick={() => setTab('data')}
>
Данные
</button>
</div>
<div className="tab-content">
{tab === 'accounts' && <AccountsList />}
{tab === 'categories' && <CategoriesList />}
{tab === 'rules' && <RulesList />}
{tab === 'data' && <DataSection />}
</div>
</div>
);

View File

@@ -390,6 +390,15 @@ textarea {
border-color: var(--color-border-hover);
}
.btn-danger {
background: var(--color-danger);
color: #fff;
}
.btn-danger:hover:not(:disabled) {
background: #dc2626;
}
.btn-block {
width: 100%;
}
@@ -497,6 +506,16 @@ textarea {
white-space: nowrap;
}
.filter-dates-wrap {
display: flex;
align-items: center;
gap: 6px;
}
.filter-dates-wrap .btn-page {
flex-shrink: 0;
}
.filter-dates {
display: flex;
align-items: center;
@@ -1003,6 +1022,46 @@ textarea {
padding: 0;
}
.data-section {
padding: 20px 24px;
}
.section-block {
margin-bottom: 24px;
}
.section-block:last-child {
margin-bottom: 0;
}
.section-block h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
}
.section-desc {
color: var(--color-text-secondary);
font-size: 14px;
margin-bottom: 12px;
max-width: 480px;
}
.clear-history-warn {
color: var(--color-danger);
font-weight: 500;
margin-bottom: 16px;
}
.clear-history-check {
margin-bottom: 12px;
}
.input-error {
border-color: var(--color-danger) !important;
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.15);
}
/* ================================================================
Toggle button
================================================================ */