import { useCallback, useEffect, useMemo, useState } from 'react'; import { getSikshyaApi, SIKSHYA_ENDPOINTS } from '../api'; import { AddonSettingsPage } from './AddonSettingsPage'; import { GatedFeatureWorkspace } from '../components/GatedFeatureWorkspace'; import { ApiErrorPanel } from '../components/shared/ApiErrorPanel'; import { ListPanel } from '../components/shared/list/ListPanel'; import { ListEmptyState } from '../components/shared/list/ListEmptyState'; import { ListPaginationBar } from '../components/shared/list/ListPaginationBar'; import { RowActionsMenu, type RowActionItem } from '../components/shared/list/RowActionsMenu'; import { StatusBadge } from '../components/shared/list/StatusBadge'; import { ButtonPrimary, ButtonSecondary } from '../components/shared/buttons'; import { EmbeddableShell } from '../components/shared/EmbeddableShell'; import { SingleCoursePicker } from '../components/shared/SingleCoursePicker'; import { Modal } from '../components/shared/Modal'; import { useAsyncData } from '../hooks/useAsyncData'; import { useAddonEnabled } from '../hooks/useAddons'; import { appViewHref } from '../lib/appUrl'; import { isFeatureEnabled, resolveGatedWorkspaceMode } from '../lib/licensing'; import type { SikshyaReactConfig } from '../types'; import { __ } from '../lib/i18n'; type Row = { user_id: number; user_name?: string; user_email?: string; user_edit_url?: string; course_id: number; course_title?: string; course_edit_url?: string; quizzes_taken: number; avg_quiz_score: number; assignments_graded: number; avg_assignment_grade: number | null; overall_score: number | null; letter_grade?: string | null; gpa_display?: string | null; has_override?: boolean; override_percent?: number | null; override_note?: string | null; }; type Resp = { ok?: boolean; rows?: Row[]; page?: number; per_page?: number; total?: number; total_pages?: number }; type ExportResp = { ok?: boolean; csv?: string; filename?: string }; type LearnerDetail = { ok?: boolean; user_id: number; course_id: number; course_weights?: { wq?: number; wa?: number }; quizzes: Array<{ quiz_id: number; title: string; best_score: number; weight: number }>; assignments: Array<{ assignment_id: number; title: string; grade: number; weight: number }>; computed_percent: number | null; letter_grade?: string | null; gpa_display?: string | null; override?: { override_percent: unknown; note?: string | null } | null; }; type GridItem = { type: 'quiz' | 'assignment'; id: number; title: string; weight: number }; type GridRow = { user: { id: number; name: string; email: string }; overall_percent: number | null; letter_grade?: string | null; gpa_display?: string | null; has_override?: boolean; override_percent?: number | null; cells: Record< string, { value?: number | null; status?: string; submission_id?: number; submitted_at?: string; graded_at?: string } >; }; type GridResp = { ok?: boolean; course_id: number; course_title?: string; course_weights?: { wq?: number; wa?: number }; items: GridItem[]; rows: GridRow[]; page?: number; per_page?: number; total?: number; total_pages?: number; }; type DrillQuiz = { ok?: boolean; course_id: number; user_id: number; item_type: 'quiz'; item_id: number; item_title?: string; attempts: Array<{ id: number; attempt_number: number; score: number; status: string; started_at: string; completed_at: string | null; }>; }; type DrillAssignment = { ok?: boolean; course_id: number; user_id: number; item_type: 'assignment'; item_id: number; item_title?: string; rubric_criteria?: Array<{ index: number; label: string; max_points: number }>; submission: null | { id: number; content: string | null; attachment_ids: string | null; status: string; grade: number | null; feedback: string | null; rubric_scores_json?: string | null; submitted_at: string; graded_at: string | null; }; }; type SubmissionQueueRow = { id: number; assignment_id: number; assignment_title?: string; course_id: number; course_title?: string; user_id: number; user_name?: string; user_email?: string; status: string; grade: number | null; submitted_at: string; graded_at: string | null; }; type SubmissionsIdxResp = { ok?: boolean; rows?: SubmissionQueueRow[]; page?: number; per_page?: number; total?: number; total_pages?: number; }; function parseStoredRubricScores( raw: string | null | undefined, criteria?: Array<{ index: number }>, ): Record { const out: Record = {}; if (!raw || !criteria?.length) return out; try { const v = JSON.parse(raw) as { scores?: Array<{ i?: number; e?: number }> }; const scores = Array.isArray(v.scores) ? v.scores : []; for (const s of scores) { if (typeof s.i === 'number' && s.e !== undefined && s.e !== null) { out[s.i] = String(s.e); } } } catch { return out; } return out; } function formatPct(n: number | null | undefined): string { if (n === null || n === undefined || Number.isNaN(Number(n))) { return '—'; } return Number(n).toFixed(2); } export function GradebookPage(props: { embedded?: boolean; config: SikshyaReactConfig; title: string; /** Sidebar “Assignment submissions” — emphasizes course grid → assignment cells for grading. */ uiMode?: 'default' | 'submissions'; }) { const { config, title, uiMode = 'default' } = props; const featureOk = isFeatureEnabled(config, 'gradebook'); const addon = useAddonEnabled('gradebook'); const mode = resolveGatedWorkspaceMode(featureOk, addon.enabled, addon.loading); const enabled = mode === 'full'; const [courseId, setCourseId] = useState(0); const [search, setSearch] = useState(''); const [page, setPage] = useState(1); const [submissionPage, setSubmissionPage] = useState(1); const [submissionStatus, setSubmissionStatus] = useState(''); const [view, setView] = useState<'summary' | 'grid'>('summary'); const [workspaceTab, setWorkspaceTab] = useState<'grades' | 'settings'>('grades'); useEffect(() => { if (uiMode !== 'submissions') { return; } const cid = Number(config.query?.course_id ?? 0); if (Number.isFinite(cid) && cid > 0) { setCourseId(cid); setPage(1); setSubmissionPage(1); } }, [uiMode, config.query?.course_id]); useEffect(() => { if (uiMode === 'submissions') { setSubmissionPage(1); } }, [uiMode, courseId, search, submissionStatus]); const loader = useCallback(async () => { if (!enabled) { return { ok: true, rows: [] as Row[], page: 1, per_page: 50, total: 0, total_pages: 0 }; } const q = new URLSearchParams(); if (courseId > 0) q.set('course_id', String(courseId)); if (search.trim()) q.set('search', search.trim()); q.set('page', String(page)); q.set('per_page', '50'); const s = q.toString(); const path = s ? `${SIKSHYA_ENDPOINTS.pro.gradebook()}?${s}` : SIKSHYA_ENDPOINTS.pro.gradebook(); return getSikshyaApi().get(path); }, [courseId, enabled, page, search]); const { loading, data, error, refetch } = useAsyncData(loader, [courseId, enabled, page, search]); const rows = data?.rows ?? []; const [exporting, setExporting] = useState(false); const totalPages = Math.max(1, Number(data?.total_pages) || 1); const gridLoader = useCallback(async () => { if (!enabled || courseId <= 0 || view !== 'grid') { return null; } const q = new URLSearchParams(); if (search.trim()) q.set('search', search.trim()); q.set('page', String(page)); q.set('per_page', '50'); const base = SIKSHYA_ENDPOINTS.pro.gradebookGrid(courseId); const s = q.toString(); const path = s ? `${base}${base.includes('?') ? '&' : '?'}${s}` : base; return getSikshyaApi().get(path); }, [courseId, enabled, page, search, view]); const gridState = useAsyncData(gridLoader, [courseId, enabled, page, search, view]); const grid = gridState.data; const submissionsLoader = useCallback(async () => { if (!enabled || uiMode !== 'submissions') { return { ok: true, rows: [] as SubmissionQueueRow[], page: 1, per_page: 25, total: 0, total_pages: 0, }; } return getSikshyaApi().get( SIKSHYA_ENDPOINTS.pro.gradebookAssignmentSubmissions({ course_id: courseId > 0 ? courseId : undefined, search: search.trim() || undefined, status: submissionStatus || undefined, page: submissionPage, per_page: 25, }) ); }, [courseId, enabled, submissionPage, submissionStatus, search, uiMode]); const submissionsState = useAsyncData(submissionsLoader, [ courseId, enabled, submissionPage, submissionStatus, search, uiMode, ]); const submissionRows = submissionsState.data?.rows ?? []; const [drillOpen, setDrillOpen] = useState(false); const [drillBusy, setDrillBusy] = useState(false); const [drillErr, setDrillErr] = useState(null); const [drillData, setDrillData] = useState(null); const [drillCtx, setDrillCtx] = useState<{ user: GridRow['user']; item: GridItem; courseId: number; } | null>(null); const [gradeInput, setGradeInput] = useState(''); const [feedbackInput, setFeedbackInput] = useState(''); const [rubricScoreInputs, setRubricScoreInputs] = useState>({}); const [gradeInlineErr, setGradeInlineErr] = useState(''); const [gradeSaving, setGradeSaving] = useState(false); const loadDrilldown = useCallback( async (u: GridRow['user'], item: GridItem, explicitCourseId?: number) => { const cid = explicitCourseId ?? courseId; if (!enabled || cid <= 0) { return; } setDrillBusy(true); setDrillErr(null); setDrillData(null); setGradeInput(''); setFeedbackInput(''); setRubricScoreInputs({}); setGradeInlineErr(''); try { const d = await getSikshyaApi().get( SIKSHYA_ENDPOINTS.pro.gradebookDrilldown({ course_id: cid, user_id: u.id, item_type: item.type, item_id: item.id, }) ); setDrillData(d); if (d && typeof d === 'object' && 'item_type' in d && d.item_type === 'assignment') { const da = d as DrillAssignment; if (da.submission) { setGradeInput(da.submission.grade != null ? String(da.submission.grade) : ''); setFeedbackInput(typeof da.submission.feedback === 'string' ? da.submission.feedback : ''); const parsed = parseStoredRubricScores(da.submission.rubric_scores_json, da.rubric_criteria); const next: Record = { ...parsed }; (da.rubric_criteria || []).forEach((c) => { if (next[c.index] === undefined) next[c.index] = ''; }); setRubricScoreInputs(next); } else { const next: Record = {}; (da.rubric_criteria || []).forEach((c) => { next[c.index] = ''; }); setRubricScoreInputs(next); } } } catch (e) { setDrillErr(e); } finally { setDrillBusy(false); } }, [courseId, enabled] ); const openDrill = (u: GridRow['user'], item: GridItem, explicitCourseId?: number) => { const cid = explicitCourseId ?? courseId; if (!enabled || cid <= 0) { return; } setDrillCtx({ user: u, item, courseId: cid }); setDrillOpen(true); void loadDrilldown(u, item, cid); }; const closeDrill = useCallback(() => { setDrillOpen(false); setDrillCtx(null); }, []); const saveAssignmentGrade = async () => { if (!enabled || !drillData || drillData.item_type !== 'assignment' || !drillData.submission) return; const da = drillData as DrillAssignment; const sub = da.submission; if (!sub) return; const submissionId = sub.id; const trimmed = gradeInput.trim(); if (trimmed !== '' && !Number.isFinite(Number(trimmed))) return; const criteria = da.rubric_criteria ?? []; const gradeCourseId = drillCtx?.courseId ?? courseId; const body: Record = { submission_id: submissionId, course_id: gradeCourseId, grade: trimmed === '' ? null : Number(trimmed), feedback: feedbackInput.trim(), status: 'graded', }; if (criteria.length > 0) { const parts = criteria.map((c) => { const raw = (rubricScoreInputs[c.index] ?? '').trim(); return { index: c.index, earned: raw }; }); const allFilled = parts.every((p) => p.earned !== '' && Number.isFinite(Number(p.earned))); const anyTouched = parts.some((p) => p.earned !== ''); if (allFilled) { body.rubric_scores = parts.map((p) => ({ index: p.index, earned: Number(p.earned) })); } else if (trimmed === '' && anyTouched) { return; } } setGradeSaving(true); try { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.pro.gradebookAssignmentGrade, body); closeDrill(); await refetch(); gridState.refetch(); if (uiMode === 'submissions') { await submissionsState.refetch(); } } finally { setGradeSaving(false); } }; const [detailOpen, setDetailOpen] = useState(false); const [detailUserId, setDetailUserId] = useState(0); const [detailCourseId, setDetailCourseId] = useState(0); const [detailLabel, setDetailLabel] = useState(''); const [detailFetchVersion, setDetailFetchVersion] = useState(0); const [detailLoading, setDetailLoading] = useState(false); const [detailError, setDetailError] = useState(null); const [detail, setDetail] = useState(null); const [overridePctInput, setOverridePctInput] = useState(''); const [overrideNoteInput, setOverrideNoteInput] = useState(''); const [overrideSaving, setOverrideSaving] = useState(false); const openDetail = (r: Row) => { setDetailUserId(r.user_id); setDetailCourseId(r.course_id); setDetailLabel(`${r.user_name || `User #${r.user_id}`} · ${r.course_title || `Course #${r.course_id}`}`); setDetail(null); setDetailError(null); setOverridePctInput(''); setOverrideNoteInput(''); setDetailFetchVersion((v) => v + 1); setDetailOpen(true); }; useEffect(() => { if (!detailOpen || detailUserId <= 0 || detailCourseId <= 0 || !enabled) { return; } let cancelled = false; setDetailLoading(true); setDetailError(null); void getSikshyaApi() .get( SIKSHYA_ENDPOINTS.pro.gradebookLearner({ user_id: detailUserId, course_id: detailCourseId }) ) .then((d) => { if (cancelled) return; setDetail(d); const ov = d.override; const raw = ov && ov.override_percent !== null && ov.override_percent !== undefined ? ov.override_percent : ''; const pct = raw === '' || raw === null ? '' : String(typeof raw === 'number' ? raw : Number(raw)); setOverridePctInput(Number.isFinite(Number(pct)) ? String(Number(pct)) : ''); setOverrideNoteInput(ov && typeof ov.note === 'string' ? ov.note : ''); }) .catch((e) => { if (!cancelled) { setDetailError(e); setDetail(null); } }) .finally(() => { if (!cancelled) { setDetailLoading(false); } }); return () => { cancelled = true; }; }, [detailOpen, detailUserId, detailCourseId, enabled, detailFetchVersion]); const saveOverride = async () => { if (!enabled || detailUserId <= 0 || detailCourseId <= 0) return; const trimmed = overridePctInput.trim(); if (trimmed !== '') { const n = Number(trimmed); if (!Number.isFinite(n)) { return; } } setOverrideSaving(true); try { if (trimmed === '') { await getSikshyaApi().post<{ ok?: boolean }>(SIKSHYA_ENDPOINTS.pro.gradebookOverride, { user_id: detailUserId, course_id: detailCourseId, override_percent: null, }); } else { const n = Number(trimmed); await getSikshyaApi().post<{ ok?: boolean }>(SIKSHYA_ENDPOINTS.pro.gradebookOverride, { user_id: detailUserId, course_id: detailCourseId, override_percent: n, note: overrideNoteInput.trim(), }); } setDetailOpen(false); await refetch(); } finally { setOverrideSaving(false); } }; const clearOverrideOnly = async () => { if (!enabled || detailUserId <= 0 || detailCourseId <= 0) return; setOverrideSaving(true); try { await getSikshyaApi().post<{ ok?: boolean }>(SIKSHYA_ENDPOINTS.pro.gradebookOverride, { user_id: detailUserId, course_id: detailCourseId, override_percent: null, }); setDetailOpen(false); await refetch(); } finally { setOverrideSaving(false); } }; const exportCsv = async () => { if (!enabled) return; setExporting(true); try { const q = new URLSearchParams(); if (courseId > 0) q.set('course_id', String(courseId)); if (search.trim()) q.set('search', search.trim()); const path = q.toString() !== '' ? `${SIKSHYA_ENDPOINTS.pro.gradebookExport()}?${q.toString()}` : SIKSHYA_ENDPOINTS.pro.gradebookExport(); const r = await getSikshyaApi().get(path); const csv = r.csv || ''; const name = r.filename || 'sikshya-gradebook.csv'; const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } finally { setExporting(false); } }; const wq = detail?.course_weights?.wq; const wa = detail?.course_weights?.wa; const courseWeightsLine = useMemo(() => { if (wq === undefined && wa === undefined) return null; const a = Number(wq); const b = Number(wa); if (!Number.isFinite(a) && !Number.isFinite(b)) return null; return `Course mix: quizzes ${Number.isFinite(a) ? a : '—'}% · assignments ${Number.isFinite(b) ? b : '—'}%`; }, [wq, wa]); const shellSubtitle = uiMode === 'submissions' ? 'Every submission from your courses (newest first). Use optional filters, then open a row to grade. Use the course grid below when you prefer columns per assignment.' : 'Weighted quiz and assignment scores per learner and course—summary list, course grid, CSV export, and staff overrides.'; return ( setWorkspaceTab('settings')}> Gradebook settings window.open(appViewHref(config, 'grading'), '_self')}> Grading & scales refetch()}> Refresh void exportCsv()}> {exporting ? __('Exporting…', 'sikshya') : __('Export CSV', 'sikshya')} ) : null } > addon.enable()} addonError={addon.error} > {enabled ? (
) : null} {enabled && workspaceTab === 'settings' ? ( ) : error ? ( refetch()} /> ) : ( <> {enabled && uiMode === 'submissions' && workspaceTab === 'grades' ? (

{__('Submission queue', 'sikshya')}

Newest first across all courses you can grade. Use the filters in the next panel to match the gradebook summary tools below.

{submissionsState.error ? ( void submissionsState.refetch()} /> ) : submissionsState.loading ? (
{__('Loading submissions…', 'sikshya')}
) : submissionRows.length === 0 ? ( ) : ( <>
{submissionRows.map((r) => ( ))}
{__('Submitted', 'sikshya')} {__('Learner', 'sikshya')} {__('Course', 'sikshya')} {__('Assignment', 'sikshya')} {__('Status', 'sikshya')} {__('Grade', 'sikshya')}
{r.submitted_at || '—'}
{r.user_name || `User #${r.user_id}`}
{r.user_email ? (
{r.user_email}
) : null}
{r.course_title || `Course #${r.course_id}`}
{r.assignment_title || `Assignment #${r.assignment_id}`}
{r.status || '—'} {r.grade != null && !Number.isNaN(Number(r.grade)) ? Number(r.grade).toFixed(2) : '—'} openDrill( { id: r.user_id, name: r.user_name || `User #${r.user_id}`, email: r.user_email || '', }, { type: 'assignment', id: r.assignment_id, title: r.assignment_title || `Assignment #${r.assignment_id}`, weight: 0, }, r.course_id ) } > Grade
)}
) : null} {enabled && uiMode === 'submissions' && workspaceTab === 'grades' ? (
Quick tips: grading assignments
  1. The {__('submission list', 'sikshya')}{' '} shows every upload by default; use course and status filters to narrow it.
  2. Click {__('Grade', 'sikshya')} on a row to open the same panel as the course grid.
  3. The {__('Course grid', 'sikshya')} further below is best when you want columns per assignment—select a course first.
) : null}

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

{ setCourseId(id); setPage(1); if (id <= 0) { setView('summary'); } }} placeholder={__('All courses', 'sikshya')} className="w-full max-w-full" reserveHintSpace={false} density="compact" />
{ setSearch(e.target.value); setPage(1); }} placeholder={__('Learner, email, course…', 'sikshya')} className="mt-1 block w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-white" />
{uiMode === 'submissions' ? (
) : null}
{view === 'grid' && courseId > 0 ? (

Click a cell to view attempts / grade.

) : null}

{uiMode === 'submissions' ? 'Optional: limit the submission list, summary, and export to one course. Choose a course to enable the course grid (one column per quiz or assignment).' : 'Optional: filter the summary and CSV export. Choose a course to unlock the course grid.'}

{view === 'summary' ? ( {loading ? (
{__('Loading…', 'sikshya')}
) : rows.length === 0 ? (
) : ( {courseId <= 0 ? ( ) : gridState.loading ? (
{__('Loading grid…', 'sikshya')}
) : gridState.error ? (
gridState.refetch()} />
) : !grid || (grid.rows?.length ?? 0) === 0 ? ( ) : (
{(grid.items || []).map((it) => ( ))} {grid.rows.map((r) => ( {(grid.items || []).map((it) => { const key = `${it.type}:${it.id}`; const cell = r.cells?.[key] || {}; const v = cell.value; const shown = v == null || Number.isNaN(Number(v)) ? '—' : `${Number(v).toFixed(2)}%`; const subtitle = it.type === 'assignment' && cell.status ? String(cell.status) : ''; return ( ); })} ))}
{__('Learner', 'sikshya')} {__('Overall', 'sikshya')}
{it.title || `${it.type} #${it.id}`}
Weight {Number(it.weight || 1).toFixed(2)}
{r.user.name || `User #${r.user.id}`}
{r.user.email ?
{r.user.email}
: null}
{r.overall_percent === null ? '—' : Number(r.overall_percent).toFixed(2)} {r.has_override ? ( ) : null}
{r.letter_grade || '—'}
)}
)} {totalPages > 1 ? (
Page {page} of {totalPages}
) : null} setDetailOpen(false)} size="lg" footer={
void clearOverrideOnly()}> Clear override
setDetailOpen(false)}> Close void saveOverride()}> {overrideSaving ? __('Saving…', 'sikshya') : __('Save', 'sikshya')}
} > {detailLoading ? (

{__('Loading detail…', 'sikshya')}

) : detailError ? ( { setDetailFetchVersion((v) => v + 1); }} /> ) : detail ? (
{courseWeightsLine ? (

{courseWeightsLine}

) : null}
Overall %
{formatPct(detail.computed_percent)}
{__('Letter', 'sikshya')}
{detail.letter_grade ? String(detail.letter_grade) : '—'}
{__('GPA', 'sikshya')}
{detail.gpa_display ? String(detail.gpa_display) : '—'}

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

{detail.quizzes.length === 0 ? (

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

) : (
{detail.quizzes.map((q) => ( ))}
{__('Quiz', 'sikshya')} Best % {__('Weight', 'sikshya')}
{q.title || `Quiz #${q.quiz_id}`} {q.best_score.toFixed(2)} {q.weight.toFixed(2)}
)}

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

{detail.assignments.length === 0 ? (

{__('No graded assignments yet.', 'sikshya')}

) : (
{detail.assignments.map((a) => ( ))}
{__('Assignment', 'sikshya')} Grade % {__('Weight', 'sikshya')}
{a.title || `Assignment #${a.assignment_id}`} {a.grade.toFixed(2)} {a.weight.toFixed(2)}
)}

{__('Manual override', 'sikshya')}

Set a final course percent (0–100). Leave empty to use the computed value and clear any stored override.

setOverridePctInput(e.target.value)} placeholder={__('e.g. 87.5', 'sikshya')} />