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

This commit is contained in:
2025-07-09 10:36:44 +07:00
parent 9d31682745
commit e0fa98f21a
3 changed files with 187 additions and 98 deletions
-6
View File
@@ -23,12 +23,6 @@
} }
/* Custom hover effects */ /* 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 { .btn:hover {
transform: translateY(-1px); transform: translateY(-1px);
transition: all 0.2s ease; transition: all 0.2s ease;
+49 -1
View File
@@ -12,7 +12,10 @@ import {
IconActivity, IconActivity,
IconBell, IconBell,
IconChevronDown, IconChevronDown,
IconUser IconUser,
IconDatabase,
IconAlertTriangle,
IconAlertCircle
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import DataManager from './DataManager'; import DataManager from './DataManager';
import './App.css'; 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() { function Dashboard() {
return ( return (
<div> <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="page-header d-print-none mb-4">
<div className="row align-items-center"> <div className="row align-items-center">
<div className="col"> <div className="col">
+122 -75
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import axios from 'axios'; import axios from 'axios';
import { import {
IconPlus, IconPlus,
@@ -30,6 +30,12 @@ function DataManager({ entityName, entityKey, placeholder }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null); 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(() => { useEffect(() => {
fetchItems(); 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()) 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 ( return (
<> <>
{error && ( {error && (
@@ -278,91 +330,80 @@ function DataManager({ entityName, entityKey, placeholder }) {
</div> </div>
<div className="col-lg-8"> <div className="col-lg-8">
{/* Search and Table Card */}
<div className="card"> <div className="card">
<div className="card-header"> <div className="card-header d-flex justify-content-between align-items-center">
<h3 className="card-title">Список {entityName}</h3> <h3 className="card-title mb-0">Список {entityName === 'домен' ? 'доменов' : entityName}</h3>
<div className="card-actions"> <div className="d-flex gap-2 w-50">
<div className="input-icon"> {/* Фильтр по шлюзу */}
<IconSearch className="icon" /> <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 <input
type="text" type="text"
className="form-control" className="form-control"
placeholder={`Поиск ${entityName}...`} placeholder={`Поиск ${entityName}...`}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
/> />
</div> </div>
</div> </div>
</div> </div>
<div className="card-body">
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-vcenter card-table"> <table className="table card-table table-vcenter table-nowrap mb-0">
<thead> <thead>
<tr> <tr>
<th>{entityName}</th> <th style={{cursor:'pointer'}} onClick={() => handleSort('domain')}>
<th>Шлюз</th> Домен
<th className="w-1"></th> {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> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredItems.map((item, index) => ( {paginatedItems.map((item) => (
<tr key={index}> <tr key={item.domain} className={editingDomain === item.domain ? 'table-info' : ''}>
<td> <td>{item.domain}</td>
<td><span className="badge bg-blue-lt text-blue">{item.type}</span></td>
<td className="text-end">
{editingDomain === item.domain ? ( {editingDomain === item.domain ? (
<>
<input <input
type="text" type="text"
className="form-control form-control-sm" className="form-control d-inline-block w-auto me-2"
value={editingDomain}
onChange={(e) => setEditingDomain(e.target.value)}
/>
) : (
item.domain
)}
</td>
<td>
{editingDomain === item.domain ? (
<input
type="text"
className="form-control form-control-sm"
value={editingValue} 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}}
/> />
<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>
</>
) : ( ) : (
<span className="badge bg-blue-lt">{item.type}</span> <>
)} <button className="btn btn-outline-primary btn-icon me-1" onClick={() => handleEdit(item)}><IconEdit size={18} /></button>
</td> <button className="btn btn-outline-danger btn-icon" onClick={() => confirmDelete(item)}><IconTrash size={18} /></button>
<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> </td>
</tr> </tr>
@@ -370,21 +411,27 @@ function DataManager({ entityName, entityKey, placeholder }) {
</tbody> </tbody>
</table> </table>
</div> </div>
{filteredItems.length === 0 && ( {/* Пагинация */}
<div className="empty"> {totalPages > 1 && (
<div className="empty-img"> <div className="card-footer d-flex justify-content-center">
<IconAlertTriangle className="icon" /> <ul className="pagination m-0">
</div> <li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
<p className="empty-title">Нет {entityName} для отображения</p> <button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} tabIndex={-1} disabled={currentPage === 1}>&laquo;</button>
<p className="empty-subtitle text-muted"> </li>
Добавьте первый {entityName} используя форму слева. {Array.from({length: totalPages}, (_, i) => i + 1).map(page => (
</p> <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}>&raquo;</button>
</li>
</ul>
</div> </div>
)} )}
</div> </div>
</div> </div>
</div> </div>
</div>
{/* Delete Confirmation Modal */} {/* Delete Confirmation Modal */}
{showDeleteModal && ( {showDeleteModal && (