Feat(Web): Mini Calendario con indicadores de días activos en bitácoras - DB-First
This commit is contained in:
@@ -19,6 +19,17 @@ module.exports = (pool) => {
|
|||||||
res.json(rows);
|
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
|
// GET /api/bitacoras/:fecha/completa — Bitácora completa con entradas agrupadas por nodo
|
||||||
router.get('/:fecha/completa', async (req, res) => {
|
router.get('/:fecha/completa', async (req, res) => {
|
||||||
const { fecha } = req.params;
|
const { fecha } = req.params;
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ function App() {
|
|||||||
const [proyectos, setProyectos] = useState<{ id: number, codigo: string, nombre: string }[]>([]);
|
const [proyectos, setProyectos] = useState<{ id: number, codigo: string, nombre: string }[]>([]);
|
||||||
const [selectedProyecto, setSelectedProyecto] = useState<string>('P2601');
|
const [selectedProyecto, setSelectedProyecto] = useState<string>('P2601');
|
||||||
const [loading, setLoading] = useState(true);
|
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
|
// Form: nueva entrada
|
||||||
const [newInicio, setNewInicio] = useState('');
|
const [newInicio, setNewInicio] = useState('');
|
||||||
@@ -212,6 +215,14 @@ function App() {
|
|||||||
} catch (err) { console.error('Error:', err); }
|
} 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 () => {
|
const fetchProyectos = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/proyectos`);
|
const res = await fetch(`${API_URL}/proyectos`);
|
||||||
@@ -275,7 +286,34 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { fetchBitacora(); }, [fetchBitacora]);
|
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 ===
|
// === ENTRADAS CRUD ===
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@@ -430,20 +468,54 @@ function App() {
|
|||||||
{/* =================== TAB BITÁCORA =================== */}
|
{/* =================== TAB BITÁCORA =================== */}
|
||||||
{tab === 'bitacora' && (
|
{tab === 'bitacora' && (
|
||||||
<>
|
<>
|
||||||
{/* Date Selector */}
|
{/* Date Selector con Mini Calendario */}
|
||||||
<div className="date-selector">
|
<div className="date-selector">
|
||||||
<button className="btn btn-sm btn-primary" onClick={() => {
|
<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>
|
}}><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={() => {
|
<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>
|
}}><i className="fas fa-chevron-right"></i></button>
|
||||||
<button className="btn btn-sm" onClick={() => setFecha(new Date().toISOString().split('T')[0])}
|
<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)' }}>
|
style={{ background: 'var(--bg-card)', color: 'var(--text-secondary)', border: '1px solid var(--border)' }}>
|
||||||
<i className="fas fa-calendar-day"></i> Hoy
|
<i className="fas fa-calendar-day"></i> Hoy
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Stats */}
|
||||||
<div className="stats-bar">
|
<div className="stats-bar">
|
||||||
|
|||||||
@@ -308,23 +308,92 @@ body {
|
|||||||
.date-selector {
|
.date-selector {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 0.5rem;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-selector input[type="date"] {
|
/* Mini Calendario */
|
||||||
|
.mini-cal {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
color: var(--text);
|
border-radius: 12px;
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.75rem;
|
||||||
border-radius: 8px;
|
margin-bottom: 1.5rem;
|
||||||
font-size: 1rem;
|
box-shadow: 0 8px 32px rgba(0,0,0,0.25);
|
||||||
font-family: inherit;
|
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 {
|
.mini-cal-header {
|
||||||
outline: none;
|
display: flex;
|
||||||
border-color: var(--primary);
|
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 */
|
/* Stats bar */
|
||||||
|
|||||||
Reference in New Issue
Block a user