feat: Implementa Dashboard de estadísticas visuales del inventario
Se añade una nueva sección "Dashboard" para proporcionar una visión general y analítica del inventario de IT. Esta vista transforma los datos brutos en gráficos interactivos, facilitando la toma de decisiones y la comprensión del estado del parque informático. La implementación se realizó de forma iterativa, refinando tanto la obtención de datos como la presentación visual para una mejor experiencia de usuario. **Principales Cambios:** **1. Backend:** - Se crea un nuevo `DashboardController` con el endpoint `GET /api/dashboard/stats`. - Se implementan consultas SQL agregadas para obtener estadísticas por: - Sistema Operativo. - Cantidad de equipos por Sector (mostrando todos los sectores). - Modelos de CPU (mostrando todos los modelos). - Cantidad de Memoria RAM instalada (GB). **2. Frontend:** - Se integran las librerías `chart.js` y `react-chartjs-2` para la visualización de datos. - Se añade una nueva vista "Dashboard" accesible desde la barra de navegación principal. - Se crean componentes reutilizables para cada tipo de gráfico: `OsChart`, `RamChart`, `CpuChart` y `SectorChart`. - Se diseña un layout responsivo en formato de grilla 2x2 para una visualización equilibrada de los cuatro gráficos. **3. Mejoras de Experiencia de Usuario (UX):** - Se utilizan gráficos de barras horizontales (`indexAxis: 'y'`) para mejorar la legibilidad de etiquetas largas, como los modelos de CPU y nombres de sectores. - Se implementan contenedores con scroll vertical (`overflow-y: auto`) para los gráficos de CPU y Sectores. Esto permite mostrar la totalidad de los datos sin comprometer el diseño ni crear gráficos excesivamente grandes. - Se calcula una altura dinámica para los gráficos de barras para que se adapten a la cantidad de datos que contienen, mejorando la presentación.
This commit is contained in:
84
backend/Controllers/DashboardController.cs
Normal file
84
backend/Controllers/DashboardController.cs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
using Dapper;
|
||||||
|
using Inventario.API.Data;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Inventario.API.Controllers
|
||||||
|
{
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class DashboardController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly DapperContext _context;
|
||||||
|
|
||||||
|
public DashboardController(DapperContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class StatItemDto
|
||||||
|
{
|
||||||
|
// Cambiamos el tipo de Label a string para que Dapper no tenga problemas
|
||||||
|
// al leer el ram_installed (que es un int). Lo formatearemos en el frontend.
|
||||||
|
public string Label { get; set; } = "";
|
||||||
|
public int Count { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DashboardStatsDto
|
||||||
|
{
|
||||||
|
public IEnumerable<StatItemDto> OsStats { get; set; } = new List<StatItemDto>();
|
||||||
|
public IEnumerable<StatItemDto> SectorStats { get; set; } = new List<StatItemDto>();
|
||||||
|
public IEnumerable<StatItemDto> CpuStats { get; set; } = new List<StatItemDto>();
|
||||||
|
public IEnumerable<StatItemDto> RamStats { get; set; } = new List<StatItemDto>(); // <-- 1. Añadir propiedad para RAM
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("stats")]
|
||||||
|
public async Task<IActionResult> GetDashboardStats()
|
||||||
|
{
|
||||||
|
var osQuery = @"
|
||||||
|
SELECT Os AS Label, COUNT(Id) AS Count
|
||||||
|
FROM dbo.equipos
|
||||||
|
WHERE Os IS NOT NULL AND Os != ''
|
||||||
|
GROUP BY Os
|
||||||
|
ORDER BY Count DESC;";
|
||||||
|
|
||||||
|
var sectorQuery = @"
|
||||||
|
SELECT s.Nombre AS Label, COUNT(e.Id) AS Count
|
||||||
|
FROM dbo.equipos e
|
||||||
|
JOIN dbo.sectores s ON e.sector_id = s.id
|
||||||
|
GROUP BY s.Nombre
|
||||||
|
ORDER BY Count DESC;";
|
||||||
|
|
||||||
|
var cpuQuery = @"
|
||||||
|
SELECT Cpu AS Label, COUNT(Id) AS Count
|
||||||
|
FROM dbo.equipos
|
||||||
|
WHERE Cpu IS NOT NULL AND Cpu != ''
|
||||||
|
GROUP BY Cpu
|
||||||
|
ORDER BY Count DESC;";
|
||||||
|
|
||||||
|
var ramQuery = @"
|
||||||
|
SELECT CAST(ram_installed AS VARCHAR) AS Label, COUNT(Id) AS Count
|
||||||
|
FROM dbo.equipos
|
||||||
|
WHERE ram_installed > 0
|
||||||
|
GROUP BY ram_installed
|
||||||
|
ORDER BY ram_installed ASC;";
|
||||||
|
|
||||||
|
using (var connection = _context.CreateConnection())
|
||||||
|
{
|
||||||
|
var osStats = await connection.QueryAsync<StatItemDto>(osQuery);
|
||||||
|
var sectorStats = await connection.QueryAsync<StatItemDto>(sectorQuery);
|
||||||
|
var cpuStats = await connection.QueryAsync<StatItemDto>(cpuQuery);
|
||||||
|
var ramStats = await connection.QueryAsync<StatItemDto>(ramQuery);
|
||||||
|
|
||||||
|
var result = new DashboardStatsDto
|
||||||
|
{
|
||||||
|
OsStats = osStats,
|
||||||
|
SectorStats = sectorStats,
|
||||||
|
CpuStats = cpuStats,
|
||||||
|
RamStats = ramStats
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ using System.Reflection;
|
|||||||
[assembly: System.Reflection.AssemblyCompanyAttribute("Inventario.API")]
|
[assembly: System.Reflection.AssemblyCompanyAttribute("Inventario.API")]
|
||||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+afd378712c9176f32a705fb3a10d2d56ed6c8ba2")]
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+8162d59331f63963077dd822669378174380b386")]
|
||||||
[assembly: System.Reflection.AssemblyProductAttribute("Inventario.API")]
|
[assembly: System.Reflection.AssemblyProductAttribute("Inventario.API")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("Inventario.API")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("Inventario.API")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
|||||||
30
frontend/package-lock.json
generated
30
frontend/package-lock.json
generated
@@ -9,7 +9,9 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
|
"react-chartjs-2": "^5.3.0",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-tooltip": "^5.29.1"
|
"react-tooltip": "^5.29.1"
|
||||||
@@ -1034,6 +1036,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -1987,6 +1995,18 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/classnames": {
|
"node_modules/classnames": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||||
@@ -2985,6 +3005,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-chartjs-2": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"chart.js": "^4.1.1",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.0",
|
"version": "19.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
|
"react-chartjs-2": "^5.3.0",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-tooltip": "^5.29.1"
|
"react-tooltip": "^5.29.1"
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { useState } from 'react';
|
|||||||
import SimpleTable from "./components/SimpleTable";
|
import SimpleTable from "./components/SimpleTable";
|
||||||
import GestionSectores from "./components/GestionSectores";
|
import GestionSectores from "./components/GestionSectores";
|
||||||
import GestionComponentes from './components/GestionComponentes';
|
import GestionComponentes from './components/GestionComponentes';
|
||||||
|
import Dashboard from './components/Dashboard';
|
||||||
import Navbar from './components/Navbar';
|
import Navbar from './components/Navbar';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
export type View = 'equipos' | 'sectores' | 'admin';
|
export type View = 'equipos' | 'sectores' | 'admin' | 'dashboard';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [currentView, setCurrentView] = useState<View>('equipos');
|
const [currentView, setCurrentView] = useState<View>('equipos');
|
||||||
@@ -18,6 +19,7 @@ function App() {
|
|||||||
{currentView === 'equipos' && <SimpleTable />}
|
{currentView === 'equipos' && <SimpleTable />}
|
||||||
{currentView === 'sectores' && <GestionSectores />}
|
{currentView === 'sectores' && <GestionSectores />}
|
||||||
{currentView === 'admin' && <GestionComponentes />}
|
{currentView === 'admin' && <GestionComponentes />}
|
||||||
|
{currentView === 'dashboard' && <Dashboard />}
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
59
frontend/src/components/CpuChart.tsx
Normal file
59
frontend/src/components/CpuChart.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js';
|
||||||
|
import { Bar } from 'react-chartjs-2';
|
||||||
|
import type { StatItem } from '../types/interfaces';
|
||||||
|
|
||||||
|
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: StatItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CpuChart: React.FC<Props> = ({ data }) => {
|
||||||
|
// Altura dinámica: 40px de base + 20px por cada CPU
|
||||||
|
const chartHeight = 40 + data.length * 20;
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
indexAxis: 'y' as const,
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Equipos por CPU',
|
||||||
|
font: {
|
||||||
|
size: 16
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { beginAtZero: true, ticks: { stepSize: 1 } },
|
||||||
|
y: { ticks: { font: { size: 10 } } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels: data.map(item => item.label),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Nº de Equipos',
|
||||||
|
data: data.map(item => item.count),
|
||||||
|
backgroundColor: 'rgba(153, 102, 255, 0.8)',
|
||||||
|
borderColor: 'rgba(153, 102, 255, 1)',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Envolvemos la barra en un div con altura calculada
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', height: `${chartHeight}px`, minHeight: '400px' }}>
|
||||||
|
<Bar options={options} data={chartData} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CpuChart;
|
||||||
42
frontend/src/components/Dashboard.module.css
Normal file
42
frontend/src/components/Dashboard.module.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
.dashboardHeader {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboardHeader h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 300;
|
||||||
|
color: #343a40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsGrid {
|
||||||
|
display: grid;
|
||||||
|
gap: 2rem;
|
||||||
|
/* Grilla simple de 2 columnas */
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartContainer {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 450px; /* Altura mínima para todos los gráficos */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Contenedor especial para los gráficos de barras horizontales con scroll */
|
||||||
|
.scrollableChartContainer {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.statsGrid {
|
||||||
|
grid-template-columns: 1fr; /* Una sola columna en pantallas pequeñas */
|
||||||
|
}
|
||||||
|
}
|
||||||
71
frontend/src/components/Dashboard.tsx
Normal file
71
frontend/src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { dashboardService } from '../services/apiService';
|
||||||
|
import type { DashboardStats } from '../types/interfaces';
|
||||||
|
import OsChart from './OsChart';
|
||||||
|
import SectorChart from './SectorChart';
|
||||||
|
import CpuChart from './CpuChart';
|
||||||
|
import RamChart from './RamChart'; // <-- 1. Importar el nuevo gráfico
|
||||||
|
import styles from './Dashboard.module.css';
|
||||||
|
|
||||||
|
const Dashboard = () => {
|
||||||
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
dashboardService.getStats()
|
||||||
|
.then(data => {
|
||||||
|
setStats(data);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error('No se pudieron cargar las estadísticas del dashboard.');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.dashboardHeader}>
|
||||||
|
<h2>Cargando estadísticas...</h2>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!stats) {
|
||||||
|
return (
|
||||||
|
<div className={styles.dashboardHeader}>
|
||||||
|
<h2>No hay datos disponibles para mostrar.</h2>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={styles.dashboardHeader}>
|
||||||
|
<h2>Dashboard de Inventario</h2>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsGrid}>
|
||||||
|
{/* Fila 1, Columna 1 */}
|
||||||
|
<div className={styles.chartContainer}>
|
||||||
|
<OsChart data={stats.osStats} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fila 1, Columna 2 */}
|
||||||
|
<div className={styles.chartContainer}>
|
||||||
|
<RamChart data={stats.ramStats} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fila 2, Columna 1 */}
|
||||||
|
<div className={`${styles.chartContainer} ${styles.scrollableChartContainer}`}>
|
||||||
|
<CpuChart data={stats.cpuStats} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fila 2, Columna 2 */}
|
||||||
|
<div className={`${styles.chartContainer} ${styles.scrollableChartContainer}`}>
|
||||||
|
<SectorChart data={stats.sectorStats} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
// frontend/src/components/Navbar.tsx
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { View } from '../App'; // Importaremos el tipo desde App.tsx
|
import type { View } from '../App';
|
||||||
import '../App.css'; // Usaremos los estilos globales que acabamos de crear
|
import '../App.css';
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
currentView: View;
|
currentView: View;
|
||||||
@@ -14,7 +13,7 @@ const Navbar: React.FC<NavbarProps> = ({ currentView, setCurrentView }) => {
|
|||||||
<div className="app-title">
|
<div className="app-title">
|
||||||
Inventario IT
|
Inventario IT
|
||||||
</div>
|
</div>
|
||||||
<nav className="nav-links">
|
<nav className="nav-links">
|
||||||
<button
|
<button
|
||||||
className={`nav-link ${currentView === 'equipos' ? 'nav-link-active' : ''}`}
|
className={`nav-link ${currentView === 'equipos' ? 'nav-link-active' : ''}`}
|
||||||
onClick={() => setCurrentView('equipos')}
|
onClick={() => setCurrentView('equipos')}
|
||||||
@@ -33,6 +32,12 @@ const Navbar: React.FC<NavbarProps> = ({ currentView, setCurrentView }) => {
|
|||||||
>
|
>
|
||||||
Administración
|
Administración
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`nav-link ${currentView === 'dashboard' ? 'nav-link-active' : ''}`}
|
||||||
|
onClick={() => setCurrentView('dashboard')}
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
49
frontend/src/components/OsChart.tsx
Normal file
49
frontend/src/components/OsChart.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Chart as ChartJS, ArcElement, Tooltip, Legend, Title } from 'chart.js';
|
||||||
|
import { Doughnut } from 'react-chartjs-2';
|
||||||
|
import type { StatItem } from '../types/interfaces';
|
||||||
|
|
||||||
|
ChartJS.register(ArcElement, Tooltip, Legend, Title);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: StatItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const OsChart: React.FC<Props> = ({ data }) => {
|
||||||
|
const chartData = {
|
||||||
|
labels: data.map(item => item.label),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Nº de Equipos',
|
||||||
|
data: data.map(item => item.count),
|
||||||
|
backgroundColor: [
|
||||||
|
'#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F',
|
||||||
|
'#EDC948', '#B07AA1', '#FF9DA7', '#9C755F', '#BAB0AC'
|
||||||
|
],
|
||||||
|
borderColor: '#ffffff',
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'right' as const, // <-- ¡CAMBIO CLAVE! Mueve la leyenda a la derecha
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Distribución por Sistema Operativo',
|
||||||
|
font: {
|
||||||
|
size: 16
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Doughnut data={chartData} options={options} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OsChart;
|
||||||
55
frontend/src/components/RamChart.tsx
Normal file
55
frontend/src/components/RamChart.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js';
|
||||||
|
import { Bar } from 'react-chartjs-2';
|
||||||
|
import type { StatItem } from '../types/interfaces';
|
||||||
|
|
||||||
|
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: StatItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const RamChart: React.FC<Props> = ({ data }) => {
|
||||||
|
const options = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Distribución de Memoria RAM',
|
||||||
|
font: {
|
||||||
|
size: 16
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
stepSize: 2 // Escala de 2 en 2 para que sea más legible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
// Formateamos la etiqueta para que diga "X GB"
|
||||||
|
labels: data.map(item => `${item.label} GB`),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Nº de Equipos',
|
||||||
|
data: data.map(item => item.count),
|
||||||
|
backgroundColor: 'rgba(225, 87, 89, 0.8)', // Un color rojo/coral
|
||||||
|
borderColor: 'rgba(225, 87, 89, 1)',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Bar options={options} data={chartData} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RamChart;
|
||||||
58
frontend/src/components/SectorChart.tsx
Normal file
58
frontend/src/components/SectorChart.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js';
|
||||||
|
import { Bar } from 'react-chartjs-2';
|
||||||
|
import type { StatItem } from '../types/interfaces';
|
||||||
|
|
||||||
|
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: StatItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SectorChart: React.FC<Props> = ({ data }) => {
|
||||||
|
// Altura dinámica: 40px de base + 25px por cada sector
|
||||||
|
const chartHeight = 40 + data.length * 25;
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
indexAxis: 'y' as const,
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Equipos por Sector',
|
||||||
|
font: {
|
||||||
|
size: 16
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { beginAtZero: true, ticks: { stepSize: 2 } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels: data.map(item => item.label),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Nº de Equipos',
|
||||||
|
data: data.map(item => item.count),
|
||||||
|
backgroundColor: 'rgba(76, 175, 80, 0.8)',
|
||||||
|
borderColor: 'rgba(76, 175, 80, 1)',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Envolvemos la barra en un div con altura calculada
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', height: `${chartHeight}px`, minHeight: '400px' }}>
|
||||||
|
<Bar options={options} data={chartData} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SectorChart;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// frontend/src/services/apiService.ts
|
// frontend/src/services/apiService.ts
|
||||||
|
|
||||||
import type { Equipo, Sector, HistorialEquipo, Usuario, MemoriaRam } from '../types/interfaces';
|
import type { Equipo, Sector, HistorialEquipo, Usuario, MemoriaRam, DashboardStats } from '../types/interfaces';
|
||||||
|
|
||||||
const BASE_URL = '/api';
|
const BASE_URL = '/api';
|
||||||
|
|
||||||
@@ -117,4 +117,8 @@ export const adminService = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
deleteTextComponent: (type: string, value: string) => request<void>(`${BASE_URL}/admin/componentes/${type}/${encodeURIComponent(value)}`, { method: 'DELETE' }),
|
deleteTextComponent: (type: string, value: string) => request<void>(`${BASE_URL}/admin/componentes/${type}/${encodeURIComponent(value)}`, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dashboardService = {
|
||||||
|
getStats: () => request<DashboardStats>(`${BASE_URL}/dashboard/stats`),
|
||||||
};
|
};
|
||||||
@@ -80,4 +80,18 @@ export interface Equipo {
|
|||||||
discos: DiscoDetalle[];
|
discos: DiscoDetalle[];
|
||||||
memoriasRam: MemoriaRamEquipoDetalle[];
|
memoriasRam: MemoriaRamEquipoDetalle[];
|
||||||
historial: HistorialEquipo[];
|
historial: HistorialEquipo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Interfaces para el Dashboard ---
|
||||||
|
|
||||||
|
export interface StatItem {
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
osStats: StatItem[];
|
||||||
|
sectorStats: StatItem[];
|
||||||
|
cpuStats: StatItem[];
|
||||||
|
ramStats: StatItem[];
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user