[Dashboard] Backend/Frontend actualizados — Endpoint Ámbitos con Planes

BACKEND (ambitos.js):
- Nuevo endpoint GET /api/ambitos/:codigo/proyecto
- Devuelve estructura compatible con frontend existente
- Planes con hitos, métricas y nodos (igual que proyectos)

FRONTEND (ProyectoDashboard.tsx):
- Cambiado default de 'P2601' a 'A04'
- Fetch desde /ambitos/${codigo}/proyecto en lugar de /proyectos
- Mantiene compatibilidad con interfaz existente

DATOS ACTUALIZADOS:
- 29 hitos totales
- 21 completados 
- 8 pendientes/legacy 🛑
- 6 planes: A04.P001 a A04.P006
- 60 horas totales (14.5h presencial + 45.5h remoto)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ricardo Monla
2026-04-15 20:58:00 -03:00
co-authored by Claude Opus 4.6
parent cafa07303b
commit 5b384a1b37
2 changed files with 93 additions and 11 deletions
@@ -114,17 +114,17 @@ module.exports = (pool) => {
const nodos = await pool.query('SELECT * FROM bitacoras.nodos WHERE ambito_id = $1 AND activo = true', [id]);
const entradasRecientes = await pool.query(`
SELECT e.*, n.nombre as nodo_nombre
FROM bitacoras.entradas e
JOIN bitacoras.nodos n ON e.nodo_id = n.id
WHERE n.ambito_id = $1
SELECT e.*, n.nombre as nodo_nombre
FROM bitacoras.entradas e
JOIN bitacoras.nodos n ON e.nodo_id = n.id
WHERE n.ambito_id = $1
ORDER BY e.inicio DESC LIMIT 10
`, [id]);
const gestionPendiente = await pool.query(`
SELECT g.*, n.nombre as nodo_nombre
FROM bitacoras.gestion g
JOIN bitacoras.nodos n ON g.nodo_id = n.id
SELECT g.*, n.nombre as nodo_nombre
FROM bitacoras.gestion g
JOIN bitacoras.nodos n ON g.nodo_id = n.id
WHERE n.ambito_id = $1 AND g.activo = true AND g.tipo = 'pendiente'
`, [id]);
@@ -139,5 +139,86 @@ module.exports = (pool) => {
}
});
// GET /api/ambitos/:codigo/proyecto — Dashboard con planes e hitos (compatible con frontend P2601)
router.get('/:codigo/proyecto', async (req, res) => {
const { codigo } = req.params;
const aResult = await pool.query('SELECT * FROM bitacoras.ambitos WHERE codigo = $1', [codigo]);
if (aResult.rows.length === 0) return res.status(404).json({ error: 'Ámbito no encontrado' });
const ambito = aResult.rows[0];
// Planes con hitos y nodo
const { rows: planes } = await pool.query(`
SELECT p.*, json_agg(
json_build_object(
'id', h.id,
'id_hito', h.id_hito,
'titulo', h.titulo,
'descripcion', h.descripcion,
'estado', h.estado,
'fecha', h.fecha,
'horas_presencial', h.horas_presencial,
'horas_remoto', h.horas_remoto,
'nodo_nombre', n.nombre,
'nodo_ip', n.ip
) ORDER BY h.fecha NULLS LAST, h.id
) FILTER (WHERE h.id IS NOT NULL) AS hitos
FROM bitacoras.planes p
LEFT JOIN bitacoras.hitos h ON h.plan_id = p.id
LEFT JOIN bitacoras.nodos n ON h.nodo_id = n.id
WHERE p.ambito_id = $1
GROUP BY p.id
ORDER BY p.codigo
`, [ambito.id]);
// Métricas globales
const { rows: [metricas] } = await pool.query(`
SELECT
COUNT(*) FILTER (WHERE h.estado = '✅') AS hitos_completados,
COUNT(*) FILTER (WHERE h.estado != '✅') AS hitos_pendientes,
COUNT(*) AS hitos_total,
COALESCE(SUM(h.horas_presencial), 0) AS total_presencial,
COALESCE(SUM(h.horas_remoto), 0) AS total_remoto,
COALESCE(SUM(h.horas_presencial + h.horas_remoto), 0) AS total_horas
FROM bitacoras.hitos h
JOIN bitacoras.planes p ON h.plan_id = p.id
WHERE p.ambito_id = $1
`, [ambito.id]);
// Nodos únicos involucrados
const { rows: nodos } = await pool.query(`
SELECT DISTINCT n.id, n.nombre, n.ip, n.tipo, n.descripcion
FROM bitacoras.hitos h
JOIN bitacoras.planes p ON h.plan_id = p.id
JOIN bitacoras.nodos n ON h.nodo_id = n.id
WHERE p.ambito_id = $1
ORDER BY n.nombre
`, [ambito.id]);
// Construir respuesta compatible con frontend existente
const response = {
id: ambito.id,
codigo: ambito.codigo,
nombre: ambito.nombre,
estado: ambito.activo ? '✅' : '⏳',
descripcion: ambito.descripcion || '',
detalles_json: null,
fases: planes.map(p => ({
id: p.id,
numero: parseInt(p.codigo.split('.')[1] || '0'),
nombre: p.titulo,
estado: p.estado,
descripcion: p.descripcion || '',
fecha_inicio: p.fecha_inicio,
fecha_fin: p.fecha_fin,
hitos: p.hitos
})),
metricas,
nodos
};
res.json(response);
});
return router;
};
@@ -131,7 +131,7 @@ function DonutChart({ segments, size = 140, strokeWidth = 18, centerLabel, cente
/* ═══════════ MAIN COMPONENT ═══════════ */
export default function ProyectoDashboard({ codigo = 'P2601' }: { codigo?: string }) {
export default function ProyectoDashboard({ codigo = 'A04' }: { codigo?: string }) {
const [proyecto, setProyecto] = useState<Proyecto | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -144,8 +144,9 @@ export default function ProyectoDashboard({ codigo = 'P2601' }: { codigo?: strin
const fetchProyecto = async () => {
setLoading(true);
try {
const res = await fetch(`${API_URL}/proyectos/${codigo}`);
if (!res.ok) throw new Error('Proyecto no encontrado');
// Usar endpoint de ambitos en lugar de proyectos
const res = await fetch(`${API_URL}/ambitos/${codigo}/proyecto`);
if (!res.ok) throw new Error('Ámbito no encontrado');
const data = await res.json();
setProyecto(data);
setExpandedFases(new Set(data.fases.map((f: Fase) => f.id)));