feat(FilterManager): implement drag-and-drop functionality for filter reordering; integrate @dnd-kit libraries for enhanced user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
This commit is contained in:
Generated
+62
@@ -8,6 +8,9 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@tabler/core": "^1.3.2",
|
"@tabler/core": "^1.3.2",
|
||||||
"@tabler/icons-react": "^3.34.0",
|
"@tabler/icons-react": "^3.34.0",
|
||||||
"@tanstack/react-query": "^5.56.2",
|
"@tanstack/react-query": "^5.56.2",
|
||||||
@@ -290,6 +293,59 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/win32-x64": {
|
"node_modules/@esbuild/win32-x64": {
|
||||||
"version": "0.25.5",
|
"version": "0.25.5",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
@@ -2213,6 +2269,12 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
@@ -12,15 +12,18 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@tabler/core": "^1.3.2",
|
"@tabler/core": "^1.3.2",
|
||||||
"@tabler/icons-react": "^3.34.0",
|
"@tabler/icons-react": "^3.34.0",
|
||||||
"@tanstack/react-query": "^5.56.2",
|
"@tanstack/react-query": "^5.56.2",
|
||||||
"@tanstack/react-virtual": "^3.10.8",
|
"@tanstack/react-virtual": "^3.10.8",
|
||||||
|
"@xyflow/react": "^12.3.4",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1"
|
||||||
"@xyflow/react": "^12.3.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.29.0",
|
"@eslint/js": "^9.29.0",
|
||||||
|
|||||||
+241
-98
@@ -31,8 +31,25 @@ import {
|
|||||||
IconSortAscending,
|
IconSortAscending,
|
||||||
IconSortDescending,
|
IconSortDescending,
|
||||||
IconWorld,
|
IconWorld,
|
||||||
IconFileText
|
IconFileText,
|
||||||
|
IconGripVertical
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { normalizeGateways, countryToFlag } from './utils/serverUtils.js';
|
import { normalizeGateways, countryToFlag } from './utils/serverUtils.js';
|
||||||
import {
|
import {
|
||||||
AddFilterModal,
|
AddFilterModal,
|
||||||
@@ -514,6 +531,138 @@ function GatewayAutocomplete({ label, value, onChange, gateways = [], required =
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Компонент перетаскиваемой строки фильтра
|
||||||
|
function SortableFilterRow({
|
||||||
|
filter,
|
||||||
|
index,
|
||||||
|
filterKey,
|
||||||
|
isSelected,
|
||||||
|
onToggleSelect,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
communities,
|
||||||
|
baseAS,
|
||||||
|
selectedServerGateways
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: filterKey });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
backgroundColor: isDragging ? 'var(--tblr-bg-surface-secondary)' : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr ref={setNodeRef} style={style}>
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="cursor-grab d-flex align-items-center justify-content-center"
|
||||||
|
style={{ touchAction: 'none' }}
|
||||||
|
>
|
||||||
|
<IconGripVertical size={16} className="text-muted" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
className="form-check-input m-0 align-middle"
|
||||||
|
type="checkbox"
|
||||||
|
aria-label="Выбрать строку"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={onToggleSelect}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="badge bg-secondary-lt text-muted me-2">#{index + 1}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex align-items-start">
|
||||||
|
<span className="badge bg-blue-lt text-blue me-2 mt-1">
|
||||||
|
<IconHash size={12} />
|
||||||
|
</span>
|
||||||
|
<div className="flex-grow-1">
|
||||||
|
<code className="text-blue">{filter?.community || ''}</code>
|
||||||
|
{(() => {
|
||||||
|
const communityData = communities.find(c => {
|
||||||
|
if (c.value === filter?.community) return true;
|
||||||
|
const normalized = c.value.includes(':') ? c.value : `${baseAS}:${c.value}`;
|
||||||
|
return normalized === filter?.community;
|
||||||
|
});
|
||||||
|
if (communityData && (communityData.name || communityData.description)) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted small mt-1">
|
||||||
|
{communityData.name && <div className="fw-bold">{communityData.name}</div>}
|
||||||
|
{communityData.description && <div>{communityData.description}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex align-items-start">
|
||||||
|
<span className="badge bg-green-lt text-green me-2 mt-1">
|
||||||
|
<IconServer size={12} />
|
||||||
|
</span>
|
||||||
|
<div className="flex-grow-1">
|
||||||
|
<code className="text-green">{filter?.gateway || ''}</code>
|
||||||
|
{(() => {
|
||||||
|
const gatewayData = selectedServerGateways.find(gw => gw.name === filter?.gateway);
|
||||||
|
if (gatewayData && (gatewayData.ip || gatewayData.comment)) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted small mt-1">
|
||||||
|
{gatewayData.ip && <div className="fw-bold">{gatewayData.ip}</div>}
|
||||||
|
{gatewayData.comment && <div>{gatewayData.comment}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="text-truncate" style={{ maxWidth: '250px' }} title={filter?.description || ''}>
|
||||||
|
{filter?.description ? (
|
||||||
|
<span className="text-muted">{filter.description}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted fst-italic">Без описания</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="text-end">
|
||||||
|
<div className="btn-list">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary btn-icon btn-sm"
|
||||||
|
onClick={onEdit}
|
||||||
|
title="Редактировать фильтр"
|
||||||
|
>
|
||||||
|
<IconEdit size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-danger btn-icon btn-sm"
|
||||||
|
onClick={onDelete}
|
||||||
|
title="Удалить фильтр"
|
||||||
|
>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FilterManager() {
|
function FilterManager() {
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState('');
|
||||||
@@ -1338,6 +1487,47 @@ function FilterManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Drag and drop sensors
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Обработчик завершения перетаскивания
|
||||||
|
const handleDragEnd = async (event) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
// Находим индексы в исходном массиве serverFilters
|
||||||
|
const oldIndex = serverFilters.findIndex(f => filterKey(f) === active.id);
|
||||||
|
const newIndex = serverFilters.findIndex(f => filterKey(f) === over.id);
|
||||||
|
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return;
|
||||||
|
|
||||||
|
// Перемещаем элемент
|
||||||
|
const newFilters = arrayMove(serverFilters, oldIndex, newIndex);
|
||||||
|
setServerFilters(newFilters);
|
||||||
|
|
||||||
|
// Сохраняем новый порядок
|
||||||
|
try {
|
||||||
|
await api.post(`/server-filters/${selectedServer.id}`, { filters: newFilters });
|
||||||
|
setSuccess('Порядок фильтров обновлён');
|
||||||
|
setTimeout(() => setSuccess(''), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving filter order:', error);
|
||||||
|
setError('Не удалось сохранить порядок фильтров');
|
||||||
|
// Откатываем изменения
|
||||||
|
setServerFilters(serverFilters);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Убрано сохранение режима в localStorage по просьбе пользователя
|
// Убрано сохранение режима в localStorage по просьбе пользователя
|
||||||
|
|
||||||
const toggleSelectFilter = (key) => {
|
const toggleSelectFilter = (key) => {
|
||||||
@@ -1807,10 +1997,20 @@ function FilterManager() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<div className="alert alert-info mb-3 d-flex align-items-center">
|
||||||
|
<IconGripVertical size={18} className="me-2" />
|
||||||
|
<span>Перетащите строки за иконку <IconGripVertical size={14} className="mx-1" /> чтобы изменить приоритет правил</span>
|
||||||
|
</div>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style={{ width: '40px' }}></th>
|
||||||
<th className="w-1">
|
<th className="w-1">
|
||||||
<input
|
<input
|
||||||
className="form-check-input m-0 align-middle"
|
className="form-check-input m-0 align-middle"
|
||||||
@@ -1820,121 +2020,64 @@ function FilterManager() {
|
|||||||
onChange={toggleSelectAllProcessed}
|
onChange={toggleSelectAllProcessed}
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th style={{ width: '25%' }} className="cursor-pointer" onClick={() => toggleSort('community')}>
|
<th style={{ width: '60px' }}>#</th>
|
||||||
|
<th style={{ width: '22%' }} className="cursor-pointer" onClick={() => toggleSort('community')}>
|
||||||
<span className="me-1">COMMUNITY</span>
|
<span className="me-1">COMMUNITY</span>
|
||||||
{sortBy === 'community' && (sortDir === 'asc' ? <IconSortAscending size={16} /> : <IconSortDescending size={16} />)}
|
{sortBy === 'community' && (sortDir === 'asc' ? <IconSortAscending size={16} /> : <IconSortDescending size={16} />)}
|
||||||
</th>
|
</th>
|
||||||
<th style={{ width: '25%' }} className="cursor-pointer" onClick={() => toggleSort('gateway')}>
|
<th style={{ width: '22%' }} className="cursor-pointer" onClick={() => toggleSort('gateway')}>
|
||||||
<span className="me-1">GATEWAY</span>
|
<span className="me-1">GATEWAY</span>
|
||||||
{sortBy === 'gateway' && (sortDir === 'asc' ? <IconSortAscending size={16} /> : <IconSortDescending size={16} />)}
|
{sortBy === 'gateway' && (sortDir === 'asc' ? <IconSortAscending size={16} /> : <IconSortDescending size={16} />)}
|
||||||
</th>
|
</th>
|
||||||
<th style={{ width: '35%' }} className="cursor-pointer" onClick={() => toggleSort('description')}>
|
<th style={{ width: '30%' }} className="cursor-pointer" onClick={() => toggleSort('description')}>
|
||||||
<span className="me-1">ОПИСАНИЕ</span>
|
<span className="me-1">ОПИСАНИЕ</span>
|
||||||
{sortBy === 'description' && (sortDir === 'asc' ? <IconSortAscending size={16} /> : <IconSortDescending size={16} />)}
|
{sortBy === 'description' && (sortDir === 'asc' ? <IconSortAscending size={16} /> : <IconSortDescending size={16} />)}
|
||||||
</th>
|
</th>
|
||||||
<th style={{ width: '15%' }} className="text-end">ДЕЙСТВИЯ</th>
|
<th style={{ width: '120px' }} className="text-end">ДЕЙСТВИЯ</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
<SortableContext
|
||||||
|
items={serverFilters.filter(f => f != null).map(f => filterKey(f))}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
<tbody>
|
<tbody>
|
||||||
{processedFilters.filter(f => f != null).map((filter, index) => {
|
{serverFilters.filter(f => f != null).map((filter, index) => {
|
||||||
const key = filterKey(filter);
|
const key = filterKey(filter);
|
||||||
const isSelected = selectedFilterKeys.has(key);
|
const isSelected = selectedFilterKeys.has(key);
|
||||||
|
// Показываем только если совпадает с поиском
|
||||||
|
if (filterSearch.trim()) {
|
||||||
|
const search = filterSearch.trim().toLowerCase();
|
||||||
|
const c = String(filter.community || '').toLowerCase();
|
||||||
|
const g = String(filter.gateway || '').toLowerCase();
|
||||||
|
const d = String(filter.description || '').toLowerCase();
|
||||||
|
if (!c.includes(search) && !g.includes(search) && !d.includes(search)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<tr key={`${filter?.community || ''}-${index}`}>
|
<SortableFilterRow
|
||||||
<td>
|
key={key}
|
||||||
<input
|
filter={filter}
|
||||||
className="form-check-input m-0 align-middle"
|
index={index}
|
||||||
type="checkbox"
|
filterKey={key}
|
||||||
aria-label="Выбрать строку"
|
isSelected={isSelected}
|
||||||
checked={isSelected}
|
onToggleSelect={() => toggleSelectFilter(key)}
|
||||||
onChange={() => toggleSelectFilter(key)}
|
onEdit={() => handleEditFilter(filter, index)}
|
||||||
/>
|
onDelete={() => {
|
||||||
</td>
|
setFilterToDelete(filter);
|
||||||
<td>
|
setDeleteFilterModalOpen(true);
|
||||||
<div className="d-flex align-items-start">
|
}}
|
||||||
<span className="badge bg-blue-lt text-blue me-2 mt-1">
|
communities={communities}
|
||||||
<IconHash size={12} />
|
baseAS={baseAS}
|
||||||
</span>
|
selectedServerGateways={selectedServerGateways}
|
||||||
<div className="flex-grow-1">
|
/>
|
||||||
<code className="text-blue">{filter?.community || ''}</code>
|
);
|
||||||
{(() => {
|
})}
|
||||||
// Ищем community в справочнике
|
|
||||||
const communityData = communities.find(c => {
|
|
||||||
if (c.value === filter?.community) return true;
|
|
||||||
const normalized = c.value.includes(':') ? c.value : `${baseAS}:${c.value}`;
|
|
||||||
return normalized === filter?.community;
|
|
||||||
});
|
|
||||||
if (communityData && (communityData.name || communityData.description)) {
|
|
||||||
return (
|
|
||||||
<div className="text-muted small mt-1">
|
|
||||||
{communityData.name && <div className="fw-bold">{communityData.name}</div>}
|
|
||||||
{communityData.description && <div>{communityData.description}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="d-flex align-items-start">
|
|
||||||
<span className="badge bg-green-lt text-green me-2 mt-1">
|
|
||||||
<IconServer size={12} />
|
|
||||||
</span>
|
|
||||||
<div className="flex-grow-1">
|
|
||||||
<code className="text-green">{filter?.gateway || ''}</code>
|
|
||||||
{(() => {
|
|
||||||
// Ищем gateway в списке шлюзов сервера
|
|
||||||
const gatewayData = selectedServerGateways.find(gw => gw.name === filter?.gateway);
|
|
||||||
if (gatewayData && (gatewayData.ip || gatewayData.comment)) {
|
|
||||||
return (
|
|
||||||
<div className="text-muted small mt-1">
|
|
||||||
{gatewayData.ip && <div className="fw-bold">{gatewayData.ip}</div>}
|
|
||||||
{gatewayData.comment && <div>{gatewayData.comment}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="text-truncate" style={{ maxWidth: '300px' }} title={filter?.description || ''}>
|
|
||||||
{filter?.description ? (
|
|
||||||
<span className="text-muted">{filter.description}</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted fst-italic">Без описания</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="text-end">
|
|
||||||
<div className="btn-list">
|
|
||||||
<button
|
|
||||||
className="btn btn-outline-primary btn-icon btn-sm"
|
|
||||||
onClick={() => handleEditFilter(filter, index)}
|
|
||||||
title="Редактировать фильтр"
|
|
||||||
>
|
|
||||||
<IconEdit size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline-danger btn-icon btn-sm"
|
|
||||||
onClick={() => {
|
|
||||||
setFilterToDelete(filter);
|
|
||||||
setDeleteFilterModalOpen(true);
|
|
||||||
}}
|
|
||||||
title="Удалить фильтр"
|
|
||||||
>
|
|
||||||
<IconTrash size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
|
</SortableContext>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</DndContext>
|
||||||
{selectedFilterKeys.size > 0 && (
|
{selectedFilterKeys.size > 0 && (
|
||||||
<div className="d-flex justify-content-between align-items-center mt-3">
|
<div className="d-flex justify-content-between align-items-center mt-3">
|
||||||
<div className="text-muted">Выбрано: {selectedFilterKeys.size}</div>
|
<div className="text-muted">Выбрано: {selectedFilterKeys.size}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user