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:
		| @@ -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) => ( | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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}> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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)} | ||||
|   | ||||
| @@ -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> | ||||
|     ); | ||||
|   | ||||
| @@ -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 */ | ||||
| } | ||||
| @@ -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> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
							
								
								
									
										120
									
								
								frontend/src/services/apiService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								frontend/src/services/apiService.ts
									
									
									
									
									
										Normal 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' }), | ||||
| }; | ||||
		Reference in New Issue
	
	Block a user