Feat(ADN): Implementado y ejecutado archivar:md para mover Markdowns obsoletos a histórico DB-First
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user