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
+375 -170
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 api from './lib/api.js';
import { import {
IconPlus, IconPlus,
@@ -7,25 +7,23 @@ import {
IconTrash, IconTrash,
IconCheck, IconCheck,
IconX, IconX,
IconDatabase,
IconRefresh,
IconUpload,
IconDownload,
IconDeviceFloppy,
IconHash, IconHash,
IconFilter, IconFilter,
IconChartBar, IconChartBar,
IconList, IconList,
IconArrowUp, IconSortAscending,
IconArrowDown IconSortDescending
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import PageHeaderActions from './components/PageHeaderActions.jsx'; import PageHeaderActions from './components/PageHeaderActions.jsx';
import PageHeader from './components/PageHeader.jsx';
import EmptyState from './components/EmptyState.jsx'; import EmptyState from './components/EmptyState.jsx';
import { TableEmpty } from './components/TableSkeleton.jsx';
import Breadcrumbs from './components/Breadcrumbs.jsx'; import Breadcrumbs from './components/Breadcrumbs.jsx';
import CommunityStats from './components/CommunityStats.jsx'; import CommunityStats from './components/CommunityStats.jsx';
import SavedFilters from './components/SavedFilters.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'; 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 [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 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(); }, []); useEffect(() => { fetchItems(); }, []);
@@ -135,6 +138,7 @@ function CommunitiesManager() {
const value = String(newItem.value || '').trim(); const value = String(newItem.value || '').trim();
if (!value) { setError('Поле value обязательно.'); return; } if (!value) { setError('Поле value обязательно.'); return; }
if (items.some(i => i.value === 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(''); setError('');
setItems([...items, { setItems([...items, {
value, value,
@@ -145,6 +149,9 @@ function CommunitiesManager() {
color: String(newItem.color || ''), color: String(newItem.color || ''),
}]); }]);
setNewItem({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' }); setNewItem({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' });
setAddModalOpen(false);
setSuccess('Community успешно добавлен!');
setTimeout(() => setSuccess(''), 3000);
}; };
const startEdit = (item) => { const startEdit = (item) => {
@@ -157,11 +164,13 @@ function CommunitiesManager() {
gatewayDefault: item.gatewayDefault || '', gatewayDefault: item.gatewayDefault || '',
color: item.color || '', color: item.color || '',
}); });
setEditModalOpen(true);
}; };
const saveEdit = () => { const saveEdit = () => {
const value = String(editingDraft.value || '').trim(); const value = String(editingDraft.value || '').trim();
if (!value) { setError('Поле value обязательно.'); return; } 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; } if (value !== editingValue && items.some(i => i.value === value)) { setError('Такое value уже существует.'); return; }
const next = items.map(i => i.value === editingValue ? { const next = items.map(i => i.value === editingValue ? {
value, value,
@@ -173,11 +182,30 @@ function CommunitiesManager() {
} : i); } : i);
setItems(next); setItems(next);
setEditingValue(null); 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 handleImport = () => {
const text = window.prompt('Вставьте JSON-массив community (value, name, description, tags, gatewayDefault, color)'); const text = window.prompt('Вставьте JSON-массив community (value, name, description, tags, gatewayDefault, color)');
@@ -236,17 +264,24 @@ function CommunitiesManager() {
}; };
return ( return (
<div> <div className="page-wrapper">
<PageHeader <div className="page-header d-print-none">
title={(<><IconFilter className="me-2" />Справочник Community</>)} <div className="container-xl">
pretitle={( <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={[ <Breadcrumbs items={[
{ label: 'Главная', to: '/dashboard' }, { label: 'Главная', to: '/dashboard' },
{ label: 'Данные' }, { label: 'Данные' },
{ label: 'Community' }, { label: 'Community' },
]} /> ]} />
)} </div>
actions={( </div>
<div className="col-auto ms-auto d-print-none">
<PageHeaderActions <PageHeaderActions
loading={loading} loading={loading}
onRefresh={fetchItems} onRefresh={fetchItems}
@@ -257,101 +292,90 @@ function CommunitiesManager() {
onSave={handleSave} onSave={handleSave}
disableSave={loading} disableSave={loading}
/> />
)} </div>
/>
{/* Вкладки: Список / Статистика */}
<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> </div>
</div> </div>
</div> </div>
<div className="page-body">
<div className="container-xl">
{/* Уведомления */}
{error && ( {error && (
<div className="alert alert-danger alert-dismissible" role="alert"> <ErrorAlert message={error} onClose={() => setError('')} onRetry={fetchItems} />
<IconX className="me-2" />{error}
<button type="button" className="btn-close" onClick={() => setError('')}></button>
</div>
)} )}
{success && ( {success && (
<div className="alert alert-success alert-dismissible" role="alert"> <div className="alert alert-success alert-dismissible" role="alert">
<IconCheck className="me-2" />{success} <div className="d-flex">
<div>
<IconCheck className="icon alert-icon" />
</div>
<div>
{success}
</div>
</div>
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button> <button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
</div> </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 */} {/* Отображение списка communities */}
{activeTab === 'list' && ( {activeTab === 'list' && (
<div className="row g-3"> <div className="row g-3">
<div className="col-lg-3"> <div className="col-12">
<div className="card card-md animate-in"> <div className="card">
<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>
</div>
{/* Карточка действий не нужна — действия перенесены в page-header */}
</div>
<div className="col-lg-9">
<div className="card animate-in" style={{animationDelay: '0.2s'}}>
<div className="card-header"> <div className="card-header">
<div className="row align-items-center w-100 g-2"> <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>
</div>
<div className="col"> <div className="col">
<div className="d-flex gap-2 flex-wrap"> <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">
<SavedFilters <SavedFilters
pageKey="communities" pageKey="communities"
currentFilters={{ searchTerm, sortField, sortOrder }} currentFilters={{ searchTerm, sortField, sortOrder }}
@@ -364,17 +388,26 @@ function CommunitiesManager() {
/> />
<div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}> <div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}>
<span className="input-icon-addon"><IconSearch size={18} /></span> <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 && ( {!!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} /> <IconX size={16} />
</button> </button>
)} )}
</div> </div>
</div> </div>
</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">
<thead> <thead>
@@ -383,14 +416,22 @@ function CommunitiesManager() {
<div className="d-flex align-items-center"> <div className="d-flex align-items-center">
<IconHash size={16} className="me-1 text-muted" /> <IconHash size={16} className="me-1 text-muted" />
Value Value
{sortField==='value' && <span className="ms-1">{sortOrder==='asc'? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}</span>} {sortField==='value' && (
<span className="ms-1">
{sortOrder==='asc' ? <IconSortAscending size={14} /> : <IconSortDescending size={14} />}
</span>
)}
</div> </div>
</th> </th>
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('name')}> <th className="cursor-pointer user-select-none" onClick={()=>changeSort('name')}>
<div className="d-flex align-items-center"> <div className="d-flex align-items-center">
<IconFilter size={16} className="me-1 text-muted" /> <IconFilter size={16} className="me-1 text-muted" />
Название Название
{sortField==='name' && <span className="ms-1">{sortOrder==='asc'? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}</span>} {sortField==='name' && (
<span className="ms-1">
{sortOrder==='asc' ? <IconSortAscending size={14} /> : <IconSortDescending size={14} />}
</span>
)}
</div> </div>
</th> </th>
<th>Описание</th> <th>Описание</th>
@@ -404,96 +445,69 @@ function CommunitiesManager() {
<tr><td colSpan={6} className="text-center text-muted py-4">Загрузка...</td></tr> <tr><td colSpan={6} 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={6} className="text-center text-muted py-4">
<EmptyState title="Нет community" description="Добавьте запись, чтобы начать." action={<button className="btn btn-primary" onClick={addItem}>Добавить</button>} /> <EmptyState
title="Нет community"
description="Добавьте запись, чтобы начать."
action={
<button className="btn btn-primary" onClick={() => setAddModalOpen(true)}>
<IconPlus className="me-2" />
Добавить
</button>
}
/>
</td></tr> </td></tr>
) : paginated.map((item, idx) => ( ) : paginated.map((item) => (
<tr key={item.value} className={editingValue === item.value ? 'table-active' : ''} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}> <tr key={item.value}>
<td> <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> <code className="text-blue">{item.value}</code>
{item._external && ( {item._external && (
<span className="badge bg-yellow-lt text-yellow ms-2" title="Не внесён в справочник">не в справ.</span> <span className="badge bg-yellow-lt text-yellow ms-2" title="Не внесён в справочник">не в справ.</span>
)} )}
</>
)}
</td> </td>
<td>{item.name || <span className="text-muted">—</span>}</td>
<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> <span className="text-muted" title={item.description}>{item.description || '—'}</span>
)}
</td> </td>
<td> <td>
{editingValue === item.value ? ( {(item.tags || []).map(t => (
<input className="form-control form-control-sm" value={editingDraft.tags} onChange={(e)=>setEditingDraft({...editingDraft, tags:e.target.value})} /> <span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span>
) : ( ))}
(item.tags || []).map(t => <span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span>)
)}
</td> </td>
<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> <span className="text-muted">{item.gatewayDefault || '—'}</span>
)}
</td> </td>
<td className="text-end"> <td className="text-end">
{editingValue === item.value ? ( <div className="btn-list">
<> <button
<button className="btn btn-success btn-icon btn-sm me-1" onClick={saveEdit}><IconCheck size={16} /></button> className="btn btn-outline-primary btn-icon btn-sm"
<button className="btn btn-secondary btn-icon btn-sm" onClick={cancelEdit}><IconX size={16} /></button> onClick={()=>startEdit(item)}
</> title="Редактировать"
) : ( >
<> <IconEdit size={16} />
<button className="btn btn-outline-primary btn-icon btn-sm me-1" onClick={()=>startEdit(item)}><IconEdit size={16} /></button> </button>
<button className="btn btn-outline-danger btn-icon btn-sm" onClick={()=>deleteItem(item.value)}><IconTrash size={16} /></button> <button
</> className="btn btn-outline-danger btn-icon btn-sm"
)} onClick={()=>handleDeleteClick(item)}
title="Удалить"
>
<IconTrash size={16} />
</button>
</div>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
{totalPages > 1 && ( {totalPages > 1 && (
<div className="card-footer"> <Pagination
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2"> currentPage={page}
<div className="text-muted small">Показано <strong>{((page - 1) * pageSize) + 1}</strong> - <strong>{Math.min(page * pageSize, filtered.length)}</strong> из <strong>{filtered.length}</strong></div> totalPages={totalPages}
<ul className="pagination m-0"> totalItems={filtered.length}
<li className={`page-item${page === 1 ? ' disabled' : ''}`}> pageSize={pageSize}
<button className="page-link" onClick={()=>setCurrentPage(1)} disabled={page===1} aria-label="Первая страница"> onPageChange={setCurrentPage}
<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> </div>
</div> </div>
@@ -505,6 +519,197 @@ function CommunitiesManager() {
<CommunityStats communities={items} /> <CommunityStats communities={items} />
)} )}
</div> </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>
); );
} }