Feat Modo Oscuro y Otras Estéticas

This commit is contained in:
2025-10-09 17:03:53 -03:00
parent 5f72f30931
commit d9da1c82c9
19 changed files with 702 additions and 257 deletions

View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"lucide-react": "^0.545.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-chartjs-2": "^5.3.0", "react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
@@ -2746,6 +2747,15 @@
"yallist": "^3.0.2" "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": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",

View File

@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"lucide-react": "^0.545.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-chartjs-2": "^5.3.0", "react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",

View File

@@ -4,14 +4,14 @@ main {
margin: 0 auto; margin: 0 auto;
} }
/* Estilos para la nueva Barra de Navegación */
.navbar { .navbar {
background-color: #343a40; /* Un color oscuro para el fondo */ background-color: var(--color-navbar-bg);
padding: 0 2rem; padding: 0 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: 0 2px 4px rgba(0,0,0,0.1);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
border-bottom: 1px solid var(--color-border); /* Borde sutil */
} }
.nav-links { .nav-links {
@@ -21,27 +21,27 @@ main {
.nav-link { .nav-link {
background: none; background: none;
border: none; border: none;
color: #adb5bd; /* Color de texto gris claro */ color: var(--color-navbar-text);
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 500;
text-decoration: none; text-decoration: none;
transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out; 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; /* Borde inferior para el indicador activo */ border-bottom: 3px solid transparent;
} }
.nav-link:hover { .nav-link:hover {
color: #ffffff; /* Texto blanco al pasar el ratón */ color: var(--color-navbar-text-hover);
} }
.nav-link-active { .nav-link-active {
color: #ffffff; color: var(--color-navbar-text-hover);
border-bottom: 3px solid #007bff; /* Indicador azul para la vista activa */ border-bottom: 3px solid var(--color-primary);
} }
.app-title { .app-title {
font-size: 1.5rem; font-size: 1.5rem;
color: #ffffff; color: var(--color-navbar-text-hover);
font-weight: bold; font-weight: bold;
} }

View File

@@ -1,6 +1,6 @@
.dashboardHeader { .dashboardHeader {
margin-bottom: 2rem; margin-bottom: 2rem;
border-bottom: 1px solid #dee2e6; border-bottom: 1px solid var(--color-border);
padding-bottom: 1rem; padding-bottom: 1rem;
} }
@@ -8,7 +8,7 @@
margin: 0; margin: 0;
font-size: 2rem; font-size: 2rem;
font-weight: 300; font-weight: 300;
color: #343a40; color: var(--color-text-primary);
} }
.statsGrid { .statsGrid {
@@ -19,14 +19,14 @@
} }
.chartContainer { .chartContainer {
background-color: #ffffff; background-color: var(--color-surface);
border: 1px solid #e9ecef; border: 1px solid var(--color-border-subtle);
border-radius: 12px; border-radius: 12px;
padding: 1.5rem; padding: 1.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
display: flex; display: flex;
flex-direction: column; 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 */ /* Contenedor especial para los gráficos de barras horizontales con scroll */
@@ -37,6 +37,6 @@
@media (max-width: 1200px) { @media (max-width: 1200px) {
.statsGrid { .statsGrid {
grid-template-columns: 1fr; /* Una sola columna en pantallas pequeñas */ grid-template-columns: 1fr;
} }
} }

View File

@@ -1,3 +1,4 @@
// frontend/src/components/Dashboard.tsx
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { dashboardService } from '../services/apiService'; import { dashboardService } from '../services/apiService';
@@ -5,8 +6,9 @@ import type { DashboardStats } from '../types/interfaces';
import OsChart from './OsChart'; import OsChart from './OsChart';
import SectorChart from './SectorChart'; import SectorChart from './SectorChart';
import CpuChart from './CpuChart'; 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 styles from './Dashboard.module.css';
import skeletonStyles from './Skeleton.module.css';
const Dashboard = () => { const Dashboard = () => {
const [stats, setStats] = useState<DashboardStats | null>(null); const [stats, setStats] = useState<DashboardStats | null>(null);
@@ -24,13 +26,24 @@ const Dashboard = () => {
setIsLoading(false); setIsLoading(false);
}); });
}, []); }, []);
if (isLoading) { if (isLoading) {
return ( return (
<div className={styles.dashboardHeader}> <div>
<h2>Cargando estadísticas...</h2> <div className={styles.dashboardHeader}>
<h2>Dashboard de Inventario</h2>
</div>
<div className={styles.statsGrid}>
{/* Replicamos la estructura de la grilla con esqueletos */}
<div className={`${styles.chartContainer} ${skeletonStyles.skeleton}`}></div>
<div className={`${styles.chartContainer} ${skeletonStyles.skeleton}`}></div>
<div className={`${styles.chartContainer} ${skeletonStyles.skeleton}`}></div>
<div className={`${styles.chartContainer} ${skeletonStyles.skeleton}`}></div>
</div>
</div> </div>
); );
} }
if (!stats) { if (!stats) {
return ( return (
<div className={styles.dashboardHeader}> <div className={styles.dashboardHeader}>

View File

@@ -9,22 +9,22 @@ import {
flexRender, flexRender,
type SortingState, type SortingState,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import { Pencil, Trash2 } from 'lucide-react';
import styles from './SimpleTable.module.css'; import styles from './SimpleTable.module.css';
import { adminService } from '../services/apiService'; import { adminService } from '../services/apiService';
import TableSkeleton from './TableSkeleton';
// Interfaces para los diferentes tipos de datos // Interfaces
interface TextValue { interface TextValue {
valor: string; valor: string;
conteo: number; conteo: number;
} }
interface RamValue { interface RamValue {
fabricante?: string; fabricante?: string;
tamano: number; tamano: number;
velocidad?: number; velocidad?: number;
conteo: number; conteo: number;
} }
type ComponentValue = TextValue | RamValue; type ComponentValue = TextValue | RamValue;
const GestionComponentes = () => { const GestionComponentes = () => {
@@ -34,8 +34,6 @@ const GestionComponentes = () => {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [valorAntiguo, setValorAntiguo] = useState(''); const [valorAntiguo, setValorAntiguo] = useState('');
const [valorNuevo, setValorNuevo] = useState(''); const [valorNuevo, setValorNuevo] = useState('');
// Estados para la tabla (filtrado y ordenamiento)
const [globalFilter, setGlobalFilter] = useState(''); const [globalFilter, setGlobalFilter] = useState('');
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
@@ -71,23 +69,13 @@ const GestionComponentes = () => {
}; };
const handleDeleteRam = useCallback(async (ramGroup: RamValue) => { 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.")) { if (!window.confirm("¿Estás seguro de eliminar todas las entradas maestras para este tipo de RAM? Esta acción es irreversible.")) return;
return;
}
const toastId = toast.loading('Eliminando grupo de módulos...'); const toastId = toast.loading('Eliminando grupo de módulos...');
try { try {
await adminService.deleteRamComponent({ await adminService.deleteRamComponent({ fabricante: ramGroup.fabricante, tamano: ramGroup.tamano, velocidad: ramGroup.velocidad });
fabricante: ramGroup.fabricante,
tamano: ramGroup.tamano,
velocidad: ramGroup.velocidad
});
setValores(prev => prev.filter(v => { setValores(prev => prev.filter(v => {
const currentRam = v as RamValue; const currentRam = v as RamValue;
return !(currentRam.fabricante === ramGroup.fabricante && return !(currentRam.fabricante === ramGroup.fabricante && currentRam.tamano === ramGroup.tamano && currentRam.velocidad === ramGroup.velocidad);
currentRam.tamano === ramGroup.tamano &&
currentRam.velocidad === ramGroup.velocidad);
})); }));
toast.success("Grupo de módulos de RAM eliminado.", { id: toastId }); toast.success("Grupo de módulos de RAM eliminado.", { id: toastId });
} catch (error) { } catch (error) {
@@ -96,9 +84,7 @@ const GestionComponentes = () => {
}, []); }, []);
const handleDeleteTexto = useCallback(async (valor: string) => { const handleDeleteTexto = useCallback(async (valor: string) => {
if (!window.confirm(`Este valor ya no está en uso. ¿Quieres eliminarlo de la base de datos maestra?`)) { if (!window.confirm(`Este valor ya no está en uso. ¿Quieres eliminarlo de la base de datos maestra?`)) return;
return;
}
const toastId = toast.loading('Eliminando valor...'); const toastId = toast.loading('Eliminando valor...');
try { try {
await adminService.deleteTextComponent(componentType, valor); await adminService.deleteTextComponent(componentType, valor);
@@ -130,49 +116,43 @@ const GestionComponentes = () => {
{ {
header: 'Acciones', header: 'Acciones',
id: 'acciones', id: 'acciones',
cell: ({ row }: { row: { original: ComponentValue } }) => { cell: ({ row }: { row: { original: ComponentValue } }) => (
const item = row.original; <div style={{ display: 'flex', gap: '5px' }}>
return ( {componentType === 'ram' ? (
<div style={{ display: 'flex', gap: '5px' }}> <button
{componentType === 'ram' ? ( onClick={() => handleDeleteRam(row.original as RamValue)}
<button className={styles.deleteUserButton}
onClick={() => handleDeleteRam(item as RamValue)} style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.9em', border: '1px solid', borderRadius: '4px', padding: '4px 8px' }}
className={styles.deleteUserButton} disabled={row.original.conteo > 0}
style={{ fontSize: '0.9em', border: '1px solid', borderRadius: '4px', padding: '4px 8px', cursor: item.conteo > 0 ? 'not-allowed' : 'pointer', opacity: item.conteo > 0 ? 0.5 : 1 }} title={row.original.conteo > 0 ? `En uso por ${row.original.conteo} equipo(s)` : 'Eliminar este grupo de módulos maestros'}
disabled={item.conteo > 0} >
title={item.conteo > 0 ? `En uso por ${item.conteo} equipo(s)` : 'Eliminar este grupo de módulos maestros'} <Trash2 size={14} /> Eliminar
> </button>
🗑 Eliminar ) : (
<>
<button onClick={() => handleOpenModal((row.original as TextValue).valor)} className={styles.tableButton} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<Pencil size={14} /> Unificar
</button> </button>
) : ( <button
<> onClick={() => handleDeleteTexto((row.original as TextValue).valor)}
<button onClick={() => handleOpenModal((item as TextValue).valor)} className={styles.tableButton}> className={styles.deleteUserButton}
Unificar style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.9em', border: '1px solid', borderRadius: '4px', padding: '4px 8px' }}
</button> disabled={row.original.conteo > 0}
<button title={row.original.conteo > 0 ? `En uso por ${row.original.conteo} equipo(s). Unifique primero.` : 'Eliminar este valor'}
onClick={() => handleDeleteTexto((item as TextValue).valor)} >
className={styles.deleteUserButton} <Trash2 size={14} /> Eliminar
style={{ fontSize: '0.9em', border: '1px solid', borderRadius: '4px', padding: '4px 8px', cursor: item.conteo > 0 ? 'not-allowed' : 'pointer', opacity: item.conteo > 0 ? 0.5 : 1 }} </button>
disabled={item.conteo > 0} </>
title={item.conteo > 0 ? `En uso por ${item.conteo} equipo(s). Unifique primero.` : 'Eliminar este valor'} )}
> </div>
🗑 Eliminar )
</button>
</>
)}
</div>
);
}
} }
], [componentType, renderValor, handleDeleteRam, handleDeleteTexto, handleOpenModal]); ], [componentType, renderValor, handleDeleteRam, handleDeleteTexto, handleOpenModal]);
const table = useReactTable({ const table = useReactTable({
data: valores, data: valores,
columns, columns,
state: { state: { sorting, globalFilter },
sorting,
globalFilter,
},
onSortingChange: setSorting, onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter, onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
@@ -180,6 +160,19 @@ const GestionComponentes = () => {
getFilteredRowModel: getFilteredRowModel(), 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 ( return (
<div> <div>
<h2>Gestión de Componentes Maestros ({table.getFilteredRowModel().rows.length})</h2> <h2>Gestión de Componentes Maestros ({table.getFilteredRowModel().rows.length})</h2>
@@ -206,34 +199,53 @@ const GestionComponentes = () => {
</div> </div>
{isLoading ? ( {isLoading ? (
<p>Cargando...</p> <div style={tableContainerStyle}>
<TableSkeleton rows={6} columns={3} />
</div>
) : ( ) : (
<div style={{ overflowX: 'auto', border: '1px solid #dee2e6', borderRadius: '8px', marginTop: '1rem' }}> <div style={tableContainerStyle}>
<table className={styles.table}> <table className={styles.table} style={tableStyle}>
<thead> <thead>
{table.getHeaderGroups().map(headerGroup => ( {table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map(header => ( {headerGroup.headers.map(header => {
<th key={header.id} className={styles.th} onClick={header.column.getToggleSortingHandler()}> const classNames = [styles.th];
{flexRender(header.column.columnDef.header, header.getContext())} if (header.id === 'conteo') classNames.push(styles.thNumeric);
{header.column.getIsSorted() && ( if (header.id === 'acciones') classNames.push(styles.thActions);
<span className={styles.sortIndicator}> return (
{header.column.getIsSorted() === 'asc' ? '⇑' : '⇓'} <th
</span> key={header.id}
)} className={classNames.join(' ')}
</th> onClick={header.column.getToggleSortingHandler()}
))} >
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() && (
<span className={styles.sortIndicator}>
{header.column.getIsSorted() === 'asc' ? '⇑' : '⇓'}
</span>
)}
</th>
)
})}
</tr> </tr>
))} ))}
</thead> </thead>
<tbody> <tbody>
{table.getRowModel().rows.map(row => ( {table.getRowModel().rows.map(row => (
<tr key={row.id} className={styles.tr}> <tr key={row.id} className={styles.tr}>
{row.getVisibleCells().map(cell => ( {row.getVisibleCells().map(cell => {
<td key={cell.id} className={styles.td}> const classNames = [styles.td];
{flexRender(cell.column.columnDef.cell, cell.getContext())} if (cell.column.id === 'conteo') classNames.push(styles.tdNumeric);
</td> if (cell.column.id === 'acciones') classNames.push(styles.tdActions);
))} return (
<td
key={cell.id}
className={classNames.join(' ')}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
)
})}
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -6,6 +6,7 @@ import type { Sector } from '../types/interfaces';
import styles from './SimpleTable.module.css'; import styles from './SimpleTable.module.css';
import ModalSector from './ModalSector'; import ModalSector from './ModalSector';
import { sectorService } from '../services/apiService'; import { sectorService } from '../services/apiService';
import { PlusCircle, Pencil, Trash2 } from 'lucide-react';
const GestionSectores = () => { const GestionSectores = () => {
const [sectores, setSectores] = useState<Sector[]>([]); const [sectores, setSectores] = useState<Sector[]>([]);
@@ -80,8 +81,8 @@ const GestionSectores = () => {
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h2>Gestión de Sectores</h2> <h2>Gestión de Sectores</h2>
<button onClick={handleOpenCreateModal} className={`${styles.btn} ${styles.btnPrimary}`}> <button onClick={handleOpenCreateModal} className={`${styles.btn} ${styles.btnPrimary}`} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
+ Añadir Sector <PlusCircle size={18} /> Añadir Sector
</button> </button>
</div> </div>
<table className={styles.table}> <table className={styles.table}>
@@ -97,9 +98,9 @@ const GestionSectores = () => {
<td className={styles.td}>{sector.nombre}</td> <td className={styles.td}>{sector.nombre}</td>
<td className={styles.td}> <td className={styles.td}>
<div style={{ display: 'flex', gap: '10px' }}> <div style={{ display: 'flex', gap: '10px' }}>
<button onClick={() => handleOpenEditModal(sector)} className={styles.tableButton}> Editar</button> <button onClick={() => handleOpenEditModal(sector)} className={styles.tableButton} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><Pencil size={16} /> Editar</button>
<button onClick={() => handleDelete(sector.id)} className={styles.deleteUserButton} style={{ fontSize: '1em', padding: '0.375rem 0.75rem', border: '1px solid #dc3545', borderRadius: '4px' }}> <button onClick={() => handleDelete(sector.id)} className={styles.deleteUserButton} style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '1em', padding: '0.375rem 0.75rem', border: '1px solid #dc3545', borderRadius: '4px' }}>
🗑 Eliminar <Trash2 size={16} /> Eliminar
</button> </button>
</div> </div>
</td> </td>

View File

@@ -6,6 +6,7 @@ import styles from './SimpleTable.module.css';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import AutocompleteInput from './AutocompleteInput'; import AutocompleteInput from './AutocompleteInput';
import { equipoService } from '../services/apiService'; import { equipoService } from '../services/apiService';
import { X, Pencil, HardDrive, MemoryStick, UserPlus, Trash2, Power, Info, Component, Keyboard, Cog, Zap, History } from 'lucide-react';
interface ModalDetallesEquipoProps { interface ModalDetallesEquipoProps {
equipo: Equipo; equipo: Equipo;
@@ -81,7 +82,6 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
}; };
const handleWolClick = async () => { const handleWolClick = async () => {
// La validación ahora es redundante por el 'disabled', pero la dejamos como buena práctica
if (!equipo.mac || !equipo.ip) { if (!equipo.mac || !equipo.ip) {
toast.error("Este equipo no tiene MAC o IP para encenderlo."); toast.error("Este equipo no tiene MAC o IP para encenderlo.");
return; return;
@@ -118,7 +118,9 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
return ( return (
<div className={styles.modalLarge}> <div className={styles.modalLarge}>
<button onClick={onClose} className={styles.closeButton} disabled={isChildModalOpen}>×</button> <button onClick={onClose} className={styles.closeButton} disabled={isChildModalOpen}>
<X size={20} />
</button>
<div className={styles.modalLargeContent}> <div className={styles.modalLargeContent}>
<div className={styles.modalLargeHeader}> <div className={styles.modalLargeHeader}>
@@ -131,7 +133,7 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
<button onClick={handleCancel} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button> <button onClick={handleCancel} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
</> </>
) : ( ) : (
<button onClick={handleEditClick} className={`${styles.btn} ${styles.btnPrimary}`}> Editar</button> <button onClick={handleEditClick} className={`${styles.btn} ${styles.btnPrimary}`} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}><Pencil size={16} /> Editar</button>
)} )}
</div> </div>
)} )}
@@ -141,12 +143,12 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
<div className={styles.mainColumn}> <div className={styles.mainColumn}>
<div className={styles.section}> <div className={styles.section}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 className={styles.sectionTitle} style={{ border: 'none', padding: 0 }}>🔗 Datos Principales</h3> <h3 className={styles.sectionTitle} style={{ border: 'none', padding: 0 }}><Info size={20} /> Datos Principales</h3>
{equipo.origen === 'manual' && ( {equipo.origen === 'manual' && (
<div style={{ display: 'flex', gap: '5px' }}> <div style={{ display: 'flex', gap: '5px' }}>
<button onClick={() => onAddComponent('disco')} className={styles.tableButtonMas}>Agregar Disco</button> <button onClick={() => onAddComponent('disco')} className={styles.tableButtonMas} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><HardDrive size={16} /> Disco</button>
<button onClick={() => onAddComponent('ram')} className={styles.tableButtonMas}>Agregar RAM</button> <button onClick={() => onAddComponent('ram')} className={styles.tableButtonMas} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><MemoryStick size={16} /> RAM</button>
<button onClick={() => onAddComponent('usuario')} className={styles.tableButtonMas}>Agregar Usuario</button> <button onClick={() => onAddComponent('usuario')} className={styles.tableButtonMas} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><UserPlus size={16} /> Usuario</button>
</div> </div>
)} )}
</div> </div>
@@ -158,12 +160,12 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
<div className={styles.detailItem}><strong className={styles.detailLabel}>Sector:</strong>{isEditing ? <select name="sector_id" value={editableEquipo.sector_id || ''} onChange={handleChange} className={styles.modalInput}><option value="">- Sin Asignar -</option>{sectores.map(s => <option key={s.id} value={s.id}>{s.nombre}</option>)}</select> : <span className={styles.detailValue}>{equipo.sector?.nombre || 'No asignado'}</span>}</div> <div className={styles.detailItem}><strong className={styles.detailLabel}>Sector:</strong>{isEditing ? <select name="sector_id" value={editableEquipo.sector_id || ''} onChange={handleChange} className={styles.modalInput}><option value="">- Sin Asignar -</option>{sectores.map(s => <option key={s.id} value={s.id}>{s.nombre}</option>)}</select> : <span className={styles.detailValue}>{equipo.sector?.nombre || 'No asignado'}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Creación:</strong><span className={styles.detailValue}>{formatDate(equipo.created_at)}</span></div> <div className={styles.detailItem}><strong className={styles.detailLabel}>Creación:</strong><span className={styles.detailValue}>{formatDate(equipo.created_at)}</span></div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Última Actualización:</strong><span className={styles.detailValue}>{formatDate(equipo.updated_at)}</span></div> <div className={styles.detailItem}><strong className={styles.detailLabel}>Última Actualización:</strong><span className={styles.detailValue}>{formatDate(equipo.updated_at)}</span></div>
<div className={styles.detailItemFull}><strong className={styles.detailLabel}>Usuarios:</strong><span className={styles.detailValue}>{equipo.usuarios?.length > 0 ? equipo.usuarios.map(u => (<div key={u.id} className={styles.componentItem}><div><span title={`Origen: ${u.origen}`}>{u.origen === 'manual' ? '⌨️' : '⚙️'}</span>{` ${u.username}`}</div>{u.origen === 'manual' && (<button onClick={() => onRemoveAssociation('usuario', { equipoId: equipo.id, usuarioId: u.id })} className={styles.deleteUserButton} title="Quitar este usuario">🗑</button>)}</div>)) : 'N/A'}</span></div> <div className={styles.detailItemFull}><strong className={styles.detailLabel}>Usuarios:</strong><span className={styles.detailValue}>{equipo.usuarios?.length > 0 ? equipo.usuarios.map(u => (<div key={u.id} className={styles.componentItem}><div><span title={`Origen: ${u.origen}`}>{u.origen === 'manual' ? <Keyboard size={16} /> : <Cog size={16} />}</span>{` ${u.username}`}</div>{u.origen === 'manual' && (<button onClick={() => onRemoveAssociation('usuario', { equipoId: equipo.id, usuarioId: u.id })} className={styles.deleteUserButton} title="Quitar este usuario"><Trash2 size={16} /></button>)}</div>)) : 'N/A'}</span></div>
</div> </div>
</div> </div>
<div className={styles.section}> <div className={styles.section}>
<h3 className={styles.sectionTitle}>💻 Componentes</h3> <h3 className={styles.sectionTitle}><Component size={20} /> Componentes</h3>
<div className={styles.detailsGrid}> <div className={styles.detailsGrid}>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Motherboard:</strong>{isEditing ? <AutocompleteInput mode="static" name="motherboard" value={editableEquipo.motherboard} onChange={handleChange} className={styles.modalInput} fetchSuggestions={fetchMotherboardSuggestions} /> : <span className={styles.detailValue}>{equipo.motherboard || 'N/A'}</span>}</div> <div className={styles.detailItem}><strong className={styles.detailLabel}>Motherboard:</strong>{isEditing ? <AutocompleteInput mode="static" name="motherboard" value={editableEquipo.motherboard} onChange={handleChange} className={styles.modalInput} fetchSuggestions={fetchMotherboardSuggestions} /> : <span className={styles.detailValue}>{equipo.motherboard || 'N/A'}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>CPU:</strong>{isEditing ? <AutocompleteInput mode="static" name="cpu" value={editableEquipo.cpu} onChange={handleChange} className={styles.modalInput} fetchSuggestions={fetchCpuSuggestions} /> : <span className={styles.detailValue}>{equipo.cpu || 'N/A'}</span>}</div> <div className={styles.detailItem}><strong className={styles.detailLabel}>CPU:</strong>{isEditing ? <AutocompleteInput mode="static" name="cpu" value={editableEquipo.cpu} onChange={handleChange} className={styles.modalInput} fetchSuggestions={fetchCpuSuggestions} /> : <span className={styles.detailValue}>{equipo.cpu || 'N/A'}</span>}</div>
@@ -185,7 +187,7 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
<span className={styles.detailValue}>{equipo.architecture || 'N/A'}</span> <span className={styles.detailValue}>{equipo.architecture || 'N/A'}</span>
)} )}
</div> </div>
<div className={styles.detailItemFull}><strong className={styles.detailLabel}>Discos:</strong><span className={styles.detailValue}>{equipo.discos?.length > 0 ? equipo.discos.map(d => (<div key={d.equipoDiscoId} className={styles.componentItem}><div><span title={`Origen: ${d.origen}`}>{d.origen === 'manual' ? '⌨️' : '⚙️'}</span>{` ${d.mediatype} ${d.size}GB`}</div>{d.origen === 'manual' && (<button onClick={() => onRemoveAssociation('disco', d.equipoDiscoId)} className={styles.deleteUserButton} title="Eliminar este disco">🗑</button>)}</div>)) : 'N/A'}</span></div> <div className={styles.detailItemFull}><strong className={styles.detailLabel}>Discos:</strong><span className={styles.detailValue}>{equipo.discos?.length > 0 ? equipo.discos.map(d => (<div key={d.equipoDiscoId} className={styles.componentItem}><div><span title={`Origen: ${d.origen}`}>{d.origen === 'manual' ? <Keyboard size={16} /> : <Cog size={16} />}</span>{` ${d.mediatype} ${d.size}GB`}</div>{d.origen === 'manual' && (<button onClick={() => onRemoveAssociation('disco', d.equipoDiscoId)} className={styles.deleteUserButton} title="Eliminar este disco"><Trash2 size={16} /></button>)}</div>)) : 'N/A'}</span></div>
<div className={styles.detailItem}> <div className={styles.detailItem}>
<strong className={styles.detailLabel}>Total Slots RAM:</strong> <strong className={styles.detailLabel}>Total Slots RAM:</strong>
{isEditing ? ( {isEditing ? (
@@ -201,14 +203,14 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
<span className={styles.detailValue}>{equipo.ram_slots || 'N/A'}</span> <span className={styles.detailValue}>{equipo.ram_slots || 'N/A'}</span>
)} )}
</div> </div>
<div className={styles.detailItemFull}><strong className={styles.detailLabel}>Módulos RAM:</strong><span className={styles.detailValue}>{equipo.memoriasRam?.length > 0 ? equipo.memoriasRam.map(m => (<div key={m.equipoMemoriaRamId} className={styles.componentItem}><div><span title={`Origen: ${m.origen}`}>{m.origen === 'manual' ? '⌨️' : '⚙️'}</span>{` Slot ${m.slot}: ${m.partNumber || 'N/P'} ${m.tamano}GB ${m.velocidad || ''}MHz`}</div>{m.origen === 'manual' && (<button onClick={() => onRemoveAssociation('ram', m.equipoMemoriaRamId)} className={styles.deleteUserButton} title="Eliminar este módulo">🗑</button>)}</div>)) : 'N/A'}</span></div> <div className={styles.detailItemFull}><strong className={styles.detailLabel}>Módulos RAM:</strong><span className={styles.detailValue}>{equipo.memoriasRam?.length > 0 ? equipo.memoriasRam.map(m => (<div key={m.equipoMemoriaRamId} className={styles.componentItem}><div><span title={`Origen: ${m.origen}`}>{m.origen === 'manual' ? <Keyboard size={16} /> : <Cog size={16} />}</span>{` Slot ${m.slot}: ${m.partNumber || 'N/P'} ${m.tamano}GB ${m.velocidad || ''}MHz`}</div>{m.origen === 'manual' && (<button onClick={() => onRemoveAssociation('ram', m.equipoMemoriaRamId)} className={styles.deleteUserButton} title="Eliminar este módulo"><Trash2 size={16} /></button>)}</div>)) : 'N/A'}</span></div>
</div> </div>
</div> </div>
</div> </div>
<div className={styles.sidebarColumn}> <div className={styles.sidebarColumn}>
<div className={styles.section}> <div className={styles.section}>
<h3 className={styles.sectionTitle}> Acciones y Estado</h3> <h3 className={styles.sectionTitle}><Zap size={20} /> Acciones y Estado</h3>
<div className={styles.actionsGrid}> <div className={styles.actionsGrid}>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Estado:</strong><div className={styles.statusIndicator}><div className={`${styles.statusDot} ${isOnline ? styles.statusOnline : styles.statusOffline}`} /><span>{isOnline ? 'En línea' : 'Sin conexión'}</span></div></div> <div className={styles.detailItem}><strong className={styles.detailLabel}>Estado:</strong><div className={styles.statusIndicator}><div className={`${styles.statusDot} ${isOnline ? styles.statusOnline : styles.statusOffline}`} /><span>{isOnline ? 'En línea' : 'Sin conexión'}</span></div></div>
@@ -220,7 +222,7 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
data-tooltip-id="modal-power-tooltip" data-tooltip-id="modal-power-tooltip"
disabled={!equipo.mac} disabled={!equipo.mac}
> >
<img src="./power.png" alt="Encender equipo" className={styles.powerIcon} /> <Power size={18} />
Encender (WOL) Encender (WOL)
</button> </button>
<Tooltip id="modal-power-tooltip" place="top"> <Tooltip id="modal-power-tooltip" place="top">
@@ -235,7 +237,7 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
className={styles.deleteButton} className={styles.deleteButton}
data-tooltip-id="modal-delete-tooltip" data-tooltip-id="modal-delete-tooltip"
> >
🗑 Eliminar <Trash2 size={16} /> Eliminar
</button> </button>
<Tooltip id="modal-delete-tooltip" place="top"> <Tooltip id="modal-delete-tooltip" place="top">
Eliminar este equipo permanentemente del inventario Eliminar este equipo permanentemente del inventario
@@ -247,7 +249,7 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
</div> </div>
<div className={`${styles.section} ${styles.historySectionFullWidth}`}> <div className={`${styles.section} ${styles.historySectionFullWidth}`}>
<h3 className={styles.sectionTitle}>📜 Historial de cambios</h3> <h3 className={styles.sectionTitle}><History size={20} /> Historial de cambios</h3>
<div className={styles.historyContainer}> <div className={styles.historyContainer}>
<table className={styles.historyTable}> <table className={styles.historyTable}>
<thead><tr><th className={styles.historyTh}>Fecha</th><th className={styles.historyTh}>Campo</th><th className={styles.historyTh}>Valor anterior</th><th className={styles.historyTh}>Valor nuevo</th></tr></thead> <thead><tr><th className={styles.historyTh}>Fecha</th><th className={styles.historyTh}>Campo</th><th className={styles.historyTh}>Valor anterior</th><th className={styles.historyTh}>Valor nuevo</th></tr></thead>

View File

@@ -1,5 +1,7 @@
// frontend/src/components/Navbar.tsx
import React from 'react'; import React from 'react';
import type { View } from '../App'; import type { View } from '../App';
import ThemeToggle from './ThemeToggle';
import '../App.css'; import '../App.css';
interface NavbarProps { interface NavbarProps {
@@ -38,7 +40,11 @@ const Navbar: React.FC<NavbarProps> = ({ currentView, setCurrentView }) => {
> >
Dashboard Dashboard
</button> </button>
<div style={{ padding: '0.25rem' }}>
<ThemeToggle />
</div>
</nav> </nav>
</header> </header>
); );
}; };

View File

@@ -9,33 +9,37 @@
.searchInput, .sectorSelect { .searchInput, .sectorSelect {
padding: 8px 12px; padding: 8px 12px;
border-radius: 6px; border-radius: 6px;
border: 1px solid #ced4da; border: 1px solid var(--color-border);
font-size: 14px; font-size: 14px;
background-color: var(--color-surface);
color: var(--color-text-primary);
} }
/* Estilos de la tabla */
.table { .table {
border-collapse: collapse; border-collapse: collapse;
font-family: system-ui, -apple-system, sans-serif; font-family: system-ui, -apple-system, sans-serif;
font-size: 0.875rem; font-size: 0.875rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.08); box-shadow: 0 1px 3px rgba(0,0,0,0.08);
width: 100%; width: 100%;
min-width: 1200px; /* Ancho mínimo para forzar el scroll horizontal si es necesario */ min-width: 1200px;
table-layout: fixed;
} }
.th { .th {
color: #212529; color: var(--color-text-primary);
font-weight: 600; font-weight: 600;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-bottom: 2px solid #dee2e6; border-bottom: 2px solid var(--color-border);
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
white-space: nowrap; white-space: nowrap;
position: sticky; position: sticky;
top: 0; /* Mantiene la posición sticky en la parte superior del viewport */ top: 0;
z-index: 2; z-index: 2;
background-color: #f8f9fa; /* Es crucial tener un fondo sólido */ background-color: var(--color-background);
overflow: hidden;
text-overflow: ellipsis;
} }
.sortIndicator { .sortIndicator {
@@ -43,11 +47,11 @@
font-size: 1.2em; font-size: 1.2em;
display: inline-block; display: inline-block;
transform: translateY(-1px); transform: translateY(-1px);
color: #007bff; color: var(--color-primary);
min-width: 20px; min-width: 20px;
} }
.tooltip{ .tooltip {
z-index: 9999; z-index: 9999;
} }
@@ -56,70 +60,105 @@
} }
.tr:hover { .tr:hover {
background-color: #f1f3f5; background-color: var(--color-surface-hover);
} }
.td { .td {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-bottom: 1px solid #e9ecef; border-bottom: 1px solid var(--color-border-subtle);
color: #495057; color: var(--color-text-secondary);
background-color: white; 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 */ /* Estilos de botones dentro de la tabla */
.hostnameButton { .hostnameButton {
background: none; background: none;
border: none; border: none;
color: #007bff; color: var(--color-primary);
cursor: pointer; cursor: pointer;
text-decoration: underline; text-decoration: underline;
padding: 0; padding: 0;
font-size: inherit; font-size: inherit;
font-family: inherit; font-family: inherit;
transition: color 0.2s ease;
} }
.hostnameButton:hover {
color: var(--color-primary-hover);
}
.tableButton { .tableButton {
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
border-radius: 4px; border-radius: 4px;
border: 1px solid #dee2e6; border: 1px solid var(--color-border);
background-color: transparent; background-color: transparent;
color: #212529; color: var(--color-text-primary);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.tableButton:hover { .tableButton:hover {
background-color: #e9ecef; background-color: var(--color-surface-hover);
border-color: #adb5bd; border-color: var(--color-text-muted);
} }
.tableButtonMas { .tableButtonMas {
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
border-radius: 4px; border-radius: 4px;
border: 1px solid #007bff; border: 1px solid var(--color-primary);
background-color: #007bff; background-color: var(--color-primary);
color: #ffffff; color: var(--color-navbar-text-hover);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.tableButtonMas:hover { .tableButtonMas:hover {
background-color: #0056b3; background-color: var(--color-primary-hover);
border-color: #0056b3; border-color: var(--color-primary-hover);
} }
.deleteUserButton { .deleteUserButton {
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
color: #dc3545; color: var(--color-danger);
font-size: 1rem; font-size: 1rem;
padding: 0 5px; padding: 0 5px;
opacity: 0.7; transition: opacity 0.3s ease, color 0.3s ease, background-color 0.3s ease;
transition: opacity 0.3s ease, color 0.3s ease;
line-height: 1; line-height: 1;
} }
.deleteUserButton:hover {
opacity: 1; .deleteUserButton:hover:not(:disabled) {
color: #a4202e; color: var(--color-danger-hover);
}
.deleteUserButton:disabled {
opacity: 0.5;
cursor: not-allowed;
} }
/* Estilo para el botón de scroll-to-top */ /* Estilo para el botón de scroll-to-top */
@@ -130,21 +169,46 @@
width: 40px; width: 40px;
height: 40px; height: 40px;
border-radius: 50%; border-radius: 50%;
background-color: #007bff; background-color: var(--color-primary);
color: white; color: white;
border: none; border: none;
cursor: pointer; 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; font-size: 20px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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; z-index: 1002;
} }
.scrollToTop:hover { .scrollToTop:hover {
transform: translateY(-3px); 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 */ /* Estilos genéricos para modales */
@@ -159,23 +223,27 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
animation: fadeIn 0.2s ease-out;
/* Aplicamos animación */
} }
.modal { .modal {
background-color: #ffffff; background-color: var(--color-surface);
border-radius: 12px; border-radius: 12px;
padding: 2rem; padding: 2rem;
box-shadow: 0px 8px 30px rgba(0, 0, 0, 0.12); box-shadow: 0px 8px 30px rgba(0, 0, 0, 0.12);
z-index: 1000; z-index: 1000;
min-width: 400px; min-width: 400px;
max-width: 90%; max-width: 90%;
border: 1px solid #e0e0e0; border: 1px solid var(--color-border);
font-family: 'Segoe UI', sans-serif; font-family: 'Segoe UI', sans-serif;
animation: scaleIn 0.2s ease-out;
/* Aplicamos animación */
} }
.modal h3 { .modal h3 {
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
color: #2d3436; color: var(--color-text-primary);
} }
.modal label { .modal label {
@@ -187,18 +255,32 @@
.modalInput { .modalInput {
padding: 10px; padding: 10px;
border-radius: 6px; 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%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
margin-top: 4px; /* Separado del label */ margin-top: 4px;
margin-bottom: 4px; /* Espacio antes del siguiente elemento */ /* 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 { .modalActions {
display: flex; display: flex;
gap: 10px; gap: 10px;
margin-top: 1.5rem; 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 */ /* Estilos de botones para modales */
@@ -213,24 +295,24 @@
} }
.btnPrimary { .btnPrimary {
background-color: #007bff; background-color: var(--color-primary);
color: white; color: white;
} }
.btnPrimary:hover { .btnPrimary:hover {
background-color: #0056b3; background-color: var(--color-primary-hover);
} }
.btnPrimary:disabled { .btnPrimary:disabled {
background-color: #e9ecef; background-color: var(--color-surface-hover);
color: #6c757d; color: var(--color-text-muted);
cursor: not-allowed; cursor: not-allowed;
} }
.btnSecondary { .btnSecondary {
background-color: #6c757d; background-color: var(--color-text-muted);
color: white; color: white;
} }
.btnSecondary:hover { .btnSecondary:hover {
background-color: #5a6268; background-color: var(--color-text-secondary);
} }
/* ===== NUEVOS ESTILOS PARA EL MODAL DE DETALLES ===== */ /* ===== NUEVOS ESTILOS PARA EL MODAL DE DETALLES ===== */
@@ -241,19 +323,24 @@
left: 0; left: 0;
width: 100vw; width: 100vw;
height: 100vh; 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; z-index: 1003;
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 2rem; padding: 2rem;
box-sizing: border-box; box-sizing: border-box;
animation: fadeIn 0.3s ease-out;
/* Animación ligeramente más lenta para pantalla completa */
} }
.modalLargeContent { .modalLargeContent {
max-width: 1400px; /* Ancho máximo del contenido */ max-width: 1400px;
/* Ancho máximo del contenido */
width: 100%; width: 100%;
margin: 0 auto; /* Centrar el contenido */ margin: 0 auto;
/* Centrar el contenido */
} }
.modalLargeHeader { .modalLargeHeader {
@@ -267,7 +354,7 @@
.modalLargeHeader h2 { .modalLargeHeader h2 {
font-weight: 400; font-weight: 400;
font-size: 1.5rem; font-size: 1.5rem;
color: #343a40; color: var(--color-text-primary);
} }
.closeButton { .closeButton {
@@ -283,12 +370,13 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1004; 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; transition: transform 0.2s, background-color 0.2s;
position: fixed; position: fixed;
right: 30px; right: 30px;
top: 30px; top: 30px;
} }
.closeButton:hover { .closeButton:hover {
transform: scale(1.1); transform: scale(1.1);
background-color: #333; background-color: #333;
@@ -314,19 +402,19 @@
} }
.section { .section {
background-color: #ffffff; background-color: var(--color-surface);
border: 1px solid #dee2e6; border: 1px solid var(--color-border);
border-radius: 8px; border-radius: 8px;
padding: 1.5rem; 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 { .sectionTitle {
font-size: 1.25rem; font-size: 1.25rem;
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
padding-bottom: 0.75rem; padding-bottom: 0.75rem;
border-bottom: 1px solid #e9ecef; border-bottom: 1px solid var(--color-border-subtle);
color: #2d3436; color: var(--color-text-primary);
font-weight: 600; font-weight: 600;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -339,7 +427,6 @@
gap: 1rem; gap: 1rem;
} }
/* CAMBIO: Se aplica el mismo estilo de grid a componentsGrid para que se vea igual que detailsGrid */
.componentsGrid { .componentsGrid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
@@ -352,24 +439,25 @@
gap: 1rem; gap: 1rem;
} }
.detailItem, .detailItemFull { .detailItem,
.detailItemFull {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
padding: 10px; padding: 10px;
background-color: #f8f9fa;
border-radius: 4px; border-radius: 4px;
border: 1px solid #e9ecef; background-color: var(--color-background);
border: 1px solid var(--color-border-subtle);
} }
.detailLabel { .detailLabel {
color: #6c757d; color: var(--color-text-muted);
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 700; font-weight: 700;
} }
.detailValue { .detailValue {
color: #495057; color: var(--color-text-secondary);
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.4; line-height: 1.4;
word-break: break-word; word-break: break-word;
@@ -383,9 +471,10 @@
padding: 2px 0; padding: 2px 0;
} }
.powerButton, .deleteButton { .powerButton,
.deleteButton {
background: none; background: none;
border: 1px solid #dee2e6; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: 4px;
padding: 8px; padding: 8px;
cursor: pointer; cursor: pointer;
@@ -398,9 +487,9 @@
} }
.powerButton:hover { .powerButton:hover {
border-color: #007bff; border-color: var(--color-primary);
background-color: #e7f1ff; background-color: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: #0056b3; color: var(--color-primary-hover);
} }
.powerIcon { .powerIcon {
@@ -409,13 +498,16 @@
} }
.deleteButton { .deleteButton {
color: #dc3545; color: var(--color-danger);
transition: all 0.2s ease;
} }
.deleteButton:hover { .deleteButton:hover {
border-color: #dc3545; border-color: var(--color-danger);
background-color: #fbebee; background-color: var(--color-danger-background);
color: #a4202e; color: var(--color-danger-hover);
} }
.deleteButton:disabled { .deleteButton:disabled {
color: #6c757d; color: #6c757d;
background-color: #e9ecef; background-color: #e9ecef;
@@ -425,7 +517,7 @@
.historyContainer { .historyContainer {
max-height: 400px; max-height: 400px;
overflow-y: auto; overflow-y: auto;
border: 1px solid #dee2e6; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: 4px;
} }
@@ -435,7 +527,7 @@
} }
.historyTh { .historyTh {
background-color: #f8f9fa; background-color: var(--color-background);
padding: 12px; padding: 12px;
text-align: left; text-align: left;
font-size: 0.875rem; font-size: 0.875rem;
@@ -445,16 +537,15 @@
.historyTd { .historyTd {
padding: 12px; padding: 12px;
color: #495057;
font-size: 0.8125rem; 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 { .historyTr:last-child .historyTd {
border-bottom: none; border-bottom: none;
} }
/* CAMBIO: Nueva clase para dar espacio a la sección de historial */
.historySectionFullWidth { .historySectionFullWidth {
margin-top: 2rem; margin-top: 2rem;
} }
@@ -493,30 +584,66 @@
margin-top: 4px; 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 {
.userList { min-width: 240px; } 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; } .userItem {
.sectorContainer { display: flex; justify-content: space-between; align-items: center; width: 100%; gap: 0.5rem; } display: flex;
.sectorName { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } align-items: center;
.sectorNameAssigned { color: #212529; font-style: normal; } justify-content: space-between;
.sectorNameUnassigned { color: #6c757d; font-style: italic; } 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;
}
/* Estilo para el overlay de un modal anidado */
.modalOverlay--nested { .modalOverlay--nested {
/* z-index superior al del botón de cierre del modal principal (1004) */
z-index: 1005; z-index: 1005;
} }
/* También nos aseguramos de que el contenido del modal anidado tenga un z-index superior */
.modalOverlay--nested .modal { .modalOverlay--nested .modal {
z-index: 1006; z-index: 1006;
} }
/* Estilo para deshabilitar el botón de cierre del modal principal */
.closeButton:disabled { .closeButton:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.5; opacity: 0.5;
background-color: #6c757d; /* Gris para indicar inactividad */ background-color: #6c757d;
} }

View File

@@ -9,8 +9,10 @@ import { Tooltip } from 'react-tooltip';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import type { Equipo, Sector, Usuario, UsuarioEquipoDetalle } from '../types/interfaces'; import type { Equipo, Sector, Usuario, UsuarioEquipoDetalle } from '../types/interfaces';
import styles from './SimpleTable.module.css'; 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 { equipoService, sectorService, usuarioService } from '../services/apiService';
import { PlusCircle, KeyRound, UserX, Pencil, ArrowUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import ModalAnadirEquipo from './ModalAnadirEquipo'; import ModalAnadirEquipo from './ModalAnadirEquipo';
import ModalEditarSector from './ModalEditarSector'; import ModalEditarSector from './ModalEditarSector';
@@ -19,6 +21,7 @@ import ModalDetallesEquipo from './ModalDetallesEquipo';
import ModalAnadirDisco from './ModalAnadirDisco'; import ModalAnadirDisco from './ModalAnadirDisco';
import ModalAnadirRam from './ModalAnadirRam'; import ModalAnadirRam from './ModalAnadirRam';
import ModalAnadirUsuario from './ModalAnadirUsuario'; import ModalAnadirUsuario from './ModalAnadirUsuario';
import TableSkeleton from './TableSkeleton'; // Importamos el componente de esqueleto
const SimpleTable = () => { const SimpleTable = () => {
const [data, setData] = useState<Equipo[]>([]); const [data, setData] = useState<Equipo[]>([]);
@@ -317,23 +320,23 @@ const SimpleTable = () => {
} }
}; };
// --- DEFINICIÓN DE COLUMNAS CORREGIDA ---
const columns: ColumnDef<Equipo>[] = [ const columns: ColumnDef<Equipo>[] = [
{ header: "ID", accessorKey: "id", enableHiding: true }, { header: "ID", accessorKey: "id", enableHiding: true },
{ {
header: "Nombre", accessorKey: "hostname", header: "Nombre", accessorKey: "hostname",
cell: (info: CellContext<Equipo, any>) => (<button onClick={() => setSelectedEquipo(info.row.original)} className={styles.hostnameButton}>{info.row.original.hostname}</button>) cell: (info: CellContext<Equipo, any>) => (<button onClick={() => setSelectedEquipo(info.row.original)} className={styles.hostnameButton}>{info.row.original.hostname}</button>)
}, },
{ header: "IP", accessorKey: "ip" }, { header: "IP", accessorKey: "ip", id: 'ip' },
{ header: "MAC", accessorKey: "mac", enableHiding: true }, { header: "MAC", accessorKey: "mac", enableHiding: true },
{ header: "Motherboard", accessorKey: "motherboard" }, { header: "Motherboard", accessorKey: "motherboard" },
{ header: "CPU", accessorKey: "cpu" }, { 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: "Discos", accessorFn: (row: Equipo) => row.discos?.map(d => `${d.mediatype} ${d.size}GB`).join(" | ") || "Sin discos" },
{ header: "OS", accessorKey: "os" }, { header: "OS", accessorKey: "os" },
{ header: "Arquitectura", accessorKey: "architecture" }, { header: "Arquitectura", accessorKey: "architecture", id: 'arch' },
{ {
header: "Usuarios y Claves", header: "Usuarios y Claves",
id: 'usuarios',
cell: (info: CellContext<Equipo, any>) => { cell: (info: CellContext<Equipo, any>) => {
const { row } = info; const { row } = info;
const usuarios = row.original.usuarios || []; const usuarios = row.original.usuarios || [];
@@ -351,7 +354,7 @@ const SimpleTable = () => {
className={styles.tableButton} className={styles.tableButton}
data-tooltip-id={`edit-${u.id}`} data-tooltip-id={`edit-${u.id}`}
> >
<KeyRound size={16} />
<Tooltip id={`edit-${u.id}`} className={styles.tooltip} place="top">Cambiar Clave</Tooltip> <Tooltip id={`edit-${u.id}`} className={styles.tooltip} place="top">Cambiar Clave</Tooltip>
</button> </button>
@@ -360,7 +363,7 @@ const SimpleTable = () => {
className={styles.deleteUserButton} className={styles.deleteUserButton}
data-tooltip-id={`remove-${u.id}`} data-tooltip-id={`remove-${u.id}`}
> >
🗑 <UserX size={16} />
<Tooltip id={`remove-${u.id}`} className={styles.tooltip} place="top">Eliminar Usuario</Tooltip> <Tooltip id={`remove-${u.id}`} className={styles.tooltip} place="top">Eliminar Usuario</Tooltip>
</button> </button>
</div> </div>
@@ -378,7 +381,7 @@ const SimpleTable = () => {
return ( return (
<div className={styles.sectorContainer}> <div className={styles.sectorContainer}>
<span className={`${styles.sectorName} ${sector ? styles.sectorNameAssigned : styles.sectorNameUnassigned}`}>{sector?.nombre || 'Asignar'}</span> <span className={`${styles.sectorName} ${sector ? styles.sectorNameAssigned : styles.sectorNameUnassigned}`}>{sector?.nombre || 'Asignar'}</span>
<button onClick={() => setModalData(row.original)} className={styles.tableButton} data-tooltip-id={`editSector-${row.original.id}`}><Tooltip id={`editSector-${row.original.id}`} className={styles.tooltip} place="top">Cambiar Sector</Tooltip></button> <button onClick={() => setModalData(row.original)} className={styles.tableButton} data-tooltip-id={`editSector-${row.original.id}`}><Pencil size={16} /><Tooltip id={`editSector-${row.original.id}`} className={styles.tooltip} place="top">Cambiar Sector</Tooltip></button>
</div> </div>
); );
} }
@@ -410,8 +413,18 @@ const SimpleTable = () => {
if (isLoading) { if (isLoading) {
return ( return (
<div style={{ padding: '2rem', textAlign: 'center' }}> <div>
<h2>Cargando Equipos...</h2> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2>Equipos (...)</h2>
<div className={`${skeletonStyles.skeleton}`} style={{ height: '40px', width: '160px' }}></div>
</div>
<div className={styles.controlsContainer}>
<div className={`${skeletonStyles.skeleton}`} style={{ height: '38px', width: '300px' }}></div>
<div className={`${skeletonStyles.skeleton}`} style={{ height: '38px', width: '200px' }}></div>
</div>
<div><p style={{ fontSize: '12px', fontWeight: 'bold' }}>** La tabla permite ordenar por múltiples columnas manteniendo shift al hacer click en la cabecera. **</p></div>
<div className={`${skeletonStyles.skeleton}`} style={{ height: '54px', marginBottom: '1rem' }}></div>
<TableSkeleton rows={15} columns={11} />
</div> </div>
); );
} }
@@ -420,16 +433,16 @@ const SimpleTable = () => {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 0' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 0' }}>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<button onClick={() => table.setPageIndex(0)} disabled={!table.getCanPreviousPage()} className={styles.tableButton}> <button onClick={() => table.setPageIndex(0)} disabled={!table.getCanPreviousPage()} className={styles.tableButton}>
{'<<'} <ChevronsLeft size={18} />
</button> </button>
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} className={styles.tableButton}> <button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} className={styles.tableButton}>
{'<'} <ChevronLeft size={18} />
</button> </button>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} className={styles.tableButton}> <button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} className={styles.tableButton}>
{'>'} <ChevronRight size={18} />
</button> </button>
<button onClick={() => table.setPageIndex(table.getPageCount() - 1)} disabled={!table.getCanNextPage()} className={styles.tableButton}> <button onClick={() => table.setPageIndex(table.getPageCount() - 1)} disabled={!table.getCanNextPage()} className={styles.tableButton}>
{'>>'} <ChevronsRight size={18} />
</button> </button>
</div> </div>
<span> <span>
@@ -474,8 +487,9 @@ const SimpleTable = () => {
<button <button
className={`${styles.btn} ${styles.btnPrimary}`} className={`${styles.btn} ${styles.btnPrimary}`}
onClick={() => setIsAddModalOpen(true)} onClick={() => setIsAddModalOpen(true)}
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
> >
+ Añadir Equipo <PlusCircle size={18} /> Añadir Equipo
</button> </button>
</div> </div>
<div className={styles.controlsContainer}> <div className={styles.controlsContainer}>
@@ -495,23 +509,41 @@ const SimpleTable = () => {
<thead> <thead>
{table.getHeaderGroups().map(hg => ( {table.getHeaderGroups().map(hg => (
<tr key={hg.id}> <tr key={hg.id}>
{hg.headers.map(h => ( {hg.headers.map(h => {
<th key={h.id} className={styles.th} onClick={h.column.getToggleSortingHandler()}> const classNames = [styles.th];
{flexRender(h.column.columnDef.header, h.getContext())} if (h.id === 'ip') classNames.push(styles.thIp);
{h.column.getIsSorted() && (<span className={styles.sortIndicator}>{h.column.getIsSorted() === 'asc' ? '⇑' : '⇓'}</span>)} if (h.id === 'ram') classNames.push(styles.thRam);
</th> 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 (
<th key={h.id} className={classNames.join(' ')} onClick={h.column.getToggleSortingHandler()}>
{flexRender(h.column.columnDef.header, h.getContext())}
{h.column.getIsSorted() && (<span className={styles.sortIndicator}>{h.column.getIsSorted() === 'asc' ? '⇑' : '⇓'}</span>)}
</th>
);
})}
</tr> </tr>
))} ))}
</thead> </thead>
<tbody> <tbody>
{table.getRowModel().rows.map(row => ( {table.getRowModel().rows.map(row => (
<tr key={row.id} className={styles.tr}> <tr key={row.id} className={styles.tr}>
{row.getVisibleCells().map(cell => ( {row.getVisibleCells().map(cell => {
<td key={cell.id} className={styles.td}> const classNames = [styles.td];
{flexRender(cell.column.columnDef.cell, cell.getContext())} if (cell.column.id === 'ip') classNames.push(styles.tdIp);
</td> 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 (
<td key={cell.id} className={classNames.join(' ')}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -520,7 +552,7 @@ const SimpleTable = () => {
{PaginacionControles} {PaginacionControles}
{showScrollButton && (<button onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} className={styles.scrollToTop} title="Volver arriba"></button>)} {showScrollButton && (<button onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} className={styles.scrollToTop} title="Volver arriba"><ArrowUp size={24} /></button>)}
{modalData && <ModalEditarSector modalData={modalData} setModalData={setModalData} sectores={sectores} onClose={() => setModalData(null)} onSave={handleSave} />} {modalData && <ModalEditarSector modalData={modalData} setModalData={setModalData} sectores={sectores} onClose={() => setModalData(null)} onSave={handleSave} />}

View File

@@ -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%);
}
}

View File

@@ -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<SkeletonProps> = ({ style }) => {
return <div className={skeletonStyles.skeleton} style={style}></div>;
};
interface TableSkeletonProps {
rows?: number;
columns?: number;
}
const TableSkeleton: React.FC<TableSkeletonProps> = ({ rows = 10, columns = 8 }) => {
return (
<div style={{ overflowX: 'auto', border: '1px solid #dee2e6', borderRadius: '8px' }}>
<table className={styles.table}>
<thead>
<tr>
{Array.from({ length: columns }).map((_, i) => (
<th key={i} className={styles.th}>
<Skeleton style={{ height: '20px', width: '80%' }} />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, i) => (
<tr key={i} className={styles.tr}>
{Array.from({ length: columns }).map((_, j) => (
<td key={j} className={styles.td}>
<Skeleton style={{ height: '20px' }} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};
export default TableSkeleton;

View File

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

View File

@@ -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 (
<button
className="theme-toggle-button"
onClick={toggleTheme}
title={theme === 'light' ? 'Activar modo oscuro' : 'Activar modo claro'}
>
{theme === 'light' ? <Moon size={20} /> : <Sun size={20} />}
</button>
);
};
export default ThemeToggle;

View File

@@ -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<ThemeContextType | undefined>(undefined);
// Creamos el proveedor del contexto.
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [theme, setTheme] = useState<Theme>(() => {
// 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 (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
};
// 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;
};

View File

@@ -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 { body {
margin: 0; margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
background-color: #f8f9fa; background-color: var(--color-background);
color: #212529; 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 { body::-webkit-scrollbar {
width: 8px; width: 8px;
background-color: #f1f1f1; background-color: var(--scrollbar-bg);
} }
body::-webkit-scrollbar-thumb { body::-webkit-scrollbar-thumb {
background-color: #888; background-color: var(--scrollbar-thumb);
border-radius: 4px; border-radius: 4px;
} }
/* Clase para bloquear el scroll cuando un modal está abierto */
body.scroll-lock { body.scroll-lock {
padding-right: 8px !important; padding-right: 8px !important;
overflow: hidden !important; overflow: hidden !important;

View File

@@ -3,28 +3,30 @@ import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App.tsx' import App from './App.tsx'
import './index.css' 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( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<App /> <ThemeProvider>
<Toaster <App />
position="bottom-right" // Posición de las notificaciones <Toaster
toastOptions={{ position="bottom-right"
// Estilos por defecto para las notificaciones toastOptions={{
success: { success: {
style: { style: {
background: '#28a745', background: '#28a745',
color: 'white', color: 'white',
},
}, },
}, error: {
error: { style: {
style: { background: '#dc3545',
background: '#dc3545', color: 'white',
color: 'white', },
}, },
}, }}
}} />
/> </ThemeProvider>
</React.StrictMode>, </React.StrictMode>,
) )

View File

@@ -84,6 +84,11 @@ export interface Equipo {
// --- Interfaces para el Dashboard --- // --- Interfaces para el Dashboard ---
export interface EquipoFilter {
field: string; // 'os', 'cpu', 'sector', etc.
value: string; // El valor específico del filtro
}
export interface StatItem { export interface StatItem {
label: string; label: string;
count: number; count: number;