feat: Enhance FilterManager with advanced mode, search, and sorting capabilities for improved filter management and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m43s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m43s
This commit is contained in:
+268
-46
@@ -21,7 +21,9 @@ import {
|
|||||||
IconSettings,
|
IconSettings,
|
||||||
IconDeviceFloppy,
|
IconDeviceFloppy,
|
||||||
IconHash,
|
IconHash,
|
||||||
IconRocket
|
IconRocket,
|
||||||
|
IconSortAscending,
|
||||||
|
IconSortDescending
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
@@ -57,13 +59,25 @@ function FilterManager() {
|
|||||||
const [editServerError, setEditServerError] = useState('');
|
const [editServerError, setEditServerError] = useState('');
|
||||||
|
|
||||||
// Новое состояние для режима
|
// Новое состояние для режима
|
||||||
const [mode, setMode] = useState('simple'); // 'simple' | 'advanced'
|
const [mode, setMode] = useState('advanced'); // 'simple' | 'advanced'
|
||||||
const [simpleFilters, setSimpleFilters] = useState([]);
|
const [simpleFilters, setSimpleFilters] = useState([]);
|
||||||
const [simpleAddFilter, setSimpleAddFilter] = useState({ community: '', gateway: '', description: '' });
|
const [simpleAddFilter, setSimpleAddFilter] = useState({ community: '', gateway: '', description: '' });
|
||||||
const [simpleError, setSimpleError] = useState('');
|
const [simpleError, setSimpleError] = useState('');
|
||||||
const [simpleSuccess, setSimpleSuccess] = useState('');
|
const [simpleSuccess, setSimpleSuccess] = useState('');
|
||||||
const [simpleLoading, setSimpleLoading] = useState(false);
|
const [simpleLoading, setSimpleLoading] = useState(false);
|
||||||
|
|
||||||
|
// Состояние поиска/сортировки в расширенном режиме
|
||||||
|
const [filterSearch, setFilterSearch] = useState('');
|
||||||
|
const [sortBy, setSortBy] = useState('community'); // community | gateway | description
|
||||||
|
const [sortDir, setSortDir] = useState('asc'); // asc | desc
|
||||||
|
|
||||||
|
// Состояние для инлайн-редактирования и массовых действий
|
||||||
|
const [editingKey, setEditingKey] = useState(null);
|
||||||
|
const [editingDraft, setEditingDraft] = useState({ community: '', gateway: '', description: '' });
|
||||||
|
const [selectedFilterKeys, setSelectedFilterKeys] = useState(new Set());
|
||||||
|
|
||||||
|
const filterKey = (f) => `${String(f.community || '')}||${String(f.gateway || '')}`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchServers();
|
fetchServers();
|
||||||
fetchSimpleFilters();
|
fetchSimpleFilters();
|
||||||
@@ -571,6 +585,113 @@ function FilterManager() {
|
|||||||
const enabledServers = servers.filter(s => s.enabled).length;
|
const enabledServers = servers.filter(s => s.enabled).length;
|
||||||
const serversWithFilters = servers.filter(s => s.id === selectedServer?.id ? serverFilters.length > 0 : false).length;
|
const serversWithFilters = servers.filter(s => s.id === selectedServer?.id ? serverFilters.length > 0 : false).length;
|
||||||
|
|
||||||
|
// Преобразованные фильтры для таблицы (поиск + сортировка)
|
||||||
|
const processedFilters = (() => {
|
||||||
|
const search = filterSearch.trim().toLowerCase();
|
||||||
|
const filtered = search
|
||||||
|
? serverFilters.filter((f) => {
|
||||||
|
const c = String(f.community || '').toLowerCase();
|
||||||
|
const g = String(f.gateway || '').toLowerCase();
|
||||||
|
const d = String(f.description || '').toLowerCase();
|
||||||
|
return c.includes(search) || g.includes(search) || d.includes(search);
|
||||||
|
})
|
||||||
|
: serverFilters.slice();
|
||||||
|
const sorted = filtered.sort((a, b) => {
|
||||||
|
const av = String(a[sortBy] || '').toLowerCase();
|
||||||
|
const bv = String(b[sortBy] || '').toLowerCase();
|
||||||
|
if (av < bv) return sortDir === 'asc' ? -1 : 1;
|
||||||
|
if (av > bv) return sortDir === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const toggleSort = (field) => {
|
||||||
|
if (sortBy === field) {
|
||||||
|
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||||
|
} else {
|
||||||
|
setSortBy(field);
|
||||||
|
setSortDir('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Убрано сохранение режима в localStorage по просьбе пользователя
|
||||||
|
|
||||||
|
const startInlineEdit = (filter) => {
|
||||||
|
setEditingKey(filterKey(filter));
|
||||||
|
setEditingDraft({
|
||||||
|
community: filter.community || '',
|
||||||
|
gateway: filter.gateway || '',
|
||||||
|
description: filter.description || ''
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelInlineEdit = () => {
|
||||||
|
setEditingKey(null);
|
||||||
|
setEditingDraft({ community: '', gateway: '', description: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveInlineEdit = async () => {
|
||||||
|
if (!editingDraft.community.trim() || !editingDraft.gateway.trim()) {
|
||||||
|
setError('Поля Community и Gateway должны быть заполнены.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = serverFilters.map((f) =>
|
||||||
|
filterKey(f) === editingKey
|
||||||
|
? { community: editingDraft.community.trim(), gateway: editingDraft.gateway.trim(), description: editingDraft.description }
|
||||||
|
: f
|
||||||
|
);
|
||||||
|
setServerFilters(updated);
|
||||||
|
cancelInlineEdit();
|
||||||
|
setSelectedFilterKeys(new Set());
|
||||||
|
try {
|
||||||
|
await axios.post(`${API_URL}/server-filters/${selectedServer.id}`, { filters: updated });
|
||||||
|
setSuccess('Фильтр успешно обновлен!');
|
||||||
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving server filters:', error);
|
||||||
|
setError('Не удалось сохранить фильтр.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelectFilter = (key) => {
|
||||||
|
setSelectedFilterKeys((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key); else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const areAllProcessedSelected = processedFilters.length > 0 && processedFilters.every((f) => selectedFilterKeys.has(filterKey(f)));
|
||||||
|
const toggleSelectAllProcessed = () => {
|
||||||
|
setSelectedFilterKeys((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (areAllProcessedSelected) {
|
||||||
|
processedFilters.forEach((f) => next.delete(filterKey(f)));
|
||||||
|
} else {
|
||||||
|
processedFilters.forEach((f) => next.add(filterKey(f)));
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSelectedFilters = async () => {
|
||||||
|
if (selectedFilterKeys.size === 0) return;
|
||||||
|
const confirmed = window.confirm(`Удалить выбранные фильтры (${selectedFilterKeys.size})?`);
|
||||||
|
if (!confirmed) return;
|
||||||
|
const updated = serverFilters.filter((f) => !selectedFilterKeys.has(filterKey(f)));
|
||||||
|
setServerFilters(updated);
|
||||||
|
setSelectedFilterKeys(new Set());
|
||||||
|
try {
|
||||||
|
await axios.post(`${API_URL}/server-filters/${selectedServer.id}`, { filters: updated });
|
||||||
|
setSuccess('Выбранные фильтры удалены.');
|
||||||
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting selected filters:', error);
|
||||||
|
setError('Не удалось удалить выбранные фильтры.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Функция для применения фильтров из упрощённого режима на все сервера
|
// Функция для применения фильтров из упрощённого режима на все сервера
|
||||||
const handleDeploySimpleFiltersToAll = async () => {
|
const handleDeploySimpleFiltersToAll = async () => {
|
||||||
if (servers.length === 0) {
|
if (servers.length === 0) {
|
||||||
@@ -950,7 +1071,7 @@ function FilterManager() {
|
|||||||
<div className="col-12">
|
<div className="col-12">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<div className="row align-items-center">
|
<div className="row align-items-center g-2">
|
||||||
<div className="col">
|
<div className="col">
|
||||||
<h3 className="card-title">
|
<h3 className="card-title">
|
||||||
<IconFilter className="me-2" />
|
<IconFilter className="me-2" />
|
||||||
@@ -963,7 +1084,24 @@ function FilterManager() {
|
|||||||
Управление фильтрами community/gateway для генерации конфигурации MikroTik
|
Управление фильтрами community/gateway для генерации конфигурации MikroTik
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<div className="col-12 col-md-auto">
|
||||||
|
<div className="input-group" style={{ maxWidth: 360 }}>
|
||||||
|
<span className="input-group-text">
|
||||||
|
<IconSearch size={16} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Поиск по community, gateway, описанию"
|
||||||
|
value={filterSearch}
|
||||||
|
onChange={(e) => setFilterSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
{filterSearch && (
|
||||||
|
<button className="btn btn-outline-secondary" onClick={() => setFilterSearch('')}>Сброс</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-auto">
|
||||||
<div className="btn-list">
|
<div className="btn-list">
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary btn-sm"
|
className="btn btn-outline-primary btn-sm"
|
||||||
@@ -1038,70 +1176,154 @@ function FilterManager() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<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: '25%' }}>COMMUNITY</th>
|
<th className="w-1">
|
||||||
<th style={{ width: '25%' }}>GATEWAY</th>
|
<input
|
||||||
<th style={{ width: '35%' }}>ОПИСАНИЕ</th>
|
className="form-check-input m-0 align-middle"
|
||||||
|
type="checkbox"
|
||||||
|
aria-label="Выбрать все"
|
||||||
|
checked={areAllProcessedSelected}
|
||||||
|
onChange={toggleSelectAllProcessed}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th style={{ width: '25%', 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: '25%', 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: '35%', cursor: 'pointer' }} onClick={() => toggleSort('description')}>
|
||||||
|
<span className="me-1">ОПИСАНИЕ</span>
|
||||||
|
{sortBy === 'description' && (sortDir === 'asc' ? <IconSortAscending size={16} /> : <IconSortDescending size={16} />)}
|
||||||
|
</th>
|
||||||
<th style={{ width: '15%' }} className="text-end">ДЕЙСТВИЯ</th>
|
<th style={{ width: '15%' }} className="text-end">ДЕЙСТВИЯ</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{serverFilters.map((filter, index) => (
|
{processedFilters.map((filter, index) => {
|
||||||
<tr key={`${filter.community}-${index}`}>
|
const key = filterKey(filter);
|
||||||
|
const isEditing = editingKey === key;
|
||||||
|
const isSelected = selectedFilterKeys.has(key);
|
||||||
|
return (
|
||||||
|
<tr key={`${filter.community}-${index}`} className={isEditing ? 'table-info' : ''}>
|
||||||
<td>
|
<td>
|
||||||
<div className="d-flex align-items-center">
|
<input
|
||||||
<span className="badge bg-blue-lt text-blue me-2">
|
className="form-check-input m-0 align-middle"
|
||||||
<IconHash size={12} />
|
type="checkbox"
|
||||||
</span>
|
aria-label="Выбрать строку"
|
||||||
<code className="text-blue">{filter.community}</code>
|
checked={isSelected}
|
||||||
</div>
|
onChange={() => toggleSelectFilter(key)}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="d-flex align-items-center">
|
{isEditing ? (
|
||||||
<span className="badge bg-green-lt text-green me-2">
|
<input
|
||||||
<IconServer size={12} />
|
className="form-control form-control-sm"
|
||||||
</span>
|
value={editingDraft.community}
|
||||||
<code className="text-green">{filter.gateway}</code>
|
onChange={(e) => setEditingDraft({ ...editingDraft, community: e.target.value })}
|
||||||
</div>
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<span className="badge bg-blue-lt text-blue me-2">
|
||||||
|
<IconHash size={12} />
|
||||||
|
</span>
|
||||||
|
<code className="text-blue">{filter.community}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="text-truncate" style={{ maxWidth: '300px' }}>
|
{isEditing ? (
|
||||||
{filter.description ? (
|
<input
|
||||||
<span className="text-muted">{filter.description}</span>
|
className="form-control form-control-sm"
|
||||||
) : (
|
value={editingDraft.gateway}
|
||||||
<span className="text-muted fst-italic">Без описания</span>
|
onChange={(e) => setEditingDraft({ ...editingDraft, gateway: e.target.value })}
|
||||||
)}
|
/>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<span className="badge bg-green-lt text-green me-2">
|
||||||
|
<IconServer size={12} />
|
||||||
|
</span>
|
||||||
|
<code className="text-green">{filter.gateway}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
value={editingDraft.description || ''}
|
||||||
|
onChange={(e) => setEditingDraft({ ...editingDraft, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
<div className="btn-list">
|
<div className="btn-list">
|
||||||
<button
|
{isEditing ? (
|
||||||
className="btn btn-outline-primary btn-icon btn-sm"
|
<>
|
||||||
onClick={() => handleEditFilter(filter, index)}
|
<button className="btn btn-success btn-icon btn-sm" onClick={saveInlineEdit} title="Сохранить">
|
||||||
title="Редактировать фильтр"
|
<IconCheck size={16} />
|
||||||
>
|
</button>
|
||||||
<IconEdit size={16} />
|
<button className="btn btn-secondary btn-icon btn-sm" onClick={cancelInlineEdit} title="Отмена">
|
||||||
</button>
|
<IconX size={16} />
|
||||||
<button
|
</button>
|
||||||
className="btn btn-outline-danger btn-icon btn-sm"
|
</>
|
||||||
onClick={() => {
|
) : (
|
||||||
setFilterToDelete(filter);
|
<>
|
||||||
setDeleteFilterModalOpen(true);
|
<button
|
||||||
}}
|
className="btn btn-outline-primary btn-icon btn-sm"
|
||||||
title="Удалить фильтр"
|
onClick={() => startInlineEdit(filter)}
|
||||||
>
|
title="Редактировать фильтр"
|
||||||
<IconTrash size={16} />
|
>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{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>
|
||||||
|
|||||||
Reference in New Issue
Block a user