refactor(FilterManager): simplify notification handling and improve layout structure for enhanced user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s
This commit is contained in:
+166
-128
@@ -1740,32 +1740,32 @@ function FilterManager() {
|
||||
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<>
|
||||
{/* Уведомления */}
|
||||
{error && (
|
||||
<div className="alert alert-danger alert-dismissible" role="alert">
|
||||
<IconAlertTriangle className="me-2" />
|
||||
{error}
|
||||
<button type="button" className="btn-close" onClick={() => setError('')}></button>
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="alert alert-success alert-dismissible" role="alert">
|
||||
<IconCheck className="me-2" />
|
||||
{success}
|
||||
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PageHeader
|
||||
title="Управление фильтрами frouting"
|
||||
icon={<IconFilter size={24} />}
|
||||
/>
|
||||
|
||||
<div className="page-body">
|
||||
<div className="container-xl">
|
||||
{/* Уведомления */}
|
||||
{error && (
|
||||
<div className="alert alert-danger alert-dismissible" role="alert">
|
||||
<IconAlertTriangle className="me-2" />
|
||||
{error}
|
||||
<button type="button" className="btn-close" onClick={() => setError('')}></button>
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="alert alert-success alert-dismissible" role="alert">
|
||||
<IconCheck className="me-2" />
|
||||
{success}
|
||||
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Селектор серверов на одной странице (Tabler UI) */}
|
||||
<div className="card mb-3">
|
||||
<div className="row">
|
||||
<div className="col-12">
|
||||
{/* Селектор серверов на одной странице (Tabler UI) */}
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title d-flex align-items-center gap-2">
|
||||
<IconServer className="icon" />
|
||||
@@ -1976,9 +1976,9 @@ function FilterManager() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Секция фильтров выбранного сервера */}
|
||||
{selectedServer && (
|
||||
<div className="card">
|
||||
{/* Секция фильтров выбранного сервера */}
|
||||
{selectedServer && (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
@@ -2051,113 +2051,151 @@ function FilterManager() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="alert alert-info mb-3">
|
||||
<div className="d-flex align-items-center">
|
||||
<IconGripVertical size={18} className="me-2" />
|
||||
<span>Перетащите строки за иконку <IconGripVertical size={14} className="mx-1" /> чтобы изменить приоритет</span>
|
||||
</div>
|
||||
<div className="text-muted small mt-1">
|
||||
<strong>Важно:</strong> В RouterOS правила внизу списка имеют <strong>высший приоритет</strong> (переопределяют верхние)
|
||||
</div>
|
||||
</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '40px' }}></th>
|
||||
<th className="w-1">
|
||||
<input
|
||||
className="form-check-input m-0 align-middle"
|
||||
type="checkbox"
|
||||
aria-label="Выбрать все"
|
||||
checked={areAllProcessedSelected}
|
||||
onChange={toggleSelectAllProcessed}
|
||||
/>
|
||||
</th>
|
||||
<th style={{ width: '60px' }}>#</th>
|
||||
<th style={{ width: '22%' }} className="cursor-pointer" onClick={() => toggleSort('community')}>
|
||||
<span className="me-1">COMMUNITY</span>
|
||||
{sortBy === 'community' && (sortDir === 'asc' ? <IconSortAscending size={16} /> : <IconSortDescending size={16} />)}
|
||||
</th>
|
||||
<th style={{ width: '22%' }} className="cursor-pointer" onClick={() => toggleSort('gateway')}>
|
||||
<span className="me-1">GATEWAY</span>
|
||||
{sortBy === 'gateway' && (sortDir === 'asc' ? <IconSortAscending size={16} /> : <IconSortDescending size={16} />)}
|
||||
</th>
|
||||
<th style={{ width: '30%' }} className="cursor-pointer" onClick={() => toggleSort('description')}>
|
||||
<span className="me-1">ОПИСАНИЕ</span>
|
||||
{sortBy === 'description' && (sortDir === 'asc' ? <IconSortAscending size={16} /> : <IconSortDescending size={16} />)}
|
||||
</th>
|
||||
<th style={{ width: '120px' }} className="text-end">ДЕЙСТВИЯ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<SortableContext
|
||||
items={serverFilters.filter(f => f != null).map(f => filterKey(f))}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<tbody>
|
||||
{serverFilters.filter(f => f != null).map((filter, index) => {
|
||||
const key = filterKey(filter);
|
||||
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 (
|
||||
<SortableFilterRow
|
||||
key={key}
|
||||
filter={filter}
|
||||
index={index}
|
||||
totalCount={serverFilters.filter(f => f != null).length}
|
||||
filterKey={key}
|
||||
isSelected={isSelected}
|
||||
onToggleSelect={() => toggleSelectFilter(key)}
|
||||
onEdit={() => handleEditFilter(filter, index)}
|
||||
onDelete={() => {
|
||||
setFilterToDelete(filter);
|
||||
setDeleteFilterModalOpen(true);
|
||||
}}
|
||||
communities={communities}
|
||||
baseAS={baseAS}
|
||||
selectedServerGateways={selectedServerGateways}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</SortableContext>
|
||||
</table>
|
||||
</div>
|
||||
</DndContext>
|
||||
{selectedFilterKeys.size > 0 && (
|
||||
<div className="d-flex justify-content-between align-items-center mt-3">
|
||||
<div className="text-muted">Выбрано: {selectedFilterKeys.size}</div>
|
||||
<div className="btn-list">
|
||||
<button className="btn btn-outline-danger btn-sm" onClick={deleteSelectedFilters}>
|
||||
<IconTrash className="me-2" /> Удалить выбранные
|
||||
</button>
|
||||
<button className="btn btn-outline-secondary btn-sm" onClick={() => setSelectedFilterKeys(new Set())}>
|
||||
<IconX className="me-2" /> Отменить выбор
|
||||
</button>
|
||||
<div className="alert alert-info mb-3">
|
||||
<div className="d-flex align-items-center">
|
||||
<IconGripVertical size={18} className="me-2" />
|
||||
<span>
|
||||
Перетащите строки за иконку{' '}
|
||||
<IconGripVertical size={14} className="mx-1" /> чтобы изменить приоритет
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted small mt-1">
|
||||
<strong>Важно:</strong> В RouterOS правила внизу списка имеют{' '}
|
||||
<strong>высший приоритет</strong> (переопределяют верхние)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '40px' }}></th>
|
||||
<th className="w-1">
|
||||
<input
|
||||
className="form-check-input m-0 align-middle"
|
||||
type="checkbox"
|
||||
aria-label="Выбрать все"
|
||||
checked={areAllProcessedSelected}
|
||||
onChange={toggleSelectAllProcessed}
|
||||
/>
|
||||
</th>
|
||||
<th style={{ width: '60px' }}>#</th>
|
||||
<th
|
||||
style={{ width: '22%' }}
|
||||
className="cursor-pointer"
|
||||
onClick={() => toggleSort('community')}
|
||||
>
|
||||
<span className="me-1">COMMUNITY</span>
|
||||
{sortBy === 'community' &&
|
||||
(sortDir === 'asc' ? (
|
||||
<IconSortAscending size={16} />
|
||||
) : (
|
||||
<IconSortDescending size={16} />
|
||||
))}
|
||||
</th>
|
||||
<th
|
||||
style={{ width: '22%' }}
|
||||
className="cursor-pointer"
|
||||
onClick={() => toggleSort('gateway')}
|
||||
>
|
||||
<span className="me-1">GATEWAY</span>
|
||||
{sortBy === 'gateway' &&
|
||||
(sortDir === 'asc' ? (
|
||||
<IconSortAscending size={16} />
|
||||
) : (
|
||||
<IconSortDescending size={16} />
|
||||
))}
|
||||
</th>
|
||||
<th
|
||||
style={{ width: '30%' }}
|
||||
className="cursor-pointer"
|
||||
onClick={() => toggleSort('description')}
|
||||
>
|
||||
<span className="me-1">ОПИСАНИЕ</span>
|
||||
{sortBy === 'description' &&
|
||||
(sortDir === 'asc' ? (
|
||||
<IconSortAscending size={16} />
|
||||
) : (
|
||||
<IconSortDescending size={16} />
|
||||
))}
|
||||
</th>
|
||||
<th style={{ width: '120px' }} className="text-end">
|
||||
ДЕЙСТВИЯ
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<SortableContext
|
||||
items={serverFilters.filter((f) => f != null).map((f) => filterKey(f))}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<tbody>
|
||||
{serverFilters
|
||||
.filter((f) => f != null)
|
||||
.map((filter, index) => {
|
||||
const key = filterKey(filter);
|
||||
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 (
|
||||
<SortableFilterRow
|
||||
key={key}
|
||||
filter={filter}
|
||||
index={index}
|
||||
totalCount={serverFilters.filter((f) => f != null).length}
|
||||
filterKey={key}
|
||||
isSelected={isSelected}
|
||||
onToggleSelect={() => toggleSelectFilter(key)}
|
||||
onEdit={() => handleEditFilter(filter, index)}
|
||||
onDelete={() => {
|
||||
setFilterToDelete(filter);
|
||||
setDeleteFilterModalOpen(true);
|
||||
}}
|
||||
communities={communities}
|
||||
baseAS={baseAS}
|
||||
selectedServerGateways={selectedServerGateways}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</SortableContext>
|
||||
</table>
|
||||
</div>
|
||||
</DndContext>
|
||||
{selectedFilterKeys.size > 0 && (
|
||||
<div className="d-flex justify-content-between align-items-center mt-3">
|
||||
<div className="text-muted">Выбрано: {selectedFilterKeys.size}</div>
|
||||
<div className="btn-list">
|
||||
<button className="btn btn-outline-danger btn-sm" onClick={deleteSelectedFilters}>
|
||||
<IconTrash className="me-2" /> Удалить выбранные
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
onClick={() => setSelectedFilterKeys(new Set())}
|
||||
>
|
||||
<IconX className="me-2" /> Отменить выбор
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модальные окна */}
|
||||
<FormModal
|
||||
@@ -2635,7 +2673,7 @@ function FilterManager() {
|
||||
onCancel={() => setConfirmState({ open: false, text: '', onConfirm: null })}
|
||||
onConfirm={() => confirmState.onConfirm?.()}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user