diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fbe32bd..8723c5c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tanstack/react-table": "^8.21.3", "chart.js": "^4.5.0", + "lucide-react": "^0.545.0", "react": "^19.1.1", "react-chartjs-2": "^5.3.0", "react-dom": "^19.1.1", @@ -2746,6 +2747,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.545.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.545.0.tgz", + "integrity": "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index fd266b5..c6e137b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "dependencies": { "@tanstack/react-table": "^8.21.3", "chart.js": "^4.5.0", + "lucide-react": "^0.545.0", "react": "^19.1.1", "react-chartjs-2": "^5.3.0", "react-dom": "^19.1.1", diff --git a/frontend/src/App.css b/frontend/src/App.css index 79b48e7..b5e7447 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -4,14 +4,14 @@ main { margin: 0 auto; } -/* Estilos para la nueva Barra de Navegación */ .navbar { - background-color: #343a40; /* Un color oscuro para el fondo */ + background-color: var(--color-navbar-bg); padding: 0 2rem; box-shadow: 0 2px 4px rgba(0,0,0,0.1); display: flex; justify-content: space-between; align-items: center; + border-bottom: 1px solid var(--color-border); /* Borde sutil */ } .nav-links { @@ -21,27 +21,27 @@ main { .nav-link { background: none; border: none; - color: #adb5bd; /* Color de texto gris claro */ + color: var(--color-navbar-text); padding: 1rem 1.5rem; cursor: pointer; font-size: 1rem; font-weight: 500; text-decoration: none; - transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out; - border-bottom: 3px solid transparent; /* Borde inferior para el indicador activo */ + transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out, border-color 0.2s ease-in-out; + border-bottom: 3px solid transparent; } .nav-link:hover { - color: #ffffff; /* Texto blanco al pasar el ratón */ + color: var(--color-navbar-text-hover); } .nav-link-active { - color: #ffffff; - border-bottom: 3px solid #007bff; /* Indicador azul para la vista activa */ + color: var(--color-navbar-text-hover); + border-bottom: 3px solid var(--color-primary); } .app-title { font-size: 1.5rem; - color: #ffffff; + color: var(--color-navbar-text-hover); font-weight: bold; } \ No newline at end of file diff --git a/frontend/src/components/Dashboard.module.css b/frontend/src/components/Dashboard.module.css index d0bd48e..aad93d0 100644 --- a/frontend/src/components/Dashboard.module.css +++ b/frontend/src/components/Dashboard.module.css @@ -1,6 +1,6 @@ .dashboardHeader { margin-bottom: 2rem; - border-bottom: 1px solid #dee2e6; + border-bottom: 1px solid var(--color-border); padding-bottom: 1rem; } @@ -8,7 +8,7 @@ margin: 0; font-size: 2rem; font-weight: 300; - color: #343a40; + color: var(--color-text-primary); } .statsGrid { @@ -19,14 +19,14 @@ } .chartContainer { - background-color: #ffffff; - border: 1px solid #e9ecef; + background-color: var(--color-surface); + border: 1px solid var(--color-border-subtle); 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 */ + min-height: 450px; } /* Contenedor especial para los gráficos de barras horizontales con scroll */ @@ -37,6 +37,6 @@ @media (max-width: 1200px) { .statsGrid { - grid-template-columns: 1fr; /* Una sola columna en pantallas pequeñas */ + grid-template-columns: 1fr; } } \ No newline at end of file diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 84ffae7..bd00db7 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,3 +1,4 @@ +// frontend/src/components/Dashboard.tsx import { useState, useEffect } from 'react'; import toast from 'react-hot-toast'; import { dashboardService } from '../services/apiService'; @@ -5,8 +6,9 @@ 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 RamChart from './RamChart'; import styles from './Dashboard.module.css'; +import skeletonStyles from './Skeleton.module.css'; const Dashboard = () => { const [stats, setStats] = useState(null); @@ -24,13 +26,24 @@ const Dashboard = () => { setIsLoading(false); }); }, []); + if (isLoading) { return ( -
-

Cargando estadísticas...

+
+
+

Dashboard de Inventario

+
+
+ {/* Replicamos la estructura de la grilla con esqueletos */} +
+
+
+
+
); } + if (!stats) { return (
diff --git a/frontend/src/components/GestionComponentes.tsx b/frontend/src/components/GestionComponentes.tsx index 05bd7ea..09436ca 100644 --- a/frontend/src/components/GestionComponentes.tsx +++ b/frontend/src/components/GestionComponentes.tsx @@ -9,22 +9,22 @@ import { flexRender, type SortingState, } from '@tanstack/react-table'; +import { Pencil, Trash2 } from 'lucide-react'; import styles from './SimpleTable.module.css'; import { adminService } from '../services/apiService'; +import TableSkeleton from './TableSkeleton'; -// Interfaces para los diferentes tipos de datos +// Interfaces interface TextValue { valor: string; conteo: number; } - interface RamValue { fabricante?: string; tamano: number; velocidad?: number; conteo: number; } - type ComponentValue = TextValue | RamValue; const GestionComponentes = () => { @@ -34,8 +34,6 @@ const GestionComponentes = () => { const [isModalOpen, setIsModalOpen] = useState(false); const [valorAntiguo, setValorAntiguo] = useState(''); const [valorNuevo, setValorNuevo] = useState(''); - - // Estados para la tabla (filtrado y ordenamiento) const [globalFilter, setGlobalFilter] = useState(''); const [sorting, setSorting] = useState([]); @@ -71,23 +69,13 @@ const GestionComponentes = () => { }; const handleDeleteRam = useCallback(async (ramGroup: RamValue) => { - if (!window.confirm("¿Estás seguro de eliminar todas las entradas maestras para este tipo de RAM? Esta acción es irreversible.")) { - return; - } - + if (!window.confirm("¿Estás seguro de eliminar todas las entradas maestras para este tipo de RAM? Esta acción es irreversible.")) return; const toastId = toast.loading('Eliminando grupo de módulos...'); try { - await adminService.deleteRamComponent({ - fabricante: ramGroup.fabricante, - tamano: ramGroup.tamano, - velocidad: ramGroup.velocidad - }); - + await adminService.deleteRamComponent({ fabricante: ramGroup.fabricante, tamano: ramGroup.tamano, velocidad: ramGroup.velocidad }); setValores(prev => prev.filter(v => { const currentRam = v as RamValue; - return !(currentRam.fabricante === ramGroup.fabricante && - currentRam.tamano === ramGroup.tamano && - currentRam.velocidad === ramGroup.velocidad); + return !(currentRam.fabricante === ramGroup.fabricante && currentRam.tamano === ramGroup.tamano && currentRam.velocidad === ramGroup.velocidad); })); toast.success("Grupo de módulos de RAM eliminado.", { id: toastId }); } catch (error) { @@ -96,9 +84,7 @@ const GestionComponentes = () => { }, []); const handleDeleteTexto = useCallback(async (valor: string) => { - if (!window.confirm(`Este valor ya no está en uso. ¿Quieres eliminarlo de la base de datos maestra?`)) { - return; - } + if (!window.confirm(`Este valor ya no está en uso. ¿Quieres eliminarlo de la base de datos maestra?`)) return; const toastId = toast.loading('Eliminando valor...'); try { await adminService.deleteTextComponent(componentType, valor); @@ -130,49 +116,43 @@ const GestionComponentes = () => { { header: 'Acciones', id: 'acciones', - cell: ({ row }: { row: { original: ComponentValue } }) => { - const item = row.original; - return ( -
- {componentType === 'ram' ? ( - + ) : ( + <> + - ) : ( - <> - - - - )} -
- ); - } + + + )} +
+ ) } ], [componentType, renderValor, handleDeleteRam, handleDeleteTexto, handleOpenModal]); const table = useReactTable({ data: valores, columns, - state: { - sorting, - globalFilter, - }, + state: { sorting, globalFilter }, onSortingChange: setSorting, onGlobalFilterChange: setGlobalFilter, getCoreRowModel: getCoreRowModel(), @@ -180,6 +160,19 @@ const GestionComponentes = () => { getFilteredRowModel: getFilteredRowModel(), }); + // CORRECCIÓN: Añadimos el tipo React.CSSProperties + const tableContainerStyle: React.CSSProperties = { + overflowX: 'auto', + border: '1px solid #dee2e6', + borderRadius: '8px', + marginTop: '1rem' + }; + + // CORRECCIÓN: Añadimos el tipo React.CSSProperties + const tableStyle: React.CSSProperties = { + minWidth: 'auto' + }; + return (

Gestión de Componentes Maestros ({table.getFilteredRowModel().rows.length})

@@ -206,34 +199,53 @@ const GestionComponentes = () => {
{isLoading ? ( -

Cargando...

+
+ +
) : ( -
- +
+
{table.getHeaderGroups().map(headerGroup => ( - {headerGroup.headers.map(header => ( - - ))} + {headerGroup.headers.map(header => { + const classNames = [styles.th]; + if (header.id === 'conteo') classNames.push(styles.thNumeric); + if (header.id === 'acciones') classNames.push(styles.thActions); + return ( + + ) + })} ))} {table.getRowModel().rows.map(row => ( - {row.getVisibleCells().map(cell => ( - - ))} + {row.getVisibleCells().map(cell => { + const classNames = [styles.td]; + if (cell.column.id === 'conteo') classNames.push(styles.tdNumeric); + if (cell.column.id === 'acciones') classNames.push(styles.tdActions); + return ( + + ) + })} ))} diff --git a/frontend/src/components/GestionSectores.tsx b/frontend/src/components/GestionSectores.tsx index e958737..4c62e2d 100644 --- a/frontend/src/components/GestionSectores.tsx +++ b/frontend/src/components/GestionSectores.tsx @@ -6,6 +6,7 @@ import type { Sector } from '../types/interfaces'; import styles from './SimpleTable.module.css'; import ModalSector from './ModalSector'; import { sectorService } from '../services/apiService'; +import { PlusCircle, Pencil, Trash2 } from 'lucide-react'; const GestionSectores = () => { const [sectores, setSectores] = useState([]); @@ -80,8 +81,8 @@ const GestionSectores = () => {

Gestión de Sectores

-
- {flexRender(header.column.columnDef.header, header.getContext())} - {header.column.getIsSorted() && ( - - {header.column.getIsSorted() === 'asc' ? '⇑' : '⇓'} - - )} - + {flexRender(header.column.columnDef.header, header.getContext())} + {header.column.getIsSorted() && ( + + {header.column.getIsSorted() === 'asc' ? '⇑' : '⇓'} + + )} +
- {flexRender(cell.column.columnDef.cell, cell.getContext())} - + {flexRender(cell.column.columnDef.cell, cell.getContext())} +
@@ -97,9 +98,9 @@ const GestionSectores = () => { diff --git a/frontend/src/components/ModalDetallesEquipo.tsx b/frontend/src/components/ModalDetallesEquipo.tsx index 95448b0..1617eb8 100644 --- a/frontend/src/components/ModalDetallesEquipo.tsx +++ b/frontend/src/components/ModalDetallesEquipo.tsx @@ -6,6 +6,7 @@ import styles from './SimpleTable.module.css'; import toast from 'react-hot-toast'; import AutocompleteInput from './AutocompleteInput'; import { equipoService } from '../services/apiService'; +import { X, Pencil, HardDrive, MemoryStick, UserPlus, Trash2, Power, Info, Component, Keyboard, Cog, Zap, History } from 'lucide-react'; interface ModalDetallesEquipoProps { equipo: Equipo; @@ -81,7 +82,6 @@ const ModalDetallesEquipo: React.FC = ({ }; const handleWolClick = async () => { - // La validación ahora es redundante por el 'disabled', pero la dejamos como buena práctica if (!equipo.mac || !equipo.ip) { toast.error("Este equipo no tiene MAC o IP para encenderlo."); return; @@ -118,7 +118,9 @@ const ModalDetallesEquipo: React.FC = ({ return (
- +
@@ -131,7 +133,7 @@ const ModalDetallesEquipo: React.FC = ({ ) : ( - + )}
)} @@ -141,12 +143,12 @@ const ModalDetallesEquipo: React.FC = ({
-

🔗 Datos Principales

+

Datos Principales

{equipo.origen === 'manual' && (
- - - + + +
)}
@@ -158,12 +160,12 @@ const ModalDetallesEquipo: React.FC = ({
Sector:{isEditing ? : {equipo.sector?.nombre || 'No asignado'}}
Creación:{formatDate(equipo.created_at)}
Última Actualización:{formatDate(equipo.updated_at)}
-
Usuarios:{equipo.usuarios?.length > 0 ? equipo.usuarios.map(u => (
{u.origen === 'manual' ? '⌨️' : '⚙️'}{` ${u.username}`}
{u.origen === 'manual' && ()}
)) : 'N/A'}
+
Usuarios:{equipo.usuarios?.length > 0 ? equipo.usuarios.map(u => (
{u.origen === 'manual' ? : }{` ${u.username}`}
{u.origen === 'manual' && ()}
)) : 'N/A'}
-

💻 Componentes

+

Componentes

Motherboard:{isEditing ? : {equipo.motherboard || 'N/A'}}
CPU:{isEditing ? : {equipo.cpu || 'N/A'}}
@@ -185,7 +187,7 @@ const ModalDetallesEquipo: React.FC = ({ {equipo.architecture || 'N/A'} )}
-
Discos:{equipo.discos?.length > 0 ? equipo.discos.map(d => (
{d.origen === 'manual' ? '⌨️' : '⚙️'}{` ${d.mediatype} ${d.size}GB`}
{d.origen === 'manual' && ()}
)) : 'N/A'}
+
Discos:{equipo.discos?.length > 0 ? equipo.discos.map(d => (
{d.origen === 'manual' ? : }{` ${d.mediatype} ${d.size}GB`}
{d.origen === 'manual' && ()}
)) : 'N/A'}
Total Slots RAM: {isEditing ? ( @@ -201,14 +203,14 @@ const ModalDetallesEquipo: React.FC = ({ {equipo.ram_slots || 'N/A'} )}
-
Módulos RAM:{equipo.memoriasRam?.length > 0 ? equipo.memoriasRam.map(m => (
{m.origen === 'manual' ? '⌨️' : '⚙️'}{` Slot ${m.slot}: ${m.partNumber || 'N/P'} ${m.tamano}GB ${m.velocidad || ''}MHz`}
{m.origen === 'manual' && ()}
)) : 'N/A'}
+
Módulos RAM:{equipo.memoriasRam?.length > 0 ? equipo.memoriasRam.map(m => (
{m.origen === 'manual' ? : }{` Slot ${m.slot}: ${m.partNumber || 'N/P'} ${m.tamano}GB ${m.velocidad || ''}MHz`}
{m.origen === 'manual' && ()}
)) : 'N/A'}
-

⚡ Acciones y Estado

+

Acciones y Estado

Estado:
{isOnline ? 'En línea' : 'Sin conexión'}
@@ -220,7 +222,7 @@ const ModalDetallesEquipo: React.FC = ({ data-tooltip-id="modal-power-tooltip" disabled={!equipo.mac} > - Encender equipo + Encender (WOL) @@ -235,7 +237,7 @@ const ModalDetallesEquipo: React.FC = ({ className={styles.deleteButton} data-tooltip-id="modal-delete-tooltip" > - 🗑️ Eliminar + Eliminar Eliminar este equipo permanentemente del inventario @@ -247,7 +249,7 @@ const ModalDetallesEquipo: React.FC = ({
-

📜 Historial de cambios

+

Historial de cambios

{sector.nombre}
- - +
diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 510c27c..a9bc8c9 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -1,5 +1,7 @@ +// frontend/src/components/Navbar.tsx import React from 'react'; import type { View } from '../App'; +import ThemeToggle from './ThemeToggle'; import '../App.css'; interface NavbarProps { @@ -38,7 +40,11 @@ const Navbar: React.FC = ({ currentView, setCurrentView }) => { > Dashboard +
+ +
+ ); }; diff --git a/frontend/src/components/SimpleTable.module.css b/frontend/src/components/SimpleTable.module.css index de4fc1c..e2b62c2 100644 --- a/frontend/src/components/SimpleTable.module.css +++ b/frontend/src/components/SimpleTable.module.css @@ -9,33 +9,37 @@ .searchInput, .sectorSelect { padding: 8px 12px; border-radius: 6px; - border: 1px solid #ced4da; + border: 1px solid var(--color-border); font-size: 14px; + background-color: var(--color-surface); + color: var(--color-text-primary); } -/* Estilos de la tabla */ .table { border-collapse: collapse; font-family: system-ui, -apple-system, sans-serif; font-size: 0.875rem; box-shadow: 0 1px 3px rgba(0,0,0,0.08); width: 100%; - min-width: 1200px; /* Ancho mínimo para forzar el scroll horizontal si es necesario */ + min-width: 1200px; + table-layout: fixed; } .th { - color: #212529; + color: var(--color-text-primary); font-weight: 600; padding: 0.75rem 1rem; - border-bottom: 2px solid #dee2e6; + border-bottom: 2px solid var(--color-border); text-align: left; cursor: pointer; user-select: none; white-space: nowrap; position: sticky; - top: 0; /* Mantiene la posición sticky en la parte superior del viewport */ + top: 0; z-index: 2; - background-color: #f8f9fa; /* Es crucial tener un fondo sólido */ + background-color: var(--color-background); + overflow: hidden; + text-overflow: ellipsis; } .sortIndicator { @@ -43,11 +47,11 @@ font-size: 1.2em; display: inline-block; transform: translateY(-1px); - color: #007bff; + color: var(--color-primary); min-width: 20px; } -.tooltip{ +.tooltip { z-index: 9999; } @@ -56,70 +60,105 @@ } .tr:hover { - background-color: #f1f3f5; + background-color: var(--color-surface-hover); } .td { padding: 0.75rem 1rem; - border-bottom: 1px solid #e9ecef; - color: #495057; - background-color: white; + border-bottom: 1px solid var(--color-border-subtle); + color: var(--color-text-secondary); + background-color: var(--color-surface); + word-break: break-word; } +/* NUEVA SECCIÓN PARA ANCHOS DE COLUMNA */ + +/* Columna numérica (Nº de Equipos) */ +.thNumeric, +.tdNumeric { + width: 150px; + text-align: left; +} + +.thActions, +.tdActions { + width: 220px; + white-space: nowrap; +} + +/* NUEVO: Columnas de Tabla Principal (SimpleTable) */ +.thIp, .tdIp { width: 90px; } +.thRam, .tdRam { width: 40px; text-align: center; } +.thArch, .tdArch { width: 80px; } +.thUsers, .tdUsers { width: 230px; } +.thSector, .tdSector { width: 170px; } + /* Estilos de botones dentro de la tabla */ .hostnameButton { background: none; border: none; - color: #007bff; + color: var(--color-primary); cursor: pointer; text-decoration: underline; padding: 0; font-size: inherit; font-family: inherit; + transition: color 0.2s ease; } +.hostnameButton:hover { + color: var(--color-primary-hover); +} + + .tableButton { padding: 0.375rem 0.75rem; border-radius: 4px; - border: 1px solid #dee2e6; + border: 1px solid var(--color-border); background-color: transparent; - color: #212529; + color: var(--color-text-primary); cursor: pointer; transition: all 0.2s ease; } + .tableButton:hover { - background-color: #e9ecef; - border-color: #adb5bd; + background-color: var(--color-surface-hover); + border-color: var(--color-text-muted); } .tableButtonMas { padding: 0.375rem 0.75rem; border-radius: 4px; - border: 1px solid #007bff; - background-color: #007bff; - color: #ffffff; + border: 1px solid var(--color-primary); + background-color: var(--color-primary); + color: var(--color-navbar-text-hover); cursor: pointer; transition: all 0.2s ease; } + .tableButtonMas:hover { - background-color: #0056b3; - border-color: #0056b3; + background-color: var(--color-primary-hover); + border-color: var(--color-primary-hover); } .deleteUserButton { background: none; border: none; cursor: pointer; - color: #dc3545; + color: var(--color-danger); font-size: 1rem; padding: 0 5px; - opacity: 0.7; - transition: opacity 0.3s ease, color 0.3s ease; + transition: opacity 0.3s ease, color 0.3s ease, background-color 0.3s ease; line-height: 1; } -.deleteUserButton:hover { - opacity: 1; - color: #a4202e; + +.deleteUserButton:hover:not(:disabled) { + color: var(--color-danger-hover); +} + +.deleteUserButton:disabled { + opacity: 0.5; + cursor: not-allowed; } /* Estilo para el botón de scroll-to-top */ @@ -130,21 +169,46 @@ width: 40px; height: 40px; border-radius: 50%; - background-color: #007bff; + background-color: var(--color-primary); color: white; border: none; cursor: pointer; - box-shadow: 0 2px 5px rgba(0,0,0,0.2); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); font-size: 20px; display: flex; align-items: center; justify-content: center; - transition: opacity 0.3s, transform 0.3s; + transition: opacity 0.3s, transform 0.3s, background-color 0.3s; z-index: 1002; } + .scrollToTop:hover { transform: translateY(-3px); - background-color: #0056b3; + background-color: var(--color-primary-hover); +} + +/* ===== INICIO DE CAMBIOS PARA MODALES Y ANIMACIONES ===== */ + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.95); + } + + to { + opacity: 1; + transform: scale(1); + } } /* Estilos genéricos para modales */ @@ -159,23 +223,27 @@ display: flex; align-items: center; justify-content: center; + animation: fadeIn 0.2s ease-out; + /* Aplicamos animación */ } .modal { - background-color: #ffffff; + background-color: var(--color-surface); border-radius: 12px; padding: 2rem; box-shadow: 0px 8px 30px rgba(0, 0, 0, 0.12); z-index: 1000; min-width: 400px; max-width: 90%; - border: 1px solid #e0e0e0; + border: 1px solid var(--color-border); font-family: 'Segoe UI', sans-serif; + animation: scaleIn 0.2s ease-out; + /* Aplicamos animación */ } .modal h3 { margin: 0 0 1.5rem; - color: #2d3436; + color: var(--color-text-primary); } .modal label { @@ -187,18 +255,32 @@ .modalInput { padding: 10px; border-radius: 6px; - border: 1px solid #ced4da; + border: 1px solid var(--color-border); + background-color: var(--color-background); /* Ligeramente diferente para contraste */ + color: var(--color-text-primary); width: 100%; box-sizing: border-box; - margin-top: 4px; /* Separado del label */ - margin-bottom: 4px; /* Espacio antes del siguiente elemento */ + margin-top: 4px; + /* Separado del label */ + margin-bottom: 4px; + /* Espacio antes del siguiente elemento */ + transition: border-color 0.2s ease, box-shadow 0.2s ease; + /* Transición para el foco */ } +.modalInput:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 25%, transparent); +} + + .modalActions { display: flex; gap: 10px; margin-top: 1.5rem; - justify-content: flex-end; /* Alinea los botones a la derecha por defecto */ + justify-content: flex-end; + /* Alinea los botones a la derecha por defecto */ } /* Estilos de botones para modales */ @@ -213,24 +295,24 @@ } .btnPrimary { - background-color: #007bff; + background-color: var(--color-primary); color: white; } .btnPrimary:hover { - background-color: #0056b3; + background-color: var(--color-primary-hover); } .btnPrimary:disabled { - background-color: #e9ecef; - color: #6c757d; + background-color: var(--color-surface-hover); + color: var(--color-text-muted); cursor: not-allowed; } .btnSecondary { - background-color: #6c757d; + background-color: var(--color-text-muted); color: white; } .btnSecondary:hover { - background-color: #5a6268; + background-color: var(--color-text-secondary); } /* ===== NUEVOS ESTILOS PARA EL MODAL DE DETALLES ===== */ @@ -241,19 +323,24 @@ left: 0; width: 100vw; height: 100vh; - background-color: #f8f9fa; /* Un fondo ligeramente gris para el modal */ + background-color: var(--color-background); + /* Un fondo ligeramente gris para el modal */ z-index: 1003; overflow-y: auto; display: flex; flex-direction: column; padding: 2rem; box-sizing: border-box; + animation: fadeIn 0.3s ease-out; + /* Animación ligeramente más lenta para pantalla completa */ } .modalLargeContent { - max-width: 1400px; /* Ancho máximo del contenido */ + max-width: 1400px; + /* Ancho máximo del contenido */ width: 100%; - margin: 0 auto; /* Centrar el contenido */ + margin: 0 auto; + /* Centrar el contenido */ } .modalLargeHeader { @@ -267,7 +354,7 @@ .modalLargeHeader h2 { font-weight: 400; font-size: 1.5rem; - color: #343a40; + color: var(--color-text-primary); } .closeButton { @@ -283,12 +370,13 @@ align-items: center; justify-content: center; z-index: 1004; - box-shadow: 0 2px 5px rgba(0,0,0,0.2); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); transition: transform 0.2s, background-color 0.2s; position: fixed; right: 30px; top: 30px; } + .closeButton:hover { transform: scale(1.1); background-color: #333; @@ -314,19 +402,19 @@ } .section { - background-color: #ffffff; - border: 1px solid #dee2e6; + background-color: var(--color-surface); + border: 1px solid var(--color-border); border-radius: 8px; padding: 1.5rem; - box-shadow: 0 2px 4px rgba(0,0,0,0.05); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); } .sectionTitle { font-size: 1.25rem; margin: 0 0 1rem 0; padding-bottom: 0.75rem; - border-bottom: 1px solid #e9ecef; - color: #2d3436; + border-bottom: 1px solid var(--color-border-subtle); + color: var(--color-text-primary); font-weight: 600; display: flex; align-items: center; @@ -339,7 +427,6 @@ gap: 1rem; } -/* CAMBIO: Se aplica el mismo estilo de grid a componentsGrid para que se vea igual que detailsGrid */ .componentsGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); @@ -352,24 +439,25 @@ gap: 1rem; } -.detailItem, .detailItemFull { +.detailItem, +.detailItemFull { display: flex; flex-direction: column; gap: 0.25rem; padding: 10px; - background-color: #f8f9fa; border-radius: 4px; - border: 1px solid #e9ecef; + background-color: var(--color-background); + border: 1px solid var(--color-border-subtle); } .detailLabel { - color: #6c757d; + color: var(--color-text-muted); font-size: 0.8rem; font-weight: 700; } .detailValue { - color: #495057; + color: var(--color-text-secondary); font-size: 0.9rem; line-height: 1.4; word-break: break-word; @@ -383,9 +471,10 @@ padding: 2px 0; } -.powerButton, .deleteButton { +.powerButton, +.deleteButton { background: none; - border: 1px solid #dee2e6; + border: 1px solid var(--color-border); border-radius: 4px; padding: 8px; cursor: pointer; @@ -398,9 +487,9 @@ } .powerButton:hover { - border-color: #007bff; - background-color: #e7f1ff; - color: #0056b3; + border-color: var(--color-primary); + background-color: color-mix(in srgb, var(--color-primary) 15%, transparent); + color: var(--color-primary-hover); } .powerIcon { @@ -409,13 +498,16 @@ } .deleteButton { - color: #dc3545; + color: var(--color-danger); + transition: all 0.2s ease; } + .deleteButton:hover { - border-color: #dc3545; - background-color: #fbebee; - color: #a4202e; + border-color: var(--color-danger); + background-color: var(--color-danger-background); + color: var(--color-danger-hover); } + .deleteButton:disabled { color: #6c757d; background-color: #e9ecef; @@ -425,7 +517,7 @@ .historyContainer { max-height: 400px; overflow-y: auto; - border: 1px solid #dee2e6; + border: 1px solid var(--color-border); border-radius: 4px; } @@ -435,7 +527,7 @@ } .historyTh { - background-color: #f8f9fa; + background-color: var(--color-background); padding: 12px; text-align: left; font-size: 0.875rem; @@ -445,16 +537,15 @@ .historyTd { padding: 12px; - color: #495057; font-size: 0.8125rem; - border-bottom: 1px solid #dee2e6; + color: var(--color-text-secondary); + border-bottom: 1px solid var(--color-border-subtle); } .historyTr:last-child .historyTd { border-bottom: none; } -/* CAMBIO: Nueva clase para dar espacio a la sección de historial */ .historySectionFullWidth { margin-top: 2rem; } @@ -493,30 +584,66 @@ margin-top: 4px; } -/* Clases para la sección de usuarios y claves - No se usan en el nuevo modal pero se mantienen por si acaso */ -.userList { min-width: 240px; } -.userItem { display: flex; align-items: center; justify-content: space-between; margin: 4px 0; padding: 6px; background-color: #f8f9fa; border-radius: 4px; position: relative; } -.userInfo { color: #495057; } -.userActions { display: flex; gap: 4px; align-items: center; } -.sectorContainer { display: flex; justify-content: space-between; align-items: center; width: 100%; gap: 0.5rem; } -.sectorName { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.sectorNameAssigned { color: #212529; font-style: normal; } -.sectorNameUnassigned { color: #6c757d; font-style: italic; } - -/* Estilo para el overlay de un modal anidado */ -.modalOverlay--nested { - /* z-index superior al del botón de cierre del modal principal (1004) */ - z-index: 1005; +.userList { + min-width: 240px; +} + +.userItem { + display: flex; + align-items: center; + justify-content: space-between; + margin: 4px 0; + padding: 6px; + background-color: var(--color-background); + border-radius: 4px; + position: relative; +} + +.userInfo { + color: var(--color-text-secondary); +} + +.userActions { + display: flex; + gap: 4px; + align-items: center; +} + +.sectorContainer { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + gap: 0.5rem; +} + +.sectorName { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sectorNameAssigned { + color: var(--color-text-secondary); + font-style: normal; +} + +.sectorNameUnassigned { + color: var(--color-text-muted); + font-style: italic; +} + +.modalOverlay--nested { + z-index: 1005; } -/* También nos aseguramos de que el contenido del modal anidado tenga un z-index superior */ .modalOverlay--nested .modal { z-index: 1006; } -/* Estilo para deshabilitar el botón de cierre del modal principal */ .closeButton:disabled { cursor: not-allowed; opacity: 0.5; - background-color: #6c757d; /* Gris para indicar inactividad */ + background-color: #6c757d; } \ No newline at end of file diff --git a/frontend/src/components/SimpleTable.tsx b/frontend/src/components/SimpleTable.tsx index 43e5f00..a0924b0 100644 --- a/frontend/src/components/SimpleTable.tsx +++ b/frontend/src/components/SimpleTable.tsx @@ -9,8 +9,10 @@ import { Tooltip } from 'react-tooltip'; import toast from 'react-hot-toast'; import type { Equipo, Sector, Usuario, UsuarioEquipoDetalle } from '../types/interfaces'; import styles from './SimpleTable.module.css'; +import skeletonStyles from './Skeleton.module.css'; // Importamos el estilo del esqueleto import { equipoService, sectorService, usuarioService } from '../services/apiService'; +import { PlusCircle, KeyRound, UserX, Pencil, ArrowUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; import ModalAnadirEquipo from './ModalAnadirEquipo'; import ModalEditarSector from './ModalEditarSector'; @@ -19,6 +21,7 @@ import ModalDetallesEquipo from './ModalDetallesEquipo'; import ModalAnadirDisco from './ModalAnadirDisco'; import ModalAnadirRam from './ModalAnadirRam'; import ModalAnadirUsuario from './ModalAnadirUsuario'; +import TableSkeleton from './TableSkeleton'; // Importamos el componente de esqueleto const SimpleTable = () => { const [data, setData] = useState([]); @@ -317,23 +320,23 @@ const SimpleTable = () => { } }; - // --- DEFINICIÓN DE COLUMNAS CORREGIDA --- const columns: ColumnDef[] = [ { header: "ID", accessorKey: "id", enableHiding: true }, { header: "Nombre", accessorKey: "hostname", cell: (info: CellContext) => () }, - { header: "IP", accessorKey: "ip" }, + { header: "IP", accessorKey: "ip", id: 'ip' }, { header: "MAC", accessorKey: "mac", enableHiding: true }, { header: "Motherboard", accessorKey: "motherboard" }, { header: "CPU", accessorKey: "cpu" }, - { header: "RAM", accessorKey: "ram_installed" }, + { header: "RAM", accessorKey: "ram_installed", id: 'ram' }, { header: "Discos", accessorFn: (row: Equipo) => row.discos?.map(d => `${d.mediatype} ${d.size}GB`).join(" | ") || "Sin discos" }, { header: "OS", accessorKey: "os" }, - { header: "Arquitectura", accessorKey: "architecture" }, + { header: "Arquitectura", accessorKey: "architecture", id: 'arch' }, { header: "Usuarios y Claves", + id: 'usuarios', cell: (info: CellContext) => { const { row } = info; const usuarios = row.original.usuarios || []; @@ -351,7 +354,7 @@ const SimpleTable = () => { className={styles.tableButton} data-tooltip-id={`edit-${u.id}`} > - ✏️ + Cambiar Clave @@ -360,7 +363,7 @@ const SimpleTable = () => { className={styles.deleteUserButton} data-tooltip-id={`remove-${u.id}`} > - 🗑️ + Eliminar Usuario @@ -378,7 +381,7 @@ const SimpleTable = () => { return (
{sector?.nombre || 'Asignar'} - +
); } @@ -410,8 +413,18 @@ const SimpleTable = () => { if (isLoading) { return ( -
-

Cargando Equipos...

+
+
+

Equipos (...)

+
+
+
+
+
+
+

** La tabla permite ordenar por múltiples columnas manteniendo shift al hacer click en la cabecera. **

+
+
); } @@ -420,16 +433,16 @@ const SimpleTable = () => {
@@ -474,8 +487,9 @@ const SimpleTable = () => {
@@ -495,23 +509,41 @@ const SimpleTable = () => {
{table.getHeaderGroups().map(hg => ( - {hg.headers.map(h => ( - - ))} + {hg.headers.map(h => { + const classNames = [styles.th]; + if (h.id === 'ip') classNames.push(styles.thIp); + if (h.id === 'ram') classNames.push(styles.thRam); + if (h.id === 'arch') classNames.push(styles.thArch); + if (h.id === 'usuarios') classNames.push(styles.thUsers); + if (h.id === 'sector') classNames.push(styles.thSector); + + return ( + + ); + })} ))} {table.getRowModel().rows.map(row => ( - {row.getVisibleCells().map(cell => ( - - ))} + {row.getVisibleCells().map(cell => { + const classNames = [styles.td]; + if (cell.column.id === 'ip') classNames.push(styles.tdIp); + if (cell.column.id === 'ram') classNames.push(styles.tdRam); + if (cell.column.id === 'arch') classNames.push(styles.tdArch); + if (cell.column.id === 'usuarios') classNames.push(styles.tdUsers); + if (cell.column.id === 'sector') classNames.push(styles.tdSector); + + return ( + + ); + })} ))} @@ -520,7 +552,7 @@ const SimpleTable = () => { {PaginacionControles} - {showScrollButton && ()} + {showScrollButton && ()} {modalData && setModalData(null)} onSave={handleSave} />} diff --git a/frontend/src/components/Skeleton.module.css b/frontend/src/components/Skeleton.module.css new file mode 100644 index 0000000..3221b41 --- /dev/null +++ b/frontend/src/components/Skeleton.module.css @@ -0,0 +1,33 @@ +/* frontend/src/components/Skeleton.module.css */ + +.skeleton { + background-color: #e0e0e0; + border-radius: 4px; + position: relative; + overflow: hidden; +} + +.skeleton::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.4), + transparent + ); + animation: shimmer 1.5s infinite; +} + +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} \ No newline at end of file diff --git a/frontend/src/components/TableSkeleton.tsx b/frontend/src/components/TableSkeleton.tsx new file mode 100644 index 0000000..03d27ee --- /dev/null +++ b/frontend/src/components/TableSkeleton.tsx @@ -0,0 +1,49 @@ +// frontend/src/components/TableSkeleton.tsx + +import React from 'react'; +import styles from './SimpleTable.module.css'; +import skeletonStyles from './Skeleton.module.css'; + +interface SkeletonProps { + style?: React.CSSProperties; +} + +const Skeleton: React.FC = ({ style }) => { + return
; +}; + +interface TableSkeletonProps { + rows?: number; + columns?: number; +} + +const TableSkeleton: React.FC = ({ rows = 10, columns = 8 }) => { + return ( +
+
FechaCampoValor anteriorValor nuevo
- {flexRender(h.column.columnDef.header, h.getContext())} - {h.column.getIsSorted() && ({h.column.getIsSorted() === 'asc' ? '⇑' : '⇓'})} - + {flexRender(h.column.columnDef.header, h.getContext())} + {h.column.getIsSorted() && ({h.column.getIsSorted() === 'asc' ? '⇑' : '⇓'})} +
- {flexRender(cell.column.columnDef.cell, cell.getContext())} - + {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ + + {Array.from({ length: columns }).map((_, i) => ( + + ))} + + + + {Array.from({ length: rows }).map((_, i) => ( + + {Array.from({ length: columns }).map((_, j) => ( + + ))} + + ))} + +
+ +
+ +
+
+ ); +}; + +export default TableSkeleton; \ No newline at end of file diff --git a/frontend/src/components/ThemeToggle.css b/frontend/src/components/ThemeToggle.css new file mode 100644 index 0000000..c71414f --- /dev/null +++ b/frontend/src/components/ThemeToggle.css @@ -0,0 +1,20 @@ +/* frontend/src/components/ThemeToggle.css */ +.theme-toggle-button { + background-color: transparent; + border: 1px solid var(--color-text-muted); + color: var(--color-text-muted); + border-radius: 50%; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; +} + +.theme-toggle-button:hover { + color: var(--color-primary); + border-color: var(--color-primary); + transform: rotate(15deg); +} \ No newline at end of file diff --git a/frontend/src/components/ThemeToggle.tsx b/frontend/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..1effb7e --- /dev/null +++ b/frontend/src/components/ThemeToggle.tsx @@ -0,0 +1,20 @@ +// frontend/src/components/ThemeToggle.tsx +import { Sun, Moon } from 'lucide-react'; +import { useTheme } from '../context/ThemeContext'; +import './ThemeToggle.css'; + +const ThemeToggle = () => { + const { theme, toggleTheme } = useTheme(); + + return ( + + ); +}; + +export default ThemeToggle; \ No newline at end of file diff --git a/frontend/src/context/ThemeContext.tsx b/frontend/src/context/ThemeContext.tsx new file mode 100644 index 0000000..530dca9 --- /dev/null +++ b/frontend/src/context/ThemeContext.tsx @@ -0,0 +1,58 @@ +// frontend/src/context/ThemeContext.tsx + +import React, { createContext, useState, useEffect, useContext, useMemo } from 'react'; + +type Theme = 'light' | 'dark'; + +interface ThemeContextType { + theme: Theme; + toggleTheme: () => void; +} + +// Creamos el contexto con un valor por defecto. +const ThemeContext = createContext(undefined); + +// Creamos el proveedor del contexto. +export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [theme, setTheme] = useState(() => { + // 1. Intentamos leer el tema desde localStorage. + const savedTheme = localStorage.getItem('theme') as Theme | null; + if (savedTheme) return savedTheme; + + // 2. Si no hay nada, respetamos la preferencia del sistema operativo. + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark'; + } + + // 3. Como última opción, usamos el tema claro por defecto. + return 'light'; + }); + + useEffect(() => { + // Aplicamos el tema al body y lo guardamos en localStorage cada vez que cambia. + document.body.dataset.theme = theme; + localStorage.setItem('theme', theme); + }, [theme]); + + const toggleTheme = () => { + setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light')); + }; + + // Usamos useMemo para evitar que el valor del contexto se recalcule en cada render. + const value = useMemo(() => ({ theme, toggleTheme }), [theme]); + + return ( + + {children} + + ); +}; + +// Hook personalizado para usar el contexto de forma sencilla. +export const useTheme = (): ThemeContextType => { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme debe ser utilizado dentro de un ThemeProvider'); + } + return context; +}; \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index ea55645..37c9734 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,23 +1,77 @@ -/* Limpieza básica y configuración de fuente */ +/* frontend/src/index.css */ + +/* 1. Definición de variables de color para el tema claro (por defecto) */ +:root { + --color-background: #f8f9fa; + --color-text-primary: #212529; + --color-text-secondary: #495057; + --color-text-muted: #6c757d; + + --color-surface: #ffffff; /* Para tarjetas, modales, etc. */ + --color-surface-hover: #f1f3f5; + --color-border: #dee2e6; + --color-border-subtle: #e9ecef; + + --color-primary: #007bff; + --color-primary-hover: #0056b3; + --color-danger: #dc3545; + --color-danger-hover: #a4202e; + --color-danger-background: #fbebee; + + --color-navbar-bg: #343a40; + --color-navbar-text: #adb5bd; + --color-navbar-text-hover: #ffffff; + + --scrollbar-bg: #f1f1f1; + --scrollbar-thumb: #888; +} + +/* 2. Sobrescribir variables para el tema oscuro */ +[data-theme='dark'] { + --color-background: #121212; + --color-text-primary: #e0e0e0; + --color-text-secondary: #b0b0b0; + --color-text-muted: #888; + + --color-surface: #1e1e1e; + --color-surface-hover: #2a2a2a; + --color-border: #333; + --color-border-subtle: #2c2c2c; + + --color-primary: #3a97ff; + --color-primary-hover: #63b0ff; + --color-danger: #ff5252; + --color-danger-hover: #ff8a80; + --color-danger-background: #4d2323; + + --color-navbar-bg: #1e1e1e; + --color-navbar-text: #888; + --color-navbar-text-hover: #ffffff; + + --scrollbar-bg: #2c2c2c; + --scrollbar-thumb: #555; +} + + +/* 3. Aplicar las variables a los estilos base */ body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; - background-color: #f8f9fa; - color: #212529; + background-color: var(--color-background); + color: var(--color-text-primary); + transition: background-color 0.2s ease, color 0.2s ease; /* Transición suave al cambiar de tema */ } -/* Estilos de la scrollbar que estaban en index.html */ body::-webkit-scrollbar { width: 8px; - background-color: #f1f1f1; + background-color: var(--scrollbar-bg); } body::-webkit-scrollbar-thumb { - background-color: #888; + background-color: var(--scrollbar-thumb); border-radius: 4px; } -/* Clase para bloquear el scroll cuando un modal está abierto */ body.scroll-lock { padding-right: 8px !important; overflow: hidden !important; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bd49d23..45bf160 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,28 +3,30 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App.tsx' import './index.css' -import { Toaster } from 'react-hot-toast' // Importamos el Toaster +import { Toaster } from 'react-hot-toast' +import { ThemeProvider } from './context/ThemeContext'; ReactDOM.createRoot(document.getElementById('root')!).render( - - + + + }} + /> + , ) \ No newline at end of file diff --git a/frontend/src/types/interfaces.ts b/frontend/src/types/interfaces.ts index 1af8e2f..04341c5 100644 --- a/frontend/src/types/interfaces.ts +++ b/frontend/src/types/interfaces.ts @@ -84,6 +84,11 @@ export interface Equipo { // --- Interfaces para el Dashboard --- +export interface EquipoFilter { + field: string; // 'os', 'cpu', 'sector', etc. + value: string; // El valor específico del filtro +} + export interface StatItem { label: string; count: number;