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,
|
||||
IconDeviceFloppy,
|
||||
IconHash,
|
||||
IconRocket
|
||||
IconRocket,
|
||||
IconSortAscending,
|
||||
IconSortDescending
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
const API_URL = '/api';
|
||||
@@ -57,13 +59,25 @@ function FilterManager() {
|
||||
const [editServerError, setEditServerError] = useState('');
|
||||
|
||||
// Новое состояние для режима
|
||||
const [mode, setMode] = useState('simple'); // 'simple' | 'advanced'
|
||||
const [mode, setMode] = useState('advanced'); // 'simple' | 'advanced'
|
||||
const [simpleFilters, setSimpleFilters] = useState([]);
|
||||
const [simpleAddFilter, setSimpleAddFilter] = useState({ community: '', gateway: '', description: '' });
|
||||
const [simpleError, setSimpleError] = useState('');
|
||||
const [simpleSuccess, setSimpleSuccess] = useState('');
|
||||
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(() => {
|
||||
fetchServers();
|
||||
fetchSimpleFilters();
|
||||
@@ -571,6 +585,113 @@ function FilterManager() {
|
||||
const enabledServers = servers.filter(s => s.enabled).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 () => {
|
||||
if (servers.length === 0) {
|
||||
@@ -950,7 +1071,7 @@ function FilterManager() {
|
||||
<div className="col-12">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center">
|
||||
<div className="row align-items-center g-2">
|
||||
<div className="col">
|
||||
<h3 className="card-title">
|
||||
<IconFilter className="me-2" />
|
||||
@@ -963,7 +1084,24 @@ function FilterManager() {
|
||||
Управление фильтрами community/gateway для генерации конфигурации MikroTik
|
||||
</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">
|
||||
<button
|
||||
className="btn btn-outline-primary btn-sm"
|
||||
@@ -1038,70 +1176,154 @@ function FilterManager() {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '25%' }}>COMMUNITY</th>
|
||||
<th style={{ width: '25%' }}>GATEWAY</th>
|
||||
<th style={{ width: '35%' }}>ОПИСАНИЕ</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: '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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{serverFilters.map((filter, index) => (
|
||||
<tr key={`${filter.community}-${index}`}>
|
||||
{processedFilters.map((filter, 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>
|
||||
<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>
|
||||
<input
|
||||
className="form-check-input m-0 align-middle"
|
||||
type="checkbox"
|
||||
aria-label="Выбрать строку"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleSelectFilter(key)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<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>
|
||||
{isEditing ? (
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
value={editingDraft.community}
|
||||
onChange={(e) => setEditingDraft({ ...editingDraft, community: e.target.value })}
|
||||
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>
|
||||
<div className="text-truncate" style={{ maxWidth: '300px' }}>
|
||||
{filter.description ? (
|
||||
<span className="text-muted">{filter.description}</span>
|
||||
) : (
|
||||
<span className="text-muted fst-italic">Без описания</span>
|
||||
)}
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
value={editingDraft.gateway}
|
||||
onChange={(e) => setEditingDraft({ ...editingDraft, gateway: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<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 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>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button className="btn btn-success btn-icon btn-sm" onClick={saveInlineEdit} title="Сохранить">
|
||||
<IconCheck size={16} />
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-icon btn-sm" onClick={cancelInlineEdit} title="Отмена">
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-outline-primary btn-icon btn-sm"
|
||||
onClick={() => startInlineEdit(filter)}
|
||||
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>
|
||||
</table>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user