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