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

This commit is contained in:
2025-11-30 22:36:15 +07:00
parent 2d87ca1632
commit aa882bad20
+453 -248
View File
@@ -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>
);
}