feat: Enhance CommunitiesManager with filter usage tracking and UI improvements, including a refresh button and search prefill from query parameters for better user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s

This commit is contained in:
2025-12-06 18:51:14 +07:00
parent b79d733868
commit 2e6f3d9263
2 changed files with 167 additions and 52 deletions
+158 -52
View File
@@ -11,6 +11,8 @@ import {
IconFilter,
IconChartBar,
IconList,
IconLink,
IconRefresh,
IconSortAscending,
IconSortDescending
} from '@tabler/icons-react';
@@ -44,6 +46,10 @@ function CommunitiesManager() {
const [newItem, setNewItem] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', category: '', priority: 0, enabled: true });
const [editingValue, setEditingValue] = useState(null);
const [editingDraft, setEditingDraft] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', category: '', priority: 0, enabled: true });
const [filterUsage, setFilterUsage] = useState({});
const [filterUsageLoading, setFilterUsageLoading] = useState(false);
const [filterUsageError, setFilterUsageError] = useState('');
const [showOnlyUsedInFilters, setShowOnlyUsedInFilters] = useState(false);
// Модальные окна
const [addModalOpen, setAddModalOpen] = useState(false);
@@ -51,7 +57,24 @@ function CommunitiesManager() {
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
useEffect(() => { fetchItems(); }, []);
useEffect(() => {
fetchItems();
fetchFilterUsage();
}, []);
useEffect(() => {
if (!filterUsage) return;
setItems((prev) => {
const map = new Map(prev.map((i) => [i.value, i]));
Object.keys(filterUsage).forEach((value) => {
if (!value) return;
if (!map.has(value)) {
map.set(value, { value, name: '', description: '', tags: [], gatewayDefault: '', color: '', _external: true });
}
});
return Array.from(map.values());
});
}, [filterUsage]);
const fetchItems = async () => {
setLoading(true);
@@ -115,6 +138,42 @@ function CommunitiesManager() {
}
};
const fetchFilterUsage = async () => {
setFilterUsageLoading(true);
setFilterUsageError('');
try {
const serversRes = await api.get(`/server-configs`);
const serversList = Array.isArray(serversRes.data) ? serversRes.data : [];
if (!serversList.length) {
setFilterUsage({});
return;
}
const responses = await Promise.all(
serversList.map((server) =>
api.get(`/server-filters/${server.id}`).catch(() => ({ data: [] }))
)
);
const usageMap = new Map();
responses.forEach((res) => {
const arr = Array.isArray(res.data) ? res.data : [];
arr.forEach((f) => {
const value = String(f?.community || '').trim();
if (!value) return;
usageMap.set(value, (usageMap.get(value) || 0) + 1);
});
});
setFilterUsage(Object.fromEntries(usageMap));
} catch (e) {
console.error('Error fetching filter usage:', e);
setFilterUsageError('Не удалось загрузить использование в фильтрах.');
} finally {
setFilterUsageLoading(false);
}
};
const saveAll = async (data) => {
setLoading(true);
try {
@@ -245,14 +304,16 @@ function CommunitiesManager() {
const filtered = useMemo(() => {
const q = searchTerm.trim().toLowerCase();
if (!q) return sorted;
return sorted.filter(i =>
(i.value || '').toLowerCase().includes(q) ||
(i.name || '').toLowerCase().includes(q) ||
(i.description || '').toLowerCase().includes(q) ||
(i.tags || []).some(t => (t || '').toLowerCase().includes(q))
);
}, [sorted, searchTerm]);
const searchFiltered = q
? sorted.filter(i =>
(i.value || '').toLowerCase().includes(q) ||
(i.name || '').toLowerCase().includes(q) ||
(i.description || '').toLowerCase().includes(q) ||
(i.tags || []).some(t => (t || '').toLowerCase().includes(q))
)
: sorted;
return searchFiltered.filter((i) => !showOnlyUsedInFilters || (filterUsage[i.value] || 0) > 0);
}, [sorted, searchTerm, showOnlyUsedInFilters, filterUsage]);
const totalPages = Math.ceil(filtered.length / pageSize) || 1;
const page = Math.min(currentPage, totalPages);
@@ -375,7 +436,7 @@ function CommunitiesManager() {
</div>
</div>
<div className="card-body">
<div className="d-flex gap-2 flex-wrap mb-3">
<div className="d-flex gap-2 flex-wrap mb-3 align-items-center">
<SavedFilters
pageKey="communities"
currentFilters={{ searchTerm, sortField, sortOrder }}
@@ -407,6 +468,33 @@ function CommunitiesManager() {
</button>
)}
</div>
<div className="d-flex align-items-center gap-3 flex-wrap">
<label className="form-check form-switch mb-0 d-flex align-items-center gap-2">
<input
className="form-check-input"
type="checkbox"
checked={showOnlyUsedInFilters}
onChange={(e) => { setShowOnlyUsedInFilters(e.target.checked); setCurrentPage(1); }}
/>
<span className="form-check-label">Только используемые в фильтрах</span>
{filterUsageLoading && (
<span className="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
)}
</label>
<button
className="btn btn-outline-secondary btn-sm"
type="button"
onClick={fetchFilterUsage}
disabled={filterUsageLoading}
title="Обновить использование из /filters"
>
<IconRefresh className="icon me-1" size={16} />
Обновить использование
</button>
{filterUsageError && (
<span className="text-danger small">{filterUsageError}</span>
)}
</div>
</div>
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
@@ -437,14 +525,15 @@ function CommunitiesManager() {
<th>Описание</th>
<th>Теги</th>
<th>Gateway</th>
<th style={{ width: '10%' }}>Исп. в фильтрах</th>
<th className="text-end">Действия</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={6} className="text-center text-muted py-4">Загрузка...</td></tr>
<tr><td colSpan={7} className="text-center text-muted py-4">Загрузка...</td></tr>
) : paginated.length === 0 ? (
<tr><td colSpan={6} className="text-center text-muted py-4">
<tr><td colSpan={7} className="text-center text-muted py-4">
<EmptyState
title="Нет community"
description="Добавьте запись, чтобы начать."
@@ -456,46 +545,63 @@ function CommunitiesManager() {
}
/>
</td></tr>
) : paginated.map((item) => (
<tr key={item.value}>
<td>
<code className="text-blue">{item.value}</code>
{item._external && (
<span className="badge bg-yellow-lt text-yellow ms-2" title="Не внесён в справочник">не в справ.</span>
)}
</td>
<td>{item.name || <span className="text-muted"></span>}</td>
<td>
<span className="text-muted" title={item.description}>{item.description || '—'}</span>
</td>
<td>
{(item.tags || []).map(t => (
<span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span>
))}
</td>
<td>
<span className="text-muted">{item.gatewayDefault || '—'}</span>
</td>
<td className="text-end">
<div className="btn-list">
<button
className="btn btn-outline-primary btn-icon btn-sm"
onClick={()=>startEdit(item)}
title="Редактировать"
>
<IconEdit size={16} />
</button>
<button
className="btn btn-outline-danger btn-icon btn-sm"
onClick={()=>handleDeleteClick(item)}
title="Удалить"
>
<IconTrash size={16} />
</button>
</div>
</td>
</tr>
))}
) : paginated.map((item) => {
const usageCount = filterUsage[item.value] || 0;
return (
<tr key={item.value}>
<td>
<code className="text-blue">{item.value}</code>
{item._external && (
<span className="badge bg-yellow-lt text-yellow ms-2" title="Не внесён в справочник">не в справ.</span>
)}
</td>
<td>{item.name || <span className="text-muted"></span>}</td>
<td>
<span className="text-muted" title={item.description}>{item.description || '—'}</span>
</td>
<td>
{(item.tags || []).map(t => (
<span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span>
))}
</td>
<td>
<span className="text-muted">{item.gatewayDefault || '—'}</span>
</td>
<td>
{usageCount > 0 ? (
<span className="badge bg-blue-lt text-blue">{usageCount} в фильтрах</span>
) : (
<span className="text-muted">0</span>
)}
</td>
<td className="text-end">
<div className="btn-list">
<a
className="btn btn-outline-primary btn-icon btn-sm"
href={`/filters?community=${encodeURIComponent(item.value)}`}
title="Открыть в /filters с поиском по community"
>
<IconLink size={16} />
</a>
<button
className="btn btn-outline-primary btn-icon btn-sm"
onClick={()=>startEdit(item)}
title="Редактировать"
>
<IconEdit size={16} />
</button>
<button
className="btn btn-outline-danger btn-icon btn-sm"
onClick={()=>handleDeleteClick(item)}
title="Удалить"
>
<IconTrash size={16} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
+9
View File
@@ -69,6 +69,15 @@ function FilterManager() {
const [filterSearch, setFilterSearch] = useState('');
const [sortBy, setSortBy] = useState('community'); // community | gateway | description
const [sortDir, setSortDir] = useState('asc'); // asc | desc
// Префилл поиска из query-параметра ?community=...
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const communityFromQuery = params.get('community');
if (communityFromQuery) {
setFilterSearch(communityFromQuery);
}
}, []);
// Состояние управления серверами
const [serverListModalOpen, setServerListModalOpen] = useState(false);