Feat(Web): Mini Calendario con indicadores de días activos en bitácoras - DB-First

This commit is contained in:
Ricardo Monla
2026-03-10 17:45:46 -03:00
parent 8d27a8958e
commit c3da4cb6f0
3 changed files with 168 additions and 16 deletions
@@ -19,6 +19,17 @@ module.exports = (pool) => {
res.json(rows);
});
// GET /api/bitacoras/fechas/activas — Fechas que tienen entradas cargadas (para el calendario)
router.get('/fechas/activas', async (req, res) => {
const { rows } = await pool.query(`
SELECT b.fecha::text
FROM bitacoras.bitacoras b
WHERE EXISTS (SELECT 1 FROM bitacoras.entradas e WHERE e.bitacora_id = b.id)
ORDER BY b.fecha DESC
`);
res.json(rows.map(r => r.fecha));
});
// GET /api/bitacoras/:fecha/completa — Bitácora completa con entradas agrupadas por nodo
router.get('/:fecha/completa', async (req, res) => {
const { fecha } = req.params;
@@ -99,6 +99,9 @@ function App() {
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('');
@@ -212,6 +215,14 @@ function App() {
} catch (err) { console.error('Error:', 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`);
@@ -275,7 +286,34 @@ function App() {
};
useEffect(() => { fetchBitacora(); }, [fetchBitacora]);
useEffect(() => { fetchNodos(); fetchProyectos(); }, []);
useEffect(() => { fetchNodos(); fetchProyectos(); fetchFechasActivas(); }, []);
// 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) => {
@@ -430,20 +468,54 @@ function App() {
{/* =================== TAB BITÁCORA =================== */}
{tab === 'bitacora' && (
<>
{/* Date Selector */}
{/* Date Selector con Mini Calendario */}
<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]);
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>
<input type="date" value={fecha} onChange={e => setFecha(e.target.value)} />
<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); d.setDate(d.getDate() + 1); setFecha(d.toISOString().split('T')[0]);
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">
@@ -308,23 +308,92 @@ body {
.date-selector {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
gap: 0.5rem;
margin-bottom: 0.5rem;
position: relative;
}
.date-selector input[type="date"] {
/* Mini Calendario */
.mini-cal {
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;
border-radius: 12px;
padding: 0.75rem;
margin-bottom: 1.5rem;
box-shadow: 0 8px 32px rgba(0,0,0,0.25);
max-width: 320px;
animation: calSlide 0.2s ease-out;
}
@keyframes calSlide { from { opacity:0; transform:translateY(-8px); } to { opacity:1; transform:translateY(0); } }
.date-selector input[type="date"]:focus {
outline: none;
border-color: var(--primary);
.mini-cal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
font-weight: 700;
font-size: 0.95rem;
color: var(--text);
}
.mini-cal-header button {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 6px;
transition: background 0.15s;
}
.mini-cal-header button:hover { background: var(--bg-hover); }
.mini-cal-grid {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.mini-cal-grid th {
color: var(--text-secondary);
font-size: 0.7rem;
font-weight: 600;
text-align: center;
padding: 0.25rem;
}
.mini-cal-grid td {
text-align: center;
padding: 0.3rem;
font-size: 0.82rem;
cursor: pointer;
border-radius: 8px;
transition: all 0.15s;
position: relative;
color: var(--text-secondary);
}
.mini-cal-grid td:hover:not(.empty) { background: var(--bg-hover); }
.mini-cal-grid td.empty { cursor: default; }
.mini-cal-grid td.has-data {
color: var(--text);
font-weight: 700;
}
.mini-cal-grid td.has-data::after {
content: '';
display: block;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--primary);
margin: 2px auto 0;
box-shadow: 0 0 6px var(--primary);
}
.mini-cal-grid td.selected {
background: var(--primary);
color: #fff !important;
font-weight: 700;
box-shadow: 0 0 12px rgba(99,102,241,0.5);
}
.mini-cal-grid td.selected::after { background: #fff; box-shadow: none; }
.mini-cal-grid td.today:not(.selected) {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
/* Stats bar */