/
Pitonx
/
MyFinance
Обзор
Документация
Войти
/
Pitonx
/
MyFinance
Код
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/components/AdminDataTransfer.js
344 строки
14 KB
Сергей
v9.7.2-9.7.12. Вошедшие изменения см. в CHANGELOG.md
09 май 2026, 16:20
09 май 2026, 16:20
92cbce6
Код
Авторство
О чём код?
import React, { useState, useEffect, useCallback, useRef } from 'react'; import { api } from '../services/api'; import { cacheApi } from '../services/cacheApi'; import { invalidateDashboardCache } from '../services/dashboardApi'; import { invalidateLookupsCache } from '../hooks/useLookupData'; 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 AdminDataTransfer() { const [users, setUsers] = useState([]); const [selectedUser, setSelectedUser] = useState('all'); const [isExporting, setIsExporting] = useState(false); const [isImporting, setIsImporting] = useState(false); const [isRecalcing, setIsRecalcing] = useState(false); const [importMode, setImportMode] = useState('replace'); const [importTargetUser, setImportTargetUser] = useState(''); const [dragActive, setDragActive] = useState(false); const [result, setResult] = useState(null); const fileInputRef = useRef(null); // Загрузка списка пользователей useEffect(() => { api.users.getAll().then(data => { setUsers(data || []); }).catch(() => { setUsers([]); }); }, []); // ── ЭКСПОРТ ── 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/admin/export/`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': `Token ${token}` } : {}), }, body: JSON.stringify({ user_id: selectedUser }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.detail || `HTTP ${res.status}`); } const blob = await res.blob(); const filename = res.headers.get('Content-Disposition')?.match(/filename="(.+)"/)?.[1] || (selectedUser === 'all' ? 'all_users_export.zip' : 'user_export.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); } }, [selectedUser, isExporting]); // ── ИМПОРТ ── const handleImport = useCallback(async (file) => { if (!file || isImporting) return; setIsImporting(true); setResult(null); try { const token = localStorage.getItem('auth_token'); const formData = new FormData(); formData.append('file', file); formData.append('mode', importMode); if (importTargetUser) formData.append('user', importTargetUser); const res = await fetch(`${API_BASE}/api/v1/admin/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}`); } setResult({ type: 'success', message: 'Импорт завершён', details: data, }); } catch (err) { setResult({ type: 'error', message: `Ошибка импорта: ${err.message}` }); } finally { setIsImporting(false); setDragActive(false); } }, [importMode, importTargetUser, isImporting]); // ── ПЕРЕСЧЁТ РЕГИСТРОВ ── 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/admin/recalc-registers/`, { method: 'POST', headers: token ? { 'Authorization': `Token ${token}` } : {}, }); const data = await res.json(); if (!res.ok) { throw new Error(data.detail || `HTTP ${res.status}`); } // Инвалидируем локальный кэш остатков, дашборда и lookups // (для desktop + mobile — обе версии используют тот же cacheApi) cacheApi.invalidate('accounts'); cacheApi.invalidate('dashboard'); invalidateDashboardCache(); invalidateLookupsCache(); setResult({ type: 'success', message: 'Регистры пересчитаны. Кэш очищен — обновите страницу для актуальных данных.', details: { output: data.output }, }); } catch (err) { setResult({ type: 'error', message: `Ошибка пересчёта: ${err.message}` }); } finally { setIsRecalcing(false); } }, [isRecalcing]); // Drag-n-drop const onDragOver = (e) => { e.preventDefault(); setDragActive(true); }; const onDragLeave = () => setDragActive(false); const onDrop = (e) => { e.preventDefault(); const file = e.dataTransfer.files[0]; if (file) handleImport(file); }; const onFileSelect = (e) => { const file = e.target.files[0]; if (file) handleImport(file); }; 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={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}> <label style={{ fontSize: 13, color: '#555' }}>Пользователь:</label> <select value={selectedUser} onChange={e => setSelectedUser(e.target.value)} style={{ padding: '4px 8px', fontSize: 13, minWidth: 200 }} > <option value="all">📦 Все пользователи</option> {users.map(u => ( <option key={u.id} value={String(u.id)}>👤 {u.username} {u.email ? `(${u.email})` : ''}</option> ))} </select> <button className="btn btn-success" onClick={handleExport} disabled={isExporting} style={{ minWidth: 120 }} > {isExporting ? '⏳ Экспорт...' : '⬇️ Экспортировать'} </button> </div> <div style={{ fontSize: 11, color: '#888' }}> Результат: ZIP-архив с JSON-файлами данных (справочники + документы). {selectedUser === 'all' ? ' Для всех пользователей — отдельный файл на каждого + метаданные.' : ' Для одного пользователя.'} </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 }}> <label style={{ fontSize: 13, color: '#555' }}>Режим:</label> <select value={importMode} onChange={e => setImportMode(e.target.value)} style={{ padding: '4px 8px', fontSize: 13 }} > <option value="replace">🔄 Заменить (очистить старые → загрузить новые)</option> <option value="append">➕ Добавить (загрузить к существующим)</option> </select> <label style={{ fontSize: 13, color: '#555' }}>Целевой пользователь (опц.):</label> <select value={importTargetUser} onChange={e => setImportTargetUser(e.target.value)} style={{ padding: '4px 8px', fontSize: 13, minWidth: 150 }} > <option value="">— из архива —</option> {users.map(u => ( <option key={u.id} value={u.username}>👤 {u.username}</option> ))} </select> </div> {/* Drag-n-drop зона */} <div onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop} onClick={() => fileInputRef.current?.click()} style={{ border: `2px dashed ${dragActive ? '#4caf50' : '#bbb'}`, borderRadius: 6, padding: '32px 24px', textAlign: 'center', cursor: 'pointer', background: dragActive ? '#e8f5e9' : '#f5f5f5', transition: 'all 0.2s', }} > <input ref={fileInputRef} type="file" accept=".zip" onChange={onFileSelect} style={{ display: 'none' }} /> {isImporting ? ( <div style={{ fontSize: 14, color: '#666' }}>⏳ Импортирование...</div> ) : ( <> <div style={{ fontSize: 24, marginBottom: 8 }}>📁</div> <div style={{ fontSize: 13, color: '#555' }}> Перетащите ZIP-архив сюда или <strong>нажмите для выбора</strong> </div> <div style={{ fontSize: 11, color: '#888', marginTop: 4 }}> Поддерживаются архивы, созданные через экспорт (user_*.json или all_users_meta.json) </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' }}> <button className="btn btn-warning" onClick={handleRecalc} disabled={isRecalcing} style={{ minWidth: 160 }} > {isRecalcing ? '⏳ Пересчёт...' : '🔄 Пересчитать регистры'} </button> <span style={{ fontSize: 12, color: '#888' }}> Пересоздаёт накопительные регистры: остатки на счетах и ежедневные обороты. После пересчёта кэш приложения очищается автоматически. </span> </div> </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?.imported && ( <pre style={{ margin: '8px 0 0', fontSize: 11, whiteSpace: 'pre-wrap' }}> {JSON.stringify(result.details.imported, null, 2)} </pre> )} {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> ); }