diff --git a/src/pages/admin/AdminCourses.tsx b/src/pages/admin/AdminCourses.tsx
index 66d83ba..15fef5f 100644
--- a/src/pages/admin/AdminCourses.tsx
+++ b/src/pages/admin/AdminCourses.tsx
@@ -13,12 +13,12 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCourses, useCreateCourse, useStudents, useBulkEnroll, useBatches } from "@/hooks/queries";
import { lmsService, taxonomyService, resourcesService } from "@/services";
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 AiCreationAssistant from "@/components/ai/AiCreationAssistant";
import AiTipBanner from "@/components/ai/AiTipBanner";
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";
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
(10);
+ const [editing, setEditing] = useState(null);
+ const [editName, setEditName] = useState("");
+ const [editCode, setEditCode] = useState("");
+ const [editSeq, setEditSeq] = useState(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 (
+
+ );
+}
+
export default function AdminCourses() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [editingCourse, setEditingCourse] = useState(null);
+ const [sectionsCourse, setSectionsCourse] = useState(null);
const [enrollOpen, setEnrollOpen] = useState(false);
const [enrollCourseId, setEnrollCourseId] = useState(null);
const [selectedStudentIds, setSelectedStudentIds] = useState([]);
@@ -539,10 +864,11 @@ export default function AdminCourses() {
Course
Subject / Tags
Difficulty
+ Sections
Chapters
Enrolled / Cap
Status
-
+
@@ -594,6 +920,36 @@ export default function AdminCourses() {
)}
+
+
+
@@ -627,6 +983,14 @@ export default function AdminCourses() {
>
+
- );
-}
-
-function StudentWeek({
- week,
- materials,
-}: {
- week: CoursePlanWeek;
- materials: CoursePlanMaterial[];
-}) {
- const { t } = useTranslation();
- const [skillFilter, setSkillFilter] = useState("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 (
-
-
-
-
- {t("coursePlan.weekN", { n: week.week_number })}
-
-
-
- {week.focus || week.unit || "—"}
-
- {week.date_label && (
-
- {week.date_label}
-
- )}
-
-
-
-
- {skills.length > 0 && (
-
- setSkillFilter("all")}
- >
- {t("common.all", "All")}
-
- {skills.map((s) => (
- setSkillFilter(s)}
- className="capitalize"
- >
- {s}
-
- ))}
-
- )}
- {materials.length === 0 && (
-
- {t("coursePlan.media.noMedia")}
-
- )}
- {filteredMaterials.map((m) => (
-
- ))}
-
-
- );
-}
-
-function StudentMaterial({ material }: { material: CoursePlanMaterial }) {
- const { t } = useTranslation();
- const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
- return (
-
-
-
-
-
- {material.title}
-
-
- {t(
- `coursePlan.materialType.${material.material_type}`,
- material.material_type,
- )}
-
-
-
- {material.summary && {material.summary}}
- {material.share_date && (
-
- {t("coursePlan.shareDate", "Share date")}: {material.share_date}
-
- )}
-
-
- {(material.media ?? []).map((m) => (
-
- ))}
-
-
-
- );
-}
-
-function StudentMediaTile({ media }: { media: CoursePlanMedia }) {
- const url = withAuthQuery(media.preview_url || media.download_url || "");
- if (!url) return null;
- return (
-
-
- {media.kind === "audio" && }
- {media.kind === "image" && }
- {media.kind === "video" && }
-
- {media.kind}
-
-
- {media.kind === "audio" &&
}
- {media.kind === "image" && (
-

- )}
- {media.kind === "video" && (
-
- )}
+ {plan &&
}
);
}
diff --git a/src/pages/student/StudentDashboard.tsx b/src/pages/student/StudentDashboard.tsx
index d92f4c1..0fd3ca5 100644
--- a/src/pages/student/StudentDashboard.tsx
+++ b/src/pages/student/StudentDashboard.tsx
@@ -2,22 +2,29 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
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 { useTranslation } from "react-i18next";
+import { useQuery } from "@tanstack/react-query";
import { useMyEnrolledCourses, useGrades } from "@/hooks/queries";
import { useAuth } from "@/contexts/AuthContext";
import AiStudyCoach from "@/components/ai/AiStudyCoach";
import AiTipBanner from "@/components/ai/AiTipBanner";
+import { coursePlanService } from "@/services/coursePlan.service";
export default function StudentDashboard() {
const { user } = useAuth();
const { t } = useTranslation();
const { data: enrolledData, isLoading: lc } = useMyEnrolledCourses();
const { data: gradesData, isLoading: lg } = useGrades();
+ const { data: planData, isLoading: lp } = useQuery({
+ queryKey: ["student-course-plans"],
+ queryFn: () => coursePlanService.studentList(),
+ });
const myCourses = enrolledData?.items ?? [];
+ const myPlans = planData?.items ?? [];
const gradeRecords = gradesData ?? [];
- if (lc || lg) return ;
+ if (lc || lg || lp) return ;
const recentGrades = gradeRecords.slice(0, 3);
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 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.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" },
@@ -93,6 +100,42 @@ export default function StudentDashboard() {
+
+
+
+
+ {t("studentDash.myCoursePlans")}
+
+
+
+ {t("common.viewAll")}
+
+
+
+
+ {myPlans.length === 0 ? (
+ {t("studentDash.noCoursePlans")}
+ ) : myPlans.slice(0, 4).map((p) => (
+
+
+
+
{p.name}
+
+ {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.cefr_level}
+
+
+
+ ))}
+
+
+
{t("studentDash.quickActions")}
diff --git a/src/services/classrooms.service.ts b/src/services/classrooms.service.ts
index deeca5c..ff6fc66 100644
--- a/src/services/classrooms.service.ts
+++ b/src/services/classrooms.service.ts
@@ -1,36 +1,127 @@
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 {
entity_id?: number;
+ branch_id?: number;
+ search?: string;
+ active?: boolean;
}
export const classroomsService = {
async list(params?: ClassroomListParams): Promise> {
- return api.get>("/groups", params as Record);
+ const raw = await api.get(
+ "/classrooms",
+ params as Record,
+ );
+ return asPaginated(raw);
},
async getById(id: number): Promise {
- return api.get(`/groups/${id}`);
+ const raw = await api.get(`/classrooms/${id}`);
+ return asRecordData(raw);
},
async create(data: ClassroomCreateRequest): Promise {
- return api.post("/groups", data);
+ const raw = await api.post("/classrooms", data);
+ return asRecordData(raw);
+ },
+
+ async update(id: number, data: Partial): Promise {
+ const raw = await api.patch(`/classrooms/${id}`, data);
+ return asRecordData(raw);
},
async delete(id: number): Promise {
- return api.delete(`/groups/${id}`);
+ return api.delete(`/classrooms/${id}`);
},
- async transfer(data: { student_ids: number[]; from_id: number; to_id: number }): Promise {
- return api.post("/groups/transfer", data);
+ // ---- Roster ----------------------------------------------------------
+ async listStudents(id: number): Promise<{ items: ClassroomStudent[]; total: number }> {
+ return api.get(`/classrooms/${id}/students`);
},
- async addMembers(id: number, userIds: number[]): Promise {
- return api.post(`/groups/${id}/members`, { user_ids: userIds });
+ /** Replace or add to roster. ``mode='set'`` overwrites; ``mode='add'`` appends. */
+ async setStudents(
+ id: number,
+ studentIds: number[],
+ mode: "set" | "add" = "set",
+ ): Promise {
+ const raw = await api.post(`/classrooms/${id}/students`, {
+ student_ids: studentIds,
+ mode,
+ });
+ return asRecordData(raw);
},
- async removeMembers(id: number, userIds: number[]): Promise {
- return api.post(`/groups/${id}/members/remove`, { user_ids: userIds });
+ async removeStudents(id: number, studentIds: number[]): Promise {
+ const raw = await api.delete(`/classrooms/${id}/students`, {
+ student_ids: studentIds,
+ });
+ return asRecordData(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 {
+ const raw = await api.post(`/classrooms/${id}/teachers`, {
+ teacher_ids: teacherIds,
+ mode,
+ });
+ return asRecordData(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,
+ );
+ },
+
+ /** 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 {
+ const raw = await api.delete(`/classrooms/${id}/courses/${courseId}`);
+ return asRecordData(raw);
+ },
+
+ // ---- Batches --------------------------------------------------------
+ async listBatches(id: number): Promise<{ items: ClassroomBatchSummary[]; total: number }> {
+ return api.get(`/classrooms/${id}/batches`);
},
};
diff --git a/src/services/coursePlan.service.ts b/src/services/coursePlan.service.ts
index 8fcffa6..69c60b7 100644
--- a/src/services/coursePlan.service.ts
+++ b/src/services/coursePlan.service.ts
@@ -8,6 +8,9 @@ import type {
CoursePlanMedia,
CoursePlanSource,
CoursePlanSourceKind,
+ WorkbookAttempt,
+ WorkbookExtractedFrom,
+ WorkbookGroundedSource,
} from "@/types";
/**
@@ -54,6 +57,55 @@ export const coursePlanService = {
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(
materialId: number,
payload: {
@@ -193,6 +245,29 @@ export const coursePlanService = {
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(
planId: number,
weekNumber: number,
@@ -257,4 +332,42 @@ export const coursePlanService = {
async studentGet(planId: number): Promise<{ data: CoursePlan }> {
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; 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`,
+ );
+ },
};
diff --git a/src/services/lms.service.ts b/src/services/lms.service.ts
index d12484f..e869c08 100644
--- a/src/services/lms.service.ts
+++ b/src/services/lms.service.ts
@@ -18,6 +18,8 @@ import type {
MyEnrolledCourse,
EnrollStudentRequest,
BulkEnrollRequest,
+ LmsBranch,
+ CourseSection,
} from "@/types";
function mapCourseRaw(r: Record): Course {
@@ -53,6 +55,12 @@ function mapCourseRaw(r: Record): Course {
chapter_count: Number(r.chapter_count ?? 0),
resource_count: Number(r.resource_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[]).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): Batch {
active: "active",
completed: "completed",
};
+ const teacherIds = (r.teacher_ids as number[]) || [];
+ const teacherNames = (r.teacher_names as string[]) || [];
return {
id: r.id as number,
name: String(r.name || ""),
course_id: Number(r.course_id || 0),
course_name: String(r.course_name || ""),
- teacher_id: 0,
- teacher_name: "",
+ teacher_id: teacherIds[0] || 0,
+ teacher_name: teacherNames[0] || "",
+ teacher_ids: teacherIds,
+ teacher_names: teacherNames,
student_ids: ((r.students as { id: number }[]) ?? []).map((s) => s.id),
student_count: Number(r.student_count ?? 0),
start_date: String(r.start_date || ""),
@@ -77,6 +89,32 @@ function mapBatchRaw(r: Record): Batch {
schedule: "",
status: statusMap[st] || "upcoming",
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): 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 };
},
+ async listBranches(params?: PaginationParams & { search?: string; entity_id?: number; active?: boolean }): Promise> {
+ const raw = await api.get("/branches", params as Record);
+ return asPaginated(raw);
+ },
+
+ async createBranch(data: Partial): Promise {
+ const raw = await api.post("/branches", data);
+ return asRecordData(raw);
+ },
+
+ async updateBranch(id: number, data: Partial): Promise {
+ const raw = await api.patch(`/branches/${id}`, data);
+ return asRecordData(raw);
+ },
+
+ async deleteBranch(id: number): Promise {
+ return api.delete(`/branches/${id}`);
+ },
+
async getCourse(id: number): Promise {
const raw = await api.get(`/courses/${id}`);
return mapCourseRaw(asRecordData>(raw));
@@ -179,6 +236,51 @@ export const lmsService = {
return api.delete(`/courses/${id}`);
},
+ async listCourseSections(courseId: number): Promise<{ items: CourseSection[]; total: number }> {
+ const raw = await api.get(`/courses/${courseId}/sections`);
+ const p = asPaginated>(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 {
+ const raw = await api.post(`/courses/${courseId}/sections`, data);
+ return asRecordData(raw);
+ },
+
+ async updateCourseSection(
+ courseId: number,
+ sectionId: number,
+ data: Partial<{ name: string; code: string; sequence: number; active: boolean; notes: string; branch_id: number | null }>,
+ ): Promise {
+ const raw = await api.patch(`/courses/${courseId}/sections/${sectionId}`, data);
+ return asRecordData(raw);
+ },
+
+ async deleteCourseSection(courseId: number, sectionId: number): Promise {
+ return api.delete(`/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[];
+ 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 }> {
return api.post("/courses/ai-generate", data);
},
@@ -255,6 +357,25 @@ export const lmsService = {
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 {
return api.delete(`/students/${id}`);
},
diff --git a/src/types/classroom.ts b/src/types/classroom.ts
index 29b3fbd..5b6b2da 100644
--- a/src/types/classroom.ts
+++ b/src/types/classroom.ts
@@ -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 {
id: number;
name: string;
- admin_id: number;
- admin_name: string;
- entity_id: number;
+ code: string;
+ capacity: number;
+ active: boolean;
+ notes?: string;
+ entity_id: number | null;
entity_name: string;
- participant_count: number;
- participants: ClassroomMember[];
+ branch_id?: number | null;
+ 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/ (full payload). */
+ students?: ClassroomStudent[];
+ teachers?: ClassroomMember[];
+ courses?: ClassroomCourse[];
+ sections?: ClassroomSection[];
+ batches?: ClassroomBatchSummary[];
}
export interface ClassroomMember {
id: number;
name: 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 {
name: string;
- entity_id: number;
- admin_id: number;
- participant_ids?: number[];
+ code?: string;
+ capacity?: 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;
}
diff --git a/src/types/coursePlan.ts b/src/types/coursePlan.ts
index 7309e3a..674a5ce 100644
--- a/src/types/coursePlan.ts
+++ b/src/types/coursePlan.ts
@@ -20,8 +20,129 @@ export type CoursePlanMaterialType =
| "grammar_lesson"
| "vocabulary_list"
| "practice"
+ | "interactive_workbook"
| "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;
+ score: WorkbookAttemptScore | Record;
+}
+
export interface CoursePlanOutcome {
code: string;
description: string;
@@ -83,6 +204,10 @@ export interface CoursePlanMaterial {
/** Loose shape: depends on material_type. */
body: Record;
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[];
}
diff --git a/src/types/lms.ts b/src/types/lms.ts
index 76209b3..630bfa3 100644
--- a/src/types/lms.ts
+++ b/src/types/lms.ts
@@ -2,6 +2,8 @@ export interface Course {
id: number;
title: string;
code: string;
+ branch_id?: number | null;
+ branch_name?: string;
subject_id?: number;
subject_name?: string;
encoach_subject_id?: number | null;
@@ -28,6 +30,24 @@ export interface Course {
chapter_count?: number;
resource_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 {
@@ -48,10 +68,22 @@ export interface CourseLesson {
export interface Batch {
id: number;
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_name: string;
+ /** @deprecated single teacher kept for back-compat. Use `teacher_ids`. */
teacher_id: number;
+ /** @deprecated single teacher kept for back-compat. */
teacher_name: string;
+ teacher_ids?: number[];
+ teacher_names?: string[];
student_ids: number[];
student_count: number;
start_date: string;
@@ -132,6 +164,8 @@ export interface LmsStudentRecord {
batch_name: string;
partner_id: number;
user_id: number | null;
+ branch_id?: number | null;
+ branch_name?: string;
}
/** OpenEduCat `op.faculty` row from `/api/teachers` */
@@ -149,8 +183,25 @@ export interface LmsTeacherRecord {
department_name: string;
specialization: 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. */
export interface MyEnrolledCourse {
id: number;
@@ -194,6 +245,7 @@ export interface LmsStudentCreateRequest {
password?: string;
/** Default true: create portal user linked to `op.student`. */
create_portal_user?: boolean;
+ branch_id?: number;
}
export interface LmsTeacherCreateRequest {
@@ -205,4 +257,5 @@ export interface LmsTeacherCreateRequest {
birth_date?: string;
department_id?: number;
specialization?: string;
+ branch_id?: number;
}