/* ============================================================ 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}
{_rT("Loading…")}
; 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 &&
{_rT("Loading digest…")}
} {!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 toast = useToast(); 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 (
); } /* ── 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)}
); })}
)}
); } /* ── 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 (
} /> {/* Leaderboards */} {/* Charts */} {/* 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) */} {/* Violations (#25) */} {/* Per-user trends (#25) */} {/* B7: per-employee violations report with type filter */} {/* Hours distribution */}
); } /* ── 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
{_rT("Loading…")}
; 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
{_rT("Loading…")}
; 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
{_rT("Loading…")}
; 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 &&
{_rT("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("Loading…")}
{_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', ' ') : '—'}
); } 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;