feat: Add StatCard component to Dashboard and implement sorting, filtering, and pagination in DataManager
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 16m3s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 16m3s
This commit is contained in:
@@ -23,12 +23,6 @@
|
||||
}
|
||||
|
||||
/* Custom hover effects */
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
+49
-1
@@ -12,7 +12,10 @@ import {
|
||||
IconActivity,
|
||||
IconBell,
|
||||
IconChevronDown,
|
||||
IconUser
|
||||
IconUser,
|
||||
IconDatabase,
|
||||
IconAlertTriangle,
|
||||
IconAlertCircle
|
||||
} from '@tabler/icons-react';
|
||||
import DataManager from './DataManager';
|
||||
import './App.css';
|
||||
@@ -151,9 +154,54 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, color, value, title, subtitle }) {
|
||||
return (
|
||||
<div className="card h-100">
|
||||
<div className="card-body d-flex align-items-center">
|
||||
<span className={`avatar avatar-lg me-3 bg-${color}-lt text-${color} border-0`}>
|
||||
<Icon size={32} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="h3 mb-0 fw-bold">{value} <span className="fs-5 fw-normal">{title}</span></div>
|
||||
<div className="text-muted lh-1">{subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
return (
|
||||
<div>
|
||||
<div className="row g-3 mb-4">
|
||||
<div className="col-md-4">
|
||||
<StatCard
|
||||
icon={IconDatabase}
|
||||
color="blue"
|
||||
value={6}
|
||||
title="Событий"
|
||||
subtitle="Всего событий"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<StatCard
|
||||
icon={IconAlertTriangle}
|
||||
color="red"
|
||||
value={3}
|
||||
title="Ошибок"
|
||||
subtitle="Критические ошибки"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<StatCard
|
||||
icon={IconAlertCircle}
|
||||
color="yellow"
|
||||
value={0}
|
||||
title="Предупреждений"
|
||||
subtitle="Системные предупреждения"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-header d-print-none mb-4">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
|
||||
+138
-91
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
IconPlus,
|
||||
@@ -30,6 +30,12 @@ function DataManager({ entityName, entityKey, placeholder }) {
|
||||
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();
|
||||
@@ -122,10 +128,56 @@ function DataManager({ entityName, entityKey, placeholder }) {
|
||||
}
|
||||
};
|
||||
|
||||
const filteredItems = items.filter(i =>
|
||||
// Сортировка
|
||||
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 && (
|
||||
@@ -278,110 +330,105 @@ function DataManager({ entityName, entityKey, placeholder }) {
|
||||
</div>
|
||||
|
||||
<div className="col-lg-8">
|
||||
{/* Search and Table Card */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Список {entityName}</h3>
|
||||
<div className="card-actions">
|
||||
<div className="input-icon">
|
||||
<IconSearch className="icon" />
|
||||
<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)}
|
||||
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{entityName}</th>
|
||||
<th>Шлюз</th>
|
||||
<th className="w-1"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredItems.map((item, index) => (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
{editingDomain === item.domain ? (
|
||||
<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 form-control-sm"
|
||||
value={editingDomain}
|
||||
onChange={(e) => setEditingDomain(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
item.domain
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editingDomain === item.domain ? (
|
||||
<input
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
className="form-control d-inline-block w-auto me-2"
|
||||
value={editingValue}
|
||||
onChange={(e) => setEditingValue(e.target.value)}
|
||||
ref={editInputRef}
|
||||
onChange={e => setEditingValue(e.target.value)}
|
||||
onKeyDown={e => handleEditKeyDown(e, item.domain)}
|
||||
style={{maxWidth: 120}}
|
||||
/>
|
||||
) : (
|
||||
<span className="badge bg-blue-lt">{item.type}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editingDomain === item.domain ? (
|
||||
<div className="btn-list">
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => handleSaveEdit(item.domain)}
|
||||
>
|
||||
<IconCheck className="icon" />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={handleCancelEdit}
|
||||
>
|
||||
<IconX className="icon" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="btn-list">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
onClick={() => handleEdit(item)}
|
||||
>
|
||||
<IconEdit className="icon" />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={() => confirmDelete(item)}
|
||||
>
|
||||
<IconTrash className="icon" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{filteredItems.length === 0 && (
|
||||
<div className="empty">
|
||||
<div className="empty-img">
|
||||
<IconAlertTriangle className="icon" />
|
||||
</div>
|
||||
<p className="empty-title">Нет {entityName} для отображения</p>
|
||||
<p className="empty-subtitle text-muted">
|
||||
Добавьте первый {entityName} используя форму слева.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user