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

This commit is contained in:
2025-08-10 16:46:28 +07:00
parent 9c3332fe36
commit bbad45dfad
+268 -46
View File
@@ -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>