/// import React, { useState, useEffect, useCallback } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import ProyectoDashboard from './pages/ProyectoDashboard'; import AmbitoDashboard from './pages/AmbitoDashboard'; // Constantes estáticas para evitar parpadeos por cambio de referencia en cada render. const MARKDOWN_PLUGINS = [remarkGfm]; const API_URL = import.meta.env.VITE_API_URL || '/bitacoras/api'; interface Entrada { id: number; inicio: string; fin: string | null; descripcion: string; estado: string; modo: string; es_ia: boolean; nodo_nombre: string; nodo_ip: string; } interface NodoGroup { nodo: string; ip: string; entradas: Entrada[]; resumen?: string; } interface Bitacora { id: number; fecha: string; resumen: string; nodos: NodoGroup[]; gestion: GestionItem[]; } interface GestionItem { id: number; tipo: 'pendiente' | 'proceso'; nodo_nombre: string; detalle: string; estado: string; activo: boolean; } interface Nodo { id: number; nombre: string; ip: string; tipo: string; descripcion: string; ambito_id: number | null; ambito_nombre?: string; activo: boolean; } interface Ambito { id: number; nombre: string; descripcion: string; parent_id: number | null; parent_nombre?: string; count_nodos?: number; activo: boolean; } // ===== MODAL DE CONFIRMACIÓN ===== function ConfirmModal({ show, title, message, onConfirm, onCancel }: { show: boolean; title: string; message: string; onConfirm: () => void; onCancel: () => void; }) { if (!show) return null; return (
e.stopPropagation()}>
{title}

{message}

); } // ===== TABS ===== type Tab = 'bitacora' | 'nodos' | 'ambitos' | 'proyecto'; function getInitialTab(): Tab { const path = window.location.pathname.toLowerCase(); if (path.includes('/p2601')) return 'proyecto'; if (path.includes('/nodos')) return 'nodos'; return 'bitacora'; } function App() { const [tab, setTab] = useState(getInitialTab()); const changeTab = React.useCallback((t: Tab) => { setTab(t); const base = '/bitacoras/'; const paths: Record = { bitacora: base, nodos: base + 'nodos', ambitos: base + 'ambitos', proyecto: base + 'p2601' }; window.history.replaceState(null, '', paths[t]); }, []); const [fecha, setFecha] = useState(new Date().toISOString().split('T')[0]); const [bitacora, setBitacora] = useState(null); const [nodos, setNodos] = useState([]); const [proyectos, setProyectos] = useState<{ id: number, codigo: string, nombre: string }[]>([]); const [selectedProyecto, setSelectedProyecto] = useState('P2601'); const [loading, setLoading] = useState(true); const [fechasActivas, setFechasActivas] = useState>(new Set()); const [showCal, setShowCal] = useState(false); const [calMonth, setCalMonth] = useState(() => { const d = new Date(); return { year: d.getFullYear(), month: d.getMonth() }; }); // Form: nueva entrada const [newInicio, setNewInicio] = useState(''); const [newFin, setNewFin] = useState(''); const [newDesc, setNewDesc] = useState(''); const [newEstado, setNewEstado] = useState('⏳'); const [newModo, setNewModo] = useState('P'); const [newNodoId, setNewNodoId] = useState(''); const [newEsIa, setNewEsIa] = useState(false); // Edit: entrada en edición const [editId, setEditId] = useState(null); const [editInicio, setEditInicio] = useState(''); const [editFin, setEditFin] = useState(''); const [editDesc, setEditDesc] = useState(''); const [editEstado, setEditEstado] = useState(''); const [editModo, setEditModo] = useState(''); const [editEsIa, setEditEsIa] = useState(false); // Confirm delete const [confirmDelete, setConfirmDelete] = useState<{ type: string; id: number; name: string } | null>(null); // Ámbitos const [ambitos, setAmbitos] = useState([]); // Form states const [nodoFormVisible, setNodoFormVisible] = useState(false); const [editNodoId, setEditNodoId] = useState(null); const [nodoNombre, setNodoNombre] = useState(''); const [nodoIp, setNodoIp] = useState(''); const [nodoTipo, setNodoTipo] = useState('servidor'); const [nodoDesc, setNodoDesc] = useState(''); const [nodoAmbitoId, setNodoAmbitoId] = useState(''); const [ambitoFormVisible, setAmbitoFormVisible] = useState(false); const [editAmbitoId, setEditAmbitoId] = useState(null); const [ambitoNombre, setAmbitoNombre] = useState(''); const [ambitoDesc, setAmbitoDesc] = useState(''); const [ambitoParentId, setAmbitoParentId] = useState(''); const [activeAmbitoId, setActiveAmbitoId] = useState(null); const [globalStats, setGlobalStats] = useState<{ nombre: string, count_nodos: number, count_entradas: number }[]>([]); // Doc viewer modal const [docModal, setDocModal] = useState<{ show: boolean; title: string; content: string; loading: boolean }>({ show: false, title: '', content: '', loading: false }); const openDocModal = React.useCallback(async (fileRelPath: string) => { const archivo = fileRelPath.split('/').pop() || ''; setDocModal({ show: true, title: archivo.replace('.md', ''), content: '', loading: true }); try { const res = await fetch(`${API_URL}/docs/${fileRelPath}`); const data = await res.json(); if (data.content) { setDocModal({ show: true, title: archivo.replace('.md', ''), content: data.content, loading: false }); } else { setDocModal({ show: true, title: 'Error', content: `No se pudo cargar el archivo: ${data.error || 'desconocido'}`, loading: false }); } } catch (err) { setDocModal({ show: true, title: 'Error', content: 'Error de conexión al cargar el documento', loading: false }); } }, []); // === MARKDOWN COMPONENTS (MEMOIZED TO AVOID FLICKER) === const markdownComponents = React.useMemo(() => ({ a: ({ node, ...props }: any) => { const url = props.href || ''; // Si es un link a un .md en ../proy/ o ../ambito/, abrir modal de documento if ((url.includes('../proy/') || url.includes('../ambito/')) && url.endsWith('.md')) { // Obtener todo lo que viene después de "docs/" basándonos en si fue proy/ o ambito/ const linkType = url.includes('../proy/') ? '../proy/' : '../ambito/'; const docsIndex = url.indexOf(linkType) + 3; // +3 skips "../" so we start at "proy/" or "ambito/" const fileRelPath = url.substring(docsIndex); // ex: proy/P2601_Dasuten/P26... return ( { e.preventDefault(); e.stopPropagation(); openDocModal(fileRelPath); }} className="desc-link" title={`Ver ${url.split('/').pop()} en la web`} style={{ cursor: 'pointer', borderBottom: '1px dashed var(--primary)' }} > {props.children} ); } // Si es un link a un proyecto del ADN, interceptamos para navegación SPA if (url.startsWith('../proyectos/')) { const projectFile = url.split('/').pop() || ''; const projectCodeMatch = projectFile.match(/^(P\d{4})/); if (projectCodeMatch) { const projectCode = projectCodeMatch[1]; return ( { e.preventDefault(); setSelectedProyecto(projectCode); changeTab('proyecto'); }} className="desc-link" title={`Ver plan ${projectCode} en la web`} > {props.children} ); } // Si no tiene el formato PXXXX, fallback a link estático en la carpeta de docs props.href = '/bitacoras/docs/proyectos/' + projectFile; } return ( e.stopPropagation()} > {props.children} ); }, // Estilos para código (bloque e inline) code: ({ node, className, children, ...props }: any) => { const match = /language-(\w+)/.exec(className || ''); const isInline = !match; return ( {children} ); } }), []); // All deps (changeTab, setSelectedProyecto, openDocModal) are stable useCallbacks/setState // === HELPERS === const renderDescription = (text: string) => { if (!text) return ''; return ( {text} ); }; // === FETCH === const fetchBitacora = useCallback(async () => { // Solo mostramos loading si es la primera carga (sin datos) if (!bitacora) setLoading(true); try { const res = await fetch(`${API_URL}/bitacoras/${fecha}/completa`); const data = await res.json(); setBitacora(data); } catch (err) { console.error('Error fetchBitacora:', err); } setLoading(false); }, [fecha, bitacora]); const fetchNodos = useCallback(async () => { try { const res = await fetch(`${API_URL}/nodos`); const data = await res.json(); setNodos(data); } catch (err) { console.error('Error nodes:', err); } }, []); const fetchAmbitos = useCallback(async () => { try { const res = await fetch(`${API_URL}/ambitos`); const data = await res.json(); setAmbitos(data); } catch (err) { console.error('Error ambitos:', err); } }, []); const fetchGlobalStats = useCallback(async () => { try { const res = await fetch(`${API_URL}/ambitos/stats/global`); const data = await res.json(); setGlobalStats(data); } catch (err) { console.error('Error global stats:', err); } }, []); const fetchFechasActivas = async () => { try { const res = await fetch(`${API_URL}/bitacoras/fechas/activas`); const data: string[] = await res.json(); setFechasActivas(new Set(data)); } catch (err) { console.error('Error fetchFechasActivas:', err); } }; const fetchProyectos = async () => { try { const res = await fetch(`${API_URL}/proyectos`); const data = await res.json(); setProyectos(data); if (data.length > 0) { // Al cargar por primera vez, si no hay P2601 explícito o se quiere forzar al último setSelectedProyecto(prev => prev === 'P2601' && prev ? data[data.length - 1].codigo : prev || data[data.length - 1].codigo); } } catch (err) { console.error('Error:', err); } }; const addGestion = async (tipo: 'pendiente' | 'proceso') => { if (!bitacora) return; const detalle = prompt(`Nuevo ${tipo}:`); if (!detalle) return; await fetch(`${API_URL}/gestion`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tipo, detalle, bitacora_id: bitacora.id, estado: '⏳' }) }); fetchBitacora(); }; const deleteGestion = async (id: number) => { if (!confirm('¿Eliminar item de gestión?')) return; await fetch(`${API_URL}/gestion/${id}`, { method: 'DELETE' }); fetchBitacora(); }; const updateResumenNodo = async (nodoNombre: string, current: string) => { if (!bitacora) return; const newResumen = prompt(`Resumen Integral para ${nodoNombre}:`, current); if (newResumen === null) return; const nodo = nodos.find(n => n.nombre === nodoNombre); if (!nodo) return; await fetch(`${API_URL}/resumen`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bitacora_id: bitacora.id, nodo_id: nodo.id, resumen: newResumen }) }); fetchBitacora(); }; const navigateProyecto = (direction: number) => { if (proyectos.length === 0) return; const currentIndex = proyectos.findIndex(p => p.codigo === selectedProyecto); let nextIndex = currentIndex + direction; if (nextIndex < 0) nextIndex = proyectos.length - 1; if (nextIndex >= proyectos.length) nextIndex = 0; setSelectedProyecto(proyectos[nextIndex].codigo); }; // Auto-refresh inteligente: solo actualizar si hay cambios const [lastVersion, setLastVersion] = useState<{ max_id: number; count: number } | null>(null); const fetchBitacoraVersion = useCallback(async () => { try { const res = await fetch(`${API_URL}/bitacoras/${fecha}/version`); return await res.json(); } catch { return null; } }, [fecha]); const smartRefresh = useCallback(async () => { const newVersion = await fetchBitacoraVersion(); if (!newVersion) { fetchBitacora(); fetchFechasActivas(); fetchNodos(); fetchAmbitos(); fetchGlobalStats(); return; } if (!lastVersion || newVersion.max_id !== lastVersion.max_id || Number(newVersion.count) !== Number(lastVersion.count)) { setLastVersion(newVersion); fetchBitacora(); fetchFechasActivas(); fetchNodos(); fetchAmbitos(); fetchGlobalStats(); } }, [fetchBitacoraVersion, fetchBitacora, fetchFechasActivas, lastVersion, fetchNodos, fetchAmbitos, fetchGlobalStats]); useEffect(() => { fetchBitacora(); }, [fetchBitacora]); useEffect(() => { fetchNodos(); fetchProyectos(); fetchAmbitos(); fetchGlobalStats(); }, []); useEffect(() => { fetchFechasActivas(); }, [fetchFechasActivas]); // Inicializar versión al cargar useEffect(() => { fetchBitacoraVersion().then(v => { if (v) setLastVersion(v); }); }, [fetchBitacoraVersion]); // Auto-refresh cada 30 segundos (solo si hay cambios) useEffect(() => { const interval = setInterval(smartRefresh, 30000); // También actualizar cuando la página recupera el foco const handleVisibility = () => { if (!document.hidden) smartRefresh(); }; document.addEventListener('visibilitychange', handleVisibility); return () => { clearInterval(interval); document.removeEventListener('visibilitychange', handleVisibility); }; }, [smartRefresh]); // Rebuild calendar view month when fecha changes useEffect(() => { const d = new Date(fecha + 'T12:00:00'); setCalMonth({ year: d.getFullYear(), month: d.getMonth() }); }, [fecha]); // Mini calendario builder const buildCalDays = () => { const { year, month } = calMonth; const firstDay = new Date(year, month, 1).getDay(); // 0=Sun const daysInMonth = new Date(year, month + 1, 0).getDate(); const rows: { day: number; dateStr: string; active: boolean; selected: boolean; today: boolean }[][] = []; let row: typeof rows[0] = []; const todayStr = new Date().toISOString().split('T')[0]; // Fill blanks for (let i = 0; i < firstDay; i++) row.push({ day: 0, dateStr: '', active: false, selected: false, today: false }); for (let d = 1; d <= daysInMonth; d++) { const ds = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; row.push({ day: d, dateStr: ds, active: fechasActivas.has(ds), selected: ds === fecha, today: ds === todayStr }); if (row.length === 7) { rows.push(row); row = []; } } if (row.length > 0) { while (row.length < 7) row.push({ day: 0, dateStr: '', active: false, selected: false, today: false }); rows.push(row); } return rows; }; const MONTH_NAMES = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic']; // === ENTRADAS CRUD === const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!bitacora || !newDesc || !newInicio || !newNodoId) return; try { await fetch(`${API_URL}/entradas`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inicio: newInicio, fin: newFin || null, descripcion: newDesc, estado: newEstado, modo: newModo, es_ia: newEsIa, bitacora_id: bitacora.id, nodo_id: parseInt(newNodoId) }) }); setNewDesc(''); setNewInicio(''); setNewFin(''); fetchBitacora(); } catch (err) { console.error('Error:', err); } }; const startEdit = (e: Entrada) => { setEditId(e.id); setEditInicio(e.inicio?.substring(0, 8) || ''); setEditFin(e.fin ? e.fin.substring(0, 8) : ''); setEditDesc(e.descripcion); setEditEstado(e.estado); setEditModo(e.modo); setEditEsIa(e.es_ia); }; const cancelEdit = () => setEditId(null); const saveEdit = async () => { if (!editId) return; await fetch(`${API_URL}/entradas/${editId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inicio: editInicio, fin: editFin || null, descripcion: editDesc, estado: editEstado, modo: editModo, es_ia: editEsIa, }) }); setEditId(null); fetchBitacora(); }; const requestDeleteEntrada = (id: number, desc: string) => { setConfirmDelete({ type: 'entrada', id, name: desc.substring(0, 50) + '...' }); }; const executeDelete = async () => { if (!confirmDelete) return; const { type, id } = confirmDelete; if (type === 'entrada') { await fetch(`${API_URL}/entradas/${id}`, { method: 'DELETE' }); fetchBitacora(); } else if (type === 'nodo') { await fetch(`${API_URL}/nodos/${id}`, { method: 'DELETE' }); fetchNodos(); } else if (type === 'ambito') { await fetch(`${API_URL}/ambitos/${id}`, { method: 'DELETE' }); fetchAmbitos(); } setConfirmDelete(null); }; // === NODOS CRUD === const resetNodoForm = () => { setEditNodoId(null); setNodoNombre(''); setNodoIp(''); setNodoTipo('servidor'); setNodoDesc(''); setNodoAmbitoId(''); setNodoFormVisible(false); }; const openNodoForm = (nodo?: Nodo) => { if (nodo) { setEditNodoId(nodo.id); setNodoNombre(nodo.nombre); setNodoIp(nodo.ip || ''); setNodoTipo(nodo.tipo || 'servidor'); setNodoDesc(nodo.descripcion || ''); setNodoAmbitoId(nodo.ambito_id?.toString() || ''); } else { setEditNodoId(null); setNodoNombre(''); setNodoIp(''); setNodoTipo('servidor'); setNodoDesc(''); setNodoAmbitoId(''); } setNodoFormVisible(true); }; const saveNodo = async (e: React.FormEvent) => { e.preventDefault(); if (!nodoNombre) return; const payload = { nombre: nodoNombre, ip: nodoIp, tipo: nodoTipo, descripcion: nodoDesc, ambito_id: nodoAmbitoId ? parseInt(nodoAmbitoId) : null }; if (editNodoId) { const existing = nodos.find(n => n.id === editNodoId); await fetch(`${API_URL}/nodos/${editNodoId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...payload, activo: existing?.activo ?? true }) }); } else { await fetch(`${API_URL}/nodos`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); } resetNodoForm(); fetchNodos(); }; const requestDeleteNodo = (id: number, nombre: string) => { setConfirmDelete({ type: 'nodo', id, name: nombre }); }; // === AMBITOS CRUD === const resetAmbitoForm = () => { setEditAmbitoId(null); setAmbitoNombre(''); setAmbitoDesc(''); setAmbitoParentId(''); setAmbitoFormVisible(false); }; const openAmbitoForm = (ambito?: Ambito) => { if (ambito) { setEditAmbitoId(ambito.id); setAmbitoNombre(ambito.nombre); setAmbitoDesc(ambito.descripcion || ''); setAmbitoParentId(ambito.parent_id?.toString() || ''); } else { setEditAmbitoId(null); setAmbitoNombre(''); setAmbitoDesc(''); setAmbitoParentId(''); } setAmbitoFormVisible(true); }; const saveAmbito = async (e: React.FormEvent) => { e.preventDefault(); if (!ambitoNombre) return; const payload = { nombre: ambitoNombre, descripcion: ambitoDesc, parent_id: ambitoParentId ? parseInt(ambitoParentId) : null }; if (editAmbitoId) { await fetch(`${API_URL}/ambitos/${editAmbitoId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); } else { await fetch(`${API_URL}/ambitos`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); } resetAmbitoForm(); fetchAmbitos(); fetchNodos(); // Los nodos pueden haber cambiado su ambito_nombre }; const requestDeleteAmbito = (id: number, nombre: string) => { setConfirmDelete({ type: 'ambito', id, name: nombre }); }; // === STATS === const totalEntradas = bitacora?.nodos?.reduce((sum, n) => sum + n.entradas.length, 0) || 0; const totalNodos = bitacora?.nodos?.length || 0; return (
{/* Modal de confirmación */} setConfirmDelete(null)} /> {/* Modal visor de documentos .md */} {docModal.show && (
setDocModal({ ...docModal, show: false })}>
e.stopPropagation()} style={{ maxWidth: '800px', maxHeight: '85vh', width: '90%', display: 'flex', flexDirection: 'column' }}>
{docModal.title}
{docModal.loading ? (
) : (
{docModal.content}
)}
)}
{/* =================== TAB BITÁCORA =================== */} {tab === 'bitacora' && ( <> {/* Global Stats Row */} {/* Global Stats Row */} {globalStats.length > 0 && (
{globalStats.map((s, i) => (
{s.nombre}
{s.count_nodos} nodos {s.count_entradas} ev.
))}
)} {/* Date Selector con Mini Calendario */}
{showCal && (
{MONTH_NAMES[calMonth.month]} {calMonth.year}
{'DLMMJVS'.split('').map((d,i)=>)} {buildCalDays().map((row, ri) => ( {row.map((c, ci) => ( ))} ))}
{d}
{ if (c.day > 0) { setFecha(c.dateStr); setShowCal(false); } }} >{c.day > 0 ? c.day : ''}
)} {/* Stats */}
{bitacora?.nodos?.reduce((s, n) => s + n.entradas.length, 0) || 0}
Entradas
{bitacora?.nodos?.length || 0}
Nodos Activos
{bitacora?.nodos?.reduce((s, n) => s + n.entradas.filter(e => e.estado === '✅').length, 0) || 0}
Completadas
{bitacora?.nodos?.reduce((s, n) => s + n.entradas.filter(e => e.estado === '⏳').length, 0) || 0}
En Proceso
{/* =================== CONTROL DE GESTIÓN =================== */}
Pendientes
{bitacora?.gestion?.filter(g => g.tipo === 'pendiente').map(g => ( ))}
{g.nodo_nombre || '—'} {g.detalle}
{g.estado}
En Proceso
{bitacora?.gestion?.filter(g => g.tipo === 'proceso').map(g => ( ))}
{g.nodo_nombre || '—'} {g.detalle}
{g.estado}
{/* New Entry Form */}
Nueva Entrada
setNewInicio(e.target.value)} placeholder="I" required step="1" /> setNewFin(e.target.value)} placeholder="F" step="1" /> setNewDesc(e.target.value)} placeholder="Descripción..." required />
{/* Loading */} {loading &&
} {/* Agrupar entradas por ÁMBITO en lugar de por nodo */} {(() => { // Agrupar por ámbito a nivel de cada entrada const porAmbito: Record = {}; bitacora?.nodos?.forEach(nodoGroup => { if (nodoGroup.resumen) { // Si el nodo tiene resumen genérico, lo guardamos en su primer ámbito disponible o "Global" const defaultAmbito = nodoGroup.entradas.length > 0 ? (nodoGroup.entradas[0].ambito_nombre || nodoGroup.ambito_nombre || 'Sin Ámbito') : 'Sin Ámbito'; if (!porAmbito[defaultAmbito]) porAmbito[defaultAmbito] = { ambito: defaultAmbito, nodos: new Set(), entradas: [], resumenes: [] }; porAmbito[defaultAmbito].resumenes.push({ nodo: nodoGroup.nodo, resumen: nodoGroup.resumen }); } nodoGroup.entradas.forEach(e => { const ambito = e.ambito_nombre || nodoGroup.ambito_nombre || 'Sin Ámbito'; if (!porAmbito[ambito]) { porAmbito[ambito] = { ambito: ambito, nodos: new Set(), entradas: [], resumenes: [] }; } porAmbito[ambito].entradas.push(e); porAmbito[ambito].nodos.add(nodoGroup.nodo || e.nodo_nombre); }); }); return Object.values(porAmbito).map((ambitoGroup, idx) => (
{ambitoGroup.ambito} {ambitoGroup.nodos.size} nodos | {ambitoGroup.entradas.length} entradas
{/* Resúmenes por nodo dentro del ámbito */} {ambitoGroup.resumenes.length > 0 && ambitoGroup.resumenes.map((r, i) => (
0 ? '0.5rem' : 0 }}>
{r.nodo}: {r.resumen}
))}
{ambitoGroup.entradas.sort((a, b) => b.inicio.localeCompare(a.inicio)).map(e => ( editId === e.id ? ( /* ==== FILA EN EDICIÓN ==== */ ) : ( /* ==== FILA NORMAL ==== */ ) ))}
ID I F Descripción Nodo J E Acc.
{e.id} setEditInicio(ev.target.value)} className="edit-input" step="1" /> setEditFin(ev.target.value)} className="edit-input" step="1" /> setEditDesc(ev.target.value)} className="edit-input" style={{ width: '100%' }} /> {e.nodo_nombre}
{e.id} {e.inicio?.substring(0, 5)} {e.fin ? e.fin.substring(0, 5) : '—'} {e.es_ia && IA} {e.doc_path && (() => { const parts = e.doc_path.split('/'); const archivo = parts.pop() || ''; const proyecto = parts.pop() || ''; const label = archivo.replace('.md', '').replace(/_/g, ' ').split(' ')[0]; return ( ); })()} {renderDescription(e.descripcion)} {e.nodo_nombre} {e.modo} {e.estado}
)); })()} {!loading && totalEntradas === 0 && (
Sin entradas para esta fecha. ¡Creá la primera!
)} )} {/* =================== TAB NODOS =================== */} {tab === 'nodos' && ( <>

Gestión de Nodos

{/* Nodo Form */} {nodoFormVisible && (
{editNodoId ? 'Editar Nodo' : 'Nuevo Nodo'}
setNodoNombre(e.target.value)} placeholder="Nombre (ej: srv-ns8)" required style={{ minWidth: '150px' }} /> setNodoIp(e.target.value)} placeholder="IP (ej: 10.0.10.200)" style={{ minWidth: '130px' }} /> setNodoDesc(e.target.value)} placeholder="Descripción..." />
)} {/* Nodos Table */}
{nodos.map(n => ( ))}
ID Nombre IP Tipo Ámbito Descripción Acc.
{n.id} {n.nombre} {n.ip || '—'} {n.tipo || 'N/A'} {n.ambito_nombre || '—'} {n.descripcion || '—'}
)} {/* =================== TAB ÁMBITOS =================== */} {tab === 'ambitos' && ( <> {activeAmbitoId ? ( setActiveAmbitoId(null)} /> ) : ( <>

Gestión de Ámbitos

{/* Ámbito Form */} {ambitoFormVisible && (
{editAmbitoId ? 'Editar Ámbito' : 'Nuevo Ámbito'}
setAmbitoNombre(e.target.value)} placeholder="Nombre (ej: dtic-DASUTEN)" required style={{ minWidth: '180px' }} /> setAmbitoDesc(e.target.value)} placeholder="Descripción..." style={{ flex: 1 }} />
)} {/* Ámbitos Table */}
{ambitos.map(a => ( ))}
ID Nombre Padre Nodos Descripción Acc.
{a.id} {a.nombre} {a.parent_nombre || '—'} {a.count_nodos || 0} {a.descripcion || '—'}
)} )} {/* =================== TAB PROYECTO =================== */} {tab === 'proyecto' && ( <>
Plan:
)}
); } export default App;