Reestructuración modular integral de servicios, reorganización de gestión en docs/ y limpieza profunda del repositorio

This commit is contained in:
Ricardo Monla
2026-03-06 18:03:07 -03:00
parent 15725e0b3c
commit ac9bdf516b
286 changed files with 36187 additions and 112775 deletions
@@ -0,0 +1,528 @@
/// <reference types="vite/client" />
import React, { useState, useEffect, useCallback } from 'react';
import ProyectoDashboard from './pages/ProyectoDashboard';
const API_URL = import.meta.env.VITE_API_URL || '/bitacoras/api';
interface Entrada {
id: number;
inicio: string;
fin: string | null;
descripcion: string;
estado: string;
modo: string;
es_ia: boolean;
nodo_nombre: string;
nodo_ip: string;
}
interface NodoGroup {
nodo: string;
ip: string;
entradas: Entrada[];
}
interface Bitacora {
id: number;
fecha: string;
resumen: string;
nodos: NodoGroup[];
}
interface Nodo {
id: number;
nombre: string;
ip: string;
tipo: string;
descripcion: string;
activo: boolean;
}
// ===== MODAL DE CONFIRMACIÓN =====
function ConfirmModal({ show, title, message, onConfirm, onCancel }: {
show: boolean; title: string; message: string;
onConfirm: () => void; onCancel: () => void;
}) {
if (!show) return null;
return (
<div className="modal-overlay" onClick={onCancel}>
<div className="modal-box" onClick={e => e.stopPropagation()}>
<div className="modal-header"><i className="fas fa-exclamation-triangle"></i> {title}</div>
<p className="modal-body">{message}</p>
<div className="modal-actions">
<button className="btn btn-sm" onClick={onCancel} style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', color: 'var(--text)' }}>Cancelar</button>
<button className="btn btn-sm btn-danger" onClick={onConfirm}><i className="fas fa-trash-alt"></i> Eliminar</button>
</div>
</div>
</div>
);
}
// ===== TABS =====
type Tab = 'bitacora' | 'nodos' | 'proyecto';
function getInitialTab(): Tab {
const path = window.location.pathname.toLowerCase();
if (path.includes('/p2601')) return 'proyecto';
if (path.includes('/nodos')) return 'nodos';
return 'bitacora';
}
function App() {
const [tab, setTab] = useState<Tab>(getInitialTab());
const changeTab = (t: Tab) => {
setTab(t);
const base = '/bitacoras/';
// Asegurar que las rutas incluyan el prefijo correcto para el ruteo de NGINX
const paths: Record<Tab, string> = {
bitacora: base,
nodos: base + 'nodos',
proyecto: base + 'p2601'
};
window.history.replaceState(null, '', paths[t]);
};
const [fecha, setFecha] = useState(new Date().toISOString().split('T')[0]);
const [bitacora, setBitacora] = useState<Bitacora | null>(null);
const [nodos, setNodos] = useState<Nodo[]>([]);
const [loading, setLoading] = useState(true);
// Form: nueva entrada
const [newInicio, setNewInicio] = useState('');
const [newFin, setNewFin] = useState('');
const [newDesc, setNewDesc] = useState('');
const [newEstado, setNewEstado] = useState('⏳');
const [newModo, setNewModo] = useState('P');
const [newNodoId, setNewNodoId] = useState('');
const [newEsIa, setNewEsIa] = useState(false);
// Edit: entrada en edición
const [editId, setEditId] = useState<number | null>(null);
const [editInicio, setEditInicio] = useState('');
const [editFin, setEditFin] = useState('');
const [editDesc, setEditDesc] = useState('');
const [editEstado, setEditEstado] = useState('');
const [editModo, setEditModo] = useState('');
const [editEsIa, setEditEsIa] = useState(false);
// Confirm delete
const [confirmDelete, setConfirmDelete] = useState<{ type: string; id: number; name: string } | null>(null);
// Nodo form
const [nodoFormVisible, setNodoFormVisible] = useState(false);
const [editNodoId, setEditNodoId] = useState<number | null>(null);
const [nodoNombre, setNodoNombre] = useState('');
const [nodoIp, setNodoIp] = useState('');
const [nodoTipo, setNodoTipo] = useState('servidor');
const [nodoDesc, setNodoDesc] = useState('');
// === FETCH ===
const fetchBitacora = useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`${API_URL}/bitacoras/${fecha}/completa`);
const data = await res.json();
setBitacora(data);
} catch (err) { console.error('Error:', err); }
setLoading(false);
}, [fecha]);
const fetchNodos = async () => {
try {
const res = await fetch(`${API_URL}/nodos`);
setNodos(await res.json());
} catch (err) { console.error('Error:', err); }
};
useEffect(() => { fetchBitacora(); }, [fetchBitacora]);
useEffect(() => { fetchNodos(); }, []);
// === ENTRADAS CRUD ===
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!bitacora || !newDesc || !newInicio || !newNodoId) return;
try {
await fetch(`${API_URL}/entradas`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
inicio: newInicio, fin: newFin || null, descripcion: newDesc,
estado: newEstado, modo: newModo, es_ia: newEsIa,
bitacora_id: bitacora.id, nodo_id: parseInt(newNodoId)
})
});
setNewDesc(''); setNewInicio(''); setNewFin('');
fetchBitacora();
} catch (err) { console.error('Error:', err); }
};
const startEdit = (e: Entrada) => {
setEditId(e.id);
setEditInicio(e.inicio?.substring(0, 5) || '');
setEditFin(e.fin ? e.fin.substring(0, 5) : '');
setEditDesc(e.descripcion);
setEditEstado(e.estado);
setEditModo(e.modo);
setEditEsIa(e.es_ia);
};
const cancelEdit = () => setEditId(null);
const saveEdit = async () => {
if (!editId) return;
await fetch(`${API_URL}/entradas/${editId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
inicio: editInicio, fin: editFin || null, descripcion: editDesc,
estado: editEstado, modo: editModo, es_ia: editEsIa,
})
});
setEditId(null);
fetchBitacora();
};
const requestDeleteEntrada = (id: number, desc: string) => {
setConfirmDelete({ type: 'entrada', id, name: desc.substring(0, 50) + '...' });
};
const executeDelete = async () => {
if (!confirmDelete) return;
const { type, id } = confirmDelete;
if (type === 'entrada') {
await fetch(`${API_URL}/entradas/${id}`, { method: 'DELETE' });
fetchBitacora();
} else if (type === 'nodo') {
await fetch(`${API_URL}/nodos/${id}`, { method: 'DELETE' });
fetchNodos();
}
setConfirmDelete(null);
};
// === NODOS CRUD ===
const resetNodoForm = () => {
setEditNodoId(null); setNodoNombre(''); setNodoIp(''); setNodoTipo('servidor'); setNodoDesc('');
setNodoFormVisible(false);
};
const openNodoForm = (nodo?: Nodo) => {
if (nodo) {
setEditNodoId(nodo.id); setNodoNombre(nodo.nombre); setNodoIp(nodo.ip || '');
setNodoTipo(nodo.tipo || 'servidor'); setNodoDesc(nodo.descripcion || '');
} else {
setEditNodoId(null); setNodoNombre(''); setNodoIp(''); setNodoTipo('servidor'); setNodoDesc('');
}
setNodoFormVisible(true);
};
const saveNodo = async (e: React.FormEvent) => {
e.preventDefault();
if (!nodoNombre) return;
if (editNodoId) {
const existing = nodos.find(n => n.id === editNodoId);
await fetch(`${API_URL}/nodos/${editNodoId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nombre: nodoNombre, ip: nodoIp, tipo: nodoTipo, descripcion: nodoDesc, activo: existing?.activo ?? true })
});
} else {
await fetch(`${API_URL}/nodos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nombre: nodoNombre, ip: nodoIp, tipo: nodoTipo, descripcion: nodoDesc })
});
}
resetNodoForm();
fetchNodos();
};
const requestDeleteNodo = (id: number, nombre: string) => {
setConfirmDelete({ type: 'nodo', id, name: nombre });
};
// === STATS ===
const totalEntradas = bitacora?.nodos?.reduce((sum, n) => sum + n.entradas.length, 0) || 0;
const totalNodos = bitacora?.nodos?.length || 0;
return (
<div className="app">
{/* Modal de confirmación */}
<ConfirmModal
show={!!confirmDelete}
title="Confirmar eliminación"
message={`¿Estás seguro que querés eliminar "${confirmDelete?.name}"? Esta acción no se puede deshacer.`}
onConfirm={executeDelete}
onCancel={() => setConfirmDelete(null)}
/>
<nav className="navbar">
<div className="navbar-brand">
<i className="fas fa-book-open"></i>
dtic-BITACORAs
<span className="badge badge-gradient">v1.1</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
className={`btn btn-sm ${tab === 'bitacora' ? 'btn-primary' : ''}`}
onClick={() => changeTab('bitacora')}
style={tab !== 'bitacora' ? { background: 'var(--bg-card)', color: 'var(--text)', border: '1px solid var(--border)' } : {}}
><i className="fas fa-book"></i> Bitácora</button>
<button
className={`btn btn-sm ${tab === 'nodos' ? 'btn-primary' : ''}`}
onClick={() => changeTab('nodos')}
style={tab !== 'nodos' ? { background: 'var(--bg-card)', color: 'var(--text)', border: '1px solid var(--border)' } : {}}
><i className="fas fa-server"></i> Nodos</button>
<button
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>
{tab === 'bitacora' && (
<a href={`${API_URL}/bitacoras/${fecha}/export`} target="_blank" rel="noopener"
className="btn btn-sm btn-primary">
<i className="fas fa-file-export"></i> Exportar MD
</a>
)}
</div>
</nav>
<main className="main">
{/* =================== TAB BITÁCORA =================== */}
{tab === 'bitacora' && (
<>
{/* Date Selector */}
<div className="date-selector">
<button className="btn btn-sm btn-primary" onClick={() => {
const d = new Date(fecha); d.setDate(d.getDate() - 1); setFecha(d.toISOString().split('T')[0]);
}}><i className="fas fa-chevron-left"></i></button>
<input type="date" value={fecha} onChange={e => setFecha(e.target.value)} />
<button className="btn btn-sm btn-primary" onClick={() => {
const d = new Date(fecha); d.setDate(d.getDate() + 1); setFecha(d.toISOString().split('T')[0]);
}}><i className="fas fa-chevron-right"></i></button>
<button className="btn btn-sm" onClick={() => setFecha(new Date().toISOString().split('T')[0])}
style={{ background: 'var(--bg-card)', color: 'var(--text-secondary)', border: '1px solid var(--border)' }}>
<i className="fas fa-calendar-day"></i> Hoy
</button>
</div>
{/* Stats */}
<div className="stats-bar">
<div className="stat-card">
<div className="stat-value">{totalEntradas}</div>
<div className="stat-label">Entradas</div>
</div>
<div className="stat-card">
<div className="stat-value">{totalNodos}</div>
<div className="stat-label">Nodos Activos</div>
</div>
<div className="stat-card">
<div className="stat-value">{bitacora?.nodos?.reduce((s, n) => s + n.entradas.filter(e => e.estado === '✅').length, 0) || 0}</div>
<div className="stat-label">Completadas</div>
</div>
<div className="stat-card">
<div className="stat-value">{bitacora?.nodos?.reduce((s, n) => s + n.entradas.filter(e => e.estado === '⏳').length, 0) || 0}</div>
<div className="stat-label">En Proceso</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>
<form className="form-inline" onSubmit={handleSubmit}>
<input type="time" className="input-time" value={newInicio} onChange={e => setNewInicio(e.target.value)} placeholder="I" required />
<input type="time" className="input-time" value={newFin} onChange={e => setNewFin(e.target.value)} placeholder="F" />
<input type="text" className="input-desc" value={newDesc} onChange={e => setNewDesc(e.target.value)} placeholder="Descripción..." required />
<select value={newNodoId} onChange={e => setNewNodoId(e.target.value)} required>
<option value="">Nodo...</option>
{nodos.map(n => <option key={n.id} value={n.id}>{n.nombre}</option>)}
</select>
<select value={newEstado} onChange={e => setNewEstado(e.target.value)}>
<option value="⏳"></option><option value="✅"></option>
<option value="⚠️"></option><option value="❌"></option><option value="👁️">👁</option>
</select>
<select value={newModo} onChange={e => setNewModo(e.target.value)}>
<option value="P">[P] Presencial</option><option value="R">[R] Remoto</option>
</select>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', color: 'var(--text-secondary)', fontSize: '0.85rem' }}>
<input type="checkbox" checked={newEsIa} onChange={e => setNewEsIa(e.target.checked)} /> IA
</label>
<button type="submit" className="btn btn-primary"><i className="fas fa-save"></i> Guardar</button>
</form>
</div>
{/* Loading */}
{loading && <div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}><i className="fas fa-spinner fa-spin fa-2x"></i></div>}
{/* Entries by Node */}
{bitacora?.nodos?.map((nodoGroup, idx) => (
<div key={idx} className="nodo-section card" style={{ marginBottom: '1.5rem' }}>
<div className="nodo-title">
<i className="fas fa-server"></i>
{nodoGroup.nodo}
{nodoGroup.ip && <span className="nodo-ip">({nodoGroup.ip})</span>}
<span className="badge badge-gradient" style={{ marginLeft: 'auto' }}>{nodoGroup.entradas.length}</span>
</div>
<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-status">E</th>
<th style={{ width: '80px' }}>Acc.</th>
</tr>
</thead>
<tbody>
{nodoGroup.entradas.map(e => (
editId === e.id ? (
/* ==== FILA EN EDICIÓN ==== */
<tr key={e.id} style={{ background: 'rgba(102, 126, 234, 0.1)' }}>
<td><input type="time" value={editInicio} onChange={ev => setEditInicio(ev.target.value)} className="edit-input" /></td>
<td><input type="time" value={editFin} onChange={ev => setEditFin(ev.target.value)} className="edit-input" /></td>
<td>
<input type="text" value={editDesc} onChange={ev => setEditDesc(ev.target.value)} className="edit-input" style={{ width: '100%' }} />
<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>
</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>
<button className="btn btn-sm" onClick={cancelEdit} title="Cancelar" style={{ background: 'var(--bg-card)', color: 'var(--text-muted)' }}><i className="fas fa-times"></i></button>
</div>
</td>
</tr>
) : (
/* ==== FILA NORMAL ==== */
<tr key={e.id}>
<td className="col-time">{e.inicio?.substring(0, 5)}</td>
<td className="col-time">{e.fin ? e.fin.substring(0, 5) : '—'}</td>
<td className="col-desc">
{e.es_ia && <span className="ia-tag">IA</span>}
{e.descripcion}
</td>
<td className="col-status">[{e.modo}] {e.estado}</td>
<td>
<div style={{ display: 'flex', gap: '0.2rem' }}>
<button className="btn btn-sm" onClick={() => startEdit(e)} title="Editar"
style={{ background: 'var(--info)', color: 'white' }}>
<i className="fas fa-pen"></i>
</button>
<button className="btn btn-sm btn-danger" onClick={() => requestDeleteEntrada(e.id, e.descripcion)} title="Eliminar">
<i className="fas fa-trash-alt"></i>
</button>
</div>
</td>
</tr>
)
))}
</tbody>
</table>
</div>
))}
{!loading && totalEntradas === 0 && (
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
<i className="fas fa-inbox fa-3x" style={{ marginBottom: '1rem', display: 'block' }}></i>
Sin entradas para esta fecha. ¡Creá la primera!
</div>
)}
</>
)}
{/* =================== TAB NODOS =================== */}
{tab === 'nodos' && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h2 style={{ fontSize: '1.3rem', fontWeight: 600 }}><i className="fas fa-server" style={{ color: 'var(--primary)', marginRight: '0.5rem' }}></i>Gestión de Nodos</h2>
<button className="btn btn-primary" onClick={() => openNodoForm()}>
<i className="fas fa-plus"></i> Nuevo Nodo
</button>
</div>
{/* Nodo Form */}
{nodoFormVisible && (
<div className="card" style={{ marginBottom: '1.5rem' }}>
<div className="card-header">
<i className={editNodoId ? 'fas fa-pen' : 'fas fa-plus-circle'}></i>
{editNodoId ? 'Editar Nodo' : 'Nuevo Nodo'}
</div>
<form className="form-inline" onSubmit={saveNodo}>
<input type="text" value={nodoNombre} onChange={e => setNodoNombre(e.target.value)} placeholder="Nombre (ej: srv-ns8)" required style={{ minWidth: '150px' }} />
<input type="text" value={nodoIp} onChange={e => setNodoIp(e.target.value)} placeholder="IP (ej: 10.0.10.200)" style={{ minWidth: '130px' }} />
<select value={nodoTipo} onChange={e => setNodoTipo(e.target.value)}>
<option value="servidor">Servidor</option>
<option value="vm">VM</option>
<option value="pc">PC</option>
<option value="switch">Switch</option>
<option value="otro">Otro</option>
</select>
<input type="text" className="input-desc" value={nodoDesc} onChange={e => setNodoDesc(e.target.value)} placeholder="Descripción..." />
<button type="submit" className="btn btn-success"><i className="fas fa-save"></i> {editNodoId ? 'Actualizar' : 'Crear'}</button>
<button type="button" className="btn btn-sm" onClick={resetNodoForm} style={{ background: 'var(--bg-card)', color: 'var(--text-muted)', border: '1px solid var(--border)' }}>
<i className="fas fa-times"></i> Cancelar
</button>
</form>
</div>
)}
{/* Nodos Table */}
<div className="card">
<table className="table-ifde">
<thead>
<tr>
<th>ID</th>
<th>Nombre</th>
<th>IP</th>
<th>Tipo</th>
<th>Descripción</th>
<th style={{ width: '80px' }}>Acc.</th>
</tr>
</thead>
<tbody>
{nodos.map(n => (
<tr key={n.id}>
<td style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{n.id}</td>
<td style={{ fontWeight: 600 }}>{n.nombre}</td>
<td style={{ fontFamily: 'monospace', color: 'var(--text-secondary)' }}>{n.ip || '—'}</td>
<td><span className="badge" style={{ background: 'rgba(102, 126, 234, 0.15)', color: 'var(--primary)' }}>{n.tipo || 'N/A'}</span></td>
<td style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>{n.descripcion || '—'}</td>
<td>
<div style={{ display: 'flex', gap: '0.2rem' }}>
<button className="btn btn-sm" onClick={() => openNodoForm(n)} title="Editar"
style={{ background: 'var(--info)', color: 'white' }}>
<i className="fas fa-pen"></i>
</button>
<button className="btn btn-sm btn-danger" onClick={() => requestDeleteNodo(n.id, n.nombre)} title="Eliminar">
<i className="fas fa-trash-alt"></i>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{/* =================== TAB PROYECTO =================== */}
{tab === 'proyecto' && (
<ProyectoDashboard codigo="P2601" />
)}
</main>
</div>
);
}
export default App;