[P2606] WebServer NGINX: plan actualizado, ficha nodo, HTTPS operativo

- P2606: Plan v1.1 EN EJECUCIÓN. Fases 1-3 completadas.
- Ficha nodo srvv-nginx-rm.md. P2606 en adn/07_proyectos.md.
- Incluye cambios acumulados de sesiones anteriores.
This commit is contained in:
Ricardo Monla
2026-03-28 12:18:42 -03:00
parent 97d9b777b4
commit 160da47f96
78 changed files with 6461 additions and 624 deletions
@@ -3,6 +3,7 @@ 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];
@@ -51,6 +52,18 @@ interface Nodo {
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;
}
@@ -75,7 +88,7 @@ function ConfirmModal({ show, title, message, onConfirm, onCancel }: {
}
// ===== TABS =====
type Tab = 'bitacora' | 'nodos' | 'proyecto';
type Tab = 'bitacora' | 'nodos' | 'ambitos' | 'proyecto';
function getInitialTab(): Tab {
const path = window.location.pathname.toLowerCase();
@@ -87,17 +100,17 @@ function getInitialTab(): Tab {
function App() {
const [tab, setTab] = useState<Tab>(getInitialTab());
const changeTab = (t: Tab) => {
const changeTab = React.useCallback((t: Tab) => {
setTab(t);
const base = '/bitacoras/';
// Asegurar que las rutas incluyan el prefijo correcto para el ruteo de NGINX
const paths: Record<Tab, string> = {
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<Bitacora | null>(null);
const [nodos, setNodos] = useState<Nodo[]>([]);
@@ -129,19 +142,73 @@ function App() {
// Confirm delete
const [confirmDelete, setConfirmDelete] = useState<{ type: string; id: number; name: string } | null>(null);
// Nodo form
// Ámbitos
const [ambitos, setAmbitos] = useState<Ambito[]>([]);
// Form states
const [nodoFormVisible, setNodoFormVisible] = useState(false);
const [editNodoId, setEditNodoId] = useState<number | null>(null);
const [nodoNombre, setNodoNombre] = useState('');
const [nodoIp, setNodoIp] = useState('');
const [nodoTipo, setNodoTipo] = useState('servidor');
const [nodoDesc, setNodoDesc] = useState('');
const [nodoAmbitoId, setNodoAmbitoId] = useState<string>('');
const [ambitoFormVisible, setAmbitoFormVisible] = useState(false);
const [editAmbitoId, setEditAmbitoId] = useState<number | null>(null);
const [ambitoNombre, setAmbitoNombre] = useState('');
const [ambitoDesc, setAmbitoDesc] = useState('');
const [ambitoParentId, setAmbitoParentId] = useState<string>('');
const [activeAmbitoId, setActiveAmbitoId] = useState<number | null>(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 (proyecto: string, archivo: string) => {
setDocModal({ show: true, title: archivo.replace('.md', ''), content: '', loading: true });
try {
const res = await fetch(`${API_URL}/docs/${proyecto}/${archivo}`);
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/, abrir modal de documento
if (url.includes('../proy/') && url.endsWith('.md')) {
const parts = url.split('/');
const archivo = parts.pop() || '';
const proyecto = parts.pop() || '';
return (
<a
{...props}
href="#"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
openDocModal(proyecto, archivo);
}}
className="desc-link"
title={`Ver ${archivo} en la web`}
style={{ cursor: 'pointer', borderBottom: '1px dashed var(--primary)' }}
>
{props.children}
</a>
);
}
// Si es un link a un proyecto del ADN, interceptamos para navegación SPA
if (url.startsWith('../proyectos/')) {
const projectFile = url.split('/').pop() || '';
@@ -153,7 +220,7 @@ function App() {
<a
{...props}
href="#"
onClick={(e) => {
onClick={(e: React.MouseEvent) => {
e.preventDefault();
setSelectedProyecto(projectCode);
changeTab('proyecto');
@@ -176,7 +243,7 @@ function App() {
target="_blank"
rel="noopener noreferrer"
className="desc-link"
onClick={(e) => e.stopPropagation()}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
{props.children}
</a>
@@ -205,7 +272,7 @@ function App() {
</code>
);
}
}), [changeTab, setSelectedProyecto]);
}), []); // All deps (changeTab, setSelectedProyecto, openDocModal) are stable useCallbacks/setState
// === HELPERS ===
const renderDescription = (text: string) => {
@@ -233,12 +300,29 @@ function App() {
setLoading(false);
}, [fecha, bitacora]);
const fetchNodos = async () => {
const fetchNodos = useCallback(async () => {
try {
const res = await fetch(`${API_URL}/nodos`);
setNodos(await res.json());
} catch (err) { console.error('Error:', err); }
};
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 {
@@ -325,17 +409,23 @@ function App() {
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]);
}, [fetchBitacoraVersion, fetchBitacora, fetchFechasActivas, lastVersion, fetchNodos, fetchAmbitos, fetchGlobalStats]);
useEffect(() => { fetchBitacora(); }, [fetchBitacora]);
useEffect(() => { fetchNodos(); fetchProyectos(); }, []);
useEffect(() => { fetchNodos(); fetchProyectos(); fetchAmbitos(); fetchGlobalStats(); }, []);
useEffect(() => { fetchFechasActivas(); }, [fetchFechasActivas]);
// Inicializar versión al cargar
@@ -407,8 +497,8 @@ function App() {
const startEdit = (e: Entrada) => {
setEditId(e.id);
setEditInicio(e.inicio?.substring(0, 5) || '');
setEditFin(e.fin ? e.fin.substring(0, 5) : '');
setEditInicio(e.inicio?.substring(0, 8) || '');
setEditFin(e.fin ? e.fin.substring(0, 8) : '');
setEditDesc(e.descripcion);
setEditEstado(e.estado);
setEditModo(e.modo);
@@ -444,13 +534,16 @@ function App() {
} 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('');
setEditNodoId(null); setNodoNombre(''); setNodoIp(''); setNodoTipo('servidor'); setNodoDesc(''); setNodoAmbitoId('');
setNodoFormVisible(false);
};
@@ -458,8 +551,10 @@ function App() {
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);
};
@@ -467,18 +562,22 @@ function App() {
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({ nombre: nodoNombre, ip: nodoIp, tipo: nodoTipo, descripcion: nodoDesc, activo: existing?.activo ?? true })
body: JSON.stringify({ ...payload, activo: existing?.activo ?? true })
});
} else {
await fetch(`${API_URL}/nodos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nombre: nodoNombre, ip: nodoIp, tipo: nodoTipo, descripcion: nodoDesc })
body: JSON.stringify(payload)
});
}
resetNodoForm();
@@ -489,6 +588,51 @@ function App() {
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;
@@ -504,6 +648,31 @@ function App() {
onCancel={() => setConfirmDelete(null)}
/>
{/* Modal visor de documentos .md */}
{docModal.show && (
<div className="modal-overlay" onClick={() => setDocModal({ ...docModal, show: false })}>
<div className="modal-box" onClick={e => e.stopPropagation()} style={{ maxWidth: '800px', maxHeight: '85vh', width: '90%', display: 'flex', flexDirection: 'column' }}>
<div className="modal-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span><i className="fas fa-file-alt"></i> {docModal.title}</span>
<button className="btn btn-sm" onClick={() => setDocModal({ ...docModal, show: false })} style={{ background: 'transparent', color: 'var(--text-muted)', border: 'none', fontSize: '1.2rem' }}>
<i className="fas fa-times"></i>
</button>
</div>
<div style={{ overflow: 'auto', padding: '1.5rem', flex: 1 }}>
{docModal.loading ? (
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}><i className="fas fa-spinner fa-spin fa-2x"></i></div>
) : (
<div className="doc-viewer-content">
<ReactMarkdown remarkPlugins={MARKDOWN_PLUGINS} components={markdownComponents as any}>
{docModal.content}
</ReactMarkdown>
</div>
)}
</div>
</div>
</div>
)}
<nav className="navbar">
<div className="navbar-brand">
<i className="fas fa-book-open"></i>
@@ -526,6 +695,11 @@ function App() {
onClick={() => changeTab('proyecto')}
style={tab !== 'proyecto' ? { background: 'var(--bg-card)', color: 'var(--text)', border: '1px solid var(--border)' } : {}}
><i className="fas fa-project-diagram"></i> Planes</button>
<button
className={`btn btn-sm ${tab === 'ambitos' ? 'btn-primary' : ''}`}
onClick={() => changeTab('ambitos')}
style={tab !== 'ambitos' ? { background: 'var(--bg-card)', color: 'var(--text)', border: '1px solid var(--border)' } : {}}
><i className="fas fa-layer-group"></i> Ámbitos</button>
{tab === 'bitacora' && (
<a href={`${API_URL}/bitacoras/${fecha}/export`} target="_blank" rel="noopener"
className="btn btn-sm btn-primary">
@@ -539,6 +713,21 @@ function App() {
{/* =================== TAB BITÁCORA =================== */}
{tab === 'bitacora' && (
<>
{/* Global Stats Row */}
{/* Global Stats Row */}
{globalStats.length > 0 && (
<div style={{ display: 'flex', gap: '1rem', overflowX: 'auto', paddingBottom: '1rem', marginBottom: '1rem', borderBottom: '1px solid var(--border)' }}>
{globalStats.map((s, i) => (
<div key={i} className="card" style={{ padding: '0.6rem 1rem', minWidth: '150px', flexShrink: 0 }}>
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', textTransform: 'uppercase', fontWeight: 600 }}>{s.nombre}</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginTop: '0.2rem' }}>
<span style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--primary)' }}>{s.count_nodos} <small style={{ fontSize: '0.6rem', fontWeight: 400 }}>nodos</small></span>
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{s.count_entradas} <small style={{ fontSize: '0.6rem' }}>ev.</small></span>
</div>
</div>
))}
</div>
)}
{/* Date Selector con Mini Calendario */}
<div className="date-selector">
<button className="btn btn-sm btn-primary" onClick={() => {
@@ -664,8 +853,8 @@ function App() {
<div className="card" style={{ marginBottom: '2rem' }}>
<div className="card-header"><i className="fas fa-plus-circle"></i> Nueva Entrada</div>
<form className="form-inline" onSubmit={handleSubmit}>
<input type="time" className="input-time" value={newInicio} onChange={e => setNewInicio(e.target.value)} placeholder="I" required />
<input type="time" className="input-time" value={newFin} onChange={e => setNewFin(e.target.value)} placeholder="F" />
<input type="time" className="input-time" value={newInicio} onChange={e => setNewInicio(e.target.value)} placeholder="I" required step="1" />
<input type="time" className="input-time" value={newFin} onChange={e => setNewFin(e.target.value)} placeholder="F" step="1" />
<input type="text" className="input-desc" value={newDesc} onChange={e => setNewDesc(e.target.value)} placeholder="Descripción..." required />
<select value={newNodoId} onChange={e => setNewNodoId(e.target.value)} required>
<option value="">Nodo...</option>
@@ -688,98 +877,144 @@ function App() {
{/* Loading */}
{loading && <div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}><i className="fas fa-spinner fa-spin fa-2x"></i></div>}
{/* Entries by Node */}
{bitacora?.nodos?.map((nodoGroup, idx) => (
<div key={idx} className="nodo-section card" style={{ marginBottom: '1.5rem' }}>
<div className="nodo-title">
<i className="fas fa-server"></i>
{nodoGroup.nodo}
{nodoGroup.ip && <span className="nodo-ip">({nodoGroup.ip})</span>}
<span className="badge badge-gradient" style={{ marginLeft: 'auto' }}>{nodoGroup.entradas.length}</span>
</div>
<div style={{ padding: '0.8rem 1rem', background: 'rgba(102, 126, 234, 0.05)', borderBottom: '1px solid var(--border)', fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<i className="fas fa-quote-left" style={{ opacity: 0.5 }}></i>
<div style={{ fontStyle: 'italic', flex: 1 }}>{nodoGroup.resumen || 'Sin resumen integral registrado.'}</div>
<button className="btn btn-sm" onClick={() => updateResumenNodo(nodoGroup.nodo, nodoGroup.resumen || '')} style={{ padding: '0.1rem 0.4rem', fontSize: '0.7rem' }} title="Editar Resumen"><i className="fas fa-pen"></i></button>
{/* Agrupar entradas por ÁMBITO en lugar de por nodo */}
{(() => {
// Agrupar por ámbito
const porAmbito = {};
bitacora?.nodos?.forEach(nodoGroup => {
const ambito = nodoGroup.ambito_nombre || 'Sin Ámbito';
if (!porAmbito[ambito]) {
porAmbito[ambito] = {
ambito: ambito,
nodos: new Set(),
entradas: [],
resumenes: []
};
}
porAmbito[ambito].entradas.push(...nodoGroup.entradas);
porAmbito[ambito].nodos.add(nodoGroup.nodo);
if (nodoGroup.resumen) {
porAmbito[ambito].resumenes.push({ nodo: nodoGroup.nodo, resumen: nodoGroup.resumen });
}
});
return Object.values(porAmbito).map((ambitoGroup, idx) => (
<div key={idx} className="nodo-section card" style={{ marginBottom: '1.5rem' }}>
<div className="nodo-title">
<i className="fas fa-layer-group" style={{ color: 'var(--primary)' }}></i>
{ambitoGroup.ambito}
<span className="badge badge-gradient" style={{ marginLeft: 'auto' }}>
{ambitoGroup.nodos.size} nodos | {ambitoGroup.entradas.length} entradas
</span>
</div>
{/* Resúmenes por nodo dentro del ámbito */}
{ambitoGroup.resumenes.length > 0 && ambitoGroup.resumenes.map((r, i) => (
<div key={i} style={{ padding: '0.8rem 1rem', background: 'rgba(102, 126, 234, 0.05)', borderBottom: '1px solid var(--border)', fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: i > 0 ? '0.5rem' : 0 }}>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<i className="fas fa-quote-left" style={{ opacity: 0.5 }}></i>
<div style={{ fontStyle: 'italic', flex: 1 }}>
<strong>{r.nodo}:</strong> {r.resumen}
</div>
</div>
</div>
))}
<div className="table-responsive">
<table className="table-ifde">
<thead>
<tr>
<th style={{ width: '40px', color: 'var(--text-muted)', fontSize: '0.85rem' }}>ID</th>
<th className="col-time">I</th>
<th className="col-time">F</th>
<th className="col-desc">Descripción</th>
<th style={{ width: '90px', textAlign: 'center' }}>Nodo</th>
<th className="col-j-header" style={{ width: '35px', textAlign: 'center' }}>J</th>
<th className="col-status">E</th>
<th style={{ width: '80px' }}>Acc.</th>
</tr>
</thead>
<tbody>
{ambitoGroup.entradas.sort((a, b) => b.inicio.localeCompare(a.inicio)).map(e => (
editId === e.id ? (
/* ==== FILA EN EDICIÓN ==== */
<tr key={e.id} style={{ background: 'rgba(102, 126, 234, 0.1)' }}>
<td style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{e.id}</td>
<td><input type="time" value={editInicio?.substring(0,5)} onChange={ev => setEditInicio(ev.target.value)} className="edit-input" step="1" /></td>
<td><input type="time" value={editFin?.substring(0,5)} onChange={ev => setEditFin(ev.target.value)} className="edit-input" step="1" /></td>
<td>
<input type="text" value={editDesc} onChange={ev => setEditDesc(ev.target.value)} className="edit-input" style={{ width: '100%' }} />
<label style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: '0.2rem', marginTop: '0.3rem' }}>
<input type="checkbox" checked={editEsIa} onChange={ev => setEditEsIa(ev.target.checked)} /> IA
</label>
</td>
<td style={{ textAlign: 'center', fontSize: '0.8rem', fontWeight: 600, color: 'var(--text-secondary)' }}>{e.nodo_nombre}</td>
<td style={{ textAlign: 'center' }}>
<select value={editModo} onChange={ev => setEditModo(ev.target.value)} className="edit-input" style={{ width: '45px', padding: '0.35rem 0.2rem' }}>
<option value="P">P</option><option value="R">R</option><option value="S">S</option>
</select>
</td>
<td style={{ textAlign: 'center' }}>
<select value={editEstado} onChange={ev => setEditEstado(ev.target.value)} className="edit-input" style={{ width: '45px', padding: '0.35rem 0.2rem' }}>
<option value="⏳"></option><option value="✅"></option>
<option value="⚠️"></option><option value="❌"></option><option value="👁️">👁</option>
</select>
</td>
<td>
<div style={{ display: 'flex', gap: '0.2rem' }}>
<button className="btn btn-sm btn-success" onClick={saveEdit} title="Guardar"><i className="fas fa-check"></i></button>
<button className="btn btn-sm" onClick={cancelEdit} title="Cancelar" style={{ background: 'var(--bg-card)', color: 'var(--text-muted)' }}><i className="fas fa-times"></i></button>
</div>
</td>
</tr>
) : (
/* ==== FILA NORMAL ==== */
<tr key={e.id}>
<td style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{e.id}</td>
<td className="col-time">{e.inicio?.substring(0, 5)}</td>
<td className="col-time">{e.fin ? e.fin.substring(0, 5) : '—'}</td>
<td className="col-desc">
{e.es_ia && <span className="ia-tag">IA</span>}
{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 (
<button
className="plan-link-btn"
onClick={(ev) => { ev.stopPropagation(); openDocModal(proyecto, archivo); }}
title={`Ver ${archivo}`}
>
<i className="fas fa-file-alt"></i> {label}
</button>
);
})()}
{renderDescription(e.descripcion)}
</td>
<td style={{ textAlign: 'center', fontSize: '0.8rem', fontWeight: 600, color: 'var(--primary)', background: 'rgba(102, 126, 234, 0.05)' }}>{e.nodo_nombre}</td>
<td style={{ textAlign: 'center', fontWeight: 600, color: 'var(--text-secondary)' }}>{e.modo}</td>
<td className="col-status">{e.estado}</td>
<td>
<div style={{ display: 'flex', gap: '0.2rem' }}>
<button className="btn btn-sm" onClick={() => startEdit(e)} title="Editar"
style={{ background: 'var(--info)', color: 'white' }}>
<i className="fas fa-pen"></i>
</button>
<button className="btn btn-sm btn-danger" onClick={() => requestDeleteEntrada(e.id, e.descripcion)} title="Eliminar">
<i className="fas fa-trash-alt"></i>
</button>
</div>
</td>
</tr>
)
))}
</tbody>
</table>
</div>
</div>
<table className="table-ifde">
<thead>
<tr>
<th style={{ width: '40px', color: 'var(--text-muted)', fontSize: '0.85rem' }}>ID</th>
<th className="col-time">I</th>
<th className="col-time">F</th>
<th>Descripción</th>
<th className="col-j-header" style={{ width: '35px', textAlign: 'center' }}>J</th>
<th className="col-status">E</th>
<th style={{ width: '80px' }}>Acc.</th>
</tr>
</thead>
<tbody>
{nodoGroup.entradas.map(e => (
editId === e.id ? (
/* ==== FILA EN EDICIÓN ==== */
<tr key={e.id} style={{ background: 'rgba(102, 126, 234, 0.1)' }}>
<td style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{e.id}</td>
<td><input type="time" value={editInicio} onChange={ev => setEditInicio(ev.target.value)} className="edit-input" /></td>
<td><input type="time" value={editFin} onChange={ev => setEditFin(ev.target.value)} className="edit-input" /></td>
<td>
<input type="text" value={editDesc} onChange={ev => setEditDesc(ev.target.value)} className="edit-input" style={{ width: '100%' }} />
<label style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: '0.2rem', marginTop: '0.3rem' }}>
<input type="checkbox" checked={editEsIa} onChange={ev => setEditEsIa(ev.target.checked)} /> IA
</label>
</td>
<td style={{ textAlign: 'center' }}>
<select value={editModo} onChange={ev => setEditModo(ev.target.value)} className="edit-input" style={{ width: '45px', padding: '0.35rem 0.2rem' }}>
<option value="P">P</option><option value="R">R</option><option value="S">S</option>
</select>
</td>
<td style={{ textAlign: 'center' }}>
<select value={editEstado} onChange={ev => setEditEstado(ev.target.value)} className="edit-input" style={{ width: '45px', padding: '0.35rem 0.2rem' }}>
<option value="⏳"></option><option value="✅"></option>
<option value="⚠️"></option><option value="❌"></option><option value="👁️">👁</option>
</select>
</td>
<td>
<div style={{ display: 'flex', gap: '0.2rem' }}>
<button className="btn btn-sm btn-success" onClick={saveEdit} title="Guardar"><i className="fas fa-check"></i></button>
<button className="btn btn-sm" onClick={cancelEdit} title="Cancelar" style={{ background: 'var(--bg-card)', color: 'var(--text-muted)' }}><i className="fas fa-times"></i></button>
</div>
</td>
</tr>
) : (
/* ==== FILA NORMAL ==== */
<tr key={e.id}>
<td style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{e.id}</td>
<td className="col-time">{e.inicio?.substring(0, 5)}</td>
<td className="col-time">{e.fin ? e.fin.substring(0, 5) : '—'}</td>
<td className="col-desc">
{e.es_ia && <span className="ia-tag">IA</span>}
{renderDescription(e.descripcion)}
</td>
<td style={{ textAlign: 'center', fontWeight: 600, color: 'var(--text-secondary)' }}>{e.modo}</td>
<td className="col-status">{e.estado}</td>
<td>
<div style={{ display: 'flex', gap: '0.2rem' }}>
<button className="btn btn-sm" onClick={() => startEdit(e)} title="Editar"
style={{ background: 'var(--info)', color: 'white' }}>
<i className="fas fa-pen"></i>
</button>
<button className="btn btn-sm btn-danger" onClick={() => requestDeleteEntrada(e.id, e.descripcion)} title="Eliminar">
<i className="fas fa-trash-alt"></i>
</button>
</div>
</td>
</tr>
)
))}
</tbody>
</table>
</div>
))}
));
})()}
{!loading && totalEntradas === 0 && (
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
@@ -817,6 +1052,10 @@ function App() {
<option value="switch">Switch</option>
<option value="otro">Otro</option>
</select>
<select value={nodoAmbitoId} onChange={e => setNodoAmbitoId(e.target.value)}>
<option value="">Sin Ámbito</option>
{ambitos.map(a => <option key={a.id} value={a.id}>{a.nombre}</option>)}
</select>
<input type="text" className="input-desc" value={nodoDesc} onChange={e => setNodoDesc(e.target.value)} placeholder="Descripción..." />
<button type="submit" className="btn btn-success"><i className="fas fa-save"></i> {editNodoId ? 'Actualizar' : 'Crear'}</button>
<button type="button" className="btn btn-sm" onClick={resetNodoForm} style={{ background: 'var(--bg-card)', color: 'var(--text-muted)', border: '1px solid var(--border)' }}>
@@ -835,6 +1074,7 @@ function App() {
<th>Nombre</th>
<th>IP</th>
<th>Tipo</th>
<th>Ámbito</th>
<th>Descripción</th>
<th style={{ width: '80px' }}>Acc.</th>
</tr>
@@ -846,6 +1086,7 @@ function App() {
<td style={{ fontWeight: 600 }}>{n.nombre}</td>
<td style={{ fontFamily: 'monospace', color: 'var(--text-secondary)' }}>{n.ip || '—'}</td>
<td><span className="badge" style={{ background: 'rgba(102, 126, 234, 0.15)', color: 'var(--primary)' }}>{n.tipo || 'N/A'}</span></td>
<td><span className="badge" style={{ background: 'var(--bg-tag)', color: 'var(--text-secondary)', border: '1px solid var(--border)' }}>{n.ambito_nombre || '—'}</span></td>
<td style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>{n.descripcion || '—'}</td>
<td>
<div style={{ display: 'flex', gap: '0.2rem' }}>
@@ -866,10 +1107,96 @@ function App() {
</>
)}
{/* =================== TAB ÁMBITOS =================== */}
{tab === 'ambitos' && (
<>
{activeAmbitoId ? (
<AmbitoDashboard id={activeAmbitoId} onBack={() => setActiveAmbitoId(null)} />
) : (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h2 style={{ fontSize: '1.3rem', fontWeight: 600 }}><i className="fas fa-layer-group" style={{ color: 'var(--primary)', marginRight: '0.5rem' }}></i>Gestión de Ámbitos</h2>
<button className="btn btn-primary" onClick={() => openAmbitoForm()}>
<i className="fas fa-plus"></i> Nuevo Ámbito
</button>
</div>
{/* Ámbito Form */}
{ambitoFormVisible && (
<div className="card" style={{ marginBottom: '1.5rem' }}>
<div className="card-header">
<i className={editAmbitoId ? 'fas fa-pen' : 'fas fa-plus-circle'}></i>
{editAmbitoId ? 'Editar Ámbito' : 'Nuevo Ámbito'}
</div>
<form className="form-inline" onSubmit={saveAmbito}>
<input type="text" value={ambitoNombre} onChange={e => setAmbitoNombre(e.target.value)} placeholder="Nombre (ej: dtic-DASUTEN)" required style={{ minWidth: '180px' }} />
<select value={ambitoParentId} onChange={e => setAmbitoParentId(e.target.value)}>
<option value="">Ámbito Raíz (Sin Padre)</option>
{ambitos.filter(a => a.id !== editAmbitoId).map(a => (
<option key={a.id} value={a.id}>{a.nombre}</option>
))}
</select>
<input type="text" className="input-desc" value={ambitoDesc} onChange={e => setAmbitoDesc(e.target.value)} placeholder="Descripción..." style={{ flex: 1 }} />
<button type="submit" className="btn btn-success"><i className="fas fa-save"></i> {editAmbitoId ? 'Actualizar' : 'Crear'}</button>
<button type="button" className="btn btn-sm" onClick={resetAmbitoForm} style={{ background: 'var(--bg-card)', color: 'var(--text-muted)', border: '1px solid var(--border)' }}>
<i className="fas fa-times"></i> Cancelar
</button>
</form>
</div>
)}
{/* Ámbitos Table */}
<div className="card">
<table className="table-ifde">
<thead>
<tr>
<th>ID</th>
<th>Nombre</th>
<th>Padre</th>
<th>Nodos</th>
<th>Descripción</th>
<th style={{ width: '120px' }}>Acc.</th>
</tr>
</thead>
<tbody>
{ambitos.map(a => (
<tr key={a.id} style={!a.activo ? { opacity: 0.5, textDecoration: 'line-through' } : {}}>
<td style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{a.id}</td>
<td style={{ fontWeight: 600 }}>{a.nombre}</td>
<td style={{ fontStyle: 'italic', color: 'var(--text-secondary)' }}>{a.parent_nombre || '—'}</td>
<td style={{ textAlign: 'center' }}>
<span className="badge badge-gradient">{a.count_nodos || 0}</span>
</td>
<td style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>{a.descripcion || '—'}</td>
<td>
<div style={{ display: 'flex', gap: '0.2rem' }}>
<button className="btn btn-sm" onClick={() => setActiveAmbitoId(a.id)} title="Ver Dashboard"
style={{ background: 'var(--primary)', color: 'white' }}>
<i className="fas fa-chart-line"></i>
</button>
<button className="btn btn-sm" onClick={() => openAmbitoForm(a)} title="Editar"
style={{ background: 'var(--info)', color: 'white' }}>
<i className="fas fa-pen"></i>
</button>
<button className="btn btn-sm btn-danger" onClick={() => requestDeleteAmbito(a.id, a.nombre)} title="Eliminar">
<i className="fas fa-trash-alt"></i>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</>
)}
{/* =================== TAB PROYECTO =================== */}
{tab === 'proyecto' && (
<>
<div className="date-selector" style={{ marginBottom: '1.5rem', justifyContent: 'center' }}>
<div className="date-selector" style={{ marginBottom: '1.5rem', justifyContent: 'center' }}>
<button className="btn btn-sm btn-primary" onClick={() => navigateProyecto(-1)}>
<i className="fas fa-chevron-left"></i>
</button>