diff --git a/frontend/src/components/HistoryModal.jsx b/frontend/src/components/HistoryModal.jsx index 8f0f691..aaa4a88 100644 --- a/frontend/src/components/HistoryModal.jsx +++ b/frontend/src/components/HistoryModal.jsx @@ -1,11 +1,16 @@ -import { useEffect, useState } from 'react'; +import Modal from './Modal'; +import { useState } from 'react'; import api from '../lib/api.js'; import { useQuery } from '@tanstack/react-query'; import ConfirmDialog from './ConfirmDialog.jsx'; import { useNotify } from './NotifyProvider.jsx'; -import { IconHistory, IconRefresh, IconDeviceFloppy, IconInfoCircle, IconRotate2 } from '@tabler/icons-react'; +import { IconHistory, IconRefresh, IconInfoCircle, IconRotate2, IconClock } from '@tabler/icons-react'; import { formatDateTimeWithRelative } from '../lib/datetime.js'; +/** + * HistoryModal - модальное окно истории версий + * Рефакторинг: теперь использует базовый Modal компонент + */ export default function HistoryModal({ resource, show, onClose, onRolledBack }) { const [loading, setLoading] = useState(false); const [items, setItems] = useState([]); @@ -15,123 +20,147 @@ export default function HistoryModal({ resource, show, onClose, onRolledBack }) queryKey: ['history', resource], enabled: false, queryFn: async () => { - const res = await api.get(`/history/${resource}`) - const data = Array.isArray(res.data?.items) ? res.data.items : [] - setItems(data) - return data + const res = await api.get(`/history/${resource}`); + const data = Array.isArray(res.data?.items) ? res.data.items : []; + setItems(data); + return data; }, - }) - - useEffect(() => { if (show) refetch().catch(() => notify.error('Не удалось загрузить историю версий')); }, [show, resource]); + }); const [confirmState, setConfirmState] = useState({ open: false, onConfirm: null }); + const rollback = async (versionId) => { if (!versionId) return; - await new Promise((resolve) => { - setConfirmState({ - open: true, - onConfirm: async () => { - setConfirmState({ open: false, onConfirm: null }); - setLoading(true); - try { - const res = await api.post(`/history/${resource}/rollback`, { versionId }); - notify.success('Откат выполнен'); - onRolledBack && onRolledBack(res.data || {}); - onClose && onClose(); - } catch (e) { - notify.error('Не удалось выполнить откат'); - } finally { - setLoading(false); - } - resolve(); + setConfirmState({ + open: true, + onConfirm: async () => { + setConfirmState({ open: false, onConfirm: null }); + setLoading(true); + try { + const res = await api.post(`/history/${resource}/rollback`, { versionId }); + notify.success('Откат выполнен успешно'); + onRolledBack?.(res.data || {}); + onClose?.(); + } catch (e) { + notify.error('Не удалось выполнить откат'); + } finally { + setLoading(false); } - }); + } }); }; - if (!show) return null; + const handleOpen = () => { + if (show) { + refetch().catch(() => notify.error('Не удалось загрузить историю версий')); + } + }; return ( -
{ if (e.key === 'Escape') onClose?.() }}> -
-
{ - if (e.key === 'Tab') { - const c = e.currentTarget - const focusable = c.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])') - if (!focusable || focusable.length === 0) return - const first = focusable[0] - const last = focusable[focusable.length - 1] - if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } - else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } - } - }}> -
-
- История версий -
- + <> + + + История версий
-
-
-
- - Ресурс: {resource} -
- -
-
- - - - - - - - - - - - {items.length === 0 ? ( - - ) : items.map((v) => ( - - - - - - - - ))} - -
ВерсияДатаРазмерETagДействия
Нет данных (возможно, версионирование бакета отключено)
{v.versionId}{v.lastModified ? formatDateTimeWithRelative(v.lastModified) : '—'}{typeof v.size === 'number' ? `${v.size} байт` : '—'}{v.etag || '—'} - {!v.isLatest && ( - - )} -
-
-
-
- + } + size="lg" + centered + scrollable + onOpen={handleOpen} + > + {/* Info bar */} +
+
+ + Ресурс: {resource}
+
-
+ + {/* Table */} +
+ + + + + + + + + + + + {items.length === 0 ? ( + + + + ) : ( + items.map((v) => ( + + + + + + + + )) + )} + +
ВерсияДатаРазмерETagДействия
+
+
+ +
+

Нет данных

+

+ Версионирование бакета может быть отключено +

+
+
+ {v.versionId} + {v.isLatest && ( + Текущая + )} + + {v.lastModified ? formatDateTimeWithRelative(v.lastModified) : '—'} + {typeof v.size === 'number' ? `${v.size} байт` : '—'}{v.etag || '—'} + {!v.isLatest && ( + + )} +
+
+ + setConfirmState({ open: false, onConfirm: null })} onConfirm={confirmState.onConfirm} /> -
+ ); } - - diff --git a/frontend/src/components/ImportModal.jsx b/frontend/src/components/ImportModal.jsx index 6586e85..ffe3114 100644 --- a/frontend/src/components/ImportModal.jsx +++ b/frontend/src/components/ImportModal.jsx @@ -1,5 +1,11 @@ +import Modal from './Modal'; import { useEffect, useRef, useState } from 'react'; +import { IconUpload, IconFileText, IconAlertCircle, IconCheck, IconX } from '@tabler/icons-react'; +/** + * ImportModal - модальное окно импорта данных + * Рефакторинг: использует базовый Modal, улучшенный UX + */ function ImportModal({ show, title = 'Импорт', @@ -23,12 +29,10 @@ function ImportModal({ setParsed({ items: [], invalid: [] }); setFileName(''); setDragOver(false); - setTimeout(() => textAreaRef.current?.focus(), 0); + setTimeout(() => textAreaRef.current?.focus(), 100); } }, [show]); - if (!show) return null; - const parseText = (raw) => { const lines = String(raw || '') .split(/\r?\n/) @@ -38,7 +42,11 @@ function ImportModal({ const invalid = []; for (const line of lines) { const obj = parseLine(line); - if (obj && validateItem(obj)) items.push(obj); else invalid.push(line); + if (obj && validateItem(obj)) { + items.push(obj); + } else { + invalid.push(line); + } } setParsed({ items, invalid }); }; @@ -78,73 +86,141 @@ function ImportModal({ }; return ( -
-
-
- -
-

{title}

-
-
-
{description}
-
{ e.preventDefault(); setDragOver(true); }} - onDragLeave={() => setDragOver(false)} - onDrop={handleDrop} - > -
-
- {fileName ? `Файл: ${fileName}` : 'Перетащите TXT/CSV сюда или вставьте текст ниже'} + + + {title} +
+ } + size="lg" + centered + footer={ + <> + + + + + } + > + {/* Description */} +
+
+ +
{description}
+
+
+ + {/* Drop zone */} +
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + > +
+
+
+ {fileName ? ( +
+ + Файл: {fileName}
- -
+ ) : ( + 'Перетащите TXT/CSV сюда или вставьте текст ниже' + )}
-