/* ============================================================ page-reports.jsx — Generate reports + admin digest + hours distribution ============================================================ */ /* Re-bind parseDetail — set by primitives.jsx */ const parseDetail = window.parseDetail || ((body, status) => (body && (body.detail || body.message)) || `HTTP ${status}`); /* i18n helpers — Arabic via window.appT, falls back to identity. File-unique names (_rT/_rAr): other v2 bundles declare their own top-level `_T` in the shared in-browser-Babel global scope, so a bare `const _T` here would throw "already declared" and blank the page. */ const _rT = (s) => (window.appT || (x => x))(s); const _rAr = () => (window.appLang && window.appLang() === 'ar'); /* ── Hours Distribution stacked bar chart (inline SVG) ── */ /* ── Per-user 30-day trends (sparklines + status donut) — #25 ── */ // B7: per-employee violations report with type filter function PerEmployeeViolationsCard({ refreshTick }) { const D = window.STATUS_DATA || {}; const employees = D.EMPLOYEES || []; const [userId, setUserId] = useState(employees[0]?.id || null); const [period, setPeriod] = useState('month'); const [type, setType] = useState('all'); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const load = async () => { if (!userId) return; setLoading(true); try { const r = await fetch( `/api/employees/${userId}/violations?period=${period}&type=${type}`, { credentials: 'include' } ); if (r.ok) setData(await r.json()); } catch (_) {} finally { setLoading(false); } }; useEffect(() => { load(); }, [userId, period, type, refreshTick]); return (
} />
{!data ? (
{loading ? _rT('Loading…') : _rT('Pick a user to see violations.')}
) : ( <>
{Object.entries(data.counts || {}).map(([k, v]) => (
{k.replace(/_/g, ' ')}: 0 ? 'var(--err)' : 'var(--ok)' }}>{v}
))}
{(data.events || []).length === 0 ? ( ) : ( data.events.map((ev, i) => ( )) )}
{_rT("When")}{_rT("Kind")}{_rT("Severity")}{_rT("Detail")}
{_rT("No events for this filter.")}
{ev.when ? new Date(ev.when).toLocaleString() : '—'} {ev.kind} {ev.severity} {ev.detail}
)}
); } function PerUserTrendsCard({ refreshTick }) { const [rows, setRows] = React.useState([]); const [loading, setLoading] = React.useState(true); const reload = React.useCallback(async () => { setLoading(true); try { // Reuse the hours-distribution endpoint (per-user × per-day) const r = await fetch('/api/reports/hours-distribution?period=month', { credentials: 'same-origin' }); if (r.ok) setRows(await r.json()); } catch(_) {} finally { setLoading(false); } }, []); React.useEffect(() => { reload(); }, [reload, refreshTick]); if (loading) return null; if (!rows || rows.length === 0) return null; // For each user: extract daily hours array + compute totals const enriched = rows.map(u => { const days = Object.values(u.hours_by_day || {}); const total = days.reduce((s, h) => s + (h || 0), 0); const present = days.filter(h => (h || 0) >= 1).length; const partial = days.filter(h => (h || 0) > 0 && (h || 0) < 1).length; const absent = days.filter(h => (h || 0) === 0).length; return { ...u, days, total, present, partial, absent }; }); enriched.sort((a, b) => b.total - a.total); // Aggregate status mix across all users const totalPresent = enriched.reduce((s, u) => s + u.present, 0); const totalPartial = enriched.reduce((s, u) => s + u.partial, 0); const totalAbsent = enriched.reduce((s, u) => s + u.absent, 0); const totalAll = totalPresent + totalPartial + totalAbsent || 1; const slices = [ { value: totalPresent, label: _rT('Present'), color: 'var(--ok)' }, { value: totalPartial, label: _rT('Partial'), color: 'var(--warn)' }, { value: totalAbsent, label: _rT('Absent'), color: 'var(--err)' }, ]; // Inline donut (small) const SIZE = 130, R = SIZE/2 - 12, CX = SIZE/2, CY = SIZE/2; const C = 2 * Math.PI * R; let off = 0; return (
{slices.map((s, i) => { if (!s.value) return null; const len = (s.value / totalAll) * C; const node = ( {s.label}: {s.value} {_rT("day-slots")} ); off += len; return node; })} {Math.round((totalPresent / totalAll) * 100)}% {_rT("present")}
{slices.map(s => (
{s.label} {s.value} {totalAll > 0 ? Math.round((s.value/totalAll)*100) : 0}%
))}
{enriched.map(u => (
{u.name} {u.present}p · {u.partial}half · {u.absent}a
{u.total.toFixed(1)}h
))}
); } function HoursDistributionChart({ period, refreshTick }) { // Independent dropdown so admins can scope the chart without touching // the page-wide 'period' that drives the report-generation form. const [localPeriod, setLocalPeriod] = useState(period || 'week'); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const toast = useToast(); const fetchData = async (p) => { setLoading(true); try { const r = await fetch(`/api/reports/hours-distribution?period=${p}`, { credentials: 'same-origin' }); if (!r.ok) throw new Error(`${r.status}`); const raw = await r.json(); setData(Array.isArray(raw) ? raw : []); } catch(e) { toast.push({ msg: (_rAr() ? 'فشل جلب توزيع الساعات: ' : 'Hours distribution fetch failed: ') + e.message, kind: 'err' }); setData([]); } finally { setLoading(false); } }; useEffect(() => { fetchData(localPeriod); }, [localPeriod, refreshTick]); // Render a tiny dropdown above the chart so callers can override the period. const PERIOD_OPTIONS = [ { value: 'day', label: _rT('Day') }, { value: 'week', label: _rT('Week') }, { value: 'month', label: _rT('Month') }, { value: 'quarter', label: _rT('Quarter') }, { value: '6_months', label: _rT('Semi') }, { value: 'annual', label: _rT('Annual') }, ]; const periodControl = (
{_rT("Range:")}
); if (loading) return <>{periodControl}; if (!data.length) return <>{periodControl}} title={_rT("No hours data")} subtitle={_rT("No attendance hours logged for this period.")} />; const total = data.reduce((s, u) => s + (u.total || 0), 0); if (total === 0) return <>{periodControl}} title={_rT("No hours data")} subtitle={_rT("No attendance hours logged for this period.")} />; // Per-user pie of hours-share. Bigger users = bigger slices. const PALETTE = ['#3b82f6','#06b6d4','#8b5cf6','#f59e0b','#10b981','#f43f5e','#a3e635','#ec4899','#14b8a6','#eab308']; const SIZE = 220, R = SIZE/2 - 10, CX = SIZE/2, CY = SIZE/2; const C = 2 * Math.PI * R; let off = 0; const slices = data.map((u, i) => { const v = u.total || 0; if (!v) return null; const len = (v / total) * C; const node = ( {`${u.name}: ${v.toFixed(1)}h (${Math.round(v/total*100)}%)`} ); off += len; return node; }).filter(Boolean); return ( <> {periodControl}
{slices} {total.toFixed(0)}h {_rT("total")}
{/* Sort a COPY for display; color by ORIGINAL index so legend swatches match the pie slices (the pie above maps PALETTE[i] over `data`). The old code sorted `data` in place during render, desyncing the colors and mutating shared state. */} {[...data].sort((a,b) => (b.total||0) - (a.total||0)).map((u, i) => { const v = u.total || 0; const pct = total ? Math.round(v/total*100) : 0; const color = PALETTE[data.indexOf(u) % PALETTE.length]; return (
{u.name} {v.toFixed(1)}h {pct}%
); })}
); } /* ── Admin weekly digest card ── */ function AdminDigestCard({ refreshTick }) { const [digest, setDigest] = useState(null); const [loading, setLoading] = useState(false); const toast = useToast(); const fetchDigest = async () => { setLoading(true); try { const r = await fetch('/api/reports/admin-weekly-digest', { credentials: 'same-origin' }); if (!r.ok) throw new Error(`${r.status}`); const data = await r.json(); setDigest(data); } catch(e) { toast.push({ msg: (_rAr() ? 'فشل جلب الملخص: ' : 'Digest fetch failed: ') + e.message, kind: 'err' }); setDigest(null); } finally { setLoading(false); } }; useEffect(() => { fetchDigest(); }, [refreshTick]); return (
} /> {loading && } {!loading && !digest && ( } title={_rT("No digest available")} subtitle={_rT("Digest generates automatically each week.")} /> )} {!loading && digest && (
{(digest.users || []).map(u => ( ))} {(!digest.users || digest.users.length === 0) && ( )}
{_rT("Person")} {_rT("Team")} {_rT("Days present")} {_rT("Days late")} {_rT("Total hours")} {_rT("Violations")}
{u.display_name} {u.team || '—'} {u.days_present} 0 ? 'var(--warn)' : undefined }}>{u.days_late} {(u.total_hours || 0).toFixed(1)}h = 3 ? 'var(--err)' : u.violations > 0 ? 'var(--warn)' : undefined }}>{u.violations}
{_rT("No employee data in this digest.")}
)}
); } /* ── Generate report form ── */ function GenerateReportForm({ employees, period, setPeriod }) { const [selectedUser, setSelectedUser] = useState('all'); const [fmt, setFmt] = useState('pdf'); const [sendDm, setSendDm] = useState(false); const [busy, setBusy] = useState(false); const [execMonth, setExecMonth] = useState(() => new Date().toISOString().slice(0, 7)); const [execBusy, setExecBusy] = useState(false); const toast = useToast(); const handleExecSummary = async () => { setExecBusy(true); try { const r = await fetch(`/api/reports/executive-summary?month=${encodeURIComponent(execMonth)}`, { credentials: 'same-origin' }); if (!r.ok) throw new Error(`${r.status}`); const blob = await r.blob(); const link = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = link; a.download = `executive-summary-${execMonth}.pdf`; a.click(); // Defer revoke — Safari aborts in-progress downloads if revoked synchronously. setTimeout(() => URL.revokeObjectURL(link), 60000); toast.push({ msg: _rAr() ? 'تم تنزيل الملخص التنفيذي' : 'Executive summary downloaded', kind: 'ok' }); } catch(e) { toast.push({ msg: _rAr() ? `فشل: ${e.message}` : `Failed: ${e.message}`, kind: 'err' }); } finally { setExecBusy(false); } }; const handleGenerate = async () => { setBusy(true); try { if (sendDm) { // Send via DM if (selectedUser === 'all') { const r = await fetch(`/api/reports/send-to-all?period=${period}`, { method: 'POST', credentials: 'same-origin', }); if (!r.ok) throw new Error(await r.text()); const data = await r.json(); toast.push({ msg: _rAr() ? `تم إرسال التقرير إلى ${data.sent_count || 'الكل'} مستخدم عبر الرسائل المباشرة` : `Report sent to ${data.sent_count || 'all'} users via DM`, kind: 'ok' }); } else { const r = await fetch(`/api/reports/send-to-user/${selectedUser}?period=${period}`, { method: 'POST', credentials: 'same-origin', }); if (!r.ok) throw new Error(await r.text()); const emp = employees.find(e => String(e.id) === String(selectedUser)); toast.push({ msg: _rAr() ? `تم إرسال التقرير إلى ${emp?.display_name || selectedUser}` : `Report DM'd to ${emp?.display_name || selectedUser}`, kind: 'ok' }); } } else { // Download const userQ = selectedUser === 'all' ? '' : `&user_id=${encodeURIComponent(selectedUser)}`; const url = `/api/summary/export?format=${fmt}&period=${period}${userQ}`; const r = await fetch(url, { credentials: 'same-origin' }); if (!r.ok) throw new Error(`${r.status}`); const blob = await r.blob(); const link = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = link; const suffix = selectedUser === 'all' ? 'all' : (employees.find(e => String(e.id) === String(selectedUser))?.display_name || selectedUser); a.download = `report-${suffix}-${period}.${fmt}`; a.click(); // Defer revoke — Safari aborts in-progress downloads if revoked synchronously. setTimeout(() => URL.revokeObjectURL(link), 60000); toast.push({ msg: _rAr() ? `تم تنزيل تقرير ${fmt.toUpperCase()}` : `${fmt.toUpperCase()} report downloaded`, kind: 'ok' }); } } catch(e) { toast.push({ msg: _rAr() ? `فشل: ${e.message}` : `Failed: ${e.message}`, kind: 'err' }); } finally { setBusy(false); } }; return (
setExecMonth(e.target.value)} />
{_rT("💬 Or just ask in chat — DM the bot \"send me the weekly report as xlsx\" and it's generated on the fly.")}
); } /* ── Attendance detail (day-by-day) ── */ const DETAIL_STRIKE_LABELS = { late: 'Late sign-in strike', format: 'Standup format strike', attendance: 'Missed sign-off strike', late_reason_rejected: 'Late reason rejected', rescinded: 'Strike rescinded', }; const DETAIL_STATUS_LABELS = { worked: 'worked', absent: 'absent', off_day: 'off', off_day_worked: 'off + work', holiday: 'holiday', excused: 'leave', }; function AttendanceDetailSection({ employees }) { const [userId, setUserId] = useState(''); const [month, setMonth] = useState(() => new Date().toISOString().slice(0, 7)); const [busy, setBusy] = useState(false); const [pdfBusy, setPdfBusy] = useState(false); const [sendBusy, setSendBusy] = useState(false); const [sendTarget, setSendTarget] = useState('selected'); const [data, setData] = useState(null); const toast = useToast(); const loadDetail = async () => { if (!userId || !month) return; setBusy(true); try { const r = await fetch( `/api/reports/attendance-detail?user_id=${encodeURIComponent(userId)}&month=${encodeURIComponent(month)}`, { credentials: 'same-origin' }); if (!r.ok) throw new Error(`${r.status}`); setData(await r.json()); } catch (e) { toast.push({ msg: (_rAr() ? 'فشل جلب التفاصيل: ' : 'Detail fetch failed: ') + e.message, kind: 'err' }); setData(null); } finally { setBusy(false); } }; const downloadPdf = async () => { if (!userId || !month) return; setPdfBusy(true); try { const r = await fetch( `/api/reports/attendance-detail/pdf?user_id=${encodeURIComponent(userId)}&month=${encodeURIComponent(month)}`, { credentials: 'same-origin' }); if (!r.ok) throw new Error(`${r.status}`); const blob = await r.blob(); const link = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = link; a.download = `attendance-detail-${month}.pdf`; a.click(); // Defer revoke — Safari aborts in-progress downloads if revoked synchronously. setTimeout(() => URL.revokeObjectURL(link), 60000); toast.push({ msg: _rAr() ? 'تم تنزيل التقرير التفصيلي' : 'Detail report downloaded', kind: 'ok' }); } catch (e) { toast.push({ msg: (_rAr() ? 'فشل: ' : 'Failed: ') + e.message, kind: 'err' }); } finally { setPdfBusy(false); } }; const teams = [...new Set(employees.map(e => e.team).filter(Boolean))].sort(); const sendDm = async () => { if (!month) return; if (sendTarget === 'selected' && !userId) return; setSendBusy(true); try { let okMsg; if (sendTarget === 'selected') { const emp = employees.find(e => String(e.id) === String(userId)); const r = await fetch( `/api/reports/attendance-detail/send?user_id=${encodeURIComponent(userId)}&month=${encodeURIComponent(month)}`, { method: 'POST', credentials: 'same-origin' }); if (!r.ok) throw new Error(`${r.status}`); okMsg = _rAr() ? `تم إرسال التقرير التفصيلي إلى ${emp?.display_name || userId}` : `Detail report DM'd to ${emp?.display_name || userId}`; } else { const body = sendTarget === 'all' ? { scope: 'all', month } : { scope: 'team', team: sendTarget.slice(5), month }; const r = await fetch('/api/reports/attendance-detail/send-bulk', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!r.ok) throw new Error(`${r.status}`); const data = await r.json(); okMsg = _rAr() ? `جارٍ إرسال ${data.scheduled} تقريرًا تفصيليًا عبر الرسائل المباشرة` : `Sending ${data.scheduled} detail report(s) via DM`; } toast.push({ msg: okMsg, kind: 'ok' }); } catch (e) { toast.push({ msg: (_rAr() ? 'فشل الإرسال: ' : 'Send failed: ') + e.message, kind: 'err' }); } finally { setSendBusy(false); } }; const dayFlags = (d) => { const flags = []; if (d.was_late) flags.push(_rT('late')); if (d.missed_signoff) { flags.push(d.after_close_signoff ? `${_rT('no in-day sign-off — message at')} ${d.after_close_signoff}` : _rT('no sign-off')); } if (d.sign_off_note) flags.push(_rT('prev-day after-midnight sign-off')); (d.strikes || []).forEach(s => { flags.push((DETAIL_STRIKE_LABELS[s.kind] || s.kind) + (s.refunded ? ` (${_rT('refunded')})` : '')); }); if (d.excuse_reason && (d.type === 'excused' || d.type === 'holiday')) flags.push(d.excuse_reason); return flags.join(' · '); }; const summary = data?.summary; return (
{ setMonth(e.target.value); setData(null); }} />
{data && summary && (
{data.user.display_name} · {data.user.team || '—'} · {_rT("all times")} {data.user.timezone} ·{' '} {summary.expected_days} {_rT("expected days")} · {summary.day_hours.toFixed(1)}h /{' '} {summary.expected_hours.toFixed(0)}h {summary.hours_ratio_pct != null && ` (${summary.hours_ratio_pct.toFixed(0)}%)`} ·{' '} {_rT("late")} {summary.late_days} · {_rT("no in-day sign-off")} {summary.missed_signoff_days} ·{' '} {_rT("strikes")}: {summary.strikes.late} {_rT("late")}, {summary.strikes.format} {_rT("format")},{' '} {summary.strikes.attendance} {_rT("sign-off")} {summary.strikes.attendance_refunded > 0 && ` (+${summary.strikes.attendance_refunded} ${_rT("refunded")})`}
{data.days.map(d => { const quiet = d.type === 'off_day' || d.type === 'holiday' || d.type === 'excused'; return ( ); })}
{_rT("Date")} {_rT("Day")} {_rT("Status")} {_rT("In")} {_rT("Out")} {_rT("Hours")} {_rT("Flags / notes")}
{d.date} {d.weekday} {_rT(DETAIL_STATUS_LABELS[d.type] || d.type)} {d.sign_in || '—'} {d.sign_off || '—'} {d.hours ? d.hours.toFixed(1) : '—'} {dayFlags(d) || }
{_rT("A day closes at 23:55 local. Sign-offs sent after midnight show on the day they belong to but the day still counts as having no in-day sign-off (hours are estimated from activity). Late is measured against the employee's own learned average start, not a fixed clock.")}
)}
); } /* ── Leaderboards section ── */ const LEADERBOARD_METRICS = [ { value: 'progress', label: 'Progress' }, { value: 'attendance', label: 'Attendance' }, { value: 'blockers', label: 'Blockers' }, { value: 'hours_max', label: 'Most hours' }, { value: 'hours_min', label: 'Fewest hours' }, { value: 'absence', label: 'Absences' }, ]; const MEDALS = ['🥇', '🥈', '🥉']; function LeaderboardsSection() { const [metric, setMetric] = React.useState('progress'); const [period, setPeriod] = React.useState('week'); const [rows, setRows] = React.useState([]); const [loading, setLoading] = React.useState(false); const toast = useToast(); const load = async (m, p) => { setLoading(true); try { const r = await fetch(`/api/leaderboard?metric=${m}&period=${p}&limit=10`, { credentials: 'same-origin' }); if (!r.ok) throw new Error(`${r.status}`); setRows(await r.json()); } catch(e) { toast.push({ msg: (_rAr() ? 'فشل جلب لوحة المتصدرين: ' : 'Leaderboard fetch failed: ') + e.message, kind: 'err' }); setRows([]); } finally { setLoading(false); } }; useEffect(() => { load(metric, period); }, [metric, period]); const isHours = metric === 'hours_max' || metric === 'hours_min'; const formatValue = (v) => isHours ? `${Number(v).toFixed(1)}h` : String(v); return (
{/* Metric tabs */}
{LEADERBOARD_METRICS.map(m => ( ))}
{/* Period radio */}
{_rT("Period:")} {[['day','Day'],['week','Week'],['month','Month'],['quarter','Quarter'],['6_months','Semi'],['annual','Annual']].map(([v,l]) => ( ))}
{/* Results */} {loading && (
{[...Array(5)].map((_,i) => (
))}
)} {!loading && rows.length === 0 && ( } title={_rT("No data")} subtitle={_rT("No records found for this metric and period.")} /> )} {!loading && rows.length > 0 && (
{rows.map((row) => { const medal = row.rank <= 3 ? MEDALS[row.rank - 1] : null; const avatarEl = (window.resolveAvatarUrl && (() => { const url = window.resolveAvatarUrl({ avatar_url: row.avatar_url }); return url ? { e.target.style.display='none'; }} /> :
{(row.display_name||'?')[0].toUpperCase()}
; })()) ||
; return (
{medal || `#${row.rank}`} {avatarEl} {row.display_name} {formatValue(row.value)}
); })}
)}
); } /* ── Impact / business-value summary card (measured, honest numbers) ── */ function ImpactCard({ refreshTick }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const toast = useToast(); const load = React.useCallback(async () => { setLoading(true); try { const r = await fetch('/api/impact/summary', { credentials: 'same-origin' }); if (!r.ok) throw new Error(parseDetail(await r.json().catch(() => null), r.status)); setData(await r.json()); } catch (e) { toast.push({ msg: (_rAr() ? 'فشل جلب ملخص الأثر: ' : 'Impact summary fetch failed: ') + e.message, kind: 'err' }); setData(null); } finally { setLoading(false); } }, []); useEffect(() => { load(); }, [load, refreshTick]); const productivity = data && data.productivity; const syncCalls = data && data.sync_calls; const blockers = data && data.blockers; const pto = data && data.pto; const pctChange = productivity && productivity.pct_change; const pctUp = pctChange != null && pctChange >= 0; const NotEnoughData = () => (
{_rT('Not enough data yet')}
); const tileStyle = { flex: '1 1 220px', background: 'var(--bg-elev-2)', border: '1px solid var(--line)', borderRadius: 'var(--r-sm)', padding: '10px 14px', }; const labelStyle = { fontSize: 10.5, textTransform: 'uppercase', letterSpacing: .4 }; return (
} /> {loading && } {!loading && !data && ( } title={_rT("No impact data yet")} subtitle={_rT("Nothing measurable recorded since adoption yet.")} /> )} {!loading && data && ( <>
{/* Productivity since adoption */}
{_rT('Productivity since adoption')}
{pctChange == null ? : ( <>
{pctUp ? '↑' : '↓'} {pctUp ? '+' : ''}{pctChange.toFixed(1)}%
{_rAr() ? `أول 4 أسابيع ${productivity.first4_avg.toFixed(1)}% ← آخر 4 أسابيع ${productivity.last4_avg.toFixed(1)}%` : `first 4 weeks avg ${productivity.first4_avg.toFixed(1)}% → last 4 weeks avg ${productivity.last4_avg.toFixed(1)}%`}
{/* Like-for-like both sides (server-computed): the published Efficiency% is not comparable across the migration — see productivity._CMP_WEIGHTS. */} {productivity.before_cutover_avg != null && (
{_rAr() ? `قبل البوت: ${productivity.before_cutover_avg.toFixed(1)}% · مع البوت: ${productivity.after_cutover_avg != null ? productivity.after_cutover_avg.toFixed(1) + '%' : '—'}${productivity.after_cutover_recent_avg != null ? ` · آخر ٣ أسابيع: ${productivity.after_cutover_recent_avg.toFixed(1)}%` : ''} (مقارنة متكافئة)` : `before bot: ${productivity.before_cutover_avg.toFixed(1)}% · with bot: ${productivity.after_cutover_avg != null ? productivity.after_cutover_avg.toFixed(1) + '%' : '—'}${productivity.after_cutover_recent_avg != null ? ` · last 3 wks: ${productivity.after_cutover_recent_avg.toFixed(1)}%` : ''} (like-for-like)`}
)} )}
{/* Sync-call hours saved */}
{_rT('Sync-call hours saved')}
{!syncCalls || syncCalls.hours_saved == null ? : ( <>
{syncCalls.hours_saved.toFixed(1)}h
{_rAr() ? `${syncCalls.standups_recorded} تسجيل يومي × ${syncCalls.call_minutes_per_standup} د` : `${syncCalls.standups_recorded} standups × ${syncCalls.call_minutes_per_standup} min`}
)}
{/* Blocker resolution */}
{_rT('Blocker resolution')}
{!blockers || blockers.avg_resolution_hours == null ? : ( <>
{blockers.avg_resolution_hours.toFixed(1)}h{' '} {_rT('vs')} {blockers.baseline_days}d {_rT('baseline')}
{_rAr() ? `${blockers.resolved_count} تم حله · الوسيط ${blockers.median_resolution_hours != null ? blockers.median_resolution_hours.toFixed(1) + 'h' : '—'}` : `${blockers.resolved_count} resolved · median ${blockers.median_resolution_hours != null ? blockers.median_resolution_hours.toFixed(1) + 'h' : '—'}`}
)}
{/* PTO handled */}
{_rT('PTO handled')}
{!pto ? : ( <>
{pto.requests_handled}
{_rAr() ? `${pto.pto_blocks} فترة إجازة` : `${pto.pto_blocks} PTO block${pto.pto_blocks === 1 ? '' : 's'}`}
)}
{_rT("All figures measured from recorded activity — see Help → Business value.")}
)}
); } /* ── Main ReportsPage ── */ function ReportsPage() { const D = window.STATUS_DATA; const toast = useToast ? useToast() : { push: () => {} }; const [period, setPeriod] = React.useState('week'); const [refreshTick, setRefreshTick] = React.useState(0); const toastTop = useToast ? useToast() : { push: () => {} }; const employees = D.EMPLOYEES || []; 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) {} toastTop.push({ msg: _rT('Refresh complete'), kind: 'ok' }); }; return (
} /> {/* ── Visuals first ──────────────────────────────────────────────── Charts lead the page; tables, then forms/admin tooling follow. Previously PerUserTrends and Hours-distribution were stranded at the very bottom, below the report form, digest and two violation tables — so the charts people actually open this page for were several screens down. Anything that renders a graph now sits above anything that renders a list or a form. */} {/* Charts */} {/* Productivity trend: company / team / employee weekly Efficiency% */} {/* Per-user trends (#25) */} {/* Hours distribution */}
{/* ── Summaries & tables ─────────────────────────────────────────── */} {/* Impact / business-value summary — measured, honest numbers */} {/* Leaderboards */} {/* Violations (#25) */} {/* B7: per-employee violations report with type filter */} {/* ── Tooling: generate / schedule / digest ──────────────────────── */} {/* On-call standings moved to the On-call tab — see page-oncall.jsx. The component itself stays defined below so window.OnCallStandingsSection (set at the function's bottom) remains importable from the On-call page bundle without a duplicate definition. */} {/* Generate report form */} {/* Admin digest */}
{/* Scheduled reports (#18) */}
); } /* ── Per-user picker for a scheduled report row ── */ // Admin chooses individual recipients OR "all employees together" with one // click. Replaces the old comma-separated user-id text input with a real // employee checklist (name + matrix_id) sourced from window.STATUS_DATA. function ScheduledReportUserPicker({ selectedIds, onSave, onCancel }) { const D = window.STATUS_DATA || {}; const employees = (D.EMPLOYEES || []).filter(e => !e.is_test_account); const [sel, setSel] = useState(() => new Set((selectedIds || []).map(String))); const [q, setQ] = useState(''); const toggle = (id) => setSel(prev => { const next = new Set(prev); const s = String(id); if (next.has(s)) next.delete(s); else next.add(s); return next; }); const selectAll = () => setSel(new Set(employees.map(e => String(e.id)))); const clearAll = () => setSel(new Set()); const filtered = employees.filter(e => { const t = q.trim().toLowerCase(); if (!t) return true; return (e.display_name || '').toLowerCase().includes(t) || (e.matrix_id || '').toLowerCase().includes(t); }); return (
setQ(e.target.value)} style={{ flex: 1, fontSize: 12, padding:'4px 8px' }} /> {_rAr() ? `${sel.size} محدد` : `${sel.size} picked`}
{filtered.length === 0 && (
{_rT("No employees match")}
)} {filtered.map(e => { const on = sel.has(String(e.id)); return ( ); })}
); } /* ── Auto-Report Scheduling panel (Bug 18 — restored + scheduler jobs table) ── */ function AutoReportSchedule() { // Q10: full admin-managed scheduled-report surface. // GET /api/scheduled-reports → list items with enabled, recipients_kind, // recipient_ids, channel, last_run_at/status/count, next_run_at. // PATCH /api/scheduled-reports/{cadence_key} updates a single row. // POST /api/scheduled-reports/{cadence_key}/run-now fires immediately. const [items, setItems] = useState([]); const [isAdmin, setIsAdmin] = useState(true); const [busy, setBusy] = useState(null); const [jobs, setJobs] = useState([]); const [editing, setEditing] = useState(null); // cadence_key being edited const toast = useToast(); const reload = async () => { try { const r = await fetch('/api/scheduled-reports', { credentials: 'same-origin' }); if (r.status === 403) { setIsAdmin(false); return; } if (r.ok) { const d = await r.json(); setItems(d.items || []); setIsAdmin(true); } } catch(_) {} }; const reloadJobs = async () => { try { const r = await fetch('/api/server-health', { credentials: 'same-origin' }); if (r.ok) { const d = await r.json(); setJobs(Array.isArray(d.scheduler_jobs) ? d.scheduler_jobs : []); } } catch(_) {} }; useEffect(() => { reload(); reloadJobs(); }, []); const patch = async (cadence_key, body) => { setBusy(cadence_key); try { const r = await fetch(`/api/scheduled-reports/${cadence_key}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(body), }); if (!r.ok) throw new Error(parseDetail(await r.json().catch(() => null), r.status)); toast.push({ msg: _rAr() ? `تم تحديث ${cadence_key}` : `Updated ${cadence_key}`, kind: 'ok' }); await reload(); } catch(e) { toast.push({ msg: (_rAr() ? 'فشل التحديث: ' : 'Update failed: ') + e.message, kind: 'err' }); } finally { setBusy(null); } }; const runNow = async (cadence_key) => { setBusy(cadence_key); try { const r = await fetch(`/api/scheduled-reports/${cadence_key}/run-now`, { method: 'POST', credentials: 'same-origin', }); if (!r.ok) throw new Error(parseDetail(await r.json().catch(() => null), r.status)); toast.push({ msg: _rT('Report fired'), kind: 'ok' }); await reload(); } catch(e) { toast.push({ msg: (_rAr() ? 'فشل التشغيل: ' : 'Run failed: ') + e.message, kind: 'err' }); } finally { setBusy(null); } }; const fmtRel = (iso) => { if (!iso) return '—'; try { const d = new Date(iso); const diff = (d.getTime() - Date.now()) / 1000; const abs = Math.abs(diff); const ar = _rAr(); const sign = diff < 0 ? (ar ? 'مضت' : 'ago') : (ar ? 'خلال' : 'in'); if (abs < 60) return diff < 0 ? (ar ? 'الآن' : 'just now') : (ar ? 'الآن' : 'now'); if (abs < 3600) return ar ? `${sign} ${Math.round(abs/60)} دقيقة` : `${Math.round(abs/60)}m ${sign}`; if (abs < 86400) return ar ? `${sign} ${Math.round(abs/3600)} ساعة` : `${Math.round(abs/3600)}h ${sign}`; return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}); } catch (_) { return iso; } }; return (
{ reload(); reloadJobs(); }} loading={false} label={_rT("Refresh")} />} /> {!isAdmin ? (
{_rT("Admins only. Ask an admin to configure scheduled reports.")}
) : ( {items.map(it => ( {editing === it.cadence_key && ( )} ))}
{_rT("Cadence")}{_rT("Cron")}{_rT("Enabled")} {_rT("Recipients")}{_rT("Channel")} {_rT("Last run")}{_rT("Next run")}
{it.label} {it.cron} {(it.recipients_kind === 'users' || it.recipients_kind === 'room') && ( )} {fmtRel(it.last_run_at)} {it.last_run_status && ( {it.last_run_status} · {it.last_run_count} )} {fmtRel(it.next_run_at)}
{it.recipients_kind === 'room' ? ( <>
{_rT("Matrix room ID (e.g. !abc:k9.ms):")}
{ const v = e.target.value.trim(); patch(it.cadence_key, { recipient_ids: v ? [v] : [] }); setEditing(null); }} /> ) : ( // Per-user / all-users picker. Checklist of every // employee — admin ticks the ones who should // receive this cadence. "Select all" + "Clear" // shortcuts make all-users vs per-user trivial. { patch(it.cadence_key, { recipient_ids: ids }); setEditing(null); }} onCancel={() => setEditing(null)} /> )}
)} {jobs.length > 0 && ( <>
{_rT("Active scheduler jobs")}
{jobs.map((j, i) => ( ))}
{_rT("Job name")} {_rT("Cron / trigger")} {_rT("Next run")}
{j.name} {j.cron || '—'} {j.next ? new Date(j.next).toLocaleString() : '—'}
)}
); } /* ── Charts: period → API range param mapping ── */ const CHART_PERIODS = [ { value: 'day', label: 'Day', range: 'daily' }, { value: 'week', label: 'Week', range: 'weekly' }, { value: 'month', label: 'Month', range: 'monthly' }, ]; /* ── Chart 1: Stacked bar — daily attendance breakdown ── */ function AttendanceStackedBar({ range }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const toast = useToast(); useEffect(() => { if (!range) return; setLoading(true); Promise.allSettled([ fetch(`/api/reports/timeseries?range=${range}&metric=present`, { credentials: 'same-origin' }).then(r => r.ok ? r.json() : Promise.reject(r.status)), fetch(`/api/reports/timeseries?range=${range}&metric=late`, { credentials: 'same-origin' }).then(r => r.ok ? r.json() : Promise.reject(r.status)), ]).then((results) => { const presentData = results[0].status === 'fulfilled' ? results[0].value : { labels: [], datasets: [] }; const lateData = results[1].status === 'fulfilled' ? results[1].value : { labels: [], datasets: [] }; const _ts_anyOk = results[0].status === 'fulfilled' || results[1].status === 'fulfilled'; if (!_ts_anyOk) { setLoading(false); return; } // Continue with whichever metric(s) succeeded. void _ts_anyOk; const labels = presentData.labels || []; const numUsers = presentData.datasets ? presentData.datasets.length : 0; // Sum across users per day const presentPerDay = labels.map((_, i) => (presentData.datasets || []).reduce((s, ds) => s + (ds.data[i] || 0), 0) ); const latePerDay = labels.map((_, i) => (lateData.datasets || []).reduce((s, ds) => s + (ds.data[i] || 0), 0) ); // on_time = present but not late const onTimePerDay = labels.map((_, i) => Math.max(0, presentPerDay[i] - latePerDay[i])); const absentPerDay = labels.map((_, i) => Math.max(0, numUsers - presentPerDay[i])); setData({ labels, onTimePerDay, latePerDay, absentPerDay, numUsers }); }).catch(e => { toast.push({ msg: (_rAr() ? 'فشل جلب مخطط الحضور: ' : 'Attendance chart fetch failed: ') + e, kind: 'err' }); setData(null); }).finally(() => setLoading(false)); }, [range]); if (loading) return ; if (!data || !data.labels.length) return } title={_rT("No attendance data")} subtitle={_rT("No records for this period.")} />; const { labels, onTimePerDay, latePerDay, absentPerDay } = data; const maxVal = Math.max(...labels.map((_, i) => onTimePerDay[i] + latePerDay[i] + absentPerDay[i]), 1); const SVG_W = 480, SVG_H = 180, PAD_L = 28, PAD_B = 36, PAD_T = 12, PAD_R = 12; const chartW = SVG_W - PAD_L - PAD_R; const chartH = SVG_H - PAD_B - PAD_T; const n = labels.length; const barW = Math.max(4, Math.min(30, (chartW / n) * 0.7)); const gap = chartW / n; const toY = (v) => PAD_T + chartH - (v / maxVal) * chartH; const toH = (v) => (v / maxVal) * chartH; // X-axis labels — show every Nth const labelStep = Math.ceil(n / 8); const fmtLabel = (iso) => { const d = new Date(iso + 'T00:00:00'); return `${d.getMonth()+1}/${d.getDate()}`; }; const COLORS = { onTime: '#10b981', late: '#f59e0b', absent: '#f43f5e' }; return (
{/* Y gridlines */} {[0,0.25,0.5,0.75,1].map(f => { const y = PAD_T + chartH - f * chartH; return ( {Math.round(f * maxVal)} ); })} {/* Bars */} {labels.map((label, i) => { const cx = PAD_L + i * gap + gap / 2; const x = cx - barW / 2; const h1 = toH(onTimePerDay[i]); const h2 = toH(latePerDay[i]); const h3 = toH(absentPerDay[i]); const y1 = PAD_T + chartH - h1; const y2 = y1 - h2; const y3 = y2 - h3; return ( {onTimePerDay[i] > 0 && {`${fmtLabel(label)}\n${_rT("On time")}: ${onTimePerDay[i]}`} } {latePerDay[i] > 0 && {`${fmtLabel(label)}\n${_rT("Late")}: ${latePerDay[i]}`} } {absentPerDay[i] > 0 && {`${fmtLabel(label)}\n${_rT("Absent")}: ${absentPerDay[i]}`} } {i % labelStep === 0 && ( {fmtLabel(label)} )} ); })} {/* Axes */} {/* Legend */}
{[['On time', COLORS.onTime, 1], ['Late', COLORS.late, 1], ['Absent', COLORS.absent, 0.6]].map(([l, c, op]) => ( {_rT(l)} ))}
); } /* ── Chart 2: Line chart — total hours per day ── */ function HoursTrendLine({ range }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const toast = useToast(); useEffect(() => { if (!range) return; setLoading(true); fetch(`/api/reports/timeseries?range=${range}&metric=hours`, { credentials: 'same-origin' }) .then(r => r.ok ? r.json() : Promise.reject(r.status)) .then(raw => { const labels = raw.labels || []; const hoursPerDay = labels.map((_, i) => (raw.datasets || []).reduce((s, ds) => s + (ds.data[i] || 0), 0) ); setData({ labels, hoursPerDay }); }) .catch(e => { toast.push({ msg: (_rAr() ? 'فشل جلب مخطط الساعات: ' : 'Hours chart fetch failed: ') + e, kind: 'err' }); setData(null); }) .finally(() => setLoading(false)); }, [range]); if (loading) return ; if (!data || !data.labels.length) return } title={_rT("No hours data")} subtitle={_rT("No records for this period.")} />; const { labels, hoursPerDay } = data; const maxVal = Math.max(...hoursPerDay, 1); const minVal = 0; const SVG_W = 480, SVG_H = 180, PAD_L = 32, PAD_B = 36, PAD_T = 12, PAD_R = 12; const chartW = SVG_W - PAD_L - PAD_R; const chartH = SVG_H - PAD_B - PAD_T; const n = labels.length; const toX = (i) => PAD_L + (i / Math.max(n - 1, 1)) * chartW; const toY = (v) => PAD_T + chartH - ((v - minVal) / (maxVal - minVal)) * chartH; const labelStep = Math.ceil(n / 8); const fmtLabel = (iso) => { const d = new Date(iso + 'T00:00:00'); return `${d.getMonth()+1}/${d.getDate()}`; }; const pts = labels.map((_, i) => `${toX(i)},${toY(hoursPerDay[i])}`).join(' '); const fillPts = `${PAD_L},${PAD_T + chartH} ${pts} ${toX(n-1)},${PAD_T + chartH}`; return (
{/* Y gridlines */} {[0, 0.25, 0.5, 0.75, 1].map(f => { const y = PAD_T + chartH - f * chartH; const v = Math.round(f * maxVal); return ( {v}h ); })} {/* Fill */} {n > 1 && } {/* Line */} {n > 1 && } {/* Dots + tooltips */} {labels.map((label, i) => ( {`${fmtLabel(label)}: ${hoursPerDay[i].toFixed(1)}h`} ))} {/* X labels */} {labels.map((label, i) => i % labelStep === 0 && ( {fmtLabel(label)} ))} {/* Axes */}
{_rT("Total workforce hours/day")}
); } /* ── Chart 3: Donut — status distribution ── */ function StatusDonut({ range, periodLabel }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [hovered, setHovered] = useState(null); const toast = useToast(); useEffect(() => { if (!range) return; setLoading(true); fetch(`/api/reports/distribution?range=${range}`, { credentials: 'same-origin' }) .then(r => r.ok ? r.json() : Promise.reject(r.status)) .then(raw => { const labels = (raw.attendance && raw.attendance.labels) || []; const counts = (raw.attendance && raw.attendance.data) || []; setData({ labels, counts }); }) .catch(e => { toast.push({ msg: (_rAr() ? 'فشل جلب التوزيع: ' : 'Distribution fetch failed: ') + e, kind: 'err' }); setData(null); }) .finally(() => setLoading(false)); }, [range]); if (loading) return ; if (!data || !data.labels.length) return } title={_rT("No distribution data")} subtitle={_rT("No records for this period.")} />; const { labels, counts } = data; const total = counts.reduce((s, v) => s + v, 0) || 1; // Color map by label substring const labelColor = (l) => { const ll = l.toLowerCase(); if (ll.includes('late')) return '#f59e0b'; if (ll.includes('absent')) return '#f43f5e'; if (ll.includes('sign')) return '#8b5cf6'; if (ll.includes('present')) return '#10b981'; return '#0ea5e9'; }; const SIZE = 180, R = 70, IR = 46, CX = SIZE/2, CY = SIZE/2; const C = 2 * Math.PI * R; // Build slices let cumAngle = -Math.PI / 2; // start at top const slices = labels.map((label, i) => { const v = counts[i] || 0; if (!v) return null; const frac = v / total; const angle = frac * 2 * Math.PI; const x1 = CX + R * Math.cos(cumAngle); const y1 = CY + R * Math.sin(cumAngle); cumAngle += angle; const x2 = CX + R * Math.cos(cumAngle); const y2 = CY + R * Math.sin(cumAngle); const ix1 = CX + IR * Math.cos(cumAngle - angle); const iy1 = CY + IR * Math.sin(cumAngle - angle); const ix2 = CX + IR * Math.cos(cumAngle); const iy2 = CY + IR * Math.sin(cumAngle); const large = angle > Math.PI ? 1 : 0; const d = `M ${ix1} ${iy1} L ${x1} ${y1} A ${R} ${R} 0 ${large} 1 ${x2} ${y2} L ${ix2} ${iy2} A ${IR} ${IR} 0 ${large} 0 ${ix1} ${iy1} Z`; const color = labelColor(label); const isHov = hovered === i; return ( setHovered(i)} onMouseLeave={() => setHovered(null)} > {`${label}: ${v} (${Math.round(v/total*100)}%)`} ); }).filter(Boolean); // Center label const hovLabel = hovered != null ? labels[hovered] : periodLabel; const hovCount = hovered != null ? counts[hovered] : total; const hovPct = hovered != null ? `${Math.round(counts[hovered]/total*100)}%` : _rT('total'); return (
{slices} {hovCount} {hovPct} {hovLabel}
{labels.map((label, i) => { const v = counts[i] || 0; const pct = Math.round(v / total * 100); const color = labelColor(label); return (
setHovered(i)} onMouseLeave={() => setHovered(null)}> {label} {v} {pct}%
); })}
); } /* ── On-call standings section ── */ function OnCallStandingsSection() { const [period, setPeriod] = useState('30d'); const [team, setTeam] = useState(''); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const toast = useToast(); const PERIODS = [ { value: '7d', label: _rT('7 days') }, { value: '30d', label: _rT('30 days') }, { value: '90d', label: _rT('90 days') }, ]; const load = async (p, t) => { setLoading(true); try { const qs = `period=${p}${t ? `&team=${encodeURIComponent(t)}` : ''}`; const r = await fetch(`/api/oncall/metrics?${qs}`, { credentials: 'same-origin' }); if (!r.ok) throw new Error(await r.text()); setData(await r.json()); } catch(e) { toast.push({ msg: (_rAr() ? 'فشل جلب مقاييس المناوبة: ' : 'On-call metrics fetch failed: ') + e.message, kind: 'err' }); } finally { setLoading(false); } }; useEffect(() => { load(period, team); }, [period, team]); const cardStyle = { flex: '1 1 140px', background: 'var(--surface-2)', borderRadius: 10, padding: '14px 18px', textAlign: 'center', }; return (
{PERIODS.map(p => ( ))} setTeam(e.target.value)} style={{ width: 120, padding:'3px 8px', fontSize: 11 }} />
} /> {loading && } {!loading && data && ( <> {/* Summary cards */}
{data.shifts_total}
{_rT("Total shifts")}
{data.hours_total}h
{_rT("Total hours on call")}
{data.escalations}
{_rT("Escalations")}
{/* Ranked lists */}
{/* Top users by hours */}
{_rT("Top users by hours on call")}
{(data.per_user ?? []).length === 0 ?
{_rT("No data for this period.")}
: (data.per_user ?? []).slice(0, 10).map((u, i) => (
#{i+1} {u.display_name} {u.hours}h {_rAr() ? `${u.shifts} مناوبة` : `${u.shifts} shift${u.shifts !== 1 ? 's' : ''}`}
)) }
{/* Top teams by shifts */}
{_rT("Top teams by shifts")}
{(data.per_team ?? []).length === 0 ?
{_rT("No data for this period.")}
: (data.per_team ?? []).slice(0, 10).map((t, i) => (
#{i+1} {t.team_name} {t.hours}h {_rAr() ? `${t.shifts} مناوبة` : `${t.shifts} shift${t.shifts !== 1 ? 's' : ''}`}
)) }
)} {!loading && !data && (
{_rT("No on-call data available.")}
)}
); } // Re-exposed so the On-call page bundle can mount the same standings block. window.OnCallStandingsSection = OnCallStandingsSection; /* ── Charts section — period picker + 2-col grid ── */ /* ── Violations section (#25) — period filter + table + pie + trend ── */ function ViolationsSection() { const [period, setPeriod] = React.useState('week'); const [search, setSearch] = React.useState(''); const [rows, setRows] = React.useState([]); const [breakdown, setBreakdown] = React.useState({ labels: [], data: [], total: 0 }); const [timeline, setTimeline] = React.useState({ labels: [], data: [], total: 0 }); const [loading, setLoading] = React.useState(false); const toast = useToast(); const trendRef = React.useRef(null); const pieRef = React.useRef(null); const PERIODS = [ { value: 'day', label: _rT('Day') }, { value: 'week', label: _rT('Week') }, { value: 'month', label: _rT('Month') }, { value: 'quarter', label: _rT('Quarter') }, { value: 'year', label: _rT('Year') }, ]; const PIE_COLORS = ['#ef4444', '#f59e0b', '#3b82f6', '#a855f7', '#10b981', '#ec4899', '#14b8a6']; const load = async (p) => { setLoading(true); try { // Scope all 3 fetches to the same search so donut/trend/table never drift. const searchQ = search ? '&search=' + encodeURIComponent(search) : ''; const [r1, r2, r3] = await Promise.all([ fetch('/api/summary/violations?period=' + encodeURIComponent(p) + searchQ, { credentials: 'same-origin' }).then(r => r.ok ? r.json() : []), fetch('/api/violations/breakdown?period=' + encodeURIComponent(p) + searchQ, { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { labels: [], data: [], total: 0 }), fetch('/api/violations/timeline?period=' + encodeURIComponent(p === 'day' ? 'week' : p) + searchQ, { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { labels: [], data: [], total: 0 }), ]); setRows(Array.isArray(r1) ? r1 : []); setBreakdown(r2); setTimeline(r3); } catch(e) { toast.push({ msg: (_rAr() ? 'فشل جلب المخالفات: ' : 'Violations fetch failed: ') + e.message, kind: 'err' }); } finally { setLoading(false); } }; React.useEffect(() => { load(period); }, [period, search]); // Chart.js pie + line React.useEffect(() => { if (!window.Chart || !pieRef.current || !breakdown.labels.length) return; const ctx = pieRef.current.getContext('2d'); if (pieRef.current._chart) pieRef.current._chart.destroy(); pieRef.current._chart = new window.Chart(ctx, { type: 'doughnut', data: { labels: breakdown.labels, datasets: [{ data: breakdown.data, backgroundColor: breakdown.labels.map((_, i) => PIE_COLORS[i % PIE_COLORS.length]), borderWidth: 2, borderColor: 'var(--bg-elev-2)', }], }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { color: 'var(--text-1)', boxWidth: 12, font:{size:11} } }, tooltip: {}, }, }, }); return () => { if (pieRef.current && pieRef.current._chart) pieRef.current._chart.destroy(); }; }, [breakdown]); React.useEffect(() => { if (!window.Chart || !trendRef.current || !timeline.labels.length) return; const ctx = trendRef.current.getContext('2d'); if (trendRef.current._chart) trendRef.current._chart.destroy(); trendRef.current._chart = new window.Chart(ctx, { type: 'line', data: { labels: timeline.labels.map(d => d.slice(5)), datasets: [{ label: _rT('Violations'), data: timeline.data, borderColor: '#ef4444', backgroundColor: 'rgba(239,68,68,0.18)', fill: true, tension: 0.3, pointRadius: 2, }], }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { color: 'var(--text-2)', stepSize: 1 } }, x: { ticks: { color: 'var(--text-2)', maxTicksLimit: 8 } }, }, }, }); return () => { if (trendRef.current && trendRef.current._chart) trendRef.current._chart.destroy(); }; }, [timeline]); const totalViolations = rows.reduce((s, r) => s + (r.violation_count || 0), 0); return (
setPeriod(e.target.value)} style={{ fontSize: 12, padding: '4px 8px' }}> {PERIODS.map(p => ())} } /> {/* Charts row */}
{_rT("By type")}
{breakdown.labels.length === 0 && !loading && (
{_rT("No violations in this window 🎉")}
)}
{_rT("Trend")}
{timeline.labels.length === 0 && !loading && (
)}
{/* Search + table — search scopes pie + trend + table together */}
setSearch(e.target.value)} style={{ maxWidth: 280 }} /> {search && ( )} {search ? (_rAr() ? `عرض الحلقة + الاتجاه + الجدول لـ "${search}"` : `Showing donut + trend + table for "${search}"`) : (_rAr() ? `عرض كل المستخدمين · ${rows.length}` : `Showing all users · ${rows.length}`)}
{loading && } {!loading && rows.length === 0 && ( )} {rows.map(r => { const left = Math.max(0, r.strikes_left || 0); const cap = r.strike_cap || 3; const leftColor = left === 0 ? 'var(--err)' : left === 1 ? 'var(--warn)' : 'var(--ok)'; return ( ); })}
{_rT("User")} {_rT("Team")} {_rT("Period total")} {_rT("By type")} {_rT("Late")} {_rT("Absent")} {_rT("Short")} {_rT("Att %")} {_rT("Strikes left (lifetime)")} {_rT("Last")}
{_rT("No data.")}
{r.display_name} {r.team || '—'} {r.violation_count} {Object.entries(r.by_type || {}).length === 0 ? ( ) : Object.entries(r.by_type || {}).map(([k, v], i) => ( {k.replace('_',' ')}: {v} ))} {r.days_late || 0} {r.days_absent || 0} {r.short_days || 0} {r.attendance_pct != null ? Math.round(r.attendance_pct) + '%' : '—'} {left} / {cap} {r.last_violation ? r.last_violation.slice(0, 16).replace('T', ' ') : '—'}
); } /* ── Productivity trend (org / team / employee weekly Efficiency%) ── */ // Categorical line palette for multi-series (teams / employees). Chosen for // contrast in both light and dark themes. const TREND_PALETTE = [ '#0ea5e9', '#f59e0b', '#10b981', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16', '#06b6d4', '#e11d48', '#a855f7', '#22c55e', '#eab308', '#3b82f6', ]; function _trendHash(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return Math.abs(h); } const _MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; /* ── Small multiples: one mini-chart per employee ────────────────────────── Overlaying every employee on one axis is a spaghetti chart. Past ~8 series categorical colour stops working — the palette gets cycled, so two people silently share a hue — and no single line is traceable anyway; with 24 staff the migration marker and the trend both vanish into noise. Faceting is the fix: each cell is ONE series, so it needs no legend and no unique hue. Colour carries TEAM (3 teams — well inside the categorical ceiling) and is keyed off the team NAME, so filtering people out never repaints the survivors. Sorted by latest value: the roster reads as a ranking instead of a tangle. */ function _teamColor(team) { return TREND_PALETTE[_trendHash(String(team || 'none')) % TREND_PALETTE.length]; } function TrendSmallMultiples({ series, labels, axis, cutover, fmtVal, monthly, onPick }) { const cells = React.useMemo(() => series.map(s => { const vals = s.points.filter(p => p.score != null); const latest = vals.length ? vals[vals.length - 1].score : null; const bef = [], aft = []; s.points.forEach(p => { if (p.score == null) return; ((monthly ? p.week_start.slice(0, 7) < cutover.slice(0, 7) : p.week_start < cutover) ? bef : aft).push(p.score); }); const avg = a => a.length ? a.reduce((x, y) => x + y, 0) / a.length : null; const b = avg(bef), a = avg(aft); return { ...s, latest, delta: (b != null && a != null) ? a - b : null }; }).sort((x, y) => (y.latest ?? -1) - (x.latest ?? -1)), [series, cutover, monthly]); const W = 190, H = 42, PAD = 3; // Shared y-scale across every cell — per-cell auto-scaling would make a flat // 60% and a flat 20% look identical, which is the classic small-multiples trap. const allVals = series.flatMap(s => s.points.filter(p => p.score != null).map(p => p.score)); const maxY = Math.max(1, ...allVals) * 1.1; const n = axis.length; const toX = i => PAD + (i / Math.max(n - 1, 1)) * (W - PAD * 2); const toY = v => H - PAD - (Math.max(0, v) / maxY) * (H - PAD * 2); let cutIdx = -1; if (cutover) { const cm = monthly ? cutover.slice(0, 7) : cutover; cutIdx = axis.findIndex(a => (monthly ? a.slice(0, 7) : a) >= cm); } const cutX = cutIdx > 0 ? toX(cutIdx) - (W - PAD * 2) / Math.max(n - 1, 1) / 2 : -1; return (
{cells.map(c => { const runs = []; let run = []; c.points.forEach((p, i) => { if (p.score == null) { if (run.length) { runs.push(run); run = []; } } else run.push(i); }); if (run.length) runs.push(run); const col = _teamColor(c.team); return (
onPick && onPick(c.key)} title={_rT('Open this employee')} style={{ background: 'var(--bg-2, rgba(127,127,127,.06))', border: '1px solid var(--line)', borderRadius: 8, padding: '7px 9px 5px', cursor: onPick ? 'pointer' : 'default' }}>
{c.label} {fmtVal(c.latest)}
{c.team || _rT('no team')} {c.delta != null && ( = 0 ? 'var(--ok,#10b981)' : 'var(--err,#f43f5e)' }}> {c.delta >= 0 ? '▲' : '▼'} {Math.abs(c.delta).toFixed(1)} )}
{cutX > 0 && } {cutX > 0 && } {runs.map((r, ri) => ( `${toX(i)},${toY(c.points[i].score)}`).join(' ')} fill="none" stroke={col} strokeWidth={1.6} strokeLinejoin="round" strokeLinecap="round" /> ))}
); })}
); } const _PROD_ADMIN_ROLES = ['admin', 'moderator', 'superadmin']; function _isProdAdmin(e) { return !!e && (e.is_superadmin || _PROD_ADMIN_ROLES.includes(String(e.org_role || '').toLowerCase())); } function ProductivityTrendCard({ refreshTick }) { const D = window.STATUS_DATA || {}; // Admins / moderators / superadmins are leaders, not tracked ICs — keep them // out of the employee picker so their line can't be charted, matching the // exclude-admins default applied to the org/team means below. const employees = React.useMemo(() => ( (D.EMPLOYEES || []).filter(e => !e.is_test_account && !_isProdAdmin(e)) .slice().sort((a, b) => (a.display_name || '').localeCompare(b.display_name || '')) ), []); const teams = React.useMemo(() => ( (D.TEAMS || []).filter(t => t && t.team_name) .slice().sort((a, b) => (a.team_name || '').localeCompare(b.team_name || '')) ), []); const [level, setLevel] = useState('org'); // org|team|user|teams|employees const [teamId, setTeamId] = useState(teams[0]?.team_name || ''); const [userId, setUserId] = useState(employees[0]?.id || ''); const [weeks, setWeeks] = useState(26); const [gran, setGran] = useState('week'); // week|month const [metric, setMetric] = useState('efficiency'); // efficiency|transparency|tasks|hours const [exclAdmins, setExclAdmins] = useState(true); // The current week is still running, so its point always dips — it is a // partial week, not a downturn. Hidden by default; the toggle shows it // explicitly labelled rather than letting it read as a real decline. const [showPartial, setShowPartial] = useState(false); const [helpOpen, setHelpOpen] = useState(false); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [hidden, setHidden] = useState({}); // series key -> true (legend toggle) const [hovered, setHovered] = useState(null); // axis index const [forecast, setForecast] = useState(false); // "Projection" toggle — default off const toast = useToast(); const isMulti = level === 'teams' || level === 'employees'; const scopedId = level === 'team' ? teamId : level === 'user' ? userId : ''; // Forecast is only supported by the single-series /api/productivity/trend // endpoint (org/team/user) — trend-multi has no forecast= param. const forecastActive = forecast && !isMulti; const load = React.useCallback(async () => { if ((level === 'team' && !teamId) || (level === 'user' && !userId)) { setData(null); return; } setLoading(true); try { let url; if (isMulti) { url = `/api/productivity/trend-multi?mode=${level}&weeks=${weeks}&metric=${metric}` + (exclAdmins ? '&exclude_admins=true' : ''); } else { const qs = new URLSearchParams({ level, weeks: String(weeks), metric }); if (scopedId) qs.set('id', String(scopedId)); if (exclAdmins) qs.set('exclude_admins', 'true'); if (forecastActive) qs.set('forecast', 'true'); url = `/api/productivity/trend?${qs}`; } const r = await fetch(url, { credentials: 'same-origin' }); if (!r.ok) throw new Error(parseDetail(await r.json().catch(() => null), r.status)); setData(await r.json()); } catch (e) { toast.push({ msg: (_rAr() ? 'فشل جلب اتجاه الإنتاجية: ' : 'Productivity trend fetch failed: ') + e.message, kind: 'err' }); setData(null); } finally { setLoading(false); } }, [level, teamId, userId, weeks, scopedId, isMulti, metric, forecastActive, exclAdmins]); useEffect(() => { load(); }, [load, refreshTick]); useEffect(() => { setHidden({}); setHovered(null); }, [level, weeks, metric]); // Unit-aware formatting: efficiency/transparency are %, tasks a count, hours in h. const unit = (data && data.unit) || 'pct'; const isPct = unit === 'pct'; const fmtVal = (v) => v == null ? '—' : isPct ? v.toFixed(1) + '%' : unit === 'hours' ? v.toFixed(1) + 'h' : String(Math.round(v)); const fmtDelta = (d) => d == null ? '—' : (d >= 0 ? '↑ ' : '↓ ') + (isPct ? (d > 0 ? '+' : '') + d.toFixed(1) + ' pts' : unit === 'hours' ? (d > 0 ? '+' : '') + d.toFixed(1) + 'h' : (d > 0 ? '+' : '') + Math.round(d)); // Relative % change — "+38%". Distinct from fmtDelta, which reports a // percentage-POINTS gap ("+8.7 pts") for pct metrics. const fmtPctChange = (v) => v == null ? '—' : (v >= 0 ? '+' : '') + v.toFixed(1) + '%'; const METRIC_OPTS = [ { v: 'efficiency', l: _rT('Efficiency') }, { v: 'transparency', l: _rT('Transparency') }, { v: 'tasks', l: _rT('Tasks done') }, { v: 'hours', l: _rT('Hours') }, ]; // What each number actually measures — surfaced behind the "?" so nobody has // to guess how e.g. Transparency is derived. const METRIC_HELP = { efficiency: _rT('Weighted Efficiency% — already a composite: Progress 40%, Presence & hours 25%, Reliability 15%, Integrity 10%, Responsibility 10%. Strikes apply as a capped penalty. A dropped pillar (e.g. no on-call) is removed and its weight redistributed.'), transparency: _rT('Visibility% = reporting rate ×50% + sign-off rate ×30% + ping responsiveness ×20%. Reporting rate = days a standup was posted ÷ expected working days. If no pings were sent that week, the ping weight is redistributed over the other two (×62.5 / ×37.5). Measures how openly work is REPORTED — not how much was done.'), tasks: _rT('Distinct tasks the standup reported as completed that week. At team/company level this is the mean per employee. Counts what was WRITTEN DOWN, so it rises when reporting improves even if output does not.'), hours: _rT('Hours worked that week, derived from sign-in → sign-off spans (mean per employee at team/company level). Days missing either side are undercounted — pre-cutover history is structurally low for this reason.'), }; const METRIC_CAVEAT = _rT('Weeks left of the migration line are reconstructed from the archived room, where the bot did not enforce a standup format. Improvements across that line partly reflect better REPORTING, not only more work. The plotted line is NOT comparable across the migration: before the cutover there were no availability pings, so that pillar does not exist and its weight redistributes, and no attendance strike could be recorded — which flatters the "before" side. Use the like-for-like footnote for any before/after claim.'); // ---- Normalise single- and multi-series responses to one weekly shape ---- // norm.axis = [weekIso...]; norm.series = [{key,label,team,color,points:[{week_start,score,n}]}] const norm = React.useMemo(() => { if (!data) return null; if (isMulti) { const axis = data.weeks || []; const series = (data.series || []).map((s, i) => ({ key: s.key, label: s.label, team: s.team, // Keyed off the entity, never the row index: hiding one series must not // repaint the others (a reader who learned "Wael is blue" stays right). color: level === 'teams' ? _teamColor(s.team || s.key) : TREND_PALETTE[_trendHash(s.key) % TREND_PALETTE.length], points: s.points || [], })); return { axis, series, cutover: data.cutover, beforeAfter: null }; } if (!data.series || !data.series.length) return null; const axis = data.series.map(p => p.week_start); const label = level === 'org' ? _rT('Company') : (scopedId != null ? String(scopedId) : _rT('Series')); return { axis, cutover: data.cutover, beforeAfter: data.before_after || null, series: [{ key: 'main', label, color: '#0ea5e9', points: data.series }] }; }, [data, isMulti, level, scopedId]); // ---- Month rollup (average of non-null weekly scores per calendar month) -- // Drop the still-running week unless explicitly asked for. Its point is // always low simply because the week isn't over, which reads as a decline — // the honest default is to plot complete weeks and offer the partial one as // an opt-in (rather than quietly trimming whatever looks bad). const trimmed = React.useMemo(() => { if (!norm) return null; if (showPartial || norm.axis.length < 2) return norm; return { ...norm, axis: norm.axis.slice(0, -1), series: norm.series.map(s => ({ ...s, points: s.points.slice(0, -1) })), }; }, [norm, showPartial]); const view = React.useMemo(() => { const norm = trimmed; if (!norm) return null; if (gran === 'week') { return { axis: norm.axis, labels: norm.axis.map(iso => { const d = new Date(iso + 'T00:00:00'); return `${d.getMonth() + 1}/${d.getDate()}`; }), series: norm.series, cutover: norm.cutover, monthly: false }; } const monthsAxis = []; norm.axis.forEach(iso => { const mk = iso.slice(0, 7); if (!monthsAxis.includes(mk)) monthsAxis.push(mk); }); const rollup = (points) => { const byM = {}; points.forEach(p => { const mk = p.week_start.slice(0, 7); (byM[mk] = byM[mk] || []).push(p); }); return monthsAxis.map(mk => { const rows = (byM[mk] || []).filter(p => p.score != null); const vals = rows.map(p => p.score); // A rolled-up month only counts as "projected" when every week that // fed it was a forecast week — a month mixing real + forecast weeks // stays a solid (real) point so client math never blends fake data // into an "actual" bucket. const allPredicted = rows.length > 0 && rows.every(p => p.predicted); return { week_start: mk + '-01', score: vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null, n: rows.length, predicted: allPredicted }; }); }; return { axis: monthsAxis.map(mk => mk + '-01'), labels: monthsAxis.map(mk => `${_MONTHS[parseInt(mk.slice(5, 7), 10) - 1]} '${mk.slice(2, 4)}`), series: norm.series.map(s => ({ ...s, points: rollup(s.points) })), cutover: norm.cutover, monthly: true, }; }, [trimmed, gran]); // ---- Before/after cutover averages (from WEEKLY data, excludes the last // in-progress week for fairness) ---- // "How much better are we?" — the RELATIVE % change across the visible range. // That is what "we improved 40%" means, and it is the number the card must // prove. The tiles used to show a percentage-POINTS delta (48.6% -> 57.3% = // "+8.7 pts"), which is a different and much smaller-looking figure — it was // understating a real +38% trend. // // Both ends are averaged over up to 3 weeks: a single endpoint week swings // ~5 points here (45.98 then 50.02 back to back), so a raw first-vs-last // headline would be noise-driven and cherry-pickable. const progress = React.useMemo(() => { if (!norm) return null; const lastWeek = norm.axis[norm.axis.length - 1]; const vis = norm.series.filter(s => !hidden[s.key]); const byWeek = new Map(); vis.forEach(s => s.points.forEach(p => { // never average a forecast point or the still-running week if (p.score == null || p.predicted || p.week_start === lastWeek) return; if (!byWeek.has(p.week_start)) byWeek.set(p.week_start, []); byWeek.get(p.week_start).push(p.score); })); const weeks = [...byWeek.keys()].sort(); if (weeks.length < 2) return null; const mean = (a) => a.reduce((x, y) => x + y, 0) / a.length; const wAvg = (w) => mean(byWeek.get(w)); const ends = Math.max(1, Math.min(3, Math.floor(weeks.length / 2))); const start = mean(weeks.slice(0, ends).map(wAvg)); const now = mean(weeks.slice(-ends).map(wAvg)); return { start, now, ends, span: weeks.length, pts: now - start, relPct: start ? (now - start) / start * 100 : null, firstWeek: weeks[0], lastWeek: weeks[weeks.length - 1], }; }, [norm, hidden]); // Migration context (archived-room vs live-room), kept as a one-line footnote // rather than three tiles — it answers a different question than "are we // improving", and crowding both made the card unreadable. // Migration context (archived-room vs live-room), kept as a one-line footnote // rather than three tiles — it answers a different question than "are we // improving", and crowding both made the card unreadable. // // ★ Computed SERVER-side on a like-for-like basis and never re-derived here. // Averaging the plotted Efficiency% across the migration line compares two // different rubrics: before the cutover there are no availability pings, so // the integrity pillar does not exist and its weight redistributes onto the // pillars archive data scores well on, and no attendance strike can be // recorded. That comparison read -0.7% while the like-for-like one read // +14%. The server drops everything unavailable before the cutover from BOTH // sides; there is no honest way to do it from the plotted points alone. const beforeAfter = (norm && norm.beforeAfter) || null; const toggleSeries = (k) => setHidden(h => ({ ...h, [k]: !h[k] })); return (
} />
{[ { value: 'org', label: _rT('Company') }, { value: 'team', label: _rT('Team') }, { value: 'user', label: _rT('Employee') }, { value: 'teams', label: _rT('All teams') }, { value: 'employees', label: _rT('All employees') }, ].map(o => ( ))}
{level === 'team' && ( )} {level === 'user' && ( )}
{[{ v: 'week', l: _rT('Weekly') }, { v: 'month', l: _rT('Monthly') }].map(o => ( ))}
{/* Metric selector — what the trend plots */}
{_rT('Metric')} {METRIC_OPTS.map(o => ( ))}
{/* "?" — what this metric actually measures, incl. the reporting caveat */} {helpOpen && (
{(METRIC_OPTS.find(o => o.v === metric) || {}).l}
{METRIC_HELP[metric]}
{METRIC_CAVEAT}
)} {loading && } {!loading && (!view || !view.series.length) && ( } title={_rT("No productivity data")} subtitle={_rT("No scored weeks in this range yet.")} /> )} {/* THE headline: how much better are we, as a %. */} {!loading && progress && (
{progress.span} {_rT('weeks ago')}
{fmtVal(progress.start)}
{_rT('avg of first')} {progress.ends} {_rT('wks')}
{_rT('Now')}
{fmtVal(progress.now)}
{_rT('avg of last')} {progress.ends} {_rT('wks')}
{_rT('Improvement')}
= 0 ? 'var(--ok,#10b981)' : 'var(--err,#f43f5e)' }}> {fmtPctChange(progress.relPct)}
{isPct ? <>{fmtDelta(progress.pts)} · {_rT('relative')} : _rT('relative change')}
{beforeAfter && beforeAfter.rel_pct != null && (
{_rT('Across the migration')}, {_rT('like for like')}: {fmtVal(beforeAfter.before)} ({_rT('archived room')}, {beforeAfter.weeks_before} {_rT('wks')}) → {fmtVal(beforeAfter.after)} ({_rT('live room')}, {beforeAfter.weeks_after} {_rT('wks')}) = {fmtPctChange(beforeAfter.rel_pct)} {beforeAfter.after_recent != null && ( <> · {_rT('last')} {beforeAfter.recent_weeks} {_rT('wks')} {fmtVal(beforeAfter.after_recent)} = {fmtPctChange(beforeAfter.rel_pct_recent)} )}
{_rT('Same rubric both sides (progress · presence · reliability) — the pillars that exist without the bot.')} {beforeAfter.avg_people_before != null && beforeAfter.avg_people_after != null && ( <> {_rT('Visible per week')}: {beforeAfter.avg_people_before} → {beforeAfter.avg_people_after} {_rT('of')} {beforeAfter.roster}. )}
{/* A zero-filled "before" is RECORDED productivity across the whole roster, not observed productivity. It must never be shown without saying so — the number is otherwise read as evidence that those people did nothing, which the data does not say. */} {beforeAfter.zero_filled && (
⚠ {_rT('Pre-migration weeks with no record are counted as 0%')} {beforeAfter.zero_filled_person_weeks > 0 && ( <> ({beforeAfter.zero_filled_person_weeks} {_rT('of')} {beforeAfter.person_weeks_before} {_rT('person-weeks')}) )} . {_rT('That is what we RECORDED, not what was worked — those people were simply not tracked before the bot.')}
)}
)}
)} {/* Past ~8 series an overlaid line chart stops working — hues get cycled so two people share a colour, and no line is traceable. Facet instead: one mini-chart per employee, shared y-scale, click to open them. */} {!loading && view && level === 'employees' && view.series.length > 8 && ( { setUserId(k); setLevel('user'); }} /> )} {!loading && view && view.series.length > 0 && !(level === 'employees' && view.series.length > 8) && (() => { const axis = view.axis, n = axis.length; const SVG_W = 520, SVG_H = 200, PAD_L = 32, PAD_B = 40, PAD_T = 12, PAD_R = 12; const chartW = SVG_W - PAD_L - PAD_R, chartH = SVG_H - PAD_B - PAD_T; const toX = (i) => PAD_L + (i / Math.max(n - 1, 1)) * chartW; // y-axis scales to the metric: 0-100 for %, else a "nice" max over the data. const visForScale = view.series.filter(s => !hidden[s.key]); let maxY = 100; if (!isPct) { let mx = 0; visForScale.forEach(s => s.points.forEach(p => { if (p.score != null && p.score > mx) mx = p.score; })); maxY = Math.max(unit === 'hours' ? 10 : 5, Math.ceil(mx / 5) * 5); } const gridVals = isPct ? [0, 25, 50, 75, 100] : [0, 0.25, 0.5, 0.75, 1].map(f => Math.round(f * maxY)); const toY = (v) => PAD_T + chartH - (Math.max(0, Math.min(maxY, v)) / maxY) * chartH; const stepX = chartW / Math.max(n - 1, 1); // Cutover axis position: first index at/after the cutover date. Separator // sits just left of it; the reconstructed region is shaded. let cutIdx = -1; if (view.cutover) { const cm = view.monthly ? view.cutover.slice(0, 7) : view.cutover; cutIdx = axis.findIndex(a => (view.monthly ? a.slice(0, 7) : a) >= cm); } const cutX = cutIdx > 0 ? toX(cutIdx) - stepX / 2 : (cutIdx === 0 ? PAD_L : -1); const visSeries = view.series.filter(s => !hidden[s.key]); // Actual-data runs stop at a predicted point (it continues as the // dotted projection below, never as part of the solid line). const runsFor = (pts) => { const R = []; let c = []; pts.forEach((p, i) => { if (p.score == null || p.predicted) { if (c.length) { R.push(c); c = []; } } else c.push(i); }); if (c.length) R.push(c); return R; }; // Dotted continuation: last real point + every predicted point after it. const predictedRunFor = (pts) => { let lastActual = -1; pts.forEach((p, i) => { if (p.score != null && !p.predicted) lastActual = i; }); const predIdx = []; pts.forEach((p, i) => { if (p.predicted && p.score != null) predIdx.push(i); }); if (!predIdx.length) return []; return lastActual >= 0 ? [lastActual, ...predIdx] : predIdx; }; const labelEvery = Math.ceil(n / 12); return (
{/* shaded reconstructed (pre-cutover) region */} {cutX > PAD_L && ( {_rT('migration')} )} {gridVals.map(v => { const y = toY(v); return ( {v} ); })} {/* one polyline group per visible series */} {visSeries.map(s => ( {runsFor(s.points).map((run, ri) => ( `${toX(i)},${toY(s.points[i].score)}`).join(' ')} fill="none" stroke={s.color} strokeWidth={isMulti ? 1.6 : 2.2} strokeLinejoin="round" strokeLinecap="round" opacity={0.95} /> ))} {/* Projected continuation — dotted, muted color, distinct from the real-data line */} {forecastActive && (() => { const predRun = predictedRunFor(s.points); return predRun.length > 1 ? ( `${toX(i)},${toY(s.points[i].score)}`).join(' ')} fill="none" stroke="var(--text-3)" strokeWidth={isMulti ? 1.6 : 2.2} strokeDasharray="4,3" strokeLinejoin="round" strokeLinecap="round" opacity={0.85} /> ) : null; })()} {(!isMulti) && s.points.map((p, i) => p.score == null ? null : ( setHovered(i)} onMouseLeave={() => setHovered(null)}> {p.predicted ? `${view.labels[i]}: ${fmtVal(p.score)} (${_rT('projected')})` : `${view.labels[i]}: ${fmtVal(p.score)}`} ))} ))} {/* x labels (thinned) */} {axis.map((a, i) => (i % labelEvery === 0 || i === n - 1) ? ( {view.labels[i]} ) : null)} {showPartial && ( {_rT('(in progress)')} )} {/* "projected" legend chip — single-series views with an active forecast */} {forecastActive && !isMulti && visSeries.some(s => s.points.some(p => p.predicted)) && (
{_rT('projected')}
)} {/* Legend (multi-series): click to toggle */} {isMulti && (
{view.series.map(s => { const off = !!hidden[s.key]; const last = [...s.points].reverse().find(p => p.score != null); return ( ); })}
)}
); })()}
); } function ChartsSection() { const [period, setPeriod] = React.useState('week'); const selected = CHART_PERIODS.find(p => p.value === period) || CHART_PERIODS[1]; return (
{CHART_PERIODS.map(p => ( ))}
} /> {/* Top row: stacked bar + line chart */}
{_rT("Daily attendance breakdown")}
{_rT("Total hours per day")}
{/* Bottom row: donut spans both */}
{_rT("Status distribution")} — {_rT(selected.label)}
); } window.ReportsPage = ReportsPage;