/
Pitonx
/
MyFinance
Обзор
Документация
Войти
/
Pitonx
/
MyFinance
Код
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/components/UserSettings.js
521 строка
23 KB
agent
v15.6.4 — настройка TTL кэша в минутах (UI в UserSettings, по умолчанию 10)
14 июн 2026, 22:26
14 июн 2026, 22:26
e3bae62
Код
Авторство
О чём код?
import React, { useState, useCallback, useRef } from 'react'; import { cacheApi } from '../services/cacheApi'; import { invalidateDashboardCache } from '../services/dashboardApi'; import { invalidateLookupsCache } from '../hooks/useLookupData'; import { restApi as api } from '../services/restApi'; import { useEntityData } from '../hooks/useEntityData'; import { getCacheTTLMinutes, setCacheTTLMinutes } from '../config/apiConfig'; import LogViewer from './LogViewer'; const API_BASE = (() => { try { return require('../config/apiConfig').default.API_BASE; } catch { return process.env.REACT_APP_API_URL || 'http://localhost:8000'; } })(); export default function UserSettings({ user, onOperationalDateChange, onNavigate }) { const [isRecalcing, setIsRecalcing] = useState(false); const [isExporting, setIsExporting] = useState(false); const [isImporting, setIsImporting] = useState(false); const [importMode, setImportMode] = useState('replace'); const [result, setResult] = useState(null); const [opDate, setOpDate] = useState(user?.operationalDate || ''); const [isSavingOpDate, setIsSavingOpDate] = useState(false); const [dragActive, setDragActive] = useState(false); const [cacheTTL, setCacheTTL] = useState(getCacheTTLMinutes); const fileInputRef = useRef(null); // Счета через useEntityData (с кэшированием) const { items: accounts, loading: loadingAccounts, setItems: setAccounts, refresh: refreshAccounts } = useEntityData('accounts'); const handleToggleArchive = useCallback(async (accountId, currentValue) => { try { await api.accounts.update(accountId, { is_archived: !currentValue }); invalidateLookupsCache(); refreshAccounts(); setResult({ type: 'success', message: currentValue ? 'Счёт разархивирован.' : 'Счёт перемещён в архив.' }); } catch (err) { setResult({ type: 'error', message: 'Ошибка: ' + (err.message || err) }); } }, [refreshAccounts]); const _clearAllCache = useCallback(() => { // Инвалидируем кэш для desktop + mobile (общий cacheApi) cacheApi.invalidate('accounts'); cacheApi.invalidate('dashboard'); cacheApi.invalidate('expenses'); cacheApi.invalidate('incomes'); cacheApi.invalidate('transfer'); cacheApi.invalidate('loansGiven'); cacheApi.invalidate('loansTaken'); cacheApi.invalidate('tags'); invalidateDashboardCache(); invalidateLookupsCache(); }, []); // ── ПЕРЕСЧЁТ РЕГИСТРОВ ── const handleRecalc = useCallback(async () => { if (isRecalcing) return; if (!window.confirm( 'Пересчитать накопительные регистры?\n\n' + 'Это обновит остатки на всех счетах и ежедневные обороты на основе всех документов.' )) return; setIsRecalcing(true); setResult(null); try { const token = localStorage.getItem('auth_token'); const res = await fetch(`${API_BASE}/api/v1/accounts/recalc-balances/`, { method: 'POST', headers: token ? { 'Authorization': `Token ${token}` } : {}, }); const data = await res.json(); if (!res.ok) { throw new Error(data.detail || `HTTP ${res.status}`); } _clearAllCache(); setResult({ type: 'success', message: 'Регистры пересчитаны. Кэш очищен — данные актуальны.', details: { output: data.output }, }); } catch (err) { setResult({ type: 'error', message: `Ошибка пересчёта: ${err.message}` }); } finally { setIsRecalcing(false); } }, [isRecalcing, _clearAllCache]); // ── ЭКСПОРТ (БЭКАП) ── const handleExport = useCallback(async () => { if (isExporting) return; setIsExporting(true); setResult(null); try { const token = localStorage.getItem('auth_token'); const res = await fetch(`${API_BASE}/api/v1/export/`, { method: 'POST', headers: token ? { 'Authorization': `Token ${token}` } : {}, }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.detail || `HTTP ${res.status}`); } const blob = await res.blob(); const username = user?.username || 'user'; const filename = `${username}_backup_${new Date().toISOString().slice(0, 10)}.zip`; const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); window.URL.revokeObjectURL(url); setResult({ type: 'success', message: `Бэкап сохранён: ${filename}` }); } catch (err) { setResult({ type: 'error', message: `Ошибка экспорта: ${err.message}` }); } finally { setIsExporting(false); } }, [isExporting, user]); // ── ИМПОРТ (ВОССТАНОВЛЕНИЕ) ── const doImport = useCallback(async (file) => { if (!file || isImporting) return; if (!window.confirm( `Восстановить данные из бэкапа?\n\n` + `Режим: «${importMode === 'replace' ? 'Заменить' : 'Добавить'}»\n\n` + (importMode === 'replace' ? 'ВНИМАНИЕ: все текущие данные будут удалены и заменены данными из бэкапа!' : 'Данные из бэкапа будут добавлены к существующим.') )) return; setIsImporting(true); setResult(null); try { const token = localStorage.getItem('auth_token'); const formData = new FormData(); formData.append('file', file); formData.append('mode', importMode); const res = await fetch(`${API_BASE}/api/v1/import/`, { method: 'POST', headers: token ? { 'Authorization': `Token ${token}` } : {}, body: formData, }); const data = await res.json(); if (!res.ok) { throw new Error(data.detail || `HTTP ${res.status}`); } _clearAllCache(); setResult({ type: 'success', message: 'Данные восстановлены. Кэш очищен — обновите страницу.', details: { imported: data.imported, errors: data.errors }, }); } catch (err) { setResult({ type: 'error', message: `Ошибка импорта: ${err.message}` }); } finally { setIsImporting(false); } }, [isImporting, importMode, _clearAllCache]); const handleDrag = useCallback((e) => { e.preventDefault(); e.stopPropagation(); if (e.type === 'dragenter' || e.type === 'dragover') { setDragActive(true); } else if (e.type === 'dragleave') { setDragActive(false); } }, []); const handleDrop = useCallback((e) => { e.preventDefault(); e.stopPropagation(); setDragActive(false); const file = e.dataTransfer.files?.[0]; if (file) doImport(file); }, [doImport]); const handleFileSelect = useCallback((e) => { const file = e.target.files?.[0]; if (file) doImport(file); }, [doImport]); // ── ОПЕРАЦИОННЫЙ ДЕНЬ ── const handleSaveOpDate = useCallback(async () => { if (isSavingOpDate) return; setIsSavingOpDate(true); try { const updated = await api.auth.operationalDate(opDate || null); setResult({ type: 'success', message: `Операционный день ${opDate ? 'установлен' : 'сброшен'}.` }); if (onOperationalDateChange) { onOperationalDateChange(updated.operationalDate || null); } const stored = JSON.parse(localStorage.getItem('auth_user') || '{}'); stored.operationalDate = updated.operationalDate || null; localStorage.setItem('auth_user', JSON.stringify(stored)); } catch (err) { setResult({ type: 'error', message: `Ошибка: ${err.message}` }); } finally { setIsSavingOpDate(false); } }, [opDate, isSavingOpDate, onOperationalDateChange]); return ( <div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> {/* Заголовок */} <div style={{ padding: '8px 12px', background: 'linear-gradient(180deg, #f0f0f0 0%, #e0e0e0 100%)', borderBottom: '1px solid #c0c0c0', display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0, }}> <span style={{ fontWeight: 600, fontSize: 14 }}>🔧 Настройки пользователя</span> </div> <div style={{ flex: 1, overflow: 'auto', padding: 16, display: 'flex', flexDirection: 'column', gap: 20 }}> {/* Пользователь */} <div style={{ border: '1px solid #c0c0c0', borderRadius: 4, padding: 16, background: '#fafafa' }}> <h3 style={{ margin: '0 0 12px', fontSize: 14, color: '#333' }}>👤 Профиль</h3> <div style={{ fontSize: 13, color: '#555', lineHeight: 1.8 }}> <div><strong>Логин:</strong> {user?.username || '—'}</div> <div><strong>Имя:</strong> {user?.name || '—'}</div> <div><strong>Роль:</strong> {user?.role === 'admin' ? 'Администратор' : 'Пользователь'}</div> </div> </div> {/* Операционный день */} <div style={{ border: '1px solid #c0c0c0', borderRadius: 4, padding: 16, background: '#fafafa' }}> <h3 style={{ margin: '0 0 12px', fontSize: 14, color: '#333' }}>📅 Операционный день</h3> <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}> <input type="date" value={opDate} onChange={e => setOpDate(e.target.value)} style={{ padding: '4px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 3 }} /> <button className="btn btn-primary" onClick={handleSaveOpDate} disabled={isSavingOpDate} style={{ minWidth: 120 }}> {isSavingOpDate ? 'Сохранение...' : 'Сохранить'} </button> <button className="btn" onClick={() => { setOpDate(''); }} style={{ minWidth: 100 }}> Сбросить </button> <button className="btn" onClick={() => { setOpDate(new Date().toISOString().split('T')[0]); }} style={{ minWidth: 100 }}> Сегодня </button> <span style={{ fontSize: 12, color: '#888' }}> {opDate ? `Новые операции будут создаваться с датой ${new Date(opDate).toLocaleDateString('ru-RU')}` : 'Используется текущая календарная дата'} </span> </div> </div> {/* Корзина операций */} <TrashRetentionSetting /> {/* Кэширование */} <div style={{ border: '1px solid #c0c0c0', borderRadius: 4, padding: 16, background: '#fafafa' }}> <h3 style={{ margin: '0 0 12px', fontSize: 14, color: '#333' }}>⚡ Кэширование</h3> <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}> <label style={{ fontSize: 13, color: '#555' }}> Время жизни кэша: <input type="number" min={1} max={1440} value={cacheTTL} onChange={e => { const v = parseInt(e.target.value, 10); if (isNaN(v) || v < 1) return; setCacheTTL(v); setCacheTTLMinutes(v); setResult({ type: 'success', message: `Кэш установлен на ${v} мин. Перезагрузите страницу для применения.` }); }} style={{ width: 60, padding: '4px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 3, marginLeft: 8 }} /> <span style={{ marginLeft: 4, color: '#888' }}>мин</span> </label> <span style={{ fontSize: 12, color: '#888' }}> Данные загружаются из кэша без запроса к серверу. Увеличьте для скорости, уменьшите для актуальности. </span> </div> </div> {/* Импорт из банка */} <div style={{ border: '1px solid #c0c0c0', borderRadius: 4, padding: 16, background: '#fafafa' }}> <h3 style={{ margin: '0 0 12px', fontSize: 14, color: '#333' }}>📥 Импорт из банка</h3> <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}> <button className="btn btn-primary" onClick={() => onNavigate?.('bankImport')} style={{ minWidth: 160 }} > 📥 Импортировать из банка </button> <button className="btn" onClick={() => onNavigate?.('importRules')} style={{ minWidth: 140 }} > 🏷️ Правила импорта </button> <span style={{ fontSize: 12, color: '#888' }}> Настройка автоматического определения категорий по MCC и ключевым словам. </span> </div> </div> {/* Пересчёт остатков */} <div style={{ border: '1px solid #c0c0c0', borderRadius: 4, padding: 16, background: '#fafafa' }}> <h3 style={{ margin: '0 0 12px', fontSize: 14, color: '#333' }}>🔄 Пересчёт остатков</h3> <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}> <button className="btn btn-warning" onClick={handleRecalc} disabled={isRecalcing} style={{ minWidth: 160 }} > {isRecalcing ? '⏳ Пересчёт...' : '🔄 Пересчитать регистры'} </button> <span style={{ fontSize: 12, color: '#888' }}> Пересоздаёт накопительные регистры: остатки на счетах и ежедневные обороты. После пересчёта кэш очищается автоматически (desktop + mobile). </span> </div> </div> {/* Бэкап */} <div style={{ border: '1px solid #c0c0c0', borderRadius: 4, padding: 16, background: '#fafafa' }}> <h3 style={{ margin: '0 0 12px', fontSize: 14, color: '#333' }}>💾 Бэкап данных</h3> <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}> <button className="btn btn-success" onClick={handleExport} disabled={isExporting} style={{ minWidth: 160 }} > {isExporting ? '⏳ Экспорт...' : '⬇️ Скачать бэкап'} </button> <span style={{ fontSize: 12, color: '#888' }}> Скачать ZIP-архив со всеми вашими данными. </span> </div> {/* Импорт */} <div style={{ marginTop: 12 }}> <div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}> <span style={{ fontSize: 12, color: '#888' }}>Режим восстановления:</span> <select className="form-control" value={importMode} onChange={(e) => setImportMode(e.target.value)} style={{ width: 'auto', minWidth: 140, fontSize: 12 }} > <option value="replace">Заменить всё</option> <option value="append">Добавить</option> </select> </div> <div onDragEnter={handleDrag} onDragOver={handleDrag} onDragLeave={handleDrag} onDrop={handleDrop} onClick={() => fileInputRef.current?.click()} style={{ border: `2px dashed ${dragActive ? '#1976D2' : '#ccc'}`, borderRadius: 6, padding: '20px 16px', textAlign: 'center', cursor: 'pointer', background: dragActive ? '#e3f2fd' : '#fff', transition: 'all 0.2s', }} > <input ref={fileInputRef} type="file" accept=".zip" onChange={handleFileSelect} style={{ display: 'none' }} /> <div style={{ fontSize: 13, color: dragActive ? '#1976D2' : '#666' }}> {isImporting ? '⏳ Восстановление...' : '📁 Перетащите ZIP-файл или нажмите для выбора'} </div> <div style={{ fontSize: 11, color: '#aaa', marginTop: 4 }}> Только .zip файлы, созданные через «Скачать бэкап» </div> </div> </div> </div> {/* Управление архивными счетами */} <div style={{ border: '1px solid #c0c0c0', borderRadius: 4, padding: 16, background: '#fafafa' }}> <h3 style={{ margin: '0 0 12px', fontSize: 14, color: '#333' }}>🗄️ Архивные счета</h3> {loadingAccounts ? ( <span style={{ fontSize: 13, color: '#888' }}>Загрузка...</span> ) : ( <> {/* Активные счета */} <div style={{ marginBottom: 12 }}> <h4 style={{ margin: '0 0 8px', fontSize: 12, color: '#555', fontWeight: 600 }}>Активные</h4> {accounts.filter(a => !a.is_archived).length === 0 && ( <span style={{ fontSize: 12, color: '#888' }}>Нет активных счетов</span> )} {accounts.filter(a => !a.is_archived).map(acc => ( <div key={acc.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 0', borderBottom: '1px solid #eee' }}> <span style={{ fontSize: 13 }}>{acc.name}</span> <button className="btn" style={{ fontSize: 11, padding: '2px 8px' }} onClick={() => handleToggleArchive(acc.id, false)}> В архив </button> </div> ))} </div> {/* Архивные счета */} <div> <h4 style={{ margin: '0 0 8px', fontSize: 12, color: '#888', fontWeight: 600 }}>В архиве</h4> {accounts.filter(a => a.is_archived).length === 0 && ( <span style={{ fontSize: 12, color: '#888' }}>Нет архивных счетов</span> )} {accounts.filter(a => a.is_archived).map(acc => ( <div key={acc.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 0', borderBottom: '1px solid #eee' }}> <span style={{ fontSize: 13, color: '#888' }}>{acc.name}</span> <button className="btn btn-success" style={{ fontSize: 11, padding: '2px 8px' }} onClick={() => handleToggleArchive(acc.id, true)}> Разархивировать </button> </div> ))} </div> </> )} </div> {/* Логи ошибок (только администратор) */} {user?.role === 'admin' && ( <div style={{ border: '1px solid #c0c0c0', borderRadius: 4, padding: 16, background: '#fafafa' }}> <h3 style={{ margin: '0 0 12px', fontSize: 14, color: '#333' }}>📋 Логи ошибок</h3> <LogViewer /> </div> )} {/* Результат */} {result && ( <div style={{ padding: '10px 14px', borderRadius: 4, fontSize: 13, background: result.type === 'success' ? '#e8f5e9' : '#ffebee', color: result.type === 'success' ? '#2e7d32' : '#c62828', border: `1px solid ${result.type === 'success' ? '#a5d6a7' : '#ef9a9a'}`, }}> <strong>{result.type === 'success' ? '✅' : '❌'}</strong> {result.message} {result.details?.output && ( <pre style={{ margin: '8px 0 0', fontSize: 11, whiteSpace: 'pre-wrap', maxHeight: 200, overflow: 'auto' }}> {result.details.output.join('\n')} </pre> )} {result.details?.errors?.length > 0 && ( <div style={{ marginTop: 8, fontSize: 11 }}> <strong>Ошибки:</strong> {result.details.errors.map((e, i) => ( <div key={i}>• {e}</div> ))} </div> )} </div> )} </div> </div> ); } // ── TrashRetentionSetting — дней хранения в корзине ── function TrashRetentionSetting() { const [days, setDays] = useState(() => { const v = localStorage.getItem('trash_retention_days'); return v !== null ? parseInt(v, 10) : 30; }); const [saved, setSaved] = useState(false); const handleSave = () => { if (days < 1) { alert('Минимум 1 день'); return; } localStorage.setItem('trash_retention_days', String(days)); setSaved(true); setTimeout(() => setSaved(false), 2000); }; return ( <div style={{ border: '1px solid #c0c0c0', borderRadius: 4, padding: 16, background: '#fafafa' }}> <h3 style={{ margin: '0 0 12px', fontSize: 14, color: '#333' }}>🗑 Корзина операций</h3> <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}> <label style={{ fontSize: 13, color: '#555' }}>Хранить удалённые операции (дней):</label> <input type="number" min={1} max={365} value={days} onChange={e => setDays(parseInt(e.target.value, 10) || 1)} style={{ width: 60, padding: '4px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 3 }} /> <button className="btn btn-primary" onClick={handleSave} style={{ minWidth: 100 }}> Сохранить </button> {saved && <span style={{ fontSize: 12, color: '#4caf50' }}>✓ Сохранено</span>} </div> <div style={{ fontSize: 12, color: '#888', marginTop: 6 }}> Операции старше указанного срока будут удалены безвозвратно. Текущая настройка влияет только на отображение — на сервере срок: 30 дней. </div> </div> ); }