diff --git a/backend/Controllers/DashboardController.cs b/backend/Controllers/DashboardController.cs new file mode 100644 index 0000000..6125f3a --- /dev/null +++ b/backend/Controllers/DashboardController.cs @@ -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 OsStats { get; set; } = new List(); + public IEnumerable SectorStats { get; set; } = new List(); + public IEnumerable CpuStats { get; set; } = new List(); + public IEnumerable RamStats { get; set; } = new List(); // <-- 1. Añadir propiedad para RAM + } + + [HttpGet("stats")] + public async Task 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(osQuery); + var sectorStats = await connection.QueryAsync(sectorQuery); + var cpuStats = await connection.QueryAsync(cpuQuery); + var ramStats = await connection.QueryAsync(ramQuery); + + var result = new DashboardStatsDto + { + OsStats = osStats, + SectorStats = sectorStats, + CpuStats = cpuStats, + RamStats = ramStats + }; + + return Ok(result); + } + } + } +} \ No newline at end of file diff --git a/backend/obj/Debug/net9.0/Inventario.API.AssemblyInfo.cs b/backend/obj/Debug/net9.0/Inventario.API.AssemblyInfo.cs index e811222..6ce36c7 100644 --- a/backend/obj/Debug/net9.0/Inventario.API.AssemblyInfo.cs +++ b/backend/obj/Debug/net9.0/Inventario.API.AssemblyInfo.cs @@ -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")] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b6ed6c9..fbe32bd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index ce30c2d..fd266b5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 010fceb..2583597 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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('equipos'); @@ -18,6 +19,7 @@ function App() { {currentView === 'equipos' && } {currentView === 'sectores' && } {currentView === 'admin' && } + {currentView === 'dashboard' && } ); diff --git a/frontend/src/components/CpuChart.tsx b/frontend/src/components/CpuChart.tsx new file mode 100644 index 0000000..8fe7fef --- /dev/null +++ b/frontend/src/components/CpuChart.tsx @@ -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 = ({ 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 ( +
+ +
+ ); +}; + +export default CpuChart; \ No newline at end of file diff --git a/frontend/src/components/Dashboard.module.css b/frontend/src/components/Dashboard.module.css new file mode 100644 index 0000000..d0bd48e --- /dev/null +++ b/frontend/src/components/Dashboard.module.css @@ -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 */ + } +} \ No newline at end of file diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx new file mode 100644 index 0000000..84ffae7 --- /dev/null +++ b/frontend/src/components/Dashboard.tsx @@ -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(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 ( +
+

Cargando estadísticas...

+
+ ); + } + if (!stats) { + return ( +
+

No hay datos disponibles para mostrar.

+
+ ); + } + return ( +
+
+

Dashboard de Inventario

+
+
+ {/* Fila 1, Columna 1 */} +
+ +
+ + {/* Fila 1, Columna 2 */} +
+ +
+ + {/* Fila 2, Columna 1 */} +
+ +
+ + {/* Fila 2, Columna 2 */} +
+ +
+
+
+ ); +}; + +export default Dashboard; \ No newline at end of file diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 1c7a7ec..510c27c 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -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; @@ -14,7 +13,7 @@ const Navbar: React.FC = ({ currentView, setCurrentView }) => {
Inventario IT
-