diff --git a/frontend/src/ASNsNewManager.jsx b/frontend/src/ASNsNewManager.jsx index 6c96b65..1d54099 100644 --- a/frontend/src/ASNsNewManager.jsx +++ b/frontend/src/ASNsNewManager.jsx @@ -19,7 +19,8 @@ import { IconFileText, IconFilter, IconArrowUp, - IconArrowDown + IconArrowDown, + IconCopy } from '@tabler/icons-react'; import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx'; import S3MetaBar from './components/S3MetaBar.jsx'; @@ -37,6 +38,8 @@ import ImportModal from './components/ImportModal.jsx'; import QuickAddBar from './components/QuickAddBar.jsx'; import AccordionCard from './components/AccordionCard.jsx'; import SavedFilters from './components/SavedFilters.jsx'; +import BulkActionsBar from './components/BulkActionsBar.jsx'; +import Tooltip from './components/Tooltip.jsx'; import { getAsnName, getAsnNameSync } from './lib/asn.js'; const API_URL = '/api'; @@ -67,6 +70,7 @@ function ASNsNewManager() { const editInputRef = useRef(null); const pageSize = 10; const [asnNameMap, setAsnNameMap] = useState({}); + const [selectedItems, setSelectedItems] = useState(new Set()); // Справочник community для подсказок const [communities, setCommunities] = useState([]); @@ -161,6 +165,65 @@ function ASNsNewManager() { const handleDeleteItem = (asnToDelete) => { setItems(items.filter(i => i.asn !== asnToDelete)); + setSelectedItems(prev => { + const next = new Set(prev); + next.delete(asnToDelete); + return next; + }); + }; + + // Функции для работы с выбранными элементами + const toggleSelectItem = (asn) => { + setSelectedItems(prev => { + const next = new Set(prev); + if (next.has(asn)) { + next.delete(asn); + } else { + next.add(asn); + } + return next; + }); + }; + + const selectAll = () => { + setSelectedItems(new Set(paginatedItems.map(i => i.asn))); + }; + + const deselectAll = () => { + setSelectedItems(new Set()); + }; + + const handleBulkDelete = () => { + if (selectedItems.size === 0) return; + const count = selectedItems.size; + setItems(items.filter(i => !selectedItems.has(i.asn))); + setSelectedItems(new Set()); + window.notify?.success?.(`Удалено ${count} записей`); + }; + + const handleBulkExport = () => { + if (selectedItems.size === 0) return; + const selected = items.filter(i => selectedItems.has(i.asn)); + const header = ['asn', 'community']; + const csv = [header, ...selected.map(i => [i.asn, i.community])] + .map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')) + .join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `asns_selected_${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); + window.notify?.success?.(`Экспортировано ${selectedItems.size} записей`); + }; + + const copyToClipboard = (text) => { + navigator.clipboard.writeText(text).then(() => { + window.notify?.success?.('Скопировано в буфер обмена'); + }).catch(() => { + window.notify?.error?.('Не удалось скопировать'); + }); }; const confirmDelete = (item) => { @@ -491,66 +554,6 @@ function ASNsNewManager() { /> )} /> - {/* Статистические карточки */} -
-
-
-
-
-
- - - -
-
-
Всего ASN
-
- {items.length} -
-
-
-
-
-
-
-
-
-
-
- - - -
-
-
Отфильтровано
-
- {filtered.length} -
-
-
-
-
-
-
-
-
-
-
- - - -
-
-
Уникальных Community
-
- {allCommunities.length} -
-
-
-
-
-
-
{/* Classic Add New ASN Card (improved) */} @@ -666,18 +669,32 @@ function ASNsNewManager() {
+ {/* BulkActionsBar */} + 0 ? () => { + if (confirm(`Удалить ${selectedItems.size} записей?`)) { + handleBulkDelete(); + } + } : null} + onExport={selectedItems.size > 0 ? handleBulkExport : null} + /> +
-
+

Список ASN {items.length}

-
+
{ - if (filters.searchTerm !== undefined) setSearchTerm(filters.searchTerm); - if (filters.filterCommunity !== undefined) setFilterCommunity(filters.filterCommunity); - if (filters.sortField !== undefined) setSortField(filters.sortField); - if (filters.sortOrder !== undefined) setSortOrder(filters.sortOrder); + setSearchTerm(filters.searchTerm || ''); + setFilterCommunity(filters.filterCommunity || ''); + setSortField(filters.sortField || 'asn'); + setSortOrder(filters.sortOrder || 'asc'); setCurrentPage(1); }} /> @@ -763,6 +780,21 @@ function ASNsNewManager() { + - + {paginatedItems.map((item, idx) => ( - + + diff --git a/frontend/src/DomainsNewManager.jsx b/frontend/src/DomainsNewManager.jsx index e17fe3f..0863cb0 100644 --- a/frontend/src/DomainsNewManager.jsx +++ b/frontend/src/DomainsNewManager.jsx @@ -23,7 +23,8 @@ import { IconTrendingUp, IconUsers, IconArrowUp, - IconArrowDown + IconArrowDown, + IconCopy } from '@tabler/icons-react'; import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx'; import PageHeader from './components/PageHeader.jsx'; @@ -44,6 +45,7 @@ import AccordionCard from './components/AccordionCard.jsx'; import SavedFilters from './components/SavedFilters.jsx'; import BulkActionsBar from './components/BulkActionsBar.jsx'; import FormField from './components/FormField.jsx'; +import Tooltip from './components/Tooltip.jsx'; import { useToast } from './components/ToastContainer.jsx'; const API_URL = '/api'; @@ -171,6 +173,19 @@ function DomainsNewManager() { const handleDeleteItem = (domainNameToDelete) => { setItems(items.filter(i => i.domain !== domainNameToDelete)); + setSelectedDomains(prev => { + const next = new Set(prev); + next.delete(domainNameToDelete); + return next; + }); + }; + + const copyToClipboard = (text) => { + navigator.clipboard.writeText(text).then(() => { + toast.success('Скопировано в буфер обмена'); + }).catch(() => { + toast.error('Не удалось скопировать'); + }); }; const confirmDelete = (item) => { @@ -448,10 +463,11 @@ function DomainsNewManager() { const handleBulkDelete = () => { if (selectedDomains.size === 0) return; + const count = selectedDomains.size; const newItems = items.filter(i => !selectedDomains.has(i.domain)); setItems(newItems); setSelectedDomains(new Set()); - toast.success(`Удалено ${selectedDomains.size} доменов`); + toast.success(`Удалено ${count} доменов`); }; const handleBulkExport = () => { @@ -536,96 +552,6 @@ function DomainsNewManager() { )} /> - {/* Статистические карточки */} -
-
-
-
-
-
- - - -
-
-
Всего доменов
-
- {items.length} -
-
-
-
-
-
-
-
-
-
-
- - - -
-
-
Отфильтровано
-
- {filtered.length} - {filtered.length !== items.length && ( - - ({items.length - filtered.length} скрыто) - - )} -
-
-
-
-
-
-
-
-
-
-
- - - -
-
-
Уникальных Community
-
- {allCommunities.length} -
-
-
-
-
-
-
-
-
-
-
- - - -
-
-
Выбрано
-
- {selectedDomains.size} - {selectedDomains.size > 0 && ( - - ({Math.round((selectedDomains.size / filtered.length) * 100)}%) - - )} -
-
-
-
-
-
-
-
{/* Classic Add New Domain Card (improved) */} @@ -908,14 +834,14 @@ function DomainsNewManager() { )}
-
+ {paginatedItems.map((item, idx) => ( handleToggleSelect(item.domain)} aria-label={`Выбрать ${item.domain}`} + onClick={(e) => e.stopPropagation()} />
+ 0 && paginatedItems.every(i => selectedItems.has(i.asn))} + onChange={(e) => { + if (e.target.checked) { + selectAll(); + } else { + deselectAll(); + } + }} + title="Выбрать все на странице" + /> + handleSort('asn')}>
@@ -788,16 +820,34 @@ function ASNsNewManager() { )}
Действия
+ toggleSelectItem(item.asn)} + onClick={(e) => e.stopPropagation()} + /> +
{item.asn} + + +
@@ -819,13 +869,24 @@ function ASNsNewManager() { className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`} style={{ width: '150px' }} /> - - + + + + + + ) : ( <> - - + + + + + + + + + )} Действия
{item.domain} + + +
@@ -952,42 +888,55 @@ function DomainsNewManager() { className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`} style={{ width: '150px' }} /> - - + + + + + + ) : ( <> - - + + + + + + + + + )} diff --git a/frontend/src/IPRangesManager.jsx b/frontend/src/IPRangesManager.jsx index 354b78e..6661d15 100644 --- a/frontend/src/IPRangesManager.jsx +++ b/frontend/src/IPRangesManager.jsx @@ -20,7 +20,9 @@ import { IconFilter, IconArrowUp, IconArrowDown, - IconNetwork + IconNetwork, + IconCopy, + IconCopyCheck } from '@tabler/icons-react'; import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx'; import S3MetaBar from './components/S3MetaBar.jsx'; @@ -38,6 +40,8 @@ import ImportModal from './components/ImportModal.jsx'; import QuickAddBar from './components/QuickAddBar.jsx'; import AccordionCard from './components/AccordionCard.jsx'; import SavedFilters from './components/SavedFilters.jsx'; +import BulkActionsBar from './components/BulkActionsBar.jsx'; +import Tooltip from './components/Tooltip.jsx'; const API_URL = '/api'; @@ -66,6 +70,7 @@ function IPRangesManager() { const [filterCommunity, setFilterCommunity] = useState(''); const editInputRef = useRef(null); const pageSize = 10; + const [selectedItems, setSelectedItems] = useState(new Set()); // Справочник community для подсказок const [communities, setCommunities] = useState([]); @@ -170,6 +175,70 @@ function IPRangesManager() { const handleDeleteItem = (ipRangeToDelete) => { setItems(items.filter(i => i.ipRange !== ipRangeToDelete)); + setSelectedItems(prev => { + const next = new Set(prev); + next.delete(ipRangeToDelete); + return next; + }); + }; + + // Функции для работы с выбранными элементами + const toggleSelectItem = (ipRange) => { + setSelectedItems(prev => { + const next = new Set(prev); + if (next.has(ipRange)) { + next.delete(ipRange); + } else { + next.add(ipRange); + } + return next; + }); + }; + + const selectAll = () => { + setSelectedItems(new Set(paginatedItems.map(i => i.ipRange))); + }; + + const deselectAll = () => { + setSelectedItems(new Set()); + }; + + const handleBulkDelete = () => { + if (selectedItems.size === 0) return; + const count = selectedItems.size; + setItems(items.filter(i => !selectedItems.has(i.ipRange))); + setSelectedItems(new Set()); + window.notify?.success?.(`Удалено ${count} записей`); + }; + + const handleBulkExport = () => { + if (selectedItems.size === 0) return; + const selected = items.filter(i => selectedItems.has(i.ipRange)); + const header = ['ipRange', 'community']; + const csv = [header, ...selected.map(i => [i.ipRange, i.community])] + .map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')) + .join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `ip_ranges_selected_${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); + window.notify?.success?.(`Экспортировано ${selectedItems.size} записей`); + }; + + const copyToClipboard = (text) => { + navigator.clipboard.writeText(text).then(() => { + window.notify?.success?.('Скопировано в буфер обмена'); + }).catch(() => { + window.notify?.error?.('Не удалось скопировать'); + }); + }; + + const duplicateItem = (item) => { + setItems([...items, { ...item, ipRange: `${item.ipRange.split('/')[0]}/24` }]); + window.notify?.success?.('Запись продублирована'); }; const confirmDelete = (item) => { @@ -745,66 +814,6 @@ function IPRangesManager() { /> )} /> - {/* Статистические карточки */} -
-
-
-
-
-
- - - -
-
-
Всего диапазонов
-
- {items.length} -
-
-
-
-
-
-
-
-
-
-
- - - -
-
-
Отфильтровано
-
- {filtered.length} -
-
-
-
-
-
-
-
-
-
-
- - - -
-
-
Уникальных Community
-
- {allCommunities.length} -
-
-
-
-
-
-
{/* Classic Add New Item Card (improved) */} @@ -919,54 +928,81 @@ function IPRangesManager() {
+ {/* BulkActionsBar */} + 0 ? () => { + if (confirm(`Удалить ${selectedItems.size} записей?`)) { + handleBulkDelete(); + } + } : null} + onExport={selectedItems.size > 0 ? handleBulkExport : null} + /> +
-
+

Список IP-диапазонов {items.length}

-
- {/* Фильтр по community */} - - {/* Поиск */} -
- - - - { setSearchTerm(e.target.value); setCurrentPage(1); }} - aria-label="Поиск IP-диапазона" - /> +
+ { + setSearchTerm(filters.searchTerm || ''); + setFilterCommunity(filters.filterCommunity || ''); + setSortField(filters.sortField || 'ipRange'); + setSortOrder(filters.sortOrder || 'asc'); + setCurrentPage(1); + }} + /> +
+ {/* Фильтр по community */} + + {/* Поиск */} +
+ + + + { setSearchTerm(e.target.value); setCurrentPage(1); }} + aria-label="Поиск IP-диапазона" + /> +
-
-
- + + +
{/* Мета-информация будет показана внизу карточки */} @@ -986,6 +1022,21 @@ function IPRangesManager() { + - + {paginatedItems.map((item, idx) => ( - + + @@ -1032,13 +1101,24 @@ function IPRangesManager() { style={{ width: '150px' }} onKeyDown={(e) => handleEditKeyDown(e, item.ipRange)} /> - - + + + + + + ) : ( <> - - + + + + + + + + + )} diff --git a/frontend/src/index.css b/frontend/src/index.css index 7785fd1..3eef7bd 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -4,6 +4,14 @@ .page { min-height: 100vh; display: flex; flex-direction: column; } .footer { margin-top: auto; } +/* Table selected row styling */ +.table-selected { + background-color: rgba(32, 107, 196, 0.08) !important; +} +.table-selected:hover { + background-color: rgba(32, 107, 196, 0.12) !important; +} + /* ===== Tabler visual consistency (global overrides) ===== */ :root { --app-gap-xs: .25rem;
+ 0 && paginatedItems.every(i => selectedItems.has(i.ipRange))} + onChange={(e) => { + if (e.target.checked) { + selectAll(); + } else { + deselectAll(); + } + }} + title="Выбрать все на странице" + /> + handleSort('ipRange')}>
@@ -1008,16 +1059,34 @@ function IPRangesManager() { )}
Действия
+ toggleSelectItem(item.ipRange)} + onClick={(e) => e.stopPropagation()} + /> +
{item.ipRange} + + +
{renderCommunityBadge(item.community)}