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": {
"@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",

View File

@@ -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",

View File

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

View File

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

View File

@@ -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<DashboardStats | null>(null);
@@ -24,13 +26,24 @@ const Dashboard = () => {
setIsLoading(false);
});
}, []);
if (isLoading) {
return (
<div className={styles.dashboardHeader}>
<h2>Cargando estadísticas...</h2>
<div>
<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>
);
}
if (!stats) {
return (
<div className={styles.dashboardHeader}>

View File

@@ -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<SortingState>([]);
@@ -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 (
<div style={{ display: 'flex', gap: '5px' }}>
{componentType === 'ram' ? (
<button
onClick={() => handleDeleteRam(item as RamValue)}
className={styles.deleteUserButton}
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 }}
disabled={item.conteo > 0}
title={item.conteo > 0 ? `En uso por ${item.conteo} equipo(s)` : 'Eliminar este grupo de módulos maestros'}
>
🗑 Eliminar
cell: ({ row }: { row: { original: ComponentValue } }) => (
<div style={{ display: 'flex', gap: '5px' }}>
{componentType === 'ram' ? (
<button
onClick={() => handleDeleteRam(row.original as RamValue)}
className={styles.deleteUserButton}
style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.9em', border: '1px solid', borderRadius: '4px', padding: '4px 8px' }}
disabled={row.original.conteo > 0}
title={row.original.conteo > 0 ? `En uso por ${row.original.conteo} equipo(s)` : 'Eliminar este grupo de módulos maestros'}
>
<Trash2 size={14} /> Eliminar
</button>
) : (
<>
<button onClick={() => handleOpenModal((row.original as TextValue).valor)} className={styles.tableButton} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<Pencil size={14} /> Unificar
</button>
) : (
<>
<button onClick={() => handleOpenModal((item as TextValue).valor)} className={styles.tableButton}>
Unificar
</button>
<button
onClick={() => handleDeleteTexto((item as TextValue).valor)}
className={styles.deleteUserButton}
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 }}
disabled={item.conteo > 0}
title={item.conteo > 0 ? `En uso por ${item.conteo} equipo(s). Unifique primero.` : 'Eliminar este valor'}
>
🗑 Eliminar
</button>
</>
)}
</div>
);
}
<button
onClick={() => handleDeleteTexto((row.original as TextValue).valor)}
className={styles.deleteUserButton}
style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.9em', border: '1px solid', borderRadius: '4px', padding: '4px 8px' }}
disabled={row.original.conteo > 0}
title={row.original.conteo > 0 ? `En uso por ${row.original.conteo} equipo(s). Unifique primero.` : 'Eliminar este valor'}
>
<Trash2 size={14} /> Eliminar
</button>
</>
)}
</div>
)
}
], [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 (
<div>
<h2>Gestión de Componentes Maestros ({table.getFilteredRowModel().rows.length})</h2>
@@ -206,34 +199,53 @@ const GestionComponentes = () => {
</div>
{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' }}>
<table className={styles.table}>
<div style={tableContainerStyle}>
<table className={styles.table} style={tableStyle}>
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th key={header.id} className={styles.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>
))}
{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 (
<th
key={header.id}
className={classNames.join(' ')}
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>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id} className={styles.tr}>
{row.getVisibleCells().map(cell => (
<td key={cell.id} className={styles.td}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
{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 (
<td
key={cell.id}
className={classNames.join(' ')}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
)
})}
</tr>
))}
</tbody>

View File

@@ -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<Sector[]>([]);
@@ -80,8 +81,8 @@ const GestionSectores = () => {
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h2>Gestión de Sectores</h2>
<button onClick={handleOpenCreateModal} className={`${styles.btn} ${styles.btnPrimary}`}>
+ Añadir Sector
<button onClick={handleOpenCreateModal} className={`${styles.btn} ${styles.btnPrimary}`} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<PlusCircle size={18} /> Añadir Sector
</button>
</div>
<table className={styles.table}>
@@ -97,9 +98,9 @@ const GestionSectores = () => {
<td className={styles.td}>{sector.nombre}</td>
<td className={styles.td}>
<div style={{ display: 'flex', gap: '10px' }}>
<button onClick={() => handleOpenEditModal(sector)} className={styles.tableButton}> Editar</button>
<button onClick={() => handleDelete(sector.id)} className={styles.deleteUserButton} style={{ fontSize: '1em', padding: '0.375rem 0.75rem', border: '1px solid #dc3545', borderRadius: '4px' }}>
🗑 Eliminar
<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={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '1em', padding: '0.375rem 0.75rem', border: '1px solid #dc3545', borderRadius: '4px' }}>
<Trash2 size={16} /> Eliminar
</button>
</div>
</td>

View File

@@ -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<ModalDetallesEquipoProps> = ({
};
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<ModalDetallesEquipoProps> = ({
return (
<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.modalLargeHeader}>
@@ -131,7 +133,7 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
<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>
)}
@@ -141,12 +143,12 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
<div className={styles.mainColumn}>
<div className={styles.section}>
<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' && (
<div style={{ display: 'flex', gap: '5px' }}>
<button onClick={() => onAddComponent('disco')} className={styles.tableButtonMas}>Agregar Disco</button>
<button onClick={() => onAddComponent('ram')} className={styles.tableButtonMas}>Agregar RAM</button>
<button onClick={() => onAddComponent('usuario')} className={styles.tableButtonMas}>Agregar Usuario</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} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><MemoryStick size={16} /> RAM</button>
<button onClick={() => onAddComponent('usuario')} className={styles.tableButtonMas} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><UserPlus size={16} /> Usuario</button>
</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}>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.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 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.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>
@@ -185,7 +187,7 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
<span className={styles.detailValue}>{equipo.architecture || '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' ? '⌨️' : '⚙️'}</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}>
<strong className={styles.detailLabel}>Total Slots RAM:</strong>
{isEditing ? (
@@ -201,14 +203,14 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
<span className={styles.detailValue}>{equipo.ram_slots || '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' ? '⌨️' : '⚙️'}</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 className={styles.sidebarColumn}>
<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.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"
disabled={!equipo.mac}
>
<img src="./power.png" alt="Encender equipo" className={styles.powerIcon} />
<Power size={18} />
Encender (WOL)
</button>
<Tooltip id="modal-power-tooltip" place="top">
@@ -235,7 +237,7 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
className={styles.deleteButton}
data-tooltip-id="modal-delete-tooltip"
>
🗑 Eliminar
<Trash2 size={16} /> Eliminar
</button>
<Tooltip id="modal-delete-tooltip" place="top">
Eliminar este equipo permanentemente del inventario
@@ -247,7 +249,7 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
</div>
<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}>
<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>

View File

@@ -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<NavbarProps> = ({ currentView, setCurrentView }) => {
>
Dashboard
</button>
<div style={{ padding: '0.25rem' }}>
<ThemeToggle />
</div>
</nav>
</header>
);
};

View File

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

View File

@@ -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<Equipo[]>([]);
@@ -317,23 +320,23 @@ const SimpleTable = () => {
}
};
// --- DEFINICIÓN DE COLUMNAS CORREGIDA ---
const columns: ColumnDef<Equipo>[] = [
{ header: "ID", accessorKey: "id", enableHiding: true },
{
header: "Nombre", accessorKey: "hostname",
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: "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<Equipo, any>) => {
const { row } = info;
const usuarios = row.original.usuarios || [];
@@ -351,7 +354,7 @@ const SimpleTable = () => {
className={styles.tableButton}
data-tooltip-id={`edit-${u.id}`}
>
<KeyRound size={16} />
<Tooltip id={`edit-${u.id}`} className={styles.tooltip} place="top">Cambiar Clave</Tooltip>
</button>
@@ -360,7 +363,7 @@ const SimpleTable = () => {
className={styles.deleteUserButton}
data-tooltip-id={`remove-${u.id}`}
>
🗑
<UserX size={16} />
<Tooltip id={`remove-${u.id}`} className={styles.tooltip} place="top">Eliminar Usuario</Tooltip>
</button>
</div>
@@ -378,7 +381,7 @@ const SimpleTable = () => {
return (
<div className={styles.sectorContainer}>
<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>
);
}
@@ -410,8 +413,18 @@ const SimpleTable = () => {
if (isLoading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<h2>Cargando Equipos...</h2>
<div>
<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>
);
}
@@ -420,16 +433,16 @@ const SimpleTable = () => {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 0' }}>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<button onClick={() => table.setPageIndex(0)} disabled={!table.getCanPreviousPage()} className={styles.tableButton}>
{'<<'}
<ChevronsLeft size={18} />
</button>
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} className={styles.tableButton}>
{'<'}
<ChevronLeft size={18} />
</button>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} className={styles.tableButton}>
{'>'}
<ChevronRight size={18} />
</button>
<button onClick={() => table.setPageIndex(table.getPageCount() - 1)} disabled={!table.getCanNextPage()} className={styles.tableButton}>
{'>>'}
<ChevronsRight size={18} />
</button>
</div>
<span>
@@ -474,8 +487,9 @@ const SimpleTable = () => {
<button
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={() => setIsAddModalOpen(true)}
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
>
+ Añadir Equipo
<PlusCircle size={18} /> Añadir Equipo
</button>
</div>
<div className={styles.controlsContainer}>
@@ -495,23 +509,41 @@ const SimpleTable = () => {
<thead>
{table.getHeaderGroups().map(hg => (
<tr key={hg.id}>
{hg.headers.map(h => (
<th key={h.id} className={styles.th} onClick={h.column.getToggleSortingHandler()}>
{flexRender(h.column.columnDef.header, h.getContext())}
{h.column.getIsSorted() && (<span className={styles.sortIndicator}>{h.column.getIsSorted() === 'asc' ? '⇑' : '⇓'}</span>)}
</th>
))}
{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 (
<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>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id} className={styles.tr}>
{row.getVisibleCells().map(cell => (
<td key={cell.id} className={styles.td}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
{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 (
<td key={cell.id} className={classNames.join(' ')}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
))}
</tbody>
@@ -520,7 +552,7 @@ const SimpleTable = () => {
{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} />}

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

View File

@@ -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(
<React.StrictMode>
<App />
<Toaster
position="bottom-right" // Posición de las notificaciones
toastOptions={{
// Estilos por defecto para las notificaciones
success: {
style: {
background: '#28a745',
color: 'white',
<ThemeProvider>
<App />
<Toaster
position="bottom-right"
toastOptions={{
success: {
style: {
background: '#28a745',
color: 'white',
},
},
},
error: {
style: {
background: '#dc3545',
color: 'white',
error: {
style: {
background: '#dc3545',
color: 'white',
},
},
},
}}
/>
}}
/>
</ThemeProvider>
</React.StrictMode>,
)

View File

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