feat: Add communities management routes and integrate community selection in FilterManager for enhanced user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 4m19s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 4m19s
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconDatabase,
|
||||
IconRefresh,
|
||||
IconUpload,
|
||||
IconDownload,
|
||||
IconDeviceFloppy,
|
||||
IconHash,
|
||||
IconFilter
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
function CommunitiesManager() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortField, setSortField] = useState('value');
|
||||
const [sortOrder, setSortOrder] = useState('asc');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
|
||||
const [newItem, setNewItem] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' });
|
||||
const [editingValue, setEditingValue] = useState(null);
|
||||
const [editingDraft, setEditingDraft] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' });
|
||||
const editRef = useRef(null);
|
||||
|
||||
useEffect(() => { fetchItems(); }, []);
|
||||
|
||||
const fetchItems = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get(`${API_URL}/communities`);
|
||||
setItems(res.data);
|
||||
setError('');
|
||||
} catch (e) {
|
||||
console.error('Error fetching communities:', e);
|
||||
setError('Не удалось загрузить справочник community.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveAll = async (data) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await axios.post(`${API_URL}/communities`, { communities: data });
|
||||
setSuccess('Справочник сохранён!');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
} catch (e) {
|
||||
console.error('Error saving communities:', e);
|
||||
setError(e.response?.data || 'Не удалось сохранить справочник.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toTagsArray = (str) => (str || '').split(',').map(t => t.trim()).filter(Boolean);
|
||||
const fromTagsArray = (arr) => (arr || []).join(', ');
|
||||
|
||||
const addItem = () => {
|
||||
const value = String(newItem.value || '').trim();
|
||||
if (!value) { setError('Поле value обязательно.'); return; }
|
||||
if (items.some(i => i.value === value)) { setError('Такое value уже существует.'); return; }
|
||||
setError('');
|
||||
setItems([...items, {
|
||||
value,
|
||||
name: String(newItem.name || ''),
|
||||
description: String(newItem.description || ''),
|
||||
tags: toTagsArray(newItem.tags),
|
||||
gatewayDefault: String(newItem.gatewayDefault || ''),
|
||||
color: String(newItem.color || ''),
|
||||
}]);
|
||||
setNewItem({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' });
|
||||
};
|
||||
|
||||
const startEdit = (item) => {
|
||||
setEditingValue(item.value);
|
||||
setEditingDraft({
|
||||
value: item.value,
|
||||
name: item.name || '',
|
||||
description: item.description || '',
|
||||
tags: fromTagsArray(item.tags),
|
||||
gatewayDefault: item.gatewayDefault || '',
|
||||
color: item.color || '',
|
||||
});
|
||||
};
|
||||
|
||||
const saveEdit = () => {
|
||||
const value = String(editingDraft.value || '').trim();
|
||||
if (!value) { setError('Поле value обязательно.'); return; }
|
||||
if (value !== editingValue && items.some(i => i.value === value)) { setError('Такое value уже существует.'); return; }
|
||||
const next = items.map(i => i.value === editingValue ? {
|
||||
value,
|
||||
name: String(editingDraft.name || ''),
|
||||
description: String(editingDraft.description || ''),
|
||||
tags: toTagsArray(editingDraft.tags),
|
||||
gatewayDefault: String(editingDraft.gatewayDefault || ''),
|
||||
color: String(editingDraft.color || ''),
|
||||
} : i);
|
||||
setItems(next);
|
||||
setEditingValue(null);
|
||||
};
|
||||
|
||||
const cancelEdit = () => setEditingValue(null);
|
||||
|
||||
const deleteItem = (value) => setItems(items.filter(i => i.value !== value));
|
||||
|
||||
const handleImport = () => {
|
||||
const text = window.prompt('Вставьте JSON-массив community (value, name, description, tags, gatewayDefault, color)');
|
||||
if (!text) return;
|
||||
try {
|
||||
const arr = JSON.parse(text);
|
||||
if (!Array.isArray(arr)) throw new Error('Ожидается массив');
|
||||
setItems(prev => [...prev, ...arr.filter(x => x && x.value)]);
|
||||
} catch (e) {
|
||||
setError('Неверный JSON.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const blob = new Blob([JSON.stringify(items, null, 2)], { type: 'application/json;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `communities_${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleSave = () => saveAll(items);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const arr = [...items];
|
||||
arr.sort((a, b) => {
|
||||
let va = (a[sortField] ?? '').toString().toLowerCase();
|
||||
let vb = (b[sortField] ?? '').toString().toLowerCase();
|
||||
if (va < vb) return sortOrder === 'asc' ? -1 : 1;
|
||||
if (va > vb) return sortOrder === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
return arr;
|
||||
}, [items, sortField, sortOrder]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = searchTerm.trim().toLowerCase();
|
||||
if (!q) return sorted;
|
||||
return sorted.filter(i =>
|
||||
(i.value || '').toLowerCase().includes(q) ||
|
||||
(i.name || '').toLowerCase().includes(q) ||
|
||||
(i.description || '').toLowerCase().includes(q) ||
|
||||
(i.tags || []).some(t => (t || '').toLowerCase().includes(q))
|
||||
);
|
||||
}, [sorted, searchTerm]);
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / pageSize) || 1;
|
||||
const page = Math.min(currentPage, totalPages);
|
||||
const paginated = filtered.slice((page - 1) * pageSize, page * pageSize);
|
||||
|
||||
const changeSort = (f) => {
|
||||
if (sortField === f) setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
else { setSortField(f); setSortOrder('asc'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container-xl">
|
||||
<div className="page-header d-print-none mb-4">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<h2 className="page-title"><IconFilter className="me-2" />Справочник Community</h2>
|
||||
<div className="page-pretitle">Главная / Данные / Community</div>
|
||||
</div>
|
||||
<div className="col-auto ms-auto d-print-none">
|
||||
<div className="btn-list">
|
||||
<button className="btn btn-outline-secondary" onClick={fetchItems} disabled={loading}><IconRefresh className="me-2" />Обновить</button>
|
||||
<button className="btn btn-outline-primary" onClick={handleImport}><IconUpload className="me-2" />Импорт JSON</button>
|
||||
<button className="btn btn-outline-primary" onClick={handleExport} disabled={items.length === 0}><IconDownload className="me-2" />Экспорт</button>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={loading}><IconDeviceFloppy className="me-2" />Сохранить в S3</button>
|
||||
</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="row g-3">
|
||||
<div className="col-lg-3">
|
||||
<div className="card card-md">
|
||||
<div className="card-header"><h3 className="card-title"><IconPlus className="me-2" />Добавить</h3></div>
|
||||
<div className="card-body">
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Value</label>
|
||||
<input className="form-control" value={newItem.value} onChange={(e)=>setNewItem({...newItem, value:e.target.value})} placeholder="65001:200 или 100" />
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Название</label>
|
||||
<input className="form-control" value={newItem.name} onChange={(e)=>setNewItem({...newItem, name:e.target.value})} placeholder="Напр. Social" />
|
||||
</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}><IconPlus className="me-2" />Добавить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card card-md">
|
||||
<div className="card-header"><h3 className="card-title"><IconDatabase className="me-2" />Действия</h3></div>
|
||||
<div className="card-body d-grid gap-2">
|
||||
<button className="btn btn-outline-secondary" onClick={fetchItems} disabled={loading}><IconRefresh className="me-2" />Обновить</button>
|
||||
<button className="btn btn-outline-primary" onClick={handleImport}><IconUpload className="me-2" />Импорт JSON</button>
|
||||
<button className="btn btn-outline-primary" onClick={handleExport} disabled={items.length === 0}><IconDownload className="me-2" />Экспорт</button>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={loading}><IconDeviceFloppy className="me-2" />Сохранить в S3</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-lg-9">
|
||||
<div className="card">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<h3 className="card-title mb-0">Список community <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
|
||||
<div className="input-icon w-50">
|
||||
<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);}} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{cursor:'pointer'}} onClick={()=>changeSort('value')}>Value {sortField==='value' && <span className="ms-1">{sortOrder==='asc'?'▲':'▼'}</span>}</th>
|
||||
<th style={{cursor:'pointer'}} onClick={()=>changeSort('name')}>Название {sortField==='name' && <span className="ms-1">{sortOrder==='asc'?'▲':'▼'}</span>}</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">Нет записей</td></tr>
|
||||
) : paginated.map((item) => (
|
||||
<tr key={item.value} className={editingValue === item.value ? 'table-info' : ''}>
|
||||
<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>
|
||||
)}
|
||||
</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 d-flex align-items-center justify-content-between">
|
||||
<div className="text-muted">Показано {((page - 1) * pageSize) + 1} - {Math.min(page * pageSize, filtered.length)} из {filtered.length}</div>
|
||||
<ul className="pagination m-0">
|
||||
<li className={`page-item${page === 1 ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={()=>setCurrentPage(1)} disabled={page===1}>Первая</button>
|
||||
</li>
|
||||
<li className={`page-item${page === 1 ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={()=>setCurrentPage(page-1)} disabled={page===1}>Назад</button>
|
||||
</li>
|
||||
<li className={`page-item${page === totalPages ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={()=>setCurrentPage(page+1)} disabled={page===totalPages}>Вперед</button>
|
||||
</li>
|
||||
<li className={`page-item${page === totalPages ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={()=>setCurrentPage(totalPages)} disabled={page===totalPages}>Последняя</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommunitiesManager;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user