diff --git a/src/App.tsx b/src/App.tsx index 6977187..9576adf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -96,6 +96,7 @@ const AdminCoursePlanDetail = lazy(() => import("@/pages/admin/AdminCoursePlanDe const AdminCourses = lazy(() => import("@/pages/admin/AdminCourses")); const AdminStudents = lazy(() => import("@/pages/admin/AdminStudents")); const AdminTeachers = lazy(() => import("@/pages/admin/AdminTeachers")); +const AdminBranches = lazy(() => import("@/pages/admin/AdminBranches")); const AdminBatches = lazy(() => import("@/pages/admin/AdminBatches")); const AdminBatchDetail = lazy(() => import("@/pages/admin/AdminBatchDetail")); const AdminTimetable = lazy(() => import("@/pages/admin/AdminTimetable")); @@ -320,10 +321,11 @@ const App = () => ( } /> {/* Original platform dashboard */} } /> - {/* LMS pages */} + {/* LMS pages (entity-scoped, includes branch management) */} } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/AdminLmsLayout.tsx b/src/components/AdminLmsLayout.tsx index 4c41af6..a5d3d01 100644 --- a/src/components/AdminLmsLayout.tsx +++ b/src/components/AdminLmsLayout.tsx @@ -54,6 +54,8 @@ const lmsItems: NavItem[] = [ { titleKey: "nav.courses", url: "/admin/courses", icon: BookOpen }, { titleKey: "nav.students", url: "/admin/students", icon: Users }, { titleKey: "nav.teachers", url: "/admin/teachers", icon: GraduationCap }, + { titleKey: "nav.branches", url: "/admin/branches", icon: GitBranch }, + { titleKey: "nav.classrooms", url: "/admin/classrooms", icon: GraduationCap }, { titleKey: "nav.batches", url: "/admin/batches", icon: Layers }, { titleKey: "nav.timetable", url: "/admin/timetable", icon: Calendar }, { titleKey: "nav.reports", url: "/admin/reports", icon: BarChart3 }, @@ -97,7 +99,6 @@ const institutionalItems: NavItem[] = [ const managementItems: NavItem[] = [ { titleKey: "nav.users", url: "/admin/users", icon: Users }, { titleKey: "nav.entities", url: "/admin/entities", icon: Building2 }, - { titleKey: "nav.classrooms", url: "/admin/classrooms", icon: GraduationCap }, { titleKey: "nav.userRoles", url: "/admin/user-roles", icon: UserCog }, { titleKey: "nav.rolesPermissions", url: "/admin/roles-permissions", icon: Settings }, { titleKey: "nav.authorityMatrix", url: "/admin/authority-matrix", icon: Workflow }, @@ -167,7 +168,7 @@ function SidebarNavGroup({ labelKey, items }: { labelKey: string; items: NavItem const routeLabelKeys: Record = { admin: "breadcrumb.admin", dashboard: "breadcrumb.dashboard", platform: "breadcrumb.platform", courses: "nav.courses", - students: "nav.students", teachers: "nav.teachers", batches: "nav.batches", + students: "nav.students", teachers: "nav.teachers", branches: "nav.branches", batches: "nav.batches", timetable: "nav.timetable", reports: "nav.reports", assignments: "nav.assignments", examsList: "nav.examsList", "exam-structures": "nav.examStructures", rubrics: "nav.rubrics", diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 9f0bfb9..d89876c 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -22,12 +22,12 @@ const mainItems = [ { title: "Rubrics", url: "/rubrics", icon: BookOpen }, { title: "Generation", url: "/generation", icon: Wand2 }, { title: "Approval Workflows", url: "/approval-workflows", icon: GitBranch }, + { title: "Classrooms", url: "/classrooms", icon: GraduationCap }, ]; const managementItems = [ { title: "Users", url: "/users", icon: Users }, { title: "Entities", url: "/entities", icon: Building2 }, - { title: "Classrooms", url: "/classrooms", icon: GraduationCap }, ]; const reportItems = [ @@ -103,7 +103,7 @@ export function AppSidebar() { - + diff --git a/src/components/coursePlan/InteractiveWorkbook.tsx b/src/components/coursePlan/InteractiveWorkbook.tsx new file mode 100644 index 0000000..02edd75 --- /dev/null +++ b/src/components/coursePlan/InteractiveWorkbook.tsx @@ -0,0 +1,867 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Award, + CheckCircle2, + Eye, + GripVertical, + Lightbulb, + Loader2, + RotateCcw, + Save, + XCircle, +} from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Progress } from "@/components/ui/progress"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Textarea } from "@/components/ui/textarea"; +import { useToast } from "@/hooks/use-toast"; +import { cn } from "@/lib/utils"; +import { coursePlanService } from "@/services/coursePlan.service"; +import type { + CoursePlanMaterial, + GapFillExercise, + MatchPairsExercise, + MultipleChoiceExercise, + ReorderWordsExercise, + ShortAnswerExercise, + TransformationExercise, + WorkbookAttempt, + WorkbookAttemptScoreItem, + WorkbookBody, + WorkbookExercise, +} from "@/types"; + +/** + * Workbook renderer for `material_type === "interactive_workbook"`. + * + * Modes: + * - `student` → answers persist server-side (graded, scored, saved). + * - `preview` → no persistence, no Submit; "Show answers" toggle reveals + * the key so the admin can verify the v2 generator's output. + * + * The renderer also accepts a `WorkbookBody` directly (for skill bodies + * that embed `interactive_workbook: { exercises: [...] }`), so it can be + * reused inside other material renderers without duplicating the logic. + */ +export type WorkbookMode = "student" | "preview"; + +interface InteractiveWorkbookProps { + material: Pick< + CoursePlanMaterial, + "id" | "plan_id" | "body" | "title" | "summary" | "material_type" + >; + /** Optional override — when present we read exercises from here instead + * of `material.body`. Used by skill bodies that embed a workbook one + * level deep. */ + bodyOverride?: WorkbookBody; + mode?: WorkbookMode; +} + +type AnswerValue = string | string[] | number[][] | undefined; + +function toExercises(body: unknown): WorkbookExercise[] { + if (!body || typeof body !== "object") return []; + const b = body as Record; + if (Array.isArray(b.exercises)) { + return b.exercises as WorkbookExercise[]; + } + // Skill bodies sometimes nest the workbook under `interactive_workbook`. + const nested = b.interactive_workbook as { exercises?: WorkbookExercise[] } | undefined; + if (nested && Array.isArray(nested.exercises)) { + return nested.exercises; + } + return []; +} + +function toLessonPlan(body: unknown): WorkbookBody["lesson_plan"] | undefined { + if (!body || typeof body !== "object") return undefined; + const b = body as Record; + if (b.lesson_plan && typeof b.lesson_plan === "object") { + return b.lesson_plan as WorkbookBody["lesson_plan"]; + } + const nested = b.interactive_workbook as { lesson_plan?: WorkbookBody["lesson_plan"] } | undefined; + return nested?.lesson_plan; +} + +// Local copies of the backend grading functions for instant per-exercise +// feedback while typing. The authoritative score still comes from the +// server when we POST — these are only used for the "Check" button. +function normText(s: unknown): string { + if (s == null) return ""; + return String(s).replace(/\s+/g, " ").trim().toLowerCase(); +} +function normPunct(s: unknown): string { + return normText(s).replace(/[\s.?!,]+$/u, ""); +} +function globMatch(pattern: string, value: string): boolean { + const pat = normText(pattern); + const val = normText(value); + if (!pat) return false; + if (!pat.includes("*")) return pat === val; + const rx = new RegExp( + "^" + pat.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*") + "$", + ); + return rx.test(val); +} + +function gradeOne(ex: WorkbookExercise, given: AnswerValue): boolean { + if (given === undefined) return false; + switch (ex.type) { + case "gap_fill": { + const accepted = [ex.answer, ...(ex.alt ?? [])]; + const n = normText(given as string); + return accepted.some((a) => normText(a) === n); + } + case "multiple_choice": { + const opts = ex.options ?? []; + const ans = ex.answer; + const g = normText(given as string); + if (g === normText(ans)) return true; + if (typeof ans === "string" && ans.length === 1 && /[a-z]/i.test(ans)) { + const idx = ans.toUpperCase().charCodeAt(0) - 65; + if (opts[idx] && normText(opts[idx]) === g) return true; + } + if (typeof given === "string" && given.length === 1 && /[a-z]/i.test(given)) { + const idx = given.toUpperCase().charCodeAt(0) - 65; + if (opts[idx] && normText(opts[idx]) === normText(ans)) return true; + } + return false; + } + case "match_pairs": { + const pairs = (given as number[][]) ?? []; + const expected = ex.answer ?? []; + const setOf = (rows: number[][]) => + new Set(rows.filter((p) => p.length === 2).map((p) => `${p[0]},${p[1]}`)); + const a = setOf(expected); + const b = setOf(pairs); + if (a.size !== b.size) return false; + for (const v of a) if (!b.has(v)) return false; + return true; + } + case "reorder_words": { + const value = Array.isArray(given) ? (given as string[]).join(" ") : String(given ?? ""); + return normText(value) === normText(ex.answer); + } + case "transformation": { + const accepted = [ex.answer, ...(ex.alt ?? [])]; + const n = normPunct(given as string); + return accepted.some((a) => normPunct(a) === n); + } + case "short_answer": { + const accepted = [...(ex.accepted ?? []), ex.answer].filter(Boolean) as string[]; + return accepted.some((p) => globMatch(p, given as string)); + } + default: + return false; + } +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export default function InteractiveWorkbook({ + material, + bodyOverride, + mode = "student", +}: InteractiveWorkbookProps) { + const exercises = useMemo( + () => toExercises(bodyOverride ?? material.body), + [bodyOverride, material.body], + ); + const lessonPlan = useMemo( + () => toLessonPlan(bodyOverride ?? material.body), + [bodyOverride, material.body], + ); + + const { toast } = useToast(); + const [answers, setAnswers] = useState>({}); + const [revealedIds, setRevealedIds] = useState>(new Set()); + const [showKey, setShowKey] = useState(false); + const [serverScore, setServerScore] = useState<{ + correct: number; + total: number; + percent: number; + items?: WorkbookAttemptScoreItem[]; + is_final?: boolean; + } | null>(null); + const [saving, setSaving] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [loading, setLoading] = useState(mode === "student"); + + // Load existing attempt (student mode only). + useEffect(() => { + if (mode !== "student") return; + let cancelled = false; + (async () => { + try { + const res = await coursePlanService.myWorkbookAttempt( + material.plan_id, + material.id, + ); + if (cancelled) return; + const attempt = res.data; + if (attempt) { + setAnswers((attempt.answers ?? {}) as Record); + if (attempt.score && "items" in attempt.score) { + setServerScore({ + correct: attempt.correct_count, + total: attempt.total_count, + percent: attempt.percent, + items: attempt.score.items, + is_final: attempt.is_final, + }); + } + } + } catch { + // Non-fatal — empty workbook on first open. + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [material.id, material.plan_id, mode]); + + const isFinal = !!serverScore?.is_final; + const liveCorrect = useMemo( + () => exercises.filter((ex) => gradeOne(ex, answers[ex.id])).length, + [answers, exercises], + ); + const livePercent = exercises.length + ? Math.round((liveCorrect / exercises.length) * 100) + : 0; + + const onChange = (id: string, value: AnswerValue) => { + setAnswers((prev) => ({ ...prev, [id]: value })); + }; + + const onCheck = (id: string) => { + setRevealedIds((prev) => new Set(prev).add(id)); + if (mode === "student") void persist(false); + }; + + const persist = async (finalize: boolean) => { + if (mode !== "student") return; + if (finalize) setSubmitting(true); + else setSaving(true); + try { + const res = await coursePlanService.saveWorkbookAttempt( + material.plan_id, + material.id, + { answers, finalize }, + ); + const attempt: WorkbookAttempt = res.data; + setServerScore({ + correct: attempt.correct_count, + total: attempt.total_count, + percent: attempt.percent, + items: attempt.score && "items" in attempt.score ? attempt.score.items : [], + is_final: attempt.is_final, + }); + if (finalize) { + toast({ + title: "Submitted", + description: `${attempt.correct_count} / ${attempt.total_count} correct (${attempt.percent.toFixed(0)}%)`, + }); + } + } catch (err) { + toast({ + title: finalize ? "Submit failed" : "Save failed", + description: err instanceof Error ? err.message : String(err), + variant: "destructive", + }); + } finally { + setSaving(false); + setSubmitting(false); + } + }; + + // Debounce per-keystroke saves: every 1.5s after the last change. + const saveTimer = useRef(null); + useEffect(() => { + if (mode !== "student" || isFinal || loading) return; + if (saveTimer.current) window.clearTimeout(saveTimer.current); + saveTimer.current = window.setTimeout(() => { + void persist(false); + }, 1500); + return () => { + if (saveTimer.current) window.clearTimeout(saveTimer.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [answers]); + + const reset = () => { + setAnswers({}); + setRevealedIds(new Set()); + }; + + if (!exercises.length) { + return ( +
+ This workbook has no exercises yet — generate or extract first. +
+ ); + } + + return ( +
+ {mode === "preview" && ( +
+ + Preview — answers won't be saved. Toggle the + answer key to verify the generated content. + + +
+ )} + + {lessonPlan && Object.values(lessonPlan).some(Boolean) && ( +
+ + Teacher's lesson plan + +
+ {(["warmup", "presentation", "controlled_practice", "freer_practice", "exit_ticket"] as const).map( + (k) => + lessonPlan[k] ? ( +
+
+ {k.replace(/_/g, " ")} +
+
{lessonPlan[k]}
+
+ ) : null, + )} +
+
+ )} + +
+
+
+ + {liveCorrect} + / {exercises.length} correct (live) + {serverScore && ( + + Server: {serverScore.correct}/{serverScore.total} ({serverScore.percent.toFixed(0)}%) + + )} +
+ {mode === "student" && ( +
+ {saving ? ( + <> + Saving… + + ) : ( + <> + {" "} + {serverScore ? "Auto-saved" : "Auto-save on"} + + )} +
+ )} +
+ +
+ +
    + {exercises.map((ex, idx) => { + const value = answers[ex.id]; + const checked = revealedIds.has(ex.id) || isFinal || showKey; + const correct = checked ? gradeOne(ex, value) : null; + return ( +
  1. +
    +
    + Exercise {idx + 1} · {ex.type.replace(/_/g, " ")} +
    + {checked && ( + + {correct ? ( + <> + Correct + + ) : ( + <> + Try again + + )} + + )} +
    + + {renderExercise(ex, value, (v) => onChange(ex.id, v), { + disabled: mode === "preview" || isFinal, + showKey: showKey, + })} + + {ex.hint && !checked && ( +
    + {ex.hint} +
    + )} + + {checked && ( +
    + {!correct && ( +
    + Expected: {formatAnswer(ex)} +
    + )} + {ex.rationale &&
    {ex.rationale}
    } +
    + )} + + {!isFinal && mode !== "preview" && ( +
    + +
    + )} +
  2. + ); + })} +
+ + {mode === "student" && ( +
+ + +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Per-type renderers +// --------------------------------------------------------------------------- + +function renderExercise( + ex: WorkbookExercise, + value: AnswerValue, + onChange: (v: AnswerValue) => void, + opts: { disabled?: boolean; showKey?: boolean }, +): React.ReactNode { + switch (ex.type) { + case "gap_fill": + return ; + case "multiple_choice": + return ( + + ); + case "match_pairs": + return ( + + ); + case "reorder_words": + return ( + + ); + case "transformation": + return ( + + ); + case "short_answer": + return ( + + ); + default: + return

Unsupported exercise type.

; + } +} + +function formatAnswer(ex: WorkbookExercise): string { + switch (ex.type) { + case "match_pairs": + return (ex.answer ?? []) + .map((p) => `${ex.left[p[0]] ?? "?"} ↔ ${ex.right[p[1]] ?? "?"}`) + .join(", "); + case "reorder_words": + return ex.answer; + default: + return String((ex as { answer?: unknown }).answer ?? ""); + } +} + +// --------------------------------------------------------------------------- +// Type-specific input subcomponents +// --------------------------------------------------------------------------- + +function GapFill({ + ex, + value, + onChange, + disabled, +}: { + ex: GapFillExercise; + value: string | undefined; + onChange: (v: string) => void; + disabled?: boolean; +}) { + // Replace ___ in the stem with an inline input. Falls back to a stem + // label + standalone input when there's no underscore marker. + const parts = ex.stem.split(/_{2,}/); + if (parts.length === 1) { + return ( +
+

{ex.stem}

+ onChange(e.target.value)} + disabled={disabled} + placeholder="Type your answer" + /> +
+ ); + } + return ( +

+ {parts.map((part, i) => ( + + {part} + {i < parts.length - 1 && ( + onChange(e.target.value)} + disabled={disabled} + placeholder="…" + /> + )} + + ))} +

+ ); +} + +function MultipleChoice({ + ex, + value, + onChange, + disabled, +}: { + ex: MultipleChoiceExercise; + value: string | undefined; + onChange: (v: string) => void; + disabled?: boolean; +}) { + return ( +
+

{ex.stem}

+ + {ex.options.map((opt, i) => { + const id = `${ex.id}_opt_${i}`; + return ( + + ); + })} + +
+ ); +} + +function MatchPairs({ + ex, + value, + onChange, + disabled, +}: { + ex: MatchPairsExercise; + value: number[][] | undefined; + onChange: (v: number[][]) => void; + disabled?: boolean; +}) { + const [selectedLeft, setSelectedLeft] = useState(null); + const pairs = value ?? []; + const pairedLeft = new Set(pairs.map((p) => p[0])); + const pairedRight = new Set(pairs.map((p) => p[1])); + + const tapLeft = (idx: number) => { + if (disabled) return; + setSelectedLeft(idx); + }; + const tapRight = (idx: number) => { + if (disabled || selectedLeft == null) return; + const next = pairs.filter((p) => p[0] !== selectedLeft && p[1] !== idx); + next.push([selectedLeft, idx]); + onChange(next); + setSelectedLeft(null); + }; + const clear = (li: number) => { + if (disabled) return; + onChange(pairs.filter((p) => p[0] !== li)); + }; + + return ( +
+
+
Match these…
+ {ex.left.map((item, i) => { + const paired = pairs.find((p) => p[0] === i); + return ( + + ); + })} +
+
+
…with these
+ {ex.right.map((item, j) => ( + + ))} +
+
+ Tap a left item, then tap its match on the right. Tap a paired left + item to clear it. +
+ {!pairedLeft.size && ( +
+ {ex.left.length} pair(s) to match. +
+ )} +
+ ); +} + +function ReorderWords({ + ex, + value, + onChange, + disabled, +}: { + ex: ReorderWordsExercise; + value: string[] | undefined; + onChange: (v: string[]) => void; + disabled?: boolean; +}) { + // Stateful tap-to-insert: tap a token in the bank to append; tap a + // token in the answer strip to remove. Keeps it touch-friendly. + const order = value ?? []; + const remaining: { tok: string; bankIdx: number }[] = []; + const used: number[] = []; + // Build maps so duplicate tokens stay independently selectable. + const usedCounts = new Map(); + order.forEach((t) => usedCounts.set(t, (usedCounts.get(t) ?? 0) + 1)); + const seenWhileBuilding = new Map(); + ex.tokens.forEach((tok, idx) => { + const seen = seenWhileBuilding.get(tok) ?? 0; + const usesLeft = (usedCounts.get(tok) ?? 0) - seen; + if (usesLeft > 0) { + used.push(idx); + seenWhileBuilding.set(tok, seen + 1); + } else { + remaining.push({ tok, bankIdx: idx }); + } + }); + + const append = (tok: string) => { + if (disabled) return; + onChange([...order, tok]); + }; + const removeAt = (i: number) => { + if (disabled) return; + onChange(order.filter((_, idx) => idx !== i)); + }; + + return ( +
+
+ {order.length === 0 ? ( + Tap tokens below to build the sentence… + ) : ( + order.map((tok, i) => ( + + )) + )} +
+
+ {remaining.map(({ tok, bankIdx }) => ( + + ))} +
+
+ ); +} + +function Transformation({ + ex, + value, + onChange, + disabled, +}: { + ex: TransformationExercise; + value: string | undefined; + onChange: (v: string) => void; + disabled?: boolean; +}) { + return ( +
+
+ + {ex.instruction} + + {ex.from} +
+