Compare commits
24 Commits
release-v4
...
fdc5097ce8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdc5097ce8 | ||
|
|
a0cee77628 | ||
| c9bf12ad8e | |||
|
|
b4b5868223 | ||
|
|
c2a07a6e5c | ||
|
|
01a32e7785 | ||
|
|
45408acd5c | ||
|
|
bbb2b44ae0 | ||
|
|
dcb7727a4b | ||
|
|
96a2945512 | ||
|
|
a4e92d1f40 | ||
|
|
fbd58fa5a6 | ||
|
|
b02c2e7526 | ||
|
|
9b20d6ce66 | ||
|
|
000969b0b9 | ||
|
|
95938c0079 | ||
|
|
435930a827 | ||
|
|
b78124bb7b | ||
|
|
1d7ebea2af | ||
|
|
2f450a7f26 | ||
|
|
e066c595b9 | ||
|
|
74c2c9f2d2 | ||
|
|
5b2ccbfeec | ||
|
|
11a7265460 |
@@ -96,6 +96,7 @@ const AdminCoursePlanDetail = lazy(() => import("@/pages/admin/AdminCoursePlanDe
|
|||||||
const AdminCourses = lazy(() => import("@/pages/admin/AdminCourses"));
|
const AdminCourses = lazy(() => import("@/pages/admin/AdminCourses"));
|
||||||
const AdminStudents = lazy(() => import("@/pages/admin/AdminStudents"));
|
const AdminStudents = lazy(() => import("@/pages/admin/AdminStudents"));
|
||||||
const AdminTeachers = lazy(() => import("@/pages/admin/AdminTeachers"));
|
const AdminTeachers = lazy(() => import("@/pages/admin/AdminTeachers"));
|
||||||
|
const AdminBranches = lazy(() => import("@/pages/admin/AdminBranches"));
|
||||||
const AdminBatches = lazy(() => import("@/pages/admin/AdminBatches"));
|
const AdminBatches = lazy(() => import("@/pages/admin/AdminBatches"));
|
||||||
const AdminBatchDetail = lazy(() => import("@/pages/admin/AdminBatchDetail"));
|
const AdminBatchDetail = lazy(() => import("@/pages/admin/AdminBatchDetail"));
|
||||||
const AdminTimetable = lazy(() => import("@/pages/admin/AdminTimetable"));
|
const AdminTimetable = lazy(() => import("@/pages/admin/AdminTimetable"));
|
||||||
@@ -320,10 +321,11 @@ const App = () => (
|
|||||||
<Route path="/admin/course-plans/:planId" element={<AdminCoursePlanDetail />} />
|
<Route path="/admin/course-plans/:planId" element={<AdminCoursePlanDetail />} />
|
||||||
{/* Original platform dashboard */}
|
{/* Original platform dashboard */}
|
||||||
<Route path="/admin/platform" element={<AdminDashboard />} />
|
<Route path="/admin/platform" element={<AdminDashboard />} />
|
||||||
{/* LMS pages */}
|
{/* LMS pages (entity-scoped, includes branch management) */}
|
||||||
<Route path="/admin/courses" element={<AdminCourses />} />
|
<Route path="/admin/courses" element={<AdminCourses />} />
|
||||||
<Route path="/admin/students" element={<AdminStudents />} />
|
<Route path="/admin/students" element={<AdminStudents />} />
|
||||||
<Route path="/admin/teachers" element={<AdminTeachers />} />
|
<Route path="/admin/teachers" element={<AdminTeachers />} />
|
||||||
|
<Route path="/admin/branches" element={<AdminBranches />} />
|
||||||
<Route path="/admin/batches" element={<AdminBatches />} />
|
<Route path="/admin/batches" element={<AdminBatches />} />
|
||||||
<Route path="/admin/batches/:id" element={<AdminBatchDetail />} />
|
<Route path="/admin/batches/:id" element={<AdminBatchDetail />} />
|
||||||
<Route path="/admin/timetable" element={<AdminTimetable />} />
|
<Route path="/admin/timetable" element={<AdminTimetable />} />
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ const lmsItems: NavItem[] = [
|
|||||||
{ titleKey: "nav.courses", url: "/admin/courses", icon: BookOpen },
|
{ titleKey: "nav.courses", url: "/admin/courses", icon: BookOpen },
|
||||||
{ titleKey: "nav.students", url: "/admin/students", icon: Users },
|
{ titleKey: "nav.students", url: "/admin/students", icon: Users },
|
||||||
{ titleKey: "nav.teachers", url: "/admin/teachers", icon: GraduationCap },
|
{ 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.batches", url: "/admin/batches", icon: Layers },
|
||||||
{ titleKey: "nav.timetable", url: "/admin/timetable", icon: Calendar },
|
{ titleKey: "nav.timetable", url: "/admin/timetable", icon: Calendar },
|
||||||
{ titleKey: "nav.reports", url: "/admin/reports", icon: BarChart3 },
|
{ titleKey: "nav.reports", url: "/admin/reports", icon: BarChart3 },
|
||||||
@@ -97,7 +99,6 @@ const institutionalItems: NavItem[] = [
|
|||||||
const managementItems: NavItem[] = [
|
const managementItems: NavItem[] = [
|
||||||
{ titleKey: "nav.users", url: "/admin/users", icon: Users },
|
{ titleKey: "nav.users", url: "/admin/users", icon: Users },
|
||||||
{ titleKey: "nav.entities", url: "/admin/entities", icon: Building2 },
|
{ 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.userRoles", url: "/admin/user-roles", icon: UserCog },
|
||||||
{ titleKey: "nav.rolesPermissions", url: "/admin/roles-permissions", icon: Settings },
|
{ titleKey: "nav.rolesPermissions", url: "/admin/roles-permissions", icon: Settings },
|
||||||
{ titleKey: "nav.authorityMatrix", url: "/admin/authority-matrix", icon: Workflow },
|
{ titleKey: "nav.authorityMatrix", url: "/admin/authority-matrix", icon: Workflow },
|
||||||
@@ -167,7 +168,7 @@ function SidebarNavGroup({ labelKey, items }: { labelKey: string; items: NavItem
|
|||||||
const routeLabelKeys: Record<string, string> = {
|
const routeLabelKeys: Record<string, string> = {
|
||||||
admin: "breadcrumb.admin", dashboard: "breadcrumb.dashboard",
|
admin: "breadcrumb.admin", dashboard: "breadcrumb.dashboard",
|
||||||
platform: "breadcrumb.platform", courses: "nav.courses",
|
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",
|
timetable: "nav.timetable", reports: "nav.reports",
|
||||||
assignments: "nav.assignments", examsList: "nav.examsList",
|
assignments: "nav.assignments", examsList: "nav.examsList",
|
||||||
"exam-structures": "nav.examStructures", rubrics: "nav.rubrics",
|
"exam-structures": "nav.examStructures", rubrics: "nav.rubrics",
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ const mainItems = [
|
|||||||
{ title: "Rubrics", url: "/rubrics", icon: BookOpen },
|
{ title: "Rubrics", url: "/rubrics", icon: BookOpen },
|
||||||
{ title: "Generation", url: "/generation", icon: Wand2 },
|
{ title: "Generation", url: "/generation", icon: Wand2 },
|
||||||
{ title: "Approval Workflows", url: "/approval-workflows", icon: GitBranch },
|
{ title: "Approval Workflows", url: "/approval-workflows", icon: GitBranch },
|
||||||
|
{ title: "Classrooms", url: "/classrooms", icon: GraduationCap },
|
||||||
];
|
];
|
||||||
|
|
||||||
const managementItems = [
|
const managementItems = [
|
||||||
{ title: "Users", url: "/users", icon: Users },
|
{ title: "Users", url: "/users", icon: Users },
|
||||||
{ title: "Entities", url: "/entities", icon: Building2 },
|
{ title: "Entities", url: "/entities", icon: Building2 },
|
||||||
{ title: "Classrooms", url: "/classrooms", icon: GraduationCap },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const reportItems = [
|
const reportItems = [
|
||||||
@@ -103,7 +103,7 @@ export function AppSidebar() {
|
|||||||
<SidebarSeparator />
|
<SidebarSeparator />
|
||||||
|
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<SidebarNavGroup label="Academic" items={mainItems} />
|
<SidebarNavGroup label="LMS" items={mainItems} />
|
||||||
<SidebarNavGroup label="Management" items={managementItems} />
|
<SidebarNavGroup label="Management" items={managementItems} />
|
||||||
<SidebarNavGroup label="Reports" items={reportItems} />
|
<SidebarNavGroup label="Reports" items={reportItems} />
|
||||||
|
|
||||||
|
|||||||
867
src/components/coursePlan/InteractiveWorkbook.tsx
Normal file
867
src/components/coursePlan/InteractiveWorkbook.tsx
Normal file
@@ -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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<WorkbookExercise[]>(
|
||||||
|
() => toExercises(bodyOverride ?? material.body),
|
||||||
|
[bodyOverride, material.body],
|
||||||
|
);
|
||||||
|
const lessonPlan = useMemo(
|
||||||
|
() => toLessonPlan(bodyOverride ?? material.body),
|
||||||
|
[bodyOverride, material.body],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [answers, setAnswers] = useState<Record<string, AnswerValue>>({});
|
||||||
|
const [revealedIds, setRevealedIds] = useState<Set<string>>(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<string, AnswerValue>);
|
||||||
|
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<number | null>(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 (
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-6 text-sm text-muted-foreground">
|
||||||
|
This workbook has no exercises yet — generate or extract first.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{mode === "preview" && (
|
||||||
|
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-900 flex items-center justify-between">
|
||||||
|
<span>
|
||||||
|
<strong>Preview</strong> — answers won't be saved. Toggle the
|
||||||
|
answer key to verify the generated content.
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={showKey ? "default" : "outline"}
|
||||||
|
onClick={() => setShowKey((v) => !v)}
|
||||||
|
>
|
||||||
|
<Eye className="mr-1 h-4 w-4" />
|
||||||
|
{showKey ? "Hide answers" : "Show answers"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lessonPlan && Object.values(lessonPlan).some(Boolean) && (
|
||||||
|
<details className="rounded-lg border bg-background/70 p-3">
|
||||||
|
<summary className="cursor-pointer text-sm font-medium">
|
||||||
|
Teacher's lesson plan
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 grid gap-2 sm:grid-cols-2 text-sm">
|
||||||
|
{(["warmup", "presentation", "controlled_practice", "freer_practice", "exit_ticket"] as const).map(
|
||||||
|
(k) =>
|
||||||
|
lessonPlan[k] ? (
|
||||||
|
<div key={k}>
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
{k.replace(/_/g, " ")}
|
||||||
|
</div>
|
||||||
|
<div className="leading-6">{lessonPlan[k]}</div>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-background/70 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Award className="h-4 w-4 text-primary" />
|
||||||
|
<strong>{liveCorrect}</strong>
|
||||||
|
<span className="text-muted-foreground">/ {exercises.length} correct (live)</span>
|
||||||
|
{serverScore && (
|
||||||
|
<Badge variant="secondary" className="ml-2">
|
||||||
|
Server: {serverScore.correct}/{serverScore.total} ({serverScore.percent.toFixed(0)}%)
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{mode === "student" && (
|
||||||
|
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" /> Saving…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="h-3 w-3" />{" "}
|
||||||
|
{serverScore ? "Auto-saved" : "Auto-save on"}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Progress value={livePercent} className="h-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol className="space-y-3 list-none p-0">
|
||||||
|
{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 (
|
||||||
|
<li
|
||||||
|
key={ex.id || idx}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border p-4 bg-background/70 space-y-3",
|
||||||
|
checked && correct === true && "border-emerald-400 bg-emerald-50/40",
|
||||||
|
checked && correct === false && "border-rose-400 bg-rose-50/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Exercise {idx + 1} · {ex.type.replace(/_/g, " ")}
|
||||||
|
</div>
|
||||||
|
{checked && (
|
||||||
|
<Badge variant={correct ? "default" : "destructive"} className="capitalize">
|
||||||
|
{correct ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="mr-1 h-3 w-3" /> Correct
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XCircle className="mr-1 h-3 w-3" /> Try again
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{renderExercise(ex, value, (v) => onChange(ex.id, v), {
|
||||||
|
disabled: mode === "preview" || isFinal,
|
||||||
|
showKey: showKey,
|
||||||
|
})}
|
||||||
|
|
||||||
|
{ex.hint && !checked && (
|
||||||
|
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Lightbulb className="h-3 w-3" /> {ex.hint}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{checked && (
|
||||||
|
<div className="text-xs text-muted-foreground space-y-1">
|
||||||
|
{!correct && (
|
||||||
|
<div>
|
||||||
|
Expected: <span className="font-medium">{formatAnswer(ex)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ex.rationale && <div>{ex.rationale}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isFinal && mode !== "preview" && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onCheck(ex.id)}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="mr-1 h-4 w-4" /> Check
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{mode === "student" && (
|
||||||
|
<div className="flex items-center justify-between rounded-lg border bg-background/70 p-3">
|
||||||
|
<Button type="button" variant="ghost" onClick={reset} disabled={isFinal}>
|
||||||
|
<RotateCcw className="mr-1 h-4 w-4" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={submitting || isFinal}
|
||||||
|
onClick={() => persist(true)}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-1 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{isFinal ? "Submitted" : "Submit final"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 <GapFill ex={ex} value={value as string} onChange={onChange} disabled={opts.disabled} />;
|
||||||
|
case "multiple_choice":
|
||||||
|
return (
|
||||||
|
<MultipleChoice
|
||||||
|
ex={ex}
|
||||||
|
value={value as string}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={opts.disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "match_pairs":
|
||||||
|
return (
|
||||||
|
<MatchPairs
|
||||||
|
ex={ex}
|
||||||
|
value={value as number[][]}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={opts.disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "reorder_words":
|
||||||
|
return (
|
||||||
|
<ReorderWords
|
||||||
|
ex={ex}
|
||||||
|
value={value as string[]}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={opts.disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "transformation":
|
||||||
|
return (
|
||||||
|
<Transformation
|
||||||
|
ex={ex}
|
||||||
|
value={value as string}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={opts.disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "short_answer":
|
||||||
|
return (
|
||||||
|
<ShortAnswer
|
||||||
|
ex={ex}
|
||||||
|
value={value as string}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={opts.disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <p className="text-sm text-muted-foreground">Unsupported exercise type.</p>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="leading-7">{ex.stem}</p>
|
||||||
|
<Input
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="Type your answer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<p className="leading-8 text-base flex flex-wrap items-center gap-1">
|
||||||
|
{parts.map((part, i) => (
|
||||||
|
<span key={i} className="inline-flex items-center gap-1">
|
||||||
|
<span>{part}</span>
|
||||||
|
{i < parts.length - 1 && (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
className="inline-block w-32 align-baseline"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="…"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MultipleChoice({
|
||||||
|
ex,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
ex: MultipleChoiceExercise;
|
||||||
|
value: string | undefined;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="leading-7">{ex.stem}</p>
|
||||||
|
<RadioGroup
|
||||||
|
value={value ?? ""}
|
||||||
|
onValueChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
className="grid gap-2 sm:grid-cols-2"
|
||||||
|
>
|
||||||
|
{ex.options.map((opt, i) => {
|
||||||
|
const id = `${ex.id}_opt_${i}`;
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
key={id}
|
||||||
|
htmlFor={id}
|
||||||
|
className="flex items-start gap-2 rounded-md border p-2 cursor-pointer hover:bg-muted"
|
||||||
|
>
|
||||||
|
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
|
||||||
|
<span className="text-sm">{opt}</span>
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatchPairs({
|
||||||
|
ex,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
ex: MatchPairsExercise;
|
||||||
|
value: number[][] | undefined;
|
||||||
|
onChange: (v: number[][]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const [selectedLeft, setSelectedLeft] = useState<number | null>(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 (
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted-foreground">Match these…</div>
|
||||||
|
{ex.left.map((item, i) => {
|
||||||
|
const paired = pairs.find((p) => p[0] === i);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={`L${i}`}
|
||||||
|
onClick={() => (paired ? clear(i) : tapLeft(i))}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"block w-full rounded border px-2 py-1.5 text-left",
|
||||||
|
selectedLeft === i && "border-primary ring-1 ring-primary",
|
||||||
|
paired && "bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground mr-1">{i + 1}.</span>
|
||||||
|
{item}
|
||||||
|
{paired ? (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
→ {ex.right[paired[1]]}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted-foreground">…with these</div>
|
||||||
|
{ex.right.map((item, j) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={`R${j}`}
|
||||||
|
onClick={() => tapRight(j)}
|
||||||
|
disabled={disabled || selectedLeft == null}
|
||||||
|
className={cn(
|
||||||
|
"block w-full rounded border px-2 py-1.5 text-left",
|
||||||
|
pairedRight.has(j) && "bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground mr-1">{String.fromCharCode(65 + j)}.</span>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-xs text-muted-foreground">
|
||||||
|
Tap a left item, then tap its match on the right. Tap a paired left
|
||||||
|
item to clear it.
|
||||||
|
</div>
|
||||||
|
{!pairedLeft.size && (
|
||||||
|
<div className="col-span-2 text-xs text-muted-foreground">
|
||||||
|
{ex.left.length} pair(s) to match.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, number>();
|
||||||
|
order.forEach((t) => usedCounts.set(t, (usedCounts.get(t) ?? 0) + 1));
|
||||||
|
const seenWhileBuilding = new Map<string, number>();
|
||||||
|
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 (
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="rounded border bg-muted/30 p-2 min-h-10 flex flex-wrap gap-1">
|
||||||
|
{order.length === 0 ? (
|
||||||
|
<span className="text-xs text-muted-foreground">Tap tokens below to build the sentence…</span>
|
||||||
|
) : (
|
||||||
|
order.map((tok, i) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={`a${i}`}
|
||||||
|
onClick={() => removeAt(i)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="rounded bg-background border px-2 py-1"
|
||||||
|
>
|
||||||
|
<GripVertical className="mr-1 inline h-3 w-3 text-muted-foreground" />
|
||||||
|
{tok}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{remaining.map(({ tok, bankIdx }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={`b${bankIdx}`}
|
||||||
|
onClick={() => append(tok)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="rounded border px-2 py-1 hover:bg-muted"
|
||||||
|
>
|
||||||
|
{tok}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Transformation({
|
||||||
|
ex,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
ex: TransformationExercise;
|
||||||
|
value: string | undefined;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="rounded border bg-muted/30 px-3 py-2 text-sm">
|
||||||
|
<Badge variant="outline" className="mr-2">
|
||||||
|
{ex.instruction}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-medium">{ex.from}</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Transformed sentence"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShortAnswer({
|
||||||
|
ex,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
ex: ShortAnswerExercise;
|
||||||
|
value: string | undefined;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="leading-7">{ex.stem}</p>
|
||||||
|
<Textarea
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Type your answer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CoursePlanMaterial } from "@/types";
|
import type { CoursePlanMaterial, WorkbookBody } from "@/types";
|
||||||
|
|
||||||
|
import InteractiveWorkbook, { type WorkbookMode } from "./InteractiveWorkbook";
|
||||||
|
|
||||||
export const SKILL_STYLE: Record<string, string> = {
|
export const SKILL_STYLE: Record<string, string> = {
|
||||||
reading: "bg-blue-100 text-blue-800 border-blue-200",
|
reading: "bg-blue-100 text-blue-800 border-blue-200",
|
||||||
@@ -56,23 +58,82 @@ function renderAny(value: unknown): React.ReactNode {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MaterialForBook = Pick<
|
||||||
|
CoursePlanMaterial,
|
||||||
|
"id" | "plan_id" | "body" | "body_text" | "material_type" | "title" | "summary"
|
||||||
|
>;
|
||||||
|
|
||||||
export default function MaterialBookView({
|
export default function MaterialBookView({
|
||||||
material,
|
material,
|
||||||
|
mode = "student",
|
||||||
}: {
|
}: {
|
||||||
material: Pick<CoursePlanMaterial, "body" | "body_text" | "material_type">;
|
// The legacy callsites only pass body/body_text/material_type, so we
|
||||||
|
// tolerate that by making id/plan_id optional in the prop type even
|
||||||
|
// though the types/index file marks them as required. Required props
|
||||||
|
// are validated at runtime when interactive_workbook is dispatched.
|
||||||
|
material: Partial<MaterialForBook> &
|
||||||
|
Pick<CoursePlanMaterial, "body" | "body_text" | "material_type">;
|
||||||
|
mode?: WorkbookMode;
|
||||||
}) {
|
}) {
|
||||||
const body = material.body ?? {};
|
const body = material.body ?? {};
|
||||||
const hasStructured = Object.keys(body).length > 0;
|
const hasStructured = Object.keys(body).length > 0;
|
||||||
|
|
||||||
|
// Dispatch to the interactive renderer for workbook materials. The
|
||||||
|
// skill-bodied workbooks (grammar/vocabulary that embed their own
|
||||||
|
// `interactive_workbook` block) render via the generic walker AND
|
||||||
|
// the embedded workbook below for the exercises portion.
|
||||||
|
if (
|
||||||
|
material.material_type === "interactive_workbook" &&
|
||||||
|
typeof material.id === "number" &&
|
||||||
|
typeof material.plan_id === "number"
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<InteractiveWorkbook
|
||||||
|
material={material as MaterialForBook}
|
||||||
|
mode={mode}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For grammar / vocabulary that embed an interactive workbook inside
|
||||||
|
// their body, we render the prose first and then the workbook UI so
|
||||||
|
// students can practise without leaving the lesson.
|
||||||
|
const embeddedWorkbook = (body as { interactive_workbook?: WorkbookBody })
|
||||||
|
.interactive_workbook;
|
||||||
|
const hasEmbedded =
|
||||||
|
embeddedWorkbook &&
|
||||||
|
Array.isArray(embeddedWorkbook.exercises) &&
|
||||||
|
embeddedWorkbook.exercises.length > 0 &&
|
||||||
|
typeof material.id === "number" &&
|
||||||
|
typeof material.plan_id === "number";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-background/70 p-4 space-y-4">
|
<div className="rounded-lg border bg-background/70 p-4 space-y-4">
|
||||||
{hasStructured ? (
|
{hasStructured ? (
|
||||||
renderAny(body)
|
renderAny(stripEmbedded(body))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm whitespace-pre-wrap leading-7">
|
<p className="text-sm whitespace-pre-wrap leading-7">
|
||||||
{material.body_text || "No content available yet."}
|
{material.body_text || "No content available yet."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasEmbedded && (
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<div className="text-sm font-medium mb-2">Practice</div>
|
||||||
|
<InteractiveWorkbook
|
||||||
|
material={material as MaterialForBook}
|
||||||
|
bodyOverride={embeddedWorkbook!}
|
||||||
|
mode={mode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripEmbedded(body: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
if (!body || typeof body !== "object") return body;
|
||||||
|
if (!("interactive_workbook" in body)) return body;
|
||||||
|
const { interactive_workbook: _drop, ...rest } = body;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|||||||
390
src/components/coursePlan/PlanReader.tsx
Normal file
390
src/components/coursePlan/PlanReader.tsx
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
Calendar,
|
||||||
|
ClipboardList,
|
||||||
|
Headphones,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Library,
|
||||||
|
Link2,
|
||||||
|
MessageSquare,
|
||||||
|
Mic,
|
||||||
|
Music,
|
||||||
|
PenSquare,
|
||||||
|
Sparkles,
|
||||||
|
Type,
|
||||||
|
Video,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
HoverCard,
|
||||||
|
HoverCardContent,
|
||||||
|
HoverCardTrigger,
|
||||||
|
} from "@/components/ui/hover-card";
|
||||||
|
import { withAuthQuery } from "@/lib/api-client";
|
||||||
|
import MaterialBookView, {
|
||||||
|
SkillBadge,
|
||||||
|
} from "@/components/coursePlan/MaterialBookView";
|
||||||
|
import type { WorkbookMode } from "@/components/coursePlan/InteractiveWorkbook";
|
||||||
|
import type {
|
||||||
|
CoursePlan,
|
||||||
|
CoursePlanMaterial,
|
||||||
|
CoursePlanMedia,
|
||||||
|
CoursePlanWeek,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
|
const SKILL_ICONS: Record<string, LucideIcon> = {
|
||||||
|
reading: BookOpen,
|
||||||
|
writing: PenSquare,
|
||||||
|
listening: Headphones,
|
||||||
|
speaking: Mic,
|
||||||
|
grammar: Type,
|
||||||
|
vocabulary: Library,
|
||||||
|
integrated: MessageSquare,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only renderer for a course plan that BOTH the student page and
|
||||||
|
* the admin "View as Student" preview share. The only difference is
|
||||||
|
* the workbook `mode`:
|
||||||
|
*
|
||||||
|
* - `mode="student"` → answers persist server-side (real attempts).
|
||||||
|
* - `mode="preview"` → InteractiveWorkbook runs in preview mode (no
|
||||||
|
* persistence) and surfaces an "Show answer key" toggle so admins
|
||||||
|
* can verify the v2 generator's output.
|
||||||
|
*
|
||||||
|
* Materials are rendered through `MaterialBookView`, which dispatches
|
||||||
|
* `interactive_workbook` materials into the live workbook component.
|
||||||
|
*/
|
||||||
|
export interface PlanReaderProps {
|
||||||
|
plan: CoursePlan;
|
||||||
|
mode?: WorkbookMode;
|
||||||
|
/** Heading level for accessibility — admins use this inside their own
|
||||||
|
* Card; students use it as the page's main grid block. Defaults to 'h3'. */
|
||||||
|
headingClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlanReader({ plan, mode = "student" }: PlanReaderProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const materialsByWeek = useMemo(() => {
|
||||||
|
const map = new Map<number, CoursePlanMaterial[]>();
|
||||||
|
if (!plan.materials) return map;
|
||||||
|
for (const m of plan.materials) {
|
||||||
|
const list = map.get(m.week_number) ?? [];
|
||||||
|
list.push(m);
|
||||||
|
map.set(m.week_number, list);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [plan.materials]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-2xl">{plan.name}</CardTitle>
|
||||||
|
{plan.description && (
|
||||||
|
<CardDescription className="max-w-3xl">
|
||||||
|
{plan.description}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Badge variant="secondary" className="uppercase">
|
||||||
|
{plan.cefr_level}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{t("coursePlan.hoursPerWeek", {
|
||||||
|
count: plan.contact_hours_per_week,
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{plan.assignment && (
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<Calendar className="h-3.5 w-3.5" />
|
||||||
|
{plan.assignment.due_date
|
||||||
|
? t("coursePlan.student.due", { date: plan.assignment.due_date })
|
||||||
|
: t("coursePlan.student.noDue")}
|
||||||
|
{plan.assignment.assigned_by_name && (
|
||||||
|
<span>
|
||||||
|
·{" "}
|
||||||
|
{t("coursePlan.student.assignedBy", {
|
||||||
|
name: plan.assignment.assigned_by_name,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{plan.assignment?.message && (
|
||||||
|
<p className="text-sm bg-muted/40 rounded-md px-3 py-2 mt-2">
|
||||||
|
{plan.assignment.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{plan.objectives && plan.objectives.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<ClipboardList className="h-4 w-4 text-primary" />
|
||||||
|
{t("coursePlan.sections.objectives")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ol className="list-decimal list-inside space-y-1 text-sm">
|
||||||
|
{plan.objectives.map((o, i) => (
|
||||||
|
<li key={i}>{o}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{plan.weeks && plan.weeks.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-primary" />
|
||||||
|
{t("coursePlan.sections.delivery")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t("coursePlan.deliveryHint")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Accordion type="multiple" className="w-full">
|
||||||
|
{plan.weeks.map((w) => (
|
||||||
|
<ReaderWeek
|
||||||
|
key={w.id}
|
||||||
|
week={w}
|
||||||
|
materials={materialsByWeek.get(w.week_number) ?? []}
|
||||||
|
mode={mode}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReaderWeek({
|
||||||
|
week,
|
||||||
|
materials,
|
||||||
|
mode,
|
||||||
|
}: {
|
||||||
|
week: CoursePlanWeek;
|
||||||
|
materials: CoursePlanMaterial[];
|
||||||
|
mode: WorkbookMode;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [skillFilter, setSkillFilter] = useState<string>("all");
|
||||||
|
const skills = useMemo(
|
||||||
|
() => Array.from(new Set(materials.map((m) => m.skill))).sort(),
|
||||||
|
[materials],
|
||||||
|
);
|
||||||
|
const filteredMaterials = useMemo(
|
||||||
|
() =>
|
||||||
|
materials.filter(
|
||||||
|
(m) => skillFilter === "all" || m.skill === skillFilter,
|
||||||
|
),
|
||||||
|
[materials, skillFilter],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<AccordionItem value={String(week.week_number)}>
|
||||||
|
<AccordionTrigger className="text-left">
|
||||||
|
<div className="flex-1 flex items-center gap-3 min-w-0">
|
||||||
|
<Badge variant="outline" className="shrink-0">
|
||||||
|
{t("coursePlan.weekN", { n: week.week_number })}
|
||||||
|
</Badge>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">
|
||||||
|
{week.focus || week.unit || "—"}
|
||||||
|
</div>
|
||||||
|
{week.date_label && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{week.date_label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-3">
|
||||||
|
{skills.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={skillFilter === "all" ? "default" : "outline"}
|
||||||
|
onClick={() => setSkillFilter("all")}
|
||||||
|
>
|
||||||
|
{t("common.all", "All")}
|
||||||
|
</Button>
|
||||||
|
{skills.map((s) => (
|
||||||
|
<Button
|
||||||
|
key={s}
|
||||||
|
size="sm"
|
||||||
|
variant={skillFilter === s ? "default" : "outline"}
|
||||||
|
onClick={() => setSkillFilter(s)}
|
||||||
|
className="capitalize"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{materials.length === 0 && (
|
||||||
|
<p className="text-sm italic text-muted-foreground">
|
||||||
|
{t("coursePlan.media.noMedia")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{filteredMaterials.map((m) => (
|
||||||
|
<ReaderMaterial key={m.id} material={m} mode={mode} />
|
||||||
|
))}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReaderMaterial({
|
||||||
|
material,
|
||||||
|
mode,
|
||||||
|
}: {
|
||||||
|
material: CoursePlanMaterial;
|
||||||
|
mode: WorkbookMode;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Icon className="h-4 w-4 text-primary" />
|
||||||
|
<CardTitle className="text-base flex-1 min-w-0">
|
||||||
|
{material.title}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{t(
|
||||||
|
`coursePlan.materialType.${material.material_type}`,
|
||||||
|
material.material_type,
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
<SkillBadge skill={material.skill} />
|
||||||
|
<GroundingBadge material={material} />
|
||||||
|
</div>
|
||||||
|
{material.summary && (
|
||||||
|
<CardDescription>{material.summary}</CardDescription>
|
||||||
|
)}
|
||||||
|
{material.share_date && (
|
||||||
|
<CardDescription>
|
||||||
|
{t("coursePlan.shareDate", "Share date")}: {material.share_date}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{(material.media ?? []).map((m) => (
|
||||||
|
<ReaderMediaTile key={m.id} media={m} />
|
||||||
|
))}
|
||||||
|
<MaterialBookView material={material} mode={mode} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReaderMediaTile({ media }: { media: CoursePlanMedia }) {
|
||||||
|
const url = withAuthQuery(media.preview_url || media.download_url || "");
|
||||||
|
if (!url) return null;
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border bg-muted/20 p-2 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{media.kind === "audio" && <Music className="h-4 w-4" />}
|
||||||
|
{media.kind === "image" && <ImageIcon className="h-4 w-4" />}
|
||||||
|
{media.kind === "video" && <Video className="h-4 w-4" />}
|
||||||
|
<span className="capitalize text-xs text-muted-foreground">
|
||||||
|
{media.kind}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{media.kind === "audio" && <audio src={url} controls className="w-full" />}
|
||||||
|
{media.kind === "image" && (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={media.title}
|
||||||
|
className="w-full rounded-md max-h-72 object-contain bg-muted/30"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{media.kind === "video" && (
|
||||||
|
<video src={url} controls className="w-full rounded-md max-h-72" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pill that surfaces RAG / extraction provenance for one material. */
|
||||||
|
export function GroundingBadge({ material }: { material: CoursePlanMaterial }) {
|
||||||
|
const grounded = material.grounded_on ?? [];
|
||||||
|
const extracted = material.extracted_from ?? null;
|
||||||
|
if (!grounded.length && !extracted) return null;
|
||||||
|
const total =
|
||||||
|
grounded.reduce((acc, g) => acc + (g.chunks_used || 0), 0) +
|
||||||
|
(extracted ? 1 : 0);
|
||||||
|
return (
|
||||||
|
<HoverCard openDelay={150}>
|
||||||
|
<HoverCardTrigger asChild>
|
||||||
|
<Badge variant="secondary" className="cursor-help">
|
||||||
|
<Link2 className="mr-1 h-3 w-3" />
|
||||||
|
Grounded on {total} reference{total === 1 ? "" : "s"}
|
||||||
|
</Badge>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent className="w-72 text-xs">
|
||||||
|
{grounded.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium text-sm">RAG sources</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{grounded.map((g) => (
|
||||||
|
<li key={g.source_id} className="flex justify-between gap-2">
|
||||||
|
<span className="truncate">{g.title || `Source #${g.source_id}`}</span>
|
||||||
|
<span className="text-muted-foreground">{g.chunks_used} chunk(s)</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{extracted && (
|
||||||
|
<div className={grounded.length ? "mt-3 border-t pt-2" : ""}>
|
||||||
|
<div className="font-medium text-sm">Extracted from</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{extracted.source_title || `Source #${extracted.source_id}`}
|
||||||
|
{extracted.page_hint ? ` · ${extracted.page_hint}` : ""}
|
||||||
|
</div>
|
||||||
|
{extracted.topic_keywords && extracted.topic_keywords.length > 0 && (
|
||||||
|
<div className="mt-1 text-muted-foreground">
|
||||||
|
Topics: {extracted.topic_keywords.slice(0, 5).join(", ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ const ar: Translations = {
|
|||||||
myCoursePlans: "خططي الدراسية",
|
myCoursePlans: "خططي الدراسية",
|
||||||
students: "الطلاب",
|
students: "الطلاب",
|
||||||
teachers: "المعلمون",
|
teachers: "المعلمون",
|
||||||
|
branches: "الفروع",
|
||||||
batches: "الدفعات",
|
batches: "الدفعات",
|
||||||
timetable: "الجدول الدراسي",
|
timetable: "الجدول الدراسي",
|
||||||
reports: "التقارير",
|
reports: "التقارير",
|
||||||
@@ -203,6 +204,8 @@ const ar: Translations = {
|
|||||||
averageGrade: "متوسط الدرجات",
|
averageGrade: "متوسط الدرجات",
|
||||||
totalChapters: "إجمالي الفصول",
|
totalChapters: "إجمالي الفصول",
|
||||||
myCourses: "دوراتي",
|
myCourses: "دوراتي",
|
||||||
|
myCoursePlans: "خططي الدراسية",
|
||||||
|
noCoursePlans: "لم يتم إسناد أي خطة دراسية لك بعد.",
|
||||||
noEnrolledCourses: "لم تسجّل في أي دورة بعد.",
|
noEnrolledCourses: "لم تسجّل في أي دورة بعد.",
|
||||||
chaptersMaterials: "{{chapters}} فصل · {{materials}} مادة",
|
chaptersMaterials: "{{chapters}} فصل · {{materials}} مادة",
|
||||||
quickActions: "إجراءات سريعة",
|
quickActions: "إجراءات سريعة",
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ const en: Translations = {
|
|||||||
myCoursePlans: "My Course Plans",
|
myCoursePlans: "My Course Plans",
|
||||||
students: "Students",
|
students: "Students",
|
||||||
teachers: "Teachers",
|
teachers: "Teachers",
|
||||||
|
branches: "Branches",
|
||||||
batches: "Batches",
|
batches: "Batches",
|
||||||
timetable: "Timetable",
|
timetable: "Timetable",
|
||||||
reports: "Reports",
|
reports: "Reports",
|
||||||
@@ -250,6 +251,8 @@ const en: Translations = {
|
|||||||
averageGrade: "Average Grade",
|
averageGrade: "Average Grade",
|
||||||
totalChapters: "Total Chapters",
|
totalChapters: "Total Chapters",
|
||||||
myCourses: "My Courses",
|
myCourses: "My Courses",
|
||||||
|
myCoursePlans: "My Course Plans",
|
||||||
|
noCoursePlans: "No course plans assigned yet.",
|
||||||
noEnrolledCourses: "No enrolled courses yet.",
|
noEnrolledCourses: "No enrolled courses yet.",
|
||||||
chaptersMaterials: "{{chapters}} chapters · {{materials}} materials",
|
chaptersMaterials: "{{chapters}} chapters · {{materials}} materials",
|
||||||
quickActions: "Quick Actions",
|
quickActions: "Quick Actions",
|
||||||
|
|||||||
@@ -283,9 +283,9 @@ export type QueryParamValue =
|
|||||||
export type QueryParams = object;
|
export type QueryParams = object;
|
||||||
|
|
||||||
const ENTITY_QUERY_SCOPE_RE =
|
const ENTITY_QUERY_SCOPE_RE =
|
||||||
/^\/(courses|students|teachers|batches|student\/my-courses|ai\/course-plan)(\/|$)/;
|
/^\/(courses|students|teachers|batches|branches|classrooms|student\/my-courses|ai\/course-plan)(\/|$)/;
|
||||||
const ENTITY_BODY_SCOPE_RE =
|
const ENTITY_BODY_SCOPE_RE =
|
||||||
/^\/(courses|students|teachers|batches|ai\/course-plan)(\/|$)/;
|
/^\/(courses|students|teachers|batches|branches|classrooms|ai\/course-plan)(\/|$)/;
|
||||||
|
|
||||||
function buildUrl(path: string, params?: QueryParams): string {
|
function buildUrl(path: string, params?: QueryParams): string {
|
||||||
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
|
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
|
||||||
@@ -409,10 +409,11 @@ export const api = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete<T>(path: string): Promise<T> {
|
async delete<T>(path: string, payload?: Record<string, unknown>): Promise<T> {
|
||||||
return performRequest<T>(buildUrl(path), {
|
return performRequest<T>(buildUrl(path), {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: buildHeaders(),
|
headers: buildHeaders(),
|
||||||
|
body: payload ? JSON.stringify(payload) : undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,34 +4,106 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { useBatches, useCourses } from "@/hooks/queries";
|
import { useBatches, useCourses, useTeachers } from "@/hooks/queries";
|
||||||
import { lmsService } from "@/services/lms.service";
|
import { lmsService } from "@/services/lms.service";
|
||||||
|
import { classroomsService } from "@/services/classrooms.service";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Search, Plus, Trash2 } from "lucide-react";
|
import { Search, Plus, Trash2, Pencil } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import AiBatchOptimizer from "@/components/ai/AiBatchOptimizer";
|
import AiBatchOptimizer from "@/components/ai/AiBatchOptimizer";
|
||||||
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
|
|
||||||
|
type BatchForm = {
|
||||||
|
name: string;
|
||||||
|
course_id: string;
|
||||||
|
course_section_id: string;
|
||||||
|
term_key: string;
|
||||||
|
classroom_id: string;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
max_students: string;
|
||||||
|
teacher_ids: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_FORM: BatchForm = {
|
||||||
|
name: "",
|
||||||
|
course_id: "",
|
||||||
|
course_section_id: "",
|
||||||
|
term_key: "",
|
||||||
|
classroom_id: "",
|
||||||
|
start_date: "",
|
||||||
|
end_date: "",
|
||||||
|
max_students: "30",
|
||||||
|
teacher_ids: [],
|
||||||
|
};
|
||||||
|
|
||||||
export default function AdminBatches() {
|
export default function AdminBatches() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [form, setForm] = useState({ name: "", course_id: "", start_date: "", end_date: "", max_students: "30" });
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [editingBatchId, setEditingBatchId] = useState<number | null>(null);
|
||||||
|
const [form, setForm] = useState<BatchForm>(EMPTY_FORM);
|
||||||
|
const [editForm, setEditForm] = useState<BatchForm>(EMPTY_FORM);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { data: batchesData, isLoading } = useBatches();
|
const { data: batchesData, isLoading } = useBatches();
|
||||||
const { data: coursesData } = useCourses({ size: 200 });
|
const { data: coursesData } = useCourses({ size: 200 });
|
||||||
|
const { data: teachersData } = useTeachers({ size: 200 });
|
||||||
|
const classroomsQ = useQuery({
|
||||||
|
queryKey: ["lms-classrooms-light"],
|
||||||
|
queryFn: async () => (await classroomsService.list({ size: 200 })).items,
|
||||||
|
});
|
||||||
|
const createSectionsQ = useQuery({
|
||||||
|
queryKey: ["course-sections", form.course_id],
|
||||||
|
queryFn: () =>
|
||||||
|
form.course_id
|
||||||
|
? lmsService.listCourseSections(Number(form.course_id))
|
||||||
|
: Promise.resolve({ items: [], total: 0 }),
|
||||||
|
enabled: !!form.course_id,
|
||||||
|
});
|
||||||
|
const editSectionsQ = useQuery({
|
||||||
|
queryKey: ["course-sections", editForm.course_id],
|
||||||
|
queryFn: () =>
|
||||||
|
editForm.course_id
|
||||||
|
? lmsService.listCourseSections(Number(editForm.course_id))
|
||||||
|
: Promise.resolve({ items: [], total: 0 }),
|
||||||
|
enabled: !!editForm.course_id,
|
||||||
|
});
|
||||||
const batches = batchesData?.items ?? [];
|
const batches = batchesData?.items ?? [];
|
||||||
const courses = coursesData?.items ?? [];
|
const courses = coursesData?.items ?? [];
|
||||||
|
const teachers = teachersData?.items ?? [];
|
||||||
|
const classrooms = classroomsQ.data ?? [];
|
||||||
|
const createSections = createSectionsQ.data?.items ?? [];
|
||||||
|
const editSections = editSectionsQ.data?.items ?? [];
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: Record<string, unknown>) => lmsService.createBatch(data as never),
|
mutationFn: (data: Record<string, unknown>) => lmsService.createBatch(data as never),
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "batches"] }); setCreateOpen(false); toast({ title: "Batch created" }); setForm({ name: "", course_id: "", start_date: "", end_date: "", max_students: "30" }); },
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
toast({ title: "Batch created" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) => lmsService.updateBatch(id, data as never),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
||||||
|
setEditOpen(false);
|
||||||
|
setEditingBatchId(null);
|
||||||
|
setEditForm(EMPTY_FORM);
|
||||||
|
toast({ title: "Batch updated" });
|
||||||
|
},
|
||||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,9 +119,26 @@ export default function AdminBatches() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openEdit(batch: (typeof batches)[number]) {
|
||||||
|
setEditingBatchId(batch.id);
|
||||||
|
setEditForm({
|
||||||
|
name: batch.name || "",
|
||||||
|
course_id: String(batch.course_id || ""),
|
||||||
|
course_section_id: batch.course_section_id ? String(batch.course_section_id) : "",
|
||||||
|
term_key: batch.term_key || "",
|
||||||
|
classroom_id: batch.classroom_id ? String(batch.classroom_id) : "",
|
||||||
|
start_date: batch.start_date || "",
|
||||||
|
end_date: batch.end_date || "",
|
||||||
|
max_students: String(batch.capacity || 30),
|
||||||
|
teacher_ids: batch.teacher_ids || [],
|
||||||
|
});
|
||||||
|
setEditOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||||
|
|
||||||
const filtered = batches.filter(b => b.name.toLowerCase().includes(search.toLowerCase()));
|
const filtered = batches.filter(b => b.name.toLowerCase().includes(search.toLowerCase()));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -62,7 +151,81 @@ export default function AdminBatches() {
|
|||||||
</div>
|
</div>
|
||||||
<AiTipBanner context="admin-batches" variant="recommendation" />
|
<AiTipBanner context="admin-batches" variant="recommendation" />
|
||||||
<div className="relative max-w-sm"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="Search batches..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9" /></div>
|
<div className="relative max-w-sm"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="Search batches..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9" /></div>
|
||||||
<Card><CardContent className="pt-6"><Table><TableHeader><TableRow><TableHead>Batch</TableHead><TableHead>Course</TableHead><TableHead>Teacher</TableHead><TableHead>Students</TableHead><TableHead>Schedule</TableHead><TableHead>Status</TableHead><TableHead></TableHead></TableRow></TableHeader><TableBody>{filtered.map(b => (<TableRow key={b.id}><TableCell className="font-medium">{b.name}</TableCell><TableCell>{b.course_name}</TableCell><TableCell>{b.teacher_name}</TableCell><TableCell>{b.student_count}/{b.capacity}</TableCell><TableCell className="text-xs">{b.schedule}</TableCell><TableCell><Badge variant={b.status === "active" ? "default" : "secondary"} className="capitalize">{b.status}</Badge></TableCell><TableCell><div className="flex gap-1"><Button size="sm" variant="ghost" asChild><Link to={`/admin/batches/${b.id}`}>View</Link></Button><Button variant="ghost" size="icon" onClick={() => handleDelete(b.id, b.name)}><Trash2 className="h-4 w-4 text-destructive" /></Button></div></TableCell></TableRow>))}</TableBody></Table></CardContent></Card>
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Batch</TableHead>
|
||||||
|
<TableHead>Course</TableHead>
|
||||||
|
<TableHead>Section</TableHead>
|
||||||
|
<TableHead>Term</TableHead>
|
||||||
|
<TableHead>Classroom</TableHead>
|
||||||
|
<TableHead>Teachers</TableHead>
|
||||||
|
<TableHead>Students</TableHead>
|
||||||
|
<TableHead>Schedule</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.map((b) => (
|
||||||
|
<TableRow key={b.id}>
|
||||||
|
<TableCell className="font-medium">{b.name}</TableCell>
|
||||||
|
<TableCell>{b.course_name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{b.course_section_name ? (
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
|
{b.course_section_code || b.course_section_name}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{b.term_key || "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{b.classroom_name || "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{b.teacher_names?.length ? b.teacher_names.join(", ") : b.teacher_name || "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{b.student_count}/{b.capacity}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">{b.schedule}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={b.status === "active" ? "default" : "secondary"}
|
||||||
|
className="capitalize"
|
||||||
|
>
|
||||||
|
{b.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" variant="ghost" asChild>
|
||||||
|
<Link to={`/admin/batches/${b.id}`}>View</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => openEdit(b)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(b.id, b.name)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader><DialogTitle>Create Batch</DialogTitle></DialogHeader>
|
<DialogHeader><DialogTitle>Create Batch</DialogTitle></DialogHeader>
|
||||||
@@ -70,7 +233,16 @@ export default function AdminBatches() {
|
|||||||
<div className="space-y-2"><Label>Batch Name *</Label><Input placeholder="e.g. IELTS Morning B" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} /></div>
|
<div className="space-y-2"><Label>Batch Name *</Label><Input placeholder="e.g. IELTS Morning B" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} /></div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Course *</Label>
|
<Label>Course *</Label>
|
||||||
<Select value={form.course_id || "__none__"} onValueChange={v => setForm(f => ({ ...f, course_id: v === "__none__" ? "" : v }))}>
|
<Select
|
||||||
|
value={form.course_id || "__none__"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
course_id: v === "__none__" ? "" : v,
|
||||||
|
course_section_id: "",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
|
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__none__">— Select —</SelectItem>
|
<SelectItem value="__none__">— Select —</SelectItem>
|
||||||
@@ -78,15 +250,233 @@ export default function AdminBatches() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Section (optional)</Label>
|
||||||
|
<Select
|
||||||
|
value={form.course_section_id || "__none__"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setForm((f) => ({ ...f, course_section_id: v === "__none__" ? "" : v }))
|
||||||
|
}
|
||||||
|
disabled={!form.course_id}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Whole course" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">Whole course (no section)</SelectItem>
|
||||||
|
{createSections.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={String(s.id)}>
|
||||||
|
{s.code ? `${s.code} · ${s.name}` : s.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Pick a course section template to keep batches isolated per group.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Term Key (optional)</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. 2026-T1"
|
||||||
|
value={form.term_key}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, term_key: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
One batch per (classroom × course × section × term).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Classroom (optional)</Label>
|
||||||
|
<Select value={form.classroom_id || "__none__"} onValueChange={v => setForm(f => ({ ...f, classroom_id: v === "__none__" ? "" : v }))}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="No classroom" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">No classroom</SelectItem>
|
||||||
|
{classrooms.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.name} ({c.student_count} students)</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">Selecting a classroom links the batch to that homeroom. Use the Classrooms page to auto-enroll the roster.</p>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2"><Label>Start Date *</Label><Input type="date" value={form.start_date} onChange={e => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
|
<div className="space-y-2"><Label>Start Date *</Label><Input type="date" value={form.start_date} onChange={e => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
|
||||||
<div className="space-y-2"><Label>End Date *</Label><Input type="date" value={form.end_date} onChange={e => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
|
<div className="space-y-2"><Label>End Date *</Label><Input type="date" value={form.end_date} onChange={e => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2"><Label>Capacity</Label><Input type="number" placeholder="30" value={form.max_students} onChange={e => setForm(f => ({ ...f, max_students: e.target.value }))} /></div>
|
<div className="space-y-2"><Label>Capacity</Label><Input type="number" placeholder="30" value={form.max_students} onChange={e => setForm(f => ({ ...f, max_students: e.target.value }))} /></div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Teachers (multi-select)</Label>
|
||||||
|
<div className="max-h-40 overflow-y-auto rounded border p-2 space-y-1">
|
||||||
|
{teachers.map((t) => (
|
||||||
|
<label key={t.id} className="flex items-center gap-2 rounded px-2 py-1 hover:bg-muted/50">
|
||||||
|
<Checkbox
|
||||||
|
checked={form.teacher_ids.includes(t.id)}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
teacher_ids: prev.teacher_ids.includes(t.id)
|
||||||
|
? prev.teacher_ids.filter((id) => id !== t.id)
|
||||||
|
: [...prev.teacher_ids, t.id],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{t.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||||
<Button disabled={createMutation.isPending || !form.name || !form.course_id || !form.start_date || !form.end_date} onClick={() => createMutation.mutate({ name: form.name, course_id: Number(form.course_id), start_date: form.start_date, end_date: form.end_date, max_students: Number(form.max_students) || 30 })}>Create</Button>
|
<Button
|
||||||
|
disabled={
|
||||||
|
createMutation.isPending ||
|
||||||
|
!form.name ||
|
||||||
|
!form.course_id ||
|
||||||
|
!form.start_date ||
|
||||||
|
!form.end_date
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
createMutation.mutate({
|
||||||
|
name: form.name,
|
||||||
|
course_id: Number(form.course_id),
|
||||||
|
course_section_id: form.course_section_id ? Number(form.course_section_id) : undefined,
|
||||||
|
term_key: form.term_key.trim() || undefined,
|
||||||
|
classroom_id: form.classroom_id ? Number(form.classroom_id) : undefined,
|
||||||
|
start_date: form.start_date,
|
||||||
|
end_date: form.end_date,
|
||||||
|
max_students: Number(form.max_students) || 30,
|
||||||
|
teacher_ids: form.teacher_ids,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Edit Batch</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2"><Label>Batch Name *</Label><Input value={editForm.name} onChange={e => setEditForm(f => ({ ...f, name: e.target.value }))} /></div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Course *</Label>
|
||||||
|
<Select
|
||||||
|
value={editForm.course_id || "__none__"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setEditForm((f) => ({
|
||||||
|
...f,
|
||||||
|
course_id: v === "__none__" ? "" : v,
|
||||||
|
course_section_id: "",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">— Select —</SelectItem>
|
||||||
|
{courses.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.title}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Section (optional)</Label>
|
||||||
|
<Select
|
||||||
|
value={editForm.course_section_id || "__none__"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setEditForm((f) => ({
|
||||||
|
...f,
|
||||||
|
course_section_id: v === "__none__" ? "" : v,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={!editForm.course_id}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Whole course" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">Whole course (no section)</SelectItem>
|
||||||
|
{editSections.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={String(s.id)}>
|
||||||
|
{s.code ? `${s.code} · ${s.name}` : s.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Term Key (optional)</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. 2026-T1"
|
||||||
|
value={editForm.term_key}
|
||||||
|
onChange={(e) => setEditForm((f) => ({ ...f, term_key: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Classroom (optional)</Label>
|
||||||
|
<Select value={editForm.classroom_id || "__none__"} onValueChange={v => setEditForm(f => ({ ...f, classroom_id: v === "__none__" ? "" : v }))}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="No classroom" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">No classroom</SelectItem>
|
||||||
|
{classrooms.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.name} ({c.student_count} students)</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2"><Label>Start Date *</Label><Input type="date" value={editForm.start_date} onChange={e => setEditForm(f => ({ ...f, start_date: e.target.value }))} /></div>
|
||||||
|
<div className="space-y-2"><Label>End Date *</Label><Input type="date" value={editForm.end_date} onChange={e => setEditForm(f => ({ ...f, end_date: e.target.value }))} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2"><Label>Capacity</Label><Input type="number" value={editForm.max_students} onChange={e => setEditForm(f => ({ ...f, max_students: e.target.value }))} /></div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Teachers (multi-select)</Label>
|
||||||
|
<div className="max-h-40 overflow-y-auto rounded border p-2 space-y-1">
|
||||||
|
{teachers.map((t) => (
|
||||||
|
<label key={t.id} className="flex items-center gap-2 rounded px-2 py-1 hover:bg-muted/50">
|
||||||
|
<Checkbox
|
||||||
|
checked={editForm.teacher_ids.includes(t.id)}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setEditForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
teacher_ids: prev.teacher_ids.includes(t.id)
|
||||||
|
? prev.teacher_ids.filter((id) => id !== t.id)
|
||||||
|
: [...prev.teacher_ids, t.id],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{t.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
disabled={updateMutation.isPending || !editingBatchId || !editForm.name || !editForm.course_id || !editForm.start_date || !editForm.end_date}
|
||||||
|
onClick={() => {
|
||||||
|
if (!editingBatchId) return;
|
||||||
|
updateMutation.mutate({
|
||||||
|
id: editingBatchId,
|
||||||
|
data: {
|
||||||
|
name: editForm.name,
|
||||||
|
course_id: Number(editForm.course_id),
|
||||||
|
course_section_id: editForm.course_section_id
|
||||||
|
? Number(editForm.course_section_id)
|
||||||
|
: null,
|
||||||
|
term_key: editForm.term_key.trim(),
|
||||||
|
classroom_id: editForm.classroom_id ? Number(editForm.classroom_id) : null,
|
||||||
|
start_date: editForm.start_date,
|
||||||
|
end_date: editForm.end_date,
|
||||||
|
max_students: Number(editForm.max_students) || 30,
|
||||||
|
teacher_ids: editForm.teacher_ids,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
260
src/pages/admin/AdminBranches.tsx
Normal file
260
src/pages/admin/AdminBranches.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Building2, Loader2, Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { lmsService } from "@/services/lms.service";
|
||||||
|
import type { LmsBranch } from "@/types";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
||||||
|
type BranchForm = {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
notes: string;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_FORM: BranchForm = { name: "", code: "", notes: "", active: true };
|
||||||
|
|
||||||
|
export default function AdminBranches() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { selectedEntity } = useAuth();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<LmsBranch | null>(null);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [form, setForm] = useState<BranchForm>(EMPTY_FORM);
|
||||||
|
|
||||||
|
const branchesQ = useQuery({
|
||||||
|
queryKey: ["lms-branches", selectedEntity?.id ?? 0, search],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await lmsService.listBranches({
|
||||||
|
size: 200,
|
||||||
|
search: search.trim() || undefined,
|
||||||
|
});
|
||||||
|
return res.items;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: (payload: Partial<LmsBranch>) => lmsService.createBranch(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["lms-branches"] });
|
||||||
|
setOpen(false);
|
||||||
|
setEditing(null);
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
toast({ title: "Branch created" });
|
||||||
|
},
|
||||||
|
onError: () => toast({ title: "Error", description: "Failed to create branch", variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMut = useMutation({
|
||||||
|
mutationFn: ({ id, payload }: { id: number; payload: Partial<LmsBranch> }) =>
|
||||||
|
lmsService.updateBranch(id, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["lms-branches"] });
|
||||||
|
setOpen(false);
|
||||||
|
setEditing(null);
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
toast({ title: "Branch updated" });
|
||||||
|
},
|
||||||
|
onError: () => toast({ title: "Error", description: "Failed to update branch", variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMut = useMutation({
|
||||||
|
mutationFn: (id: number) => lmsService.deleteBranch(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["lms-branches"] });
|
||||||
|
toast({ title: "Branch deleted" });
|
||||||
|
},
|
||||||
|
onError: () => toast({ title: "Error", description: "Failed to delete branch", variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSaving = createMut.isPending || updateMut.isPending;
|
||||||
|
const rows = useMemo(() => branchesQ.data ?? [], [branchesQ.data]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (row: LmsBranch) => {
|
||||||
|
setEditing(row);
|
||||||
|
setForm({
|
||||||
|
name: row.name || "",
|
||||||
|
code: row.code || "",
|
||||||
|
notes: row.notes || "",
|
||||||
|
active: !!row.active,
|
||||||
|
});
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSave = () => {
|
||||||
|
const payload: Partial<LmsBranch> = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
code: form.code.trim(),
|
||||||
|
notes: form.notes.trim(),
|
||||||
|
active: form.active,
|
||||||
|
};
|
||||||
|
if (!payload.name) return;
|
||||||
|
if (editing) {
|
||||||
|
updateMut.mutate({ id: editing.id, payload });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createMut.mutate(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = (row: LmsBranch) => {
|
||||||
|
if (!window.confirm(`Delete branch "${row.name}"?`)) return;
|
||||||
|
deleteMut.mutate(row.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Branches</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Create branches per entity to organize courses, batches, teachers, and students.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Branch
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editing ? "Edit Branch" : "Create Branch"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 pt-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Branch name</Label>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
|
placeholder="e.g. Riyadh Main Campus"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Branch code</Label>
|
||||||
|
<Input
|
||||||
|
value={form.code}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, code: e.target.value }))}
|
||||||
|
placeholder="e.g. RUH_MAIN"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
|
||||||
|
placeholder="Optional notes for managers"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-md border p-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Active</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Inactive branches stay in history but are hidden from active workflows.</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={form.active}
|
||||||
|
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, active: checked }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button className="w-full" onClick={onSave} disabled={isSaving || !form.name.trim()}>
|
||||||
|
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{editing ? "Update Branch" : "Create Branch"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search branches by name or code"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{branchesQ.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Code</TableHead>
|
||||||
|
<TableHead>Entity</TableHead>
|
||||||
|
<TableHead>Shared LMS</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
<TableCell className="font-medium">{row.name}</TableCell>
|
||||||
|
<TableCell>{row.code || "—"}</TableCell>
|
||||||
|
<TableCell>{row.entity_name || "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
C:{row.course_count} B:{row.batch_count} S:{row.student_count} T:{row.teacher_count}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{row.active ? (
|
||||||
|
<span className="inline-flex items-center rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-700">Active</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-600">Inactive</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => openEdit(row)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => onDelete(row)} disabled={deleteMut.isPending}>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="py-12 text-center text-muted-foreground">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Building2 className="h-5 w-5" />
|
||||||
|
<span>No branches found for this entity.</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -88,6 +88,9 @@ import { LibraryPickerDialog } from "@/components/coursePlan/LibraryPickerDialog
|
|||||||
import MaterialBookView, {
|
import MaterialBookView, {
|
||||||
SkillBadge,
|
SkillBadge,
|
||||||
} from "@/components/coursePlan/MaterialBookView";
|
} from "@/components/coursePlan/MaterialBookView";
|
||||||
|
import PlanReader, {
|
||||||
|
GroundingBadge,
|
||||||
|
} from "@/components/coursePlan/PlanReader";
|
||||||
import { describeApiError, withAuthQuery } from "@/lib/api-client";
|
import { describeApiError, withAuthQuery } from "@/lib/api-client";
|
||||||
import type {
|
import type {
|
||||||
CoursePlan,
|
CoursePlan,
|
||||||
@@ -168,6 +171,36 @@ export default function AdminCoursePlanDetail() {
|
|||||||
|
|
||||||
const plan = planQ.data?.data as CoursePlan | undefined;
|
const plan = planQ.data?.data as CoursePlan | undefined;
|
||||||
const [studentPreview, setStudentPreview] = useState(false);
|
const [studentPreview, setStudentPreview] = useState(false);
|
||||||
|
const [gateWeek, setGateWeek] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// True as soon as at least one uploaded source is fully indexed in
|
||||||
|
// pgvector. Drives the v2 (RAG-grounded) path on Generate and gates
|
||||||
|
// the soft "upload your book first" modal.
|
||||||
|
const indexedSources = useMemo(
|
||||||
|
() => (sourcesQ.data?.items ?? []).filter((s) => s.status === "indexed"),
|
||||||
|
[sourcesQ.data?.items],
|
||||||
|
);
|
||||||
|
const hasIndexedSources = indexedSources.length > 0;
|
||||||
|
|
||||||
|
const generateV2Mut = useMutation({
|
||||||
|
mutationFn: (weekNumber: number) =>
|
||||||
|
coursePlanService.generateWeekMaterialsV2(planId, weekNumber),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["course-plan", planId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["course-plan", planId, "deliverables"] });
|
||||||
|
toast.success(
|
||||||
|
t("coursePlan.weekMaterialsGenerated") +
|
||||||
|
(res.rag?.sources_used?.length
|
||||||
|
? ` (${t("coursePlan.rag.groundedOn", {
|
||||||
|
count: res.rag.sources_used.length,
|
||||||
|
defaultValue: "grounded on {{count}} source(s)",
|
||||||
|
})})`
|
||||||
|
: ""),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(describeApiError(err, t("coursePlan.weekMaterialsFailed"))),
|
||||||
|
});
|
||||||
|
|
||||||
const generateMut = useMutation({
|
const generateMut = useMutation({
|
||||||
mutationFn: (weekNumber: number) =>
|
mutationFn: (weekNumber: number) =>
|
||||||
@@ -181,6 +214,24 @@ export default function AdminCoursePlanDetail() {
|
|||||||
toast.error(describeApiError(err, t("coursePlan.weekMaterialsFailed"))),
|
toast.error(describeApiError(err, t("coursePlan.weekMaterialsFailed"))),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single entry point for the per-week Generate button:
|
||||||
|
* - if any source is indexed → call the RAG-grounded v2 endpoint
|
||||||
|
* - otherwise → open the upload gate; the admin can still choose
|
||||||
|
* "Generate without RAG" to fall back to the legacy generator
|
||||||
|
*/
|
||||||
|
const requestGenerate = (weekNumber: number) => {
|
||||||
|
if (hasIndexedSources) {
|
||||||
|
generateV2Mut.mutate(weekNumber);
|
||||||
|
} else {
|
||||||
|
setGateWeek(weekNumber);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateBusy = (weekNumber: number) =>
|
||||||
|
(generateV2Mut.isPending && generateV2Mut.variables === weekNumber) ||
|
||||||
|
(generateMut.isPending && generateMut.variables === weekNumber);
|
||||||
|
|
||||||
const bulkMediaMut = useMutation({
|
const bulkMediaMut = useMutation({
|
||||||
mutationFn: (vars: {
|
mutationFn: (vars: {
|
||||||
week: number;
|
week: number;
|
||||||
@@ -273,7 +324,7 @@ export default function AdminCoursePlanDetail() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{plan.skills_division && (
|
{plan.skills_division && !studentPreview && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{plan.skills_division}
|
{plan.skills_division}
|
||||||
</p>
|
</p>
|
||||||
@@ -281,6 +332,19 @@ export default function AdminCoursePlanDetail() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{studentPreview ? (
|
||||||
|
<>
|
||||||
|
<div className="rounded-md border border-amber-300/60 bg-amber-50 dark:bg-amber-900/20 px-3 py-2 text-sm text-amber-900 dark:text-amber-100 flex items-center gap-2">
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
{t(
|
||||||
|
"coursePlan.preview.banner",
|
||||||
|
"Preview — answers won't be saved. Toggle Student view to exit.",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<PlanReader plan={plan} mode="preview" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<DeliverablesStrip data={deliverablesQ.data} />
|
<DeliverablesStrip data={deliverablesQ.data} />
|
||||||
|
|
||||||
<SourcesCard
|
<SourcesCard
|
||||||
@@ -407,11 +471,8 @@ export default function AdminCoursePlanDetail() {
|
|||||||
key={week.id}
|
key={week.id}
|
||||||
week={week}
|
week={week}
|
||||||
materials={materialsByWeek.get(week.week_number) ?? []}
|
materials={materialsByWeek.get(week.week_number) ?? []}
|
||||||
generating={
|
generating={generateBusy(week.week_number)}
|
||||||
generateMut.isPending &&
|
onGenerate={() => requestGenerate(week.week_number)}
|
||||||
generateMut.variables === week.week_number
|
|
||||||
}
|
|
||||||
onGenerate={() => generateMut.mutate(week.week_number)}
|
|
||||||
onBulkMedia={(kinds) =>
|
onBulkMedia={(kinds) =>
|
||||||
bulkMediaMut.mutate({ week: week.week_number, kinds })
|
bulkMediaMut.mutate({ week: week.week_number, kinds })
|
||||||
}
|
}
|
||||||
@@ -420,18 +481,106 @@ export default function AdminCoursePlanDetail() {
|
|||||||
bulkMediaMut.variables?.week === week.week_number
|
bulkMediaMut.variables?.week === week.week_number
|
||||||
}
|
}
|
||||||
studentPreview={studentPreview}
|
studentPreview={studentPreview}
|
||||||
|
hasIndexedSources={hasIndexedSources}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<PreGenerationGate
|
||||||
|
open={gateWeek !== null}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
if (!v) setGateWeek(null);
|
||||||
|
}}
|
||||||
|
weekNumber={gateWeek}
|
||||||
|
onUpload={() => {
|
||||||
|
setGateWeek(null);
|
||||||
|
// Scroll to the SourcesCard upload area; the admin uploads
|
||||||
|
// the PDF, watches it index, then clicks Generate again.
|
||||||
|
document
|
||||||
|
.querySelector<HTMLElement>(`[data-section="sources-card"]`)
|
||||||
|
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}}
|
||||||
|
onContinueWithoutRag={() => {
|
||||||
|
if (gateWeek !== null) {
|
||||||
|
generateMut.mutate(gateWeek);
|
||||||
|
setGateWeek(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pre-generation upload gate
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft gate shown before Generate when the plan has no indexed sources.
|
||||||
|
* Encourages the admin to upload reference material first so the v2
|
||||||
|
* generator can ground exercises on real text. Dismissable: clicking
|
||||||
|
* "Generate without RAG" falls back to the legacy v1 endpoint so we
|
||||||
|
* never block the workflow on a missing book.
|
||||||
|
*/
|
||||||
|
function PreGenerationGate({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
weekNumber,
|
||||||
|
onUpload,
|
||||||
|
onContinueWithoutRag,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
weekNumber: number | null;
|
||||||
|
onUpload: () => void;
|
||||||
|
onContinueWithoutRag: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("coursePlan.gate.title", "Upload your reference book first?")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t(
|
||||||
|
"coursePlan.gate.subtitle",
|
||||||
|
"Generating week {{n}} works best when grounded on your uploaded PDF / DOCX / link. Upload a reference, wait for indexing, then click Generate again. You can dismiss this and continue without RAG.",
|
||||||
|
{ n: weekNumber ?? 0 },
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"coursePlan.gate.hint",
|
||||||
|
"Tip: a typical course book takes 10-30 seconds to index after upload.",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2 flex-wrap">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
{t("coursePlan.gate.cancel", "Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={onContinueWithoutRag}>
|
||||||
|
{t("coursePlan.gate.continueWithoutRag", "Generate without RAG")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onUpload}>
|
||||||
|
<Upload className="mr-1 h-4 w-4" />
|
||||||
|
{t("coursePlan.gate.upload", "Upload now")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Phase B — Deliverables / progress strip
|
// Phase B — Deliverables / progress strip
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -618,14 +767,82 @@ function SourcesCard({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const extractMut = useMutation({
|
||||||
|
mutationFn: () => coursePlanService.extractWorkbooks(planId),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
if (res.data.materials_created > 0) {
|
||||||
|
toast.success(
|
||||||
|
t("coursePlan.sources.extractDone", {
|
||||||
|
materials: res.data.materials_created,
|
||||||
|
exercises: res.data.exercises_total,
|
||||||
|
defaultValue:
|
||||||
|
"Extracted {{exercises}} exercises into {{materials}} workbooks",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.message(
|
||||||
|
res.data.reason
|
||||||
|
? `${t("coursePlan.sources.extractEmpty", { defaultValue: "No new exercises extracted" })} (${res.data.reason})`
|
||||||
|
: t("coursePlan.sources.extractEmpty", {
|
||||||
|
defaultValue: "No new exercises extracted",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
qc.invalidateQueries({ queryKey: ["course-plan", planId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["course-plan", planId, "deliverables"] });
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(
|
||||||
|
describeApiError(
|
||||||
|
err,
|
||||||
|
t("coursePlan.sources.extractFailed", {
|
||||||
|
defaultValue: "Failed to extract workbooks",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// True once at least one source is fully indexed — surfaces the
|
||||||
|
// "Extract workbooks" action only when running it can actually
|
||||||
|
// produce results.
|
||||||
|
const hasIndexed = sources.some((s) => s.status === "indexed");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card data-section="sources-card">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||||
<Database className="h-4 w-4 text-primary" />
|
<div>
|
||||||
{t("coursePlan.sources.sectionTitle")}
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
</CardTitle>
|
<Database className="h-4 w-4 text-primary" />
|
||||||
<CardDescription>{t("coursePlan.sources.sectionDesc")}</CardDescription>
|
{t("coursePlan.sources.sectionTitle")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t("coursePlan.sources.sectionDesc")}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => extractMut.mutate()}
|
||||||
|
disabled={extractMut.isPending || !hasIndexed}
|
||||||
|
title={
|
||||||
|
hasIndexed
|
||||||
|
? undefined
|
||||||
|
: t("coursePlan.sources.extractGate", {
|
||||||
|
defaultValue: "Index a source first",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{extractMut.isPending ? (
|
||||||
|
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ClipboardList className="mr-1 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t("coursePlan.sources.extractWorkbooks", {
|
||||||
|
defaultValue: "Extract workbooks from sources",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
|
||||||
@@ -1241,6 +1458,7 @@ function WeekAccordionItem({
|
|||||||
onBulkMedia,
|
onBulkMedia,
|
||||||
bulkBusy,
|
bulkBusy,
|
||||||
studentPreview,
|
studentPreview,
|
||||||
|
hasIndexedSources,
|
||||||
}: {
|
}: {
|
||||||
week: CoursePlanWeek;
|
week: CoursePlanWeek;
|
||||||
materials: CoursePlanMaterial[];
|
materials: CoursePlanMaterial[];
|
||||||
@@ -1249,6 +1467,7 @@ function WeekAccordionItem({
|
|||||||
onBulkMedia: (kinds: Array<"audio" | "image" | "video">) => void;
|
onBulkMedia: (kinds: Array<"audio" | "image" | "video">) => void;
|
||||||
bulkBusy: boolean;
|
bulkBusy: boolean;
|
||||||
studentPreview: boolean;
|
studentPreview: boolean;
|
||||||
|
hasIndexedSources: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [skillFilter, setSkillFilter] = useState<string>("all");
|
const [skillFilter, setSkillFilter] = useState<string>("all");
|
||||||
@@ -1344,6 +1563,11 @@ function WeekAccordionItem({
|
|||||||
: materials.length > 0
|
: materials.length > 0
|
||||||
? t("coursePlan.regenerateMaterials")
|
? t("coursePlan.regenerateMaterials")
|
||||||
: t("coursePlan.generateMaterials")}
|
: t("coursePlan.generateMaterials")}
|
||||||
|
{hasIndexedSources && (
|
||||||
|
<Badge variant="secondary" className="ml-2">
|
||||||
|
RAG
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{materials.length > 0 && (
|
{materials.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
@@ -1463,6 +1687,7 @@ function MaterialCard({
|
|||||||
)}
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<SkillBadge skill={material.skill} />
|
<SkillBadge skill={material.skill} />
|
||||||
|
<GroundingBadge material={material} />
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1630,6 +1855,46 @@ function MediaDrawer({
|
|||||||
toast.error(describeApiError(err, t("coursePlan.media.deleteFailed"))),
|
toast.error(describeApiError(err, t("coursePlan.media.deleteFailed"))),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Admin upload — one mutation handles all three kinds, the file picker
|
||||||
|
// hidden inputs below decide which kind the file is for. The mutation
|
||||||
|
// input is the kind+file pair so React Query can serialize multiple
|
||||||
|
// uploads (e.g. drag two images in a row) without colliding state.
|
||||||
|
const uploadMut = useMutation({
|
||||||
|
mutationFn: ({ kind, file }: { kind: "audio" | "image" | "video"; file: File }) =>
|
||||||
|
coursePlanService.uploadMedia(material.id, kind, file),
|
||||||
|
onSuccess: (_data, vars) => {
|
||||||
|
toast.success(
|
||||||
|
t(`coursePlan.media.uploaded_${vars.kind}`, {
|
||||||
|
defaultValue: `${vars.kind.charAt(0).toUpperCase() + vars.kind.slice(1)} uploaded`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(
|
||||||
|
describeApiError(
|
||||||
|
err,
|
||||||
|
t("coursePlan.media.uploadFailed", { defaultValue: "Upload failed" }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const audioInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
function handleFilePicked(
|
||||||
|
kind: "audio" | "image" | "video",
|
||||||
|
e: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
// Always reset the input value so the same file can be re-selected
|
||||||
|
// after a failed upload — otherwise onChange won't fire again.
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
uploadMut.mutate({ kind, file });
|
||||||
|
}
|
||||||
|
|
||||||
const items = mediaQ.data?.items ?? [];
|
const items = mediaQ.data?.items ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1645,47 +1910,121 @@ function MediaDrawer({
|
|||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
{!studentPreview && (
|
{!studentPreview && (
|
||||||
<div className="mt-4 grid gap-2 sm:grid-cols-3">
|
<>
|
||||||
<Button
|
<div className="mt-4 grid gap-2 sm:grid-cols-3">
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => audioMut.mutate()}
|
size="sm"
|
||||||
disabled={audioMut.isPending}
|
onClick={() => audioMut.mutate()}
|
||||||
>
|
disabled={audioMut.isPending}
|
||||||
{audioMut.isPending ? (
|
>
|
||||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
{audioMut.isPending ? (
|
||||||
) : (
|
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||||
<Music className="mr-1 h-4 w-4" />
|
) : (
|
||||||
)}
|
<Music className="mr-1 h-4 w-4" />
|
||||||
{t("coursePlan.media.generateAudio")}
|
)}
|
||||||
</Button>
|
{t("coursePlan.media.generateAudio")}
|
||||||
<Button
|
</Button>
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => imageMut.mutate()}
|
size="sm"
|
||||||
disabled={imageMut.isPending}
|
onClick={() => imageMut.mutate()}
|
||||||
>
|
disabled={imageMut.isPending}
|
||||||
{imageMut.isPending ? (
|
>
|
||||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
{imageMut.isPending ? (
|
||||||
) : (
|
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||||
<ImageIcon className="mr-1 h-4 w-4" />
|
) : (
|
||||||
)}
|
<ImageIcon className="mr-1 h-4 w-4" />
|
||||||
{t("coursePlan.media.generateImage")}
|
)}
|
||||||
</Button>
|
{t("coursePlan.media.generateImage")}
|
||||||
<Button
|
</Button>
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => videoMut.mutate()}
|
size="sm"
|
||||||
disabled={videoMut.isPending}
|
onClick={() => videoMut.mutate()}
|
||||||
>
|
disabled={videoMut.isPending}
|
||||||
{videoMut.isPending ? (
|
>
|
||||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
{videoMut.isPending ? (
|
||||||
) : (
|
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||||
<Video className="mr-1 h-4 w-4" />
|
) : (
|
||||||
)}
|
<Video className="mr-1 h-4 w-4" />
|
||||||
{t("coursePlan.media.generateVideo")}
|
)}
|
||||||
</Button>
|
{t("coursePlan.media.generateVideo")}
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Manual upload row — admins can attach a real human voiceover,
|
||||||
|
a custom illustration, or a recorded scene to override the
|
||||||
|
AI-generated asset. The hidden inputs are kept off-DOM-flow
|
||||||
|
and triggered by the visible buttons via refs so the styling
|
||||||
|
stays consistent with the Generate row above.
|
||||||
|
*/}
|
||||||
|
<div className="mt-2 grid gap-2 sm:grid-cols-3">
|
||||||
|
<input
|
||||||
|
ref={audioInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="audio/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => handleFilePicked("audio", e)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => handleFilePicked("image", e)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={videoInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="video/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => handleFilePicked("video", e)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => audioInputRef.current?.click()}
|
||||||
|
disabled={uploadMut.isPending}
|
||||||
|
>
|
||||||
|
<Upload className="mr-1 h-4 w-4" />
|
||||||
|
{t("coursePlan.media.uploadAudio", {
|
||||||
|
defaultValue: "Upload audio",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => imageInputRef.current?.click()}
|
||||||
|
disabled={uploadMut.isPending}
|
||||||
|
>
|
||||||
|
<Upload className="mr-1 h-4 w-4" />
|
||||||
|
{t("coursePlan.media.uploadImage", {
|
||||||
|
defaultValue: "Upload image",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => videoInputRef.current?.click()}
|
||||||
|
disabled={uploadMut.isPending}
|
||||||
|
>
|
||||||
|
<Upload className="mr-1 h-4 w-4" />
|
||||||
|
{t("coursePlan.media.uploadVideo", {
|
||||||
|
defaultValue: "Upload video",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{uploadMut.isPending && (
|
||||||
|
<p className="text-muted-foreground mt-2 inline-flex items-center gap-1 text-xs">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
{t("coursePlan.media.uploading", {
|
||||||
|
defaultValue: "Uploading…",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-6 space-y-3">
|
<div className="mt-6 space-y-3">
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
import { useCourses, useCreateCourse, useStudents, useBulkEnroll, useBatches } from "@/hooks/queries";
|
import { useCourses, useCreateCourse, useStudents, useBulkEnroll, useBatches } from "@/hooks/queries";
|
||||||
import { lmsService, taxonomyService, resourcesService } from "@/services";
|
import { lmsService, taxonomyService, resourcesService } from "@/services";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Search, Plus, Trash2, UserPlus, FileEdit, Pencil, BookOpen, Target, Loader2, Users, GraduationCap } from "lucide-react";
|
import { Search, Plus, Trash2, UserPlus, FileEdit, Pencil, BookOpen, Target, Loader2, Users, GraduationCap, Layers, Sparkles, X } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type { Course, CourseCreateRequest } from "@/types";
|
import type { Course, CourseCreateRequest, CourseSection } from "@/types";
|
||||||
import type { ResourceTag } from "@/types/adaptive";
|
import type { ResourceTag } from "@/types/adaptive";
|
||||||
|
|
||||||
const DIFFICULTY_OPTIONS = [
|
const DIFFICULTY_OPTIONS = [
|
||||||
@@ -409,10 +409,335 @@ function courseToFormData(c: Course): CourseFormData {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CourseSectionsDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
course,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
course: Course;
|
||||||
|
}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [newCode, setNewCode] = useState("");
|
||||||
|
const [newSeq, setNewSeq] = useState<number>(10);
|
||||||
|
const [editing, setEditing] = useState<CourseSection | null>(null);
|
||||||
|
const [editName, setEditName] = useState("");
|
||||||
|
const [editCode, setEditCode] = useState("");
|
||||||
|
const [editSeq, setEditSeq] = useState<number>(10);
|
||||||
|
const [editActive, setEditActive] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const sectionsQ = useQuery({
|
||||||
|
queryKey: ["course-sections", course.id],
|
||||||
|
queryFn: () => lmsService.listCourseSections(course.id),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
const sections = sectionsQ.data?.items ?? [];
|
||||||
|
|
||||||
|
function refreshAll() {
|
||||||
|
qc.invalidateQueries({ queryKey: ["course-sections", course.id] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms-classroom-detail"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGenerateDefaults() {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await lmsService.generateDefaultCourseSections(course.id);
|
||||||
|
toast({
|
||||||
|
title: res.created_count > 0 ? `Generated ${res.created_count} section(s)` : "Defaults already exist",
|
||||||
|
});
|
||||||
|
refreshAll();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast({
|
||||||
|
title: "Generation failed",
|
||||||
|
description: e instanceof Error ? e.message : String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdd() {
|
||||||
|
const code = newCode.trim().toUpperCase();
|
||||||
|
const name = newName.trim() || `Section ${code}`;
|
||||||
|
if (!code) {
|
||||||
|
toast({ title: "Code is required", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await lmsService.createCourseSection(course.id, {
|
||||||
|
name,
|
||||||
|
code,
|
||||||
|
sequence: newSeq || 10,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
toast({ title: `Section ${code} added` });
|
||||||
|
setNewName("");
|
||||||
|
setNewCode("");
|
||||||
|
setNewSeq(10);
|
||||||
|
refreshAll();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast({
|
||||||
|
title: "Add failed",
|
||||||
|
description: e instanceof Error ? e.message : String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(s: CourseSection) {
|
||||||
|
setEditing(s);
|
||||||
|
setEditName(s.name);
|
||||||
|
setEditCode(s.code);
|
||||||
|
setEditSeq(s.sequence || 10);
|
||||||
|
setEditActive(s.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
if (!editing) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await lmsService.updateCourseSection(course.id, editing.id, {
|
||||||
|
name: editName.trim() || editing.name,
|
||||||
|
code: editCode.trim().toUpperCase() || editing.code,
|
||||||
|
sequence: editSeq || editing.sequence,
|
||||||
|
active: editActive,
|
||||||
|
});
|
||||||
|
toast({ title: "Section updated" });
|
||||||
|
setEditing(null);
|
||||||
|
refreshAll();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast({
|
||||||
|
title: "Update failed",
|
||||||
|
description: e instanceof Error ? e.message : String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(s: CourseSection) {
|
||||||
|
if ((s.batch_count ?? 0) > 0) {
|
||||||
|
toast({
|
||||||
|
title: "Cannot delete",
|
||||||
|
description: `Section "${s.code}" has ${s.batch_count} batch(es). Reassign or remove them first.`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.confirm(`Delete section "${s.name}" (${s.code})?`)) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await lmsService.deleteCourseSection(course.id, s.id);
|
||||||
|
toast({ title: "Section deleted" });
|
||||||
|
refreshAll();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast({
|
||||||
|
title: "Delete failed",
|
||||||
|
description: e instanceof Error ? e.message : String(e),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[640px] max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Layers className="h-5 w-5" /> Sections — {course.title}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Course sections (A, B, C…) are templates. Each section can be hosted by a classroom,
|
||||||
|
which auto-creates a batch and enrolls the classroom roster.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium">Quick start</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Create the standard <strong>A · B · C</strong> sections for this course in one click.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleGenerateDefaults}
|
||||||
|
disabled={busy || sectionsQ.isLoading}
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
|
||||||
|
Generate A / B / C
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">Existing sections</Label>
|
||||||
|
{sectionsQ.isLoading ? (
|
||||||
|
<div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading…
|
||||||
|
</div>
|
||||||
|
) : sections.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-3">
|
||||||
|
No sections yet. Add one below or click <em>Generate A / B / C</em>.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 border rounded-md divide-y">
|
||||||
|
{sections.map((s) => (
|
||||||
|
<div key={s.id} className="flex items-center justify-between gap-3 px-3 py-2">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Badge variant="outline" className="font-mono">
|
||||||
|
{s.code}
|
||||||
|
</Badge>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{s.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
seq {s.sequence}
|
||||||
|
{s.batch_count != null && (
|
||||||
|
<span className="ml-2">· {s.batch_count} batch(es)</span>
|
||||||
|
)}
|
||||||
|
{!s.active && <span className="ml-2 text-amber-600">· inactive</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
title="Edit section"
|
||||||
|
onClick={() => startEdit(s)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
title="Delete section"
|
||||||
|
onClick={() => handleDelete(s)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-3 space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Add a new section</Label>
|
||||||
|
<div className="grid grid-cols-12 gap-2">
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Label className="text-[11px] text-muted-foreground">Code *</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="A"
|
||||||
|
value={newCode}
|
||||||
|
onChange={(e) => setNewCode(e.target.value)}
|
||||||
|
maxLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-7">
|
||||||
|
<Label className="text-[11px] text-muted-foreground">Name</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Section A"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Label className="text-[11px] text-muted-foreground">Seq</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={newSeq}
|
||||||
|
onChange={(e) => setNewSeq(Number(e.target.value) || 10)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" onClick={handleAdd} disabled={busy || !newCode.trim()}>
|
||||||
|
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Plus className="mr-2 h-4 w-4" />}
|
||||||
|
Add section
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<Dialog open={!!editing} onOpenChange={(open) => !open && setEditing(null)}>
|
||||||
|
<DialogContent className="sm:max-w-[480px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Pencil className="h-4 w-4" /> Edit section
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label>Code</Label>
|
||||||
|
<Input
|
||||||
|
value={editCode}
|
||||||
|
onChange={(e) => setEditCode(e.target.value)}
|
||||||
|
maxLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Sequence</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editSeq}
|
||||||
|
onChange={(e) => setEditSeq(Number(e.target.value) || 10)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input value={editName} onChange={(e) => setEditName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={editActive}
|
||||||
|
onCheckedChange={(v) => setEditActive(Boolean(v))}
|
||||||
|
/>
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditing(null)}>
|
||||||
|
<X className="mr-2 h-4 w-4" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={saveEdit} disabled={busy}>
|
||||||
|
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminCourses() {
|
export default function AdminCourses() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
|
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
|
||||||
|
const [sectionsCourse, setSectionsCourse] = useState<Course | null>(null);
|
||||||
const [enrollOpen, setEnrollOpen] = useState(false);
|
const [enrollOpen, setEnrollOpen] = useState(false);
|
||||||
const [enrollCourseId, setEnrollCourseId] = useState<number | null>(null);
|
const [enrollCourseId, setEnrollCourseId] = useState<number | null>(null);
|
||||||
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]);
|
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]);
|
||||||
@@ -539,10 +864,11 @@ export default function AdminCourses() {
|
|||||||
<TableHead>Course</TableHead>
|
<TableHead>Course</TableHead>
|
||||||
<TableHead>Subject / Tags</TableHead>
|
<TableHead>Subject / Tags</TableHead>
|
||||||
<TableHead>Difficulty</TableHead>
|
<TableHead>Difficulty</TableHead>
|
||||||
|
<TableHead>Sections</TableHead>
|
||||||
<TableHead>Chapters</TableHead>
|
<TableHead>Chapters</TableHead>
|
||||||
<TableHead>Enrolled / Cap</TableHead>
|
<TableHead>Enrolled / Cap</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead className="w-[150px]" />
|
<TableHead className="w-[180px]" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -594,6 +920,36 @@ export default function AdminCourses() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSectionsCourse(c)}
|
||||||
|
className="group flex items-center gap-1 text-xs hover:underline"
|
||||||
|
title="Manage course sections (A/B/C…)"
|
||||||
|
>
|
||||||
|
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{c.section_count ?? c.sections?.length ?? 0}</span>
|
||||||
|
<span className="text-muted-foreground">sections</span>
|
||||||
|
{c.sections && c.sections.length > 0 && (
|
||||||
|
<span className="ml-1 flex flex-wrap gap-0.5">
|
||||||
|
{c.sections.slice(0, 4).map((s) => (
|
||||||
|
<Badge
|
||||||
|
key={s.id}
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] px-1 py-0 leading-none"
|
||||||
|
>
|
||||||
|
{s.code || s.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{c.sections.length > 4 && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
+{c.sections.length - 4}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-1 text-sm">
|
<div className="flex items-center gap-1 text-sm">
|
||||||
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
@@ -627,6 +983,14 @@ export default function AdminCourses() {
|
|||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Manage sections (A/B/C…)"
|
||||||
|
onClick={() => setSectionsCourse(c)}
|
||||||
|
>
|
||||||
|
<Layers className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -653,7 +1017,7 @@ export default function AdminCourses() {
|
|||||||
))}
|
))}
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
|
<TableCell colSpan={8} className="text-center text-muted-foreground py-8">
|
||||||
No courses found.
|
No courses found.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -679,6 +1043,16 @@ export default function AdminCourses() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{sectionsCourse && (
|
||||||
|
<CourseSectionsDialog
|
||||||
|
open={!!sectionsCourse}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setSectionsCourse(null);
|
||||||
|
}}
|
||||||
|
course={sectionsCourse}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Dialog open={enrollOpen} onOpenChange={setEnrollOpen}>
|
<Dialog open={enrollOpen} onOpenChange={setEnrollOpen}>
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent className="max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -1,60 +1,14 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { ArrowLeft } from "lucide-react";
|
||||||
ArrowLeft,
|
|
||||||
BookOpen,
|
|
||||||
Calendar,
|
|
||||||
ClipboardList,
|
|
||||||
Headphones,
|
|
||||||
Image as ImageIcon,
|
|
||||||
Library,
|
|
||||||
MessageSquare,
|
|
||||||
Mic,
|
|
||||||
Music,
|
|
||||||
PenSquare,
|
|
||||||
Sparkles,
|
|
||||||
Type,
|
|
||||||
Video,
|
|
||||||
type LucideIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import PlanReader from "@/components/coursePlan/PlanReader";
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from "@/components/ui/accordion";
|
|
||||||
import { coursePlanService } from "@/services/coursePlan.service";
|
import { coursePlanService } from "@/services/coursePlan.service";
|
||||||
import { describeApiError, withAuthQuery } from "@/lib/api-client";
|
import { describeApiError } from "@/lib/api-client";
|
||||||
import MaterialBookView, { SkillBadge } from "@/components/coursePlan/MaterialBookView";
|
import type { CoursePlan } from "@/types";
|
||||||
import type {
|
|
||||||
CoursePlan,
|
|
||||||
CoursePlanMaterial,
|
|
||||||
CoursePlanMedia,
|
|
||||||
CoursePlanWeek,
|
|
||||||
} from "@/types";
|
|
||||||
|
|
||||||
const SKILL_ICONS: Record<string, LucideIcon> = {
|
|
||||||
reading: BookOpen,
|
|
||||||
writing: PenSquare,
|
|
||||||
listening: Headphones,
|
|
||||||
speaking: Mic,
|
|
||||||
grammar: Type,
|
|
||||||
vocabulary: Library,
|
|
||||||
integrated: MessageSquare,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Student detail view for an assigned AI course plan.
|
* Student detail view for an assigned AI course plan.
|
||||||
@@ -63,8 +17,8 @@ const SKILL_ICONS: Record<string, LucideIcon> = {
|
|||||||
* returns 403 unless the current user is in the plan's assignment list,
|
* returns 403 unless the current user is in the plan's assignment list,
|
||||||
* so we can render unconditionally as soon as the query resolves.
|
* so we can render unconditionally as soon as the query resolves.
|
||||||
*
|
*
|
||||||
* The page is intentionally read-only — students can play audio, view
|
* Rendering is delegated to the shared `<PlanReader>` so the admin
|
||||||
* images, and watch video, but they can't (re)generate content.
|
* "View as Student" preview matches the real student experience byte-for-byte.
|
||||||
*/
|
*/
|
||||||
export default function StudentCoursePlanDetail() {
|
export default function StudentCoursePlanDetail() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -79,20 +33,14 @@ export default function StudentCoursePlanDetail() {
|
|||||||
|
|
||||||
const plan = data?.data as CoursePlan | undefined;
|
const plan = data?.data as CoursePlan | undefined;
|
||||||
|
|
||||||
const materialsByWeek = useMemo(() => {
|
|
||||||
const map = new Map<number, CoursePlanMaterial[]>();
|
|
||||||
if (!plan?.materials) return map;
|
|
||||||
for (const m of plan.materials) {
|
|
||||||
const list = map.get(m.week_number) ?? [];
|
|
||||||
list.push(m);
|
|
||||||
map.set(m.week_number, list);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [plan?.materials]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Button variant="ghost" size="sm" asChild className="-ml-2 text-muted-foreground">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
className="-ml-2 text-muted-foreground"
|
||||||
|
>
|
||||||
<Link to="/student/course-plans" className="flex items-center gap-1">
|
<Link to="/student/course-plans" className="flex items-center gap-1">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
{t("coursePlan.student.backToList")}
|
{t("coursePlan.student.backToList")}
|
||||||
@@ -113,235 +61,7 @@ export default function StudentCoursePlanDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{plan && (
|
{plan && <PlanReader plan={plan} mode="student" />}
|
||||||
<>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<CardTitle className="text-2xl">{plan.name}</CardTitle>
|
|
||||||
{plan.description && (
|
|
||||||
<CardDescription className="max-w-3xl">
|
|
||||||
{plan.description}
|
|
||||||
</CardDescription>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
<Badge variant="secondary" className="uppercase">
|
|
||||||
{plan.cefr_level}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline">
|
|
||||||
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline">
|
|
||||||
{t("coursePlan.hoursPerWeek", {
|
|
||||||
count: plan.contact_hours_per_week,
|
|
||||||
})}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{plan.assignment && (
|
|
||||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
|
||||||
<Calendar className="h-3.5 w-3.5" />
|
|
||||||
{plan.assignment.due_date
|
|
||||||
? t("coursePlan.student.due", { date: plan.assignment.due_date })
|
|
||||||
: t("coursePlan.student.noDue")}
|
|
||||||
{plan.assignment.assigned_by_name && (
|
|
||||||
<span>
|
|
||||||
·{" "}
|
|
||||||
{t("coursePlan.student.assignedBy", {
|
|
||||||
name: plan.assignment.assigned_by_name,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{plan.assignment?.message && (
|
|
||||||
<p className="text-sm bg-muted/40 rounded-md px-3 py-2 mt-2">
|
|
||||||
{plan.assignment.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{plan.objectives.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
|
||||||
<ClipboardList className="h-4 w-4 text-primary" />
|
|
||||||
{t("coursePlan.sections.objectives")}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ol className="list-decimal list-inside space-y-1 text-sm">
|
|
||||||
{plan.objectives.map((o, i) => (
|
|
||||||
<li key={i}>{o}</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{plan.weeks && plan.weeks.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
|
||||||
<Sparkles className="h-4 w-4 text-primary" />
|
|
||||||
{t("coursePlan.sections.delivery")}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>{t("coursePlan.deliveryHint")}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Accordion type="multiple" className="w-full">
|
|
||||||
{plan.weeks.map((w) => (
|
|
||||||
<StudentWeek
|
|
||||||
key={w.id}
|
|
||||||
week={w}
|
|
||||||
materials={materialsByWeek.get(w.week_number) ?? []}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Accordion>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StudentWeek({
|
|
||||||
week,
|
|
||||||
materials,
|
|
||||||
}: {
|
|
||||||
week: CoursePlanWeek;
|
|
||||||
materials: CoursePlanMaterial[];
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [skillFilter, setSkillFilter] = useState<string>("all");
|
|
||||||
const skills = useMemo(
|
|
||||||
() => Array.from(new Set(materials.map((m) => m.skill))).sort(),
|
|
||||||
[materials],
|
|
||||||
);
|
|
||||||
const filteredMaterials = useMemo(
|
|
||||||
() => materials.filter((m) => skillFilter === "all" || m.skill === skillFilter),
|
|
||||||
[materials, skillFilter],
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<AccordionItem value={String(week.week_number)}>
|
|
||||||
<AccordionTrigger className="text-left">
|
|
||||||
<div className="flex-1 flex items-center gap-3 min-w-0">
|
|
||||||
<Badge variant="outline" className="shrink-0">
|
|
||||||
{t("coursePlan.weekN", { n: week.week_number })}
|
|
||||||
</Badge>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="font-medium truncate">
|
|
||||||
{week.focus || week.unit || "—"}
|
|
||||||
</div>
|
|
||||||
{week.date_label && (
|
|
||||||
<div className="text-xs text-muted-foreground truncate">
|
|
||||||
{week.date_label}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="space-y-3">
|
|
||||||
{skills.length > 0 && (
|
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={skillFilter === "all" ? "default" : "outline"}
|
|
||||||
onClick={() => setSkillFilter("all")}
|
|
||||||
>
|
|
||||||
{t("common.all", "All")}
|
|
||||||
</Button>
|
|
||||||
{skills.map((s) => (
|
|
||||||
<Button
|
|
||||||
key={s}
|
|
||||||
size="sm"
|
|
||||||
variant={skillFilter === s ? "default" : "outline"}
|
|
||||||
onClick={() => setSkillFilter(s)}
|
|
||||||
className="capitalize"
|
|
||||||
>
|
|
||||||
{s}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{materials.length === 0 && (
|
|
||||||
<p className="text-sm italic text-muted-foreground">
|
|
||||||
{t("coursePlan.media.noMedia")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{filteredMaterials.map((m) => (
|
|
||||||
<StudentMaterial key={m.id} material={m} />
|
|
||||||
))}
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StudentMaterial({ material }: { material: CoursePlanMaterial }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<Icon className="h-4 w-4 text-primary" />
|
|
||||||
<CardTitle className="text-base flex-1 min-w-0">
|
|
||||||
{material.title}
|
|
||||||
</CardTitle>
|
|
||||||
<Badge variant="outline" className="text-[10px]">
|
|
||||||
{t(
|
|
||||||
`coursePlan.materialType.${material.material_type}`,
|
|
||||||
material.material_type,
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
<SkillBadge skill={material.skill} />
|
|
||||||
</div>
|
|
||||||
{material.summary && <CardDescription>{material.summary}</CardDescription>}
|
|
||||||
{material.share_date && (
|
|
||||||
<CardDescription>
|
|
||||||
{t("coursePlan.shareDate", "Share date")}: {material.share_date}
|
|
||||||
</CardDescription>
|
|
||||||
)}
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{(material.media ?? []).map((m) => (
|
|
||||||
<StudentMediaTile key={m.id} media={m} />
|
|
||||||
))}
|
|
||||||
<MaterialBookView material={material} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StudentMediaTile({ media }: { media: CoursePlanMedia }) {
|
|
||||||
const url = withAuthQuery(media.preview_url || media.download_url || "");
|
|
||||||
if (!url) return null;
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border bg-muted/20 p-2 space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{media.kind === "audio" && <Music className="h-4 w-4" />}
|
|
||||||
{media.kind === "image" && <ImageIcon className="h-4 w-4" />}
|
|
||||||
{media.kind === "video" && <Video className="h-4 w-4" />}
|
|
||||||
<span className="capitalize text-xs text-muted-foreground">
|
|
||||||
{media.kind}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{media.kind === "audio" && <audio src={url} controls className="w-full" />}
|
|
||||||
{media.kind === "image" && (
|
|
||||||
<img
|
|
||||||
src={url}
|
|
||||||
alt={media.title}
|
|
||||||
className="w-full rounded-md max-h-72 object-contain bg-muted/30"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{media.kind === "video" && (
|
|
||||||
<video src={url} controls className="w-full rounded-md max-h-72" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,29 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { BookOpen, ClipboardList, BarChart3, Calendar, ArrowRight, Play } from "lucide-react";
|
import { BookOpen, ClipboardList, BarChart3, Calendar, ArrowRight, Play, Sparkles } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useMyEnrolledCourses, useGrades } from "@/hooks/queries";
|
import { useMyEnrolledCourses, useGrades } from "@/hooks/queries";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import AiStudyCoach from "@/components/ai/AiStudyCoach";
|
import AiStudyCoach from "@/components/ai/AiStudyCoach";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
|
import { coursePlanService } from "@/services/coursePlan.service";
|
||||||
|
|
||||||
export default function StudentDashboard() {
|
export default function StudentDashboard() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: enrolledData, isLoading: lc } = useMyEnrolledCourses();
|
const { data: enrolledData, isLoading: lc } = useMyEnrolledCourses();
|
||||||
const { data: gradesData, isLoading: lg } = useGrades();
|
const { data: gradesData, isLoading: lg } = useGrades();
|
||||||
|
const { data: planData, isLoading: lp } = useQuery({
|
||||||
|
queryKey: ["student-course-plans"],
|
||||||
|
queryFn: () => coursePlanService.studentList(),
|
||||||
|
});
|
||||||
const myCourses = enrolledData?.items ?? [];
|
const myCourses = enrolledData?.items ?? [];
|
||||||
|
const myPlans = planData?.items ?? [];
|
||||||
const gradeRecords = gradesData ?? [];
|
const gradeRecords = gradesData ?? [];
|
||||||
if (lc || lg) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
if (lc || lg || lp) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||||
|
|
||||||
const recentGrades = gradeRecords.slice(0, 3);
|
const recentGrades = gradeRecords.slice(0, 3);
|
||||||
const avgProgress = myCourses.length > 0 ? Math.round(myCourses.reduce((s, c) => s + c.progress, 0) / myCourses.length) : 0;
|
const avgProgress = myCourses.length > 0 ? Math.round(myCourses.reduce((s, c) => s + c.progress, 0) / myCourses.length) : 0;
|
||||||
@@ -27,7 +34,7 @@ export default function StudentDashboard() {
|
|||||||
const firstName = user?.name?.split(" ")[0] || t("dashboard.greetingFallback");
|
const firstName = user?.name?.split(" ")[0] || t("dashboard.greetingFallback");
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: t("studentDash.enrolledCourses"), value: String(myCourses.length), icon: BookOpen, color: "text-primary" },
|
{ label: t("studentDash.enrolledCourses"), value: String(myCourses.length + myPlans.length), icon: BookOpen, color: "text-primary" },
|
||||||
{ label: t("studentDash.overallProgress"), value: `${avgProgress}%`, icon: ClipboardList, color: "text-warning" },
|
{ label: t("studentDash.overallProgress"), value: `${avgProgress}%`, icon: ClipboardList, color: "text-warning" },
|
||||||
{ label: t("studentDash.averageGrade"), value: gradeRecords.length > 0 ? `${avgGrade}%` : "N/A", icon: BarChart3, color: "text-success" },
|
{ label: t("studentDash.averageGrade"), value: gradeRecords.length > 0 ? `${avgGrade}%` : "N/A", icon: BarChart3, color: "text-success" },
|
||||||
{ label: t("studentDash.totalChapters"), value: String(myCourses.reduce((s, c) => s + c.chapter_count, 0)), icon: Calendar, color: "text-info" },
|
{ label: t("studentDash.totalChapters"), value: String(myCourses.reduce((s, c) => s + c.chapter_count, 0)), icon: Calendar, color: "text-info" },
|
||||||
@@ -93,6 +100,42 @@ export default function StudentDashboard() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-primary" />
|
||||||
|
{t("studentDash.myCoursePlans")}
|
||||||
|
</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to="/student/course-plans">
|
||||||
|
{t("common.viewAll")} <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{myPlans.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">{t("studentDash.noCoursePlans")}</p>
|
||||||
|
) : myPlans.slice(0, 4).map((p) => (
|
||||||
|
<Link to={`/student/course-plans/${p.id}`} key={p.id} className="block">
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium text-sm truncate" dir="auto">{p.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{t("coursePlan.weeksCount", { count: p.total_weeks })} ·{" "}
|
||||||
|
{p.assignment?.due_date
|
||||||
|
? t("coursePlan.student.due", { date: p.assignment.due_date })
|
||||||
|
: t("coursePlan.student.noDue")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="uppercase shrink-0">
|
||||||
|
{p.cefr_level}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="text-lg">{t("studentDash.quickActions")}</CardTitle>
|
<CardTitle className="text-lg">{t("studentDash.quickActions")}</CardTitle>
|
||||||
|
|||||||
@@ -1,36 +1,127 @@
|
|||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import type { Classroom, ClassroomCreateRequest, PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types";
|
import { asPaginated, asRecordData } from "@/lib/odoo-api";
|
||||||
|
import type {
|
||||||
|
Classroom,
|
||||||
|
ClassroomAssignCourseRequest,
|
||||||
|
ClassroomBatchSummary,
|
||||||
|
ClassroomCourse,
|
||||||
|
ClassroomCreateRequest,
|
||||||
|
ClassroomMember,
|
||||||
|
ClassroomSection,
|
||||||
|
ClassroomStudent,
|
||||||
|
PaginatedResponse,
|
||||||
|
PaginationParams,
|
||||||
|
ApiSuccessResponse,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
export interface ClassroomListParams extends PaginationParams {
|
export interface ClassroomListParams extends PaginationParams {
|
||||||
entity_id?: number;
|
entity_id?: number;
|
||||||
|
branch_id?: number;
|
||||||
|
search?: string;
|
||||||
|
active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const classroomsService = {
|
export const classroomsService = {
|
||||||
async list(params?: ClassroomListParams): Promise<PaginatedResponse<Classroom>> {
|
async list(params?: ClassroomListParams): Promise<PaginatedResponse<Classroom>> {
|
||||||
return api.get<PaginatedResponse<Classroom>>("/groups", params as Record<string, string | number | boolean | undefined>);
|
const raw = await api.get<unknown>(
|
||||||
|
"/classrooms",
|
||||||
|
params as Record<string, string | number | boolean | undefined>,
|
||||||
|
);
|
||||||
|
return asPaginated<Classroom>(raw);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getById(id: number): Promise<Classroom> {
|
async getById(id: number): Promise<Classroom> {
|
||||||
return api.get<Classroom>(`/groups/${id}`);
|
const raw = await api.get<unknown>(`/classrooms/${id}`);
|
||||||
|
return asRecordData<Classroom>(raw);
|
||||||
},
|
},
|
||||||
|
|
||||||
async create(data: ClassroomCreateRequest): Promise<Classroom> {
|
async create(data: ClassroomCreateRequest): Promise<Classroom> {
|
||||||
return api.post<Classroom>("/groups", data);
|
const raw = await api.post<unknown>("/classrooms", data);
|
||||||
|
return asRecordData<Classroom>(raw);
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: number, data: Partial<ClassroomCreateRequest>): Promise<Classroom> {
|
||||||
|
const raw = await api.patch<unknown>(`/classrooms/${id}`, data);
|
||||||
|
return asRecordData<Classroom>(raw);
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete(id: number): Promise<ApiSuccessResponse> {
|
async delete(id: number): Promise<ApiSuccessResponse> {
|
||||||
return api.delete<ApiSuccessResponse>(`/groups/${id}`);
|
return api.delete<ApiSuccessResponse>(`/classrooms/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
async transfer(data: { student_ids: number[]; from_id: number; to_id: number }): Promise<ApiSuccessResponse> {
|
// ---- Roster ----------------------------------------------------------
|
||||||
return api.post<ApiSuccessResponse>("/groups/transfer", data);
|
async listStudents(id: number): Promise<{ items: ClassroomStudent[]; total: number }> {
|
||||||
|
return api.get(`/classrooms/${id}/students`);
|
||||||
},
|
},
|
||||||
|
|
||||||
async addMembers(id: number, userIds: number[]): Promise<ApiSuccessResponse> {
|
/** Replace or add to roster. ``mode='set'`` overwrites; ``mode='add'`` appends. */
|
||||||
return api.post<ApiSuccessResponse>(`/groups/${id}/members`, { user_ids: userIds });
|
async setStudents(
|
||||||
|
id: number,
|
||||||
|
studentIds: number[],
|
||||||
|
mode: "set" | "add" = "set",
|
||||||
|
): Promise<Classroom> {
|
||||||
|
const raw = await api.post<unknown>(`/classrooms/${id}/students`, {
|
||||||
|
student_ids: studentIds,
|
||||||
|
mode,
|
||||||
|
});
|
||||||
|
return asRecordData<Classroom>(raw);
|
||||||
},
|
},
|
||||||
|
|
||||||
async removeMembers(id: number, userIds: number[]): Promise<ApiSuccessResponse> {
|
async removeStudents(id: number, studentIds: number[]): Promise<Classroom> {
|
||||||
return api.post<ApiSuccessResponse>(`/groups/${id}/members/remove`, { user_ids: userIds });
|
const raw = await api.delete<unknown>(`/classrooms/${id}/students`, {
|
||||||
|
student_ids: studentIds,
|
||||||
|
});
|
||||||
|
return asRecordData<Classroom>(raw);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Homeroom teachers ----------------------------------------------
|
||||||
|
async listTeachers(id: number): Promise<{ items: ClassroomMember[]; total: number }> {
|
||||||
|
return api.get(`/classrooms/${id}/teachers`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async setTeachers(
|
||||||
|
id: number,
|
||||||
|
teacherIds: number[],
|
||||||
|
mode: "set" | "add" = "set",
|
||||||
|
): Promise<Classroom> {
|
||||||
|
const raw = await api.post<unknown>(`/classrooms/${id}/teachers`, {
|
||||||
|
teacher_ids: teacherIds,
|
||||||
|
mode,
|
||||||
|
});
|
||||||
|
return asRecordData<Classroom>(raw);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Courses + cascade ----------------------------------------------
|
||||||
|
async listCourses(id: number): Promise<{ items: ClassroomCourse[]; total: number }> {
|
||||||
|
return api.get(`/classrooms/${id}/courses`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async listSections(
|
||||||
|
id: number,
|
||||||
|
params?: { course_id?: number; active?: boolean },
|
||||||
|
): Promise<{ items: ClassroomSection[]; total: number }> {
|
||||||
|
return api.get(
|
||||||
|
`/classrooms/${id}/sections`,
|
||||||
|
params as Record<string, string | number | boolean | undefined>,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Assign a course → server creates (or reuses) the canonical batch and
|
||||||
|
* enrolls every classroom student into it. */
|
||||||
|
async assignCourse(
|
||||||
|
id: number,
|
||||||
|
payload: ClassroomAssignCourseRequest,
|
||||||
|
): Promise<{ data: Classroom; batch: ClassroomBatchSummary }> {
|
||||||
|
return api.post(`/classrooms/${id}/assign-course`, payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
async detachCourse(id: number, courseId: number): Promise<Classroom> {
|
||||||
|
const raw = await api.delete<unknown>(`/classrooms/${id}/courses/${courseId}`);
|
||||||
|
return asRecordData<Classroom>(raw);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Batches --------------------------------------------------------
|
||||||
|
async listBatches(id: number): Promise<{ items: ClassroomBatchSummary[]; total: number }> {
|
||||||
|
return api.get(`/classrooms/${id}/batches`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import type {
|
|||||||
CoursePlanMedia,
|
CoursePlanMedia,
|
||||||
CoursePlanSource,
|
CoursePlanSource,
|
||||||
CoursePlanSourceKind,
|
CoursePlanSourceKind,
|
||||||
|
WorkbookAttempt,
|
||||||
|
WorkbookExtractedFrom,
|
||||||
|
WorkbookGroundedSource,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,6 +57,55 @@ export const coursePlanService = {
|
|||||||
return api.get(`/ai/course-plan/${planId}/weeks/${weekNumber}/materials`);
|
return api.get(`/ai/course-plan/${planId}/weeks/${weekNumber}/materials`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAG-grounded richer-schema generator. Requires at least one
|
||||||
|
* indexed source on the plan; backend returns 409 otherwise so the
|
||||||
|
* UI can prompt for upload.
|
||||||
|
*/
|
||||||
|
async generateWeekMaterialsV2(
|
||||||
|
planId: number,
|
||||||
|
weekNumber: number,
|
||||||
|
): Promise<{
|
||||||
|
items: CoursePlanMaterial[];
|
||||||
|
count: number;
|
||||||
|
rag: { enabled: boolean; sources_used: number[] };
|
||||||
|
}> {
|
||||||
|
return api.post(
|
||||||
|
`/ai/course-plan/${planId}/weeks/${weekNumber}/materials/v2`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Mine indexed PDFs for original printed exercises. */
|
||||||
|
async extractWorkbooks(
|
||||||
|
planId: number,
|
||||||
|
payload?: { max_batches?: number },
|
||||||
|
): Promise<{
|
||||||
|
data: {
|
||||||
|
materials_created: number;
|
||||||
|
exercises_total: number;
|
||||||
|
batches_run: number;
|
||||||
|
skipped: number;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
plan_id: number;
|
||||||
|
}> {
|
||||||
|
return api.post(
|
||||||
|
`/ai/course-plan/${planId}/extract-workbooks`,
|
||||||
|
payload ?? {},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Source citations for one material — shown as the
|
||||||
|
* "Grounded on N references" badge popover. */
|
||||||
|
async materialGrounding(materialId: number): Promise<{
|
||||||
|
material_id: number;
|
||||||
|
grounded_on: WorkbookGroundedSource[];
|
||||||
|
extracted_from: WorkbookExtractedFrom | null;
|
||||||
|
sources: CoursePlanSource[];
|
||||||
|
}> {
|
||||||
|
return api.get(`/ai/course-plan/material/${materialId}/grounding`);
|
||||||
|
},
|
||||||
|
|
||||||
async updateMaterial(
|
async updateMaterial(
|
||||||
materialId: number,
|
materialId: number,
|
||||||
payload: {
|
payload: {
|
||||||
@@ -193,6 +245,29 @@ export const coursePlanService = {
|
|||||||
return api.delete(`/ai/course-plan/media/${mediaId}`);
|
return api.delete(`/ai/course-plan/media/${mediaId}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach an admin-supplied media file (audio / image / video) to a
|
||||||
|
* course-plan material. The backend tags the resulting row with
|
||||||
|
* `provider='manual'` so the drawer can distinguish hand-curated
|
||||||
|
* clips from AI-generated ones — and admins can swap a robotic
|
||||||
|
* Polly recording for a real human voiceover whenever they want.
|
||||||
|
*/
|
||||||
|
async uploadMedia(
|
||||||
|
materialId: number,
|
||||||
|
kind: "audio" | "image" | "video",
|
||||||
|
file: File,
|
||||||
|
title?: string,
|
||||||
|
): Promise<{ data: CoursePlanMedia }> {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("kind", kind);
|
||||||
|
fd.append("file", file);
|
||||||
|
if (title) fd.append("title", title);
|
||||||
|
return api.upload(
|
||||||
|
`/ai/course-plan/material/${materialId}/media/upload`,
|
||||||
|
fd,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
async generateWeekMedia(
|
async generateWeekMedia(
|
||||||
planId: number,
|
planId: number,
|
||||||
weekNumber: number,
|
weekNumber: number,
|
||||||
@@ -257,4 +332,42 @@ export const coursePlanService = {
|
|||||||
async studentGet(planId: number): Promise<{ data: CoursePlan }> {
|
async studentGet(planId: number): Promise<{ data: CoursePlan }> {
|
||||||
return api.get(`/student/course-plans/${planId}`);
|
return api.get(`/student/course-plans/${planId}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Phase F — Interactive workbook attempts
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
/** Save (or finalize) the current student's answers and return the
|
||||||
|
* freshly-graded score. Used by InteractiveWorkbook.tsx on every
|
||||||
|
* "Check answers" click (debounced) and on "Submit final". */
|
||||||
|
async saveWorkbookAttempt(
|
||||||
|
planId: number,
|
||||||
|
materialId: number,
|
||||||
|
payload: { answers: Record<string, unknown>; finalize?: boolean },
|
||||||
|
): Promise<{ data: WorkbookAttempt }> {
|
||||||
|
return api.post(
|
||||||
|
`/student/course-plans/${planId}/materials/${materialId}/attempts`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Latest attempt the current user owns on this material — null when
|
||||||
|
* the student hasn't started yet. */
|
||||||
|
async myWorkbookAttempt(
|
||||||
|
planId: number,
|
||||||
|
materialId: number,
|
||||||
|
): Promise<{ data: WorkbookAttempt | null }> {
|
||||||
|
return api.get(
|
||||||
|
`/student/course-plans/${planId}/materials/${materialId}/attempts/me`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Teacher-side roster of latest attempts per student. */
|
||||||
|
async listMaterialAttempts(
|
||||||
|
planId: number,
|
||||||
|
materialId: number,
|
||||||
|
): Promise<{ items: WorkbookAttempt[]; count: number }> {
|
||||||
|
return api.get(
|
||||||
|
`/ai/course-plan/${planId}/materials/${materialId}/attempts`,
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import type {
|
|||||||
MyEnrolledCourse,
|
MyEnrolledCourse,
|
||||||
EnrollStudentRequest,
|
EnrollStudentRequest,
|
||||||
BulkEnrollRequest,
|
BulkEnrollRequest,
|
||||||
|
LmsBranch,
|
||||||
|
CourseSection,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
|
||||||
function mapCourseRaw(r: Record<string, unknown>): Course {
|
function mapCourseRaw(r: Record<string, unknown>): Course {
|
||||||
@@ -53,6 +55,12 @@ function mapCourseRaw(r: Record<string, unknown>): Course {
|
|||||||
chapter_count: Number(r.chapter_count ?? 0),
|
chapter_count: Number(r.chapter_count ?? 0),
|
||||||
resource_count: Number(r.resource_count ?? 0),
|
resource_count: Number(r.resource_count ?? 0),
|
||||||
objective_count: Number(r.objective_count ?? 0),
|
objective_count: Number(r.objective_count ?? 0),
|
||||||
|
section_count: Number(r.section_count ?? 0),
|
||||||
|
sections: Array.isArray(r.sections)
|
||||||
|
? (r.sections as Record<string, unknown>[]).map(mapSectionRaw)
|
||||||
|
: [],
|
||||||
|
branch_id: (r.branch_id as number | null) ?? null,
|
||||||
|
branch_name: (r.branch_name as string) || "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,13 +71,17 @@ function mapBatchRaw(r: Record<string, unknown>): Batch {
|
|||||||
active: "active",
|
active: "active",
|
||||||
completed: "completed",
|
completed: "completed",
|
||||||
};
|
};
|
||||||
|
const teacherIds = (r.teacher_ids as number[]) || [];
|
||||||
|
const teacherNames = (r.teacher_names as string[]) || [];
|
||||||
return {
|
return {
|
||||||
id: r.id as number,
|
id: r.id as number,
|
||||||
name: String(r.name || ""),
|
name: String(r.name || ""),
|
||||||
course_id: Number(r.course_id || 0),
|
course_id: Number(r.course_id || 0),
|
||||||
course_name: String(r.course_name || ""),
|
course_name: String(r.course_name || ""),
|
||||||
teacher_id: 0,
|
teacher_id: teacherIds[0] || 0,
|
||||||
teacher_name: "",
|
teacher_name: teacherNames[0] || "",
|
||||||
|
teacher_ids: teacherIds,
|
||||||
|
teacher_names: teacherNames,
|
||||||
student_ids: ((r.students as { id: number }[]) ?? []).map((s) => s.id),
|
student_ids: ((r.students as { id: number }[]) ?? []).map((s) => s.id),
|
||||||
student_count: Number(r.student_count ?? 0),
|
student_count: Number(r.student_count ?? 0),
|
||||||
start_date: String(r.start_date || ""),
|
start_date: String(r.start_date || ""),
|
||||||
@@ -77,6 +89,32 @@ function mapBatchRaw(r: Record<string, unknown>): Batch {
|
|||||||
schedule: "",
|
schedule: "",
|
||||||
status: statusMap[st] || "upcoming",
|
status: statusMap[st] || "upcoming",
|
||||||
capacity: Number(r.max_students ?? 0),
|
capacity: Number(r.max_students ?? 0),
|
||||||
|
branch_id: (r.branch_id as number | null) ?? null,
|
||||||
|
branch_name: (r.branch_name as string) || "",
|
||||||
|
classroom_id: (r.classroom_id as number | null) ?? null,
|
||||||
|
classroom_name: (r.classroom_name as string) || "",
|
||||||
|
course_section_id: (r.course_section_id as number | null) ?? null,
|
||||||
|
course_section_name: (r.course_section_name as string) || "",
|
||||||
|
course_section_code: (r.course_section_code as string) || "",
|
||||||
|
term_key: (r.term_key as string) || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSectionRaw(r: Record<string, unknown>): CourseSection {
|
||||||
|
return {
|
||||||
|
id: Number(r.id || 0),
|
||||||
|
name: String(r.name || ""),
|
||||||
|
code: String(r.code || ""),
|
||||||
|
sequence: Number(r.sequence || 0),
|
||||||
|
active: Boolean(r.active ?? true),
|
||||||
|
notes: String(r.notes || ""),
|
||||||
|
course_id: Number(r.course_id || 0),
|
||||||
|
course_name: String(r.course_name || ""),
|
||||||
|
entity_id: (r.entity_id as number | null) ?? null,
|
||||||
|
entity_name: String(r.entity_name || ""),
|
||||||
|
branch_id: (r.branch_id as number | null) ?? null,
|
||||||
|
branch_name: String(r.branch_name || ""),
|
||||||
|
batch_count: Number(r.batch_count || 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +174,25 @@ export const lmsService = {
|
|||||||
return { items, total: p.total, page: p.page, size: p.size, pages: p.pages };
|
return { items, total: p.total, page: p.page, size: p.size, pages: p.pages };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listBranches(params?: PaginationParams & { search?: string; entity_id?: number; active?: boolean }): Promise<PaginatedResponse<LmsBranch>> {
|
||||||
|
const raw = await api.get<unknown>("/branches", params as Record<string, string | number | boolean | undefined>);
|
||||||
|
return asPaginated<LmsBranch>(raw);
|
||||||
|
},
|
||||||
|
|
||||||
|
async createBranch(data: Partial<LmsBranch>): Promise<LmsBranch> {
|
||||||
|
const raw = await api.post<unknown>("/branches", data);
|
||||||
|
return asRecordData<LmsBranch>(raw);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateBranch(id: number, data: Partial<LmsBranch>): Promise<LmsBranch> {
|
||||||
|
const raw = await api.patch<unknown>(`/branches/${id}`, data);
|
||||||
|
return asRecordData<LmsBranch>(raw);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteBranch(id: number): Promise<ApiSuccessResponse> {
|
||||||
|
return api.delete<ApiSuccessResponse>(`/branches/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
async getCourse(id: number): Promise<Course> {
|
async getCourse(id: number): Promise<Course> {
|
||||||
const raw = await api.get<unknown>(`/courses/${id}`);
|
const raw = await api.get<unknown>(`/courses/${id}`);
|
||||||
return mapCourseRaw(asRecordData<Record<string, unknown>>(raw));
|
return mapCourseRaw(asRecordData<Record<string, unknown>>(raw));
|
||||||
@@ -179,6 +236,51 @@ export const lmsService = {
|
|||||||
return api.delete<ApiSuccessResponse>(`/courses/${id}`);
|
return api.delete<ApiSuccessResponse>(`/courses/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listCourseSections(courseId: number): Promise<{ items: CourseSection[]; total: number }> {
|
||||||
|
const raw = await api.get<unknown>(`/courses/${courseId}/sections`);
|
||||||
|
const p = asPaginated<Record<string, unknown>>(raw);
|
||||||
|
return { ...p, items: p.items.map(mapSectionRaw) };
|
||||||
|
},
|
||||||
|
|
||||||
|
async createCourseSection(
|
||||||
|
courseId: number,
|
||||||
|
data: { name: string; code: string; sequence?: number; active?: boolean; notes?: string; branch_id?: number | null },
|
||||||
|
): Promise<CourseSection> {
|
||||||
|
const raw = await api.post<unknown>(`/courses/${courseId}/sections`, data);
|
||||||
|
return asRecordData<CourseSection>(raw);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateCourseSection(
|
||||||
|
courseId: number,
|
||||||
|
sectionId: number,
|
||||||
|
data: Partial<{ name: string; code: string; sequence: number; active: boolean; notes: string; branch_id: number | null }>,
|
||||||
|
): Promise<CourseSection> {
|
||||||
|
const raw = await api.patch<unknown>(`/courses/${courseId}/sections/${sectionId}`, data);
|
||||||
|
return asRecordData<CourseSection>(raw);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteCourseSection(courseId: number, sectionId: number): Promise<ApiSuccessResponse> {
|
||||||
|
return api.delete<ApiSuccessResponse>(`/courses/${courseId}/sections/${sectionId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateDefaultCourseSections(
|
||||||
|
courseId: number,
|
||||||
|
data?: { codes?: string[]; branch_id?: number | null },
|
||||||
|
): Promise<{ items: CourseSection[]; created_count: number; created_ids: number[]; total: number }> {
|
||||||
|
const raw = await api.post<{
|
||||||
|
items: Record<string, unknown>[];
|
||||||
|
created_count: number;
|
||||||
|
created_ids: number[];
|
||||||
|
total: number;
|
||||||
|
}>(`/courses/${courseId}/sections/generate-defaults`, data ?? {});
|
||||||
|
return {
|
||||||
|
created_count: raw.created_count ?? 0,
|
||||||
|
created_ids: raw.created_ids ?? [],
|
||||||
|
total: raw.total ?? 0,
|
||||||
|
items: (raw.items ?? []).map(mapSectionRaw),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
async aiGenerateCourse(data: { title: string; subject_id?: number; level?: string }): Promise<{ outline: unknown }> {
|
async aiGenerateCourse(data: { title: string; subject_id?: number; level?: string }): Promise<{ outline: unknown }> {
|
||||||
return api.post("/courses/ai-generate", data);
|
return api.post("/courses/ai-generate", data);
|
||||||
},
|
},
|
||||||
@@ -255,6 +357,25 @@ export const lmsService = {
|
|||||||
return api.post(`/batches/${batchId}/students/remove`, { student_ids: studentIds });
|
return api.post(`/batches/${batchId}/students/remove`, { student_ids: studentIds });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getBatchTeachers(batchId: number): Promise<{ items: { id: number; name: string; email: string }[]; total: number }> {
|
||||||
|
return api.get(`/batches/${batchId}/teachers`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async setBatchTeachers(
|
||||||
|
batchId: number,
|
||||||
|
teacherIds: number[],
|
||||||
|
mode: "set" | "add" = "set",
|
||||||
|
): Promise<{ success: boolean; teacher_ids: number[] }> {
|
||||||
|
return api.post(`/batches/${batchId}/teachers`, { teacher_ids: teacherIds, mode });
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeBatchTeachers(
|
||||||
|
batchId: number,
|
||||||
|
teacherIds: number[],
|
||||||
|
): Promise<{ success: boolean; teacher_ids: number[] }> {
|
||||||
|
return api.post(`/batches/${batchId}/teachers/remove`, { teacher_ids: teacherIds });
|
||||||
|
},
|
||||||
|
|
||||||
async deleteStudent(id: number): Promise<ApiSuccessResponse> {
|
async deleteStudent(id: number): Promise<ApiSuccessResponse> {
|
||||||
return api.delete<ApiSuccessResponse>(`/students/${id}`);
|
return api.delete<ApiSuccessResponse>(`/students/${id}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,24 +1,98 @@
|
|||||||
|
/** Classroom = homeroom (physical room repurposed as a class group).
|
||||||
|
*
|
||||||
|
* Owns:
|
||||||
|
* - a roster of students (M2M)
|
||||||
|
* - a set of homeroom teachers (M2M)
|
||||||
|
* - a list of courses taught here (M2M) — assigning a course cascades
|
||||||
|
* into a batch + auto-enrollment of every roster student.
|
||||||
|
* - the resulting batches (one per (classroom × course))
|
||||||
|
*/
|
||||||
export interface Classroom {
|
export interface Classroom {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
admin_id: number;
|
code: string;
|
||||||
admin_name: string;
|
capacity: number;
|
||||||
entity_id: number;
|
active: boolean;
|
||||||
|
notes?: string;
|
||||||
|
entity_id: number | null;
|
||||||
entity_name: string;
|
entity_name: string;
|
||||||
participant_count: number;
|
branch_id?: number | null;
|
||||||
participants: ClassroomMember[];
|
branch_name?: string;
|
||||||
|
student_count: number;
|
||||||
|
teacher_count: number;
|
||||||
|
course_count: number;
|
||||||
|
batch_count: number;
|
||||||
|
student_ids: number[];
|
||||||
|
teacher_ids: number[];
|
||||||
|
course_ids: number[];
|
||||||
|
batch_ids?: number[];
|
||||||
|
/** Only populated by GET /api/classrooms/<id> (full payload). */
|
||||||
|
students?: ClassroomStudent[];
|
||||||
|
teachers?: ClassroomMember[];
|
||||||
|
courses?: ClassroomCourse[];
|
||||||
|
sections?: ClassroomSection[];
|
||||||
|
batches?: ClassroomBatchSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClassroomMember {
|
export interface ClassroomMember {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
user_type: string;
|
}
|
||||||
|
|
||||||
|
export interface ClassroomStudent extends ClassroomMember {
|
||||||
|
gr_no?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClassroomCourse {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClassroomSection {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
sequence: number;
|
||||||
|
course_id: number | null;
|
||||||
|
course_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClassroomBatchSummary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
course_id: number | null;
|
||||||
|
course_name: string;
|
||||||
|
course_section_id?: number | null;
|
||||||
|
course_section_name?: string;
|
||||||
|
course_section_code?: string;
|
||||||
|
term_key?: string;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
student_count: number;
|
||||||
|
teacher_ids: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClassroomCreateRequest {
|
export interface ClassroomCreateRequest {
|
||||||
name: string;
|
name: string;
|
||||||
entity_id: number;
|
code?: string;
|
||||||
admin_id: number;
|
capacity?: number;
|
||||||
participant_ids?: number[];
|
notes?: string;
|
||||||
|
entity_id?: number;
|
||||||
|
branch_id?: number | null;
|
||||||
|
student_ids?: number[];
|
||||||
|
teacher_ids?: number[];
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClassroomAssignCourseRequest {
|
||||||
|
course_id: number;
|
||||||
|
section_id?: number;
|
||||||
|
teacher_ids?: number[];
|
||||||
|
term_key?: string;
|
||||||
|
term_name?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,129 @@ export type CoursePlanMaterialType =
|
|||||||
| "grammar_lesson"
|
| "grammar_lesson"
|
||||||
| "vocabulary_list"
|
| "vocabulary_list"
|
||||||
| "practice"
|
| "practice"
|
||||||
|
| "interactive_workbook"
|
||||||
| "other";
|
| "other";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Interactive workbooks — schema mirrors backend `_WEEK_JSON_HINT_V2` and
|
||||||
|
// `ExerciseExtractor` output. The renderer in `InteractiveWorkbook.tsx`
|
||||||
|
// is a discriminated-union switch on `type`.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type WorkbookExerciseType =
|
||||||
|
| "gap_fill"
|
||||||
|
| "multiple_choice"
|
||||||
|
| "match_pairs"
|
||||||
|
| "reorder_words"
|
||||||
|
| "transformation"
|
||||||
|
| "short_answer";
|
||||||
|
|
||||||
|
export interface WorkbookExerciseBase {
|
||||||
|
id: string;
|
||||||
|
type: WorkbookExerciseType;
|
||||||
|
hint?: string;
|
||||||
|
rationale?: string;
|
||||||
|
}
|
||||||
|
export interface GapFillExercise extends WorkbookExerciseBase {
|
||||||
|
type: "gap_fill";
|
||||||
|
stem: string;
|
||||||
|
answer: string;
|
||||||
|
alt?: string[];
|
||||||
|
}
|
||||||
|
export interface MultipleChoiceExercise extends WorkbookExerciseBase {
|
||||||
|
type: "multiple_choice";
|
||||||
|
stem: string;
|
||||||
|
options: string[];
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
export interface MatchPairsExercise extends WorkbookExerciseBase {
|
||||||
|
type: "match_pairs";
|
||||||
|
left: string[];
|
||||||
|
right: string[];
|
||||||
|
answer: number[][];
|
||||||
|
}
|
||||||
|
export interface ReorderWordsExercise extends WorkbookExerciseBase {
|
||||||
|
type: "reorder_words";
|
||||||
|
tokens: string[];
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
export interface TransformationExercise extends WorkbookExerciseBase {
|
||||||
|
type: "transformation";
|
||||||
|
from: string;
|
||||||
|
instruction: string;
|
||||||
|
answer: string;
|
||||||
|
alt?: string[];
|
||||||
|
}
|
||||||
|
export interface ShortAnswerExercise extends WorkbookExerciseBase {
|
||||||
|
type: "short_answer";
|
||||||
|
stem: string;
|
||||||
|
answer: string;
|
||||||
|
accepted?: string[];
|
||||||
|
}
|
||||||
|
export type WorkbookExercise =
|
||||||
|
| GapFillExercise
|
||||||
|
| MultipleChoiceExercise
|
||||||
|
| MatchPairsExercise
|
||||||
|
| ReorderWordsExercise
|
||||||
|
| TransformationExercise
|
||||||
|
| ShortAnswerExercise;
|
||||||
|
|
||||||
|
export interface WorkbookLessonPlan {
|
||||||
|
warmup?: string;
|
||||||
|
presentation?: string;
|
||||||
|
controlled_practice?: string;
|
||||||
|
freer_practice?: string;
|
||||||
|
exit_ticket?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkbookBody {
|
||||||
|
lesson_plan?: WorkbookLessonPlan;
|
||||||
|
exercises: WorkbookExercise[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkbookGroundedSource {
|
||||||
|
source_id: number;
|
||||||
|
title: string;
|
||||||
|
chunks_used: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkbookExtractedFrom {
|
||||||
|
source_id: number;
|
||||||
|
source_title?: string;
|
||||||
|
page_hint?: string;
|
||||||
|
topic_keywords?: string[];
|
||||||
|
chunk_indices?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkbookAttemptScoreItem {
|
||||||
|
id: string;
|
||||||
|
correct: boolean;
|
||||||
|
expected: unknown;
|
||||||
|
given: unknown;
|
||||||
|
}
|
||||||
|
export interface WorkbookAttemptScore {
|
||||||
|
items: WorkbookAttemptScoreItem[];
|
||||||
|
correct: number;
|
||||||
|
total: number;
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
export interface WorkbookAttempt {
|
||||||
|
id: number;
|
||||||
|
material_id: number;
|
||||||
|
plan_id: number | null;
|
||||||
|
student_id: number;
|
||||||
|
student_name: string;
|
||||||
|
attempt_number: number;
|
||||||
|
is_final: boolean;
|
||||||
|
submitted_at: string | null;
|
||||||
|
last_updated_at: string | null;
|
||||||
|
correct_count: number;
|
||||||
|
total_count: number;
|
||||||
|
percent: number;
|
||||||
|
answers: Record<string, unknown>;
|
||||||
|
score: WorkbookAttemptScore | Record<string, never>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CoursePlanOutcome {
|
export interface CoursePlanOutcome {
|
||||||
code: string;
|
code: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -83,6 +204,10 @@ export interface CoursePlanMaterial {
|
|||||||
/** Loose shape: depends on material_type. */
|
/** Loose shape: depends on material_type. */
|
||||||
body: Record<string, unknown>;
|
body: Record<string, unknown>;
|
||||||
body_text: string;
|
body_text: string;
|
||||||
|
/** Citations for materials produced by the v2/RAG generator. */
|
||||||
|
grounded_on?: WorkbookGroundedSource[];
|
||||||
|
/** Provenance for materials mined out of indexed PDFs. */
|
||||||
|
extracted_from?: WorkbookExtractedFrom | null;
|
||||||
media?: CoursePlanMedia[];
|
media?: CoursePlanMedia[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export interface Course {
|
|||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
code: string;
|
code: string;
|
||||||
|
branch_id?: number | null;
|
||||||
|
branch_name?: string;
|
||||||
subject_id?: number;
|
subject_id?: number;
|
||||||
subject_name?: string;
|
subject_name?: string;
|
||||||
encoach_subject_id?: number | null;
|
encoach_subject_id?: number | null;
|
||||||
@@ -28,6 +30,24 @@ export interface Course {
|
|||||||
chapter_count?: number;
|
chapter_count?: number;
|
||||||
resource_count?: number;
|
resource_count?: number;
|
||||||
objective_count?: number;
|
objective_count?: number;
|
||||||
|
section_count?: number;
|
||||||
|
sections?: CourseSection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CourseSection {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
sequence: number;
|
||||||
|
active: boolean;
|
||||||
|
notes?: string;
|
||||||
|
course_id: number;
|
||||||
|
course_name: string;
|
||||||
|
entity_id?: number | null;
|
||||||
|
entity_name?: string;
|
||||||
|
branch_id?: number | null;
|
||||||
|
branch_name?: string;
|
||||||
|
batch_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CourseModule {
|
export interface CourseModule {
|
||||||
@@ -48,10 +68,22 @@ export interface CourseLesson {
|
|||||||
export interface Batch {
|
export interface Batch {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
branch_id?: number | null;
|
||||||
|
branch_name?: string;
|
||||||
|
classroom_id?: number | null;
|
||||||
|
classroom_name?: string;
|
||||||
|
course_section_id?: number | null;
|
||||||
|
course_section_name?: string;
|
||||||
|
course_section_code?: string;
|
||||||
|
term_key?: string;
|
||||||
course_id: number;
|
course_id: number;
|
||||||
course_name: string;
|
course_name: string;
|
||||||
|
/** @deprecated single teacher kept for back-compat. Use `teacher_ids`. */
|
||||||
teacher_id: number;
|
teacher_id: number;
|
||||||
|
/** @deprecated single teacher kept for back-compat. */
|
||||||
teacher_name: string;
|
teacher_name: string;
|
||||||
|
teacher_ids?: number[];
|
||||||
|
teacher_names?: string[];
|
||||||
student_ids: number[];
|
student_ids: number[];
|
||||||
student_count: number;
|
student_count: number;
|
||||||
start_date: string;
|
start_date: string;
|
||||||
@@ -132,6 +164,8 @@ export interface LmsStudentRecord {
|
|||||||
batch_name: string;
|
batch_name: string;
|
||||||
partner_id: number;
|
partner_id: number;
|
||||||
user_id: number | null;
|
user_id: number | null;
|
||||||
|
branch_id?: number | null;
|
||||||
|
branch_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** OpenEduCat `op.faculty` row from `/api/teachers` */
|
/** OpenEduCat `op.faculty` row from `/api/teachers` */
|
||||||
@@ -149,8 +183,25 @@ export interface LmsTeacherRecord {
|
|||||||
department_name: string;
|
department_name: string;
|
||||||
specialization: string;
|
specialization: string;
|
||||||
subject_names: string[];
|
subject_names: string[];
|
||||||
|
branch_id?: number | null;
|
||||||
|
branch_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LmsBranch {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
active: boolean;
|
||||||
|
notes: string;
|
||||||
|
entity_id: number | null;
|
||||||
|
entity_name: string;
|
||||||
|
course_count: number;
|
||||||
|
batch_count: number;
|
||||||
|
student_count: number;
|
||||||
|
teacher_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Enrolled course returned by GET /api/student/my-courses with progress data. */
|
/** Enrolled course returned by GET /api/student/my-courses with progress data. */
|
||||||
export interface MyEnrolledCourse {
|
export interface MyEnrolledCourse {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -194,6 +245,7 @@ export interface LmsStudentCreateRequest {
|
|||||||
password?: string;
|
password?: string;
|
||||||
/** Default true: create portal user linked to `op.student`. */
|
/** Default true: create portal user linked to `op.student`. */
|
||||||
create_portal_user?: boolean;
|
create_portal_user?: boolean;
|
||||||
|
branch_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LmsTeacherCreateRequest {
|
export interface LmsTeacherCreateRequest {
|
||||||
@@ -205,4 +257,5 @@ export interface LmsTeacherCreateRequest {
|
|||||||
birth_date?: string;
|
birth_date?: string;
|
||||||
department_id?: number;
|
department_id?: number;
|
||||||
specialization?: string;
|
specialization?: string;
|
||||||
|
branch_id?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user