1245 lines
71 KiB
TypeScript
1245 lines
71 KiB
TypeScript
/// <reference types="vite/client" />
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import ProyectoDashboard from './pages/ProyectoDashboard';
|
|
import AmbitoDashboard from './pages/AmbitoDashboard';
|
|
|
|
// Constantes estáticas para evitar parpadeos por cambio de referencia en cada render.
|
|
const MARKDOWN_PLUGINS = [remarkGfm];
|
|
|
|
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[];
|
|
resumen?: string;
|
|
}
|
|
|
|
interface Bitacora {
|
|
id: number;
|
|
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 {
|
|
id: number;
|
|
nombre: string;
|
|
ip: string;
|
|
tipo: string;
|
|
descripcion: string;
|
|
ambito_id: number | null;
|
|
ambito_nombre?: string;
|
|
activo: boolean;
|
|
}
|
|
|
|
interface Ambito {
|
|
id: number;
|
|
nombre: string;
|
|
descripcion: string;
|
|
parent_id: number | null;
|
|
parent_nombre?: string;
|
|
count_nodos?: number;
|
|
activo: boolean;
|
|
}
|
|
|
|
// ===== 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' | 'ambitos' | '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 = React.useCallback((t: Tab) => {
|
|
setTab(t);
|
|
const base = '/bitacoras/';
|
|
const paths: Record<Tab, string> = {
|
|
bitacora: base,
|
|
nodos: base + 'nodos',
|
|
ambitos: base + 'ambitos',
|
|
proyecto: base + 'p2601'
|
|
};
|
|
window.history.replaceState(null, '', paths[t]);
|
|
}, []);
|
|
const [fecha, setFecha] = useState(new Date().toISOString().split('T')[0]);
|
|
const [bitacora, setBitacora] = useState<Bitacora | null>(null);
|
|
const [nodos, setNodos] = useState<Nodo[]>([]);
|
|
const [proyectos, setProyectos] = useState<{ id: number, codigo: string, nombre: string }[]>([]);
|
|
const [selectedProyecto, setSelectedProyecto] = useState<string>('P2601');
|
|
const [loading, setLoading] = useState(true);
|
|
const [fechasActivas, setFechasActivas] = useState<Set<string>>(new Set());
|
|
const [showCal, setShowCal] = useState(false);
|
|
const [calMonth, setCalMonth] = useState(() => { const d = new Date(); return { year: d.getFullYear(), month: d.getMonth() }; });
|
|
|
|
// 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);
|
|
|
|
// Ámbitos
|
|
const [ambitos, setAmbitos] = useState<Ambito[]>([]);
|
|
|
|
// Form states
|
|
const [nodoFormVisible, setNodoFormVisible] = useState(false);
|
|
const [editNodoId, setEditNodoId] = useState<number | null>(null);
|
|
const [nodoNombre, setNodoNombre] = useState('');
|
|
const [nodoIp, setNodoIp] = useState('');
|
|
const [nodoTipo, setNodoTipo] = useState('servidor');
|
|
const [nodoDesc, setNodoDesc] = useState('');
|
|
const [nodoAmbitoId, setNodoAmbitoId] = useState<string>('');
|
|
|
|
const [ambitoFormVisible, setAmbitoFormVisible] = useState(false);
|
|
const [editAmbitoId, setEditAmbitoId] = useState<number | null>(null);
|
|
const [ambitoNombre, setAmbitoNombre] = useState('');
|
|
const [ambitoDesc, setAmbitoDesc] = useState('');
|
|
const [ambitoParentId, setAmbitoParentId] = useState<string>('');
|
|
const [activeAmbitoId, setActiveAmbitoId] = useState<number | null>(null);
|
|
const [globalStats, setGlobalStats] = useState<{ nombre: string, count_nodos: number, count_entradas: number }[]>([]);
|
|
|
|
// Doc viewer modal
|
|
const [docModal, setDocModal] = useState<{ show: boolean; title: string; content: string; loading: boolean }>({ show: false, title: '', content: '', loading: false });
|
|
|
|
const openDocModal = React.useCallback(async (fileRelPath: string) => {
|
|
const archivo = fileRelPath.split('/').pop() || '';
|
|
setDocModal({ show: true, title: archivo.replace('.md', ''), content: '', loading: true });
|
|
try {
|
|
const res = await fetch(`${API_URL}/docs/${fileRelPath}`);
|
|
const data = await res.json();
|
|
if (data.content) {
|
|
setDocModal({ show: true, title: archivo.replace('.md', ''), content: data.content, loading: false });
|
|
} else {
|
|
setDocModal({ show: true, title: 'Error', content: `No se pudo cargar el archivo: ${data.error || 'desconocido'}`, loading: false });
|
|
}
|
|
} catch (err) {
|
|
setDocModal({ show: true, title: 'Error', content: 'Error de conexión al cargar el documento', loading: false });
|
|
}
|
|
}, []);
|
|
|
|
// === MARKDOWN COMPONENTS (MEMOIZED TO AVOID FLICKER) ===
|
|
const markdownComponents = React.useMemo(() => ({
|
|
a: ({ node, ...props }: any) => {
|
|
const url = props.href || '';
|
|
|
|
// Si es un link a un .md en ../proy/ o ../ambito/, abrir modal de documento
|
|
if ((url.includes('../proy/') || url.includes('../ambito/')) && url.endsWith('.md')) {
|
|
// Obtener todo lo que viene después de "docs/" basándonos en si fue proy/ o ambito/
|
|
const linkType = url.includes('../proy/') ? '../proy/' : '../ambito/';
|
|
const docsIndex = url.indexOf(linkType) + 3; // +3 skips "../" so we start at "proy/" or "ambito/"
|
|
const fileRelPath = url.substring(docsIndex); // ex: proy/P2601_Dasuten/P26...
|
|
|
|
return (
|
|
<a
|
|
{...props}
|
|
href="#"
|
|
onClick={(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openDocModal(fileRelPath);
|
|
}}
|
|
className="desc-link"
|
|
title={`Ver ${url.split('/').pop()} en la web`}
|
|
style={{ cursor: 'pointer', borderBottom: '1px dashed var(--primary)' }}
|
|
>
|
|
{props.children}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
// Si es un link a un proyecto del ADN, interceptamos para navegación SPA
|
|
if (url.startsWith('../proyectos/')) {
|
|
const projectFile = url.split('/').pop() || '';
|
|
const projectCodeMatch = projectFile.match(/^(P\d{4})/);
|
|
|
|
if (projectCodeMatch) {
|
|
const projectCode = projectCodeMatch[1];
|
|
return (
|
|
<a
|
|
{...props}
|
|
href="#"
|
|
onClick={(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
setSelectedProyecto(projectCode);
|
|
changeTab('proyecto');
|
|
}}
|
|
className="desc-link"
|
|
title={`Ver plan ${projectCode} en la web`}
|
|
>
|
|
{props.children}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
// Si no tiene el formato PXXXX, fallback a link estático en la carpeta de docs
|
|
props.href = '/bitacoras/docs/proyectos/' + projectFile;
|
|
}
|
|
|
|
return (
|
|
<a
|
|
{...props}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="desc-link"
|
|
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
|
>
|
|
{props.children}
|
|
</a>
|
|
);
|
|
},
|
|
// Estilos para código (bloque e inline)
|
|
code: ({ node, className, children, ...props }: any) => {
|
|
const match = /language-(\w+)/.exec(className || '');
|
|
const isInline = !match;
|
|
|
|
return (
|
|
<code
|
|
{...props}
|
|
className={className}
|
|
style={{
|
|
background: isInline ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.3)',
|
|
padding: isInline ? '0.1rem 0.3rem' : '1rem',
|
|
borderRadius: '4px',
|
|
fontFamily: 'monospace',
|
|
display: isInline ? 'inline' : 'block',
|
|
overflowX: isInline ? 'initial' : 'auto',
|
|
margin: isInline ? '0' : '0.5rem 0'
|
|
}}
|
|
>
|
|
{children}
|
|
</code>
|
|
);
|
|
}
|
|
}), []); // All deps (changeTab, setSelectedProyecto, openDocModal) are stable useCallbacks/setState
|
|
|
|
// === HELPERS ===
|
|
const renderDescription = (text: string) => {
|
|
if (!text) return '';
|
|
|
|
return (
|
|
<ReactMarkdown
|
|
remarkPlugins={MARKDOWN_PLUGINS}
|
|
components={markdownComponents as any}
|
|
>
|
|
{text}
|
|
</ReactMarkdown>
|
|
);
|
|
};
|
|
|
|
// === FETCH ===
|
|
const fetchBitacora = useCallback(async () => {
|
|
// Solo mostramos loading si es la primera carga (sin datos)
|
|
if (!bitacora) setLoading(true);
|
|
try {
|
|
const res = await fetch(`${API_URL}/bitacoras/${fecha}/completa`);
|
|
const data = await res.json();
|
|
setBitacora(data);
|
|
} catch (err) { console.error('Error fetchBitacora:', err); }
|
|
setLoading(false);
|
|
}, [fecha, bitacora]);
|
|
|
|
const fetchNodos = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`${API_URL}/nodos`);
|
|
const data = await res.json();
|
|
setNodos(data);
|
|
} catch (err) { console.error('Error nodes:', err); }
|
|
}, []);
|
|
|
|
const fetchAmbitos = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`${API_URL}/ambitos`);
|
|
const data = await res.json();
|
|
setAmbitos(data);
|
|
} catch (err) { console.error('Error ambitos:', err); }
|
|
}, []);
|
|
|
|
const fetchGlobalStats = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`${API_URL}/ambitos/stats/global`);
|
|
const data = await res.json();
|
|
setGlobalStats(data);
|
|
} catch (err) { console.error('Error global stats:', err); }
|
|
}, []);
|
|
|
|
const fetchFechasActivas = async () => {
|
|
try {
|
|
const res = await fetch(`${API_URL}/bitacoras/fechas/activas`);
|
|
const data: string[] = await res.json();
|
|
setFechasActivas(new Set(data));
|
|
} catch (err) { console.error('Error fetchFechasActivas:', 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);
|
|
};
|
|
|
|
// Auto-refresh inteligente: solo actualizar si hay cambios
|
|
const [lastVersion, setLastVersion] = useState<{ max_id: number; count: number } | null>(null);
|
|
|
|
const fetchBitacoraVersion = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`${API_URL}/bitacoras/${fecha}/version`);
|
|
return await res.json();
|
|
} catch { return null; }
|
|
}, [fecha]);
|
|
|
|
const smartRefresh = useCallback(async () => {
|
|
const newVersion = await fetchBitacoraVersion();
|
|
if (!newVersion) {
|
|
fetchBitacora();
|
|
fetchFechasActivas();
|
|
fetchNodos();
|
|
fetchAmbitos();
|
|
fetchGlobalStats();
|
|
return;
|
|
}
|
|
if (!lastVersion || newVersion.max_id !== lastVersion.max_id || Number(newVersion.count) !== Number(lastVersion.count)) {
|
|
setLastVersion(newVersion);
|
|
fetchBitacora();
|
|
fetchFechasActivas();
|
|
fetchNodos();
|
|
fetchAmbitos();
|
|
fetchGlobalStats();
|
|
}
|
|
}, [fetchBitacoraVersion, fetchBitacora, fetchFechasActivas, lastVersion, fetchNodos, fetchAmbitos, fetchGlobalStats]);
|
|
|
|
useEffect(() => { fetchBitacora(); }, [fetchBitacora]);
|
|
useEffect(() => { fetchNodos(); fetchProyectos(); fetchAmbitos(); fetchGlobalStats(); }, []);
|
|
useEffect(() => { fetchFechasActivas(); }, [fetchFechasActivas]);
|
|
|
|
// Inicializar versión al cargar
|
|
useEffect(() => {
|
|
fetchBitacoraVersion().then(v => { if (v) setLastVersion(v); });
|
|
}, [fetchBitacoraVersion]);
|
|
|
|
// Auto-refresh cada 30 segundos (solo si hay cambios)
|
|
useEffect(() => {
|
|
const interval = setInterval(smartRefresh, 30000);
|
|
|
|
// También actualizar cuando la página recupera el foco
|
|
const handleVisibility = () => {
|
|
if (!document.hidden) smartRefresh();
|
|
};
|
|
document.addEventListener('visibilitychange', handleVisibility);
|
|
|
|
return () => {
|
|
clearInterval(interval);
|
|
document.removeEventListener('visibilitychange', handleVisibility);
|
|
};
|
|
}, [smartRefresh]);
|
|
|
|
// Rebuild calendar view month when fecha changes
|
|
useEffect(() => {
|
|
const d = new Date(fecha + 'T12:00:00');
|
|
setCalMonth({ year: d.getFullYear(), month: d.getMonth() });
|
|
}, [fecha]);
|
|
|
|
// Mini calendario builder
|
|
const buildCalDays = () => {
|
|
const { year, month } = calMonth;
|
|
const firstDay = new Date(year, month, 1).getDay(); // 0=Sun
|
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
const rows: { day: number; dateStr: string; active: boolean; selected: boolean; today: boolean }[][] = [];
|
|
let row: typeof rows[0] = [];
|
|
const todayStr = new Date().toISOString().split('T')[0];
|
|
// Fill blanks
|
|
for (let i = 0; i < firstDay; i++) row.push({ day: 0, dateStr: '', active: false, selected: false, today: false });
|
|
for (let d = 1; d <= daysInMonth; d++) {
|
|
const ds = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
|
row.push({ day: d, dateStr: ds, active: fechasActivas.has(ds), selected: ds === fecha, today: ds === todayStr });
|
|
if (row.length === 7) { rows.push(row); row = []; }
|
|
}
|
|
if (row.length > 0) { while (row.length < 7) row.push({ day: 0, dateStr: '', active: false, selected: false, today: false }); rows.push(row); }
|
|
return rows;
|
|
};
|
|
|
|
const MONTH_NAMES = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
|
|
|
|
// === 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, 8) || '');
|
|
setEditFin(e.fin ? e.fin.substring(0, 8) : '');
|
|
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();
|
|
} else if (type === 'ambito') {
|
|
await fetch(`${API_URL}/ambitos/${id}`, { method: 'DELETE' });
|
|
fetchAmbitos();
|
|
}
|
|
setConfirmDelete(null);
|
|
};
|
|
|
|
// === NODOS CRUD ===
|
|
const resetNodoForm = () => {
|
|
setEditNodoId(null); setNodoNombre(''); setNodoIp(''); setNodoTipo('servidor'); setNodoDesc(''); setNodoAmbitoId('');
|
|
setNodoFormVisible(false);
|
|
};
|
|
|
|
const openNodoForm = (nodo?: Nodo) => {
|
|
if (nodo) {
|
|
setEditNodoId(nodo.id); setNodoNombre(nodo.nombre); setNodoIp(nodo.ip || '');
|
|
setNodoTipo(nodo.tipo || 'servidor'); setNodoDesc(nodo.descripcion || '');
|
|
setNodoAmbitoId(nodo.ambito_id?.toString() || '');
|
|
} else {
|
|
setEditNodoId(null); setNodoNombre(''); setNodoIp(''); setNodoTipo('servidor'); setNodoDesc('');
|
|
setNodoAmbitoId('');
|
|
}
|
|
setNodoFormVisible(true);
|
|
};
|
|
|
|
const saveNodo = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!nodoNombre) return;
|
|
const payload = {
|
|
nombre: nodoNombre, ip: nodoIp, tipo: nodoTipo, descripcion: nodoDesc,
|
|
ambito_id: nodoAmbitoId ? parseInt(nodoAmbitoId) : null
|
|
};
|
|
if (editNodoId) {
|
|
const existing = nodos.find(n => n.id === editNodoId);
|
|
await fetch(`${API_URL}/nodos/${editNodoId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ...payload, activo: existing?.activo ?? true })
|
|
});
|
|
} else {
|
|
await fetch(`${API_URL}/nodos`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
}
|
|
resetNodoForm();
|
|
fetchNodos();
|
|
};
|
|
|
|
const requestDeleteNodo = (id: number, nombre: string) => {
|
|
setConfirmDelete({ type: 'nodo', id, name: nombre });
|
|
};
|
|
|
|
// === AMBITOS CRUD ===
|
|
const resetAmbitoForm = () => {
|
|
setEditAmbitoId(null); setAmbitoNombre(''); setAmbitoDesc(''); setAmbitoParentId('');
|
|
setAmbitoFormVisible(false);
|
|
};
|
|
|
|
const openAmbitoForm = (ambito?: Ambito) => {
|
|
if (ambito) {
|
|
setEditAmbitoId(ambito.id); setAmbitoNombre(ambito.nombre); setAmbitoDesc(ambito.descripcion || '');
|
|
setAmbitoParentId(ambito.parent_id?.toString() || '');
|
|
} else {
|
|
setEditAmbitoId(null); setAmbitoNombre(''); setAmbitoDesc(''); setAmbitoParentId('');
|
|
}
|
|
setAmbitoFormVisible(true);
|
|
};
|
|
|
|
const saveAmbito = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!ambitoNombre) return;
|
|
const payload = {
|
|
nombre: ambitoNombre, descripcion: ambitoDesc,
|
|
parent_id: ambitoParentId ? parseInt(ambitoParentId) : null
|
|
};
|
|
if (editAmbitoId) {
|
|
await fetch(`${API_URL}/ambitos/${editAmbitoId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
} else {
|
|
await fetch(`${API_URL}/ambitos`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
}
|
|
resetAmbitoForm();
|
|
fetchAmbitos();
|
|
fetchNodos(); // Los nodos pueden haber cambiado su ambito_nombre
|
|
};
|
|
|
|
const requestDeleteAmbito = (id: number, nombre: string) => {
|
|
setConfirmDelete({ type: 'ambito', id, name: nombre });
|
|
};
|
|
|
|
// === STATS ===
|
|
const totalEntradas = bitacora?.nodos?.reduce((sum, n) => sum + n.entradas.length, 0) || 0;
|
|
const totalNodos = bitacora?.nodos?.length || 0;
|
|
|
|
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)}
|
|
/>
|
|
|
|
{/* Modal visor de documentos .md */}
|
|
{docModal.show && (
|
|
<div className="modal-overlay" onClick={() => setDocModal({ ...docModal, show: false })}>
|
|
<div className="modal-box" onClick={e => e.stopPropagation()} style={{ maxWidth: '800px', maxHeight: '85vh', width: '90%', display: 'flex', flexDirection: 'column' }}>
|
|
<div className="modal-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<span><i className="fas fa-file-alt"></i> {docModal.title}</span>
|
|
<button className="btn btn-sm" onClick={() => setDocModal({ ...docModal, show: false })} style={{ background: 'transparent', color: 'var(--text-muted)', border: 'none', fontSize: '1.2rem' }}>
|
|
<i className="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
<div style={{ overflow: 'auto', padding: '1.5rem', flex: 1 }}>
|
|
{docModal.loading ? (
|
|
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}><i className="fas fa-spinner fa-spin fa-2x"></i></div>
|
|
) : (
|
|
<div className="doc-viewer-content">
|
|
<ReactMarkdown remarkPlugins={MARKDOWN_PLUGINS} components={markdownComponents as any}>
|
|
{docModal.content}
|
|
</ReactMarkdown>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<nav className="navbar">
|
|
<div className="navbar-brand">
|
|
<i className="fas fa-book-open"></i>
|
|
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> Planes</button>
|
|
<button
|
|
className={`btn btn-sm ${tab === 'ambitos' ? 'btn-primary' : ''}`}
|
|
onClick={() => changeTab('ambitos')}
|
|
style={tab !== 'ambitos' ? { background: 'var(--bg-card)', color: 'var(--text)', border: '1px solid var(--border)' } : {}}
|
|
><i className="fas fa-layer-group"></i> Ámbitos</button>
|
|
{tab === 'bitacora' && (
|
|
<a href={`${API_URL}/bitacoras/${fecha}/export`} target="_blank" rel="noopener"
|
|
className="btn btn-sm btn-primary">
|
|
<i className="fas fa-file-export"></i> Exportar MD
|
|
</a>
|
|
)}
|
|
</div>
|
|
</nav>
|
|
|
|
<main className="main">
|
|
{/* =================== TAB BITÁCORA =================== */}
|
|
{tab === 'bitacora' && (
|
|
<>
|
|
{/* Global Stats Row */}
|
|
{/* Global Stats Row */}
|
|
{globalStats.length > 0 && (
|
|
<div style={{ display: 'flex', gap: '1rem', overflowX: 'auto', paddingBottom: '1rem', marginBottom: '1rem', borderBottom: '1px solid var(--border)' }}>
|
|
{globalStats.map((s, i) => (
|
|
<div key={i} className="card" style={{ padding: '0.6rem 1rem', minWidth: '150px', flexShrink: 0 }}>
|
|
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', textTransform: 'uppercase', fontWeight: 600 }}>{s.nombre}</div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginTop: '0.2rem' }}>
|
|
<span style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--primary)' }}>{s.count_nodos} <small style={{ fontSize: '0.6rem', fontWeight: 400 }}>nodos</small></span>
|
|
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{s.count_entradas} <small style={{ fontSize: '0.6rem' }}>ev.</small></span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{/* Date Selector con Mini Calendario */}
|
|
<div className="date-selector">
|
|
<button className="btn btn-sm btn-primary" onClick={() => {
|
|
const d = new Date(fecha + 'T12:00:00'); d.setDate(d.getDate() - 1); setFecha(d.toISOString().split('T')[0]);
|
|
}}><i className="fas fa-chevron-left"></i></button>
|
|
<button className="btn btn-sm cal-toggle" onClick={() => setShowCal(v => !v)}
|
|
style={{ background: 'var(--bg-card)', color: 'var(--text)', border: '1px solid var(--border)', minWidth: 160, fontWeight: 600 }}>
|
|
<i className="fas fa-calendar-alt"></i> {fecha}
|
|
</button>
|
|
<button className="btn btn-sm btn-primary" onClick={() => {
|
|
const d = new Date(fecha + 'T12:00:00'); 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>
|
|
{showCal && (
|
|
<div className="mini-cal">
|
|
<div className="mini-cal-header">
|
|
<button onClick={() => setCalMonth(p => p.month === 0 ? { year: p.year - 1, month: 11 } : { ...p, month: p.month - 1 })}>
|
|
<i className="fas fa-chevron-left"></i>
|
|
</button>
|
|
<span>{MONTH_NAMES[calMonth.month]} {calMonth.year}</span>
|
|
<button onClick={() => setCalMonth(p => p.month === 11 ? { year: p.year + 1, month: 0 } : { ...p, month: p.month + 1 })}>
|
|
<i className="fas fa-chevron-right"></i>
|
|
</button>
|
|
</div>
|
|
<table className="mini-cal-grid">
|
|
<thead><tr>{'DLMMJVS'.split('').map((d,i)=><th key={i}>{d}</th>)}</tr></thead>
|
|
<tbody>
|
|
{buildCalDays().map((row, ri) => (
|
|
<tr key={ri}>{row.map((c, ci) => (
|
|
<td key={ci}
|
|
className={[
|
|
c.day === 0 ? 'empty' : '',
|
|
c.active ? 'has-data' : '',
|
|
c.selected ? 'selected' : '',
|
|
c.today ? 'today' : ''
|
|
].join(' ')}
|
|
onClick={() => { if (c.day > 0) { setFecha(c.dateStr); setShowCal(false); } }}
|
|
>{c.day > 0 ? c.day : ''}</td>
|
|
))}</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* Stats */}
|
|
<div className="stats-bar">
|
|
<div className="stat-card">
|
|
<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">{bitacora?.nodos?.length || 0}</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>
|
|
|
|
{/* =================== 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>
|
|
<form className="form-inline" onSubmit={handleSubmit}>
|
|
<input type="time" className="input-time" value={newInicio} onChange={e => setNewInicio(e.target.value)} placeholder="I" required step="1" />
|
|
<input type="time" className="input-time" value={newFin} onChange={e => setNewFin(e.target.value)} placeholder="F" step="1" />
|
|
<input type="text" className="input-desc" value={newDesc} onChange={e => setNewDesc(e.target.value)} placeholder="Descripción..." required />
|
|
<select value={newNodoId} onChange={e => setNewNodoId(e.target.value)} required>
|
|
<option value="">Nodo...</option>
|
|
{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>}
|
|
|
|
{/* Agrupar entradas por ÁMBITO en lugar de por nodo */}
|
|
{(() => {
|
|
// Agrupar por ámbito a nivel de cada entrada
|
|
const porAmbito: Record<string, any> = {};
|
|
bitacora?.nodos?.forEach(nodoGroup => {
|
|
if (nodoGroup.resumen) {
|
|
// Si el nodo tiene resumen genérico, lo guardamos en su primer ámbito disponible o "Global"
|
|
const defaultAmbito = nodoGroup.entradas.length > 0 ? (nodoGroup.entradas[0].ambito_nombre || nodoGroup.ambito_nombre || 'Sin Ámbito') : 'Sin Ámbito';
|
|
if (!porAmbito[defaultAmbito]) porAmbito[defaultAmbito] = { ambito: defaultAmbito, nodos: new Set(), entradas: [], resumenes: [] };
|
|
porAmbito[defaultAmbito].resumenes.push({ nodo: nodoGroup.nodo, resumen: nodoGroup.resumen });
|
|
}
|
|
|
|
nodoGroup.entradas.forEach(e => {
|
|
const ambito = e.ambito_nombre || nodoGroup.ambito_nombre || 'Sin Ámbito';
|
|
if (!porAmbito[ambito]) {
|
|
porAmbito[ambito] = {
|
|
ambito: ambito,
|
|
nodos: new Set(),
|
|
entradas: [],
|
|
resumenes: []
|
|
};
|
|
}
|
|
porAmbito[ambito].entradas.push(e);
|
|
porAmbito[ambito].nodos.add(nodoGroup.nodo || e.nodo_nombre);
|
|
});
|
|
});
|
|
|
|
return Object.values(porAmbito).map((ambitoGroup, idx) => (
|
|
<div key={idx} className="nodo-section card" style={{ marginBottom: '1.5rem' }}>
|
|
<div className="nodo-title">
|
|
<i className="fas fa-layer-group" style={{ color: 'var(--primary)' }}></i>
|
|
{ambitoGroup.ambito}
|
|
<span className="badge badge-gradient" style={{ marginLeft: 'auto' }}>
|
|
{ambitoGroup.nodos.size} nodos | {ambitoGroup.entradas.length} entradas
|
|
</span>
|
|
</div>
|
|
|
|
{/* Resúmenes por nodo dentro del ámbito */}
|
|
{ambitoGroup.resumenes.length > 0 && ambitoGroup.resumenes.map((r, i) => (
|
|
<div key={i} style={{ padding: '0.8rem 1rem', background: 'rgba(102, 126, 234, 0.05)', borderBottom: '1px solid var(--border)', fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: i > 0 ? '0.5rem' : 0 }}>
|
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
|
<i className="fas fa-quote-left" style={{ opacity: 0.5 }}></i>
|
|
<div style={{ fontStyle: 'italic', flex: 1 }}>
|
|
<strong>{r.nodo}:</strong> {r.resumen}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
<div className="table-responsive">
|
|
<table className="table-ifde">
|
|
<thead>
|
|
<tr>
|
|
<th style={{ width: '40px', color: 'var(--text-muted)', fontSize: '0.85rem' }}>ID</th>
|
|
<th className="col-time">I</th>
|
|
<th className="col-time">F</th>
|
|
<th className="col-desc">Descripción</th>
|
|
<th style={{ width: '90px', textAlign: 'center' }}>Nodo</th>
|
|
<th className="col-j-header" style={{ width: '35px', textAlign: 'center' }}>J</th>
|
|
<th className="col-status">E</th>
|
|
<th style={{ width: '80px' }}>Acc.</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{ambitoGroup.entradas.sort((a, b) => b.inicio.localeCompare(a.inicio)).map(e => (
|
|
editId === e.id ? (
|
|
/* ==== FILA EN EDICIÓN ==== */
|
|
<tr key={e.id} style={{ background: 'rgba(102, 126, 234, 0.1)' }}>
|
|
<td style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{e.id}</td>
|
|
<td><input type="time" value={editInicio?.substring(0,5)} onChange={ev => setEditInicio(ev.target.value)} className="edit-input" step="1" /></td>
|
|
<td><input type="time" value={editFin?.substring(0,5)} onChange={ev => setEditFin(ev.target.value)} className="edit-input" step="1" /></td>
|
|
<td>
|
|
<input type="text" value={editDesc} onChange={ev => setEditDesc(ev.target.value)} className="edit-input" style={{ width: '100%' }} />
|
|
<label style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: '0.2rem', marginTop: '0.3rem' }}>
|
|
<input type="checkbox" checked={editEsIa} onChange={ev => setEditEsIa(ev.target.checked)} /> IA
|
|
</label>
|
|
</td>
|
|
<td style={{ textAlign: 'center', fontSize: '0.8rem', fontWeight: 600, color: 'var(--text-secondary)' }}>{e.nodo_nombre}</td>
|
|
<td style={{ textAlign: 'center' }}>
|
|
<select value={editModo} onChange={ev => setEditModo(ev.target.value)} className="edit-input" style={{ width: '45px', padding: '0.35rem 0.2rem' }}>
|
|
<option value="P">P</option><option value="R">R</option><option value="S">S</option>
|
|
</select>
|
|
</td>
|
|
<td style={{ textAlign: 'center' }}>
|
|
<select value={editEstado} onChange={ev => setEditEstado(ev.target.value)} className="edit-input" style={{ width: '45px', padding: '0.35rem 0.2rem' }}>
|
|
<option value="⏳">⏳</option><option value="✅">✅</option>
|
|
<option value="⚠️">⚠️</option><option value="❌">❌</option><option value="👁️">👁️</option>
|
|
</select>
|
|
</td>
|
|
<td>
|
|
<div style={{ display: 'flex', gap: '0.2rem' }}>
|
|
<button className="btn btn-sm btn-success" onClick={saveEdit} title="Guardar"><i className="fas fa-check"></i></button>
|
|
<button className="btn btn-sm" onClick={cancelEdit} title="Cancelar" style={{ background: 'var(--bg-card)', color: 'var(--text-muted)' }}><i className="fas fa-times"></i></button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
/* ==== FILA NORMAL ==== */
|
|
<tr key={e.id}>
|
|
<td style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{e.id}</td>
|
|
<td className="col-time">{e.inicio?.substring(0, 5)}</td>
|
|
<td className="col-time">{e.fin ? e.fin.substring(0, 5) : '—'}</td>
|
|
<td className="col-desc">
|
|
{e.es_ia && <span className="ia-tag">IA</span>}
|
|
{e.doc_path && (() => {
|
|
const parts = e.doc_path.split('/');
|
|
const archivo = parts.pop() || '';
|
|
const proyecto = parts.pop() || '';
|
|
const label = archivo.replace('.md', '').replace(/_/g, ' ').split(' ')[0];
|
|
return (
|
|
<button
|
|
className="plan-link-btn"
|
|
onClick={(ev) => { ev.stopPropagation(); openDocModal(proyecto, archivo); }}
|
|
title={`Ver ${archivo}`}
|
|
>
|
|
<i className="fas fa-file-alt"></i> {label}
|
|
</button>
|
|
);
|
|
})()}
|
|
{renderDescription(e.descripcion)}
|
|
</td>
|
|
<td style={{ textAlign: 'center', fontSize: '0.8rem', fontWeight: 600, color: 'var(--primary)', background: 'rgba(102, 126, 234, 0.05)' }}>{e.nodo_nombre}</td>
|
|
<td style={{ textAlign: 'center', fontWeight: 600, color: 'var(--text-secondary)' }}>{e.modo}</td>
|
|
<td className="col-status">{e.estado}</td>
|
|
<td>
|
|
<div style={{ display: 'flex', gap: '0.2rem' }}>
|
|
<button className="btn btn-sm" onClick={() => startEdit(e)} title="Editar"
|
|
style={{ background: 'var(--info)', color: 'white' }}>
|
|
<i className="fas fa-pen"></i>
|
|
</button>
|
|
<button className="btn btn-sm btn-danger" onClick={() => requestDeleteEntrada(e.id, e.descripcion)} title="Eliminar">
|
|
<i className="fas fa-trash-alt"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
));
|
|
})()}
|
|
|
|
{!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>
|
|
<select value={nodoAmbitoId} onChange={e => setNodoAmbitoId(e.target.value)}>
|
|
<option value="">Sin Ámbito</option>
|
|
{ambitos.map(a => <option key={a.id} value={a.id}>{a.nombre}</option>)}
|
|
</select>
|
|
<input type="text" className="input-desc" value={nodoDesc} onChange={e => setNodoDesc(e.target.value)} placeholder="Descripción..." />
|
|
<button type="submit" className="btn btn-success"><i className="fas fa-save"></i> {editNodoId ? 'Actualizar' : 'Crear'}</button>
|
|
<button type="button" className="btn btn-sm" onClick={resetNodoForm} style={{ background: 'var(--bg-card)', color: 'var(--text-muted)', border: '1px solid var(--border)' }}>
|
|
<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>Ámbito</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><span className="badge" style={{ background: 'var(--bg-tag)', color: 'var(--text-secondary)', border: '1px solid var(--border)' }}>{n.ambito_nombre || '—'}</span></td>
|
|
<td style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>{n.descripcion || '—'}</td>
|
|
<td>
|
|
<div style={{ display: 'flex', gap: '0.2rem' }}>
|
|
<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 ÁMBITOS =================== */}
|
|
{tab === 'ambitos' && (
|
|
<>
|
|
{activeAmbitoId ? (
|
|
<AmbitoDashboard id={activeAmbitoId} onBack={() => setActiveAmbitoId(null)} />
|
|
) : (
|
|
<>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
|
<h2 style={{ fontSize: '1.3rem', fontWeight: 600 }}><i className="fas fa-layer-group" style={{ color: 'var(--primary)', marginRight: '0.5rem' }}></i>Gestión de Ámbitos</h2>
|
|
<button className="btn btn-primary" onClick={() => openAmbitoForm()}>
|
|
<i className="fas fa-plus"></i> Nuevo Ámbito
|
|
</button>
|
|
</div>
|
|
|
|
{/* Ámbito Form */}
|
|
{ambitoFormVisible && (
|
|
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
|
<div className="card-header">
|
|
<i className={editAmbitoId ? 'fas fa-pen' : 'fas fa-plus-circle'}></i>
|
|
{editAmbitoId ? 'Editar Ámbito' : 'Nuevo Ámbito'}
|
|
</div>
|
|
<form className="form-inline" onSubmit={saveAmbito}>
|
|
<input type="text" value={ambitoNombre} onChange={e => setAmbitoNombre(e.target.value)} placeholder="Nombre (ej: dtic-DASUTEN)" required style={{ minWidth: '180px' }} />
|
|
<select value={ambitoParentId} onChange={e => setAmbitoParentId(e.target.value)}>
|
|
<option value="">Ámbito Raíz (Sin Padre)</option>
|
|
{ambitos.filter(a => a.id !== editAmbitoId).map(a => (
|
|
<option key={a.id} value={a.id}>{a.nombre}</option>
|
|
))}
|
|
</select>
|
|
<input type="text" className="input-desc" value={ambitoDesc} onChange={e => setAmbitoDesc(e.target.value)} placeholder="Descripción..." style={{ flex: 1 }} />
|
|
<button type="submit" className="btn btn-success"><i className="fas fa-save"></i> {editAmbitoId ? 'Actualizar' : 'Crear'}</button>
|
|
<button type="button" className="btn btn-sm" onClick={resetAmbitoForm} style={{ background: 'var(--bg-card)', color: 'var(--text-muted)', border: '1px solid var(--border)' }}>
|
|
<i className="fas fa-times"></i> Cancelar
|
|
</button>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{/* Ámbitos Table */}
|
|
<div className="card">
|
|
<table className="table-ifde">
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Nombre</th>
|
|
<th>Padre</th>
|
|
<th>Nodos</th>
|
|
<th>Descripción</th>
|
|
<th style={{ width: '120px' }}>Acc.</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{ambitos.map(a => (
|
|
<tr key={a.id} style={!a.activo ? { opacity: 0.5, textDecoration: 'line-through' } : {}}>
|
|
<td style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{a.id}</td>
|
|
<td style={{ fontWeight: 600 }}>{a.nombre}</td>
|
|
<td style={{ fontStyle: 'italic', color: 'var(--text-secondary)' }}>{a.parent_nombre || '—'}</td>
|
|
<td style={{ textAlign: 'center' }}>
|
|
<span className="badge badge-gradient">{a.count_nodos || 0}</span>
|
|
</td>
|
|
<td style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>{a.descripcion || '—'}</td>
|
|
<td>
|
|
<div style={{ display: 'flex', gap: '0.2rem' }}>
|
|
<button className="btn btn-sm" onClick={() => setActiveAmbitoId(a.id)} title="Ver Dashboard"
|
|
style={{ background: 'var(--primary)', color: 'white' }}>
|
|
<i className="fas fa-chart-line"></i>
|
|
</button>
|
|
<button className="btn btn-sm" onClick={() => openAmbitoForm(a)} title="Editar"
|
|
style={{ background: 'var(--info)', color: 'white' }}>
|
|
<i className="fas fa-pen"></i>
|
|
</button>
|
|
<button className="btn btn-sm btn-danger" onClick={() => requestDeleteAmbito(a.id, a.nombre)} title="Eliminar">
|
|
<i className="fas fa-trash-alt"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* =================== TAB PROYECTO =================== */}
|
|
{tab === 'proyecto' && (
|
|
<>
|
|
<div className="date-selector" style={{ marginBottom: '1.5rem', justifyContent: 'center' }}>
|
|
<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>
|
|
);
|
|
}
|
|
|
|
export default App;
|