Mejoras integrales en UI, lógica de negocio y auditoría

Este commit introduce una serie de mejoras significativas en toda la aplicación, abordando la experiencia de usuario, la consistencia de los datos, la robustez del backend y la implementación de un historial de cambios completo.

 **Funcionalidades y Mejoras (Features & Enhancements)**

*   **Historial de Auditoría Completo:**
    *   Se implementa el registro en el historial para todas las acciones CRUD manuales: creación de equipos, adición y eliminación de discos, RAM y usuarios.
    *   Los cambios de campos simples (IP, Hostname, etc.) ahora también se registran detalladamente.

*   **Consistencia de Datos Mejorada:**
    *   **RAM:** La selección de RAM en el modal de "Añadir RAM" y la vista de "Administración" ahora agrupan los módulos por especificaciones (Fabricante, Tamaño, Velocidad), eliminando las entradas duplicadas causadas por diferentes `part_number`.
    *   **Arquitectura:** El campo de edición para la arquitectura del equipo se ha cambiado de un input de texto a un selector con las opciones fijas "32 bits" y "64 bits".

*   **Experiencia de Usuario (UX) Optimizada:**
    *   El botón de "Wake On Lan" (WOL) ahora se deshabilita visualmente si el equipo no tiene una dirección MAC registrada.
    *   Se corrige el apilamiento de modales: los sub-modales (Añadir Disco/RAM/Usuario) ahora siempre aparecen por encima del modal principal de detalles y bloquean su cierre.
    *   El historial de cambios se actualiza en tiempo real en la interfaz después de añadir o eliminar un componente, sin necesidad de cerrar y reabrir el modal.

🐛 **Correcciones (Bug Fixes)**

*   **Actualización de Estado en Vivo:** Al añadir/eliminar un módulo de RAM, los campos "RAM Instalada" y "Última Actualización" ahora se recalculan en el backend y se actualizan instantáneamente en el frontend.
*   **Historial de Sectores Legible:** Se corrige el registro del historial para que al cambiar un sector se guarde el *nombre* del sector (ej. "Técnica") en lugar de su ID numérico.
*   **Formulario de Edición:** El dropdown de "Sector" en el modo de edición ahora preselecciona correctamente el sector asignado actualmente al equipo.
*   **Error Crítico al Añadir RAM:** Se soluciona un error del servidor (`Sequence contains more than one element`) que ocurría al añadir manualmente un tipo de RAM que ya existía con múltiples `part_number`. Se reemplazó `QuerySingleOrDefaultAsync` por `QueryFirstOrDefaultAsync` para mayor robustez.
*   **Eliminación Segura:** Se impide la eliminación de un sector si este tiene equipos asignados, protegiendo la integridad de los datos.

♻️ **Refactorización (Refactoring)**

*   **Servicio de API Centralizado:** Toda la lógica de llamadas `fetch` del frontend ha sido extraída de los componentes y centralizada en un único servicio (`apiService.ts`), mejorando drásticamente la mantenibilidad y organización del código.
*   **Optimización de Renders:** Se ha optimizado el rendimiento de los modales mediante el uso del hook `useCallback` para memorizar funciones que se pasan como props.
*   **Nulabilidad en C#:** Se han resuelto múltiples advertencias de compilación (`CS8620`) en el backend al especificar explícitamente los tipos de referencia anulables (`string?`), mejorando la seguridad de tipos del código.
This commit is contained in:
2025-10-08 13:27:44 -03:00
parent 177ad55962
commit 268c1c2bf9
22 changed files with 834 additions and 513 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -1,46 +1,55 @@
import React, { useState, useEffect } from 'react';
interface AutocompleteInputProps {
// --- Interfaces de Props más robustas usando una unión discriminada ---
type 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;
}
} & ( // Esto crea una unión: o es estático o es dinámico
| {
mode: 'static';
fetchSuggestions: () => Promise<string[]>; // No necesita 'query'
}
| {
mode: 'dynamic';
fetchSuggestions: (query: string) => Promise<string[]>; // Necesita 'query'
}
);
const AutocompleteInput: React.FC<AutocompleteInputProps> = ({
value,
onChange,
name,
placeholder,
fetchSuggestions,
className
}) => {
const AutocompleteInput: React.FC<AutocompleteInputProps> = (props) => {
const { value, onChange, name, placeholder, className } = props;
const [suggestions, setSuggestions] = useState<string[]>([]);
const dataListId = `suggestions-for-${name}`;
// CAMBIO: Lógica de "debouncing" para buscar mientras se escribe
// --- Lógica para el modo ESTÁTICO ---
// Se ejecuta UNA SOLA VEZ cuando el componente se monta
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)
if (props.mode === 'static') {
props.fetchSuggestions()
.then(setSuggestions)
.catch(err => console.error(`Error fetching suggestions for ${name}:`, err));
}, 300);
.catch(err => console.error(`Error fetching static suggestions for ${name}:`, err));
}
// La lista de dependencias asegura que solo se ejecute si estas props cambian (lo cual no harán)
}, [props.mode, props.fetchSuggestions, name]);
// Limpia el temporizador si el usuario sigue escribiendo
return () => {
clearTimeout(handler);
};
}, [value, fetchSuggestions, name]);
// --- Lógica para el modo DINÁMICO ---
// Se ejecuta cada vez que el usuario escribe, con un debounce
useEffect(() => {
if (props.mode === 'dynamic') {
if (value.length < 2) {
setSuggestions([]);
return;
}
const handler = setTimeout(() => {
props.fetchSuggestions(value)
.then(setSuggestions)
.catch(err => console.error(`Error fetching dynamic suggestions for ${name}:`, err));
}, 300);
return () => clearTimeout(handler);
}
}, [value, props.mode, props.fetchSuggestions, name]);
return (
<>
@@ -52,7 +61,7 @@ const AutocompleteInput: React.FC<AutocompleteInputProps> = ({
placeholder={placeholder}
className={className}
list={dataListId}
autoComplete="off" // Importante para que no interfiera el autocompletado del navegador
autoComplete="off"
/>
<datalist id={dataListId}>
{suggestions.map((suggestion, index) => (

View File

@@ -1,38 +1,33 @@
// frontend/src/components/GestionComponentes.tsx
import { useState, useEffect } from 'react';
import toast from 'react-hot-toast';
import styles from './SimpleTable.module.css';
const BASE_URL = '/api';
import { adminService } from '../services/apiService';
// 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 [valores, setValores] = useState<(TextValue | RamValue)[]>([]);
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())
adminService.getComponentValues(componentType)
.then(data => {
setValores(data);
})
@@ -51,21 +46,9 @@ const GestionComponentes = () => {
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();
await adminService.unifyComponentValues(componentType, valorAntiguo, valorNuevo);
const refreshedData = await adminService.getComponentValues(componentType);
setValores(refreshedData);
toast.success('Valores unificados correctamente.', { id: toastId });
setIsModalOpen(false);
} catch (error) {
@@ -73,54 +56,51 @@ const GestionComponentes = () => {
}
};
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.")) {
// 2. FUNCIÓN DELETE ACTUALIZADA: Ahora maneja un grupo
const handleDeleteRam = 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;
}
const toastId = toast.loading('Eliminando módulo...');
const toastId = toast.loading('Eliminando grupo de módulos...');
try {
const response = await fetch(`${BASE_URL}/admin/componentes/ram/${ramId}`, { method: 'DELETE' });
// El servicio ahora espera el objeto del grupo
await adminService.deleteRamComponent({
fabricante: ramGroup.fabricante,
tamano: ramGroup.tamano,
velocidad: ramGroup.velocidad
});
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 });
setValores(prev => prev.filter(v => {
const currentRam = v as RamValue;
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) {
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 });
}
};
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);
setValores(prev => prev.filter(v => (v as TextValue).valor !== valor));
toast.success("Valor eliminado.", { 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 `${ram.fabricante || 'Desconocido'} ${ram.tamano}GB ${ram.velocidad ? ram.velocidad + 'MHz' : ''}`;
}
return (item as TextValue).valor;
};
@@ -154,24 +134,22 @@ const GestionComponentes = () => {
</thead>
<tbody>
{valores.map((item) => (
<tr key={componentType === 'ram' ? (item as RamValue).id : (item as TextValue).valor} className={styles.tr}>
<tr key={componentType === 'ram' ? `${(item as RamValue).fabricante}-${(item as RamValue).tamano}-${(item as RamValue).velocidad}` : (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)}
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 módulo maestro'}
title={item.conteo > 0 ? `En uso por ${item.conteo} equipo(s)` : 'Eliminar este grupo de módulos maestros'}
>
🗑 Eliminar
</button>
) : (
// Lógica para todos los demás tipos de componentes (texto)
<>
<button onClick={() => handleOpenModal((item as TextValue).valor)} className={styles.tableButton}>
Unificar

View File

@@ -1,10 +1,11 @@
// frontend/src/components/GestionSectores.tsx
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 = '/api';
import { sectorService } from '../services/apiService';
const GestionSectores = () => {
const [sectores, setSectores] = useState<Sector[]>([]);
@@ -13,59 +14,44 @@ const GestionSectores = () => {
const [editingSector, setEditingSector] = useState<Sector | null>(null);
useEffect(() => {
fetch(`${BASE_URL}/sectores`)
.then(res => res.json())
.then((data: Sector[]) => {
sectorService.getAll()
.then(data => {
setSectores(data);
setIsLoading(false);
})
.catch(err => {
toast.error("No se pudieron cargar los sectores.");
console.error(err);
})
.finally(() => {
setIsLoading(false);
});
}, []);
const handleOpenCreateModal = () => {
setEditingSector(null); // Poner en modo 'crear'
setEditingSector(null);
setIsModalOpen(true);
};
const handleOpenEditModal = (sector: Sector) => {
setEditingSector(sector); // Poner en modo 'editar' con los datos del sector
setEditingSector(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
await sectorService.update(id, nombre);
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();
const nuevoSector = await sectorService.create(nombre);
setSectores(prev => [...prev, nuevoSector]);
toast.success('Sector creado.', { id: toastId });
}
setIsModalOpen(false); // Cerrar el modal
setIsModalOpen(false);
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
@@ -78,13 +64,7 @@ const GestionSectores = () => {
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.");
}
await sectorService.delete(id);
setSectores(prev => prev.filter(s => s.id !== id));
toast.success("Sector eliminado.", { id: toastId });
} catch (error) {
@@ -118,7 +98,7 @@ const GestionSectores = () => {
<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'}}>
<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>

View File

@@ -18,7 +18,7 @@ const ModalAnadirDisco: React.FC<Props> = ({ onClose, onSave }) => {
};
return (
<div className={styles.modalOverlay}>
<div className={`${styles.modalOverlay} ${styles['modalOverlay--nested']}`}>
<div className={styles.modal}>
<h3>Añadir Disco Manualmente</h3>
<label>Tipo de Disco</label>

View File

@@ -1,5 +1,6 @@
// frontend/src/components/ModalAnadirEquipo.tsx
import React, { useState } from 'react';
import React, { useState, useCallback } from 'react'; // <-- 1. Importar useCallback
import type { Sector, Equipo } from '../types/interfaces';
import AutocompleteInput from './AutocompleteInput';
import styles from './SimpleTable.module.css';
@@ -31,12 +32,17 @@ const ModalAnadirEquipo: React.FC<ModalAnadirEquipoProps> = ({ sectores, onClose
};
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() !== '';
// --- 2. Memorizar las funciones con useCallback ---
// El array vacío `[]` al final asegura que la función NUNCA se vuelva a crear.
const fetchMotherboardSuggestions = useCallback(() => fetch(`${BASE_URL}/equipos/distinct/motherboard`).then(res => res.json()), []);
const fetchCpuSuggestions = useCallback(() => fetch(`${BASE_URL}/equipos/distinct/cpu`).then(res => res.json()), []);
const fetchOsSuggestions = useCallback(() => fetch(`${BASE_URL}/equipos/distinct/os`).then(res => res.json()), []);
return (
<div className={styles.modalOverlay}>
<div className={styles.modal} style={{ minWidth: '500px' }}>
@@ -49,7 +55,8 @@ const ModalAnadirEquipo: React.FC<ModalAnadirEquipoProps> = ({ sectores, onClose
value={nuevoEquipo.hostname}
onChange={handleChange}
className={styles.modalInput}
placeholder="Ej: CONTABILIDAD-01"
placeholder="Ej: TECNICA10"
autoComplete="off"
/>
<label>Dirección IP (Requerido)</label>
@@ -59,7 +66,8 @@ const ModalAnadirEquipo: React.FC<ModalAnadirEquipoProps> = ({ sectores, onClose
value={nuevoEquipo.ip}
onChange={handleChange}
className={styles.modalInput}
placeholder="Ej: 192.168.1.50"
placeholder="Ej: 192.168.10.50"
autoComplete="off"
/>
<label>Sector</label>
@@ -75,31 +83,35 @@ const ModalAnadirEquipo: React.FC<ModalAnadirEquipoProps> = ({ sectores, onClose
))}
</select>
{/* --- 3. Usar las funciones memorizadas --- */}
<label>Motherboard (Opcional)</label>
<AutocompleteInput
mode="static"
name="motherboard"
value={nuevoEquipo.motherboard}
onChange={handleChange}
className={styles.modalInput}
fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/motherboard`).then(res => res.json())}
fetchSuggestions={fetchMotherboardSuggestions}
/>
<label>CPU (Opcional)</label>
<AutocompleteInput
mode="static"
name="cpu"
value={nuevoEquipo.cpu}
onChange={handleChange}
className={styles.modalInput}
fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/cpu`).then(res => res.json())}
fetchSuggestions={fetchCpuSuggestions}
/>
<label>Sistema Operativo (Opcional)</label>
<AutocompleteInput
mode="static"
name="os"
value={nuevoEquipo.os}
onChange={handleChange}
className={styles.modalInput}
fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/os`).then(res => res.json())}
fetchSuggestions={fetchOsSuggestions}
/>
<div className={styles.modalActions}>

View File

@@ -1,40 +1,100 @@
// frontend/src/components/ModalAnadirRam.tsx
import React, { useState } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import styles from './SimpleTable.module.css';
import AutocompleteInput from './AutocompleteInput';
import { memoriaRamService } from '../services/apiService';
import type { MemoriaRam } from '../types/interfaces';
interface Props {
onClose: () => void;
onSave: (ram: { slot: string, tamano: number, fabricante?: string, velocidad?: number }) => void;
onSave: (ram: { slot: string, tamano: number, fabricante?: string, velocidad?: number, partNumber?: string }) => void;
}
const ModalAnadirRam: React.FC<Props> = ({ onClose, onSave }) => {
const [ram, setRam] = useState({ slot: '', tamano: '', fabricante: '', velocidad: '' });
const [ram, setRam] = useState({
slot: '',
tamano: '',
fabricante: '',
velocidad: '',
partNumber: ''
});
const [allRamModules, setAllRamModules] = useState<MemoriaRam[]>([]);
useEffect(() => {
memoriaRamService.getAll()
.then(setAllRamModules)
.catch(err => console.error("No se pudieron cargar los módulos de RAM", err));
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setRam(prev => ({ ...prev, [e.target.name]: e.target.value }));
const { name, value } = e.target;
setRam(prev => ({ ...prev, [name]: value }));
};
const fetchRamSuggestions = useCallback(async () => {
return allRamModules.map(r =>
`${r.fabricante || 'Desconocido'} | ${r.tamano}GB | ${r.velocidad ? r.velocidad + 'MHz' : 'N/A'}`
);
}, [allRamModules]);
useEffect(() => {
const selectedSuggestion = ram.partNumber;
const match = allRamModules.find(s =>
`${s.fabricante || 'Desconocido'} | ${s.tamano}GB | ${s.velocidad ? s.velocidad + 'MHz' : 'N/A'}` === selectedSuggestion
);
if (match) {
setRam(prev => ({
...prev,
fabricante: match.fabricante || '',
tamano: match.tamano.toString(),
velocidad: match.velocidad?.toString() || '',
partNumber: match.partNumber || ''
}));
}
}, [ram.partNumber, allRamModules]);
const handleSave = () => {
onSave({
slot: ram.slot,
tamano: parseInt(ram.tamano, 10),
fabricante: ram.fabricante || undefined,
velocidad: ram.velocidad ? parseInt(ram.velocidad, 10) : undefined,
partNumber: ram.partNumber || undefined,
});
};
return (
<div className={styles.modalOverlay}>
<div className={`${styles.modalOverlay} ${styles['modalOverlay--nested']}`}>
<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>Buscar Módulo Existente (Opcional)</label>
<AutocompleteInput
mode="static"
name="partNumber"
value={ram.partNumber}
onChange={handleChange}
className={styles.modalInput}
fetchSuggestions={fetchRamSuggestions}
placeholder="Clic para ver todos o escribe para filtrar"
/>
<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>
<label>Fabricante</label>
<input type="text" name="fabricante" value={ram.fabricante} onChange={handleChange} className={styles.modalInput} />
<label>Velocidad (MHz) (Opcional)</label>
<label>Velocidad (MHz)</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>

View File

@@ -1,30 +1,34 @@
import React, { useState } from 'react';
// frontend/src/components/ModalAnadirUsuario.tsx
import React, { useState, useCallback } from 'react';
import styles from './SimpleTable.module.css';
import AutocompleteInput from './AutocompleteInput';
import { usuarioService } from '../services/apiService';
interface Props {
onClose: () => void;
onSave: (usuario: { username: string }) => void;
}
const BASE_URL = '/api';
const ModalAnadirUsuario: React.FC<Props> = ({ onClose, onSave }) => {
const [username, setUsername] = useState('');
const fetchUserSuggestions = async (query: string): Promise<string[]> => {
const fetchUserSuggestions = useCallback(async (query: string): Promise<string[]> => {
if (!query) return [];
const response = await fetch(`${BASE_URL}/usuarios/buscar/${query}`);
if (!response.ok) return [];
return response.json();
};
try {
return await usuarioService.search(query);
} catch (error) {
console.error("Error buscando usuarios", error);
return [];
}
}, []);
return (
<div className={styles.modalOverlay}>
<div className={`${styles.modalOverlay} ${styles['modalOverlay--nested']}`}>
<div className={styles.modal}>
<h3>Añadir Usuario Manualmente</h3>
<label>Nombre de Usuario</label>
<AutocompleteInput
mode="dynamic"
name="username"
value={username}
onChange={e => setUsername(e.target.value)}

View File

@@ -1,17 +1,18 @@
// frontend/src/components/ModalDetallesEquipo.tsx
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } 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';
import { equipoService } from '../services/apiService';
// Interfaces actualizadas para las props
interface ModalDetallesEquipoProps {
equipo: Equipo;
isOnline: boolean;
historial: HistorialEquipo[];
sectores: Sector[];
isChildModalOpen: boolean;
onClose: () => void;
onDelete: (id: number) => Promise<boolean>;
onRemoveAssociation: (type: 'disco' | 'ram' | 'usuario', id: any) => void;
@@ -19,10 +20,9 @@ interface ModalDetallesEquipoProps {
onAddComponent: (type: 'disco' | 'ram' | 'usuario') => void;
}
const BASE_URL = '/api';
const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
equipo, isOnline, historial, sectores, onClose, onDelete, onRemoveAssociation, onEdit, onAddComponent
equipo, isOnline, historial, sectores, isChildModalOpen,
onClose, onDelete, onRemoveAssociation, onEdit, onAddComponent
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editableEquipo, setEditableEquipo] = useState({ ...equipo });
@@ -75,19 +75,20 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
setIsEditing(false);
};
const handleEditClick = () => {
setEditableEquipo({ ...equipo });
setIsEditing(true);
};
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;
}
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.");
await equipoService.wakeOnLan(equipo.mac, equipo.ip);
toast.success('Solicitud de encendido enviada.', { id: toastId });
} catch (error) {
toast.error('Error al enviar la solicitud.', { id: toastId });
@@ -109,9 +110,14 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
const isSaveDisabled = !editableEquipo.hostname || !editableEquipo.ip || !isMacValid;
const fetchOsSuggestions = useCallback(() => equipoService.getDistinctValues('os'), []);
const fetchMotherboardSuggestions = useCallback(() => equipoService.getDistinctValues('motherboard'), []);
const fetchCpuSuggestions = useCallback(() => equipoService.getDistinctValues('cpu'), []);
return (
<div className={styles.modalLarge}>
<button onClick={onClose} className={styles.closeButton}>×</button>
<button onClick={onClose} className={styles.closeButton} disabled={isChildModalOpen}>×</button>
<div className={styles.modalLargeContent}>
<div className={styles.modalLargeHeader}>
<h2>Detalles del equipo: <strong>{equipo.hostname}</strong></h2>
@@ -123,26 +129,30 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
<button onClick={handleCancel} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
</>
) : (
<button onClick={() => setIsEditing(true)} className={`${styles.btn} ${styles.btnPrimary}`}> Editar</button>
<button onClick={handleEditClick} 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>)}
{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>
</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}>Hostname:</strong>{isEditing ? <input type="text" name="hostname" value={editableEquipo.hostname} onChange={handleChange} className={styles.modalInput} autoComplete="off" /> : <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} autoComplete="off" /> : <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}>Sistema Operativo:</strong>{isEditing ? <AutocompleteInput mode="static" name="os" value={editableEquipo.os} onChange={handleChange} className={styles.modalInput} fetchSuggestions={fetchOsSuggestions} /> : <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>
@@ -150,36 +160,78 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
</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}>Motherboard:</strong>{isEditing ? <AutocompleteInput mode="static" name="motherboard" value={editableEquipo.motherboard} onChange={handleChange} className={styles.modalInput} fetchSuggestions={fetchMotherboardSuggestions} /> : <span className={styles.detailValue}>{equipo.motherboard || 'N/A'}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>CPU:</strong>{isEditing ? <AutocompleteInput mode="static" name="cpu" value={editableEquipo.cpu} onChange={handleChange} className={styles.modalInput} fetchSuggestions={fetchCpuSuggestions} /> : <span className={styles.detailValue}>{equipo.cpu || 'N/A'}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>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.detailItem}>
<strong className={styles.detailLabel}>Arquitectura:</strong>
{isEditing ? (
<select
name="architecture"
value={editableEquipo.architecture || ''}
onChange={handleChange}
className={styles.modalInput}
>
<option value="">- Seleccionar -</option>
<option value="64 bits">64 bits</option>
<option value="32 bits">32 bits</option>
</select>
) : (
<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.detailItem}>
<strong className={styles.detailLabel}>Total Slots RAM:</strong>
{isEditing ? (
<input
type="number"
name="ram_slots"
value={editableEquipo.ram_slots || ''}
onChange={handleChange}
className={styles.modalInput}
placeholder="Ej: 4"
/>
) : (
<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}>Wake On Lan:</strong>
<button
onClick={handleWolClick}
className={styles.powerButton}
data-tooltip-id="modal-power-tooltip"
disabled={!equipo.mac}
>
<img src="./power.png" alt="Encender equipo" className={styles.powerIcon} />
Encender (WOL)
</button>
<Tooltip id="modal-power-tooltip" place="top">
{equipo.mac ? 'Encender equipo remotamente' : 'Se requiere una dirección MAC para esta acción'}
</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}>
@@ -189,7 +241,6 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
</table>
</div>
</div>
</div>
</div>
);

View File

@@ -92,6 +92,20 @@
border-color: #adb5bd;
}
.tableButtonMas {
padding: 0.375rem 0.75rem;
border-radius: 4px;
border: 1px solid #007bff;
background-color: #007bff;
color: #ffffff;
cursor: pointer;
transition: all 0.2s ease;
}
.tableButtonMas:hover {
background-color: #0056b3;
border-color: #0056b3;
}
.deleteUserButton {
background: none;
border: none;
@@ -487,4 +501,22 @@
.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; }
.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;
}
/* 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 */
}

View File

@@ -1,19 +1,16 @@
// frontend/src/components/SimpleTable.tsx
import React, { useEffect, useState } from 'react';
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
flexRender,
type CellContext
useReactTable, getCoreRowModel, getFilteredRowModel, getSortedRowModel,
getPaginationRowModel, flexRender, type CellContext
} from '@tanstack/react-table';
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 { equipoService, sectorService, usuarioService } from '../services/apiService';
import ModalAnadirEquipo from './ModalAnadirEquipo';
import ModalEditarSector from './ModalEditarSector';
import ModalCambiarClave from './ModalCambiarClave';
@@ -37,11 +34,19 @@ const SimpleTable = () => {
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [addingComponent, setAddingComponent] = useState<'disco' | 'ram' | 'usuario' | null>(null);
const [isLoading, setIsLoading] = useState(true);
const BASE_URL = '/api';
const refreshHistory = async (hostname: string) => {
try {
const data = await equipoService.getHistory(hostname);
setHistorial(data.historial);
} catch (error) {
console.error('Error refreshing history:', error);
}
};
useEffect(() => {
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
if (selectedEquipo || modalData || modalPasswordData) {
if (selectedEquipo || modalData || modalPasswordData || isAddModalOpen) {
document.body.classList.add('scroll-lock');
document.body.style.paddingRight = `${scrollBarWidth}px`;
} else {
@@ -52,7 +57,7 @@ const SimpleTable = () => {
document.body.classList.remove('scroll-lock');
document.body.style.paddingRight = '0';
};
}, [selectedEquipo, modalData, modalPasswordData]);
}, [selectedEquipo, modalData, modalPasswordData, isAddModalOpen]);
useEffect(() => {
if (!selectedEquipo) return;
@@ -60,17 +65,7 @@ const SimpleTable = () => {
const checkPing = async () => {
if (!selectedEquipo.ip) return;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`${BASE_URL}/equipos/ping`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip: selectedEquipo.ip }),
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) throw new Error('Error en la respuesta');
const data = await response.json();
const data = await equipoService.ping(selectedEquipo.ip);
if (isMounted) setIsOnline(data.isAlive);
} catch (error) {
if (isMounted) setIsOnline(false);
@@ -79,22 +74,21 @@ const SimpleTable = () => {
};
checkPing();
const interval = setInterval(checkPing, 10000);
return () => {
isMounted = false;
clearInterval(interval);
setIsOnline(false);
};
return () => { isMounted = false; clearInterval(interval); setIsOnline(false); };
}, [selectedEquipo]);
const handleCloseModal = () => {
if (addingComponent) {
toast.error("Debes cerrar la ventana de añadir componente primero.");
return;
}
setSelectedEquipo(null);
setIsOnline(false);
};
useEffect(() => {
if (selectedEquipo) {
fetch(`${BASE_URL}/equipos/${selectedEquipo.hostname}/historial`)
.then(response => response.json())
equipoService.getHistory(selectedEquipo.hostname)
.then(data => setHistorial(data.historial))
.catch(error => console.error('Error fetching history:', error));
}
@@ -109,21 +103,17 @@ const SimpleTable = () => {
useEffect(() => {
setIsLoading(true);
Promise.all([
fetch(`${BASE_URL}/equipos`).then(res => res.json()),
fetch(`${BASE_URL}/sectores`).then(res => res.json())
equipoService.getAll(),
sectorService.getAll()
]).then(([equiposData, sectoresData]) => {
setData(equiposData);
setFilteredData(equiposData);
const sectoresOrdenados = [...sectoresData].sort((a, b) =>
a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' })
);
const sectoresOrdenados = [...sectoresData].sort((a, b) => a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' }));
setSectores(sectoresOrdenados);
}).catch(error => {
toast.error("No se pudieron cargar los datos iniciales.");
console.error("Error al cargar datos:", error);
}).finally(() => {
setIsLoading(false);
});
}).finally(() => setIsLoading(false));
}, []);
const handleSectorChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
@@ -138,16 +128,14 @@ const SimpleTable = () => {
if (!modalData || !modalData.sector) return;
const toastId = toast.loading('Guardando...');
try {
const response = await fetch(`${BASE_URL}/equipos/${modalData.id}/sector/${modalData.sector.id}`, { method: 'PATCH' });
if (!response.ok) throw new Error('Error al asociar el sector');
await equipoService.updateSector(modalData.id, modalData.sector.id);
const updatedData = data.map(e => e.id === modalData.id ? { ...e, sector: modalData.sector } : e);
setData(updatedData);
setFilteredData(updatedData);
toast.success('Sector actualizado.', { id: toastId });
setModalData(null);
} catch (error) {
toast.error('No se pudo actualizar.', { id: toastId });
console.error(error);
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
@@ -155,16 +143,7 @@ const SimpleTable = () => {
if (!modalPasswordData) return;
const toastId = toast.loading('Actualizando...');
try {
const response = await fetch(`${BASE_URL}/usuarios/${modalPasswordData.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error || 'Error al actualizar');
}
const updatedUser = await response.json();
const updatedUser = await usuarioService.updatePassword(modalPasswordData.id, password);
const updatedData = data.map(equipo => ({
...equipo,
usuarios: equipo.usuarios?.map(user => user.id === updatedUser.id ? { ...user, password } : user)
@@ -182,9 +161,7 @@ const SimpleTable = () => {
if (!window.confirm(`¿Quitar a ${username} de este equipo?`)) return;
const toastId = toast.loading(`Quitando a ${username}...`);
try {
const response = await fetch(`${BASE_URL}/equipos/${hostname}/usuarios/${username}`, { method: 'DELETE' });
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'Error al desasociar');
await usuarioService.removeUserFromEquipo(hostname, username);
const updateFunc = (prev: Equipo[]) => prev.map(e => e.hostname === hostname ? { ...e, usuarios: e.usuarios.filter(u => u.username !== username) } : e);
setData(updateFunc);
setFilteredData(updateFunc);
@@ -198,57 +175,38 @@ const SimpleTable = () => {
if (!window.confirm('¿Eliminar este equipo y sus relaciones?')) return false;
const toastId = toast.loading('Eliminando equipo...');
try {
const response = await fetch(`${BASE_URL}/equipos/${id}`, { method: 'DELETE' });
if (response.status === 204) {
setData(prev => prev.filter(e => e.id !== id));
setFilteredData(prev => prev.filter(e => e.id !== id));
toast.success('Equipo eliminado.', { id: toastId });
return true;
}
const errorText = await response.text();
throw new Error(errorText || 'Error desconocido');
await equipoService.deleteManual(id);
setData(prev => prev.filter(e => e.id !== id));
setFilteredData(prev => prev.filter(e => e.id !== id));
toast.success('Equipo eliminado.', { id: toastId });
return true;
} catch (error) {
if (error instanceof Error) toast.error(`Error: ${error.message}`, { id: toastId });
if (error instanceof Error) toast.error(error.message, { id: toastId });
return false;
}
};
const handleRemoveAssociation = async (
type: 'disco' | 'ram' | 'usuario',
associationId: number | { equipoId: number, usuarioId: number }
) => {
let url = '';
let successMessage = '';
if (type === 'disco' && typeof associationId === 'number') {
url = `${BASE_URL}/equipos/asociacion/disco/${associationId}`;
successMessage = 'Disco desasociado del equipo.';
} else if (type === 'ram' && typeof associationId === 'number') {
url = `${BASE_URL}/equipos/asociacion/ram/${associationId}`;
successMessage = 'Módulo de RAM desasociado.';
} else if (type === 'usuario' && typeof associationId === 'object') {
url = `${BASE_URL}/equipos/asociacion/usuario/${associationId.equipoId}/${associationId.usuarioId}`;
successMessage = 'Usuario desasociado del equipo.';
} else {
return; // No hacer nada si los parámetros son incorrectos
}
const handleRemoveAssociation = async (type: 'disco' | 'ram' | 'usuario', associationId: number | { equipoId: number, usuarioId: number }) => {
if (!window.confirm('¿Estás seguro de que quieres eliminar esta asociación manual?')) return;
const toastId = toast.loading('Eliminando asociación...');
try {
const response = await fetch(url, { method: 'DELETE' });
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `Error al eliminar la asociación.`);
let successMessage = '';
if (type === 'disco' && typeof associationId === 'number') {
await equipoService.removeDiscoAssociation(associationId);
successMessage = 'Disco desasociado del equipo.';
} else if (type === 'ram' && typeof associationId === 'number') {
await equipoService.removeRamAssociation(associationId);
successMessage = 'Módulo de RAM desasociado.';
} else if (type === 'usuario' && typeof associationId === 'object') {
await equipoService.removeUserAssociation(associationId.equipoId, associationId.usuarioId);
successMessage = 'Usuario desasociado del equipo.';
} else {
throw new Error('Tipo de asociación no válido');
}
// Actualizar el estado local para reflejar el cambio inmediatamente
const updateState = (prevEquipos: Equipo[]) => prevEquipos.map(equipo => {
if (equipo.id !== selectedEquipo?.id) return equipo;
let updatedEquipo = { ...equipo };
if (type === 'disco' && typeof associationId === 'number') {
updatedEquipo.discos = equipo.discos.filter(d => d.equipoDiscoId !== associationId);
@@ -262,107 +220,75 @@ const SimpleTable = () => {
setData(updateState);
setFilteredData(updateState);
setSelectedEquipo(prev => prev ? updateState([prev])[0] : null); // Actualiza también el modal
setSelectedEquipo(prev => prev ? updateState([prev])[0] : null);
if (selectedEquipo) {
await refreshHistory(selectedEquipo.hostname);
}
toast.success(successMessage, { id: toastId });
} catch (error) {
if (error instanceof Error) {
toast.error(error.message, { id: toastId });
}
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
const handleCreateEquipo = async (nuevoEquipo: Omit<Equipo, 'id' | 'created_at' | 'updated_at'>) => {
const toastId = toast.loading('Creando nuevo equipo...');
try {
const response = await fetch(`${BASE_URL}/equipos/manual`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nuevoEquipo),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Error al crear el equipo.');
}
const equipoCreado = await response.json();
// Actualizamos el estado local para ver el nuevo equipo inmediatamente
const equipoCreado = await equipoService.createManual(nuevoEquipo);
setData(prev => [...prev, equipoCreado]);
setFilteredData(prev => [...prev, equipoCreado]);
toast.success(`Equipo '${equipoCreado.hostname}' creado.`, { id: toastId });
setIsAddModalOpen(false); // Cerramos el modal
setIsAddModalOpen(false);
} catch (error) {
if (error instanceof Error) {
toast.error(error.message, { id: toastId });
}
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
const handleEditEquipo = async (id: number, equipoEditado: Omit<Equipo, 'id' | 'created_at' | 'updated_at'>) => {
const handleEditEquipo = async (id: number, equipoEditado: any) => {
const toastId = toast.loading('Guardando cambios...');
try {
const response = await fetch(`${BASE_URL}/equipos/manual/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(equipoEditado),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Error al actualizar el equipo.');
}
// Actualizar el estado local para reflejar los cambios
const updateState = (prevEquipos: Equipo[]) => prevEquipos.map(equipo => {
if (equipo.id === id) {
return { ...equipo, ...equipoEditado };
}
return equipo;
});
const equipoActualizadoDesdeBackend = await equipoService.updateManual(id, equipoEditado);
const updateState = (prev: Equipo[]) =>
prev.map(e => e.id === id ? equipoActualizadoDesdeBackend : e);
setData(updateState);
setFilteredData(updateState);
setSelectedEquipo(prev => prev ? updateState([prev])[0] : null);
setSelectedEquipo(equipoActualizadoDesdeBackend);
toast.success('Equipo actualizado.', { id: toastId });
return true; // Indica que el guardado fue exitoso
return true;
} catch (error) {
if (error instanceof Error) {
toast.error(error.message, { id: toastId });
}
return false; // Indica que el guardado falló
if (error instanceof Error) toast.error(error.message, { id: toastId });
return false;
}
};
const handleAddComponent = async (type: 'disco' | 'ram' | 'usuario', data: any) => {
const handleAddComponent = async (type: 'disco' | 'ram' | 'usuario', componentData: any) => {
if (!selectedEquipo) return;
const toastId = toast.loading(`Añadiendo ${type}...`);
try {
const response = await fetch(`${BASE_URL}/equipos/manual/${selectedEquipo.id}/${type}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `Error al añadir ${type}.`);
let serviceCall;
switch (type) {
case 'disco': serviceCall = equipoService.addDisco(selectedEquipo.id, componentData); break;
case 'ram': serviceCall = equipoService.addRam(selectedEquipo.id, componentData); break;
case 'usuario': serviceCall = equipoService.addUsuario(selectedEquipo.id, componentData); break;
default: throw new Error('Tipo de componente no válido');
}
await serviceCall;
// Refrescar los datos del equipo para ver el cambio
const refreshedEquipo = await (await fetch(`${BASE_URL}/equipos/${selectedEquipo.hostname}`)).json();
// Usar el servicio directamente para obtener el equipo actualizado
const refreshedEquipo = await equipoService.getAll().then(equipos => equipos.find(e => e.id === selectedEquipo.id));
if (!refreshedEquipo) throw new Error("No se pudo recargar el equipo");
const updateState = (prev: Equipo[]) => prev.map(e => e.id === selectedEquipo.id ? refreshedEquipo : e);
setData(updateState);
setFilteredData(updateState);
setSelectedEquipo(refreshedEquipo);
await refreshHistory(selectedEquipo.hostname);
toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} añadido.`, { id: toastId });
setAddingComponent(null); // Cerrar modal
setAddingComponent(null);
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
@@ -447,7 +373,7 @@ const SimpleTable = () => {
],
columnVisibility: { id: false, mac: false },
pagination: {
pageSize: 15, // Mostrar 15 filas por página por defecto
pageSize: 15,
},
},
state: {
@@ -536,7 +462,6 @@ const SimpleTable = () => {
</select>
</div>
{/* --- 2. Renderizar los controles ANTES de la tabla --- */}
{PaginacionControles}
<div style={{ overflowX: 'auto', maxHeight: '70vh', border: '1px solid #dee2e6', borderRadius: '8px' }}>
@@ -545,7 +470,7 @@ const SimpleTable = () => {
{table.getHeaderGroups().map(hg => (
<tr key={hg.id}>
{hg.headers.map(h => (
<th key={h.id} className={styles.th}>
<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>
@@ -567,28 +492,13 @@ const SimpleTable = () => {
</table>
</div>
{/* --- 3. Renderizar los controles DESPUÉS de la tabla --- */}
{PaginacionControles}
{showScrollButton && (<button onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} className={styles.scrollToTop} title="Volver arriba"></button>)}
{modalData && (
<ModalEditarSector
modalData={modalData}
setModalData={setModalData}
sectores={sectores}
onClose={() => setModalData(null)}
onSave={handleSave}
/>
)}
{modalData && <ModalEditarSector modalData={modalData} setModalData={setModalData} sectores={sectores} onClose={() => setModalData(null)} onSave={handleSave} />}
{modalPasswordData && (
<ModalCambiarClave
usuario={modalPasswordData}
onClose={() => setModalPasswordData(null)}
onSave={handleSavePassword}
/>
)}
{modalPasswordData && <ModalCambiarClave usuario={modalPasswordData} onClose={() => setModalPasswordData(null)} onSave={handleSavePassword} />}
{selectedEquipo && (
<ModalDetallesEquipo
@@ -601,20 +511,17 @@ const SimpleTable = () => {
onEdit={handleEditEquipo}
sectores={sectores}
onAddComponent={type => setAddingComponent(type)}
isChildModalOpen={addingComponent !== null}
/>
)}
{isAddModalOpen && (
<ModalAnadirEquipo
sectores={sectores}
onClose={() => setIsAddModalOpen(false)}
onSave={handleCreateEquipo}
/>
)}
{isAddModalOpen && <ModalAnadirEquipo sectores={sectores} onClose={() => setIsAddModalOpen(false)} onSave={handleCreateEquipo} />}
{addingComponent === 'disco' && <ModalAnadirDisco onClose={() => setAddingComponent(null)} onSave={(data) => handleAddComponent('disco', data)} />}
{addingComponent === 'ram' && <ModalAnadirRam onClose={() => setAddingComponent(null)} onSave={(data) => handleAddComponent('ram', data)} />}
{addingComponent === 'usuario' && <ModalAnadirUsuario onClose={() => setAddingComponent(null)} onSave={(data) => handleAddComponent('usuario', data)} />}
{addingComponent === 'disco' && <ModalAnadirDisco onClose={() => setAddingComponent(null)} onSave={(data: { mediatype: string, size: number }) => handleAddComponent('disco', data)} />}
{addingComponent === 'ram' && <ModalAnadirRam onClose={() => setAddingComponent(null)} onSave={(data: { slot: string, tamano: number, fabricante?: string, velocidad?: number }) => handleAddComponent('ram', data)} />}
{addingComponent === 'usuario' && <ModalAnadirUsuario onClose={() => setAddingComponent(null)} onSave={(data: { username: string }) => handleAddComponent('usuario', data)} />}
</div>
);
};

View File

@@ -0,0 +1,120 @@
// frontend/src/services/apiService.ts
import type { Equipo, Sector, HistorialEquipo, Usuario, MemoriaRam } from '../types/interfaces';
const BASE_URL = '/api';
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, options);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Error en la respuesta del servidor' }));
throw new Error(errorData.message || 'Ocurrió un error desconocido');
}
if (response.status === 204) {
return null as T;
}
return response.json();
}
// --- Servicio para la gestión de Sectores ---
export const sectorService = {
getAll: () => request<Sector[]>(`${BASE_URL}/sectores`),
create: (nombre: string) => request<Sector>(`${BASE_URL}/sectores`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nombre }),
}),
update: (id: number, nombre: string) => request<void>(`${BASE_URL}/sectores/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nombre }),
}),
delete: (id: number) => request<void>(`${BASE_URL}/sectores/${id}`, { method: 'DELETE' }),
};
// --- Servicio para la gestión de Equipos ---
export const equipoService = {
getAll: () => request<Equipo[]>(`${BASE_URL}/equipos`),
getHistory: (hostname: string) => request<{ historial: HistorialEquipo[] }>(`${BASE_URL}/equipos/${hostname}/historial`),
ping: (ip: string) => request<{ isAlive: boolean }>(`${BASE_URL}/equipos/ping`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip }),
}),
wakeOnLan: (mac: string, ip: string) => request<void>(`${BASE_URL}/equipos/wake-on-lan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mac, ip }),
}),
updateSector: (equipoId: number, sectorId: number) => request<void>(`${BASE_URL}/equipos/${equipoId}/sector/${sectorId}`, { method: 'PATCH' }),
deleteManual: (id: number) => request<void>(`${BASE_URL}/equipos/${id}`, { method: 'DELETE' }),
createManual: (nuevoEquipo: Omit<Equipo, 'id' | 'created_at' | 'updated_at'>) => request<Equipo>(`${BASE_URL}/equipos/manual`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nuevoEquipo),
}),
updateManual: (id: number, equipoEditado: any) =>
request<Equipo>(`${BASE_URL}/equipos/manual/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(equipoEditado),
}),
removeDiscoAssociation: (id: number) => request<void>(`${BASE_URL}/equipos/asociacion/disco/${id}`, { method: 'DELETE' }),
removeRamAssociation: (id: number) => request<void>(`${BASE_URL}/equipos/asociacion/ram/${id}`, { method: 'DELETE' }),
removeUserAssociation: (equipoId: number, usuarioId: number) => request<void>(`${BASE_URL}/equipos/asociacion/usuario/${equipoId}/${usuarioId}`, { method: 'DELETE' }),
addDisco: (equipoId: number, data: any) => request<any>(`${BASE_URL}/equipos/manual/${equipoId}/disco`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}),
addRam: (equipoId: number, data: any) => request<any>(`${BASE_URL}/equipos/manual/${equipoId}/ram`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}),
addUsuario: (equipoId: number, data: any) => request<any>(`${BASE_URL}/equipos/manual/${equipoId}/usuario`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}),
getDistinctValues: (field: string) => request<string[]>(`${BASE_URL}/equipos/distinct/${field}`),
};
// --- Servicio para Usuarios ---
export const usuarioService = {
updatePassword: (id: number, password: string) => request<Usuario>(`${BASE_URL}/usuarios/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
}),
removeUserFromEquipo: (hostname: string, username: string) => request<void>(`${BASE_URL}/equipos/${hostname}/usuarios/${username}`, { method: 'DELETE' }),
search: (term: string) => request<string[]>(`${BASE_URL}/usuarios/buscar/${term}`),
};
// --- Servicio para RAM ---
export const memoriaRamService = {
getAll: () => request<MemoriaRam[]>(`${BASE_URL}/memoriasram`),
search: (term: string) => request<MemoriaRam[]>(`${BASE_URL}/memoriasram/buscar/${term}`),
};
// --- Servicio para Administración ---
export const adminService = {
getComponentValues: (type: string) => request<any[]>(`${BASE_URL}/admin/componentes/${type}`),
unifyComponentValues: (type: string, valorAntiguo: string, valorNuevo: string) => request<any>(`${BASE_URL}/admin/componentes/${type}/unificar`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ valorAntiguo, valorNuevo }),
}),
deleteRamComponent: (ramGroup: { fabricante?: string, tamano: number, velocidad?: number }) => request<void>(`${BASE_URL}/admin/componentes/ram`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ramGroup),
}),
deleteTextComponent: (type: string, value: string) => request<void>(`${BASE_URL}/admin/componentes/${type}/${encodeURIComponent(value)}`, { method: 'DELETE' }),
};

View File

@@ -4,4 +4,16 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
// --- AÑADIR ESTA SECCIÓN COMPLETA ---
server: {
proxy: {
// Cualquier petición que empiece con '/api' será redirigida.
'/api': {
// Redirige al servidor de backend que corre en local.
target: 'http://localhost:5198',
// Necesario para evitar problemas de CORS y de origen.
changeOrigin: true,
},
},
},
})