Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
825 lines
33 KiB
React
825 lines
33 KiB
React
import { useEffect, useMemo, useState } from 'react';
|
|
import api from './lib/api.js';
|
|
import {
|
|
IconPlus,
|
|
IconSearch,
|
|
IconEdit,
|
|
IconTrash,
|
|
IconCheck,
|
|
IconX,
|
|
IconHash,
|
|
IconFilter,
|
|
IconChartBar,
|
|
IconList,
|
|
IconLink,
|
|
IconRefresh,
|
|
IconSortAscending,
|
|
IconSortDescending
|
|
} from '@tabler/icons-react';
|
|
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
|
import EmptyState from './components/EmptyState.jsx';
|
|
import Breadcrumbs from './components/Breadcrumbs.jsx';
|
|
import CommunityStats from './components/CommunityStats.jsx';
|
|
import SavedFilters from './components/SavedFilters.jsx';
|
|
import FormModal from './components/FormModal.jsx';
|
|
import ConfirmModal from './components/ConfirmModal.jsx';
|
|
import FormField from './components/FormField.jsx';
|
|
import ErrorAlert from './components/ErrorAlert.jsx';
|
|
import Pagination from './components/Pagination.jsx';
|
|
|
|
const API_URL = '/api';
|
|
|
|
function CommunitiesManager() {
|
|
const [items, setItems] = useState([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [success, setSuccess] = useState('');
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [sortField, setSortField] = useState('value');
|
|
const [sortOrder, setSortOrder] = useState('asc');
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const pageSize = 10;
|
|
|
|
// Состояние для переключения между списком и статистикой
|
|
const [activeTab, setActiveTab] = useState('list'); // 'list' | 'stats'
|
|
|
|
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);
|
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
|
const [itemToDelete, setItemToDelete] = useState(null);
|
|
|
|
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);
|
|
try {
|
|
const [dictRes, asnsRes, ipRes, domRes] = await Promise.all([
|
|
api.get(`/communities`),
|
|
api.get(`/asns`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })),
|
|
api.get(`/ip-ranges`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })),
|
|
api.get(`/domains-new`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })),
|
|
]);
|
|
const dict = Array.isArray(dictRes.data) ? dictRes.data : [];
|
|
const asnsPayload = Array.isArray(asnsRes.data?.items) ? asnsRes.data.items : (Array.isArray(asnsRes.data) ? asnsRes.data : []);
|
|
const ipPayload = Array.isArray(ipRes.data?.items) ? ipRes.data.items : (Array.isArray(ipRes.data) ? ipRes.data : []);
|
|
const domPayload = Array.isArray(domRes.data?.items) ? domRes.data.items : (Array.isArray(domRes.data) ? domRes.data : []);
|
|
|
|
const usedCommunities = new Set();
|
|
// ASNs: fields domain/type
|
|
for (const it of asnsPayload) {
|
|
const v = String(it.type ?? '').trim();
|
|
if (v) usedCommunities.add(v);
|
|
}
|
|
// IP ranges: fields ipRange/community
|
|
for (const it of ipPayload) {
|
|
const v = String(it.community ?? '').trim();
|
|
if (v) usedCommunities.add(v);
|
|
}
|
|
// Domains: fields domain/community
|
|
for (const it of domPayload) {
|
|
const v = String(it.community ?? '').trim();
|
|
if (v) usedCommunities.add(v);
|
|
}
|
|
|
|
const map = new Map();
|
|
// put dictionary items first
|
|
for (const d of dict) {
|
|
const value = String(d.value || '').trim();
|
|
if (!value) continue;
|
|
map.set(value, {
|
|
value,
|
|
name: String(d.name || ''),
|
|
description: String(d.description || ''),
|
|
tags: Array.isArray(d.tags) ? d.tags : [],
|
|
gatewayDefault: String(d.gatewayDefault || ''),
|
|
color: String(d.color || ''),
|
|
_external: false,
|
|
});
|
|
}
|
|
// add unknown ones from usage
|
|
for (const v of usedCommunities) {
|
|
if (!map.has(v)) {
|
|
map.set(v, { value: v, name: '', description: '', tags: [], gatewayDefault: '', color: '', _external: true });
|
|
}
|
|
}
|
|
setItems(Array.from(map.values()));
|
|
setError('');
|
|
} catch (e) {
|
|
console.error('Error fetching communities:', e);
|
|
setError('Не удалось загрузить справочник community.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
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 {
|
|
// не отправляем служебное поле _external
|
|
const payload = data.map(({ _external, ...rest }) => rest);
|
|
await api.post(`/communities`, { communities: payload });
|
|
setSuccess('Справочник сохранён!');
|
|
setTimeout(() => setSuccess(''), 3000);
|
|
} catch (e) {
|
|
console.error('Error saving communities:', e);
|
|
setError(e.response?.data || 'Не удалось сохранить справочник.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const toTagsArray = (str) => (str || '').split(',').map(t => t.trim()).filter(Boolean);
|
|
const fromTagsArray = (arr) => (arr || []).join(', ');
|
|
|
|
const addItem = () => {
|
|
const value = String(newItem.value || '').trim();
|
|
if (!value) { setError('Поле value обязательно.'); return; }
|
|
if (items.some(i => i.value === value)) { setError('Такое value уже существует.'); return; }
|
|
if (!/^(\d+|\d+:\d+)$/.test(value)) { setError('Формат: число или N:N'); return; }
|
|
setError('');
|
|
setItems([...items, {
|
|
value,
|
|
name: String(newItem.name || ''),
|
|
description: String(newItem.description || ''),
|
|
tags: toTagsArray(newItem.tags),
|
|
gatewayDefault: String(newItem.gatewayDefault || ''),
|
|
color: String(newItem.color || ''),
|
|
}]);
|
|
setNewItem({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' });
|
|
setAddModalOpen(false);
|
|
setSuccess('Community успешно добавлен!');
|
|
setTimeout(() => setSuccess(''), 3000);
|
|
};
|
|
|
|
const startEdit = (item) => {
|
|
setEditingValue(item.value);
|
|
setEditingDraft({
|
|
value: item.value,
|
|
name: item.name || '',
|
|
description: item.description || '',
|
|
tags: fromTagsArray(item.tags),
|
|
gatewayDefault: item.gatewayDefault || '',
|
|
color: item.color || '',
|
|
});
|
|
setEditModalOpen(true);
|
|
};
|
|
|
|
const saveEdit = () => {
|
|
const value = String(editingDraft.value || '').trim();
|
|
if (!value) { setError('Поле value обязательно.'); return; }
|
|
if (!/^(\d+|\d+:\d+)$/.test(value)) { setError('Формат: число или N:N'); return; }
|
|
if (value !== editingValue && items.some(i => i.value === value)) { setError('Такое value уже существует.'); return; }
|
|
const next = items.map(i => i.value === editingValue ? {
|
|
value,
|
|
name: String(editingDraft.name || ''),
|
|
description: String(editingDraft.description || ''),
|
|
tags: toTagsArray(editingDraft.tags),
|
|
gatewayDefault: String(editingDraft.gatewayDefault || ''),
|
|
color: String(editingDraft.color || ''),
|
|
} : i);
|
|
setItems(next);
|
|
setEditingValue(null);
|
|
setEditModalOpen(false);
|
|
setSuccess('Community успешно обновлён!');
|
|
setTimeout(() => setSuccess(''), 3000);
|
|
};
|
|
|
|
const cancelEdit = () => {
|
|
setEditingValue(null);
|
|
setEditModalOpen(false);
|
|
};
|
|
|
|
const handleDeleteClick = (item) => {
|
|
setItemToDelete(item);
|
|
setDeleteModalOpen(true);
|
|
};
|
|
|
|
const confirmDelete = () => {
|
|
if (itemToDelete) {
|
|
setItems(items.filter(i => i.value !== itemToDelete.value));
|
|
setDeleteModalOpen(false);
|
|
setItemToDelete(null);
|
|
setSuccess('Community успешно удалён!');
|
|
setTimeout(() => setSuccess(''), 3000);
|
|
}
|
|
};
|
|
|
|
const handleImport = () => {
|
|
const text = window.prompt('Вставьте JSON-массив community (value, name, description, tags, gatewayDefault, color)');
|
|
if (!text) return;
|
|
try {
|
|
const arr = JSON.parse(text);
|
|
if (!Array.isArray(arr)) throw new Error('Ожидается массив');
|
|
setItems(prev => [...prev, ...arr.filter(x => x && x.value)]);
|
|
} catch (e) {
|
|
setError('Неверный JSON.');
|
|
}
|
|
};
|
|
|
|
const handleExport = () => {
|
|
const blob = new Blob([JSON.stringify(items, null, 2)], { type: 'application/json;charset=utf-8;' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `communities_${new Date().toISOString().split('T')[0]}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const handleSave = () => saveAll(items);
|
|
|
|
const sorted = useMemo(() => {
|
|
const arr = [...items];
|
|
arr.sort((a, b) => {
|
|
let va = (a[sortField] ?? '').toString().toLowerCase();
|
|
let vb = (b[sortField] ?? '').toString().toLowerCase();
|
|
if (va < vb) return sortOrder === 'asc' ? -1 : 1;
|
|
if (va > vb) return sortOrder === 'asc' ? 1 : -1;
|
|
return 0;
|
|
});
|
|
return arr;
|
|
}, [items, sortField, sortOrder]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = searchTerm.trim().toLowerCase();
|
|
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);
|
|
const paginated = filtered.slice((page - 1) * pageSize, page * pageSize);
|
|
|
|
const changeSort = (f) => {
|
|
if (sortField === f) setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
|
else { setSortField(f); setSortOrder('asc'); }
|
|
};
|
|
|
|
return (
|
|
<div className="page-wrapper">
|
|
<div className="page-header d-print-none">
|
|
<div className="container-xl">
|
|
<div className="row g-2 align-items-center">
|
|
<div className="col">
|
|
<h2 className="page-title">
|
|
<IconFilter className="icon me-2" />
|
|
Справочник Community
|
|
</h2>
|
|
<div className="page-pretitle">
|
|
<Breadcrumbs items={[
|
|
{ label: 'Главная', to: '/dashboard' },
|
|
{ label: 'Данные' },
|
|
{ label: 'Community' },
|
|
]} />
|
|
</div>
|
|
</div>
|
|
<div className="col-auto ms-auto d-print-none">
|
|
<PageHeaderActions
|
|
loading={loading}
|
|
onRefresh={fetchItems}
|
|
disableRefresh={loading}
|
|
onImport={handleImport}
|
|
onExport={handleExport}
|
|
disableExport={items.length === 0}
|
|
onSave={handleSave}
|
|
disableSave={loading}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="page-body">
|
|
<div className="container-xl">
|
|
{/* Уведомления */}
|
|
{error && (
|
|
<ErrorAlert message={error} onClose={() => setError('')} onRetry={fetchItems} />
|
|
)}
|
|
{success && (
|
|
<div className="alert alert-success alert-dismissible" role="alert">
|
|
<div className="d-flex">
|
|
<div>
|
|
<IconCheck className="icon alert-icon" />
|
|
</div>
|
|
<div>
|
|
{success}
|
|
</div>
|
|
</div>
|
|
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Вкладки: Список / Статистика */}
|
|
<div className="card mb-3">
|
|
<div className="card-body p-2">
|
|
<ul className="nav nav-tabs nav-tabs-bg" role="tablist">
|
|
<li className="nav-item" role="presentation">
|
|
<button
|
|
className={`nav-link ${activeTab === 'list' ? 'active' : ''}`}
|
|
onClick={() => setActiveTab('list')}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={activeTab === 'list'}
|
|
>
|
|
<IconList className="me-2" size={18} />
|
|
Список communities
|
|
</button>
|
|
</li>
|
|
<li className="nav-item" role="presentation">
|
|
<button
|
|
className={`nav-link ${activeTab === 'stats' ? 'active' : ''}`}
|
|
onClick={() => setActiveTab('stats')}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={activeTab === 'stats'}
|
|
>
|
|
<IconChartBar className="me-2" size={18} />
|
|
Статистика использования
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Отображение списка communities */}
|
|
{activeTab === 'list' && (
|
|
<div className="row g-3">
|
|
<div className="col-12">
|
|
<div className="card">
|
|
<div className="card-header">
|
|
<div className="row align-items-center w-100 g-2">
|
|
<div className="col">
|
|
<h3 className="card-title mb-0">
|
|
<IconFilter className="icon me-2" size={20} />
|
|
Список community
|
|
<span className="badge bg-blue-lt text-blue ms-2">{filtered.length}</span>
|
|
</h3>
|
|
</div>
|
|
<div className="col-auto">
|
|
<button
|
|
className="btn btn-primary"
|
|
onClick={() => setAddModalOpen(true)}
|
|
>
|
|
<IconPlus className="icon me-1" />
|
|
Добавить community
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="card-body">
|
|
<div className="d-flex gap-2 flex-wrap mb-3 align-items-center">
|
|
<SavedFilters
|
|
pageKey="communities"
|
|
currentFilters={{ searchTerm, sortField, sortOrder }}
|
|
onApplyFilter={(filters) => {
|
|
if (filters.searchTerm !== undefined) setSearchTerm(filters.searchTerm);
|
|
if (filters.sortField !== undefined) setSortField(filters.sortField);
|
|
if (filters.sortOrder !== undefined) setSortOrder(filters.sortOrder);
|
|
setCurrentPage(1);
|
|
}}
|
|
/>
|
|
<div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}>
|
|
<span className="input-icon-addon"><IconSearch size={18} /></span>
|
|
<input
|
|
className="form-control"
|
|
placeholder="Поиск по value, названию, описанию и тегам"
|
|
value={searchTerm}
|
|
onChange={(e)=>{setSearchTerm(e.target.value); setCurrentPage(1);}}
|
|
style={{ paddingRight: searchTerm ? '2.5rem' : undefined }}
|
|
/>
|
|
{!!searchTerm && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost-secondary btn-icon btn-sm"
|
|
style={{ position: 'absolute', right: '0.25rem', top: '50%', transform: 'translateY(-50%)', zIndex: 2 }}
|
|
aria-label="Очистить поиск"
|
|
onClick={()=>{ setSearchTerm(''); setCurrentPage(1); }}
|
|
>
|
|
<IconX size={16} />
|
|
</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">
|
|
<thead>
|
|
<tr>
|
|
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('value')}>
|
|
<div className="d-flex align-items-center">
|
|
<IconHash size={16} className="me-1 text-muted" />
|
|
Value
|
|
{sortField==='value' && (
|
|
<span className="ms-1">
|
|
{sortOrder==='asc' ? <IconSortAscending size={14} /> : <IconSortDescending size={14} />}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</th>
|
|
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('name')}>
|
|
<div className="d-flex align-items-center">
|
|
<IconFilter size={16} className="me-1 text-muted" />
|
|
Название
|
|
{sortField==='name' && (
|
|
<span className="ms-1">
|
|
{sortOrder==='asc' ? <IconSortAscending size={14} /> : <IconSortDescending size={14} />}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</th>
|
|
<th>Описание</th>
|
|
<th>Теги</th>
|
|
<th>Gateway</th>
|
|
<th style={{ width: '10%' }}>Исп. в фильтрах</th>
|
|
<th className="text-end">Действия</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr><td colSpan={7} className="text-center text-muted py-4">Загрузка...</td></tr>
|
|
) : paginated.length === 0 ? (
|
|
<tr><td colSpan={7} className="text-center text-muted py-4">
|
|
<EmptyState
|
|
title="Нет community"
|
|
description="Добавьте запись, чтобы начать."
|
|
action={
|
|
<button className="btn btn-primary" onClick={() => setAddModalOpen(true)}>
|
|
<IconPlus className="me-2" />
|
|
Добавить
|
|
</button>
|
|
}
|
|
/>
|
|
</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>
|
|
|
|
{totalPages > 1 && (
|
|
<Pagination
|
|
currentPage={page}
|
|
totalPages={totalPages}
|
|
totalItems={filtered.length}
|
|
pageSize={pageSize}
|
|
onPageChange={setCurrentPage}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Отображение статистики communities */}
|
|
{activeTab === 'stats' && (
|
|
<CommunityStats communities={items} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* Модальное окно добавления */}
|
|
<FormModal
|
|
show={addModalOpen}
|
|
onClose={() => {
|
|
setAddModalOpen(false);
|
|
setNewItem({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' });
|
|
setError('');
|
|
}}
|
|
onSubmit={addItem}
|
|
title="Добавить community"
|
|
submitLabel="Добавить"
|
|
submitIcon={IconPlus}
|
|
size="md"
|
|
>
|
|
{error && (
|
|
<div className="alert alert-danger mb-3" role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<FormField
|
|
label="Value"
|
|
name="value"
|
|
value={newItem.value}
|
|
onChange={(val) => setNewItem({...newItem, value: val})}
|
|
placeholder="65001:200 или 100"
|
|
required
|
|
icon={IconHash}
|
|
helpText="Формат: число или N:N"
|
|
onValidate={(val) => {
|
|
const trimmed = String(val || '').trim();
|
|
if (!trimmed) return { valid: false, message: 'Поле обязательно' };
|
|
if (!/^(\d+|\d+:\d+)$/.test(trimmed)) return { valid: false, message: 'Формат: число или N:N' };
|
|
if (items.some(i => i.value === trimmed)) return { valid: false, message: 'Такое value уже существует' };
|
|
return { valid: true, message: 'Корректно' };
|
|
}}
|
|
/>
|
|
|
|
<FormField
|
|
label="Название"
|
|
name="name"
|
|
value={newItem.name}
|
|
onChange={(val) => setNewItem({...newItem, name: val})}
|
|
placeholder="Напр. Social"
|
|
icon={IconFilter}
|
|
/>
|
|
|
|
<FormField
|
|
label="Описание"
|
|
name="description"
|
|
type="textarea"
|
|
value={newItem.description}
|
|
onChange={(val) => setNewItem({...newItem, description: val})}
|
|
rows={2}
|
|
/>
|
|
|
|
<FormField
|
|
label="Теги (через запятую)"
|
|
name="tags"
|
|
value={newItem.tags}
|
|
onChange={(val) => setNewItem({...newItem, tags: val})}
|
|
placeholder="video,bank"
|
|
helpText="Разделяйте теги запятыми"
|
|
/>
|
|
|
|
<FormField
|
|
label="Gateway по умолчанию"
|
|
name="gatewayDefault"
|
|
value={newItem.gatewayDefault}
|
|
onChange={(val) => setNewItem({...newItem, gatewayDefault: val})}
|
|
placeholder="SWE-HIPHOST"
|
|
/>
|
|
|
|
<FormField
|
|
label="Цвет бейджа (опционально)"
|
|
name="color"
|
|
value={newItem.color}
|
|
onChange={(val) => setNewItem({...newItem, color: val})}
|
|
placeholder="blue, green, red ..."
|
|
helpText="Цвет для отображения бейджа в интерфейсе"
|
|
/>
|
|
</FormModal>
|
|
|
|
{/* Модальное окно редактирования */}
|
|
<FormModal
|
|
show={editModalOpen}
|
|
onClose={cancelEdit}
|
|
onSubmit={saveEdit}
|
|
title="Редактировать community"
|
|
submitLabel="Сохранить"
|
|
submitIcon={IconCheck}
|
|
size="md"
|
|
>
|
|
{error && (
|
|
<div className="alert alert-danger mb-3" role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<FormField
|
|
label="Value"
|
|
name="value"
|
|
value={editingDraft.value}
|
|
onChange={(val) => setEditingDraft({...editingDraft, value: val})}
|
|
placeholder="65001:200 или 100"
|
|
required
|
|
icon={IconHash}
|
|
helpText="Формат: число или N:N"
|
|
onValidate={(val) => {
|
|
const trimmed = String(val || '').trim();
|
|
if (!trimmed) return { valid: false, message: 'Поле обязательно' };
|
|
if (!/^(\d+|\d+:\d+)$/.test(trimmed)) return { valid: false, message: 'Формат: число или N:N' };
|
|
if (trimmed !== editingValue && items.some(i => i.value === trimmed)) {
|
|
return { valid: false, message: 'Такое value уже существует' };
|
|
}
|
|
return { valid: true, message: 'Корректно' };
|
|
}}
|
|
/>
|
|
|
|
<FormField
|
|
label="Название"
|
|
name="name"
|
|
value={editingDraft.name}
|
|
onChange={(val) => setEditingDraft({...editingDraft, name: val})}
|
|
placeholder="Напр. Social"
|
|
icon={IconFilter}
|
|
/>
|
|
|
|
<FormField
|
|
label="Описание"
|
|
name="description"
|
|
type="textarea"
|
|
value={editingDraft.description}
|
|
onChange={(val) => setEditingDraft({...editingDraft, description: val})}
|
|
rows={2}
|
|
/>
|
|
|
|
<FormField
|
|
label="Теги (через запятую)"
|
|
name="tags"
|
|
value={editingDraft.tags}
|
|
onChange={(val) => setEditingDraft({...editingDraft, tags: val})}
|
|
placeholder="video,bank"
|
|
helpText="Разделяйте теги запятыми"
|
|
/>
|
|
|
|
<FormField
|
|
label="Gateway по умолчанию"
|
|
name="gatewayDefault"
|
|
value={editingDraft.gatewayDefault}
|
|
onChange={(val) => setEditingDraft({...editingDraft, gatewayDefault: val})}
|
|
placeholder="SWE-HIPHOST"
|
|
/>
|
|
|
|
<FormField
|
|
label="Цвет бейджа (опционально)"
|
|
name="color"
|
|
value={editingDraft.color}
|
|
onChange={(val) => setEditingDraft({...editingDraft, color: val})}
|
|
placeholder="blue, green, red ..."
|
|
helpText="Цвет для отображения бейджа в интерфейсе"
|
|
/>
|
|
</FormModal>
|
|
|
|
{/* Модальное окно подтверждения удаления */}
|
|
<ConfirmModal
|
|
show={deleteModalOpen}
|
|
onClose={() => {
|
|
setDeleteModalOpen(false);
|
|
setItemToDelete(null);
|
|
}}
|
|
onConfirm={confirmDelete}
|
|
title="Удалить community?"
|
|
message={
|
|
itemToDelete ? (
|
|
<>
|
|
<p>Вы уверены, что хотите удалить community <strong><code>{itemToDelete.value}</code></strong>?</p>
|
|
{itemToDelete.name && (
|
|
<p className="text-muted mb-0">Название: {itemToDelete.name}</p>
|
|
)}
|
|
<p className="text-muted mb-0 mt-2">Это действие необратимо.</p>
|
|
</>
|
|
) : null
|
|
}
|
|
confirmLabel="Удалить"
|
|
variant="danger"
|
|
icon={IconTrash}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default CommunitiesManager;
|
|
|
|
|