224 lines
10 KiB
TypeScript
224 lines
10 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
|
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3002';
|
|
|
|
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;
|
|
}
|
|
|
|
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 [loading, setLoading] = useState(true);
|
|
|
|
// Form state
|
|
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);
|
|
|
|
const fetchBitacora = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch(`${API_URL}/api/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}/api/nodos`);
|
|
setNodos(await res.json());
|
|
} catch (err) { console.error('Error:', err); }
|
|
};
|
|
|
|
useEffect(() => { fetchBitacora(); }, [fetchBitacora]);
|
|
useEffect(() => { fetchNodos(); }, []);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!bitacora || !newDesc || !newInicio || !newNodoId) return;
|
|
try {
|
|
await fetch(`${API_URL}/api/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 handleDelete = async (id: number) => {
|
|
await fetch(`${API_URL}/api/entradas/${id}`, { method: 'DELETE' });
|
|
fetchBitacora();
|
|
};
|
|
|
|
const totalEntradas = bitacora?.nodos?.reduce((sum, n) => sum + n.entradas.length, 0) || 0;
|
|
const totalNodos = bitacora?.nodos?.length || 0;
|
|
|
|
return (
|
|
<div className="app">
|
|
<nav className="navbar">
|
|
<div className="navbar-brand">
|
|
<i className="fas fa-book-open"></i>
|
|
dtic-BITACORAs
|
|
<span className="badge badge-gradient">v1.0</span>
|
|
</div>
|
|
<a href={`${API_URL}/api/bitacoras/${fecha}/export`} target="_blank" rel="noopener"
|
|
className="btn btn-sm btn-primary">
|
|
<i className="fas fa-file-export"></i> Exportar MD
|
|
</a>
|
|
</nav>
|
|
|
|
<main className="main">
|
|
{/* 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>
|
|
</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: '40px' }}></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{nodoGroup.entradas.map(e => (
|
|
<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>
|
|
<button className="btn btn-sm btn-danger" onClick={() => handleDelete(e.id)} title="Eliminar">
|
|
<i className="fas fa-trash-alt"></i>
|
|
</button>
|
|
</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>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|