import { useCallback, useMemo, useState } from 'react'; import { getSikshyaApi, SIKSHYA_ENDPOINTS } from '../api'; import { GatedFeatureWorkspace } from '../components/GatedFeatureWorkspace'; import { ApiErrorPanel } from '../components/shared/ApiErrorPanel'; import { ListPanel } from '../components/shared/list/ListPanel'; import { ButtonPrimary, ButtonSecondary } from '../components/shared/buttons'; import { EmbeddableShell } from '../components/shared/EmbeddableShell'; import { Modal } from '../components/shared/Modal'; import { SingleCoursePicker } from '../components/shared/SingleCoursePicker'; import { useSikshyaDialog } from '../components/shared/SikshyaDialogContext'; 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 GradeScale = { id: number; name: string; use_points: number; gpa_scale_limit: string | number; gpa_separator: string; }; type GradeScaleRow = { label: string; points: number; min_percent: number; max_percent: number; color?: string; sort_order?: number; }; type GradeScaleListResp = { ok?: boolean; scales?: GradeScale[] }; type GradeScaleGetResp = { ok?: boolean; scale?: GradeScale; rows?: GradeScaleRow[] }; export function GradingPage(props: { embedded?: boolean; config: SikshyaReactConfig; title: string }) { const { config, title } = props; const dialog = useSikshyaDialog(); const featureOk = isFeatureEnabled(config, 'gradebook'); const addon = useAddonEnabled('gradebook'); const mode = resolveGatedWorkspaceMode(featureOk, addon.enabled, addon.loading); const enabled = mode === 'full'; const gradeScaleListLoader = useCallback(async () => { if (!enabled) { return { ok: true, scales: [] as GradeScale[] }; } return getSikshyaApi().get(SIKSHYA_ENDPOINTS.pro.gradeScales); }, [enabled]); const { data: scaleListData, loading: scaleListLoading, error: scaleListError, refetch: refetchScales } = useAsyncData( gradeScaleListLoader, [enabled] ); const scales = scaleListData?.scales ?? []; const [activeScaleId, setActiveScaleId] = useState(0); const activeScaleIdResolved = useMemo(() => { if (activeScaleId > 0) return activeScaleId; return scales[0]?.id || 0; }, [activeScaleId, scales]); const gradeScaleLoader = useCallback(async () => { if (!enabled || activeScaleIdResolved <= 0) { return { ok: true, scale: undefined, rows: [] as GradeScaleRow[] }; } return getSikshyaApi().get(SIKSHYA_ENDPOINTS.pro.gradeScale(activeScaleIdResolved)); }, [activeScaleIdResolved, enabled]); const { data: scaleData, loading: scaleLoading, error: scaleError, refetch: refetchScale } = useAsyncData( gradeScaleLoader, [activeScaleIdResolved, enabled] ); const [scaleModalOpen, setScaleModalOpen] = useState(false); const [scaleModalMode, setScaleModalMode] = useState<'create' | 'edit'>('create'); const [scaleName, setScaleName] = useState(''); const [scaleUsePoints, setScaleUsePoints] = useState(true); const [scaleGpaLimit, setScaleGpaLimit] = useState('4.0'); const [scaleGpaSep, setScaleGpaSep] = useState('/'); const [scaleRows, setScaleRows] = useState([ { label: 'A', min_percent: 90, max_percent: 100, points: 4, color: '#22c55e', sort_order: 10 }, { label: 'B', min_percent: 80, max_percent: 89.99, points: 3, color: '#84cc16', sort_order: 20 }, { label: 'C', min_percent: 70, max_percent: 79.99, points: 2, color: '#f59e0b', sort_order: 30 }, { label: 'D', min_percent: 60, max_percent: 69.99, points: 1, color: '#f97316', sort_order: 40 }, { label: 'F', min_percent: 0, max_percent: 59.99, points: 0, color: '#ef4444', sort_order: 50 }, ]); const [savingScale, setSavingScale] = useState(false); const [filterCourseId, setFilterCourseId] = useState(0); const openCreateScale = () => { setScaleModalMode('create'); setScaleName(''); setScaleUsePoints(true); setScaleGpaLimit('4.0'); setScaleGpaSep('/'); setScaleRows([ { label: 'A', min_percent: 90, max_percent: 100, points: 4, color: '#22c55e', sort_order: 10 }, { label: 'B', min_percent: 80, max_percent: 89.99, points: 3, color: '#84cc16', sort_order: 20 }, { label: 'C', min_percent: 70, max_percent: 79.99, points: 2, color: '#f59e0b', sort_order: 30 }, { label: 'D', min_percent: 60, max_percent: 69.99, points: 1, color: '#f97316', sort_order: 40 }, { label: 'F', min_percent: 0, max_percent: 59.99, points: 0, color: '#ef4444', sort_order: 50 }, ]); setScaleModalOpen(true); }; const openEditScale = () => { const s = scaleData?.scale; if (!s) return; setScaleModalMode('edit'); setScaleName(s.name || ''); setScaleUsePoints(Boolean(Number(s.use_points || 0))); setScaleGpaLimit(String(s.gpa_scale_limit ?? '0')); setScaleGpaSep(String(s.gpa_separator ?? '/')); setScaleRows((scaleData?.rows ?? []).map((r, i) => ({ ...r, sort_order: r.sort_order ?? i * 10 }))); setScaleModalOpen(true); }; const saveScale = async () => { if (!enabled) return; setSavingScale(true); try { const payload = { name: scaleName.trim(), use_points: scaleUsePoints, gpa_scale_limit: Number(scaleGpaLimit || 0), gpa_separator: scaleGpaSep || '/', rows: scaleRows.map((r, i) => ({ label: r.label, min_percent: Number(r.min_percent || 0), max_percent: Number(r.max_percent || 0), points: Number(r.points || 0), color: r.color || '', sort_order: r.sort_order ?? i * 10, })), }; if (scaleModalMode === 'create') { await getSikshyaApi().post(SIKSHYA_ENDPOINTS.pro.gradeScales, payload); } else { await getSikshyaApi().put(SIKSHYA_ENDPOINTS.pro.gradeScale(activeScaleIdResolved), payload); } setScaleModalOpen(false); await refetchScales(); await refetchScale(); } finally { setSavingScale(false); } }; const deleteScale = async () => { if (!enabled || activeScaleIdResolved <= 0) return; const ok = await dialog.confirm({ title: __('Delete this grade scale?', 'sikshya'), message: __('This cannot be undone.', 'sikshya'), confirmLabel: __('Delete', 'sikshya'), variant: 'danger', }); if (!ok) return; try { await getSikshyaApi().delete(SIKSHYA_ENDPOINTS.pro.gradeScale(activeScaleIdResolved)); setActiveScaleId(0); await refetchScales(); await refetchScale(); } catch (e) { void dialog.alert({ title: __('Could not delete', 'sikshya'), message: e instanceof Error ? e.message : 'Delete failed.', }); } }; return ( void refetchScales()}> Refresh New scale ) : null } > addon.enable()} addonError={addon.error} > {scaleListError ? ( refetchScales()} /> ) : (
{ if (filterCourseId > 0) { window.location.href = appViewHref(config, 'add-course', { course_id: String(filterCourseId), }); } }} > Open course grading
{scaleListLoading ? (
{__('Loading…', 'sikshya')}
) : scales.length === 0 ? (
{__('No grade scales yet.', 'sikshya')}
) : (
{scales.map((s) => { const active = s.id === activeScaleIdResolved; return ( ); })}
)}
{scaleError ? ( refetchScale()} /> ) : !activeScaleIdResolved ? (
Create a grade scale to get started.
) : (
{scaleData?.scale?.name || `Scale #${activeScaleIdResolved}`}
{Number(scaleData?.scale?.use_points || 0) ? `GPA ${scaleData?.scale?.gpa_scale_limit || ''}${scaleData?.scale?.gpa_separator || '/'}4` : 'Letter scale only'}
Edit void deleteScale()}> Delete
{(scaleData?.rows ?? []).map((r, i) => ( ))}
{__('Grade', 'sikshya')} Min % Max % {__('Points', 'sikshya')}
{r.label} {Number(r.min_percent).toFixed(2)} {Number(r.max_percent).toFixed(2)} {Number(r.points).toFixed(2)}
)}
)} setScaleModalOpen(false)} size="xl" footer={
setScaleModalOpen(false)} disabled={savingScale}> Cancel void saveScale()} disabled={savingScale || !scaleName.trim()}> {savingScale ? __('Saving…', 'sikshya') : __('Save scale', 'sikshya')}
} >
{__('Rows', 'sikshya')}
setScaleRows((prev) => [ ...prev, { label: '', min_percent: 0, max_percent: 0, points: 0, color: '', sort_order: prev.length * 10 }, ]) } > Add row
{scaleRows.map((r, idx) => ( ))}
{__('Label', 'sikshya')} Min % Max % {__('Points', 'sikshya')}
setScaleRows((prev) => prev.map((x, i) => (i === idx ? { ...x, label: e.target.value } : x))) } className="w-24 rounded-md border border-slate-200 px-2 py-1 text-sm dark:border-slate-700 dark:bg-slate-900" placeholder={__('A+', 'sikshya')} /> setScaleRows((prev) => prev.map((x, i) => (i === idx ? { ...x, min_percent: Number(e.target.value) } : x)) ) } className="w-28 rounded-md border border-slate-200 px-2 py-1 text-sm dark:border-slate-700 dark:bg-slate-900" /> setScaleRows((prev) => prev.map((x, i) => (i === idx ? { ...x, max_percent: Number(e.target.value) } : x)) ) } className="w-28 rounded-md border border-slate-200 px-2 py-1 text-sm dark:border-slate-700 dark:bg-slate-900" /> setScaleRows((prev) => prev.map((x, i) => (i === idx ? { ...x, points: Number(e.target.value) } : x)) ) } disabled={!scaleUsePoints} className="w-28 rounded-md border border-slate-200 px-2 py-1 text-sm disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900" />
); }