2026-03-11 13:44:44 -06:00
|
|
|
import React, { useState, useEffect } from 'react';
|
|
|
|
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
|
|
|
|
|
|
|
|
|
const MONTH_NAMES = [
|
|
|
|
|
'January', 'February', 'March', 'April', 'May', 'June',
|
|
|
|
|
'July', 'August', 'September', 'October', 'November', 'December'
|
|
|
|
|
];
|
|
|
|
|
const DAY_NAMES = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
|
|
|
|
|
|
|
|
|
function toLocalDateStr(date) {
|
|
|
|
|
const y = date.getFullYear();
|
|
|
|
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
|
|
|
const d = String(date.getDate()).padStart(2, '0');
|
|
|
|
|
return `${y}-${m}-${d}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 14:09:08 -06:00
|
|
|
export default function CalendarWidget({ onDateClick }) {
|
2026-03-11 13:44:44 -06:00
|
|
|
const today = new Date();
|
|
|
|
|
const todayStr = toLocalDateStr(today);
|
|
|
|
|
|
|
|
|
|
const [calYear, setCalYear] = useState(today.getFullYear());
|
|
|
|
|
const [calMonth, setCalMonth] = useState(today.getMonth()); // 0-indexed
|
|
|
|
|
|
|
|
|
|
// Map of "YYYY-MM-DD" → count of findings due that day
|
|
|
|
|
const [dueDates, setDueDates] = useState({});
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' })
|
|
|
|
|
.then((r) => (r.ok ? r.json() : null))
|
|
|
|
|
.then((data) => {
|
|
|
|
|
if (!data?.findings) return;
|
|
|
|
|
const counts = {};
|
|
|
|
|
data.findings.forEach((f) => {
|
|
|
|
|
if (f.dueDate) {
|
|
|
|
|
counts[f.dueDate] = (counts[f.dueDate] || 0) + 1;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
setDueDates(counts);
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const prevMonth = () => {
|
|
|
|
|
if (calMonth === 0) { setCalMonth(11); setCalYear((y) => y - 1); }
|
|
|
|
|
else { setCalMonth((m) => m - 1); }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const nextMonth = () => {
|
|
|
|
|
if (calMonth === 11) { setCalMonth(0); setCalYear((y) => y + 1); }
|
|
|
|
|
else { setCalMonth((m) => m + 1); }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Build cell array: null = padding, number = day of month
|
|
|
|
|
const firstDow = new Date(calYear, calMonth, 1).getDay(); // 0=Sun
|
|
|
|
|
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
|
|
|
|
|
const cells = [
|
|
|
|
|
...Array(firstDow).fill(null),
|
|
|
|
|
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
|
|
|
|
];
|
|
|
|
|
while (cells.length % 7 !== 0) cells.push(null); // complete last row
|
|
|
|
|
|
|
|
|
|
const hasDueDatesThisMonth = cells.some((day) => {
|
|
|
|
|
if (!day) return false;
|
|
|
|
|
const ds = `${calYear}-${String(calMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
|
|
|
return !!dueDates[ds];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
{/* Month navigation */}
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.75rem' }}>
|
|
|
|
|
<button
|
|
|
|
|
onClick={prevMonth}
|
|
|
|
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748B', padding: '2px 4px', borderRadius: '4px', lineHeight: 1, transition: 'color 0.15s' }}
|
|
|
|
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#0EA5E9'; }}
|
|
|
|
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#64748B'; }}
|
|
|
|
|
>
|
|
|
|
|
<ChevronLeft style={{ width: '14px', height: '14px' }} />
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<span style={{ color: '#E2E8F0', fontFamily: 'monospace', fontWeight: '600', fontSize: '0.85rem' }}>
|
|
|
|
|
{MONTH_NAMES[calMonth]} {calYear}
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
onClick={nextMonth}
|
|
|
|
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748B', padding: '2px 4px', borderRadius: '4px', lineHeight: 1, transition: 'color 0.15s' }}
|
|
|
|
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#0EA5E9'; }}
|
|
|
|
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#64748B'; }}
|
|
|
|
|
>
|
|
|
|
|
<ChevronRight style={{ width: '14px', height: '14px' }} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Day-of-week headers */}
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px', textAlign: 'center', marginBottom: '4px' }}>
|
|
|
|
|
{DAY_NAMES.map((d) => (
|
|
|
|
|
<div key={d} style={{ fontSize: '0.6rem', color: '#475569', fontFamily: 'monospace', fontWeight: '600', textTransform: 'uppercase' }}>
|
|
|
|
|
{d}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Day cells */}
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px' }}>
|
|
|
|
|
{cells.map((day, idx) => {
|
|
|
|
|
if (!day) return <div key={idx} />;
|
|
|
|
|
|
|
|
|
|
const dateStr = `${calYear}-${String(calMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
|
|
|
const isToday = dateStr === todayStr;
|
|
|
|
|
const dueCount = dueDates[dateStr] || 0;
|
|
|
|
|
const hasDue = dueCount > 0;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={idx}
|
2026-03-11 14:09:08 -06:00
|
|
|
title={hasDue ? `${dueCount} finding${dueCount > 1 ? 's' : ''} due — click to view` : undefined}
|
|
|
|
|
onClick={hasDue && onDateClick ? () => onDateClick(dateStr) : undefined}
|
2026-03-11 13:44:44 -06:00
|
|
|
style={{
|
|
|
|
|
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
|
|
|
|
gap: '2px', padding: '3px 1px',
|
|
|
|
|
borderRadius: '4px',
|
|
|
|
|
background: isToday ? 'rgba(14,165,233,0.2)' : 'transparent',
|
|
|
|
|
border: isToday ? '1px solid rgba(14,165,233,0.5)' : '1px solid transparent',
|
2026-03-11 14:09:08 -06:00
|
|
|
cursor: hasDue ? 'pointer' : 'default',
|
|
|
|
|
transition: hasDue ? 'background 0.15s' : undefined,
|
2026-03-11 13:44:44 -06:00
|
|
|
}}
|
2026-03-11 14:09:08 -06:00
|
|
|
onMouseEnter={hasDue ? (e) => { e.currentTarget.style.background = isToday ? 'rgba(14,165,233,0.35)' : 'rgba(239,68,68,0.15)'; } : undefined}
|
|
|
|
|
onMouseLeave={hasDue ? (e) => { e.currentTarget.style.background = isToday ? 'rgba(14,165,233,0.2)' : 'transparent'; } : undefined}
|
2026-03-11 13:44:44 -06:00
|
|
|
>
|
|
|
|
|
<span style={{
|
|
|
|
|
fontSize: '0.7rem', fontFamily: 'monospace', lineHeight: 1,
|
|
|
|
|
color: isToday ? '#0EA5E9' : hasDue ? '#EF4444' : '#CBD5E1',
|
|
|
|
|
fontWeight: (isToday || hasDue) ? '700' : '400',
|
|
|
|
|
}}>
|
|
|
|
|
{day}
|
|
|
|
|
</span>
|
|
|
|
|
{/* Red dot indicator for due dates */}
|
|
|
|
|
{hasDue ? (
|
|
|
|
|
<div style={{
|
|
|
|
|
width: '4px', height: '4px', borderRadius: '50%',
|
|
|
|
|
background: '#EF4444',
|
|
|
|
|
boxShadow: '0 0 4px rgba(239,68,68,0.6)',
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
}} />
|
|
|
|
|
) : (
|
|
|
|
|
<div style={{ width: '4px', height: '4px' }} /> // spacer to keep rows even
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Legend — only shown when there are due dates this month */}
|
|
|
|
|
{hasDueDatesThisMonth && (
|
|
|
|
|
<div style={{ marginTop: '0.75rem', paddingTop: '0.625rem', borderTop: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
|
|
|
|
<div style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#EF4444', boxShadow: '0 0 4px rgba(239,68,68,0.5)', flexShrink: 0 }} />
|
|
|
|
|
<span style={{ fontSize: '0.62rem', color: '#64748B', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
|
|
|
Ivanti finding due
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|