Reestructuración modular integral de servicios, reorganización de gestión en docs/ y limpieza profunda del repositorio
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev"]
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="dtic-BITACORAs - Sistema de Bitácoras de Operaciones DTIC UTNLR" />
|
||||
<title>dtic-BITACORAs | DTIC UTNLR</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "dtic-bitacoras-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.0"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,468 @@
|
||||
/* dtic-BITACORAs — Estética del Dashboard P2601 */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
--primary: #667eea;
|
||||
--secondary: #764ba2;
|
||||
--gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--info: #3b82f6;
|
||||
--bg: #0f172a;
|
||||
--bg-card: #1e293b;
|
||||
--bg-card-hover: #334155;
|
||||
--bg-input: #1e293b;
|
||||
--text: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--border: #334155;
|
||||
--radius: 12px;
|
||||
--shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
background: var(--gradient);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.navbar-brand i {
|
||||
-webkit-text-fill-color: initial;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.badge-gradient {
|
||||
background: var(--gradient);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
transition: var(--transition);
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: var(--gradient);
|
||||
color: white;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
margin: -1.5rem -1.5rem 1.5rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Table I-F-D-E */
|
||||
.table-ifde {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.table-ifde th {
|
||||
background: rgba(102, 126, 234, 0.15);
|
||||
color: var(--primary);
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.table-ifde td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.table-ifde tr:hover td {
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
|
||||
.table-ifde .col-time {
|
||||
width: 60px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.table-ifde .col-status {
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-ifde .col-desc {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.table-ifde .ia-tag {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
color: var(--primary);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
|
||||
/* Nodo section */
|
||||
.nodo-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.nodo-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.nodo-title i {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.nodo-ip {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Form inline */
|
||||
.form-inline {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
border-radius: var(--radius);
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-inline input,
|
||||
.form-inline select {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.form-inline input:focus,
|
||||
.form-inline select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.form-inline .input-time {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.form-inline .input-desc {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
font-size: 0.85rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--gradient);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Date selector */
|
||||
.date-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.date-selector input[type="date"] {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.date-selector input[type="date"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Stats bar */
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.5rem;
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
text-align: center;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
background: var(--gradient);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.3rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.main {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-inline {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-inline .input-desc {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal de confirmación */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.modal-box {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0;
|
||||
max-width: 420px;
|
||||
width: 90%;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Campos de edición inline */
|
||||
.edit-input {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--primary);
|
||||
color: var(--text);
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.edit-input:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,645 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/bitacoras/api';
|
||||
|
||||
interface Hito {
|
||||
id: number;
|
||||
id_hito: string;
|
||||
titulo: string;
|
||||
descripcion: string;
|
||||
estado: string;
|
||||
fecha: string | null;
|
||||
horas_presencial: number;
|
||||
horas_remoto: number;
|
||||
nodo_nombre: string;
|
||||
nodo_ip: string;
|
||||
}
|
||||
|
||||
interface Fase {
|
||||
id: number;
|
||||
numero: number;
|
||||
nombre: string;
|
||||
estado: string;
|
||||
descripcion: string;
|
||||
fecha_inicio: string | null;
|
||||
fecha_fin: string | null;
|
||||
hitos: Hito[] | null;
|
||||
}
|
||||
|
||||
interface NodoInfo {
|
||||
id: number;
|
||||
nombre: string;
|
||||
ip: string;
|
||||
tipo: string;
|
||||
descripcion: string;
|
||||
}
|
||||
|
||||
interface Metricas {
|
||||
hitos_completados: string;
|
||||
hitos_pendientes: string;
|
||||
hitos_total: string;
|
||||
total_presencial: string;
|
||||
total_remoto: string;
|
||||
total_horas: string;
|
||||
}
|
||||
|
||||
interface Proyecto {
|
||||
id: number;
|
||||
codigo: string;
|
||||
nombre: string;
|
||||
estado: string;
|
||||
descripcion: string;
|
||||
fases: Fase[];
|
||||
metricas: Metricas;
|
||||
nodos: NodoInfo[];
|
||||
}
|
||||
|
||||
type Vista = 'gerencial' | 'tecnica';
|
||||
|
||||
/* ═══════════ DONUT CHART SVG ═══════════ */
|
||||
function DonutChart({ segments, size = 140, strokeWidth = 18, centerLabel, centerValue, centerColor }: {
|
||||
segments: { value: number; color: string; label: string }[];
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
centerLabel?: string;
|
||||
centerValue?: string;
|
||||
centerColor?: string;
|
||||
}) {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const total = segments.reduce((s, seg) => s + seg.value, 0);
|
||||
let offset = 0;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<svg width={size} height={size} style={{ transform: 'rotate(-90deg)' }}>
|
||||
{/* Fondo gris */}
|
||||
<circle cx={size / 2} cy={size / 2} r={radius}
|
||||
fill="none" stroke="rgba(255,255,255,0.05)" strokeWidth={strokeWidth} />
|
||||
{/* Segmentos */}
|
||||
{total > 0 && segments.map((seg, i) => {
|
||||
const segLen = (seg.value / total) * circumference;
|
||||
const el = (
|
||||
<circle key={i} cx={size / 2} cy={size / 2} r={radius}
|
||||
fill="none" stroke={seg.color} strokeWidth={strokeWidth}
|
||||
strokeDasharray={`${segLen} ${circumference - segLen}`}
|
||||
strokeDashoffset={-offset}
|
||||
strokeLinecap="round"
|
||||
style={{ transition: 'stroke-dasharray 0.6s ease, stroke-dashoffset 0.6s ease' }}
|
||||
/>
|
||||
);
|
||||
offset += segLen;
|
||||
return el;
|
||||
})}
|
||||
{/* Texto central */}
|
||||
{centerValue && (
|
||||
<g style={{ transform: 'rotate(90deg)', transformOrigin: 'center' }}>
|
||||
<text x={size / 2} y={size / 2 - 4} textAnchor="middle" dominantBaseline="middle"
|
||||
fill={centerColor || '#fff'} fontSize="1.6rem" fontWeight="700">{centerValue}</text>
|
||||
{centerLabel && (
|
||||
<text x={size / 2} y={size / 2 + 16} textAnchor="middle" dominantBaseline="middle"
|
||||
fill="var(--text-muted)" fontSize="0.6rem" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>{centerLabel}</text>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
{/* Leyenda */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem 1rem', justifyContent: 'center' }}>
|
||||
{segments.filter(s => s.value > 0).map((seg, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.7rem', color: 'var(--text-muted)' }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: seg.color, flexShrink: 0 }}></span>
|
||||
{seg.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════ CONTENIDO ENRIQUECIDO POR FASE ═══════════ */
|
||||
const faseDetalles: Record<number, { objetivo: string; desarrollo: string; logros: string }> = {
|
||||
1: {
|
||||
objetivo: 'Preparar el equipo central que alojará todos los servicios de DASUTeN, garantizando que opere de forma independiente a los sistemas de la Facultad.',
|
||||
desarrollo: 'Se instaló y configuró el servidor dedicado en la oficina de DASUTeN, creando un entorno de trabajo completamente separado de la red universitaria para mayor seguridad y control.',
|
||||
logros: 'Servidor propio operativo y listo para funcionar. Entorno aislado que brinda independencia total respecto a los sistemas de la Facultad.'
|
||||
},
|
||||
2: {
|
||||
objetivo: 'Establecer un sistema centralizado de identificación de usuarios y permisos de acceso, propio de la oficina DASUTeN.',
|
||||
desarrollo: 'Se configuró un servicio de gestión de identidades que permite controlar quién accede a cada equipo y recurso dentro de la red de DASUTeN.',
|
||||
logros: 'Sistema de usuarios operativo. Cada persona accede con credenciales propias. Se simplifica la administración de permisos y la seguridad de acceso.'
|
||||
},
|
||||
3: {
|
||||
objetivo: 'Instalar el sistema de base de datos necesario y recuperar toda la información del sistema DASUTeN desde el respaldo existente.',
|
||||
desarrollo: 'Se preparó un entorno dedicado para la base de datos, se transfirió el respaldo completo (~9 GB de información) y se restauró verificando su integridad.',
|
||||
logros: 'Toda la información de gestión de DASUTeN fue recuperada exitosamente. La base de datos está operativa y accesible desde la red interna.'
|
||||
},
|
||||
4: {
|
||||
objetivo: 'Habilitar la administración remota segura de todos los equipos y generar respaldos de seguridad como punto de recuperación.',
|
||||
desarrollo: 'Se activó el acceso remoto seguro en todos los equipos y se generaron copias de respaldo completas para poder restaurar el sistema ante cualquier eventualidad.',
|
||||
logros: 'Posibilidad de gestionar todos los equipos sin necesidad de presencia física. Respaldos de seguridad disponibles ante cualquier imprevisto.'
|
||||
},
|
||||
5: {
|
||||
objetivo: 'Analizar la aplicación de gestión DASUTeN y ponerla en funcionamiento sobre la nueva infraestructura virtual.',
|
||||
desarrollo: 'Configuración de Kermet.ini, instalación de componentes de sistema, fuentes y apertura de firewall SQL. Pruebas de conectividad exitosas.',
|
||||
logros: 'Despliegue operativo verificado con login exitoso. Validación presencial (UAT) realizada con Andrea Almirón satisfactoriamente.'
|
||||
},
|
||||
6: {
|
||||
objetivo: '🚀 Poner en funcionamiento el servidor físico en la oficina, integrar clientes al dominio y configurar políticas de seguridad.',
|
||||
desarrollo: 'Traslado físico del servidor Proxmox, unión de la PC pc-dasu0 al dominio dasuten.utnlr y despliegue de GPOs de seguridad.',
|
||||
logros: 'Hitos pendientes: Instalación física, AD-Join, GPO de seguridad y mapeo de unidades de red.'
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════ MAIN COMPONENT ═══════════ */
|
||||
export default function ProyectoDashboard({ codigo = 'P2601' }: { codigo?: string }) {
|
||||
const [proyecto, setProyecto] = useState<Proyecto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [vista, setVista] = useState<Vista>('gerencial');
|
||||
const [expandedFases, setExpandedFases] = useState<Set<number>>(new Set());
|
||||
const [expandedGerFases, setExpandedGerFases] = useState<Set<number>>(new Set());
|
||||
const [descExpanded, setDescExpanded] = useState(false);
|
||||
|
||||
useEffect(() => { fetchProyecto(); }, [codigo]);
|
||||
|
||||
const fetchProyecto = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/proyectos/${codigo}`);
|
||||
if (!res.ok) throw new Error('Proyecto no encontrado');
|
||||
const data = await res.json();
|
||||
setProyecto(data);
|
||||
setExpandedFases(new Set(data.fases.map((f: Fase) => f.id)));
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFase = (id: number) => {
|
||||
setExpandedFases(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleGerFase = (num: number) => {
|
||||
setExpandedGerFases(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(num) ? next.delete(num) : next.add(num);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>Cargando proyecto...</div>;
|
||||
if (error || !proyecto) return <div style={{ textAlign: 'center', padding: '3rem', color: '#ef4444' }}>Error: {error}</div>;
|
||||
|
||||
const m = proyecto.metricas;
|
||||
const totalH = parseFloat(m.total_horas);
|
||||
const completados = parseInt(m.hitos_completados);
|
||||
const pendientes = parseInt(m.hitos_pendientes);
|
||||
const totalHitos = parseInt(m.hitos_total);
|
||||
const progreso = totalHitos > 0 ? Math.round((completados / totalHitos) * 100) : 0;
|
||||
const hrsP = parseFloat(m.total_presencial);
|
||||
const hrsR = parseFloat(m.total_remoto);
|
||||
|
||||
const fasesCompletas = proyecto.fases.filter(f => f.estado === '✅').length;
|
||||
const fasesEnCurso = proyecto.fases.filter(f => f.estado === '⏳').length;
|
||||
const fasesPendientes = proyecto.fases.filter(f => f.estado !== '✅' && f.estado !== '⏳').length;
|
||||
|
||||
const estadoIcon = (estado: string) => {
|
||||
if (estado === '✅') return '✅';
|
||||
if (estado === '⏳') return '⏳';
|
||||
if (estado === '📋') return '📋';
|
||||
return estado;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="proyecto-dashboard">
|
||||
{/* Header */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
|
||||
<div style={{
|
||||
display: 'inline-block', padding: '3px 12px', borderRadius: '999px',
|
||||
background: 'rgba(56,189,248,0.12)', color: '#38bdf8',
|
||||
fontSize: '0.72rem', fontWeight: 600, letterSpacing: '0.05em', textTransform: 'uppercase',
|
||||
marginBottom: '0.5rem'
|
||||
}}>{proyecto.codigo}</div>
|
||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 700, margin: '0.3rem 0' }}>
|
||||
{proyecto.nombre}
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', maxWidth: '600px', margin: '0 auto 0.8rem' }}>
|
||||
{proyecto.descripcion}
|
||||
</p>
|
||||
|
||||
{/* Toggle vista */}
|
||||
<div style={{
|
||||
display: 'inline-flex', borderRadius: '8px', overflow: 'hidden',
|
||||
border: '1px solid var(--border)', background: 'rgba(255,255,255,0.03)'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setVista('gerencial')}
|
||||
style={{
|
||||
padding: '6px 16px', fontSize: '0.75rem', fontWeight: 600, border: 'none', cursor: 'pointer',
|
||||
background: vista === 'gerencial' ? 'linear-gradient(135deg, #38bdf8, #818cf8)' : 'transparent',
|
||||
color: vista === 'gerencial' ? '#fff' : 'var(--text-muted)', transition: 'all 0.2s ease'
|
||||
}}
|
||||
><i className="fas fa-chart-pie" style={{ marginRight: '5px' }}></i>Resumen</button>
|
||||
<button
|
||||
onClick={() => setVista('tecnica')}
|
||||
style={{
|
||||
padding: '6px 16px', fontSize: '0.75rem', fontWeight: 600, border: 'none', cursor: 'pointer',
|
||||
background: vista === 'tecnica' ? 'linear-gradient(135deg, #38bdf8, #818cf8)' : 'transparent',
|
||||
color: vista === 'tecnica' ? '#fff' : 'var(--text-muted)', transition: 'all 0.2s ease'
|
||||
}}
|
||||
><i className="fas fa-list-alt" style={{ marginRight: '5px' }}></i>Detalle Técnico</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══════════ VISTA GERENCIAL ═══════════ */}
|
||||
{vista === 'gerencial' && (
|
||||
<>
|
||||
{/* Descripción del Proyecto — desplegable */}
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden', marginBottom: '1.5rem' }}>
|
||||
<div
|
||||
onClick={() => setDescExpanded(prev => !prev)}
|
||||
style={{
|
||||
padding: '0.8rem 1rem', cursor: 'pointer', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'space-between',
|
||||
userSelect: 'none'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||
<span style={{ fontSize: '1.1rem' }}>📋</span>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.88rem' }}>Descripción del Proyecto</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', transition: 'transform 0.2s' }}>
|
||||
{descExpanded ? '▾' : '▸'}
|
||||
</span>
|
||||
</div>
|
||||
{descExpanded && (
|
||||
<div style={{ padding: '0 1rem 1rem', lineHeight: 1.6, fontSize: '0.82rem', color: 'var(--text)' }}>
|
||||
<p style={{ margin: '0 0 0.8rem' }}>
|
||||
<strong>DASUTeN</strong> es la obra social que brinda cobertura a empleados, docentes y alumnos de la
|
||||
Universidad Tecnológica Nacional. Su oficina en la Facultad Regional La Rioja depende
|
||||
operativamente de la <strong>sede central en Buenos Aires</strong>.
|
||||
</p>
|
||||
<p style={{ margin: '0 0 0.8rem' }}>
|
||||
Este proyecto tiene como objetivo <strong>dotar a la oficina DASUTeN de su propia infraestructura
|
||||
tecnológica independiente</strong>, operando bajo un entorno virtualizado (Proxmox) con acceso remoto seguro vía Tailscale.
|
||||
</p>
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '1.2rem', marginTop: '1rem'
|
||||
}}>
|
||||
<div>
|
||||
<div style={subheadStyle}>🎯 Objetivos y Alcance</div>
|
||||
<ul style={listStyle}>
|
||||
<li>Sistema de gestión operando desde servidor propio</li>
|
||||
<li>Independencia total de los equipos de la Facultad</li>
|
||||
<li>Oficina autosuficiente con su propia red y respaldos</li>
|
||||
<li>Servidor dedicado en oficina DASUTeN</li>
|
||||
<li>Red interna propia y segura</li>
|
||||
<li>Computadora de escritorio como acceso al sistema</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<div style={subheadStyle}>👥 Responsables y Beneficiarios</div>
|
||||
<ul style={listStyle}>
|
||||
<li><strong>Ejecución:</strong> Lic. Ricardo Monla</li>
|
||||
<li><strong>Supervisión:</strong> Dirección de TIC</li>
|
||||
</ul>
|
||||
<div style={{ fontSize: '0.7rem', color: '#38bdf8', fontWeight: 600, marginTop: '0.4rem', marginBottom: '0.2rem' }}>Usuarios directos:</div>
|
||||
<ul style={listStyle}>
|
||||
<li>Andrea Almirón — Administrativa DASUTeN</li>
|
||||
<li>Romina Molina — Administrativa</li>
|
||||
<li>Dra. Eugenia Riveros — Auditora Médica</li>
|
||||
</ul>
|
||||
<div style={{ fontSize: '0.7rem', color: '#38bdf8', fontWeight: 600, marginTop: '0.4rem', marginBottom: '0.2rem' }}>Beneficiarios indirectos:</div>
|
||||
<ul style={listStyle}>
|
||||
<li>Personal Docente</li>
|
||||
<li>Personal No Docente</li>
|
||||
<li>Alumnos</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gráficos de torta */}
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||
gap: '1.2rem', marginBottom: '1.8rem'
|
||||
}}>
|
||||
{/* Donut: Estado de Fases */}
|
||||
<div className="card" style={{ padding: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.6rem', fontWeight: 600 }}>
|
||||
Estado de Fases
|
||||
</div>
|
||||
<DonutChart
|
||||
segments={[
|
||||
{ value: fasesCompletas, color: '#22c55e', label: `${fasesCompletas} Completas` },
|
||||
{ value: fasesEnCurso, color: '#eab308', label: `${fasesEnCurso} En curso` },
|
||||
{ value: fasesPendientes, color: '#64748b', label: `${fasesPendientes} Pendientes` },
|
||||
]}
|
||||
centerValue={`${fasesCompletas + fasesEnCurso}/${proyecto.fases.length}`}
|
||||
centerLabel="fases"
|
||||
centerColor="#22c55e"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Donut: Avance Global */}
|
||||
<div className="card" style={{ padding: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.6rem', fontWeight: 600 }}>
|
||||
Avance de Hitos
|
||||
</div>
|
||||
<DonutChart
|
||||
segments={[
|
||||
{ value: completados, color: '#22c55e', label: `${completados} Completados` },
|
||||
{ value: pendientes, color: '#eab308', label: `${pendientes} Pendientes` },
|
||||
]}
|
||||
centerValue={`${progreso}%`}
|
||||
centerLabel="avance"
|
||||
centerColor="#22c55e"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Donut: Distribución de Horas */}
|
||||
<div className="card" style={{ padding: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.6rem', fontWeight: 600 }}>
|
||||
Distribución de Horas
|
||||
</div>
|
||||
<DonutChart
|
||||
segments={[
|
||||
{ value: hrsP, color: '#f97316', label: `${hrsP.toFixed(1)}h Presencial` },
|
||||
{ value: hrsR, color: '#a78bfa', label: `${hrsR.toFixed(1)}h Remoto` },
|
||||
]}
|
||||
centerValue={`${totalH.toFixed(0)}h`}
|
||||
centerLabel="total"
|
||||
centerColor="#38bdf8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fases con descripciones expandibles */}
|
||||
<SectionTitle title="📊 Fases del Proyecto" />
|
||||
{proyecto.fases.map(fase => {
|
||||
const hitos = fase.hitos || [];
|
||||
const faseCompletos = hitos.filter(h => h.estado === '✅').length;
|
||||
const faseProgreso = hitos.length > 0 ? Math.round((faseCompletos / hitos.length) * 100) : 0;
|
||||
const hrsTotal = hitos.reduce((s, h) => s + h.horas_presencial + h.horas_remoto, 0);
|
||||
const isExpanded = expandedGerFases.has(fase.numero);
|
||||
const detalle = faseDetalles[fase.numero];
|
||||
|
||||
return (
|
||||
<div key={fase.id} className="card" style={{
|
||||
marginBottom: '0.6rem', padding: 0, overflow: 'hidden', position: 'relative'
|
||||
}}>
|
||||
{/* Barra de progreso de fondo */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, bottom: 0, width: `${faseProgreso}%`,
|
||||
background: fase.estado === '✅' ? 'rgba(34,197,94,0.06)' : 'rgba(56,189,248,0.04)',
|
||||
transition: 'width 0.4s', pointerEvents: 'none'
|
||||
}} />
|
||||
|
||||
{/* Header clickeable */}
|
||||
<div
|
||||
onClick={() => toggleGerFase(fase.numero)}
|
||||
style={{
|
||||
position: 'relative', display: 'flex', justifyContent: 'space-between',
|
||||
alignItems: 'center', padding: '0.8rem 1rem', cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.7rem' }}>
|
||||
<span style={{
|
||||
fontSize: '1.3rem', width: '36px', height: '36px', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', borderRadius: '8px',
|
||||
background: fase.estado === '✅' ? 'rgba(34,197,94,0.12)' :
|
||||
fase.estado === '⏳' ? 'rgba(234,179,8,0.12)' : 'rgba(255,255,255,0.05)'
|
||||
}}>{estadoIcon(fase.estado)}</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.88rem' }}>
|
||||
F{fase.numero} · {fase.nombre}
|
||||
</div>
|
||||
{!isExpanded && fase.descripcion && (
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginTop: '2px' }}>
|
||||
{fase.descripcion}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexShrink: 0 }}>
|
||||
{hrsTotal > 0 && (
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{hrsTotal.toFixed(1)}h</span>
|
||||
)}
|
||||
<div style={{
|
||||
minWidth: '52px', textAlign: 'right', fontWeight: 700, fontSize: '0.85rem',
|
||||
color: faseProgreso === 100 ? '#22c55e' : faseProgreso > 0 ? '#38bdf8' : 'var(--text-muted)'
|
||||
}}>{faseProgreso}%</div>
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{isExpanded ? '▾' : '▸'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detalle expandible */}
|
||||
{isExpanded && detalle && (
|
||||
<div style={{
|
||||
position: 'relative', borderTop: '1px solid var(--border)',
|
||||
padding: '0.8rem 1rem',
|
||||
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '0.8rem'
|
||||
}}>
|
||||
<FaseDetailCol icon="🎯" title="Objetivo" text={detalle.objetivo} />
|
||||
<FaseDetailCol icon="⚙️" title="Desarrollo" text={detalle.desarrollo} />
|
||||
<FaseDetailCol icon="✨" title="Logros" text={detalle.logros} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ═══════════ VISTA TÉCNICA ═══════════ */}
|
||||
{vista === 'tecnica' && (
|
||||
<>
|
||||
{/* Métricas numéricas */}
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))',
|
||||
gap: '0.8rem', marginBottom: '1.5rem'
|
||||
}}>
|
||||
<MetricCard label="Fases" value={`${fasesCompletas + fasesEnCurso}/${proyecto.fases.length}`} color="#38bdf8" />
|
||||
<MetricCard label="Hitos Comp." value={m.hitos_completados} color="#22c55e" />
|
||||
<MetricCard label="Hitos Pend." value={m.hitos_pendientes} color="#eab308" />
|
||||
<MetricCard label="Hrs Físicas" value={`${hrsP.toFixed(1)}h`} color="#f97316" />
|
||||
<MetricCard label="Esfuerzo Total" value={`${totalH.toFixed(1)}h`} color="#a78bfa" />
|
||||
</div>
|
||||
|
||||
{/* Barra progreso */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem', marginBottom: '0.3rem' }}>
|
||||
<span style={{ fontWeight: 600 }}>Avance Global</span>
|
||||
<span style={{ color: '#38bdf8', fontWeight: 600 }}>{progreso}%</span>
|
||||
</div>
|
||||
<div style={{
|
||||
width: '100%', height: '10px', background: 'rgba(255,255,255,0.06)',
|
||||
borderRadius: '999px', overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${progreso}%`, height: '100%', borderRadius: '999px',
|
||||
background: 'linear-gradient(90deg, #38bdf8, #818cf8)', transition: 'width 0.6s ease'
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nodos */}
|
||||
<SectionTitle title="🖧 Nodos del Proyecto" />
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
|
||||
gap: '0.7rem', marginBottom: '1.5rem'
|
||||
}}>
|
||||
{proyecto.nodos.map(n => (
|
||||
<div key={n.id} className="card" style={{ padding: '0.8rem' }}>
|
||||
<div style={{ fontWeight: 700, fontSize: '0.85rem', marginBottom: '0.3rem' }}>{n.nombre}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
||||
{n.ip && <><code style={codeStyle}>{n.ip}</code><br /></>}
|
||||
{n.descripcion}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Fases detalladas */}
|
||||
<SectionTitle title="📋 Fases del Proyecto" />
|
||||
{proyecto.fases.map(fase => {
|
||||
const hitos = fase.hitos || [];
|
||||
const faseCompletos = hitos.filter(h => h.estado === '✅').length;
|
||||
const faseProgreso = hitos.length > 0 ? Math.round((faseCompletos / hitos.length) * 100) : 0;
|
||||
const fasHrsP = hitos.reduce((s, h) => s + h.horas_presencial, 0);
|
||||
const fasHrsR = hitos.reduce((s, h) => s + h.horas_remoto, 0);
|
||||
const expanded = expandedFases.has(fase.id);
|
||||
|
||||
return (
|
||||
<div key={fase.id} className="card" style={{ marginBottom: '0.8rem', padding: 0, overflow: 'hidden' }}>
|
||||
<div onClick={() => toggleFase(fase.id)} style={{
|
||||
padding: '0.8rem 1rem', cursor: 'pointer', display: 'flex',
|
||||
justifyContent: 'space-between', alignItems: 'center',
|
||||
borderBottom: expanded ? '1px solid var(--border)' : 'none'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||
<span style={{ fontSize: '0.7rem', color: '#38bdf8', fontWeight: 700 }}>F{fase.numero}</span>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>{fase.nombre}</span>
|
||||
<span style={{ fontSize: '0.8rem' }}>{estadoIcon(fase.estado)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.8rem', fontSize: '0.72rem', color: 'var(--text-muted)' }}>
|
||||
{fasHrsP > 0 && <span style={{ color: '#f97316' }}>🏢 {fasHrsP.toFixed(1)}h</span>}
|
||||
{fasHrsR > 0 && <span style={{ color: '#a78bfa' }}>💻 {fasHrsR.toFixed(1)}h</span>}
|
||||
<span style={{ color: '#38bdf8' }}>{faseProgreso}%</span>
|
||||
<span>{expanded ? '▾' : '▸'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ height: '3px', background: 'rgba(255,255,255,0.03)' }}>
|
||||
<div style={{
|
||||
width: `${faseProgreso}%`, height: '100%',
|
||||
background: fase.estado === '✅' ? '#22c55e' : 'linear-gradient(90deg, #38bdf8, #818cf8)',
|
||||
transition: 'width 0.4s'
|
||||
}} />
|
||||
</div>
|
||||
{expanded && hitos.length > 0 && (
|
||||
<div style={{ padding: '0.5rem 1rem 0.8rem' }}>
|
||||
<table className="table-ifde" style={{ fontSize: '0.8rem' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '55px' }}>ID</th>
|
||||
<th>Hito</th>
|
||||
<th style={{ width: '75px' }}>Fecha</th>
|
||||
<th style={{ width: '60px' }}>🏢</th>
|
||||
<th style={{ width: '60px' }}>💻</th>
|
||||
<th style={{ width: '80px' }}>Nodo</th>
|
||||
<th style={{ width: '35px' }}>E</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hitos.map(h => (
|
||||
<tr key={h.id}>
|
||||
<td><code style={{ fontSize: '0.72rem', color: '#38bdf8' }}>{h.id_hito}</code></td>
|
||||
<td>
|
||||
<strong>{h.titulo}</strong>
|
||||
{h.descripcion && <div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginTop: '2px' }}>{h.descripcion}</div>}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>
|
||||
{h.fecha ? new Date(h.fecha + 'T12:00:00').toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' }) : '—'}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', color: h.horas_presencial > 0 ? '#f97316' : 'var(--text-muted)' }}>
|
||||
{h.horas_presencial > 0 ? `${h.horas_presencial}h` : '—'}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', color: h.horas_remoto > 0 ? '#a78bfa' : 'var(--text-muted)' }}>
|
||||
{h.horas_remoto > 0 ? `${h.horas_remoto}h` : '—'}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.72rem' }}>{h.nodo_nombre || '—'}</td>
|
||||
<td style={{ textAlign: 'center' }}>{estadoIcon(h.estado)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.7rem', marginTop: '1.5rem', paddingTop: '0.8rem', borderTop: '1px solid var(--border)' }}>
|
||||
P2601 — Infraestructura DASUTEN · UTN FRLR · Dashboard integrado en dtic-BITACORAs
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══ Estilos compartidos ═══ */
|
||||
const codeStyle: React.CSSProperties = {
|
||||
background: 'rgba(255,255,255,0.06)', padding: '1px 4px', borderRadius: '3px', fontSize: '0.72rem'
|
||||
};
|
||||
const subheadStyle: React.CSSProperties = {
|
||||
fontSize: '0.75rem', fontWeight: 600, marginBottom: '0.4rem', color: '#38bdf8'
|
||||
};
|
||||
const listStyle: React.CSSProperties = {
|
||||
margin: 0, paddingLeft: '1.1rem', fontSize: '0.78rem', color: 'var(--text-muted)', lineHeight: 1.7
|
||||
};
|
||||
|
||||
/* ═══ Sub-componentes ═══ */
|
||||
function MetricCard({ label, value, color }: { label: string; value: string; color: string }) {
|
||||
return (
|
||||
<div className="card" style={{ padding: '0.9rem', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 700, color, lineHeight: 1 }}>{value}</div>
|
||||
<div style={{ fontSize: '0.68rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.04em', marginTop: '0.3rem' }}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({ title }: { title: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
fontSize: '0.78rem', textTransform: 'uppercase', letterSpacing: '0.06em',
|
||||
color: 'var(--text-muted)', fontWeight: 600, marginBottom: '0.8rem',
|
||||
paddingBottom: '0.4rem', borderBottom: '1px solid var(--border)'
|
||||
}}>{title}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FaseDetailCol({ icon, title, text }: { icon: string; title: string; text: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: '#38bdf8', marginBottom: '0.3rem' }}>
|
||||
{icon} {title}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.76rem', color: 'var(--text-muted)', lineHeight: 1.5 }}>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/bitacoras/',
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
cors: true,
|
||||
allowedHosts: ['ns8.frlr.utn.edu.ar', 'localhost'],
|
||||
proxy: {
|
||||
'/bitacoras/api': {
|
||||
target: 'http://api:3001',
|
||||
changeOrigin: true,
|
||||
rewrite: (path: string) => path.replace(/^\/bitacoras\/api/, '/api')
|
||||
}
|
||||
}
|
||||
},
|
||||
define: { 'import.meta.env.VERSION': JSON.stringify('1.0.0') }
|
||||
});
|
||||
Reference in New Issue
Block a user