/* ============================================================ 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 && ( )} {!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.')}
}
)} {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={
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 ( {rows.map(row => { const s0 = (row.last_standups || [])[0]; const s1 = (row.last_standups || [])[1]; 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')}
{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' }) : '—'}
); } /* ── 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'})} )}
{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}
)} {view === 'ai' && ( <> )}
{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={
} /> {/* 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;