feat: Refactor CommunitiesManager component to enhance UI and functionality with modals for adding, editing, and deleting communities. Improved validation and error handling, and updated layout for better user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 57s
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 57s
This commit is contained in:
+453
-248
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import api from './lib/api.js';
|
||||
import {
|
||||
IconPlus,
|
||||
@@ -7,25 +7,23 @@ import {
|
||||
IconTrash,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconDatabase,
|
||||
IconRefresh,
|
||||
IconUpload,
|
||||
IconDownload,
|
||||
IconDeviceFloppy,
|
||||
IconHash,
|
||||
IconFilter,
|
||||
IconChartBar,
|
||||
IconList,
|
||||
IconArrowUp,
|
||||
IconArrowDown
|
||||
IconSortAscending,
|
||||
IconSortDescending
|
||||
} from '@tabler/icons-react';
|
||||
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
||||
import PageHeader from './components/PageHeader.jsx';
|
||||
import EmptyState from './components/EmptyState.jsx';
|
||||
import { TableEmpty } from './components/TableSkeleton.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';
|
||||
|
||||
@@ -46,7 +44,12 @@ 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 editRef = useRef(null);
|
||||
|
||||
// Модальные окна
|
||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [itemToDelete, setItemToDelete] = useState(null);
|
||||
|
||||
useEffect(() => { fetchItems(); }, []);
|
||||
|
||||
@@ -135,6 +138,7 @@ function CommunitiesManager() {
|
||||
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,
|
||||
@@ -145,6 +149,9 @@ function CommunitiesManager() {
|
||||
color: String(newItem.color || ''),
|
||||
}]);
|
||||
setNewItem({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' });
|
||||
setAddModalOpen(false);
|
||||
setSuccess('Community успешно добавлен!');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
};
|
||||
|
||||
const startEdit = (item) => {
|
||||
@@ -157,11 +164,13 @@ function CommunitiesManager() {
|
||||
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,
|
||||
@@ -173,11 +182,30 @@ function CommunitiesManager() {
|
||||
} : i);
|
||||
setItems(next);
|
||||
setEditingValue(null);
|
||||
setEditModalOpen(false);
|
||||
setSuccess('Community успешно обновлён!');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
};
|
||||
|
||||
const cancelEdit = () => setEditingValue(null);
|
||||
const cancelEdit = () => {
|
||||
setEditingValue(null);
|
||||
setEditModalOpen(false);
|
||||
};
|
||||
|
||||
const deleteItem = (value) => setItems(items.filter(i => i.value !== value));
|
||||
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)');
|
||||
@@ -236,122 +264,118 @@ function CommunitiesManager() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={(<><IconFilter className="me-2" />Справочник Community</>)}
|
||||
pretitle={(
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Главная', to: '/dashboard' },
|
||||
{ label: 'Данные' },
|
||||
{ label: 'Community' },
|
||||
]} />
|
||||
)}
|
||||
actions={(
|
||||
<PageHeaderActions
|
||||
loading={loading}
|
||||
onRefresh={fetchItems}
|
||||
disableRefresh={loading}
|
||||
onImport={handleImport}
|
||||
onExport={handleExport}
|
||||
disableExport={items.length === 0}
|
||||
onSave={handleSave}
|
||||
disableSave={loading}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Вкладки: Список / Статистика */}
|
||||
<div className="card mb-3">
|
||||
<div className="card-body p-2">
|
||||
<div className="btn-group w-100" role="group">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${activeTab === 'list' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||
onClick={() => setActiveTab('list')}
|
||||
>
|
||||
<IconList className="me-2" size={18} />
|
||||
Список communities
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${activeTab === 'stats' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||||
onClick={() => setActiveTab('stats')}
|
||||
>
|
||||
<IconChartBar className="me-2" size={18} />
|
||||
Статистика использования
|
||||
</button>
|
||||
<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>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger alert-dismissible" role="alert">
|
||||
<IconX className="me-2" />{error}
|
||||
<button type="button" className="btn-close" onClick={() => setError('')}></button>
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="alert alert-success alert-dismissible" role="alert">
|
||||
<IconCheck className="me-2" />{success}
|
||||
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Отображение списка communities */}
|
||||
{activeTab === 'list' && (
|
||||
<div className="row g-3">
|
||||
<div className="col-lg-3">
|
||||
<div className="card card-md animate-in">
|
||||
<div className="card-header bg-primary-lt"><h3 className="card-title text-primary"><IconPlus className="me-2" />Добавить</h3></div>
|
||||
<div className="card-body">
|
||||
<div className="mb-2">
|
||||
<label className="form-label required">Value</label>
|
||||
<div className="input-icon">
|
||||
<span className="input-icon-addon"><IconHash size={18} /></span>
|
||||
<input className={`form-control${newItem.value && !/^(\d+|\d+:\d+)$/.test(String(newItem.value).trim()) ? ' is-invalid' : (newItem.value ? ' is-valid' : '')}`} value={newItem.value} onChange={(e)=>setNewItem({...newItem, value:e.target.value})} placeholder="65001:200 или 100" />
|
||||
</div>
|
||||
{newItem.value && !/^(\d+|\d+:\d+)$/.test(String(newItem.value).trim()) && (<div className="invalid-feedback">Формат: число или N:N</div>)}
|
||||
{newItem.value && /^(\d+|\d+:\d+)$/.test(String(newItem.value).trim()) && (<div className="valid-feedback">Корректно</div>)}
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Название</label>
|
||||
<div className="input-icon">
|
||||
<span className="input-icon-addon"><IconFilter size={18} /></span>
|
||||
<input className="form-control" value={newItem.name} onChange={(e)=>setNewItem({...newItem, name:e.target.value})} placeholder="Напр. Social" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Описание</label>
|
||||
<textarea className="form-control" rows={2} value={newItem.description} onChange={(e)=>setNewItem({...newItem, description:e.target.value})} />
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Теги (через запятую)</label>
|
||||
<input className="form-control" value={newItem.tags} onChange={(e)=>setNewItem({...newItem, tags:e.target.value})} placeholder="video,bank" />
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Gateway по умолчанию</label>
|
||||
<input className="form-control" value={newItem.gatewayDefault} onChange={(e)=>setNewItem({...newItem, gatewayDefault:e.target.value})} placeholder="SWE-HIPHOST" />
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Цвет бейджа (опц.)</label>
|
||||
<input className="form-control" value={newItem.color} onChange={(e)=>setNewItem({...newItem, color:e.target.value})} placeholder="blue, green, red ..." />
|
||||
</div>
|
||||
<button className="btn btn-primary w-100" onClick={addItem} disabled={!/^(\d+|\d+:\d+)$/.test(String(newItem.value || '').trim())}><IconPlus className="me-2" />Добавить</button>
|
||||
{/* Вкладки: Список / Статистика */}
|
||||
<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>
|
||||
|
||||
{/* Карточка действий не нужна — действия перенесены в page-header */}
|
||||
</div>
|
||||
|
||||
<div className="col-lg-9">
|
||||
<div className="card animate-in" style={{animationDelay: '0.2s'}}>
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center w-100 g-2">
|
||||
<div className="col-auto">
|
||||
<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>
|
||||
{/* Отображение списка 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="col">
|
||||
<div className="d-flex gap-2 flex-wrap">
|
||||
<div className="card-body">
|
||||
<div className="d-flex gap-2 flex-wrap mb-3">
|
||||
<SavedFilters
|
||||
pageKey="communities"
|
||||
currentFilters={{ searchTerm, sortField, sortOrder }}
|
||||
@@ -364,146 +388,327 @@ function CommunitiesManager() {
|
||||
/>
|
||||
<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 }} />
|
||||
<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); }}>
|
||||
<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>
|
||||
<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 className="text-end">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="text-center text-muted py-4">Загрузка...</td></tr>
|
||||
) : paginated.length === 0 ? (
|
||||
<tr><td colSpan={6} 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) => (
|
||||
<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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
totalItems={filtered.length}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</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'? <IconArrowUp size={14} /> : <IconArrowDown 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'? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}</span>}
|
||||
</div>
|
||||
</th>
|
||||
<th>Описание</th>
|
||||
<th>Теги</th>
|
||||
<th>Gateway</th>
|
||||
<th className="text-end">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="text-center text-muted py-4">Загрузка...</td></tr>
|
||||
) : paginated.length === 0 ? (
|
||||
<tr><td colSpan={6} className="text-center text-muted py-4">
|
||||
<EmptyState title="Нет community" description="Добавьте запись, чтобы начать." action={<button className="btn btn-primary" onClick={addItem}>Добавить</button>} />
|
||||
</td></tr>
|
||||
) : paginated.map((item, idx) => (
|
||||
<tr key={item.value} className={editingValue === item.value ? 'table-active' : ''} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}>
|
||||
<td>
|
||||
{editingValue === item.value ? (
|
||||
<input className="form-control form-control-sm" value={editingDraft.value} onChange={(e)=>setEditingDraft({...editingDraft, value:e.target.value})} ref={editRef} />
|
||||
) : (
|
||||
<>
|
||||
<code className="text-blue">{item.value}</code>
|
||||
{item._external && (
|
||||
<span className="badge bg-yellow-lt text-yellow ms-2" title="Не внесён в справочник">не в справ.</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editingValue === item.value ? (
|
||||
<input className="form-control form-control-sm" value={editingDraft.name} onChange={(e)=>setEditingDraft({...editingDraft, name:e.target.value})} />
|
||||
) : item.name || <span className="text-muted">—</span>}
|
||||
</td>
|
||||
<td>
|
||||
{editingValue === item.value ? (
|
||||
<input className="form-control form-control-sm" value={editingDraft.description} onChange={(e)=>setEditingDraft({...editingDraft, description:e.target.value})} />
|
||||
) : (
|
||||
<span className="text-muted" title={item.description}>{item.description || '—'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editingValue === item.value ? (
|
||||
<input className="form-control form-control-sm" value={editingDraft.tags} onChange={(e)=>setEditingDraft({...editingDraft, tags:e.target.value})} />
|
||||
) : (
|
||||
(item.tags || []).map(t => <span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span>)
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editingValue === item.value ? (
|
||||
<input className="form-control form-control-sm" value={editingDraft.gatewayDefault} onChange={(e)=>setEditingDraft({...editingDraft, gatewayDefault:e.target.value})} />
|
||||
) : (
|
||||
<span className="text-muted">{item.gatewayDefault || '—'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
{editingValue === item.value ? (
|
||||
<>
|
||||
<button className="btn btn-success btn-icon btn-sm me-1" onClick={saveEdit}><IconCheck size={16} /></button>
|
||||
<button className="btn btn-secondary btn-icon btn-sm" onClick={cancelEdit}><IconX size={16} /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn btn-outline-primary btn-icon btn-sm me-1" onClick={()=>startEdit(item)}><IconEdit size={16} /></button>
|
||||
<button className="btn btn-outline-danger btn-icon btn-sm" onClick={()=>deleteItem(item.value)}><IconTrash size={16} /></button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="card-footer">
|
||||
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<div className="text-muted small">Показано <strong>{((page - 1) * pageSize) + 1}</strong> - <strong>{Math.min(page * pageSize, filtered.length)}</strong> из <strong>{filtered.length}</strong></div>
|
||||
<ul className="pagination m-0">
|
||||
<li className={`page-item${page === 1 ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={()=>setCurrentPage(1)} disabled={page===1} aria-label="Первая страница">
|
||||
<IconArrowUp size={14} style={{ transform: 'rotate(-90deg)' }} />
|
||||
</button>
|
||||
</li>
|
||||
<li className={`page-item${page === 1 ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={()=>setCurrentPage(page-1)} disabled={page===1} aria-label="Предыдущая страница">
|
||||
<IconArrowUp size={14} style={{ transform: 'rotate(-90deg)' }} />
|
||||
<span className="d-none d-sm-inline ms-1">Назад</span>
|
||||
</button>
|
||||
</li>
|
||||
<li className={`page-item${page === totalPages ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={()=>setCurrentPage(page+1)} disabled={page===totalPages} aria-label="Следующая страница">
|
||||
<span className="d-none d-sm-inline me-1">Вперед</span>
|
||||
<IconArrowDown size={14} style={{ transform: 'rotate(-90deg)' }} />
|
||||
</button>
|
||||
</li>
|
||||
<li className={`page-item${page === totalPages ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={()=>setCurrentPage(totalPages)} disabled={page===totalPages} aria-label="Последняя страница">
|
||||
<IconArrowDown size={14} style={{ transform: 'rotate(-90deg)' }} />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Отображение статистики communities */}
|
||||
{activeTab === 'stats' && (
|
||||
<CommunityStats communities={items} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Отображение статистики communities */}
|
||||
{activeTab === 'stats' && (
|
||||
<CommunityStats communities={items} />
|
||||
)}
|
||||
{/* Модальное окно добавления */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user