fix: 404 при обновлении, стрелки периода, фильтры в URL, авто-категории и очистка истории
- Nginx: проксирование /api на backend (единая точка входа) - История: стрелки ← → для переключения недель/месяцев/годов - История: сохранение фильтров и пагинации в URL при F5 - Импорт: миграция 003 — дефолтные правила категорий (PYATEROCHK, AUCHAN и др.) - Настройки: вкладка «Данные» с кнопкой «Очистить историю» - Backend: DELETE /api/transactions для удаления всех транзакций - ClearHistoryModal: подтверждение чекбоксами и вводом «УДАЛИТЬ»
This commit is contained in:
110
frontend/src/components/ClearHistoryModal.tsx
Normal file
110
frontend/src/components/ClearHistoryModal.tsx
Normal 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}>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
37
frontend/src/components/DataSection.tsx
Normal file
37
frontend/src/components/DataSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">—</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="Предыдущий период"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
)}
|
||||
<div className="filter-dates">
|
||||
<input
|
||||
type="date"
|
||||
value={filters.from}
|
||||
onChange={(e) => handleDateChange('from', e.target.value)}
|
||||
/>
|
||||
<span className="filter-separator">—</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="Следующий период"
|
||||
>
|
||||
→
|
||||
</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')}
|
||||
>
|
||||
Год
|
||||
|
||||
Reference in New Issue
Block a user