refactor(CommunitiesManager): simplify layout structure and enhance notification handling for improved user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m49s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m49s
This commit is contained in:
+257
-265
@@ -350,7 +350,7 @@ function CommunitiesManager() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Справочник Community"
|
||||
icon={<IconFilter size={24} />}
|
||||
@@ -368,276 +368,268 @@ function CommunitiesManager() {
|
||||
}
|
||||
/>
|
||||
|
||||
<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>
|
||||
{/* Уведомления */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Вкладки: Список / Статистика в стиле Tabler */}
|
||||
<div className="card mb-3">
|
||||
<div className="card-header border-0 pb-0">
|
||||
<ul className="nav nav-tabs card-header-tabs" 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
|
||||
<span className="badge bg-blue-lt text-blue ms-2">{filtered.length}</span>
|
||||
</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>
|
||||
{success}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Отображение списка communities */}
|
||||
{activeTab === 'list' && (
|
||||
<div className="row g-3">
|
||||
<div className="col-12">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center w-100 g-2">
|
||||
<div className="col">
|
||||
<h3 className="card-title mb-0">
|
||||
<IconFilter className="icon me-2" size={20} />
|
||||
Список community
|
||||
<span className="badge bg-blue-lt text-blue ms-2">{filtered.length}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setAddModalOpen(true)}
|
||||
>
|
||||
<IconPlus className="icon me-1" />
|
||||
Добавить community
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="d-flex gap-2 flex-wrap mb-3 align-items-center">
|
||||
<SavedFilters
|
||||
pageKey="communities"
|
||||
currentFilters={{ searchTerm, sortField, sortOrder }}
|
||||
onApplyFilter={(filters) => {
|
||||
if (filters.searchTerm !== undefined) setSearchTerm(filters.searchTerm);
|
||||
if (filters.sortField !== undefined) setSortField(filters.sortField);
|
||||
if (filters.sortOrder !== undefined) setSortOrder(filters.sortOrder);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
<div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}>
|
||||
<span className="input-icon-addon"><IconSearch size={18} /></span>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="Поиск по value, названию, описанию и тегам"
|
||||
value={searchTerm}
|
||||
onChange={(e)=>{setSearchTerm(e.target.value); setCurrentPage(1);}}
|
||||
style={{ paddingRight: searchTerm ? '2.5rem' : undefined }}
|
||||
/>
|
||||
{!!searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost-secondary btn-icon btn-sm"
|
||||
style={{ position: 'absolute', right: '0.25rem', top: '50%', transform: 'translateY(-50%)', zIndex: 2 }}
|
||||
aria-label="Очистить поиск"
|
||||
onClick={()=>{ setSearchTerm(''); setCurrentPage(1); }}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-3 flex-wrap">
|
||||
<label className="form-check form-switch mb-0 d-flex align-items-center gap-2">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
checked={showOnlyUsedInFilters}
|
||||
onChange={(e) => { setShowOnlyUsedInFilters(e.target.checked); setCurrentPage(1); }}
|
||||
/>
|
||||
<span className="form-check-label">Только используемые в фильтрах</span>
|
||||
{filterUsageLoading && (
|
||||
<span className="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
|
||||
)}
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
type="button"
|
||||
onClick={fetchFilterUsage}
|
||||
disabled={filterUsageLoading}
|
||||
title="Обновить использование из /filters"
|
||||
>
|
||||
<IconRefresh className="icon me-1" size={16} />
|
||||
Обновить использование
|
||||
</button>
|
||||
{filterUsageError && (
|
||||
<span className="text-danger small">{filterUsageError}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('value')}>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconHash size={16} className="me-1 text-muted" />
|
||||
Value
|
||||
{sortField==='value' && (
|
||||
<span className="ms-1">
|
||||
{sortOrder==='asc' ? <IconSortAscending size={14} /> : <IconSortDescending size={14} />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('name')}>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconFilter size={16} className="me-1 text-muted" />
|
||||
Название
|
||||
{sortField==='name' && (
|
||||
<span className="ms-1">
|
||||
{sortOrder==='asc' ? <IconSortAscending size={14} /> : <IconSortDescending size={14} />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th>Описание</th>
|
||||
<th>Теги</th>
|
||||
<th>Gateway</th>
|
||||
<th style={{ width: '10%' }}>Исп. в фильтрах</th>
|
||||
<th className="text-end">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={7} className="text-center text-muted py-4">Загрузка...</td></tr>
|
||||
) : paginated.length === 0 ? (
|
||||
<tr><td colSpan={7} className="text-center text-muted py-4">
|
||||
<EmptyState
|
||||
title="Нет community"
|
||||
description="Добавьте запись, чтобы начать."
|
||||
action={
|
||||
<button className="btn btn-primary" onClick={() => setAddModalOpen(true)}>
|
||||
<IconPlus className="me-2" />
|
||||
Добавить
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</td></tr>
|
||||
) : paginated.map((item) => {
|
||||
const usageCount = filterUsage[item.value] || 0;
|
||||
return (
|
||||
<tr key={item.value}>
|
||||
<td>
|
||||
<code className="text-blue">{item.value}</code>
|
||||
{item._external && (
|
||||
<span className="badge bg-yellow-lt text-yellow ms-2" title="Не внесён в справочник">не в справ.</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{item.name || <span className="text-muted">—</span>}</td>
|
||||
<td>
|
||||
<span className="text-muted" title={item.description}>{item.description || '—'}</span>
|
||||
</td>
|
||||
<td>
|
||||
{(item.tags || []).map(t => (
|
||||
<span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span>
|
||||
))}
|
||||
</td>
|
||||
<td>
|
||||
<span className="text-muted">{item.gatewayDefault || '—'}</span>
|
||||
</td>
|
||||
<td>
|
||||
{usageCount > 0 ? (
|
||||
<span className="badge bg-blue-lt text-blue">{usageCount} в фильтрах</span>
|
||||
) : (
|
||||
<span className="text-muted">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<div className="btn-list">
|
||||
<a
|
||||
className="btn btn-outline-primary btn-icon btn-sm"
|
||||
href={`/filters?community=${encodeURIComponent(item.value)}`}
|
||||
title="Открыть в /filters с поиском по community"
|
||||
>
|
||||
<IconLink size={16} />
|
||||
</a>
|
||||
<button
|
||||
className="btn btn-outline-primary btn-icon btn-sm"
|
||||
onClick={()=>startEdit(item)}
|
||||
title="Редактировать"
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-danger btn-icon btn-sm"
|
||||
onClick={()=>handleDeleteClick(item)}
|
||||
title="Удалить"
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
totalItems={filtered.length}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Отображение статистики communities */}
|
||||
{activeTab === 'stats' && (
|
||||
<CommunityStats communities={items} />
|
||||
)}
|
||||
{/* Вкладки: Список / Статистика в стиле Tabler */}
|
||||
<div className="card card-flat mb-3">
|
||||
<div className="card-header border-0 pb-0">
|
||||
<ul className="nav nav-tabs card-header-tabs" 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
|
||||
<span className="badge bg-blue-lt text-blue ms-2">{filtered.length}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item" role="presentation">
|
||||
<button
|
||||
className={`nav-link ${activeTab === 'stats' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('stats')}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'stats'}
|
||||
>
|
||||
<IconChartBar className="me-2" size={18} />
|
||||
Статистика использования
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Отображение списка communities */}
|
||||
{activeTab === 'list' && (
|
||||
<div className="card card-flat">
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center w-100 g-2">
|
||||
<div className="col">
|
||||
<h3 className="card-title mb-0">
|
||||
<IconFilter className="icon me-2" size={20} />
|
||||
Список community
|
||||
<span className="badge bg-blue-lt text-blue ms-2">{filtered.length}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setAddModalOpen(true)}
|
||||
>
|
||||
<IconPlus className="icon me-1" />
|
||||
Добавить community
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="d-flex gap-2 flex-wrap mb-3 align-items-center">
|
||||
<SavedFilters
|
||||
pageKey="communities"
|
||||
currentFilters={{ searchTerm, sortField, sortOrder }}
|
||||
onApplyFilter={(filters) => {
|
||||
if (filters.searchTerm !== undefined) setSearchTerm(filters.searchTerm);
|
||||
if (filters.sortField !== undefined) setSortField(filters.sortField);
|
||||
if (filters.sortOrder !== undefined) setSortOrder(filters.sortOrder);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
<div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}>
|
||||
<span className="input-icon-addon"><IconSearch size={18} /></span>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="Поиск по value, названию, описанию и тегам"
|
||||
value={searchTerm}
|
||||
onChange={(e)=>{setSearchTerm(e.target.value); setCurrentPage(1);}}
|
||||
style={{ paddingRight: searchTerm ? '2.5rem' : undefined }}
|
||||
/>
|
||||
{!!searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost-secondary btn-icon btn-sm"
|
||||
style={{ position: 'absolute', right: '0.25rem', top: '50%', transform: 'translateY(-50%)', zIndex: 2 }}
|
||||
aria-label="Очистить поиск"
|
||||
onClick={()=>{ setSearchTerm(''); setCurrentPage(1); }}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-3 flex-wrap">
|
||||
<label className="form-check form-switch mb-0 d-flex align-items-center gap-2">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
checked={showOnlyUsedInFilters}
|
||||
onChange={(e) => { setShowOnlyUsedInFilters(e.target.checked); setCurrentPage(1); }}
|
||||
/>
|
||||
<span className="form-check-label">Только используемые в фильтрах</span>
|
||||
{filterUsageLoading && (
|
||||
<span className="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
|
||||
)}
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
type="button"
|
||||
onClick={fetchFilterUsage}
|
||||
disabled={filterUsageLoading}
|
||||
title="Обновить использование из /filters"
|
||||
>
|
||||
<IconRefresh className="icon me-1" size={16} />
|
||||
Обновить использование
|
||||
</button>
|
||||
{filterUsageError && (
|
||||
<span className="text-danger small">{filterUsageError}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('value')}>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconHash size={16} className="me-1 text-muted" />
|
||||
Value
|
||||
{sortField==='value' && (
|
||||
<span className="ms-1">
|
||||
{sortOrder==='asc' ? <IconSortAscending size={14} /> : <IconSortDescending size={14} />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('name')}>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconFilter size={16} className="me-1 text-muted" />
|
||||
Название
|
||||
{sortField==='name' && (
|
||||
<span className="ms-1">
|
||||
{sortOrder==='asc' ? <IconSortAscending size={14} /> : <IconSortDescending size={14} />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th>Описание</th>
|
||||
<th>Теги</th>
|
||||
<th>Gateway</th>
|
||||
<th style={{ width: '10%' }}>Исп. в фильтрах</th>
|
||||
<th className="text-end">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={7} className="text-center text-muted py-4">Загрузка...</td></tr>
|
||||
) : paginated.length === 0 ? (
|
||||
<tr><td colSpan={7} className="text-center text-muted py-4">
|
||||
<EmptyState
|
||||
title="Нет community"
|
||||
description="Добавьте запись, чтобы начать."
|
||||
action={
|
||||
<button className="btn btn-primary" onClick={() => setAddModalOpen(true)}>
|
||||
<IconPlus className="me-2" />
|
||||
Добавить
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</td></tr>
|
||||
) : paginated.map((item) => {
|
||||
const usageCount = filterUsage[item.value] || 0;
|
||||
return (
|
||||
<tr key={item.value}>
|
||||
<td>
|
||||
<code className="text-blue">{item.value}</code>
|
||||
{item._external && (
|
||||
<span className="badge bg-yellow-lt text-yellow ms-2" title="Не внесён в справочник">не в справ.</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{item.name || <span className="text-muted">—</span>}</td>
|
||||
<td>
|
||||
<span className="text-muted" title={item.description}>{item.description || '—'}</span>
|
||||
</td>
|
||||
<td>
|
||||
{(item.tags || []).map(t => (
|
||||
<span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span>
|
||||
))}
|
||||
</td>
|
||||
<td>
|
||||
<span className="text-muted">{item.gatewayDefault || '—'}</span>
|
||||
</td>
|
||||
<td>
|
||||
{usageCount > 0 ? (
|
||||
<span className="badge bg-blue-lt text-blue">{usageCount} в фильтрах</span>
|
||||
) : (
|
||||
<span className="text-muted">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<div className="btn-list">
|
||||
<a
|
||||
className="btn btn-outline-primary btn-icon btn-sm"
|
||||
href={`/filters?community=${encodeURIComponent(item.value)}`}
|
||||
title="Открыть в /filters с поиском по community"
|
||||
>
|
||||
<IconLink size={16} />
|
||||
</a>
|
||||
<button
|
||||
className="btn btn-outline-primary btn-icon btn-sm"
|
||||
onClick={()=>startEdit(item)}
|
||||
title="Редактировать"
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-danger btn-icon btn-sm"
|
||||
onClick={()=>handleDeleteClick(item)}
|
||||
title="Удалить"
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
totalItems={filtered.length}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Отображение статистики communities */}
|
||||
{activeTab === 'stats' && (
|
||||
<CommunityStats communities={items} />
|
||||
)}
|
||||
{/* Модальное окно добавления */}
|
||||
<FormModal
|
||||
show={addModalOpen}
|
||||
|
||||
Reference in New Issue
Block a user