Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 16m3s
484 lines
18 KiB
React
484 lines
18 KiB
React
import { useState, useEffect, useRef } from 'react';
|
||
import axios from 'axios';
|
||
import {
|
||
IconPlus,
|
||
IconSearch,
|
||
IconEdit,
|
||
IconTrash,
|
||
IconCheck,
|
||
IconX,
|
||
IconDatabase,
|
||
IconRefresh,
|
||
IconArrowsExchange,
|
||
IconDownload,
|
||
IconUpload,
|
||
IconAlertTriangle
|
||
} from '@tabler/icons-react';
|
||
|
||
const API_URL = '/api';
|
||
|
||
function DataManager({ entityName, entityKey, placeholder }) {
|
||
const [items, setItems] = useState([]);
|
||
const [newItem, setNewItem] = useState({ domain: '', type: 'SharkFIN' });
|
||
const [error, setError] = useState('');
|
||
const [success, setSuccess] = useState('');
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [editingDomain, setEditingDomain] = useState(null);
|
||
const [editingValue, setEditingValue] = useState('');
|
||
const [bulkFrom, setBulkFrom] = useState('SharkFIN');
|
||
const [bulkTo, setBulkTo] = useState('SharkAM');
|
||
const [loading, setLoading] = useState(false);
|
||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||
const [itemToDelete, setItemToDelete] = useState(null);
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [sortField, setSortField] = useState('domain');
|
||
const [sortOrder, setSortOrder] = useState('asc');
|
||
const [filterType, setFilterType] = useState('');
|
||
const editInputRef = useRef(null);
|
||
const pageSize = 10;
|
||
|
||
useEffect(() => {
|
||
fetchItems();
|
||
}, []);
|
||
|
||
const fetchItems = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const response = await axios.get(`${API_URL}/${entityKey}`);
|
||
setItems(response.data);
|
||
setError('');
|
||
} catch (error) {
|
||
console.error(`Error fetching ${entityKey}:`, error);
|
||
setError(`Не удалось загрузить ${entityName}. Проверьте, запущен ли бэкенд.`);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleAddItem = () => {
|
||
if (newItem.domain.trim() === '') {
|
||
setError(`Имя ${entityName} не может быть пустым.`);
|
||
return;
|
||
}
|
||
setError('');
|
||
setItems([...items, newItem]);
|
||
setNewItem({ domain: '', type: 'SharkFIN' });
|
||
};
|
||
|
||
const handleEdit = (item) => {
|
||
setEditingDomain(item.domain);
|
||
setEditingValue(item.type);
|
||
};
|
||
|
||
const handleSaveEdit = (domainName) => {
|
||
const updatedItems = items.map(i =>
|
||
i.domain === domainName ? { ...i, type: editingValue } : i
|
||
);
|
||
setItems(updatedItems);
|
||
setEditingDomain(null);
|
||
};
|
||
|
||
const handleCancelEdit = () => {
|
||
setEditingDomain(null);
|
||
};
|
||
|
||
const handleDeleteItem = (domainNameToDelete) => {
|
||
setItems(items.filter(i => i.domain !== domainNameToDelete));
|
||
};
|
||
|
||
const confirmDelete = (item) => {
|
||
setItemToDelete(item);
|
||
setShowDeleteModal(true);
|
||
};
|
||
|
||
const executeDelete = () => {
|
||
if (itemToDelete) {
|
||
handleDeleteItem(itemToDelete.domain);
|
||
setShowDeleteModal(false);
|
||
setItemToDelete(null);
|
||
}
|
||
};
|
||
|
||
const handleSaveChanges = async () => {
|
||
setLoading(true);
|
||
try {
|
||
await axios.post(`${API_URL}/${entityKey}`, { domains: items });
|
||
setSuccess('Изменения успешно сохранены!');
|
||
setTimeout(() => setSuccess(''), 3000);
|
||
} catch (error) {
|
||
console.error('Error saving changes:', error);
|
||
setError('Не удалось сохранить изменения.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleBulkUpdate = () => {
|
||
if (!bulkFrom || !bulkTo) {
|
||
setError('Оба поля для массового обновления должны быть заполнены.');
|
||
return;
|
||
}
|
||
const confirm = window.confirm(`Вы уверены, что хотите заменить все шлюзы "${bulkFrom}" на "${bulkTo}"? Это действие необратимо.`);
|
||
if (confirm) {
|
||
const updatedItems = items.map(i =>
|
||
i.type === bulkFrom ? { ...i, type: bulkTo } : i
|
||
);
|
||
setItems(updatedItems);
|
||
setSuccess(`Шлюзы "${bulkFrom}" были успешно заменены на "${bulkTo}". Не забудьте сохранить изменения.`);
|
||
}
|
||
};
|
||
|
||
// Сортировка
|
||
const sortedItems = [...items].sort((a, b) => {
|
||
let valA = a[sortField] || '';
|
||
let valB = b[sortField] || '';
|
||
if (typeof valA === 'string') valA = valA.toLowerCase();
|
||
if (typeof valB === 'string') valB = valB.toLowerCase();
|
||
if (valA < valB) return sortOrder === 'asc' ? -1 : 1;
|
||
if (valA > valB) return sortOrder === 'asc' ? 1 : -1;
|
||
return 0;
|
||
});
|
||
|
||
// Фильтрация по шлюзу
|
||
const filteredByType = filterType
|
||
? sortedItems.filter(i => i.type === filterType)
|
||
: sortedItems;
|
||
|
||
// Поиск
|
||
const filteredItems = filteredByType.filter(i =>
|
||
i.domain.toLowerCase().includes(searchTerm.toLowerCase())
|
||
);
|
||
|
||
// Пагинация
|
||
const totalPages = Math.ceil(filteredItems.length / pageSize);
|
||
const paginatedItems = filteredItems.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||
|
||
// Для фильтра - список всех уникальных шлюзов
|
||
const allTypes = Array.from(new Set(items.map(i => i.type)));
|
||
|
||
// Сортировка по клику
|
||
const handleSort = (field) => {
|
||
if (sortField === field) {
|
||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||
} else {
|
||
setSortField(field);
|
||
setSortOrder('asc');
|
||
}
|
||
};
|
||
|
||
// Улучшенный инлайн-редакт
|
||
useEffect(() => {
|
||
if (editingDomain && editInputRef.current) {
|
||
editInputRef.current.focus();
|
||
}
|
||
}, [editingDomain]);
|
||
|
||
const handleEditKeyDown = (e, domainName) => {
|
||
if (e.key === 'Enter') handleSaveEdit(domainName);
|
||
if (e.key === 'Escape') handleCancelEdit();
|
||
};
|
||
|
||
return (
|
||
<>
|
||
{error && (
|
||
<div className="alert alert-danger alert-dismissible" role="alert">
|
||
<div className="d-flex">
|
||
<div>
|
||
<IconX className="icon alert-icon" />
|
||
</div>
|
||
<div>
|
||
{error}
|
||
</div>
|
||
</div>
|
||
<a className="btn-close" onClick={() => setError('')}></a>
|
||
</div>
|
||
)}
|
||
|
||
{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>
|
||
<a className="btn-close" onClick={() => setSuccess('')}></a>
|
||
</div>
|
||
)}
|
||
|
||
<div className="row">
|
||
<div className="col-lg-4">
|
||
{/* Add New Item Card */}
|
||
<div className="card card-md">
|
||
<div className="card-header">
|
||
<h3 className="card-title">
|
||
<IconPlus className="icon me-2" />
|
||
Добавить новый {entityName}
|
||
</h3>
|
||
</div>
|
||
<div className="card-body">
|
||
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
|
||
<div className="mb-3">
|
||
<label className="form-label">Имя {entityName}</label>
|
||
<input
|
||
type="text"
|
||
className="form-control"
|
||
placeholder={placeholder}
|
||
value={newItem.domain}
|
||
onChange={(e) => setNewItem({ ...newItem, domain: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="mb-3">
|
||
<label className="form-label">Шлюз</label>
|
||
<input
|
||
type="text"
|
||
className="form-control"
|
||
value={newItem.type}
|
||
onChange={(e) => setNewItem({ ...newItem, type: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="form-footer">
|
||
<button type="submit" className="btn btn-primary w-100">
|
||
<IconPlus className="icon me-2" />
|
||
Добавить
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bulk Update Card */}
|
||
<div className="card card-md">
|
||
<div className="card-header">
|
||
<h3 className="card-title">
|
||
<IconArrowsExchange className="icon me-2" />
|
||
Массовое обновление
|
||
</h3>
|
||
</div>
|
||
<div className="card-body">
|
||
<div className="mb-3">
|
||
<label className="form-label">Заменить с</label>
|
||
<input
|
||
type="text"
|
||
className="form-control"
|
||
value={bulkFrom}
|
||
onChange={(e) => setBulkFrom(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="mb-3">
|
||
<label className="form-label">Заменить на</label>
|
||
<input
|
||
type="text"
|
||
className="form-control"
|
||
value={bulkTo}
|
||
onChange={(e) => setBulkTo(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="form-footer">
|
||
<button
|
||
type="button"
|
||
className="btn btn-warning w-100"
|
||
onClick={handleBulkUpdate}
|
||
>
|
||
<IconArrowsExchange className="icon me-2" />
|
||
Обновить все
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Actions Card */}
|
||
<div className="card card-md">
|
||
<div className="card-header">
|
||
<h3 className="card-title">
|
||
<IconDatabase className="icon me-2" />
|
||
Действия
|
||
</h3>
|
||
</div>
|
||
<div className="card-body">
|
||
<div className="d-grid gap-2">
|
||
<button
|
||
className="btn btn-primary"
|
||
onClick={handleSaveChanges}
|
||
disabled={loading}
|
||
>
|
||
{loading ? (
|
||
<>
|
||
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
|
||
Сохранение...
|
||
</>
|
||
) : (
|
||
<>
|
||
<IconDatabase className="icon me-2" />
|
||
Сохранить в S3
|
||
</>
|
||
)}
|
||
</button>
|
||
<button
|
||
className="btn btn-outline-secondary"
|
||
onClick={fetchItems}
|
||
disabled={loading}
|
||
>
|
||
<IconRefresh className="icon me-2" />
|
||
Обновить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="col-lg-8">
|
||
<div className="card">
|
||
<div className="card-header d-flex justify-content-between align-items-center">
|
||
<h3 className="card-title mb-0">Список {entityName === 'домен' ? 'доменов' : entityName}</h3>
|
||
<div className="d-flex gap-2 w-50">
|
||
{/* Фильтр по шлюзу */}
|
||
<select className="form-select w-auto" value={filterType} onChange={e => { setFilterType(e.target.value); setCurrentPage(1); }}>
|
||
<option value="">Все шлюзы</option>
|
||
{allTypes.map(type => (
|
||
<option key={type} value={type}>{type}</option>
|
||
))}
|
||
</select>
|
||
{/* Поиск */}
|
||
<div className="input-icon flex-grow-1">
|
||
<span className="input-icon-addon">
|
||
<IconSearch size={18} />
|
||
</span>
|
||
<input
|
||
type="text"
|
||
className="form-control"
|
||
placeholder={`Поиск ${entityName}...`}
|
||
value={searchTerm}
|
||
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="table-responsive">
|
||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th style={{cursor:'pointer'}} onClick={() => handleSort('domain')}>
|
||
Домен
|
||
{sortField === 'domain' && (
|
||
<span className="ms-1">
|
||
{sortOrder === 'asc' ? '▲' : '▼'}
|
||
</span>
|
||
)}
|
||
</th>
|
||
<th style={{cursor:'pointer'}} onClick={() => handleSort('type')}>
|
||
Шлюз
|
||
{sortField === 'type' && (
|
||
<span className="ms-1">
|
||
{sortOrder === 'asc' ? '▲' : '▼'}
|
||
</span>
|
||
)}
|
||
</th>
|
||
<th className="text-end"> </th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{paginatedItems.map((item) => (
|
||
<tr key={item.domain} className={editingDomain === item.domain ? 'table-info' : ''}>
|
||
<td>{item.domain}</td>
|
||
<td><span className="badge bg-blue-lt text-blue">{item.type}</span></td>
|
||
<td className="text-end">
|
||
{editingDomain === item.domain ? (
|
||
<>
|
||
<input
|
||
type="text"
|
||
className="form-control d-inline-block w-auto me-2"
|
||
value={editingValue}
|
||
ref={editInputRef}
|
||
onChange={e => setEditingValue(e.target.value)}
|
||
onKeyDown={e => handleEditKeyDown(e, item.domain)}
|
||
style={{maxWidth: 120}}
|
||
/>
|
||
<button className="btn btn-success btn-icon me-1" onClick={() => handleSaveEdit(item.domain)}><IconCheck size={18} /></button>
|
||
<button className="btn btn-secondary btn-icon" onClick={handleCancelEdit}><IconX size={18} /></button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<button className="btn btn-outline-primary btn-icon me-1" onClick={() => handleEdit(item)}><IconEdit size={18} /></button>
|
||
<button className="btn btn-outline-danger btn-icon" onClick={() => confirmDelete(item)}><IconTrash size={18} /></button>
|
||
</>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{/* Пагинация */}
|
||
{totalPages > 1 && (
|
||
<div className="card-footer d-flex justify-content-center">
|
||
<ul className="pagination m-0">
|
||
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
||
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} tabIndex={-1} disabled={currentPage === 1}>«</button>
|
||
</li>
|
||
{Array.from({length: totalPages}, (_, i) => i + 1).map(page => (
|
||
<li key={page} className={`page-item${currentPage === page ? ' active' : ''}`}>
|
||
<button className="page-link" onClick={() => setCurrentPage(page)}>{page}</button>
|
||
</li>
|
||
))}
|
||
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
||
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} tabIndex={-1} disabled={currentPage === totalPages}>»</button>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Delete Confirmation Modal */}
|
||
{showDeleteModal && (
|
||
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
||
<div className="modal-dialog modal-sm modal-dialog-centered" role="document">
|
||
<div className="modal-content">
|
||
<button
|
||
type="button"
|
||
className="btn-close"
|
||
onClick={() => setShowDeleteModal(false)}
|
||
></button>
|
||
<div className="modal-status bg-danger"></div>
|
||
<div className="modal-body text-center py-4">
|
||
<IconTrash className="icon mb-2 text-danger icon-lg" />
|
||
<h3>Удалить {entityName}?</h3>
|
||
<div className="text-muted">
|
||
Вы уверены, что хотите удалить "{itemToDelete?.domain}"? Это действие необратимо.
|
||
</div>
|
||
</div>
|
||
<div className="modal-footer">
|
||
<div className="w-100">
|
||
<div className="row">
|
||
<div className="col">
|
||
<button
|
||
className="btn w-100"
|
||
onClick={() => setShowDeleteModal(false)}
|
||
>
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
<div className="col">
|
||
<button
|
||
className="btn btn-danger w-100"
|
||
onClick={executeDelete}
|
||
>
|
||
Удалить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default DataManager; |