feat: Implementación de gestión manual y panel de administración

Se introduce una refactorización masiva y se añaden nuevas funcionalidades críticas para la gestión del inventario, incluyendo un panel de administración para la limpieza de datos y un sistema completo para la gestión manual de equipos.

### Nuevas Funcionalidades

*   **Panel de Administración:** Se crea una nueva vista de "Administración" para la gestión de datos maestros. Permite unificar valores inconsistentes (ej: "W10" -> "Windows 10 Pro") y eliminar registros maestros no utilizados (ej: Módulos de RAM) para mantener la base de datos limpia.

*   **Gestión de Sectores (CRUD):** Se añade una vista dedicada para crear, editar y eliminar sectores de la organización.

*   **Diferenciación Manual vs. Automático:** Se introduce una columna `origen` en la base de datos para distinguir entre los datos recopilados automáticamente por el script y los introducidos manualmente por el usuario. La UI ahora refleja visualmente este origen.

*   **CRUD de Equipos Manuales:** Se implementa la capacidad de crear, editar y eliminar equipos de origen "manual" a través de la interfaz de usuario. Se protege la eliminación de equipos automáticos.

*   **Gestión de Componentes Manuales:** Se permite añadir y eliminar componentes (Discos, RAM, Usuarios) a los equipos de origen "manual".

### Mejoras de UI/UX

*   **Refactorización de Estilos:** Se migran todos los estilos en línea del componente `SimpleTable` a un archivo CSS Module (`SimpleTable.module.css`), mejorando la mantenibilidad y el rendimiento.

*   **Notificaciones de Usuario:** Se integra `react-hot-toast` para proporcionar feedback visual inmediato (carga, éxito, error) en todas las operaciones asíncronas, reemplazando los `alert`.

*   **Componentización:** Se extraen todos los modales (`ModalDetallesEquipo`, `ModalAnadirEquipo`, etc.) a sus propios componentes, limpiando y simplificando drásticamente el componente `SimpleTable`.

*   **Paginación en Tabla Principal:** Se implementa paginación completa en la tabla de equipos, con controles para navegar, ir a una página específica y cambiar el número de items por página. Se añade un indicador de carga inicial.

*   **Navegación Mejorada:** Se reemplaza la navegación por botones con un componente `Navbar` estilizado y dedicado, mejorando la estructura visual y de código.

*   **Autocompletado de Datos:** Se introduce un componente `AutocompleteInput` reutilizable para guiar al usuario a usar datos consistentes al rellenar campos como OS, CPU y Motherboard. Se implementa búsqueda dinámica para la asociación de usuarios.

*   **Validación de MAC Address:** Se añade validación de formato en tiempo real y auto-formateo para el campo de MAC Address, reduciendo errores humanos.

*   **Consistencia de Iconos:** Se unifica el icono de eliminación a (🗑️) en toda la aplicación para una experiencia de usuario más coherente.

### Mejoras en el Backend / API

*   **Seguridad de Credenciales:** Las credenciales SSH para la función Wake On Lan se mueven del código fuente a `appsettings.json`.

*   **Nuevo `AdminController`:** Se crea un controlador dedicado para las tareas administrativas, con endpoints para obtener valores únicos de componentes y para ejecutar la lógica de unificación y eliminación.

*   **Endpoints de Gestión Manual:** Se añaden rutas específicas (`/manual/...` y `/asociacion/...`) para la manipulación de datos de origen manual, separando la lógica de la gestión automática.

*   **Protección de Datos Automáticos:** Los endpoints `DELETE` y `PUT` ahora validan el campo `origen` para prevenir la modificación o eliminación no deseada de datos generados automáticamente.

*   **Correcciones y Refinamiento:** Se soluciona el mapeo incorrecto de fechas (`created_at`, `updated_at`), se corrigen errores de compilación y se refinan las consultas SQL para incluir los nuevos campos.
This commit is contained in:
2025-10-07 14:44:16 -03:00
parent 99d98cc588
commit 242c1345c0
31 changed files with 2911 additions and 597 deletions

View File

@@ -1,5 +1,47 @@
main {
padding: 2rem;
padding: 0rem 2rem;
max-width: 1600px;
margin: 0 auto;
}
/* Estilos para la nueva Barra de Navegación */
.navbar {
background-color: #343a40; /* Un color oscuro para el fondo */
padding: 0 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-links {
display: flex;
}
.nav-link {
background: none;
border: none;
color: #adb5bd; /* Color de texto gris claro */
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 */
}
.nav-link:hover {
color: #ffffff; /* Texto blanco al pasar el ratón */
}
.nav-link-active {
color: #ffffff;
border-bottom: 3px solid #007bff; /* Indicador azul para la vista activa */
}
.app-title {
font-size: 1.5rem;
color: #ffffff;
font-weight: bold;
}

View File

@@ -1,11 +1,25 @@
import { useState } from 'react';
import SimpleTable from "./components/SimpleTable";
import GestionSectores from "./components/GestionSectores";
import GestionComponentes from './components/GestionComponentes';
import Navbar from './components/Navbar';
import './App.css';
export type View = 'equipos' | 'sectores' | 'admin';
function App() {
const [currentView, setCurrentView] = useState<View>('equipos');
return (
<main>
<SimpleTable />
</main>
<>
<Navbar currentView={currentView} setCurrentView={setCurrentView} />
<main>
{currentView === 'equipos' && <SimpleTable />}
{currentView === 'sectores' && <GestionSectores />}
{currentView === 'admin' && <GestionComponentes />}
</main>
</>
);
}

View File

@@ -0,0 +1,66 @@
import React, { useState, useEffect } from 'react';
interface AutocompleteInputProps {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
name: string;
placeholder?: string;
// CAMBIO: La función ahora recibe el término de búsqueda
fetchSuggestions: (query: string) => Promise<string[]>;
className?: string;
}
const AutocompleteInput: React.FC<AutocompleteInputProps> = ({
value,
onChange,
name,
placeholder,
fetchSuggestions,
className
}) => {
const [suggestions, setSuggestions] = useState<string[]>([]);
const dataListId = `suggestions-for-${name}`;
// CAMBIO: Lógica de "debouncing" para buscar mientras se escribe
useEffect(() => {
// No buscar si el input está vacío o es muy corto
if (value.length < 2) {
setSuggestions([]);
return;
}
// Configura un temporizador para esperar 300ms después de la última pulsación
const handler = setTimeout(() => {
fetchSuggestions(value)
.then(setSuggestions)
.catch(err => console.error(`Error fetching suggestions for ${name}:`, err));
}, 300);
// Limpia el temporizador si el usuario sigue escribiendo
return () => {
clearTimeout(handler);
};
}, [value, fetchSuggestions, name]);
return (
<>
<input
type="text"
name={name}
value={value}
onChange={onChange}
placeholder={placeholder}
className={className}
list={dataListId}
autoComplete="off" // Importante para que no interfiera el autocompletado del navegador
/>
<datalist id={dataListId}>
{suggestions.map((suggestion, index) => (
<option key={index} value={suggestion} />
))}
</datalist>
</>
);
};
export default AutocompleteInput;

View File

@@ -0,0 +1,217 @@
import { useState, useEffect } from 'react';
import toast from 'react-hot-toast';
import styles from './SimpleTable.module.css';
const BASE_URL = 'http://localhost:5198/api';
// Interfaces para los diferentes tipos de datos
interface TextValue {
valor: string;
conteo: number;
}
interface RamValue {
id: number;
fabricante?: string;
tamano: number;
velocidad?: number;
partNumber?: string;
conteo: number;
}
const GestionComponentes = () => {
const [componentType, setComponentType] = useState('os');
const [valores, setValores] = useState<(TextValue | RamValue)[]>([]); // Estado que acepta ambos tipos
const [isLoading, setIsLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [valorAntiguo, setValorAntiguo] = useState('');
const [valorNuevo, setValorNuevo] = useState('');
useEffect(() => {
setIsLoading(true);
const endpoint = componentType === 'ram' ? `${BASE_URL}/admin/componentes/ram` : `${BASE_URL}/admin/componentes/${componentType}`;
fetch(endpoint)
.then(res => res.json())
.then(data => {
setValores(data);
})
.catch(_err => {
toast.error(`No se pudieron cargar los datos de ${componentType}.`);
})
.finally(() => setIsLoading(false));
}, [componentType]);
const handleOpenModal = (valor: string) => {
setValorAntiguo(valor);
setValorNuevo(valor);
setIsModalOpen(true);
};
const handleUnificar = async () => {
const toastId = toast.loading('Unificando valores...');
try {
const response = await fetch(`${BASE_URL}/admin/componentes/${componentType}/unificar`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ valorAntiguo, valorNuevo }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'La unificación falló.');
}
// Refrescar la lista para ver el resultado
const refreshedData = await (await fetch(`${BASE_URL}/admin/componentes/${componentType}`)).json();
setValores(refreshedData);
toast.success('Valores unificados correctamente.', { id: toastId });
setIsModalOpen(false);
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
const handleDeleteRam = async (ramId: number) => {
if (!window.confirm("¿Estás seguro de eliminar este módulo de RAM de la base de datos maestra? Esta acción es irreversible.")) {
return;
}
const toastId = toast.loading('Eliminando módulo...');
try {
const response = await fetch(`${BASE_URL}/admin/componentes/ram/${ramId}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'No se pudo eliminar.');
}
setValores(prev => prev.filter(v => (v as RamValue).id !== ramId));
toast.success("Módulo de RAM eliminado.", { id: toastId });
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
const handleDeleteTexto = async (valor: string) => {
if (!window.confirm(`Este valor ya no está en uso. ¿Quieres intentar eliminarlo de la base de datos maestra? (Si no existe una tabla maestra, esta acción solo confirmará que no hay usos)`)) {
return;
}
const toastId = toast.loading('Eliminando valor...');
try {
// La API necesita el valor codificado para manejar caracteres especiales como '/'
const encodedValue = encodeURIComponent(valor);
const response = await fetch(`${BASE_URL}/admin/componentes/${componentType}/${encodedValue}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'No se pudo eliminar.');
}
setValores(prev => prev.filter(v => (v as TextValue).valor !== valor));
toast.success("Valor eliminado/confirmado como no existente.", { id: toastId });
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
const renderValor = (item: TextValue | RamValue) => {
if (componentType === 'ram') {
const ram = item as RamValue;
return `${ram.fabricante || ''} ${ram.tamano}GB ${ram.velocidad ? ram.velocidad + 'MHz' : ''} (${ram.partNumber || 'N/P'})`;
}
return (item as TextValue).valor;
};
return (
<div>
<h2>Gestión de Componentes Maestros</h2>
<p>Unifica valores inconsistentes y elimina registros no utilizados.</p>
<div style={{ marginBottom: '1.5rem' }}>
<label><strong>Selecciona un tipo de componente:</strong></label>
<select value={componentType} onChange={e => setComponentType(e.target.value)} className={styles.sectorSelect} style={{marginLeft: '10px'}}>
<option value="os">Sistema Operativo</option>
<option value="cpu">CPU</option>
<option value="motherboard">Motherboard</option>
<option value="architecture">Arquitectura</option>
<option value="ram">Memorias RAM</option>
</select>
</div>
{isLoading ? (
<p>Cargando...</p>
) : (
<table className={styles.table}>
<thead>
<tr>
<th className={styles.th}>Valor Registrado</th>
<th className={styles.th} style={{width: '150px'}}> de Equipos</th>
<th className={styles.th} style={{width: '200px'}}>Acciones</th>
</tr>
</thead>
<tbody>
{valores.map((item) => (
<tr key={componentType === 'ram' ? (item as RamValue).id : (item as TextValue).valor} className={styles.tr}>
<td className={styles.td}>{renderValor(item)}</td>
<td className={styles.td}>{item.conteo}</td>
<td className={styles.td}>
<div style={{display: 'flex', gap: '5px'}}>
{componentType === 'ram' ? (
// Lógica solo para RAM (no tiene sentido "unificar" un objeto complejo)
<button
onClick={() => handleDeleteRam((item as RamValue).id)}
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 módulo maestro'}
>
🗑 Eliminar
</button>
) : (
// Lógica para todos los demás tipos de componentes (texto)
<>
<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>
</td>
</tr>
))}
</tbody>
</table>
)}
{isModalOpen && (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<h3>Unificar Valor</h3>
<p>Se reemplazarán todas las instancias de:</p>
<strong style={{ display: 'block', marginBottom: '1rem', background: '#e9ecef', padding: '8px', borderRadius: '4px' }}>{valorAntiguo}</strong>
<label>Por el nuevo valor:</label>
<input type="text" value={valorNuevo} onChange={e => setValorNuevo(e.target.value)} className={styles.modalInput} />
<div className={styles.modalActions}>
<button onClick={handleUnificar} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!valorNuevo.trim() || valorNuevo === valorAntiguo}>Unificar</button>
<button onClick={() => setIsModalOpen(false)} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
</div>
</div>
</div>
)}
</div>
);
};
export default GestionComponentes;

View File

@@ -0,0 +1,142 @@
import { useState, useEffect } from 'react';
import toast from 'react-hot-toast';
import type { Sector } from '../types/interfaces';
import styles from './SimpleTable.module.css';
import ModalSector from './ModalSector';
const BASE_URL = 'http://localhost:5198/api';
const GestionSectores = () => {
const [sectores, setSectores] = useState<Sector[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingSector, setEditingSector] = useState<Sector | null>(null);
useEffect(() => {
fetch(`${BASE_URL}/sectores`)
.then(res => res.json())
.then((data: Sector[]) => {
setSectores(data);
setIsLoading(false);
})
.catch(err => {
toast.error("No se pudieron cargar los sectores.");
console.error(err);
setIsLoading(false);
});
}, []);
const handleOpenCreateModal = () => {
setEditingSector(null); // Poner en modo 'crear'
setIsModalOpen(true);
};
const handleOpenEditModal = (sector: Sector) => {
setEditingSector(sector); // Poner en modo 'editar' con los datos del sector
setIsModalOpen(true);
};
const handleSave = async (id: number | null, nombre: string) => {
const isEditing = id !== null;
const url = isEditing ? `${BASE_URL}/sectores/${id}` : `${BASE_URL}/sectores`;
const method = isEditing ? 'PUT' : 'POST';
const toastId = toast.loading(isEditing ? 'Actualizando...' : 'Creando...');
try {
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nombre }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'La operación falló.');
}
if (isEditing) {
// Actualizar el sector en la lista local
setSectores(prev => prev.map(s => s.id === id ? { ...s, nombre } : s));
toast.success('Sector actualizado.', { id: toastId });
} else {
// Añadir el nuevo sector a la lista local
const nuevoSector = await response.json();
setSectores(prev => [...prev, nuevoSector]);
toast.success('Sector creado.', { id: toastId });
}
setIsModalOpen(false); // Cerrar el modal
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
const handleDelete = async (id: number) => {
if (!window.confirm("¿Estás seguro de eliminar este sector? Los equipos asociados quedarán sin sector.")) {
return;
}
const toastId = toast.loading('Eliminando...');
try {
const response = await fetch(`${BASE_URL}/sectores/${id}`, { method: 'DELETE' });
if (response.status === 409) {
throw new Error("No se puede eliminar. Hay equipos asignados a este sector.");
}
if (!response.ok) {
throw new Error("El sector no se pudo eliminar.");
}
setSectores(prev => prev.filter(s => s.id !== id));
toast.success("Sector eliminado.", { id: toastId });
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
if (isLoading) {
return <div>Cargando sectores...</div>;
}
return (
<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>
</div>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.th}>Nombre del Sector</th>
<th className={styles.th} style={{ width: '200px' }}>Acciones</th>
</tr>
</thead>
<tbody>
{sectores.map(sector => (
<tr key={sector.id} className={styles.tr}>
<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>
</div>
</td>
</tr>
))}
</tbody>
</table>
{isModalOpen && (
<ModalSector
sector={editingSector}
onClose={() => setIsModalOpen(false)}
onSave={handleSave}
/>
)}
</div>
);
};
export default GestionSectores;

View File

@@ -0,0 +1,46 @@
// frontend/src/components/ModalAnadirDisco.tsx
import React, { useState } from 'react';
import styles from './SimpleTable.module.css';
interface Props {
onClose: () => void;
onSave: (disco: { mediatype: string, size: number }) => void;
}
const ModalAnadirDisco: React.FC<Props> = ({ onClose, onSave }) => {
const [mediatype, setMediatype] = useState('SSD');
const [size, setSize] = useState('');
const handleSave = () => {
if (size && parseInt(size, 10) > 0) {
onSave({ mediatype, size: parseInt(size, 10) });
}
};
return (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<h3>Añadir Disco Manualmente</h3>
<label>Tipo de Disco</label>
<select value={mediatype} onChange={e => setMediatype(e.target.value)} className={styles.modalInput}>
<option value="SSD">SSD</option>
<option value="HDD">HDD</option>
</select>
<label>Tamaño (GB)</label>
<input
type="number"
value={size}
onChange={e => setSize(e.target.value)}
className={styles.modalInput}
placeholder="Ej: 500"
/>
<div className={styles.modalActions}>
<button onClick={handleSave} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!size}>Guardar</button>
<button onClick={onClose} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
</div>
</div>
</div>
);
};
export default ModalAnadirDisco;

View File

@@ -0,0 +1,125 @@
// frontend/src/components/ModalAnadirEquipo.tsx
import React, { useState } from 'react';
import type { Sector, Equipo } from '../types/interfaces';
import AutocompleteInput from './AutocompleteInput';
import styles from './SimpleTable.module.css';
interface ModalAnadirEquipoProps {
sectores: Sector[];
onClose: () => void;
onSave: (nuevoEquipo: Omit<Equipo, 'id' | 'created_at' | 'updated_at'>) => void;
}
const BASE_URL = 'http://localhost:5198/api';
const ModalAnadirEquipo: React.FC<ModalAnadirEquipoProps> = ({ sectores, onClose, onSave }) => {
const [nuevoEquipo, setNuevoEquipo] = useState({
hostname: '',
ip: '',
motherboard: '',
cpu: '',
os: '',
sector_id: undefined as number | undefined,
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setNuevoEquipo(prev => ({
...prev,
[name]: name === 'sector_id' ? (value ? parseInt(value, 10) : undefined) : value,
}));
};
const handleSaveClick = () => {
// La UI pasará un objeto compatible con el DTO del backend
onSave(nuevoEquipo as any);
};
const isFormValid = nuevoEquipo.hostname.trim() !== '' && nuevoEquipo.ip.trim() !== '';
return (
<div className={styles.modalOverlay}>
<div className={styles.modal} style={{ minWidth: '500px' }}>
<h3>Añadir Nuevo Equipo Manualmente</h3>
<label>Hostname (Requerido)</label>
<input
type="text"
name="hostname"
value={nuevoEquipo.hostname}
onChange={handleChange}
className={styles.modalInput}
placeholder="Ej: CONTABILIDAD-01"
/>
<label>Dirección IP (Requerido)</label>
<input
type="text"
name="ip"
value={nuevoEquipo.ip}
onChange={handleChange}
className={styles.modalInput}
placeholder="Ej: 192.168.1.50"
/>
<label>Sector</label>
<select
name="sector_id"
value={nuevoEquipo.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>
<label>Motherboard (Opcional)</label>
<AutocompleteInput
name="motherboard"
value={nuevoEquipo.motherboard}
onChange={handleChange}
className={styles.modalInput}
fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/motherboard`).then(res => res.json())}
/>
<label>CPU (Opcional)</label>
<AutocompleteInput
name="cpu"
value={nuevoEquipo.cpu}
onChange={handleChange}
className={styles.modalInput}
fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/cpu`).then(res => res.json())}
/>
<label>Sistema Operativo (Opcional)</label>
<AutocompleteInput
name="os"
value={nuevoEquipo.os}
onChange={handleChange}
className={styles.modalInput}
fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/os`).then(res => res.json())}
/>
<div className={styles.modalActions}>
<button
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={handleSaveClick}
disabled={!isFormValid}
>
Guardar Equipo
</button>
<button
className={`${styles.btn} ${styles.btnSecondary}`}
onClick={onClose}
>
Cancelar
</button>
</div>
</div>
</div>
);
};
export default ModalAnadirEquipo;

View File

@@ -0,0 +1,47 @@
// frontend/src/components/ModalAnadirRam.tsx
import React, { useState } from 'react';
import styles from './SimpleTable.module.css';
interface Props {
onClose: () => void;
onSave: (ram: { slot: string, tamano: number, fabricante?: string, velocidad?: number }) => void;
}
const ModalAnadirRam: React.FC<Props> = ({ onClose, onSave }) => {
const [ram, setRam] = useState({ slot: '', tamano: '', fabricante: '', velocidad: '' });
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setRam(prev => ({ ...prev, [e.target.name]: e.target.value }));
};
const handleSave = () => {
onSave({
slot: ram.slot,
tamano: parseInt(ram.tamano, 10),
fabricante: ram.fabricante || undefined,
velocidad: ram.velocidad ? parseInt(ram.velocidad, 10) : undefined,
});
};
return (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<h3>Añadir Módulo de RAM</h3>
<label>Slot (Requerido)</label>
<input type="text" name="slot" value={ram.slot} onChange={handleChange} className={styles.modalInput} placeholder="Ej: DIMM0" />
<label>Tamaño (GB) (Requerido)</label>
<input type="number" name="tamano" value={ram.tamano} onChange={handleChange} className={styles.modalInput} placeholder="Ej: 8" />
<label>Fabricante (Opcional)</label>
<input type="text" name="fabricante" value={ram.fabricante} onChange={handleChange} className={styles.modalInput} />
<label>Velocidad (MHz) (Opcional)</label>
<input type="number" name="velocidad" value={ram.velocidad} onChange={handleChange} className={styles.modalInput} />
<div className={styles.modalActions}>
<button onClick={handleSave} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!ram.slot || !ram.tamano}>Guardar</button>
<button onClick={onClose} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
</div>
</div>
</div>
);
};
export default ModalAnadirRam;

View File

@@ -0,0 +1,44 @@
import React, { useState } from 'react';
import styles from './SimpleTable.module.css';
import AutocompleteInput from './AutocompleteInput';
interface Props {
onClose: () => void;
onSave: (usuario: { username: string }) => void;
}
const BASE_URL = 'http://localhost:5198/api';
const ModalAnadirUsuario: React.FC<Props> = ({ onClose, onSave }) => {
const [username, setUsername] = useState('');
const fetchUserSuggestions = async (query: string): Promise<string[]> => {
if (!query) return [];
const response = await fetch(`${BASE_URL}/usuarios/buscar/${query}`);
if (!response.ok) return [];
return response.json();
};
return (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<h3>Añadir Usuario Manualmente</h3>
<label>Nombre de Usuario</label>
<AutocompleteInput
name="username"
value={username}
onChange={e => setUsername(e.target.value)}
className={styles.modalInput}
fetchSuggestions={fetchUserSuggestions}
placeholder="Escribe para buscar o crear un nuevo usuario"
/>
<div className={styles.modalActions}>
<button onClick={() => onSave({ username })} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!username.trim()}>Guardar</button>
<button onClick={onClose} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
</div>
</div>
</div>
);
};
export default ModalAnadirUsuario;

View File

@@ -0,0 +1,65 @@
// frontend/src/components/ModalCambiarClave.tsx
import React, { useState, useEffect, useRef } from 'react';
import type { Usuario } from '../types/interfaces';
import styles from './SimpleTable.module.css';
interface ModalCambiarClaveProps {
usuario: Usuario; // El componente padre asegura que esto no sea nulo
onClose: () => void;
onSave: (password: string) => void;
}
const ModalCambiarClave: React.FC<ModalCambiarClaveProps> = ({ usuario, onClose, onSave }) => {
const [newPassword, setNewPassword] = useState('');
const passwordInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// Enfocar el input cuando el modal se abre
setTimeout(() => passwordInputRef.current?.focus(), 100);
}, []);
const handleSaveClick = () => {
if (newPassword.trim()) {
onSave(newPassword);
}
};
return (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<h3>
Cambiar contraseña para {usuario.username}
</h3>
<label>
Nueva contraseña:
<input
type="text"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className={styles.modalInput}
placeholder="Ingrese la nueva contraseña"
ref={passwordInputRef}
onKeyDown={(e) => e.key === 'Enter' && handleSaveClick()}
/>
</label>
<div className={styles.modalActions}>
<button
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={handleSaveClick}
disabled={!newPassword.trim()}
>
Guardar
</button>
<button
className={`${styles.btn} ${styles.btnSecondary}`}
onClick={onClose}
>
Cancelar
</button>
</div>
</div>
</div>
);
};
export default ModalCambiarClave;

View File

@@ -0,0 +1,198 @@
// frontend/src/components/ModalDetallesEquipo.tsx
import React, { useState, useEffect } from 'react';
import type { Equipo, HistorialEquipo, Sector } from '../types/interfaces';
import { Tooltip } from 'react-tooltip';
import styles from './SimpleTable.module.css';
import toast from 'react-hot-toast';
import AutocompleteInput from './AutocompleteInput';
// Interfaces actualizadas para las props
interface ModalDetallesEquipoProps {
equipo: Equipo;
isOnline: boolean;
historial: HistorialEquipo[];
sectores: Sector[];
onClose: () => void;
onDelete: (id: number) => Promise<boolean>;
onRemoveAssociation: (type: 'disco' | 'ram' | 'usuario', id: any) => void;
onEdit: (id: number, equipoEditado: any) => Promise<boolean>;
onAddComponent: (type: 'disco' | 'ram' | 'usuario') => void;
}
const BASE_URL = 'http://localhost:5198/api';
const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
equipo, isOnline, historial, sectores, onClose, onDelete, onRemoveAssociation, onEdit, onAddComponent
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editableEquipo, setEditableEquipo] = useState({ ...equipo });
const [isMacValid, setIsMacValid] = useState(true);
const macRegex = /^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$/;
useEffect(() => {
if (editableEquipo.mac && editableEquipo.mac.length > 0) {
setIsMacValid(macRegex.test(editableEquipo.mac));
} else {
setIsMacValid(true);
}
}, [editableEquipo.mac]);
useEffect(() => {
setEditableEquipo({ ...equipo });
}, [equipo]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setEditableEquipo(prev => ({
...prev,
[name]: name === 'sector_id' ? (value ? parseInt(value, 10) : null) : value,
}));
};
const handleMacBlur = (e: React.FocusEvent<HTMLInputElement>) => {
let value = e.target.value;
let cleaned = value.replace(/[^0-9A-Fa-f]/gi, '').toUpperCase().substring(0, 12);
if (cleaned.length === 12) {
value = cleaned.match(/.{1,2}/g)?.join(':') || '';
} else {
value = cleaned;
}
setEditableEquipo(prev => ({ ...prev, mac: value }));
};
const handleSave = async () => {
if (!isMacValid) {
toast.error("El formato de la MAC Address es incorrecto.");
return;
}
const success = await onEdit(equipo.id, editableEquipo);
if (success) setIsEditing(false);
};
const handleCancel = () => {
setEditableEquipo({ ...equipo });
setIsMacValid(true);
setIsEditing(false);
};
const handleWolClick = async () => {
if (!equipo.mac || !equipo.ip) {
toast.error("Este equipo no tiene MAC o IP para encenderlo.");
return;
}
const toastId = toast.loading('Enviando paquete WOL...');
try {
const response = await fetch(`${BASE_URL}/equipos/wake-on-lan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mac: equipo.mac, ip: equipo.ip })
});
if (!response.ok) throw new Error("La respuesta del servidor no fue exitosa.");
toast.success('Solicitud de encendido enviada.', { id: toastId });
} catch (error) {
toast.error('Error al enviar la solicitud.', { id: toastId });
console.error('Error al enviar la solicitud WOL:', error);
}
};
const handleDeleteClick = async () => {
const success = await onDelete(equipo.id);
if (success) onClose();
};
const formatDate = (dateString: string | undefined | null) => {
if (!dateString || dateString.startsWith('0001-01-01')) return 'No registrado';
return new Date(dateString).toLocaleString('es-ES', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
});
};
const isSaveDisabled = !editableEquipo.hostname || !editableEquipo.ip || !isMacValid;
return (
<div className={styles.modalLarge}>
<button onClick={onClose} className={styles.closeButton}>×</button>
<div className={styles.modalLargeContent}>
<div className={styles.modalLargeHeader}>
<h2>Detalles del equipo: <strong>{equipo.hostname}</strong></h2>
{equipo.origen === 'manual' && (
<div style={{ display: 'flex', gap: '10px' }}>
{isEditing ? (
<>
<button onClick={handleSave} className={`${styles.btn} ${styles.btnPrimary}`} disabled={isSaveDisabled}>Guardar Cambios</button>
<button onClick={handleCancel} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
</>
) : (
<button onClick={() => setIsEditing(true)} className={`${styles.btn} ${styles.btnPrimary}`}> Editar</button>
)}
</div>
)}
</div>
<div className={styles.modalBodyColumns}>
{/* COLUMNA PRINCIPAL */}
<div className={styles.mainColumn}>
{/* SECCIÓN DE DATOS PRINCIPALES */}
<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>
{equipo.origen === 'manual' && (<div style={{ display: 'flex', gap: '5px' }}><button onClick={() => onAddComponent('disco')} className={styles.tableButton}>+ Disco</button><button onClick={() => onAddComponent('ram')} className={styles.tableButton}>+ RAM</button><button onClick={() => onAddComponent('usuario')} className={styles.tableButton}>+ Usuario</button></div>)}
</div>
<div className={styles.componentsGrid}>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Hostname:</strong>{isEditing ? <input type="text" name="hostname" value={editableEquipo.hostname} onChange={handleChange} className={styles.modalInput} /> : <span className={styles.detailValue}>{equipo.hostname}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>IP:</strong>{isEditing ? <input type="text" name="ip" value={editableEquipo.ip} onChange={handleChange} className={styles.modalInput} /> : <span className={styles.detailValue}>{equipo.ip}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>MAC Address:</strong>{isEditing ? (<div><input type="text" name="mac" value={editableEquipo.mac || ''} onChange={handleChange} onBlur={handleMacBlur} className={`${styles.modalInput} ${!isMacValid ? styles.inputError : ''}`} placeholder="FC:AA:14:92:12:99" />{!isMacValid && <small className={styles.errorMessage}>Formato inválido.</small>}</div>) : (<span className={styles.detailValue}>{equipo.mac || 'N/A'}</span>)}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Sistema Operativo:</strong>{isEditing ? <AutocompleteInput name="os" value={editableEquipo.os} onChange={handleChange} className={styles.modalInput} fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/os`).then(res => res.json())} /> : <span className={styles.detailValue}>{equipo.os || 'N/A'}</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}>Ú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>
</div>
{/* SECCIÓN DE COMPONENTES */}
<div className={styles.section}>
<h3 className={styles.sectionTitle}>💻 Componentes</h3>
<div className={styles.detailsGrid}>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Motherboard:</strong>{isEditing ? <AutocompleteInput name="motherboard" value={editableEquipo.motherboard} onChange={handleChange} className={styles.modalInput} fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/motherboard`).then(res => res.json())} /> : <span className={styles.detailValue}>{equipo.motherboard || 'N/A'}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>CPU:</strong>{isEditing ? <AutocompleteInput name="cpu" value={editableEquipo.cpu} onChange={handleChange} className={styles.modalInput} fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/cpu`).then(res => res.json())} /> : <span className={styles.detailValue}>{equipo.cpu || 'N/A'}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>RAM Instalada:</strong><span className={styles.detailValue}>{equipo.ram_installed} GB</span></div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Arquitectura:</strong><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.detailItem}><strong className={styles.detailLabel}>Slots RAM:</strong><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>
</div>
</div>
{/* COLUMNA LATERAL */}
<div className={styles.sidebarColumn}>
{/* SECCIÓN DE ACCIONES */}
<div className={styles.section}>
<h3 className={styles.sectionTitle}> 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>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Wake On Lan:</strong><button onClick={handleWolClick} className={styles.powerButton} data-tooltip-id="modal-power-tooltip"><img src="/img/power.png" alt="Encender equipo" className={styles.powerIcon} />Encender (WOL)</button><Tooltip id="modal-power-tooltip" place="top">Encender equipo remotamente</Tooltip></div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Eliminar Equipo:</strong><button onClick={handleDeleteClick} className={styles.deleteButton} disabled={equipo.origen !== 'manual'} style={{ cursor: equipo.origen !== 'manual' ? 'not-allowed' : 'pointer' }} data-tooltip-id="modal-delete-tooltip">🗑 Eliminar</button><Tooltip id="modal-delete-tooltip" place="top">{equipo.origen === 'manual' ? 'Eliminar equipo permanentemente' : 'No se puede eliminar un equipo cargado automáticamente'}</Tooltip></div>
</div>
</div>
</div>
</div>
{/* SECCIÓN DE HISTORIAL (FUERA DE LAS COLUMNAS) */}
<div className={`${styles.section} ${styles.historySectionFullWidth}`}>
<h3 className={styles.sectionTitle}>📜 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>
<tbody>{historial.sort((a, b) => new Date(b.fecha_cambio).getTime() - new Date(a.fecha_cambio).getTime()).map((cambio, index) => (<tr key={index} className={styles.historyTr}><td className={styles.historyTd}>{formatDate(cambio.fecha_cambio)}</td><td className={styles.historyTd}>{cambio.campo_modificado}</td><td className={styles.historyTd}>{cambio.valor_anterior}</td><td className={styles.historyTd}>{cambio.valor_nuevo}</td></tr>))}</tbody>
</table>
</div>
</div>
</div>
</div>
);
};
export default ModalDetallesEquipo;

View File

@@ -0,0 +1,57 @@
// frontend/src/components/ModalEditarSector.tsx
import React from 'react';
import type { Equipo, Sector } from '../types/interfaces';
import styles from './SimpleTable.module.css';
interface ModalEditarSectorProps {
modalData: Equipo; // El componente padre asegura que esto no sea nulo
setModalData: (data: Equipo) => void;
sectores: Sector[];
onClose: () => void;
onSave: () => void;
}
const ModalEditarSector: React.FC<ModalEditarSectorProps> = ({ modalData, setModalData, sectores, onClose, onSave }) => {
return (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<h3>Editar Sector para {modalData.hostname}</h3>
<label>
Sector:
<select
className={styles.modalInput}
value={modalData.sector?.id || ""}
onChange={(e) => {
const selectedId = e.target.value;
const nuevoSector = selectedId === "" ? undefined : sectores.find(s => s.id === Number(selectedId));
setModalData({ ...modalData, sector: nuevoSector });
}}
>
<option value="">Asignar</option>
{sectores.map(sector => (
<option key={sector.id} value={sector.id}>{sector.nombre}</option>
))}
</select>
</label>
<div className={styles.modalActions}>
<button
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={onSave}
disabled={!modalData.sector}
>
Guardar cambios
</button>
<button
className={`${styles.btn} ${styles.btnSecondary}`}
onClick={onClose}
>
Cancelar
</button>
</div>
</div>
</div>
);
};
export default ModalEditarSector;

View File

@@ -0,0 +1,59 @@
import React, { useState, useEffect, useRef } from 'react';
import type { Sector } from '../types/interfaces';
import styles from './SimpleTable.module.css';
interface Props {
// Si 'sector' es nulo, es para crear. Si tiene datos, es para editar.
sector: Sector | null;
onClose: () => void;
onSave: (id: number | null, nombre: string) => void;
}
const ModalSector: React.FC<Props> = ({ sector, onClose, onSave }) => {
const [nombre, setNombre] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const isEditing = sector !== null;
useEffect(() => {
// Si estamos editando, rellenamos el campo con el nombre actual
if (isEditing) {
setNombre(sector.nombre);
}
// Enfocar el input al abrir el modal
setTimeout(() => inputRef.current?.focus(), 100);
}, [sector, isEditing]);
const handleSave = () => {
if (nombre.trim()) {
onSave(isEditing ? sector.id : null, nombre.trim());
}
};
return (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<h3>{isEditing ? 'Editar Sector' : 'Añadir Nuevo Sector'}</h3>
<label>Nombre del Sector</label>
<input
ref={inputRef}
type="text"
value={nombre}
onChange={e => setNombre(e.target.value)}
className={styles.modalInput}
onKeyDown={e => e.key === 'Enter' && handleSave()}
/>
<div className={styles.modalActions}>
<button onClick={handleSave} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!nombre.trim()}>
Guardar
</button>
<button onClick={onClose} className={`${styles.btn} ${styles.btnSecondary}`}>
Cancelar
</button>
</div>
</div>
</div>
);
};
export default ModalSector;

View File

@@ -0,0 +1,41 @@
// frontend/src/components/Navbar.tsx
import React from 'react';
import type { View } from '../App'; // Importaremos el tipo desde App.tsx
import '../App.css'; // Usaremos los estilos globales que acabamos de crear
interface NavbarProps {
currentView: View;
setCurrentView: (view: View) => void;
}
const Navbar: React.FC<NavbarProps> = ({ currentView, setCurrentView }) => {
return (
<header className="navbar">
<div className="app-title">
Inventario IT
</div>
<nav className="nav-links">
<button
className={`nav-link ${currentView === 'equipos' ? 'nav-link-active' : ''}`}
onClick={() => setCurrentView('equipos')}
>
Equipos
</button>
<button
className={`nav-link ${currentView === 'sectores' ? 'nav-link-active' : ''}`}
onClick={() => setCurrentView('sectores')}
>
Sectores
</button>
<button
className={`nav-link ${currentView === 'admin' ? 'nav-link-active' : ''}`}
onClick={() => setCurrentView('admin')}
>
Administración
</button>
</nav>
</header>
);
};
export default Navbar;

View File

@@ -0,0 +1,490 @@
/* Estilos para el contenedor principal y controles */
.controlsContainer {
display: flex;
gap: 20px;
margin-bottom: 10px;
align-items: center;
}
.searchInput, .sectorSelect {
padding: 8px 12px;
border-radius: 6px;
border: 1px solid #ced4da;
font-size: 14px;
}
/* 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 */
}
.th {
color: #212529;
font-weight: 600;
padding: 0.75rem 1rem;
border-bottom: 2px solid #dee2e6;
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 */
z-index: 2;
background-color: #f8f9fa; /* Es crucial tener un fondo sólido */
}
.sortIndicator {
margin-left: 0.5rem;
font-size: 1.2em;
display: inline-block;
transform: translateY(-1px);
color: #007bff;
min-width: 20px;
}
.tooltip{
z-index: 9999;
}
.tr {
transition: background-color 0.2s ease;
}
.tr:hover {
background-color: #f1f3f5;
}
.td {
padding: 0.75rem 1rem;
border-bottom: 1px solid #e9ecef;
color: #495057;
background-color: white;
}
/* Estilos de botones dentro de la tabla */
.hostnameButton {
background: none;
border: none;
color: #007bff;
cursor: pointer;
text-decoration: underline;
padding: 0;
font-size: inherit;
font-family: inherit;
}
.tableButton {
padding: 0.375rem 0.75rem;
border-radius: 4px;
border: 1px solid #dee2e6;
background-color: transparent;
color: #212529;
cursor: pointer;
transition: all 0.2s ease;
}
.tableButton:hover {
background-color: #e9ecef;
border-color: #adb5bd;
}
.deleteUserButton {
background: none;
border: none;
cursor: pointer;
color: #dc3545;
font-size: 1rem;
padding: 0 5px;
opacity: 0.7;
transition: opacity 0.3s ease, color 0.3s ease;
line-height: 1;
}
.deleteUserButton:hover {
opacity: 1;
color: #a4202e;
}
/* Estilo para el botón de scroll-to-top */
.scrollToTop {
position: fixed;
bottom: 60px;
right: 20px;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #007bff;
color: white;
border: none;
cursor: pointer;
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;
z-index: 1002;
}
.scrollToTop:hover {
transform: translateY(-3px);
background-color: #0056b3;
}
/* Estilos genéricos para modales */
.modalOverlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.4);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
}
.modal {
background-color: #ffffff;
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;
font-family: 'Segoe UI', sans-serif;
}
.modal h3 {
margin: 0 0 1.5rem;
color: #2d3436;
}
.modal label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.modalInput {
padding: 10px;
border-radius: 6px;
border: 1px solid #ced4da;
width: 100%;
box-sizing: border-box;
margin-top: 4px; /* Separado del label */
margin-bottom: 4px; /* Espacio antes del siguiente elemento */
}
.modalActions {
display: flex;
gap: 10px;
margin-top: 1.5rem;
justify-content: flex-end; /* Alinea los botones a la derecha por defecto */
}
/* Estilos de botones para modales */
.btn {
padding: 8px 20px;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
font-size: 14px;
}
.btnPrimary {
background-color: #007bff;
color: white;
}
.btnPrimary:hover {
background-color: #0056b3;
}
.btnPrimary:disabled {
background-color: #e9ecef;
color: #6c757d;
cursor: not-allowed;
}
.btnSecondary {
background-color: #6c757d;
color: white;
}
.btnSecondary:hover {
background-color: #5a6268;
}
/* ===== NUEVOS ESTILOS PARA EL MODAL DE DETALLES ===== */
.modalLarge {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: #f8f9fa; /* Un fondo ligeramente gris para el modal */
z-index: 1003;
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 2rem;
box-sizing: border-box;
}
.modalLargeContent {
max-width: 1400px; /* Ancho máximo del contenido */
width: 100%;
margin: 0 auto; /* Centrar el contenido */
}
.modalLargeHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.05rem;
padding-bottom: 0.05rem;
}
.modalLargeHeader h2 {
font-weight: 400;
font-size: 1.5rem;
color: #343a40;
}
.closeButton {
background: black;
color: white;
border: none;
border-radius: 50%;
width: 30px;
height: 30px;
cursor: pointer;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1004;
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;
}
.modalBodyColumns {
display: flex;
gap: 2rem;
}
.mainColumn {
flex: 3;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.sidebarColumn {
flex: 1;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.section {
background-color: #ffffff;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1.5rem;
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;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.detailsGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
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));
gap: 1rem;
}
.actionsGrid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
.detailItem, .detailItemFull {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 10px;
background-color: #f8f9fa;
border-radius: 4px;
border: 1px solid #e9ecef;
}
.detailLabel {
color: #6c757d;
font-size: 0.8rem;
font-weight: 700;
}
.detailValue {
color: #495057;
font-size: 0.9rem;
line-height: 1.4;
word-break: break-word;
}
.componentItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 2px 0;
}
.powerButton, .deleteButton {
background: none;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s ease;
width: 100%;
justify-content: center;
}
.powerButton:hover {
border-color: #007bff;
background-color: #e7f1ff;
color: #0056b3;
}
.powerIcon {
width: 20px;
height: 20px;
}
.deleteButton {
color: #dc3545;
}
.deleteButton:hover {
border-color: #dc3545;
background-color: #fbebee;
color: #a4202e;
}
.deleteButton:disabled {
color: #6c757d;
background-color: #e9ecef;
border-color: #dee2e6;
}
.historyContainer {
max-height: 400px;
overflow-y: auto;
border: 1px solid #dee2e6;
border-radius: 4px;
}
.historyTable {
width: 100%;
border-collapse: collapse;
}
.historyTh {
background-color: #f8f9fa;
padding: 12px;
text-align: left;
font-size: 0.875rem;
position: sticky;
top: 0;
}
.historyTd {
padding: 12px;
color: #495057;
font-size: 0.8125rem;
border-bottom: 1px solid #dee2e6;
}
.historyTr:last-child .historyTd {
border-bottom: none;
}
/* CAMBIO: Nueva clase para dar espacio a la sección de historial */
.historySectionFullWidth {
margin-top: 2rem;
}
.statusIndicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
}
.statusDot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.statusOnline {
background-color: #28a745;
box-shadow: 0 0 8px #28a74580;
}
.statusOffline {
background-color: #dc3545;
box-shadow: 0 0 8px #dc354580;
}
.inputError {
border-color: #dc3545;
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
}
.errorMessage {
color: #dc3545;
font-size: 0.8rem;
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; }

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,30 @@
// frontend/src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css' // Importaremos un CSS base aquí
import './index.css'
import { Toaster } from 'react-hot-toast' // Importamos el Toaster
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',
},
},
error: {
style: {
background: '#dc3545',
color: 'white',
},
},
}}
/>
</React.StrictMode>,
)

View File

@@ -13,6 +13,11 @@ export interface Usuario {
password?: string; // Es opcional ya que no siempre lo enviaremos
}
// CAMBIO: Añadimos el origen y el ID de la asociación
export interface UsuarioEquipoDetalle extends Usuario {
origen: 'manual' | 'automatica';
}
// Corresponde al modelo 'Disco'
export interface Disco {
id: number;
@@ -20,18 +25,26 @@ export interface Disco {
size: number;
}
// Corresponde al modelo 'MemoriaRam'
export interface MemoriaRam {
id: number;
partNumber?: string;
fabricante?: string;
tamano: number;
velocidad?: number;
// CAMBIO: Añadimos el origen y el ID de la asociación
export interface DiscoDetalle extends Disco {
equipoDiscoId: number; // El ID de la tabla equipos_discos
origen: 'manual' | 'automatica';
}
// Interfaz combinada para mostrar los detalles de la RAM en la tabla de equipos
export interface MemoriaRamDetalle extends MemoriaRam {
slot: string;
// Corresponde al modelo 'MemoriaRam'
export interface MemoriaRam {
id: number;
partNumber?: string;
fabricante?: string;
tamano: number;
velocidad?: number;
}
// CCorresponde al modelo 'MemoriaRamEquipoDetalle'
export interface MemoriaRamEquipoDetalle extends MemoriaRam {
equipoMemoriaRamId: number; // El ID de la tabla equipos_memorias_ram
slot: string;
origen: 'manual' | 'automatica';
}
// Corresponde al modelo 'HistorialEquipo'
@@ -59,11 +72,12 @@ export interface Equipo {
created_at: string;
updated_at: string;
sector_id?: number;
origen: 'manual' | 'automatica'; // Campo de origen para el equipo
// Propiedades de navegación que vienen de las relaciones (JOINs)
sector?: Sector;
usuarios: Usuario[];
discos: Disco[];
memoriasRam: MemoriaRamDetalle[];
usuarios: UsuarioEquipoDetalle[];
discos: DiscoDetalle[];
memoriasRam: MemoriaRamEquipoDetalle[];
historial: HistorialEquipo[];
}