Feat(ADN): Implementado y ejecutado archivar:md para mover Markdowns obsoletos a histórico DB-First

This commit is contained in:
Ricardo Monla
2026-03-10 16:02:03 -03:00
parent 8aa1532396
commit 8d27a8958e
74 changed files with 3195 additions and 2073 deletions
@@ -42,15 +42,45 @@ module.exports = (pool) => {
ORDER BY e.inicio DESC
`, [bitacora.id]);
// Obtener gestión asociada a esta bitácora (fija) + activos globales (rollover)
const { rows: gestion } = await pool.query(`
SELECT g.*, n.nombre as nodo_nombre
FROM bitacoras.gestion g
LEFT JOIN bitacoras.nodos n ON g.nodo_id = n.id
WHERE g.bitacora_id = $1 OR g.activo = true
ORDER BY g.tipo, g.orden
`, [bitacora.id]);
// Obtener resúmenes por nodo
const { rows: resúmenes } = await pool.query(`
SELECT r.*, n.nombre as nodo_nombre
FROM bitacoras.resumen_nodos r
JOIN bitacoras.nodos n ON r.nodo_id = n.id
WHERE r.bitacora_id = $1
`, [bitacora.id]);
// Agrupar por nodo
const porNodo = {};
entradas.forEach(e => {
const nodo = e.nodo_nombre || 'sin-nodo';
if (!porNodo[nodo]) porNodo[nodo] = { nodo: nodo, ip: e.nodo_ip, entradas: [] };
if (!porNodo[nodo]) porNodo[nodo] = { nodo: nodo, ip: e.nodo_ip, entradas: [], resumen: '' };
porNodo[nodo].entradas.push(e);
});
res.json({ ...bitacora, nodos: Object.values(porNodo) });
// Adjuntar resúmenes a la agrupación por nodo
resúmenes.forEach(r => {
if (porNodo[r.nodo_nombre]) {
porNodo[r.nodo_nombre].resumen = r.resumen;
} else {
porNodo[r.nodo_nombre] = { nodo: r.nodo_nombre, entradas: [], resumen: r.resumen };
}
});
res.json({
...bitacora,
nodos: Object.values(porNodo),
gestion: gestion
});
});
// GET /api/bitacoras/:fecha/export — Exportar a Markdown
@@ -61,17 +91,41 @@ module.exports = (pool) => {
const bitacora = bResult.rows[0];
const { rows: entradas } = await pool.query(`
SELECT e.*, n.nombre as nodo_nombre
FROM bitacoras.entradas e
LEFT JOIN bitacoras.nodos n ON e.nodo_id = n.id
WHERE e.bitacora_id = $1
ORDER BY n.nombre, e.inicio DESC
`, [bitacora.id]);
SELECT e.*, n.nombre as nodo_nombre FROM bitacoras.entradas e
LEFT JOIN bitacoras.nodos n ON e.nodo_id = n.id
WHERE e.bitacora_id = $1 ORDER BY n.nombre, e.inicio ASC
`, [bitacora.id]);
const { rows: gestion } = await pool.query(`
SELECT g.*, n.nombre as nodo_nombre FROM bitacoras.gestion g
LEFT JOIN bitacoras.nodos n ON g.nodo_id = n.id
WHERE g.bitacora_id = $1 OR g.activo = true ORDER BY g.tipo, g.orden
`, [bitacora.id]);
const { rows: resumenes } = await pool.query(`
SELECT r.*, n.nombre as nodo_nombre FROM bitacoras.resumen_nodos r
JOIN bitacoras.nodos n ON r.nodo_id = n.id WHERE r.bitacora_id = $1
`, [bitacora.id]);
// Generar Markdown compatible con ADN
const [y, m, d] = fecha.split('-');
let md = `# Bitácora de Operaciones - ${d}/${m}/${y}\n\n`;
md += `## 📂 Actividades Detalladas\n\n`;
let md = `# Bitácora ${fecha}\n\n---\n\n## 📋 Control de Gestión\n\n### Pendientes\n\n| ID | Nodo | Detalle |\n| :--- | :--- | :--- |\n`;
gestion.filter(g => g.tipo === 'pendiente').forEach(g => {
md += `| ${g.id} | ${g.nodo_nombre || '—'} | ${g.detalle} |\n`;
});
md += `\n### En Proceso\n\n| ID | Nodo | Detalle |\n| :--- | :--- | :--- |\n`;
gestion.filter(g => g.tipo === 'proceso').forEach(g => {
md += `| ${g.id} | ${g.nodo_nombre || '—'} | ${g.detalle} |\n`;
});
md += `\n### Resumen de Actividades\n\n| Nodo | Resumen Integral |\n| :--- | :--- |\n`;
resumenes.forEach(r => {
md += `| ${r.nodo_nombre} | ${r.resumen} |\n`;
});
md += `\n---\n\n## 📝 Actividades Detalladas\n\n`;
const porNodo = {};
entradas.forEach(e => {
@@ -82,18 +136,18 @@ module.exports = (pool) => {
Object.entries(porNodo).forEach(([nodo, entries]) => {
md += `### ${nodo}\n\n`;
md += `| I | F | Descripción | E |\n`;
md += `| :--- | :--- | :--- | :--- |\n`;
entries.forEach(e => {
// Agrupar por tema si es necesario, o lista simple
const inicio = e.inicio ? e.inicio.substring(0, 5) : '-';
const fin = e.fin ? e.fin.substring(0, 5) : '-';
const ia = e.es_ia ? '(IA) ' : '';
const estado = `[${e.modo}] ${e.estado}`;
md += `| ${inicio} | ${fin} | ${ia}${e.descripcion} | ${estado} |\n`;
md += `| ${inicio} | ${fin} | ${e.descripcion} | ${e.modo} | ${e.estado} |\n`;
});
md += '\n';
});
md += `\n---\n<!-- 🤖 PREMISAS DE TRABAJO (IA): Fuente de Verdad → adn/05_ia.md -->\n`;
res.set('Content-Type', 'text/markdown');
res.send(md);
});
@@ -31,6 +31,7 @@ module.exports = (pool) => {
UPDATE bitacoras.entradas SET inicio=$1, fin=$2, descripcion=$3, estado=$4, modo=$5, es_ia=$6
WHERE id=$7 RETURNING *
`, [inicio, fin, descripcion, estado, modo, es_ia, id]);
if (rows.length === 0) return res.status(404).json({ error: 'Entrada no encontrada' });
res.json(rows[0]);
});
@@ -0,0 +1,53 @@
module.exports = (pool) => {
const router = require('express').Router();
// GET /api/gestion — Listar activos (para rollover) o por bitácora
router.get('/', async (req, res) => {
const { bitacora_id, activos } = req.query;
let query = 'SELECT g.*, n.nombre as nodo_nombre FROM bitacoras.gestion g LEFT JOIN bitacoras.nodos n ON g.nodo_id = n.id';
const params = [];
if (activos === 'true') {
query += ' WHERE g.activo = true';
} else if (bitacora_id) {
query += ' WHERE g.bitacora_id = $1';
params.push(bitacora_id);
}
query += ' ORDER BY g.tipo, g.orden, g.created_at DESC';
const { rows } = await pool.query(query, params);
res.json(rows);
});
// POST /api/gestion — Crear nuevo pendiente/proceso
router.post('/', async (req, res) => {
const { tipo, nodo_id, detalle, estado, bitacora_id, orden } = req.body;
const { rows } = await pool.query(
'INSERT INTO bitacoras.gestion (tipo, nodo_id, detalle, estado, bitacora_id, orden) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *',
[tipo, nodo_id || null, detalle, estado || '⏳', bitacora_id, orden || 0]
);
res.status(201).json(rows[0]);
});
// PUT /api/gestion/:id — Actualizar
router.put('/:id', async (req, res) => {
const { id } = req.params;
const { tipo, nodo_id, detalle, estado, activo, orden } = req.body;
const { rows } = await pool.query(
'UPDATE bitacoras.gestion SET tipo=$1, nodo_id=$2, detalle=$3, estado=$4, activo=$5, orden=$6, updated_at=CURRENT_TIMESTAMP WHERE id=$7 RETURNING *',
[tipo, nodo_id || null, detalle, estado, activo !== undefined ? activo : true, orden || 0, id]
);
if (rows.length === 0) return res.status(404).json({ error: 'No encontrado' });
res.json(rows[0]);
});
// DELETE /api/gestion/:id
router.delete('/:id', async (req, res) => {
const { rowCount } = await pool.query('DELETE FROM bitacoras.gestion WHERE id = $1', [req.params.id]);
if (rowCount === 0) return res.status(404).json({ error: 'No encontrado' });
res.json({ message: 'Eliminado' });
});
return router;
};
@@ -0,0 +1,32 @@
module.exports = (pool) => {
const router = require('express').Router();
// GET /api/resumen — Listar resúmenes por bitácora
router.get('/', async (req, res) => {
const { bitacora_id } = req.query;
if (!bitacora_id) return res.status(400).json({ error: 'Falta bitacora_id' });
const { rows } = await pool.query(
'SELECT r.*, n.nombre as nodo_nombre FROM bitacoras.resumen_nodos r JOIN bitacoras.nodos n ON r.nodo_id = n.id WHERE r.bitacora_id = $1',
[bitacora_id]
);
res.json(rows);
});
// POST /api/resumen — Upsert resumen por nodo
router.post('/', async (req, res) => {
const { bitacora_id, nodo_id, resumen } = req.body;
const { rows } = await pool.query(
`INSERT INTO bitacoras.resumen_nodos (bitacora_id, nodo_id, resumen)
VALUES ($1, $2, $3)
ON CONFLICT (bitacora_id, nodo_id)
DO UPDATE SET resumen = EXCLUDED.resumen, updated_at = CURRENT_TIMESTAMP
RETURNING *`,
[bitacora_id, nodo_id, resumen]
);
res.status(201).json(rows[0]);
});
return router;
};
@@ -29,6 +29,8 @@ app.use('/api/nodos', require('./routes/nodos')(pool));
app.use('/api/bitacoras', require('./routes/bitacoras')(pool));
app.use('/api/entradas', require('./routes/entradas')(pool));
app.use('/api/proyectos', require('./routes/proyectos')(pool));
app.use('/api/gestion', require('./routes/gestion')(pool));
app.use('/api/resumen', require('./routes/resumen')(pool));
// Error handler
app.use((err, req, res, next) => {
@@ -20,6 +20,7 @@ interface NodoGroup {
nodo: string;
ip: string;
entradas: Entrada[];
resumen?: string;
}
interface Bitacora {
@@ -27,6 +28,16 @@ interface Bitacora {
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 {
@@ -85,6 +96,8 @@ function App() {
const [fecha, setFecha] = useState(new Date().toISOString().split('T')[0]);
const [bitacora, setBitacora] = useState<Bitacora | null>(null);
const [nodos, setNodos] = useState<Nodo[]>([]);
const [proyectos, setProyectos] = useState<{ id: number, codigo: string, nombre: string }[]>([]);
const [selectedProyecto, setSelectedProyecto] = useState<string>('P2601');
const [loading, setLoading] = useState(true);
// Form: nueva entrada
@@ -116,6 +129,71 @@ function App() {
const [nodoTipo, setNodoTipo] = useState('servidor');
const [nodoDesc, setNodoDesc] = useState('');
// === HELPERS ===
const renderDescription = (text: string) => {
if (!text) return '';
// Regex para links markdown: [texto](url)
const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
const parts: (string | JSX.Element)[] = [];
let lastIndex = 0;
let match;
while ((match = markdownLinkRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.substring(lastIndex, match.index));
}
const label = match[1];
let url = match[2];
// Si es un link a un proyecto, 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];
parts.push(
<a
key={match.index}
href="#"
onClick={(e) => {
e.preventDefault();
setSelectedProyecto(projectCode);
changeTab('proyecto');
}}
className="desc-link"
title={`Ver plan ${projectCode} en la web`}
>
{label}
</a>
);
lastIndex = markdownLinkRegex.lastIndex;
continue;
}
// Si no tiene el formato PXXXX, fallback a link estático
url = '/bitacoras/docs/proyectos/' + projectFile;
}
parts.push(
<a key={match.index} href={url} target="_blank" rel="noopener noreferrer" className="desc-link">
{label}
</a>
);
lastIndex = markdownLinkRegex.lastIndex;
}
if (lastIndex < text.length) {
parts.push(text.substring(lastIndex));
}
return parts.length > 0 ? <>{parts}</> : text;
};
// === FETCH ===
const fetchBitacora = useCallback(async () => {
setLoading(true);
@@ -134,8 +212,70 @@ function App() {
} catch (err) { console.error('Error:', 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);
};
useEffect(() => { fetchBitacora(); }, [fetchBitacora]);
useEffect(() => { fetchNodos(); }, []);
useEffect(() => { fetchNodos(); fetchProyectos(); }, []);
// === ENTRADAS CRUD ===
const handleSubmit = async (e: React.FormEvent) => {
@@ -276,7 +416,7 @@ function App() {
className={`btn btn-sm ${tab === 'proyecto' ? 'btn-primary' : ''}`}
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> P2601</button>
><i className="fas fa-project-diagram"></i> Planes</button>
{tab === 'bitacora' && (
<a href={`${API_URL}/bitacoras/${fecha}/export`} target="_blank" rel="noopener"
className="btn btn-sm btn-primary">
@@ -308,11 +448,11 @@ function App() {
{/* Stats */}
<div className="stats-bar">
<div className="stat-card">
<div className="stat-value">{totalEntradas}</div>
<div className="stat-value">{bitacora?.nodos?.reduce((s, n) => s + n.entradas.length, 0) || 0}</div>
<div className="stat-label">Entradas</div>
</div>
<div className="stat-card">
<div className="stat-value">{totalNodos}</div>
<div className="stat-value">{bitacora?.nodos?.length || 0}</div>
<div className="stat-label">Nodos Activos</div>
</div>
<div className="stat-card">
@@ -325,6 +465,58 @@ function App() {
</div>
</div>
{/* =================== CONTROL DE GESTIÓN =================== */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem', marginBottom: '2rem' }}>
<div className="card">
<div className="card-header" style={{ borderBottom: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between' }}>
<span><i className="fas fa-list-ul" style={{ color: '#fbbf24' }}></i> Pendientes</span>
<button className="btn btn-sm" onClick={() => addGestion('pendiente')} style={{ padding: '0.1rem 0.4rem', fontSize: '0.7rem' }}><i className="fas fa-plus"></i></button>
</div>
<div style={{ padding: '0.5rem', minHeight: '60px' }}>
<table style={{ width: '100%', fontSize: '0.85rem' }}>
<tbody>
{bitacora?.gestion?.filter(g => g.tipo === 'pendiente').map(g => (
<tr key={g.id} style={{ borderBottom: '1px solid var(--border-light)' }}>
<td style={{ padding: '0.4rem', color: 'var(--text-secondary)', width: '20%' }}>{g.nodo_nombre || '—'}</td>
<td style={{ padding: '0.4rem' }}>{g.detalle}</td>
<td style={{ padding: '0.4rem', textAlign: 'right', width: '60px' }}>
<div style={{ display: 'flex', gap: '0.3rem', alignItems: 'center' }}>
<span>{g.estado}</span>
<button className="btn btn-sm btn-danger" onClick={() => deleteGestion(g.id)} style={{ padding: '0 0.2rem', fontSize: '0.6rem' }}><i className="fas fa-trash"></i></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card">
<div className="card-header" style={{ borderBottom: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between' }}>
<span><i className="fas fa-spinner" style={{ color: '#3b82f6' }}></i> En Proceso</span>
<button className="btn btn-sm" onClick={() => addGestion('proceso')} style={{ padding: '0.1rem 0.4rem', fontSize: '0.7rem' }}><i className="fas fa-plus"></i></button>
</div>
<div style={{ padding: '0.5rem', minHeight: '60px' }}>
<table style={{ width: '100%', fontSize: '0.85rem' }}>
<tbody>
{bitacora?.gestion?.filter(g => g.tipo === 'proceso').map(g => (
<tr key={g.id} style={{ borderBottom: '1px solid var(--border-light)' }}>
<td style={{ padding: '0.4rem', color: 'var(--text-secondary)', width: '20%' }}>{g.nodo_nombre || '—'}</td>
<td style={{ padding: '0.4rem' }}>{g.detalle}</td>
<td style={{ padding: '0.4rem', textAlign: 'right', width: '60px' }}>
<div style={{ display: 'flex', gap: '0.3rem', alignItems: 'center' }}>
<span>{g.estado}</span>
<button className="btn btn-sm btn-danger" onClick={() => deleteGestion(g.id)} style={{ padding: '0 0.2rem', fontSize: '0.6rem' }}><i className="fas fa-trash"></i></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
{/* New Entry Form */}
<div className="card" style={{ marginBottom: '2rem' }}>
<div className="card-header"><i className="fas fa-plus-circle"></i> Nueva Entrada</div>
@@ -362,12 +554,22 @@ function App() {
{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>
</div>
</div>
<table className="table-ifde">
<thead>
<tr>
<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>
@@ -381,20 +583,21 @@ function App() {
<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%' }} />
<div style={{ display: 'flex', gap: '0.3rem', marginTop: '0.3rem', alignItems: 'center' }}>
<select value={editEstado} onChange={ev => setEditEstado(ev.target.value)} className="edit-input">
<option value="⏳"></option><option value="✅"></option>
<option value="⚠️"></option><option value="❌"></option><option value="👁️">👁</option>
</select>
<select value={editModo} onChange={ev => setEditModo(ev.target.value)} className="edit-input">
<option value="P">[P]</option><option value="R">[R]</option>
</select>
<label style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: '0.2rem' }}>
<input type="checkbox" checked={editEsIa} onChange={ev => setEditEsIa(ev.target.checked)} /> IA
</label>
</div>
<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></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>
@@ -409,9 +612,10 @@ function App() {
<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.descripcion}
{renderDescription(e.descripcion)}
</td>
<td className="col-status">[{e.modo}] {e.estado}</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"
@@ -518,7 +722,38 @@ function App() {
{/* =================== TAB PROYECTO =================== */}
{tab === 'proyecto' && (
<ProyectoDashboard codigo="P2601" />
<>
<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>
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>
<i className="fas fa-project-diagram"></i> Plan:
</span>
<select
value={selectedProyecto}
onChange={e => setSelectedProyecto(e.target.value)}
style={{
background: 'var(--bg-card)',
color: 'var(--text)',
border: '1px solid var(--border)',
padding: '0.4rem 1rem',
borderRadius: '8px',
fontSize: '0.9rem',
fontWeight: 600,
minWidth: '200px'
}}
>
{proyectos.map(p => (
<option key={p.id} value={p.codigo}>{p.codigo} - {p.nombre}</option>
))}
</select>
<button className="btn btn-sm btn-primary" onClick={() => navigateProyecto(1)}>
<i className="fas fa-chevron-right"></i>
</button>
</div>
<ProyectoDashboard codigo={selectedProyecto} />
</>
)}
</main>
</div>
@@ -61,6 +61,7 @@ body {
font-weight: 700;
background: var(--gradient);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
display: flex;
align-items: center;
@@ -160,7 +161,7 @@ body {
}
.table-ifde .col-status {
width: 80px;
width: 45px;
text-align: center;
}
@@ -178,6 +179,23 @@ body {
margin-right: 0.3rem;
}
.desc-link {
color: var(--primary);
text-decoration: none;
font-weight: 600;
background: rgba(102, 126, 234, 0.1);
padding: 0.1rem 0.4rem;
border-radius: 4px;
transition: var(--transition);
border-bottom: 1px solid transparent;
}
.desc-link:hover {
background: var(--primary);
color: white;
text-decoration: none;
}
/* Nodo section */
.nodo-section {
margin-bottom: 2rem;
@@ -337,6 +355,7 @@ body {
font-weight: 700;
background: var(--gradient);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
@@ -23,6 +23,9 @@ interface Fase {
descripcion: string;
fecha_inicio: string | null;
fecha_fin: string | null;
objetivo?: string;
desarrollo?: string;
logros?: string;
hitos: Hito[] | null;
}
@@ -49,6 +52,13 @@ interface Proyecto {
nombre: string;
estado: string;
descripcion: string;
detalles_json: {
contexto?: string[];
objetivos?: string[];
responsables?: string[];
beneficiarios_directos?: string[];
beneficiarios_indirectos?: string[];
} | null;
fases: Fase[];
metricas: Metricas;
nodos: NodoInfo[];
@@ -116,48 +126,14 @@ function DonutChart({ segments, size = 140, strokeWidth = 18, centerLabel, cente
);
}
/* ═══════════ CONTENIDO ENRIQUECIDO POR FASE ═══════════ */
const faseDetalles: Record<number, { objetivo: string; desarrollo: string; logros: string }> = {
1: {
objetivo: 'Preparar el equipo central que alojará todos los servicios de DASUTeN, garantizando que opere de forma independiente a los sistemas de la Facultad.',
desarrollo: 'Se instaló y configuró el servidor dedicado en la oficina de DASUTeN, creando un entorno de trabajo completamente separado de la red universitaria para mayor seguridad y control.',
logros: 'Servidor propio operativo y listo para funcionar. Entorno aislado que brinda independencia total respecto a los sistemas de la Facultad.'
},
2: {
objetivo: 'Establecer un sistema centralizado de identificación de usuarios y permisos de acceso, propio de la oficina DASUTeN.',
desarrollo: 'Se configuró un servicio de gestión de identidades que permite controlar quién accede a cada equipo y recurso dentro de la red de DASUTeN.',
logros: 'Sistema de usuarios operativo. Cada persona accede con credenciales propias. Se simplifica la administración de permisos y la seguridad de acceso.'
},
3: {
objetivo: 'Instalar el sistema de base de datos necesario y recuperar toda la información del sistema DASUTeN desde el respaldo existente.',
desarrollo: 'Se preparó un entorno dedicado para la base de datos, se transfirió el respaldo completo (~9 GB de información) y se restauró verificando su integridad.',
logros: 'Toda la información de gestión de DASUTeN fue recuperada exitosamente. La base de datos está operativa y accesible desde la red interna.'
},
4: {
objetivo: 'Habilitar la administración remota segura de todos los equipos y generar respaldos de seguridad como punto de recuperación.',
desarrollo: 'Se activó el acceso remoto seguro en todos los equipos y se generaron copias de respaldo completas para poder restaurar el sistema ante cualquier eventualidad.',
logros: 'Posibilidad de gestionar todos los equipos sin necesidad de presencia física. Respaldos de seguridad disponibles ante cualquier imprevisto.'
},
5: {
objetivo: 'Analizar la aplicación de gestión DASUTeN y ponerla en funcionamiento sobre la nueva infraestructura virtual.',
desarrollo: 'Configuración de Kermet.ini, instalación de componentes de sistema, fuentes y apertura de firewall SQL. Pruebas de conectividad exitosas.',
logros: 'Despliegue operativo verificado con login exitoso. Validación presencial (UAT) realizada con Andrea Almirón satisfactoriamente.'
},
6: {
objetivo: '🚀 Poner en funcionamiento el servidor físico en la oficina, integrar clientes al dominio y configurar políticas de seguridad.',
desarrollo: 'Traslado físico del servidor Proxmox, unión de la PC pc-dasu0 al dominio dasuten.utnlr y despliegue de GPOs de seguridad.',
logros: 'Hitos pendientes: Instalación física, AD-Join, GPO de seguridad y mapeo de unidades de red.'
}
};
/* ═══════════ MAIN COMPONENT ═══════════ */
export default function ProyectoDashboard({ codigo = 'P2601' }: { codigo?: string }) {
const [proyecto, setProyecto] = useState<Proyecto | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [vista, setVista] = useState<Vista>('gerencial');
const [expandedFases, setExpandedFases] = useState<Set<number>>(new Set());
const [expandedGerFases, setExpandedGerFases] = useState<Set<number>>(new Set());
const [expandedHitos, setExpandedHitos] = useState<Set<number>>(new Set());
const [descExpanded, setDescExpanded] = useState(false);
useEffect(() => { fetchProyecto(); }, [codigo]);
@@ -185,14 +161,47 @@ export default function ProyectoDashboard({ codigo = 'P2601' }: { codigo?: strin
});
};
const toggleGerFase = (num: number) => {
setExpandedGerFases(prev => {
const toggleHito = (id: number) => {
setExpandedHitos(prev => {
const next = new Set(prev);
next.has(num) ? next.delete(num) : next.add(num);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
// Actualizar Estado de Hito
const handleEstadoClick = async (h: Hito) => {
const nextEstado = h.estado === '📋' ? '⏳' : h.estado === '⏳' ? '✅' : '📋';
try {
const res = await fetch(`${API_URL}/proyectos/hitos/${h.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ estado: nextEstado })
});
if (res.ok) fetchProyecto();
} catch (e) { console.error('Error al actualizar estado', e); }
};
// Actualizar Horas
const handleHorasClick = async (h: Hito, tipo: 'P' | 'R') => {
const actual = tipo === 'P' ? h.horas_presencial : h.horas_remoto;
const imputadas = prompt(`Horas ${tipo === 'P' ? 'Presenciales' : 'Remotas'} para hito: ${h.id_hito}\n(Ej: 1.5, 0.5 o dejar vacío)`, String(actual));
if (imputadas === null) return; // Canceló
const num = parseFloat(imputadas.replace(',', '.'));
const valorFinal = isNaN(num) ? 0 : num;
try {
const body = tipo === 'P' ? { horas_presencial: valorFinal } : { horas_remoto: valorFinal };
const res = await fetch(`${API_URL}/proyectos/hitos/${h.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (res.ok) fetchProyecto();
} catch (e) { console.error('Error al actualizar horas', e); }
};
if (loading) return <div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>Cargando proyecto...</div>;
if (error || !proyecto) return <div style={{ textAlign: 'center', padding: '3rem', color: '#ef4444' }}>Error: {error}</div>;
@@ -232,38 +241,14 @@ export default function ProyectoDashboard({ codigo = 'P2601' }: { codigo?: strin
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', maxWidth: '600px', margin: '0 auto 0.8rem' }}>
{proyecto.descripcion}
</p>
{/* Toggle vista */}
<div style={{
display: 'inline-flex', borderRadius: '8px', overflow: 'hidden',
border: '1px solid var(--border)', background: 'rgba(255,255,255,0.03)'
}}>
<button
onClick={() => setVista('gerencial')}
style={{
padding: '6px 16px', fontSize: '0.75rem', fontWeight: 600, border: 'none', cursor: 'pointer',
background: vista === 'gerencial' ? 'linear-gradient(135deg, #38bdf8, #818cf8)' : 'transparent',
color: vista === 'gerencial' ? '#fff' : 'var(--text-muted)', transition: 'all 0.2s ease'
}}
><i className="fas fa-chart-pie" style={{ marginRight: '5px' }}></i>Resumen</button>
<button
onClick={() => setVista('tecnica')}
style={{
padding: '6px 16px', fontSize: '0.75rem', fontWeight: 600, border: 'none', cursor: 'pointer',
background: vista === 'tecnica' ? 'linear-gradient(135deg, #38bdf8, #818cf8)' : 'transparent',
color: vista === 'tecnica' ? '#fff' : 'var(--text-muted)', transition: 'all 0.2s ease'
}}
><i className="fas fa-list-alt" style={{ marginRight: '5px' }}></i>Detalle Técnico</button>
</div>
</div>
{/* ═══════════ VISTA GERENCIAL ═══════════ */}
{vista === 'gerencial' && (
<>
{/* Descripción del Proyecto — desplegable */}
<div className="card" style={{ padding: 0, overflow: 'hidden', marginBottom: '1.5rem' }}>
<div
onClick={() => setDescExpanded(prev => !prev)}
{/* ═══════════ VISTA UNIFICADA ═══════════ */}
{/* Descripción del Proyecto — desplegable */}
<div className="card" style={{ padding: 0, overflow: 'hidden', marginBottom: '1.5rem' }}>
<div
onClick={() => setDescExpanded(prev => !prev)}
style={{
padding: '0.8rem 1rem', cursor: 'pointer', display: 'flex',
alignItems: 'center', justifyContent: 'space-between',
@@ -280,48 +265,49 @@ export default function ProyectoDashboard({ codigo = 'P2601' }: { codigo?: strin
</div>
{descExpanded && (
<div style={{ padding: '0 1rem 1rem', lineHeight: 1.6, fontSize: '0.82rem', color: 'var(--text)' }}>
<p style={{ margin: '0 0 0.8rem' }}>
<strong>DASUTeN</strong> es la obra social que brinda cobertura a empleados, docentes y alumnos de la
Universidad Tecnológica Nacional. Su oficina en la Facultad Regional La Rioja depende
operativamente de la <strong>sede central en Buenos Aires</strong>.
</p>
<p style={{ margin: '0 0 0.8rem' }}>
Este proyecto tiene como objetivo <strong>dotar a la oficina DASUTeN de su propia infraestructura
tecnológica independiente</strong>, operando bajo un entorno virtualizado (Proxmox) con acceso remoto seguro vía Tailscale.
</p>
{proyecto.detalles_json?.contexto?.map((p, i) => (
<p key={i} style={{ margin: '0 0 0.8rem' }} dangerouslySetInnerHTML={{ __html: p.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>') }} />
))}
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)',
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '1.2rem', marginTop: '1rem'
}}>
{proyecto.detalles_json?.objetivos && (
<div>
<div style={subheadStyle}>🎯 Objetivos y Alcance</div>
<ul style={listStyle}>
{proyecto.detalles_json.objetivos.map((o, i) => <li key={i}>{o}</li>)}
</ul>
</div>
)}
<div>
<div style={subheadStyle}>🎯 Objetivos y Alcance</div>
<ul style={listStyle}>
<li>Sistema de gestión operando desde servidor propio</li>
<li>Independencia total de los equipos de la Facultad</li>
<li>Oficina autosuficiente con su propia red y respaldos</li>
<li>Servidor dedicado en oficina DASUTeN</li>
<li>Red interna propia y segura</li>
<li>Computadora de escritorio como acceso al sistema</li>
</ul>
</div>
<div>
<div style={subheadStyle}>👥 Responsables y Beneficiarios</div>
<ul style={listStyle}>
<li><strong>Ejecución:</strong> Lic. Ricardo Monla</li>
<li><strong>Supervisión:</strong> Dirección de TIC</li>
</ul>
<div style={{ fontSize: '0.7rem', color: '#38bdf8', fontWeight: 600, marginTop: '0.4rem', marginBottom: '0.2rem' }}>Usuarios directos:</div>
<ul style={listStyle}>
<li>Andrea Almirón Administrativa DASUTeN</li>
<li>Romina Molina Administrativa</li>
<li>Dra. Eugenia Riveros Auditora Médica</li>
</ul>
<div style={{ fontSize: '0.7rem', color: '#38bdf8', fontWeight: 600, marginTop: '0.4rem', marginBottom: '0.2rem' }}>Beneficiarios indirectos:</div>
<ul style={listStyle}>
<li>Personal Docente</li>
<li>Personal No Docente</li>
<li>Alumnos</li>
</ul>
{proyecto.detalles_json?.responsables && (
<>
<div style={subheadStyle}>👥 Responsables</div>
<ul style={listStyle}>
{proyecto.detalles_json.responsables.map((r, i) => (
<li key={i} dangerouslySetInnerHTML={{ __html: r.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>') }} />
))}
</ul>
</>
)}
{proyecto.detalles_json?.beneficiarios_directos && (
<>
<div style={{ fontSize: '0.7rem', color: '#38bdf8', fontWeight: 600, marginTop: '0.4rem', marginBottom: '0.2rem' }}>Usuarios directos:</div>
<ul style={listStyle}>
{proyecto.detalles_json.beneficiarios_directos.map((b, i) => <li key={i}>{b}</li>)}
</ul>
</>
)}
{proyecto.detalles_json?.beneficiarios_indirectos && (
<>
<div style={{ fontSize: '0.7rem', color: '#38bdf8', fontWeight: 600, marginTop: '0.4rem', marginBottom: '0.2rem' }}>Beneficiarios indirectos:</div>
<ul style={listStyle}>
{proyecto.detalles_json.beneficiarios_indirectos.map((b, i) => <li key={i}>{b}</li>)}
</ul>
</>
)}
</div>
</div>
</div>
@@ -383,100 +369,6 @@ export default function ProyectoDashboard({ codigo = 'P2601' }: { codigo?: strin
</div>
</div>
{/* Fases con descripciones expandibles */}
<SectionTitle title="📊 Fases del Proyecto" />
{proyecto.fases.map(fase => {
const hitos = fase.hitos || [];
const faseCompletos = hitos.filter(h => h.estado === '✅').length;
const faseProgreso = hitos.length > 0 ? Math.round((faseCompletos / hitos.length) * 100) : 0;
const hrsTotal = hitos.reduce((s, h) => s + h.horas_presencial + h.horas_remoto, 0);
const isExpanded = expandedGerFases.has(fase.numero);
const detalle = faseDetalles[fase.numero];
return (
<div key={fase.id} className="card" style={{
marginBottom: '0.6rem', padding: 0, overflow: 'hidden', position: 'relative'
}}>
{/* Barra de progreso de fondo */}
<div style={{
position: 'absolute', top: 0, left: 0, bottom: 0, width: `${faseProgreso}%`,
background: fase.estado === '✅' ? 'rgba(34,197,94,0.06)' : 'rgba(56,189,248,0.04)',
transition: 'width 0.4s', pointerEvents: 'none'
}} />
{/* Header clickeable */}
<div
onClick={() => toggleGerFase(fase.numero)}
style={{
position: 'relative', display: 'flex', justifyContent: 'space-between',
alignItems: 'center', padding: '0.8rem 1rem', cursor: 'pointer'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.7rem' }}>
<span style={{
fontSize: '1.3rem', width: '36px', height: '36px', display: 'flex',
alignItems: 'center', justifyContent: 'center', borderRadius: '8px',
background: fase.estado === '✅' ? 'rgba(34,197,94,0.12)' :
fase.estado === '⏳' ? 'rgba(234,179,8,0.12)' : 'rgba(255,255,255,0.05)'
}}>{estadoIcon(fase.estado)}</span>
<div>
<div style={{ fontWeight: 600, fontSize: '0.88rem' }}>
F{fase.numero} · {fase.nombre}
</div>
{!isExpanded && fase.descripcion && (
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginTop: '2px' }}>
{fase.descripcion}
</div>
)}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexShrink: 0 }}>
{hrsTotal > 0 && (
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{hrsTotal.toFixed(1)}h</span>
)}
<div style={{
minWidth: '52px', textAlign: 'right', fontWeight: 700, fontSize: '0.85rem',
color: faseProgreso === 100 ? '#22c55e' : faseProgreso > 0 ? '#38bdf8' : 'var(--text-muted)'
}}>{faseProgreso}%</div>
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{isExpanded ? '▾' : '▸'}</span>
</div>
</div>
{/* Detalle expandible */}
{isExpanded && detalle && (
<div style={{
position: 'relative', borderTop: '1px solid var(--border)',
padding: '0.8rem 1rem',
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '0.8rem'
}}>
<FaseDetailCol icon="🎯" title="Objetivo" text={detalle.objetivo} />
<FaseDetailCol icon="⚙️" title="Desarrollo" text={detalle.desarrollo} />
<FaseDetailCol icon="✨" title="Logros" text={detalle.logros} />
</div>
)}
</div>
);
})}
</>
)}
{/* ═══════════ VISTA TÉCNICA ═══════════ */}
{vista === 'tecnica' && (
<>
{/* Métricas numéricas */}
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))',
gap: '0.8rem', marginBottom: '1.5rem'
}}>
<MetricCard label="Fases" value={`${fasesCompletas + fasesEnCurso}/${proyecto.fases.length}`} color="#38bdf8" />
<MetricCard label="Hitos Comp." value={m.hitos_completados} color="#22c55e" />
<MetricCard label="Hitos Pend." value={m.hitos_pendientes} color="#eab308" />
<MetricCard label="Hrs Físicas" value={`${hrsP.toFixed(1)}h`} color="#f97316" />
<MetricCard label="Esfuerzo Total" value={`${totalH.toFixed(1)}h`} color="#a78bfa" />
</div>
{/* Barra progreso */}
<div style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem', marginBottom: '0.3rem' }}>
@@ -547,6 +439,22 @@ export default function ProyectoDashboard({ codigo = 'P2601' }: { codigo?: strin
transition: 'width 0.4s'
}} />
</div>
{/* Resumen Táctico de Logros y Objetivos (Merged from Gerencial) */}
{expanded && (fase.objetivo || fase.desarrollo || fase.logros) && (
<div style={{
position: 'relative', borderTop: '1px solid var(--border)',
background: 'rgba(255,255,255,0.01)',
padding: '0.8rem 1rem',
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '0.8rem'
}}>
{fase.objetivo && <FaseDetailCol icon="🎯" title="Objetivo" text={fase.objetivo} />}
{fase.desarrollo && <FaseDetailCol icon="⚙️" title="Desarrollo" text={fase.desarrollo} />}
{fase.logros && <FaseDetailCol icon="✨" title="Logros" text={fase.logros} />}
</div>
)}
{/* Tabla expandible de hitos */}
{expanded && hitos.length > 0 && (
<div style={{ padding: '0.5rem 1rem 0.8rem' }}>
<table className="table-ifde" style={{ fontSize: '0.8rem' }}>
@@ -562,26 +470,61 @@ export default function ProyectoDashboard({ codigo = 'P2601' }: { codigo?: strin
</tr>
</thead>
<tbody>
{hitos.map(h => (
<tr key={h.id}>
<td><code style={{ fontSize: '0.72rem', color: '#38bdf8' }}>{h.id_hito}</code></td>
<td>
<strong>{h.titulo}</strong>
{h.descripcion && <div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginTop: '2px' }}>{h.descripcion}</div>}
</td>
<td style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>
{h.fecha ? new Date(h.fecha + 'T12:00:00').toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' }) : '—'}
</td>
<td style={{ textAlign: 'center', color: h.horas_presencial > 0 ? '#f97316' : 'var(--text-muted)' }}>
{h.horas_presencial > 0 ? `${h.horas_presencial}h` : '—'}
</td>
<td style={{ textAlign: 'center', color: h.horas_remoto > 0 ? '#a78bfa' : 'var(--text-muted)' }}>
{h.horas_remoto > 0 ? `${h.horas_remoto}h` : '—'}
</td>
<td style={{ fontSize: '0.72rem' }}>{h.nodo_nombre || '—'}</td>
<td style={{ textAlign: 'center' }}>{estadoIcon(h.estado)}</td>
</tr>
))}
{hitos.map(h => {
const isHitoExpanded = expandedHitos.has(h.id);
return (
<React.Fragment key={h.id}>
<tr
style={{ cursor: h.descripcion ? 'pointer' : 'default' }}
onClick={() => { if(h.descripcion) toggleHito(h.id) }}
title={h.descripcion ? "Click para expandir/colapsar descripción" : ""}
>
<td onClick={(e) => e.stopPropagation()}><code style={{ fontSize: '0.72rem', color: '#38bdf8' }}>{h.id_hito}</code></td>
<td>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<strong>{h.titulo}</strong>
{h.descripcion && <span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginLeft: '8px' }}>{isHitoExpanded ? '▾' : '▸'}</span>}
</div>
</td>
<td style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }} onClick={(e) => e.stopPropagation()}>
{h.fecha ? new Date(h.fecha + 'T12:00:00').toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' }) : '—'}
</td>
<td
style={{ textAlign: 'center', color: h.horas_presencial > 0 ? '#f97316' : 'var(--text-muted)', cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); handleHorasClick(h, 'P'); }}
title="Click para editar horas presenciales"
>
{h.horas_presencial > 0 ? `${h.horas_presencial}h` : '—'}
</td>
<td
style={{ textAlign: 'center', color: h.horas_remoto > 0 ? '#a78bfa' : 'var(--text-muted)', cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); handleHorasClick(h, 'R'); }}
title="Click para editar horas remotas"
>
{h.horas_remoto > 0 ? `${h.horas_remoto}h` : '—'}
</td>
<td style={{ fontSize: '0.72rem' }} onClick={(e) => e.stopPropagation()}>{h.nodo_nombre || '—'}</td>
<td
style={{ textAlign: 'center', cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); handleEstadoClick(h); }}
title="Click para cambiar estado"
>
{estadoIcon(h.estado)}
</td>
</tr>
{isHitoExpanded && h.descripcion && (
<tr style={{ background: 'rgba(255,255,255,0.02)' }}>
<td></td>
<td colSpan={6} style={{ padding: '0.6rem 1rem', fontSize: '0.78rem', color: 'var(--text-muted)' }}>
<div style={{ borderLeft: '2px solid #38bdf8', paddingLeft: '0.8rem', whiteSpace: 'pre-wrap' }}>
{h.descripcion}
</div>
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
@@ -589,11 +532,9 @@ export default function ProyectoDashboard({ codigo = 'P2601' }: { codigo?: strin
</div>
);
})}
</>
)}
<div style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.7rem', marginTop: '1.5rem', paddingTop: '0.8rem', borderTop: '1px solid var(--border)' }}>
P2601 Infraestructura DASUTEN · UTN FRLR · Dashboard integrado en dtic-BITACORAs
Dashboard unificado integrado en dtic-BITACORAs
</div>
</div>
);