refactor(Managers): apply flat card styling across ASNs, Domains, and IPRanges managers for enhanced visual consistency and improved user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s

This commit is contained in:
2026-02-16 17:28:51 +07:00
parent eca744999c
commit dbc94d71a0
3 changed files with 440 additions and 437 deletions
+143 -142
View File
@@ -585,7 +585,7 @@ function ASNsNewManager() {
onExport={selectedItems.size > 0 ? handleBulkExport : null} onExport={selectedItems.size > 0 ? handleBulkExport : null}
/> />
<div className="card"> <div className="card card-flat">
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2"> <div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<h3 className="card-title mb-0">Список ASN <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3> <h3 className="card-title mb-0">Список ASN <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
<div className="d-flex gap-2 flex-wrap align-items-center"> <div className="d-flex gap-2 flex-wrap align-items-center">
@@ -666,149 +666,150 @@ function ASNsNewManager() {
</button> </button>
</div> </div>
</div> </div>
</div> <div className="card-body p-0">
{/* Таблица на лёгкой карточной подложке */}
{/* Таблица без карточной подложки */} {loading ? (
{loading ? ( <TableSkeleton rows={10} cols={3} flat />
<TableSkeleton rows={10} cols={3} flat /> ) : paginatedItems.length === 0 ? (
) : paginatedItems.length === 0 ? ( <TableEmpty cols={3} flat>
<TableEmpty cols={3} flat> <EmptyState
<EmptyState title="Нет ASN"
title="Нет ASN" description="Импортируйте или добавьте записи, чтобы начать."
description="Импортируйте или добавьте записи, чтобы начать." action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>} secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
secondaryAction={<button className="btn btn-outline-primary" onClick={() => { const header = ['asn','community'];
const header = ['asn','community']; const lines = [header, ['12345','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n');
const lines = [header, ['12345','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n'); const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob);
const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'asns_sample.csv'; a.click(); URL.revokeObjectURL(url);
const a = document.createElement('a'); a.href = url; a.download = 'asns_sample.csv'; a.click(); URL.revokeObjectURL(url); }}>Шаблон CSV</button>}
}}>Шаблон CSV</button>} />
/> </TableEmpty>
</TableEmpty> ) : (
) : ( <div className="table-responsive">
<div className="table-responsive"> <table className="table table-flat table-vcenter table-nowrap mb-0">
<table className="table table-flat table-vcenter table-nowrap mb-0"> <thead>
<thead> <tr>
<tr> <th style={{width: '40px'}}>
<th style={{width: '40px'}}> <input
<input type="checkbox"
type="checkbox" className="form-check-input"
className="form-check-input" checked={paginatedItems.length > 0 && paginatedItems.every(i => selectedItems.has(i.asn))}
checked={paginatedItems.length > 0 && paginatedItems.every(i => selectedItems.has(i.asn))} onChange={(e) => {
onChange={(e) => { if (e.target.checked) {
if (e.target.checked) { selectAll();
selectAll(); } else {
} else { deselectAll();
deselectAll(); }
} }}
}} title="Выбрать все на странице"
title="Выбрать все на странице" />
/> </th>
</th> <th className="cursor-pointer user-select-none" onClick={() => handleSort('asn')}>
<th className="cursor-pointer user-select-none" onClick={() => handleSort('asn')}> <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" /> Номер AS
Номер AS {sortField === 'asn' && (
{sortField === 'asn' && ( <span className="ms-1">
<span className="ms-1"> {sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />} </span>
</span> )}
)} </div>
</div> </th>
</th> <th>
<th> Имя AS
Имя AS </th>
</th> <th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}>
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}> <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" /> Community
Community {sortField === 'community' && (
{sortField === 'community' && ( <span className="ms-1">
<span className="ms-1"> {sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />} </span>
</span> )}
)} </div>
</div> </th>
</th> <th className="text-end">Действия</th>
<th className="text-end">Действия</th> </tr>
</tr> </thead>
</thead> <tbody>
<tbody> {paginatedItems.map((item, idx) => (
{paginatedItems.map((item, idx) => ( <tr key={item.asn} className={editingAsn === item.asn ? 'table-active' : (selectedItems.has(item.asn) ? 'table-selected' : '')} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}>
<tr key={item.asn} className={editingAsn === item.asn ? 'table-active' : (selectedItems.has(item.asn) ? 'table-selected' : '')} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}> <td>
<td> <input
<input type="checkbox"
type="checkbox" className="form-check-input"
className="form-check-input" checked={selectedItems.has(item.asn)}
checked={selectedItems.has(item.asn)} onChange={() => toggleSelectItem(item.asn)}
onChange={() => toggleSelectItem(item.asn)} onClick={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
/>
</td>
<td className="font-monospace" title="ASN">
<div className="d-flex align-items-center">
<IconHash size={16} className="me-2 text-muted" />
<span>{item.asn}</span>
<Tooltip content="Копировать ASN" position="top">
<button
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
onClick={() => copyToClipboard(item.asn)}
aria-label="Копировать"
title="Копировать ASN"
>
<IconCopy size={14} />
</button>
</Tooltip>
</div>
</td>
<td title={asnNameMap[item.asn] || ''}>
{asnNameMap[item.asn] ? (
<span>{asnNameMap[item.asn]}</span>
) : (
<span className="text-muted">—</span>
)}
</td>
<td>{renderCommunityBadge(item.community)}</td>
<td className="text-end">
{editingAsn === item.asn ? (
<>
<CommunityAutocompleteInput
value={editingValue}
onChange={setEditingValue}
communities={communities}
onKeyDown={(e) => handleEditKeyDown(e, item.asn)}
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
style={{ width: '150px' }}
useShortValue={true}
/> />
<Tooltip content="Сохранить (Enter)"> </td>
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.asn)} disabled={!isValidCommunity(editingValue)}><IconCheck size={16} /></button> <td className="font-monospace" title="ASN">
</Tooltip> <div className="d-flex align-items-center">
<Tooltip content="Отмена (Esc)"> <IconHash size={16} className="me-2 text-muted" />
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button> <span>{item.asn}</span>
</Tooltip> <Tooltip content="Копировать ASN" position="top">
</> <button
) : ( className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
<> onClick={() => copyToClipboard(item.asn)}
<Tooltip content="Редактировать" position="top"> aria-label="Копировать"
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)} title="Редактировать"><IconEdit size={16} /></button> title="Копировать ASN"
</Tooltip> >
<Tooltip content="Копировать" position="top"> <IconCopy size={14} />
<button className="btn btn-outline-secondary btn-icon btn-sm me-1" aria-label="Копировать" onClick={() => copyToClipboard(`${item.asn} ${item.community}`)} title="Копировать"><IconCopy size={16} /></button> </button>
</Tooltip> </Tooltip>
<Tooltip content="Удалить" position="top"> </div>
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)} title="Удалить"><IconTrash size={16} /></button> </td>
</Tooltip> <td title={asnNameMap[item.asn] || ''}>
</> {asnNameMap[item.asn] ? (
)} <span>{asnNameMap[item.asn]}</span>
</td> ) : (
</tr> <span className="text-muted">—</span>
))} )}
</tbody> </td>
</table> <td>{renderCommunityBadge(item.community)}</td>
<td className="text-end">
{editingAsn === item.asn ? (
<>
<CommunityAutocompleteInput
value={editingValue}
onChange={setEditingValue}
communities={communities}
onKeyDown={(e) => handleEditKeyDown(e, item.asn)}
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
style={{ width: '150px' }}
useShortValue={true}
/>
<Tooltip content="Сохранить (Enter)">
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.asn)} disabled={!isValidCommunity(editingValue)}><IconCheck size={16} /></button>
</Tooltip>
<Tooltip content="Отмена (Esc)">
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button>
</Tooltip>
</>
) : (
<>
<Tooltip content="Редактировать" position="top">
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)} title="Редактировать"><IconEdit size={16} /></button>
</Tooltip>
<Tooltip content="Копировать" position="top">
<button className="btn btn-outline-secondary btn-icon btn-sm me-1" aria-label="Копировать" onClick={() => copyToClipboard(`${item.asn} ${item.community}`)} title="Копировать"><IconCopy size={16} /></button>
</Tooltip>
<Tooltip content="Удалить" position="top">
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)} title="Удалить"><IconTrash size={16} /></button>
</Tooltip>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div> </div>
)} </div>
{/* Пагинация */} {/* Пагинация */}
<Pagination <Pagination
currentPage={currentPage} currentPage={currentPage}
+168 -167
View File
@@ -572,7 +572,7 @@ function DomainsNewManager() {
onExport={handleBulkExport} onExport={handleBulkExport}
/> />
<div className="card animate-in" style={{animationDelay: '0.3s'}}> <div className="card card-flat animate-in" style={{animationDelay: '0.3s'}}>
<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"> <div className="col-auto">
@@ -674,179 +674,180 @@ function DomainsNewManager() {
</div> </div>
</div> </div>
</div> </div>
</div> <div className="card-body p-0">
{/* Таблица с лёгкой карточной подложкой (плоский стиль) */}
{/* Таблица без карточной подложки (только горизонтальные разделители) */} {loading ? (
{loading ? ( <TableSkeleton rows={10} cols={4} hasCheckbox={true} flat />
<TableSkeleton rows={10} cols={4} hasCheckbox={true} flat /> ) : paginatedItems.length === 0 ? (
) : paginatedItems.length === 0 ? ( <TableEmpty cols={3} flat>
<TableEmpty cols={3} flat> <EmptyState
<EmptyState title="Нет доменов"
title="Нет доменов" description="Импортируйте или добавьте записи, чтобы начать."
description="Импортируйте или добавьте записи, чтобы начать." action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>} secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
secondaryAction={<button className="btn btn-outline-primary" onClick={() => { const header = ['domain','community'];
const header = ['domain','community']; const lines = [header, ['example.com','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n');
const lines = [header, ['example.com','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n'); const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob);
const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'domains_sample.csv'; a.click(); URL.revokeObjectURL(url);
const a = document.createElement('a'); a.href = url; a.download = 'domains_sample.csv'; a.click(); URL.revokeObjectURL(url); }}>Шаблон CSV</button>}
}}>Шаблон CSV</button>} />
/> </TableEmpty>
</TableEmpty> ) : (
) : ( <div className="table-responsive">
<div className="table-responsive"> <table className="table table-flat table-vcenter table-nowrap mb-0">
<table className="table table-flat table-vcenter table-nowrap mb-0"> <thead>
<thead> <tr>
<tr> <th style={{ width: '40px' }}>
<th style={{ width: '40px' }}> <input
<input type="checkbox"
type="checkbox" className="form-check-input"
className="form-check-input" checked={selectedDomains.size === paginatedItems.length && paginatedItems.length > 0}
checked={selectedDomains.size === paginatedItems.length && paginatedItems.length > 0} onChange={(e) => e.target.checked ? handleSelectAll() : handleDeselectAll()}
onChange={(e) => e.target.checked ? handleSelectAll() : handleDeselectAll()} />
/> </th>
</th> <th className="cursor-pointer user-select-none" onClick={() => handleSort('domain')}>
<th className="cursor-pointer user-select-none" onClick={() => handleSort('domain')}> <div className="d-flex align-items-center">
<div className="d-flex align-items-center"> <IconWorld size={16} className="me-1 text-muted" />
<IconWorld size={16} className="me-1 text-muted" /> Домен
Домен {sortField === 'domain' && (
{sortField === 'domain' && ( <span className="ms-1">
<span className="ms-1"> {sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />} </span>
</span> )}
)} </div>
</div> </th>
</th> <th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}>
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}> <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" /> Community
Community {sortField === 'community' && (
{sortField === 'community' && ( <span className="ms-1">
<span className="ms-1"> {sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />} </span>
</span> )}
)} </div>
</div> </th>
</th> <th className="text-end">Действия</th>
<th className="text-end">Действия</th> </tr>
</tr> </thead>
</thead> <tbody>
<tbody> {paginatedItems.map((item, idx) => (
{paginatedItems.map((item, idx) => ( <tr
<tr key={item.domain}
key={item.domain} className={editingDomain === item.domain ? 'table-active' : (selectedDomains.has(item.domain) ? 'table-selected' : '')}
className={editingDomain === item.domain ? 'table-active' : (selectedDomains.has(item.domain) ? 'table-selected' : '')} style={{
style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both`
animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}
}} >
> <td>
<td> <input
<input type="checkbox"
type="checkbox" className="form-check-input"
className="form-check-input" checked={selectedDomains.has(item.domain)}
checked={selectedDomains.has(item.domain)} onChange={() => handleToggleSelect(item.domain)}
onChange={() => handleToggleSelect(item.domain)} aria-label={`Выбрать ${item.domain}`}
aria-label={`Выбрать ${item.domain}`} onClick={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
/>
</td>
<td>
<div className="d-flex align-items-center">
<IconWorld size={16} className="me-2 text-muted" />
<span className="font-monospace">{item.domain}</span>
<Tooltip content="Копировать домен" position="top">
<button
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
onClick={() => copyToClipboard(item.domain)}
aria-label="Копировать"
title="Копировать домен"
>
<IconCopy size={14} />
</button>
</Tooltip>
</div>
</td>
<td>
<div className="d-flex align-items-center">
{renderCommunityBadge(item.community)}
</div>
</td>
<td className="text-end">
<div className="btn-group">
{editingDomain === item.domain ? (
<>
<CommunityAutocompleteInput
value={editingValue}
onChange={setEditingValue}
communities={communities}
onKeyDown={(e) => handleEditKeyDown(e, item.domain)}
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
style={{ width: '150px' }}
useShortValue={true}
/> />
<Tooltip content="Сохранить (Enter)"> </td>
<td>
<div className="d-flex align-items-center">
<IconWorld size={16} className="me-2 text-muted" />
<span className="font-monospace">{item.domain}</span>
<Tooltip content="Копировать домен" position="top">
<button <button
className="btn btn-success btn-icon btn-sm" className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
onClick={() => handleSaveEdit(item.domain)} onClick={() => copyToClipboard(item.domain)}
disabled={!isValidCommunity(editingValue)}
aria-label="Сохранить"
>
<IconCheck size={16} />
</button>
</Tooltip>
<Tooltip content="Отмена (Esc)">
<button
className="btn btn-secondary btn-icon btn-sm"
onClick={handleCancelEdit}
aria-label="Отмена"
>
<IconX size={16} />
</button>
</Tooltip>
</>
) : (
<>
<Tooltip content="Редактировать" position="top">
<button
className="btn btn-outline-primary btn-icon btn-sm"
aria-label="Редактировать"
onClick={() => handleEdit(item)}
title="Редактировать"
>
<IconEdit size={16} />
</button>
</Tooltip>
<Tooltip content="Копировать" position="top">
<button
className="btn btn-outline-secondary btn-icon btn-sm"
aria-label="Копировать" aria-label="Копировать"
onClick={() => copyToClipboard(`${item.domain} ${item.community}`)} title="Копировать домен"
title="Копировать"
> >
<IconCopy size={16} /> <IconCopy size={14} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip content="Удалить" position="top"> </div>
<button </td>
className="btn btn-outline-danger btn-icon btn-sm" <td>
aria-label="Удалить" <div className="d-flex align-items-center">
onClick={() => confirmDelete(item)} {renderCommunityBadge(item.community)}
title="Удалить" </div>
> </td>
<IconTrash size={16} /> <td className="text-end">
</button> <div className="btn-group">
</Tooltip> {editingDomain === item.domain ? (
</> <>
)} <CommunityAutocompleteInput
</div> value={editingValue}
</td> onChange={setEditingValue}
</tr> communities={communities}
))} onKeyDown={(e) => handleEditKeyDown(e, item.domain)}
</tbody> className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
</table> style={{ width: '150px' }}
useShortValue={true}
/>
<Tooltip content="Сохранить (Enter)">
<button
className="btn btn-success btn-icon btn-sm"
onClick={() => handleSaveEdit(item.domain)}
disabled={!isValidCommunity(editingValue)}
aria-label="Сохранить"
>
<IconCheck size={16} />
</button>
</Tooltip>
<Tooltip content="Отмена (Esc)">
<button
className="btn btn-secondary btn-icon btn-sm"
onClick={handleCancelEdit}
aria-label="Отмена"
>
<IconX size={16} />
</button>
</Tooltip>
</>
) : (
<>
<Tooltip content="Редактировать" position="top">
<button
className="btn btn-outline-primary btn-icon btn-sm"
aria-label="Редактировать"
onClick={() => handleEdit(item)}
title="Редактировать"
>
<IconEdit size={16} />
</button>
</Tooltip>
<Tooltip content="Копировать" position="top">
<button
className="btn btn-outline-secondary btn-icon btn-sm"
aria-label="Копировать"
onClick={() => copyToClipboard(`${item.domain} ${item.community}`)}
title="Копировать"
>
<IconCopy size={16} />
</button>
</Tooltip>
<Tooltip content="Удалить" position="top">
<button
className="btn btn-outline-danger btn-icon btn-sm"
aria-label="Удалить"
onClick={() => confirmDelete(item)}
title="Удалить"
>
<IconTrash size={16} />
</button>
</Tooltip>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div> </div>
)} </div>
{/* Пагинация */} {/* Пагинация */}
<Pagination <Pagination
currentPage={currentPage} currentPage={currentPage}
+127 -126
View File
@@ -845,7 +845,7 @@ function IPRangesManager() {
onExport={selectedItems.size > 0 ? handleBulkExport : null} onExport={selectedItems.size > 0 ? handleBulkExport : null}
/> />
<div className="card"> <div className="card card-flat">
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2"> <div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<h3 className="card-title mb-0">Список IP-диапазонов <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3> <h3 className="card-title mb-0">Список IP-диапазонов <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
<div className="d-flex gap-2 flex-wrap align-items-center"> <div className="d-flex gap-2 flex-wrap align-items-center">
@@ -918,133 +918,134 @@ function IPRangesManager() {
</div> </div>
</div> </div>
</div> </div>
</div> <div className="card-body p-0">
{/* Таблица на лёгкой карточной подложке */}
{/* Таблица без карточной подложки */} {loading ? (
{loading ? ( <TableSkeleton rows={10} cols={3} flat />
<TableSkeleton rows={10} cols={3} flat /> ) : paginatedItems.length === 0 ? (
) : paginatedItems.length === 0 ? ( <TableEmpty cols={3} flat>
<TableEmpty cols={3} flat> <EmptyState
<EmptyState title="Нет IP-диапазонов"
title="Нет IP-диапазонов" description="Импортируйте или добавьте записи, чтобы начать."
description="Импортируйте или добавьте записи, чтобы начать." action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>} secondaryAction={<button className="btn btn-outline-primary" onClick={downloadSampleCsv}>Шаблон CSV</button>}
secondaryAction={<button className="btn btn-outline-primary" onClick={downloadSampleCsv}>Шаблон CSV</button>} />
/> </TableEmpty>
</TableEmpty> ) : (
) : ( <div className="table-responsive">
<div className="table-responsive"> <table className="table table-flat table-vcenter table-nowrap mb-0">
<table className="table table-flat table-vcenter table-nowrap mb-0"> <thead>
<thead> <tr>
<tr> <th style={{width: '40px'}}>
<th style={{width: '40px'}}> <input
<input type="checkbox"
type="checkbox" className="form-check-input"
className="form-check-input" checked={paginatedItems.length > 0 && paginatedItems.every(i => selectedItems.has(i.ipRange))}
checked={paginatedItems.length > 0 && paginatedItems.every(i => selectedItems.has(i.ipRange))} onChange={(e) => {
onChange={(e) => { if (e.target.checked) {
if (e.target.checked) { selectAll();
selectAll(); } else {
} else { deselectAll();
deselectAll(); }
} }}
}} title="Выбрать все на странице"
title="Выбрать все на странице" />
/> </th>
</th> <th className="cursor-pointer user-select-none" onClick={() => handleSort('ipRange')}>
<th className="cursor-pointer user-select-none" onClick={() => handleSort('ipRange')}> <div className="d-flex align-items-center">
<div className="d-flex align-items-center"> <IconNetwork size={16} className="me-1 text-muted" />
<IconNetwork size={16} className="me-1 text-muted" /> IP-диапазон
IP-диапазон {sortField === 'ipRange' && (
{sortField === 'ipRange' && ( <span className="ms-1">
<span className="ms-1"> {sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />} </span>
</span> )}
)} </div>
</div> </th>
</th> <th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}>
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}> <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" /> Community
Community {sortField === 'community' && (
{sortField === 'community' && ( <span className="ms-1">
<span className="ms-1"> {sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />} </span>
</span> )}
)} </div>
</div> </th>
</th> <th className="text-end">Действия</th>
<th className="text-end">Действия</th> </tr>
</tr> </thead>
</thead> <tbody>
<tbody> {paginatedItems.map((item, idx) => (
{paginatedItems.map((item, idx) => ( <tr key={item.ipRange} className={editingIp === item.ipRange ? 'table-active' : (selectedItems.has(item.ipRange) ? 'table-selected' : '')} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}>
<tr key={item.ipRange} className={editingIp === item.ipRange ? 'table-active' : (selectedItems.has(item.ipRange) ? 'table-selected' : '')} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}> <td>
<td> <input
<input type="checkbox"
type="checkbox" className="form-check-input"
className="form-check-input" checked={selectedItems.has(item.ipRange)}
checked={selectedItems.has(item.ipRange)} onChange={() => toggleSelectItem(item.ipRange)}
onChange={() => toggleSelectItem(item.ipRange)} onClick={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
/>
</td>
<td className="font-monospace" title="CIDR">
<div className="d-flex align-items-center">
<IconNetwork size={16} className="me-2 text-muted" />
<span>{item.ipRange}</span>
<Tooltip content="Копировать IP-диапазон" position="top">
<button
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
onClick={() => copyToClipboard(item.ipRange)}
aria-label="Копировать"
title="Копировать IP-диапазон"
>
<IconCopy size={14} />
</button>
</Tooltip>
</div>
</td>
<td>{renderCommunityBadge(item.community)}</td>
<td className="text-end">
{editingIp === item.ipRange ? (
<>
<CommunityAutocompleteInput
value={editingValue}
onChange={setEditingValue}
communities={communities}
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
style={{ width: '150px' }}
onKeyDown={(e) => handleEditKeyDown(e, item.ipRange)}
useShortValue={true}
/> />
<Tooltip content="Сохранить (Enter)"> </td>
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.ipRange)} disabled={!isValidCommunity(editingValue)}><IconCheck size={16} /></button> <td className="font-monospace" title="CIDR">
</Tooltip> <div className="d-flex align-items-center">
<Tooltip content="Отмена (Esc)"> <IconNetwork size={16} className="me-2 text-muted" />
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button> <span>{item.ipRange}</span>
</Tooltip> <Tooltip content="Копировать IP-диапазон" position="top">
</> <button
) : ( className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
<> onClick={() => copyToClipboard(item.ipRange)}
<Tooltip content="Редактировать" position="top"> aria-label="Копировать"
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)} title="Редактировать"><IconEdit size={16} /></button> title="Копировать IP-диапазон"
</Tooltip> >
<Tooltip content="Копировать" position="top"> <IconCopy size={14} />
<button className="btn btn-outline-secondary btn-icon btn-sm me-1" aria-label="Копировать" onClick={() => copyToClipboard(`${item.ipRange} ${item.community}`)} title="Копировать"><IconCopy size={16} /></button> </button>
</Tooltip> </Tooltip>
<Tooltip content="Удалить" position="top"> </div>
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)} title="Удалить"><IconTrash size={16} /></button> </td>
</Tooltip> <td>{renderCommunityBadge(item.community)}</td>
</> <td className="text-end">
)} {editingIp === item.ipRange ? (
</td> <>
</tr> <CommunityAutocompleteInput
))} value={editingValue}
</tbody> onChange={setEditingValue}
</table> communities={communities}
className={`form-control d-inline-block me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
style={{ width: '150px' }}
onKeyDown={(e) => handleEditKeyDown(e, item.ipRange)}
useShortValue={true}
/>
<Tooltip content="Сохранить (Enter)">
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.ipRange)} disabled={!isValidCommunity(editingValue)}><IconCheck size={16} /></button>
</Tooltip>
<Tooltip content="Отмена (Esc)">
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button>
</Tooltip>
</>
) : (
<>
<Tooltip content="Редактировать" position="top">
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)} title="Редактировать"><IconEdit size={16} /></button>
</Tooltip>
<Tooltip content="Копировать" position="top">
<button className="btn btn-outline-secondary btn-icon btn-sm me-1" aria-label="Копировать" onClick={() => copyToClipboard(`${item.ipRange} ${item.community}`)} title="Копировать"><IconCopy size={16} /></button>
</Tooltip>
<Tooltip content="Удалить" position="top">
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)} title="Удалить"><IconTrash size={16} /></button>
</Tooltip>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div> </div>
)} </div>
{/* Пагинация */} {/* Пагинация */}
<Pagination <Pagination
currentPage={currentPage} currentPage={currentPage}