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:
2025-10-09 13:29:29 -03:00
parent 8162d59331
commit 5f72f30931
14 changed files with 482 additions and 7 deletions

View 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);
}
}
}
}

View File

@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Inventario.API")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[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.AssemblyTitleAttribute("Inventario.API")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@@ -9,7 +9,9 @@
"version": "0.0.0",
"dependencies": {
"@tanstack/react-table": "^8.21.3",
"chart.js": "^4.5.0",
"react": "^19.1.1",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.1",
"react-hot-toast": "^2.6.0",
"react-tooltip": "^5.29.1"
@@ -1034,6 +1036,12 @@
"@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": {
"version": "2.1.5",
"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"
}
},
"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": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
@@ -2985,6 +3005,16 @@
"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": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",

View File

@@ -11,7 +11,9 @@
},
"dependencies": {
"@tanstack/react-table": "^8.21.3",
"chart.js": "^4.5.0",
"react": "^19.1.1",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.1",
"react-hot-toast": "^2.6.0",
"react-tooltip": "^5.29.1"

View File

@@ -2,10 +2,11 @@ import { useState } from 'react';
import SimpleTable from "./components/SimpleTable";
import GestionSectores from "./components/GestionSectores";
import GestionComponentes from './components/GestionComponentes';
import Dashboard from './components/Dashboard';
import Navbar from './components/Navbar';
import './App.css';
export type View = 'equipos' | 'sectores' | 'admin';
export type View = 'equipos' | 'sectores' | 'admin' | 'dashboard';
function App() {
const [currentView, setCurrentView] = useState<View>('equipos');
@@ -18,6 +19,7 @@ function App() {
{currentView === 'equipos' && <SimpleTable />}
{currentView === 'sectores' && <GestionSectores />}
{currentView === 'admin' && <GestionComponentes />}
{currentView === 'dashboard' && <Dashboard />}
</main>
</>
);

View 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;

View 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 */
}
}

View 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;

View File

@@ -1,7 +1,6 @@
// frontend/src/components/Navbar.tsx
import React from 'react';
import type { View } from '../App'; // Importaremos el tipo desde App.tsx
import '../App.css'; // Usaremos los estilos globales que acabamos de crear
import type { View } from '../App';
import '../App.css';
interface NavbarProps {
currentView: View;
@@ -33,6 +32,12 @@ const Navbar: React.FC<NavbarProps> = ({ currentView, setCurrentView }) => {
>
Administración
</button>
<button
className={`nav-link ${currentView === 'dashboard' ? 'nav-link-active' : ''}`}
onClick={() => setCurrentView('dashboard')}
>
Dashboard
</button>
</nav>
</header>
);

View 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;

View 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;

View 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;

View File

@@ -1,6 +1,6 @@
// 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';
@@ -118,3 +118,7 @@ export const adminService = {
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`),
};

View File

@@ -81,3 +81,17 @@ export interface Equipo {
memoriasRam: MemoriaRamEquipoDetalle[];
historial: HistorialEquipo[];
}
// --- Interfaces para el Dashboard ---
export interface StatItem {
label: string;
count: number;
}
export interface DashboardStats {
osStats: StatItem[];
sectorStats: StatItem[];
cpuStats: StatItem[];
ramStats: StatItem[];
}