import { useCallback, useEffect, useMemo, useState } from 'react'; import { getSikshyaApi, SIKSHYA_ENDPOINTS } from '../api'; import { EmbeddableShell } from '../components/shared/EmbeddableShell'; import { GatedFeatureWorkspace } from '../components/GatedFeatureWorkspace'; import { ListPanel } from '../components/shared/list/ListPanel'; import { ButtonPrimary } from '../components/shared/buttons'; import { RowActionsMenu, type RowActionItem } from '../components/shared/list/RowActionsMenu'; import { StatusBadge } from '../components/shared/list/StatusBadge'; import { FormInput, FormSelect, FormLabel } from '../components/shared/form'; import { useSikshyaDialog } from '../components/shared/SikshyaDialogContext'; import { useAddonEnabled } from '../hooks/useAddons'; import { isFeatureEnabled, resolveGatedWorkspaceMode } from '../lib/licensing'; import type { SikshyaReactConfig } from '../types'; import { __ } from '../lib/i18n'; type ChartPayload = { labels?: string[]; counts?: number[] }; type StatsPayload = { published_courses?: number; total_enrollments?: number; distinct_learners?: number; completed_enrollments?: number; completion_rate?: number; revenue_html?: string; has_enrollment_table?: boolean; has_payments_table?: boolean; }; type Snapshot = { chart: ChartPayload; stats: StatsPayload; }; type QuizAttemptRow = { id: number; user_id: number; user_name: string; user_email: string; quiz_id: number; quiz_title: string; course_id: number; course_title: string; attempt_number: number; score: number; status: string; started_at: string; completed_at: string; attempts_used: number; attempts_limit: number; attempts_remaining: number | null; is_locked: boolean; }; type QuizAttemptsResponse = { success?: boolean; attempts?: QuizAttemptRow[]; total?: number; pages?: number; page?: number; per_page?: number; table_missing?: boolean; }; type ReportsAdvancedExportResp = { ok?: boolean; csv?: string; filename?: string; truncated?: boolean; row_count?: number; notice?: string; }; type EnterpriseStatusResp = { ok?: boolean; enabled?: boolean; recipient?: string; day_of_week?: number; hour?: number; next_run_unix?: number; next_run_iso?: string; last_run_unix?: number; last_run_iso?: string; last_status?: string; }; type EnterpriseScheduleRow = { id: number; status: 'active' | 'paused' | string; label?: string; report_type?: string; frequency?: 'daily' | 'weekly' | 'monthly' | string; day_of_week?: number; day_of_month?: number; hour?: number; recipients?: string; last_status?: string; last_run_at?: string | null; }; type EnterpriseRunRow = { id: number; schedule_id?: number | null; trigger_source?: string; status?: string; report_type?: string; row_count?: number; truncated?: number; error_message?: string | null; created_at?: string; started_at?: string | null; finished_at?: string | null; }; type EnterpriseDashboardV2Resp = { ok?: boolean; schedules?: EnterpriseScheduleRow[]; runs?: EnterpriseRunRow[]; }; function StatCard(props: { label: string; value: string; hint?: string }) { const { label, value, hint } = props; return (

{label}

{value}

{hint ?

{hint}

: null}
); } function bootSnapshot(config: SikshyaReactConfig): Snapshot { return { chart: (config.initialData.chart as ChartPayload) || {}, stats: (config.initialData.stats as StatsPayload) || {}, }; } export function ReportsPage(props: { config: SikshyaReactConfig; title: string; embedded?: boolean }) { const { config, title, embedded } = props; const dialog = useSikshyaDialog(); const reportsAdvFeature = isFeatureEnabled(config, 'reports_advanced'); const reportsAdvAddon = useAddonEnabled('reports_advanced'); const reportsAdvMode = resolveGatedWorkspaceMode( reportsAdvFeature, reportsAdvAddon.enabled, reportsAdvAddon.loading ); const reportsExportEnabled = reportsAdvMode === 'full'; const [exportBusy, setExportBusy] = useState(false); const [expType, setExpType] = useState<'summary' | 'enrollments' | 'quiz_attempts'>('summary'); const [expCourseId, setExpCourseId] = useState(''); const [expStatus, setExpStatus] = useState(''); const [expSearch, setExpSearch] = useState(''); const [expDateFrom, setExpDateFrom] = useState(''); const [expDateTo, setExpDateTo] = useState(''); const [expUserId, setExpUserId] = useState(''); const [expQuizId, setExpQuizId] = useState(''); const [exportError, setExportError] = useState(null); const [exportHint, setExportHint] = useState(null); const enterpriseFeature = isFeatureEnabled(config, 'enterprise_reports'); const enterpriseAddon = useAddonEnabled('enterprise_reports'); const enterpriseMode = resolveGatedWorkspaceMode( enterpriseFeature, enterpriseAddon.enabled, enterpriseAddon.loading ); const enterpriseEnabled = enterpriseMode === 'full'; const [enterpriseStatus, setEnterpriseStatus] = useState(null); const [enterpriseDash, setEnterpriseDash] = useState(null); const [enterpriseBusy, setEnterpriseBusy] = useState(false); const [enterpriseSendBusy, setEnterpriseSendBusy] = useState(false); const [enterpriseMsg, setEnterpriseMsg] = useState(null); const [enterpriseRecipients, setEnterpriseRecipients] = useState(''); const [enterpriseDow, setEnterpriseDow] = useState(1); const [enterpriseHour, setEnterpriseHour] = useState(9); const [enterpriseSaveBusy, setEnterpriseSaveBusy] = useState(false); const [scheduleCreateBusy, setScheduleCreateBusy] = useState(false); const [scheduleLabel, setScheduleLabel] = useState('Weekly executive summary'); const [scheduleFrequency, setScheduleFrequency] = useState<'daily' | 'weekly' | 'monthly'>('weekly'); const [scheduleReportType, setScheduleReportType] = useState<'executive_summary'>('executive_summary'); const refreshEnterprise = useCallback(async () => { if (!enterpriseEnabled) return; setEnterpriseBusy(true); try { const r = await getSikshyaApi().get(SIKSHYA_ENDPOINTS.pro.enterpriseReportsStatus); setEnterpriseStatus(r); if (typeof r.recipient === 'string') setEnterpriseRecipients(r.recipient); if (typeof r.day_of_week === 'number') setEnterpriseDow(r.day_of_week); if (typeof r.hour === 'number') setEnterpriseHour(r.hour); } catch (e) { setEnterpriseMsg(e instanceof Error ? e.message : 'Could not load status'); } finally { setEnterpriseBusy(false); } }, [enterpriseEnabled]); const refreshEnterpriseDashboard = useCallback(async () => { if (!enterpriseEnabled) return; try { const r = await getSikshyaApi().get(SIKSHYA_ENDPOINTS.pro.enterpriseReportsDashboardV2); setEnterpriseDash(r); } catch { /* ignore; keep legacy status UI usable */ } }, [enterpriseEnabled]); const sendEnterprise = async () => { if (!enterpriseEnabled) return; setEnterpriseMsg(null); setEnterpriseSendBusy(true); try { const r = await getSikshyaApi().post<{ ok?: boolean; message?: string }>( SIKSHYA_ENDPOINTS.pro.enterpriseReportsRun, {} ); setEnterpriseMsg(r.message || 'Sent.'); void refreshEnterprise(); void refreshEnterpriseDashboard(); } catch (e) { setEnterpriseMsg(e instanceof Error ? e.message : 'Failed to send'); } finally { setEnterpriseSendBusy(false); } }; useEffect(() => { if (enterpriseEnabled && !enterpriseStatus) { void refreshEnterprise(); } }, [enterpriseEnabled, enterpriseStatus, refreshEnterprise]); useEffect(() => { if (enterpriseEnabled && !enterpriseDash) { void refreshEnterpriseDashboard(); } }, [enterpriseEnabled, enterpriseDash, refreshEnterpriseDashboard]); const saveEnterpriseSettings = async () => { if (!enterpriseEnabled) return; setEnterpriseMsg(null); setEnterpriseSaveBusy(true); try { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.pro.enterpriseReportsSettings, { enabled: true, recipients: enterpriseRecipients, day_of_week: enterpriseDow, hour: enterpriseHour, }); setEnterpriseMsg('Saved scheduling settings.'); void refreshEnterprise(); void refreshEnterpriseDashboard(); } catch (e) { setEnterpriseMsg(e instanceof Error ? e.message : 'Failed to save settings'); } finally { setEnterpriseSaveBusy(false); } }; const createEnterpriseSchedule = async () => { if (!enterpriseEnabled) return; setEnterpriseMsg(null); setScheduleCreateBusy(true); try { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.pro.enterpriseReportsSchedulesV2, { label: scheduleLabel, report_type: scheduleReportType, frequency: scheduleFrequency, day_of_week: enterpriseDow, hour: enterpriseHour, recipients: enterpriseRecipients, delivery: { email_html: true, email_csv: true, webhook: true }, export: { format: 'csv' }, }); setEnterpriseMsg('Created schedule.'); void refreshEnterpriseDashboard(); } catch (e) { setEnterpriseMsg(e instanceof Error ? e.message : 'Failed to create schedule'); } finally { setScheduleCreateBusy(false); } }; const runScheduleNow = async (scheduleId: number) => { if (!enterpriseEnabled) return; setEnterpriseMsg(null); setEnterpriseSendBusy(true); try { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.pro.enterpriseReportsRunV2, { schedule_id: scheduleId }); setEnterpriseMsg('Run queued. It may take a moment to generate and email.'); void refreshEnterpriseDashboard(); } catch (e) { setEnterpriseMsg(e instanceof Error ? e.message : 'Failed to queue run'); } finally { setEnterpriseSendBusy(false); } }; const boot = useMemo(() => bootSnapshot(config), [config.initialData]); const [snap, setSnap] = useState(() => boot); const [busy, setBusy] = useState(false); const [attemptsBusy, setAttemptsBusy] = useState(false); const [attemptsResp, setAttemptsResp] = useState({}); const [attemptsPage, setAttemptsPage] = useState(1); const [attemptsPerPage] = useState(30); const [attemptsSearch, setAttemptsSearch] = useState(''); const [attemptsStatus, setAttemptsStatus] = useState<'all' | 'completed' | 'in_progress' | 'passed' | 'failed'>('all'); const [attemptsUserId, setAttemptsUserId] = useState(''); const [attemptsCourseId, setAttemptsCourseId] = useState(''); const [attemptsQuizId, setAttemptsQuizId] = useState(''); const refresh = useCallback(async () => { setBusy(true); try { const d = await getSikshyaApi().get>(SIKSHYA_ENDPOINTS.admin.reportsSnapshot); setSnap((prev) => ({ chart: d.chart ?? prev.chart, stats: { ...prev.stats, ...(d.stats || {}) }, })); } catch { /* keep current snapshot */ } finally { setBusy(false); } }, []); const refreshAttempts = useCallback(async (page = 1) => { setAttemptsBusy(true); try { const params = new URLSearchParams(); params.set('per_page', String(attemptsPerPage)); params.set('page', String(page)); if (attemptsSearch.trim()) params.set('search', attemptsSearch.trim()); if (attemptsStatus !== 'all') params.set('status', attemptsStatus); if (attemptsUserId.trim()) params.set('user_id', attemptsUserId.trim()); if (attemptsCourseId.trim()) params.set('course_id', attemptsCourseId.trim()); if (attemptsQuizId.trim()) params.set('quiz_id', attemptsQuizId.trim()); const d = await getSikshyaApi().get(`${SIKSHYA_ENDPOINTS.admin.quizAttempts}?${params.toString()}`); setAttemptsResp(d || {}); setAttemptsPage(page); } catch { setAttemptsResp((prev) => prev || {}); } finally { setAttemptsBusy(false); } }, [attemptsCourseId, attemptsPerPage, attemptsQuizId, attemptsSearch, attemptsStatus, attemptsUserId]); const resetAttemptTimer = useCallback( async (attemptId: number) => { if (!attemptId) return; const ok = await dialog.confirm({ title: __('Reset attempt timer?', 'sikshya'), message: __('This will restart the countdown and clear any in-progress answers for this attempt.', 'sikshya'), confirmLabel: __('Reset timer', 'sikshya'), variant: 'danger', }); if (!ok) return; setAttemptsBusy(true); try { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.admin.quizAttemptResetTimer(attemptId), {}); await refreshAttempts(attemptsPage); } catch { // keep current rows } finally { setAttemptsBusy(false); } }, [attemptsPage, dialog, refreshAttempts] ); useEffect(() => { setSnap(boot); }, [boot]); useEffect(() => { void refresh(); }, [refresh, boot]); useEffect(() => { void refreshAttempts(1); }, [refreshAttempts, boot]); useEffect(() => { void refreshAttempts(1); }, [attemptsSearch, attemptsStatus, attemptsUserId, attemptsCourseId, attemptsQuizId, refreshAttempts]); const exportAdvancedCsv = async () => { if (!reportsExportEnabled) return; setExportBusy(true); setExportError(null); setExportHint(null); try { const courseId = parseInt(expCourseId.replace(/[^\d]/g, ''), 10) || 0; const userId = parseInt(expUserId.replace(/[^\d]/g, ''), 10) || 0; const quizId = parseInt(expQuizId.replace(/[^\d]/g, ''), 10) || 0; const url = SIKSHYA_ENDPOINTS.pro.reportsAdvancedExport({ type: expType, course_id: courseId > 0 ? courseId : undefined, status: expStatus || undefined, search: expSearch.trim() || undefined, date_from: expDateFrom.trim() || undefined, date_to: expDateTo.trim() || undefined, user_id: userId > 0 ? userId : undefined, quiz_id: quizId > 0 ? quizId : undefined, }); const r = await getSikshyaApi().get(url); const csv = r.csv || ''; const name = r.filename || 'sikshya-export.csv'; const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); const dl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = dl; a.download = name; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(dl); const hints: string[] = []; if (r.notice) hints.push(r.notice); if (r.truncated) { hints.push( `Export stopped at the row cap (${typeof r.row_count === 'number' ? r.row_count : ''} rows). Narrow filters or raise the cap in Add-ons → Advanced analytics & exports → settings.` ); } else if (typeof r.row_count === 'number' && expType !== 'summary') { hints.push(`${r.row_count} row(s) exported.`); } setExportHint(hints.length ? hints.join(' ') : null); } catch (e) { setExportError(e instanceof Error ? e.message : 'Export failed'); } finally { setExportBusy(false); } }; const { chart, stats } = snap; const labels = chart?.labels ?? []; const counts = chart?.counts ?? []; const maxCount = counts.length ? Math.max(...counts, 1) : 1; const attemptRows = attemptsResp.attempts ?? []; const attemptsPages = attemptsResp.pages ?? 0; const attemptsTotal = attemptsResp.total ?? 0; return ( void refresh()}> {busy ? __('Refreshing…', 'sikshya') : __('Refresh report', 'sikshya')} } >

{__('Enrollments by month', 'sikshya')}

{__('Last twelve months (UTC month buckets).', 'sikshya')}

{labels.length ? (
{labels.map((label, i) => { const c = counts[i] ?? 0; const h = Math.round((c / maxCount) * 100); return (
0 ? 8 : 2)}%` }} title={`${c} enrollments`} />
{label}
); })}
) : (

No chart data yet. When the enrollments table is available and students enroll, bars will appear here.

)}

{__('Revenue', 'sikshya')}

{__('Completed payments total (if payments table exists).', 'sikshya')}

{stats.revenue_html ? String(stats.revenue_html) : '—'}

{!stats.has_payments_table ? (

Payments table not detected; revenue may show as zero until payments are recorded.

) : null}

{__('Quiz attempts', 'sikshya')}

{attemptsTotal > 0 ? (

{attemptsTotal} total • Page {attemptsPage} {attemptsPages > 0 ? ` of ${attemptsPages}` : ''}

) : null}
void refreshAttempts(attemptsPage)}> {attemptsBusy ? __('Refreshing…', 'sikshya') : __('Refresh', 'sikshya')}
{__('Search', 'sikshya')} setAttemptsSearch(e.target.value)} placeholder={__('Learner name/email, quiz, course…', 'sikshya')} className="mt-1" />
{__('Status', 'sikshya')} setAttemptsStatus(e.target.value as typeof attemptsStatus)} className="mt-1" >
{__('User ID', 'sikshya')} setAttemptsUserId(e.target.value.replace(/[^\d]/g, ''))} placeholder="#" inputMode="numeric" className="mt-1" />
{__('Course ID', 'sikshya')} setAttemptsCourseId(e.target.value.replace(/[^\d]/g, ''))} placeholder="#" inputMode="numeric" className="mt-1" />
{__('Quiz ID', 'sikshya')} setAttemptsQuizId(e.target.value.replace(/[^\d]/g, ''))} placeholder="#" inputMode="numeric" className="mt-1" />
{attemptsResp.table_missing ? (

Quiz attempts table not detected yet. Run plugin migrations and record at least one attempt to see rows here.

) : attemptRows.length ? (
{attemptRows.map((r) => ( ))}
{__('Learner', 'sikshya')} {__('Quiz', 'sikshya')} {__('Course', 'sikshya')} {__('Attempt #', 'sikshya')} {__('Used / Limit', 'sikshya')} {__('Remaining', 'sikshya')} {__('Score', 'sikshya')} {__('Status', 'sikshya')} {__('Completed', 'sikshya')} {__('Actions', 'sikshya')}
{r.user_name || `User #${r.user_id}`}
{r.user_email}
{r.quiz_title || `Quiz #${r.quiz_id}`} {r.course_title || (r.course_id ? `Course #${r.course_id}` : '—')} {r.attempt_number || '—'} {r.attempts_limit > 0 ? `${r.attempts_used} / ${r.attempts_limit}` : `${r.attempts_used} / ∞`} {typeof r.attempts_remaining === 'number' ? r.attempts_remaining : '—'} {Number.isFinite(r.score) ? String(r.score) : '—'} {r.completed_at || '—'} {(() => { const items: RowActionItem[] = [ { key: 'reset-timer', label: 'Reset timer', onClick: () => void resetAttemptTimer(Number(r.id) || 0), disabled: attemptsBusy, }, ]; return ; })()}
) : (

{__('No quiz attempts recorded yet.', 'sikshya')}

)}
reportsAdvAddon.enable()} addonError={reportsAdvAddon.error} >

{__('Spreadsheet exports', 'sikshya')}

Pick a report type, optionally narrow by course or dates, then download. Privacy and row limits live under{' '} Add-ons → Advanced analytics & exports settings.

{reportsExportEnabled ? ( void exportAdvancedCsv()}> {exportBusy ? __('Preparing…', 'sikshya') : __('Download CSV', 'sikshya')} ) : null}
{reportsExportEnabled ? ( <>
{expType !== 'summary' ? (
{expType === 'quiz_attempts' ? ( <> ) : null}
) : null} {exportError ? (

{exportError}

) : null} {exportHint ?

{exportHint}

: null} ) : null}
enterpriseAddon.enable()} addonError={enterpriseAddon.error} >

{__('Weekly summary email', 'sikshya')}

Sends to{' '} {enterpriseStatus?.recipient || 'site admin email'} {enterpriseStatus?.next_run_iso ? ( <> . Next scheduled run:{' '} {enterpriseStatus.next_run_iso} ) : ( '.' )}

{enterpriseStatus?.last_run_iso ? (

Last run: {enterpriseStatus.last_run_iso} {enterpriseStatus.last_status ? ( <> {' '} · status: {enterpriseStatus.last_status} ) : null}

) : null} {enterpriseMsg ? (

{enterpriseMsg}

) : null}
{enterpriseEnabled ? (
void sendEnterprise()}> {enterpriseSendBusy ? __('Sending…', 'sikshya') : __('Send a summary now', 'sikshya')}
) : null}
{enterpriseEnabled ? (
void saveEnterpriseSettings()}> {enterpriseSaveBusy ? __('Saving…', 'sikshya') : __('Save schedule', 'sikshya')}

{__('Enterprise schedules (v2)', 'sikshya')}

Create multiple schedules and queue runs without touching server cron settings. This is admin-only.

void createEnterpriseSchedule()}> {scheduleCreateBusy ? __('Creating…', 'sikshya') : __('Create schedule', 'sikshya')}
{enterpriseDash?.schedules?.length ? (
{enterpriseDash.schedules.map((s) => ( ))}
{__('ID', 'sikshya')} {__('Label', 'sikshya')} {__('Type', 'sikshya')} {__('Cadence', 'sikshya')} {__('Recipients', 'sikshya')} {__('Last status', 'sikshya')} {__('Actions', 'sikshya')}
{s.id} {s.label || '—'} {s.report_type || '—'} {s.frequency || '—'} {typeof s.hour === 'number' ? `@ ${String(s.hour).padStart(2, '0')}:00` : ''} {s.recipients?.trim() ? s.recipients : 'Default admin email'} {s.last_status || '—'}
) : (

{__('No schedules yet. Create one above.', 'sikshya')}

)} {enterpriseDash?.runs?.length ? (

Recent runs

{enterpriseDash.runs.map((r) => ( ))}
{__('Run', 'sikshya')} {__('Schedule', 'sikshya')} {__('Status', 'sikshya')} {__('Type', 'sikshya')} {__('Created', 'sikshya')} {__('Error', 'sikshya')}
{r.id} {r.schedule_id || '—'} {r.status || '—'} {r.report_type || '—'} {r.created_at || '—'} {r.error_message || ''}
) : null}
) : null}
); }