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, IconFilter,
IconChartBar, IconChartBar,
IconList, IconList,
IconLink,
IconRefresh,
IconSortAscending, IconSortAscending,
IconSortDescending IconSortDescending
} from '@tabler/icons-react'; } 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 [newItem, setNewItem] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', category: '', priority: 0, enabled: true });
const [editingValue, setEditingValue] = useState(null); const [editingValue, setEditingValue] = useState(null);
const [editingDraft, setEditingDraft] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', category: '', priority: 0, enabled: true }); 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); const [addModalOpen, setAddModalOpen] = useState(false);
@@ -51,7 +57,24 @@ function CommunitiesManager() {
const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null); 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 () => { const fetchItems = async () => {
setLoading(true); 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) => { const saveAll = async (data) => {
setLoading(true); setLoading(true);
try { try {
@@ -245,14 +304,16 @@ function CommunitiesManager() {
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = searchTerm.trim().toLowerCase(); const q = searchTerm.trim().toLowerCase();
if (!q) return sorted; const searchFiltered = q
return sorted.filter(i => ? sorted.filter(i =>
(i.value || '').toLowerCase().includes(q) || (i.value || '').toLowerCase().includes(q) ||
(i.name || '').toLowerCase().includes(q) || (i.name || '').toLowerCase().includes(q) ||
(i.description || '').toLowerCase().includes(q) || (i.description || '').toLowerCase().includes(q) ||
(i.tags || []).some(t => (t || '').toLowerCase().includes(q)) (i.tags || []).some(t => (t || '').toLowerCase().includes(q))
); )
}, [sorted, searchTerm]); : sorted;
return searchFiltered.filter((i) => !showOnlyUsedInFilters || (filterUsage[i.value] || 0) > 0);
}, [sorted, searchTerm, showOnlyUsedInFilters, filterUsage]);
const totalPages = Math.ceil(filtered.length / pageSize) || 1; const totalPages = Math.ceil(filtered.length / pageSize) || 1;
const page = Math.min(currentPage, totalPages); const page = Math.min(currentPage, totalPages);
@@ -375,7 +436,7 @@ function CommunitiesManager() {
</div> </div>
</div> </div>
<div className="card-body"> <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 <SavedFilters
pageKey="communities" pageKey="communities"
currentFilters={{ searchTerm, sortField, sortOrder }} currentFilters={{ searchTerm, sortField, sortOrder }}
@@ -407,6 +468,33 @@ function CommunitiesManager() {
</button> </button>
)} )}
</div> </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>
<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">
@@ -437,14 +525,15 @@ function CommunitiesManager() {
<th>Описание</th> <th>Описание</th>
<th>Теги</th> <th>Теги</th>
<th>Gateway</th> <th>Gateway</th>
<th style={{ width: '10%' }}>Исп. в фильтрах</th>
<th className="text-end">Действия</th> <th className="text-end">Действия</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{loading ? ( {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 ? ( ) : 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 <EmptyState
title="Нет community" title="Нет community"
description="Добавьте запись, чтобы начать." description="Добавьте запись, чтобы начать."
@@ -456,46 +545,63 @@ function CommunitiesManager() {
} }
/> />
</td></tr> </td></tr>
) : paginated.map((item) => ( ) : paginated.map((item) => {
<tr key={item.value}> const usageCount = filterUsage[item.value] || 0;
<td> return (
<code className="text-blue">{item.value}</code> <tr key={item.value}>
{item._external && ( <td>
<span className="badge bg-yellow-lt text-yellow ms-2" title="Не внесён в справочник">не в справ.</span> <code className="text-blue">{item.value}</code>
)} {item._external && (
</td> <span className="badge bg-yellow-lt text-yellow ms-2" title="Не внесён в справочник">не в справ.</span>
<td>{item.name || <span className="text-muted"></span>}</td> )}
<td> </td>
<span className="text-muted" title={item.description}>{item.description || '—'}</span> <td>{item.name || <span className="text-muted"></span>}</td>
</td> <td>
<td> <span className="text-muted" title={item.description}>{item.description || '—'}</span>
{(item.tags || []).map(t => ( </td>
<span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span> <td>
))} {(item.tags || []).map(t => (
</td> <span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span>
<td> ))}
<span className="text-muted">{item.gatewayDefault || '—'}</span> </td>
</td> <td>
<td className="text-end"> <span className="text-muted">{item.gatewayDefault || '—'}</span>
<div className="btn-list"> </td>
<button <td>
className="btn btn-outline-primary btn-icon btn-sm" {usageCount > 0 ? (
onClick={()=>startEdit(item)} <span className="badge bg-blue-lt text-blue">{usageCount} в фильтрах</span>
title="Редактировать" ) : (
> <span className="text-muted">0</span>
<IconEdit size={16} /> )}
</button> </td>
<button <td className="text-end">
className="btn btn-outline-danger btn-icon btn-sm" <div className="btn-list">
onClick={()=>handleDeleteClick(item)} <a
title="Удалить" className="btn btn-outline-primary btn-icon btn-sm"
> href={`/filters?community=${encodeURIComponent(item.value)}`}
<IconTrash size={16} /> title="Открыть в /filters с поиском по community"
</button> >
</div> <IconLink size={16} />
</td> </a>
</tr> <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> </tbody>
</table> </table>
</div> </div>
+9
View File
@@ -69,6 +69,15 @@ function FilterManager() {
const [filterSearch, setFilterSearch] = useState(''); const [filterSearch, setFilterSearch] = useState('');
const [sortBy, setSortBy] = useState('community'); // community | gateway | description const [sortBy, setSortBy] = useState('community'); // community | gateway | description
const [sortDir, setSortDir] = useState('asc'); // asc | desc 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); const [serverListModalOpen, setServerListModalOpen] = useState(false);