/* ============================================================
page-monthly.jsx — Summary: heatmap + scoreboard + progress + violations
============================================================ */
/* Status color mapping for heatmap cells */
function hmColor(status) {
if (status === 'present') return 'var(--ok)';
if (status === 'late') return 'var(--warn)';
if (status === 'absent') return 'var(--err)';
if (status === 'pto') return 'var(--brand)';
if (status === 'sick') return '#a855f7';
if (status === 'off_day' || status === 'weekend') return 'var(--bg-elev-2)';
return 'var(--bg-elev-3)'; // no-data
}
function Lgnd({ c, l, outline }) {
return (
{l}
);
}
/* ── Heatmap section ── */
/* ── Calendar Edit modal (Admin Edit Phase 3) ──
One inline overlay reused for: (1) "what would you like to do" picker,
(2) add company holiday, (3) add PTO for user, (4) remove holiday.
*/
function CalendarEditModal({ open, ctx, onClose, onSaved, toast, meRole, users }) {
const [mode, setMode] = useState('pick'); // 'pick' | 'holiday' | 'pto' | 'remove'
const [name, setName] = useState('');
const [userId, setUserId] = useState('');
const [ptoType, setPtoType] = useState('vacation');
const [endDate, setEndDate] = useState('');
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!open) return;
setBusy(false);
setName('');
setReason('');
setEndDate(ctx?.date || '');
setPtoType('vacation');
if (ctx?.userId) setUserId(String(ctx.userId));
// Auto-route: if cell already has a holiday, jump to remove confirm.
if (ctx?.holiday) setMode('remove');
else if (ctx?.userId) setMode('pto');
else setMode('pick');
}, [open, ctx]);
if (!open) return null;
const isAdmin = (meRole || '').toLowerCase() === 'admin';
const date = ctx?.date || '';
const close = () => { if (!busy) onClose(); };
const showUndo = (token, label) => {
if (token && typeof window.showUndoToast === 'function') {
window.showUndoToast({ token, label, onUndone: onSaved });
}
};
const submitHoliday = async () => {
if (!name.trim()) { toast.push({ msg: (window.appT||(s=>s))('Holiday name required'), kind: 'err' }); return; }
setBusy(true);
try {
const r = await fetch('/api/company-holidays', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date, name: name.trim() }),
});
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.detail || `HTTP ${r.status}`);
toast.push({ msg: (window.appLang&&window.appLang()==='ar') ? `تمت إضافة العطلة "${name.trim()}" في ${date}` : `Holiday "${name.trim()}" added on ${date}`, kind: 'ok' });
showUndo(j.undo_token, (window.appLang&&window.appLang()==='ar') ? `تمت إضافة عطلة في ${date}` : `Holiday added on ${date}`);
onSaved();
onClose();
} catch(e) { toast.push({ msg: (window.appT||(s=>s))('Add holiday failed: ') + e.message, kind: 'err' }); }
finally { setBusy(false); }
};
const submitRemoveHoliday = async () => {
if (!ctx?.holiday?.id) return;
setBusy(true);
try {
const r = await fetch(`/api/company-holidays/${ctx.holiday.id}`, {
method: 'DELETE', credentials: 'same-origin',
});
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.detail || `HTTP ${r.status}`);
toast.push({ msg: (window.appT||(s=>s))('Holiday removed'), kind: 'ok' });
showUndo(j.undo_token, (window.appLang&&window.appLang()==='ar') ? `تمت إزالة العطلة من ${date}` : `Holiday removed from ${date}`);
onSaved();
onClose();
} catch(e) { toast.push({ msg: (window.appT||(s=>s))('Remove holiday failed: ') + e.message, kind: 'err' }); }
finally { setBusy(false); }
};
const submitPto = async () => {
if (!userId) { toast.push({ msg: (window.appT||(s=>s))('Pick a user'), kind: 'err' }); return; }
if (!endDate || endDate < date) { toast.push({ msg: (window.appT||(s=>s))('End date must be ≥ start'), kind: 'err' }); return; }
setBusy(true);
try {
const r = await fetch('/api/pto', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: parseInt(userId, 10),
pto_type: ptoType,
start_date: date,
end_date: endDate,
reason: reason.trim() || null,
approved: true,
}),
});
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.detail || `HTTP ${r.status}`);
toast.push({ msg: (window.appLang&&window.appLang()==='ar') ? `تمت إضافة إجازة للمستخدم ${userId}` : `PTO added for user ${userId}`, kind: 'ok' });
showUndo(j.undo_token, (window.appLang&&window.appLang()==='ar') ? `تمت إضافة إجازة (${ptoType}) في ${date}` : `PTO (${ptoType}) added on ${date}`);
onSaved();
onClose();
} catch(e) { toast.push({ msg: (window.appT||(s=>s))('Add PTO failed: ') + e.message, kind: 'err' }); }
finally { setBusy(false); }
};
const overlay = {
position:'fixed', inset:0, background:'rgba(0,0,0,.55)', zIndex:9000,
display:'flex', alignItems:'center', justifyContent:'center', padding:20,
};
const card = {
background:'var(--bg-elev-1)', border:'1px solid var(--line)', borderRadius:'var(--r-md)',
padding:18, minWidth:340, maxWidth:480, color:'var(--text)',
boxShadow:'0 8px 32px rgba(0,0,0,.4)',
};
const inputStyle = { width:'100%', padding:'6px 8px', background:'var(--bg-elev-2)', color:'var(--text)', border:'1px solid var(--line)', borderRadius:4, fontSize:13 };
const btn = (kind) => ({ padding:'6px 12px', borderRadius:4, border:'1px solid var(--line)', cursor:'pointer', fontSize:12, fontWeight:600, background: kind==='primary' ? 'var(--brand)' : 'transparent', color: kind==='primary' ? '#fff' : 'var(--text)' });
return (
e.stopPropagation()}>
{mode === 'remove' ? (window.appT||(s=>s))('Remove holiday') : mode === 'holiday' ? (window.appT||(s=>s))('Add company holiday') : mode === 'pto' ? (window.appT||(s=>s))('Add PTO') : (window.appT||(s=>s))('Edit calendar')}
{date}
{mode === 'pick' && (
{isAdmin && (
setMode('holiday')}>{(window.appT||(s=>s))('Add company holiday')}
)}
setMode('pto')}>{(window.appT||(s=>s))('Add PTO for a user…')}
{!isAdmin &&
{(window.appT||(s=>s))('Only admins can add company holidays.')}
}
)}
{mode === 'holiday' && (
)}
{mode === 'remove' && (
{(window.appT||(s=>s))('Remove holiday')} {ctx?.holiday?.name} {(window.appT||(s=>s))('on')} {date} ?
{!isAdmin &&
{(window.appT||(s=>s))('Only admins can remove company holidays.')}
}
{(window.appT||(s=>s))('Cancel')}
{busy ? (window.appT||(s=>s))('Removing…') : (window.appT||(s=>s))('Remove')}
)}
{mode === 'pto' && (
)}
);
}
function SummaryHeatmap({ month, search }) {
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(false);
const [holidays, setHolidays] = useState([]); // [{id, date, name}]
const [users, setUsers] = useState([]);
const [me, setMe] = useState(null);
const [editCtx, setEditCtx] = useState(null); // {date, userId?, holiday?} | null
const [span, setSpan] = useState(1); // #23: 1 / 3 / 6 / 12-month window
const editMode = (typeof window.useEditMode === 'function') ? window.useEditMode() : false;
const toast = useToast();
const fetchData = async (m) => {
setLoading(true);
try {
const r = await fetch(`/api/summary/heatmap?month=${m}&months=${span}`, { credentials: 'same-origin' });
if (!r.ok) throw new Error(`${r.status}`);
const data = await r.json();
setRows(Array.isArray(data) ? data : []);
} catch(e) {
toast.push({ msg: (window.appT||(s=>s))('Heatmap fetch failed: ') + e.message, kind: 'err' });
setRows([]);
} finally {
setLoading(false);
}
};
const fetchHolidays = async (m) => {
try {
const [y, mo] = m.split('-').map(Number);
const last = new Date(y, mo, 0).getDate();
const from = `${m}-01`;
const to = `${m}-${String(last).padStart(2, '0')}`;
const r = await fetch(`/api/company-holidays?from=${from}&to=${to}`, { credentials: 'same-origin' });
if (!r.ok) { setHolidays([]); return; }
const data = await r.json();
setHolidays(Array.isArray(data) ? data : []);
} catch { setHolidays([]); }
};
const fetchUsers = async () => {
try {
const r = await fetch('/api/employees', { credentials: 'same-origin' });
if (!r.ok) return;
const data = await r.json();
setUsers(Array.isArray(data) ? data : []);
} catch {}
};
const fetchMe = async () => {
try {
const r = await fetch('/api/auth/me', { credentials: 'same-origin' });
if (r.ok) setMe(await r.json());
} catch {}
};
useEffect(() => { fetchData(month); fetchHolidays(month); }, [month, span]);
useEffect(() => { fetchUsers(); fetchMe(); }, []);
const holidayByDate = {};
for (const h of holidays) holidayByDate[h.date] = h;
const handleCellClick = (cellDate, userId) => {
if (!editMode) return;
const ho = holidayByDate[cellDate] || null;
setEditCtx({ date: cellDate, userId, holiday: ho });
};
const filteredRows = search
? rows.filter(r => (r.display_name || '').toLowerCase().includes(search.toLowerCase()))
: rows;
if (loading) {
return {(window.appT||(s=>s))('Loading heatmap…')}
;
}
if (!filteredRows.length) {
return } title={(window.appT||(s=>s))('No heatmap data')} subtitle={search ? (window.appT||(s=>s))('No matching employees.') : (window.appT||(s=>s))('No attendance data for this month.')} />;
}
// Per-user calendar grid. For each user, build a 7-col × N-row month layout.
// Days before 1st (filler) align by weekday-of-first-day.
const DOW = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const multi = span > 1; // multi-month: compact cells, hide day numbers
const cellH = multi ? 13 : 30;
return (
s))('Attendance calendar — per user')}
tip={(window.appT||(s=>s))('Heatmap of every employee × every day over the selected window (1/3/6/12 months). Color encodes status: green on-time, yellow late, red absent, blue PTO, hatched holiday, grey off-day.')}
sub={(window.appLang&&window.appLang()==='ar') ? `${span === 1 ? month : `${span} أشهر تنتهي في ${month}`} · اختر نافذة لاكتشاف الأنماط طويلة المدى` : `${span === 1 ? month : `${span} months ending ${month}`} · pick a window to spot longer-term patterns`}
right={
setSpan(Number(e.target.value))}
title={(window.appT||(s=>s))('Heatmap window')}
style={{ fontSize: 11.5, padding: '2px 6px' }}>
{(window.appT||(s=>s))('1 month')}
{(window.appT||(s=>s))('3 months')}
{(window.appT||(s=>s))('6 months')}
{(window.appT||(s=>s))('1 year')}
s))('present')} />
s))('late')} />
s))('absent')} />
s))('pto')} />
s))('sick')} />
s))('off-day')} outline />
}
/>
{filteredRows.map(row => {
const totalHrs = row.days.reduce((s, d) => s + (d.hours || 0), 0);
const present = row.days.filter(d => d.status === 'present').length;
const late = row.days.filter(d => d.status === 'late').length;
const absent = row.days.filter(d => d.status === 'absent').length;
const firstDate = row.days[0]?.date;
const firstDow = firstDate ? new Date(firstDate + 'T00:00:00').getDay() : 0;
const cells = [];
for (let i = 0; i < firstDow; i++) cells.push(null);
for (const d of row.days) cells.push(d);
// pad to multiple of 7
while (cells.length % 7 !== 0) cells.push(null);
return (
{row.display_name}
{totalHrs.toFixed(0)}h
{DOW.map(d => (
{d.charAt(0)}
))}
{cells.map((d, i) => {
if (!d) return
;
const exceptions = (d.exceptions || []).join(', ');
const dayNum = parseInt(d.date.slice(8), 10);
const ho = holidayByDate[d.date];
const tip = `${d.date} · ${d.status}${d.hours ? ` · ${d.hours.toFixed(1)}h` : ''}${exceptions ? ` · ${exceptions}` : ''}${ho ? ` · 🎌 ${ho.name}` : ''}${editMode ? ((window.appLang&&window.appLang()==='ar') ? ' · انقر للتعديل' : ' · click to edit') : ''}`;
const bg = hmColor(d.status);
const isOff = d.status === 'off_day' || d.status === 'weekend';
const isAbsent = d.status === 'absent';
return (
handleCellClick(d.date, row.user_id) : undefined}
style={{
height: cellH, borderRadius: 4,
background: bg, opacity: isOff ? 0.35 : 1,
display:'flex', alignItems:'center', justifyContent:'center',
fontSize: 10, fontFamily:'var(--font-mono)',
color: isAbsent ? '#fff' : isOff ? 'var(--text-3)' : '#fff',
fontWeight: isAbsent ? 600 : 500,
cursor: editMode ? 'pointer' : 'help',
outline: editMode ? '1px dashed var(--brand)' : 'none',
outlineOffset: editMode ? -1 : 0,
position: 'relative',
}}>
{multi ? '' : dayNum}
{ho && !multi && (
🎌
)}
);
})}
● {present}p
● {late}L
● {absent}a
);
})}
setEditCtx(null)}
onSaved={() => { fetchHolidays(month); fetchData(month); }}
toast={toast}
meRole={me?.org_role}
users={users}
/>
);
}
/* ── Scoreboard section ── */
function SummaryScoreboard({ period, search, refreshTick }) {
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(false);
const toast = useToast();
const fetchData = async () => {
setLoading(true);
try {
const params = new URLSearchParams({ period });
if (search) params.set('search', search);
const r = await fetch(`/api/summary/scoreboard?${params}`, { credentials: 'same-origin' });
if (!r.ok) throw new Error(`${r.status}`);
const data = await r.json();
setRows(Array.isArray(data) ? data : []);
} catch(e) {
toast.push({ msg: (window.appT||(s=>s))('Scoreboard fetch failed: ') + e.message, kind: 'err' });
setRows([]);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchData(); }, [period, search, refreshTick]);
const { sorted, sort, click } = useSortable(rows, { key: 'total_hours', dir: 'desc' });
if (loading) return {(window.appT||(s=>s))('Loading scoreboard…')}
;
if (!sorted.length) return } title={(window.appT||(s=>s))('No scoreboard data')} subtitle={(window.appT||(s=>s))('No activity in this period.')} />;
return (
s))('Person')} />
s))('Present')} align="right" />
s))('Late')} align="right" />
s))('Absent')} align="right" />
s))('Hours')} align="right" />
s))('Strikes')} align="right" />
s))('Compliance')} align="right" />
{sorted.map(row => (
{row.display_name}
{row.team && {row.team}
}
{row.days_present}
0 ? 'var(--warn)' : undefined }}>{row.days_late}
0 ? 'var(--err)' : undefined }}>{row.days_absent}
{(row.total_hours || 0).toFixed(1)}h
2 ? 'var(--err)' : row.violations > 0 ? 'var(--warn)' : undefined }}>
{row.violations}
= 90 ? 'var(--ok)' : (row.compliance_pct || 0) >= 70 ? 'var(--warn)' : 'var(--err)' }}>
{(row.compliance_pct || 0).toFixed(0)}%
))}
);
}
/* ── Made progress section ── */
function SummaryProgress({ period, search, refreshTick }) {
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(false);
const [notifying, setNotifying] = useState({});
const toast = useToast();
const fetchData = async () => {
setLoading(true);
try {
// Progress endpoint only accepts week|month (not 6_months)
const safePeriod = period === '6_months' ? 'month' : period;
const params = new URLSearchParams({ period: safePeriod });
if (search) params.set('search', search);
const r = await fetch(`/api/summary/progress?${params}`, { credentials: 'same-origin' });
if (!r.ok) throw new Error(`${r.status}`);
const data = await r.json();
setRows(Array.isArray(data) ? data : []);
} catch(e) {
toast.push({ msg: (window.appT||(s=>s))('Progress fetch failed: ') + e.message, kind: 'err' });
setRows([]);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchData(); }, [period, search, refreshTick]);
const notifyAdmin = async (userId) => {
setNotifying(n => ({ ...n, [userId]: true }));
try {
const r = await fetch(`/api/summary/no-progress/notify-admin?user_id=${userId}`, {
method: 'POST',
credentials: 'same-origin',
});
if (!r.ok) throw new Error(`${r.status}`);
toast.push({ msg: (window.appT||(s=>s))('Admin notified'), kind: 'ok' });
} catch(e) {
toast.push({ msg: (window.appT||(s=>s))('Notify failed: ') + e.message, kind: 'err' });
} finally {
setNotifying(n => ({ ...n, [userId]: false }));
}
};
if (loading) return {(window.appT||(s=>s))('Loading…')}
;
if (!rows.length) return } title={(window.appT||(s=>s))('No no-progress flags')} subtitle={(window.appT||(s=>s))('All employees have made progress in this period.')} />;
// Empty state — never leave the section blank. If the team is hitting
// every standup with substantive progress (no scores <2), show a friendly
// confirmation panel instead of an empty table.
if (!loading && rows.length === 0) {
return (
✓
{(window.appT||(s=>s))('No no-progress flags for this period')}
{(window.appT||(s=>s))('Every signed-in standup in the selected window scored progress ≥ 2.')}
);
}
return (
{(window.appT||(s=>s))('Person')}
{(window.appT||(s=>s))('Most recent attendance')}
{(window.appT||(s=>s))('Previous attendance')}
{(window.appT||(s=>s))('Sign-in range')}
{(window.appT||(s=>s))('Action')}
{rows.map(row => {
const s0 = (row.last_standups || [])[0];
const s1 = (row.last_standups || [])[1];
return (
{row.display_name}
{row.team && {(window.appLang&&window.appLang()==='ar') ? `${row.team} · ${row.no_progress_days} يوم بتقدم منخفض` : `${row.team} · ${row.no_progress_days} low-progress day${row.no_progress_days !== 1 ? 's' : ''}`}
}
{s0 ? <>{s0.date} {s0.today_text || — }> : — }
{s1 ? <>{s1.date} {s1.today_text || — }> : — }
{(window.appT||(s=>s))('Latest:')} {row.most_recent_signin ? new Date(row.most_recent_signin).toLocaleString([], { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }) : '—'}
{(window.appT||(s=>s))('Oldest:')} {row.least_recent_signin ? new Date(row.least_recent_signin).toLocaleString([], { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }) : '—'}
notifyAdmin(row.user_id)}
>
{notifying[row.user_id] ? (window.appT||(s=>s))('Sending…') : (window.appT||(s=>s))('Notify admin')}
);
})}
);
}
/* ── Tiny safe markdown renderer for the AI accomplishment summaries.
Handles **bold**, *italic*, _italic_, ### / ## / # headings, - bullets,
and line breaks. Escapes HTML first to avoid injection. ── */
function _renderMd(md) {
if (!md) return '';
const esc = String(md)
.replace(/&/g, '&').replace(//g, '>');
const lines = esc.split(/\r?\n/);
const out = [];
let inList = false;
for (const raw of lines) {
const line = raw;
if (/^### /.test(line)) {
if (inList) { out.push(''); inList = false; }
out.push('' + line.replace(/^### /, '') + ' ');
} else if (/^## /.test(line)) {
if (inList) { out.push(''); inList = false; }
out.push('' + line.replace(/^## /, '') + ' ');
} else if (/^# /.test(line)) {
if (inList) { out.push(''); inList = false; }
out.push('' + line.replace(/^# /, '') + ' ');
} else if (/^[-*] /.test(line)) {
if (!inList) { out.push(''); inList = true; }
out.push('' + line.replace(/^[-*] /, '') + ' ');
} else if (line.trim() === '') {
if (inList) { out.push(' '); inList = false; }
out.push(' ');
} else {
if (inList) { out.push(''); inList = false; }
out.push('' + line + '
');
}
}
if (inList) out.push('');
let html = out.join('');
html = html.replace(/\*\*(.+?)\*\*/g, '$1 ');
html = html.replace(/(?$1');
html = html.replace(/(?$1');
return html;
}
/* ── Single AI accomplishment card with "Send to user" button ── */
function AccomplishmentAiCard({ u, days }) {
const [sending, setSending] = useState(false);
const [sentAt, setSentAt] = useState(null);
const toast = useToast();
const sendToUser = async () => {
if (!confirm((window.appLang&&window.appLang()==='ar') ? `إرسال هذا الملخص بالذكاء الاصطناعي كرسالة مباشرة عبر Matrix إلى ${u.display_name}؟` : `Send this AI summary as a Matrix DM to ${u.display_name}?`)) return;
setSending(true);
try {
const r = await fetch(`/api/reports/accomplishments-ai/send-to-user/${u.user_id}?days=${days}`, {
method: 'POST', credentials: 'same-origin',
});
if (!r.ok) {
const body = await r.json().catch(() => null);
const detail = body?.detail || `${r.status}`;
throw new Error(typeof detail === 'string' ? detail : JSON.stringify(detail));
}
setSentAt(new Date());
toast.push({ msg: (window.appLang&&window.appLang()==='ar') ? `تم إرسال رسالة مباشرة إلى ${u.display_name}` : `DM sent to ${u.display_name}`, kind: 'ok' });
} catch(e) {
toast.push({ msg: (window.appT||(s=>s))('Send failed: ') + e.message, kind: 'err' });
} finally {
setSending(false);
}
};
return (
{u.display_name}
{(window.appLang&&window.appLang()==='ar') ? `${u.by_day.length} يوم · ${u.by_day.filter(d=>d.kinds && d.kinds.includes('signin')).length} تسجيل دخول` : `${u.by_day.length} day${u.by_day.length===1?'':'s'} · ${u.by_day.filter(d=>d.kinds && d.kinds.includes('signin')).length} sign-in(s)`}
{sentAt && (
✓ {(window.appT||(s=>s))('sent')} {sentAt.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}
)}
s))('No sign-in activity to send') : ((window.appLang&&window.appLang()==='ar') ? `إرسال الملخص بالذكاء الاصطناعي كرسالة مباشرة إلى ${u.display_name}` : `Send AI summary as DM to ${u.display_name}`)}
style={{ fontSize: 12 }}>
{sending ? (window.appT||(s=>s))('Sending…') : (window.appT||(s=>s))('Send to user')}
{u.by_day.length > 0 && (
{(window.appLang&&window.appLang()==='ar') ? `عرض التفصيل اليومي الخام (${u.by_day.length})` : `View raw daily breakdown (${u.by_day.length})`}
{u.by_day.map(d => (
{d.date}
{d.today &&
{(window.appT||(s=>s))('Today:')} {d.today}
}
{d.yesterday &&
{(window.appT||(s=>s))('Yesterday:')} {d.yesterday}
}
{d.blocker &&
{(window.appT||(s=>s))('Blocker:')} {d.blocker}
}
))}
)}
);
}
/* ── Daily accomplishments section (item #6/#9) — Daily + AI multi-day ── */
function SummaryAccomplishments({ search, refreshTick }) {
const [data, setData] = useState({users: [], date: ''});
const [aiData, setAiData] = useState({users: [], range_start: '', range_end: ''});
const [loading, setLoading] = useState(false);
const [aiLoading, setAiLoading] = useState(false);
const [sending, setSending] = useState(false);
const [day, setDay] = useState(() => new Date().toISOString().slice(0,10));
const [aiDays, setAiDays] = useState(7);
const [view, setView] = useState('daily'); // daily | ai
const [me, setMe] = useState(null);
const toast = useToast();
const editMode = (typeof window.useEditMode === 'function') ? window.useEditMode() : false;
const _EditableCell = window.EditableCell;
useEffect(() => {
let alive = true;
fetch('/api/auth/me', { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : null)
.then(j => { if (alive) setMe(j); })
.catch(() => {});
return () => { alive = false; };
}, []);
const _patchHours = (u) => async (newValue) => {
const trimmed = (newValue == null ? '' : String(newValue)).trim();
let hours;
if (trimmed === '' || trimmed.toLowerCase() === 'null' || trimmed === '—') {
hours = null;
} else {
const n = parseFloat(trimmed.replace(/h$/i, ''));
if (isNaN(n) || n < 0 || n > 24) {
toast.push({ msg: (window.appT||(s=>s))('Hours must be 0-24, or empty to clear'), kind: 'err' });
throw new Error('bad input');
}
hours = n;
}
const r = await fetch(
`/api/daily-attendance/${u.user_id}/${day}/hours`,
{
method: 'PATCH',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hours }),
}
);
if (!r.ok) {
const txt = await r.text();
toast.push({ msg: `Edit failed: ${txt}`, kind: 'err' });
throw new Error(`HTTP ${r.status}`);
}
const j = await r.json();
if (j.undo_token && typeof window.showUndoToast === 'function') {
window.showUndoToast({
token: j.undo_token,
label: (window.appLang&&window.appLang()==='ar') ? `${u.display_name}: تم ${hours == null ? 'مسح' : 'تجاوز'} الساعات.` : `${u.display_name}: hours ${hours == null ? 'cleared' : 'overridden'}.`,
onUndone: fetchData,
});
}
fetchData();
};
const _canEditU = (u) => {
if (!_EditableCell || !me) return false;
const role = (me.org_role || '').toLowerCase();
if (role === 'admin') return true;
if (role === 'manager' && (me.team || '') === (u.team || '')) return true;
return false;
};
const fetchData = async () => {
setLoading(true);
try {
const r = await fetch(`/api/reports/daily-accomplishments?on=${day}`, { credentials:'same-origin' });
if (!r.ok) throw new Error(`${r.status}`);
const j = await r.json();
setData(j);
} catch(e) {
toast.push({ msg: (window.appT||(s=>s))('Accomplishments fetch failed: ') + e.message, kind: 'err' });
setData({users: [], date: day});
} finally { setLoading(false); }
};
const fetchAi = async () => {
setAiLoading(true);
try {
const r = await fetch(`/api/reports/accomplishments-ai?days=${aiDays}`, { credentials:'same-origin' });
if (!r.ok) throw new Error(`${r.status}`);
const j = await r.json();
setAiData(j);
toast.push({ msg: (window.appLang&&window.appLang()==='ar') ? `تم إنشاء ملخص بالذكاء الاصطناعي لـ ${j.users.length} مستخدم` : `AI summary generated for ${j.users.length} users`, kind: 'ok' });
} catch(e) {
toast.push({ msg: (window.appT||(s=>s))('AI summary failed: ') + e.message, kind: 'err' });
} finally { setAiLoading(false); }
};
const sendDigest = async () => {
setSending(true);
try {
const r = await fetch(`/api/reports/daily-accomplishments/send?on=${day}`, {
method: 'POST', credentials: 'same-origin',
});
if (!r.ok) {
const body = await r.json().catch(() => null);
throw new Error(body?.detail || `${r.status}`);
}
const j = await r.json();
toast.push({ msg: (window.appLang&&window.appLang()==='ar') ? `تم إرسال الملخص كرسالة مباشرة إلى ${j.sent_to} مشرف` : `Digest DMed to ${j.sent_to} admin(s)`, kind: 'ok' });
} catch(e) {
toast.push({ msg: (window.appT||(s=>s))('Send failed: ') + e.message, kind: 'err' });
} finally { setSending(false); }
};
useEffect(() => { fetchData(); }, [day, refreshTick]);
const filtered = search
? data.users.filter(u => (u.display_name || '').toLowerCase().includes(search.toLowerCase()))
: data.users;
if (loading) return {(window.appT||(s=>s))('Loading…')}
;
const made = filtered.filter(u => u.progress_score === 2).length;
const partial = filtered.filter(u => u.progress_score === 1).length;
const none = filtered.filter(u => u.progress_score === 0).length;
const blocked = filtered.filter(u => u.had_blocker).length;
const noSignin = filtered.filter(u => !u.signed_in).length;
const flagPill = (u) => {
if (u.progress_score === 2) return {(window.appT||(s=>s))('made progress')} ;
if (u.progress_score === 1) return {(window.appT||(s=>s))('partial')} ;
if (u.progress_score === 0) return {(window.appT||(s=>s))('no progress')} ;
return {(window.appT||(s=>s))('unscored')} ;
};
return (
s))('Daily') },
{ value:'ai', label:(window.appT||(s=>s))('AI summary (multi-day)') },
]} />
{view === 'daily' && (
<>
setDay(e.target.value)} style={{ width: 150, fontSize: 12, padding:'4px 8px' }} />
{(window.appT||(s=>s))('Made')}
{made}
{(window.appT||(s=>s))('Partial')}
{partial}
{(window.appT||(s=>s))('No progress')}
{none}
{(window.appT||(s=>s))('Blocked')}
{blocked}
{(window.appT||(s=>s))('No sign-in')}
{noSignin}
{sending ? (window.appT||(s=>s))('Sending…') : (window.appT||(s=>s))('DM digest to admins')}
>
)}
{view === 'ai' && (
<>
setAiDays(parseInt(e.target.value))}
style={{ width: 110, fontSize: 12, padding:'4px 8px' }}>
{(window.appT||(s=>s))('Today')}
{(window.appT||(s=>s))('Last 3 days')}
{(window.appT||(s=>s))('Last 7 days')}
{(window.appT||(s=>s))('Last 14 days')}
{(window.appT||(s=>s))('Last 30 days')}
{aiLoading ? (window.appT||(s=>s))('Generating…') : (window.appT||(s=>s))('Generate AI summary')}
>
)}
{view === 'ai' ? (
aiData.users.length === 0 && !aiLoading ? (
} title={(window.appT||(s=>s))('No AI summary yet')} subtitle={(window.appT||(s=>s))("Click 'Generate AI summary' to compile sign-in messages into a narrative.")} />
) : (
{(window.appT||(s=>s))('Range:')} {aiData.range_start} → {aiData.range_end}
{aiData.users.length > 0 && ((window.appLang&&window.appLang()==='ar') ? ` · ${aiData.users.length} موظف` : ` · ${aiData.users.length} employee(s)`)}
{(search ? aiData.users.filter(u => (u.display_name || '').toLowerCase().includes(search.toLowerCase())) : aiData.users).map(u => (
))}
)
) : filtered.length === 0 ? (
} title={(window.appT||(s=>s))('No data')} subtitle={(window.appT||(s=>s))('No employees have signed in for this date yet.')} />
) : (
{filtered.map(u => (
{u.display_name}
{flagPill(u)}
{u.had_blocker && {(window.appT||(s=>s))('blocker')} }
{u.was_late && {(window.appT||(s=>s))('late')} }
{!u.signed_in && {(window.appT||(s=>s))('no sign-in')} }
{u.signed_in && (editMode && _canEditU(u) ? (
<_EditableCell
kind="text"
value={u.hours_worked != null ? u.hours_worked.toFixed(1) : ''}
formatter={() => `${(u.hours_worked || 0).toFixed(1)}h`}
onSave={_patchHours(u)}
placeholder="0.0"
/>
) : (
{u.hours_worked.toFixed(1)}h
))}
{u.last_signin_at && {(window.appLang&&window.appLang()==='ar') ? `آخر تسجيل دخول ${new Date(u.last_signin_at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}` : `last sign-in ${new Date(u.last_signin_at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}`} }
{u.today_text && (
{(window.appT||(s=>s))('Today: ')} {u.today_text}
)}
{u.yesterday_text && (
{(window.appT||(s=>s))('Yesterday: ')} {u.yesterday_text}
)}
{u.had_blocker && u.blocker_text && (
{(window.appT||(s=>s))('Blocker: ')} {u.blocker_text}
)}
{u.progress_reasoning && (
{(window.appT||(s=>s))('Bot reasoning:')} {u.progress_reasoning}
)}
))}
)}
);
}
/* ── Violations section ── */
function SummaryViolations({ period, search, refreshTick }) {
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(false);
const toast = useToast();
const fetchData = async () => {
setLoading(true);
try {
const params = new URLSearchParams({ period });
if (search) params.set('search', search);
const r = await fetch(`/api/summary/violations?${params}`, { credentials: 'same-origin' });
if (!r.ok) throw new Error(`${r.status}`);
const data = await r.json();
setRows(Array.isArray(data) ? data : []);
} catch(e) {
toast.push({ msg: (window.appT||(s=>s))('Violations fetch failed: ') + e.message, kind: 'err' });
setRows([]);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchData(); }, [period, search, refreshTick]);
const { sorted, sort, click } = useSortable(rows, { key: 'violation_count', dir: 'desc' });
if (loading) return {(window.appT||(s=>s))('Loading violations…')}
;
if (!sorted.length) return } title={(window.appT||(s=>s))('No violations')} subtitle={(window.appT||(s=>s))('No format violations in this period.')} />;
return (
s))('Person')} />
s))('Total violations')} align="right" />
s))('Strikes left')} align="right" />
s))('Last violation')} align="right" />
{sorted.map(row => (
{row.display_name}
{row.team && {row.team}
}
= 3 ? 'var(--err)' : row.violation_count > 0 ? 'var(--warn)' : undefined }}>
{row.violation_count}
{row.strikes_left ?? '—'}
{row.last_violation
? new Date(row.last_violation).toLocaleDateString([], { month:'short', day:'numeric', year:'numeric' })
: '—'}
))}
);
}
/* ── Main MonthlyPage ── */
function MonthlyPage({ live }) {
const [month, setMonth] = useState(() => new Date().toISOString().slice(0,7));
const [period, setPeriod] = useState('week');
const [search, setSearch] = useState('');
const [refreshTick, setRefreshTick] = useState(0);
const [exporting, setExporting] = useState('');
const toast = useToast();
const handleRefresh = () => {
setRefreshTick(t => t + 1);
try {
window.dispatchEvent(new CustomEvent('status-data-refresh-requested'));
if (typeof window.STATUS_REFRESH === 'function') window.STATUS_REFRESH();
} catch (e) {}
toast.push({ msg: (window.appT||(s=>s))('Refresh complete'), kind: 'ok' });
};
const handleExport = async (fmt) => {
setExporting(fmt);
try {
const r = await fetch(`/api/summary/export?format=${fmt}&period=${period}`, { credentials: 'same-origin' });
if (!r.ok) throw new Error(`${r.status}`);
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `summary-${period}.${fmt}`;
a.click();
URL.revokeObjectURL(url);
toast.push({ msg: (window.appLang&&window.appLang()==='ar') ? `تم تنزيل تصدير ${fmt.toUpperCase()}` : `${fmt.toUpperCase()} export downloaded`, kind: 'ok' });
} catch(e) {
toast.push({ msg: (window.appLang&&window.appLang()==='ar') ? `فشل التصدير: ${e.message}` : `Export failed: ${e.message}`, kind: 'err' });
} finally {
setExporting('');
}
};
return (
s))('Summary')}
meta={(window.appT||(s=>s))('Attendance heatmap, scoreboard, progress, and violations')}
right={
handleExport('xlsx')} disabled={!!exporting}>
{exporting === 'xlsx' ? (window.appT||(s=>s))('Exporting…') : 'XLSX'}
handleExport('pdf')} disabled={!!exporting}>
{exporting === 'pdf' ? (window.appT||(s=>s))('Exporting…') : 'PDF'}
}
/>
{/* Controls row */}
s))('Filter employees…')}
style={{ width: 220 }}
/>
s))('Day') },
{ value:'week', label:(window.appT||(s=>s))('Week') },
{ value:'month', label:(window.appT||(s=>s))('Month') },
{ value:'quarter', label:(window.appT||(s=>s))('Quarter') },
{ value:'6_months', label:(window.appT||(s=>s))('6 months') },
{ value:'year', label:(window.appT||(s=>s))('Year') },
]}
/>
{(window.appT||(s=>s))('Heatmap month:')}
setMonth(e.target.value)}
className="inp"
style={{ width: 140, fontSize: 12, padding:'4px 8px' }}
max={new Date().toISOString().slice(0,7)}
/>
{/* Heatmap */}
{/* Scoreboard */}
s))('Scoreboard')}
tip={(window.appT||(s=>s))('Per-user month summary: hours, days present, late, attendance %. Drives the per-employee monthly DM.')} sub={(window.appLang&&window.appLang()==='ar') ? `الفترة: ${period}` : `Period: ${period}`} />
{/* Daily accomplishments — items #6/#9 */}
s))('Daily accomplishments')}
tip={(window.appT||(s=>s))("AI-summarized 'what got done' per day per user, derived from yesterday/today fields of every sign-in in the month.")} sub={(window.appT||(s=>s))('Per-user summary parsed from sign-in messages, with progress flags + blockers')} />
{/* No-progress flags */}
s))('No-progress flags')}
tip={(window.appT||(s=>s))("Days where the bot's progress score was 0 (today text was vague or copy-pasted from yesterday). Manager attention candidates.")} sub={(window.appT||(s=>s))('Employees whose today-line repeated from yesterday')} />
{/* Violations — split into attendance/format report (item #8) */}
s))('Format violations (attendance report)')}
tip={(window.appT||(s=>s))('Every format-strike event in the month with the original message body. Strikes ≥3 trigger the admin report.')} sub={(window.appLang&&window.appLang()==='ar') ? `الفترة: ${period} · منفصلة عن الإنجازات` : `Period: ${period} · separate from accomplishments`} />
);
}
window.MonthlyPage = MonthlyPage;
window._renderMd = _renderMd;