Master grammar rules essential for IELTS.
-
-
Show completed
+
+
+
+ setSearch(e.target.value)}
+ />
+
+
+
+
+
+
+ {LEVELS.map((l) => (
+
+ {l === "all" ? "All levels" : l}
+
+ ))}
+
+
+
+
+
+ Show completed
+
+
+ {listQ.isLoading && (
+
+
+
+ )}
+ {!listQ.isLoading && filtered.length === 0 && (
+
+
+ No grammar rules match.
+
+
+ )}
{filtered.map((g) => (
-
-
-
- {g.completed &&
}
-
-
{g.rule}
-
{g.description}
-
+
+
+
+ progressMut.mutate({ id: g.id, completed: !g.completed })
+ }
+ className="shrink-0 rounded-full p-0.5 hover:bg-muted transition"
+ aria-label={g.completed ? "Mark incomplete" : "Mark complete"}
+ >
+ {g.completed ? (
+
+ ) : (
+
+ )}
+
+
+
{g.name}
+
{g.description}
+ {g.example && (
+
+ e.g. {g.example}
+
+ )}
+
+
+ {g.level}
+
+ {g.category}
+
+ delMut.mutate(g.id)}
+ >
+
+
- {g.level}
))}
@@ -70,25 +231,133 @@ export default function GrammarPage() {
- AI Recommendations
+
+ AI Recommendations
+
- • Practice passive voice transformations — high exam frequency
- • Review modal verbs for IELTS Writing Task 1
- • Focus on complex sentence structures for C1 readiness
- • Your conditional sentences are strong — try advanced mixed conditionals
+
+ •
+ Practice passive-voice transformations — high exam frequency.
+
+
+ •
+ Review modal verbs for IELTS Writing Task 1.
+
+
+ •
+ Focus on complex sentences for C1 readiness.
+
-
-
- AI Study Plan
- Based on your progress, complete Passive Voice and Subject-Verb Agreement this week. At your current pace, you'll finish all B2 grammar by April 15.
-
-
+ {summary && (
+
+
+
+ AI Study Plan
+
+
+ {summary.remaining > 0
+ ? `Finish the remaining ${summary.remaining} rule(s) at one per day to stay on track.`
+ : "You've mastered every active rule in the library."}
+
+
+
+ )}
+
+
+
+
+ Add Grammar Rule
+
+
+
+ Rule name
+ setForm((f) => ({ ...f, name: e.target.value }))}
+ placeholder="e.g. Present Perfect vs Past Simple"
+ />
+
+
+ Description
+
+
+ Example (optional)
+
+
+
+ CEFR level
+
+ setForm((f) => ({ ...f, level: v as CefrLevel }))
+ }
+ >
+
+
+
+
+ {(LEVELS.filter((l) => l !== "all") as CefrLevel[]).map(
+ (l) => (
+
+ {l}
+
+ ),
+ )}
+
+
+
+
+ Category
+
+ setForm((f) => ({ ...f, category: e.target.value }))
+ }
+ />
+
+
+
+
+ setCreateOpen(false)}>
+ Cancel
+
+ createMut.mutate(form)}
+ >
+ {createMut.isPending ? (
+ <>
+ Adding…
+ >
+ ) : (
+ "Add"
+ )}
+
+
+
+
);
}
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx
index a8a57b7..f473c04 100644
--- a/src/pages/Login.tsx
+++ b/src/pages/Login.tsx
@@ -89,7 +89,7 @@ export default function Login() {
Email
setEmail(e.target.value)}
diff --git a/src/pages/PaymentRecordPage.tsx b/src/pages/PaymentRecordPage.tsx
index ddb2454..fcaa488 100644
--- a/src/pages/PaymentRecordPage.tsx
+++ b/src/pages/PaymentRecordPage.tsx
@@ -1,57 +1,104 @@
-import { useState } from "react";
+import { useQuery } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
-import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
-import { Label } from "@/components/ui/label";
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
-import { Plus, Download } from "lucide-react";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Download, Loader2 } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import AiReportNarrative from "@/components/ai/AiReportNarrative";
-
-const payments = [
- { id: "PAY-001", corporate: "Acme Corp", manager: "John Admin", amount: 5000, currency: "USD", paid: true, date: "2025-01-15", type: "Corporate" },
- { id: "PAY-002", corporate: "Global Ltd", manager: "HR Global", amount: 8500, currency: "USD", paid: true, date: "2025-02-01", type: "Corporate" },
- { id: "PAY-003", corporate: "Tech Co", manager: "Tech Admin", amount: 2000, currency: "USD", paid: false, date: "2025-03-01", type: "Commission" },
-];
-
-const paymobOrders = [
- { id: "PMB-1001", status: "Paid", user: "Sarah Johnson", email: "sarah.j@email.com", amount: 199.00 },
- { id: "PMB-1002", status: "Pending", user: "Ahmed Hassan", email: "ahmed.h@email.com", amount: 149.00 },
- { id: "PMB-1003", status: "Paid", user: "Li Wei", email: "li.w@email.com", amount: 199.00 },
- { id: "PMB-1004", status: "Failed", user: "Emma Brown", email: "emma.b@email.com", amount: 99.00 },
-];
+import { paymentsService } from "@/services/payments.service";
export default function PaymentRecordPage() {
+ const { data, isLoading } = useQuery({
+ queryKey: ["payment-records"],
+ queryFn: () => paymentsService.list(),
+ });
+
+ const { data: paymobData } = useQuery({
+ queryKey: ["paymob-orders"],
+ queryFn: () => paymentsService.paymobOrders(),
+ });
+
+ const payments = data?.items ?? [];
+ const totals = data?.totals;
+
+ const handleExport = () => {
+ if (!payments.length) return;
+ const header = ["Ref", "Student", "Course", "Product", "Amount", "Currency", "State", "Paid", "Date"];
+ const rows = payments.map((p) => [
+ p.ref,
+ p.student_name,
+ p.course_name,
+ p.product_name,
+ p.amount.toFixed(2),
+ p.currency,
+ p.state,
+ p.paid ? "yes" : "no",
+ p.date,
+ ]);
+ const csv = [header, ...rows].map((r) => r.join(",")).join("\n");
+ const blob = new Blob([csv], { type: "text/csv" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `payment-records-${new Date().toISOString().slice(0, 10)}.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
return (
Payment Record
-
Manage payments, commissions, and Paymob orders.
+
+ Live student fees, invoices, and Paymob orders.
+
-
Export CSV
-
-
- New Payment
-
-
- Create Payment Record
-
-
Corporate Acme Corp
-
Amount (USD)
-
Type Corporate Commission
-
Create
-
-
-
+
+ Export CSV
+
+ {totals && (
+
+
+
+ Total records
+ {totals.count}
+
+
+
+
+ Paid
+ {totals.paid}
+
+
+
+
+ Unpaid
+ {totals.unpaid}
+
+
+
+
+ Total amount
+ ${totals.amount.toLocaleString()}
+
+
+
+ )}
+
@@ -67,20 +114,51 @@ export default function PaymentRecordPage() {
- ID Corporate Manager
- Amount Type Paid Date
+ Ref
+ Student
+ Course
+ Product
+ Amount
+ State
+ Paid
+ Date
+ {isLoading && (
+
+
+
+
+
+ )}
+ {!isLoading && payments.length === 0 && (
+
+
+ No payment records yet.
+
+
+ )}
{payments.map((p) => (
- {p.id}
- {p.corporate}
- {p.manager}
- ${p.amount.toLocaleString()}
- {p.type}
- {p.paid ? "Paid" : "Unpaid"}
- {p.date}
+ {p.ref}
+ {p.student_name || "—"}
+ {p.course_name || "—"}
+
+ {p.product_name || "—"}
+
+
+ ${p.amount.toLocaleString()} {p.currency}
+
+
+ {p.state}
+
+
+
+ {p.paid ? "Paid" : "Unpaid"}
+
+
+ {p.date || "—"}
))}
@@ -91,26 +169,50 @@ export default function PaymentRecordPage() {
-
-
-
-
- Order ID Status User
- Email Amount
-
-
-
- {paymobOrders.map((o) => (
-
- {o.id}
- {o.status}
- {o.user}
- {o.email}
- ${o.amount.toFixed(2)}
+
+ {(paymobData?.items?.length ?? 0) === 0 ? (
+
+ {paymobData?.message ??
+ "Paymob integration is not yet wired. Orders will appear here once configured."}
+
+ ) : (
+
+
+
+ Order ID
+ Status
+ User
+ Email
+ Amount
- ))}
-
-
+
+
+ {paymobData!.items.map((o) => (
+
+ {o.id}
+
+
+ {o.status}
+
+
+ {o.user}
+ {o.email}
+
+ ${o.amount.toFixed(2)}
+
+
+ ))}
+
+
+ )}
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
index d4ab8ad..bc35c65 100644
--- a/src/pages/SettingsPage.tsx
+++ b/src/pages/SettingsPage.tsx
@@ -1,33 +1,142 @@
+import { useEffect, useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
-import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
-import { Plus, Trash2, Copy } from "lucide-react";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Plus, Trash2, Copy, Loader2 } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
-
-const codes = [
- { code: "ENCOACH-2025-A1B2", type: "Single", used: false, created: "2025-01-10" },
- { code: "ENCOACH-2025-C3D4", type: "Single", used: true, created: "2025-01-12" },
- { code: "BATCH-2025-E5F6", type: "Batch", used: false, created: "2025-02-01" },
- { code: "BATCH-2025-G7H8", type: "Batch", used: false, created: "2025-02-01" },
-];
-
-const packages = [
- { id: 1, name: "IELTS Starter", price: 99, duration: "1 month", discount: 0 },
- { id: 2, name: "IELTS Pro", price: 249, duration: "3 months", discount: 15 },
- { id: 3, name: "Corporate Bundle", price: 1999, duration: "12 months", discount: 25 },
-];
+import { useToast } from "@/hooks/use-toast";
+import {
+ platformSettingsService,
+ type GradingConfig,
+ type PlatformPackage,
+ type RegistrationCode,
+} from "@/services/platformSettings.service";
export default function SettingsPage() {
+ const { toast } = useToast();
+ const qc = useQueryClient();
+
+ // ── Codes ──────────────────────────────────────────────────────────
+ const codesQ = useQuery({
+ queryKey: ["codes"],
+ queryFn: () => platformSettingsService.listCodes(),
+ });
+ const codes: RegistrationCode[] = codesQ.data?.items ?? [];
+
+ const genMut = useMutation({
+ mutationFn: platformSettingsService.generateCodes,
+ onSuccess: (r) => {
+ qc.invalidateQueries({ queryKey: ["codes"] });
+ toast({ title: `Generated ${r.count} code(s)` });
+ },
+ onError: () =>
+ toast({ title: "Failed to generate codes", variant: "destructive" }),
+ });
+ const delCodeMut = useMutation({
+ mutationFn: platformSettingsService.deleteCode,
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["codes"] });
+ toast({ title: "Code deleted" });
+ },
+ });
+
+ // ── Packages ───────────────────────────────────────────────────────
+ const pkgQ = useQuery({
+ queryKey: ["packages"],
+ queryFn: () => platformSettingsService.listPackages(),
+ });
+ const packages: PlatformPackage[] = pkgQ.data?.items ?? [];
+ const [pkgOpen, setPkgOpen] = useState(false);
+ const [pkgForm, setPkgForm] = useState>({
+ name: "",
+ price: 0,
+ duration: "1 month",
+ discount: 0,
+ });
+ const createPkgMut = useMutation({
+ mutationFn: platformSettingsService.createPackage,
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["packages"] });
+ setPkgOpen(false);
+ setPkgForm({ name: "", price: 0, duration: "1 month", discount: 0 });
+ toast({ title: "Package created" });
+ },
+ });
+ const delPkgMut = useMutation({
+ mutationFn: platformSettingsService.deletePackage,
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["packages"] });
+ toast({ title: "Package deleted" });
+ },
+ });
+
+ // ── Grading config ─────────────────────────────────────────────────
+ const gradingQ = useQuery({
+ queryKey: ["grading-config"],
+ queryFn: () => platformSettingsService.getGrading(),
+ });
+ const [grading, setGrading] = useState({
+ min_score: 0,
+ max_score: 9,
+ increment: 0.5,
+ });
+ useEffect(() => {
+ if (gradingQ.data) {
+ setGrading({
+ min_score: gradingQ.data.min_score,
+ max_score: gradingQ.data.max_score,
+ increment: gradingQ.data.increment,
+ });
+ }
+ }, [gradingQ.data]);
+ const saveGradingMut = useMutation({
+ mutationFn: platformSettingsService.setGrading,
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["grading-config"] });
+ toast({ title: "Grading configuration saved" });
+ },
+ onError: (err: unknown) => {
+ const msg =
+ err && typeof err === "object" && "message" in err
+ ? String((err as { message: unknown }).message)
+ : "Failed to save";
+ toast({ title: msg, variant: "destructive" });
+ },
+ });
+
return (
Settings
-
Manage codes, packages, discounts, and grading system.
+
+ Manage registration codes, packages, and the grading scale.
+
@@ -37,26 +146,107 @@ export default function SettingsPage() {
Grading System
+ {/* CODES */}
-
Generate Single
-
Generate Batch
+
+ genMut.mutate({
+ count: 1,
+ code_type: "individual",
+ user_type: "student",
+ max_uses: 1,
+ })
+ }
+ >
+ {genMut.isPending ? (
+
+ ) : (
+
+ )}{" "}
+ Generate Single
+
+
+ genMut.mutate({
+ count: 10,
+ code_type: "corporate",
+ user_type: "corporate",
+ max_uses: 50,
+ })
+ }
+ >
+ Generate Batch (10)
+
- Code Type Used Created
+
+ Code
+ Type
+ User
+ Uses
+ State
+ Created
+
+
+ {codesQ.isLoading && (
+
+
+
+
+
+ )}
+ {!codesQ.isLoading && codes.length === 0 && (
+
+
+ No codes yet. Click Generate Single to create one.
+
+
+ )}
{codes.map((c) => (
-
+
{c.code}
- {c.type}
- {c.used ? "Used" : "Available"}
- {c.created}
-
+
+ {c.code_type}
+
+
+ {c.user_type}
+
+
+ {c.uses}/{c.max_uses}
+
+
+
+ {c.used ? "Used" : "Available"}
+
+
+
+ {c.created ? new Date(c.created).toLocaleDateString() : "—"}
+
+
+ delCodeMut.mutate(c.id)}
+ >
+
+
+
))}
@@ -65,35 +255,186 @@ export default function SettingsPage() {
+ {/* PACKAGES */}
+
+
setPkgOpen(true)}>
+ Add Package
+
+
+ {pkgQ.isLoading &&
}
{packages.map((p) => (
-
+
{p.name}
+ delPkgMut.mutate(p.id)}
+ >
+
+
${p.price}
{p.duration}
- {p.discount > 0 && {p.discount}% OFF }
+ {p.discount > 0 && (
+ {p.discount}% OFF
+ )}
))}
+
+
+
+
+ New Package
+
+
+
+ Name
+
+ setPkgForm((f) => ({ ...f, name: e.target.value }))
+ }
+ />
+
+
+
+ Duration
+
+ setPkgForm((f) => ({ ...f, duration: v }))
+ }
+ >
+
+
+
+
+ 1 month
+ 3 months
+ 6 months
+ 12 months
+
+
+
+
+
+ setPkgOpen(false)}>
+ Cancel
+
+ createPkgMut.mutate(pkgForm)}
+ >
+ {createPkgMut.isPending ? (
+ <>
+ Creating…
+ >
+ ) : (
+ "Create"
+ )}
+
+
+
+
+ {/* GRADING */}
- Scoring Scale
+
+ Scoring Scale
+
- Score Increment
- Save Grading Configuration
+
+ Score Increment
+
+ setGrading((g) => ({
+ ...g,
+ increment: Number(e.target.value),
+ }))
+ }
+ />
+
+ saveGradingMut.mutate(grading)}
+ >
+ {saveGradingMut.isPending ? (
+ <>
+ Saving…
+ >
+ ) : (
+ "Save Grading Configuration"
+ )}
+
diff --git a/src/pages/TicketsPage.tsx b/src/pages/TicketsPage.tsx
index dfdf0c5..4cbdabc 100644
--- a/src/pages/TicketsPage.tsx
+++ b/src/pages/TicketsPage.tsx
@@ -33,7 +33,7 @@ export default function TicketsPage() {
}),
});
- const tickets: Ticket[] = data?.data ?? [];
+ const tickets: Ticket[] = data?.items ?? data?.data ?? [];
const createMut = useMutation({
mutationFn: ticketsService.create,
diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx
index 32a3552..bf2a0c2 100644
--- a/src/pages/UsersPage.tsx
+++ b/src/pages/UsersPage.tsx
@@ -3,151 +3,153 @@ import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
+import { Checkbox } from "@/components/ui/checkbox";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
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, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
-import { Search, Plus, Download, MoreHorizontal } from "lucide-react";
-import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
-import { useStudents, useTeachers, useCreateStudent, useCreateTeacher } from "@/hooks/queries";
-import { lmsService } from "@/services/lms.service";
-import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { Search, Plus, Download, Shield, Pencil, UserCog } from "lucide-react";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { api } from "@/lib/api-client";
import { useToast } from "@/hooks/use-toast";
-import { ApiError } from "@/lib/api-client";
-function messageFromCreateError(err: unknown): string {
- if (err instanceof ApiError && err.data && typeof err.data === "object" && err.data !== null && "error" in err.data) {
- const e = (err.data as { error: unknown }).error;
- if (e != null && String(e).trim()) return String(e);
- }
- if (err instanceof Error) return err.message;
- return "Something went wrong.";
+interface PlatformUser {
+ id: number;
+ name: string;
+ email: string;
+ login: string;
+ user_type: string;
+ active: boolean;
+ phone: string;
+ role_ids: number[];
+ roles: { id: number; name: string }[];
+ effective_permission_count: number;
}
-function toastForCreateUserError(err: unknown): { title: string; description: string; variant?: "default" | "destructive" } {
- const msg = messageFromCreateError(err).toLowerCase();
- const isDuplicate =
- msg.includes("already exists") ||
- msg.includes("duplicate") ||
- msg.includes("unique") ||
- msg.includes("already registered");
- if (isDuplicate) {
- return {
- title: "Email already in use",
- description:
- messageFromCreateError(err) +
- " Try another address, or find the existing user in the Students or Teachers tab.",
- variant: "destructive",
- };
- }
- return { title: "Could not create user", description: messageFromCreateError(err), variant: "destructive" };
+interface RoleInfo {
+ id: number;
+ name: string;
+ description: string;
+ assigned: boolean;
+ permission_count: number;
}
+const TYPE_COLORS: Record = {
+ admin: "bg-red-100 text-red-800 border-red-200",
+ teacher: "bg-blue-100 text-blue-800 border-blue-200",
+ student: "bg-green-100 text-green-800 border-green-200",
+ user: "bg-gray-100 text-gray-800 border-gray-200",
+};
+
export default function UsersPage() {
const [search, setSearch] = useState("");
+ const [typeFilter, setTypeFilter] = useState("all");
const [createOpen, setCreateOpen] = useState(false);
- const [form, setForm] = useState({ first_name: "", last_name: "", email: "", role: "student", phone: "", gender: "" });
+ const [editUser, setEditUser] = useState(null);
+ const [rolesUser, setRolesUser] = useState(null);
+ const [form, setForm] = useState({ name: "", email: "", password: "", phone: "" });
const { toast } = useToast();
const qc = useQueryClient();
- const studentsQ = useStudents({ search: search || undefined, size: 200 });
- const teachersQ = useTeachers({ search: search || undefined, size: 200 });
- const createStudentMut = useCreateStudent();
- const createTeacherMut = useCreateTeacher();
-
- const students = studentsQ.data?.items ?? [];
- const teachers = teachersQ.data?.items ?? [];
-
- const deleteMut = useMutation({
- mutationFn: async ({ type, id }: { type: "student" | "teacher"; id: number }) => {
- if (type === "student") return lmsService.deleteStudent?.(id) ?? lmsService.updateStudent(id, { status: "inactive" });
- return lmsService.updateTeacher?.(id, {}) ?? Promise.resolve();
+ const usersQ = useQuery({
+ queryKey: ["platform-users", search],
+ queryFn: async () => {
+ const params: Record = { size: 200 };
+ if (search) params.search = search;
+ const res = await api.get<{ items?: PlatformUser[]; data?: PlatformUser[]; total: number }>("/users/list", params);
+ return (res.items ?? res.data ?? []) as PlatformUser[];
},
+ });
+
+ const rolesQ = useQuery({
+ queryKey: ["user-roles-detail", rolesUser?.id],
+ queryFn: async () => {
+ if (!rolesUser) return null;
+ return api.get<{ roles: RoleInfo[]; effective_permissions: unknown[] }>(`/users/${rolesUser.id}/roles`);
+ },
+ enabled: !!rolesUser,
+ });
+
+ const createMut = useMutation({
+ mutationFn: (data: { name: string; email: string; password: string; phone?: string }) =>
+ api.post<{ data: PlatformUser }>("/users/create", data),
onSuccess: () => {
- qc.invalidateQueries({ queryKey: ["lms"] });
- toast({ title: "Done" });
+ qc.invalidateQueries({ queryKey: ["platform-users"] });
+ setCreateOpen(false);
+ setForm({ name: "", email: "", password: "", phone: "" });
+ toast({ title: "User created" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
- function handleCreate() {
- const email = form.email.trim();
- const first_name = form.first_name.trim();
- const last_name = form.last_name.trim();
- const emailKey = email.toLowerCase();
- const dupStudent = students.some((s) => (s.email || "").trim().toLowerCase() === emailKey);
- const dupTeacher = teachers.some((t) => (t.email || "").trim().toLowerCase() === emailKey);
- if (dupStudent || dupTeacher) {
- toast({
- title: "Email already in use",
- description:
- "That address matches someone already listed on this page (search may hide them). Use another email or clear search to find the user.",
- variant: "destructive",
- });
- return;
- }
- const cb = {
- onSuccess: () => {
- setCreateOpen(false);
- resetForm();
- toast({ title: "User created successfully" });
- },
- onError: (err: unknown) => toast(toastForCreateUserError(err)),
- };
- if (form.role === "teacher") {
- createTeacherMut.mutate(
- {
- first_name,
- last_name,
- email,
- phone: form.phone?.trim() || undefined,
- gender: (form.gender as "male" | "female") || undefined,
- },
- cb,
- );
- } else {
- createStudentMut.mutate(
- {
- first_name,
- last_name,
- email,
- phone: form.phone?.trim() || undefined,
- gender: form.gender || undefined,
- create_portal_user: true,
- },
- cb,
- );
- }
- }
+ const updateMut = useMutation({
+ mutationFn: (data: { id: number; name?: string; email?: string; phone?: string }) =>
+ api.patch<{ data: PlatformUser }>("/users/update", data),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["platform-users"] });
+ setEditUser(null);
+ toast({ title: "User updated" });
+ },
+ onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
+ });
- function resetForm() {
- setForm({ first_name: "", last_name: "", email: "", role: "student", phone: "", gender: "" });
- }
+ const toggleRoleMut = useMutation({
+ mutationFn: ({ userId, roleId }: { userId: number; roleId: number }) =>
+ api.post<{ assigned: boolean }>(`/users/${userId}/roles/toggle`, { role_id: roleId }),
+ onSuccess: (res) => {
+ qc.invalidateQueries({ queryKey: ["platform-users"] });
+ qc.invalidateQueries({ queryKey: ["user-roles-detail"] });
+ const verb = (res as { assigned: boolean }).assigned ? "assigned" : "removed";
+ toast({ title: `Role ${verb}` });
+ },
+ onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
+ });
+
+ const users = usersQ.data ?? [];
+ const filtered = users.filter((u) => {
+ if (typeFilter !== "all" && u.user_type !== typeFilter) return false;
+ return true;
+ });
+
+ const typeCounts = {
+ all: users.length,
+ admin: users.filter((u) => u.user_type === "admin").length,
+ teacher: users.filter((u) => u.user_type === "teacher").length,
+ student: users.filter((u) => u.user_type === "student").length,
+ };
function exportCsv() {
- const rows = [["Name", "Email", "Role", "Status"]];
- students.forEach(s => rows.push([s.name, s.email, "Student", s.status || "active"]));
- teachers.forEach(t => rows.push([t.name, t.email, "Teacher", "active"]));
- const csv = rows.map(r => r.join(",")).join("\n");
+ const rows = [["Name", "Email", "Type", "Roles", "Permissions"]];
+ users.forEach((u) =>
+ rows.push([
+ u.name,
+ u.email,
+ u.user_type,
+ u.roles.map((r) => r.name).join("; "),
+ String(u.effective_permission_count),
+ ]),
+ );
+ const csv = rows.map((r) => r.map((c) => `"${c}"`).join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
- a.download = "users_export.csv";
+ a.download = "platform_users.csv";
a.click();
URL.revokeObjectURL(url);
}
- const isPending = createStudentMut.isPending || createTeacherMut.isPending;
- const loading = studentsQ.isLoading || teachersQ.isLoading;
+ const rolesData = rolesQ.data as { roles: RoleInfo[]; effective_permissions: unknown[] } | null;
return (
Users
-
Manage platform users across all roles.
+
+ Manage all platform accounts — admins, teachers, and students.
+
@@ -159,177 +161,254 @@ export default function UsersPage() {
-
+
- {loading ? (
+ {usersQ.isLoading ? (
) : (
-
-
- Students ({students.length})
- Teachers ({teachers.length})
-
-
-
-
-
-
-
- Name
- Email
- Phone
- Batch
- Status
- Enrollment
-
+
+
+
+
+
+ User
+ Type
+ Roles
+ Permissions
+ Status
+
+
+
+
+ {filtered.length === 0 && (
+
+
+ No users found.
+
+
+ )}
+ {filtered.map((u) => {
+ const initials = u.name
+ .split(" ")
+ .map((w) => w[0])
+ .join("")
+ .slice(0, 2)
+ .toUpperCase();
+ return (
+
+
+
+
+
+
+ {u.user_type}
+
+
+
+
+ {u.roles.length > 0
+ ? u.roles.map((r) => (
+
+ {r.name}
+
+ ))
+ : No roles }
+
+
+
+ {u.effective_permission_count}
+
+
+
+ {u.active ? "Active" : "Inactive"}
+
+
+
+
+
setEditUser(u)}>
+
+
+
setRolesUser(u)}>
+
+
+
+
-
-
- {students.length === 0 && (
- No students found.
- )}
- {students.map((s) => (
-
- {s.name}
- {s.email}
- {s.phone || "—"}
- {s.batch_name || "—"}
-
- {s.status || "active"}
-
- {s.enrollment_date || "—"}
-
-
-
-
-
-
- { if (window.confirm(`Deactivate student "${s.name}"?`)) deleteMut.mutate({ type: "student", id: s.id }); }}>
- Deactivate
-
-
-
-
-
- ))}
-
-
-
-
-
-
-
-
-
-
-
- Name
- Email
- Phone
- Department
- Specialization
-
-
-
-
- {teachers.length === 0 && (
- No teachers found.
- )}
- {teachers.map((t) => (
-
- {t.name}
- {t.email}
- {t.phone || "—"}
- {t.department_name || "—"}
- {t.specialization || "—"}
-
-
-
-
-
-
- { if (window.confirm(`Remove teacher "${t.name}"?`)) deleteMut.mutate({ type: "teacher", id: t.id }); }}>
- Remove
-
-
-
-
-
- ))}
-
-
-
-
-
-
+ );
+ })}
+
+
+
+
)}
- { setCreateOpen(v); if (!v) resetForm(); }}>
+ {/* Create User Dialog */}
+
- Create New User
+
+ Create Platform User
+
+ Creates an internal Odoo user (res.users) with login access.
+ For students/teachers, use the LMS pages instead.
+
+
-
-
- First Name
- setForm(f => ({ ...f, first_name: e.target.value }))} placeholder="John" />
-
-
- Last Name
- setForm(f => ({ ...f, last_name: e.target.value }))} placeholder="Doe" />
-
+
+ Full Name
+ setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. John Admin" />
-
Email
-
setForm(f => ({ ...f, email: e.target.value }))} placeholder="john@email.com" />
-
- Must be unique in Odoo for students and teachers. If create fails, the address may already exist outside the current list.
-
+
Email (login)
+
setForm((f) => ({ ...f, email: e.target.value }))} placeholder="admin@encoach.com" />
- Phone
- setForm(f => ({ ...f, phone: e.target.value }))} placeholder="+1234567890" />
+ Password
+ setForm((f) => ({ ...f, password: e.target.value }))} placeholder="Minimum 6 characters" />
-
-
- Role
- setForm(f => ({ ...f, role: v }))}>
-
-
- Student
- Teacher
-
-
-
-
- Gender
- setForm(f => ({ ...f, gender: v }))}>
-
-
- Male
- Female
-
-
-
+
+ Phone (optional)
+ setForm((f) => ({ ...f, phone: e.target.value }))} placeholder="+1234567890" />
- { setCreateOpen(false); resetForm(); }}>Cancel
+ setCreateOpen(false)}>Cancel
createMut.mutate({ name: form.name.trim(), email: form.email.trim(), password: form.password || "admin123", phone: form.phone.trim() || undefined })}
>
- {isPending ? "Creating..." : "Create"}
+ {createMut.isPending ? "Creating..." : "Create User"}
+
+ {/* Edit User Dialog */}
+
{ if (!v) setEditUser(null); }}>
+
+
+ Edit User
+ Update profile for {editUser?.name}
+
+ {editUser && (
+
+ )}
+
+ setEditUser(null)}>Cancel
+ {
+ if (!editUser) return;
+ updateMut.mutate({
+ id: editUser.id,
+ name: editUser.name.trim(),
+ email: editUser.email.trim(),
+ phone: editUser.phone?.trim() || undefined,
+ });
+ }}
+ >
+ {updateMut.isPending ? "Saving..." : "Save Changes"}
+
+
+
+
+
+ {/* Manage Roles Dialog */}
+
{ if (!v) setRolesUser(null); }}>
+
+
+
+
+
+ Manage Roles — {rolesUser?.name}
+
+
+ Toggle roles on/off for this user.
+
+
+ {rolesQ.isLoading ? (
+
+ ) : rolesData?.roles ? (
+
+ {rolesData.roles.map((r) => (
+
+ {
+ if (rolesUser) toggleRoleMut.mutate({ userId: rolesUser.id, roleId: r.id });
+ }}
+ />
+
+
{r.name}
+ {r.description &&
{r.description}
}
+
+
+ {r.permission_count} perms
+
+
+ ))}
+
+ ) : (
+ No roles available.
+ )}
+
+
+ setRolesUser(null)}>Close
+
+
+
);
}
diff --git a/src/pages/VocabularyPage.tsx b/src/pages/VocabularyPage.tsx
index 091e014..8dc9206 100644
--- a/src/pages/VocabularyPage.tsx
+++ b/src/pages/VocabularyPage.tsx
@@ -1,96 +1,389 @@
import { useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
-import { CheckCircle, BookA, Sparkles } from "lucide-react";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Button } from "@/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ CheckCircle,
+ Plus,
+ Search,
+ Trash2,
+ Loader2,
+ Sparkles,
+ Circle,
+} from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
+import { useToast } from "@/hooks/use-toast";
+import {
+ trainingService,
+ type VocabItem,
+ type CefrLevel,
+} from "@/services/training.service";
-const vocabItems = [
- { word: "Ubiquitous", meaning: "Present, appearing, or found everywhere", level: "C1", completed: true },
- { word: "Pragmatic", meaning: "Dealing with things sensibly and realistically", level: "B2", completed: true },
- { word: "Eloquent", meaning: "Fluent or persuasive in speaking or writing", level: "C1", completed: false },
- { word: "Meticulous", meaning: "Showing great attention to detail", level: "B2", completed: false },
- { word: "Ambiguous", meaning: "Open to more than one interpretation", level: "B2", completed: false },
- { word: "Coherent", meaning: "Logical and consistent", level: "B1", completed: false },
- { word: "Versatile", meaning: "Able to adapt to many different functions", level: "B2", completed: true },
- { word: "Concise", meaning: "Giving a lot of information in few words", level: "B1", completed: false },
-];
+const LEVELS: (CefrLevel | "all")[] = ["all", "A1", "A2", "B1", "B2", "C1", "C2"];
export default function VocabularyPage() {
- const [showCompleted, setShowCompleted] = useState(false);
- const completed = vocabItems.filter(v => v.completed).length;
- const filtered = showCompleted ? vocabItems : vocabItems.filter(v => !v.completed);
+ const { toast } = useToast();
+ const qc = useQueryClient();
+ const [showCompleted, setShowCompleted] = useState(true);
+ const [levelFilter, setLevelFilter] = useState
("all");
+ const [search, setSearch] = useState("");
+ const [createOpen, setCreateOpen] = useState(false);
+ const [form, setForm] = useState({
+ word: "",
+ meaning: "",
+ example_sentence: "",
+ level: "B1" as CefrLevel,
+ part_of_speech: "noun" as VocabItem["part_of_speech"],
+ category: "general",
+ });
+
+ const listQ = useQuery({
+ queryKey: ["training-vocab", levelFilter, search],
+ queryFn: () =>
+ trainingService.listVocab({
+ ...(levelFilter !== "all" ? { level: levelFilter } : {}),
+ ...(search ? { search } : {}),
+ }),
+ });
+
+ const items = listQ.data?.items ?? [];
+ const summary = listQ.data?.summary;
+ const filtered = showCompleted ? items : items.filter((v) => !v.completed);
+
+ const createMut = useMutation({
+ mutationFn: trainingService.createVocab,
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["training-vocab"] });
+ setCreateOpen(false);
+ setForm({
+ word: "",
+ meaning: "",
+ example_sentence: "",
+ level: "B1",
+ part_of_speech: "noun",
+ category: "general",
+ });
+ toast({ title: "Word added" });
+ },
+ onError: (err: unknown) => {
+ const msg =
+ err && typeof err === "object" && "message" in err
+ ? String((err as { message: unknown }).message)
+ : "Failed to add word";
+ toast({ title: msg, variant: "destructive" });
+ },
+ });
+
+ const delMut = useMutation({
+ mutationFn: trainingService.deleteVocab,
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["training-vocab"] });
+ toast({ title: "Word deleted" });
+ },
+ });
+
+ const progressMut = useMutation({
+ mutationFn: ({ id, completed }: { id: number; completed: boolean }) =>
+ trainingService.setVocabProgress(id, { completed }),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["training-vocab"] }),
+ });
return (
-
-
Vocabulary Training
-
Build your vocabulary for IELTS success.
+
+
+
Vocabulary Training
+
+ Build and manage the vocabulary library — track completion by CEFR level.
+
+
+
setCreateOpen(true)}>
+ Add Word
+
+ {/* Summary */}
Progress
- {completed}/{vocabItems.length} completed
+
+ {summary
+ ? `${summary.completed}/${summary.total} completed (${summary.completion_rate}%)`
+ : "—"}
+
-
+
-
-
-
Show completed
+ {/* Filters */}
+
+
+
+ setSearch(e.target.value)}
+ />
+
+
+
+
+
+
+ {LEVELS.map((l) => (
+
+ {l === "all" ? "All levels" : l}
+
+ ))}
+
+
+
+
+
+ Show completed
+
+
+ {/* List */}
+ {listQ.isLoading && (
+
+
+
+ )}
+ {!listQ.isLoading && filtered.length === 0 && (
+
+
+ No vocabulary items match.
+
+
+ )}
{filtered.map((v) => (
-
-
-
- {v.completed &&
}
-
-
{v.word}
-
{v.meaning}
-
+
+
+
+ progressMut.mutate({ id: v.id, completed: !v.completed })
+ }
+ className="shrink-0 rounded-full p-0.5 hover:bg-muted transition"
+ aria-label={v.completed ? "Mark incomplete" : "Mark complete"}
+ >
+ {v.completed ? (
+
+ ) : (
+
+ )}
+
+
+
{v.word}
+
{v.meaning}
+ {v.example_sentence && (
+
+ “{v.example_sentence}”
+
+ )}
+
+
+ {v.level}
+
+ {v.part_of_speech}
+
+ delMut.mutate(v.id)}
+ >
+
+
- {v.level}
))}
+ {/* AI panel */}
- AI Recommendations
+
+ AI Recommendations
+
- • Learn "coherent" + "concise" together — they share academic writing context
- • Review C1-level vocabulary for IELTS Task 2
- • Focus on collocations with 'make' and 'do'
- • Try using "meticulous" in your next writing practice
+
+ •
+ Pair coherent + concise — they share academic-writing
+ contexts.
+
+
+ •
+ Review C1 words for IELTS Writing Task 2.
+
+
+ •
+ Try using meticulous in your next essay.
+
-
-
- AI Vocabulary Goal
- At 2 words/day, you'll complete this set by March 15. AI recommends adding 10 more domain-specific words from your upcoming exam topics.
-
-
+ {summary && (
+
+
+
+ AI Vocabulary Goal
+
+
+ {summary.remaining > 0
+ ? `At 2 words/day, you'll complete the remaining ${summary.remaining} items in about ${Math.ceil(summary.remaining / 2)} days.`
+ : "You've completed every active word — try adding more to keep challenging yourself."}
+
+
+
+ )}
+
+ {/* Create dialog */}
+
+
+
+ Add Vocabulary Word
+
+
+
+ Word
+ setForm((f) => ({ ...f, word: e.target.value }))}
+ placeholder="e.g. Ubiquitous"
+ />
+
+
+ Meaning
+
+
+ Example sentence (optional)
+
+
+
+ CEFR level
+
+ setForm((f) => ({ ...f, level: v as CefrLevel }))
+ }
+ >
+
+
+
+
+ {(LEVELS.filter((l) => l !== "all") as CefrLevel[]).map(
+ (l) => (
+
+ {l}
+
+ ),
+ )}
+
+
+
+
+ Part of speech
+
+ setForm((f) => ({
+ ...f,
+ part_of_speech: v as VocabItem["part_of_speech"],
+ }))
+ }
+ >
+
+
+
+
+ noun
+ verb
+ adjective
+ adverb
+ phrase
+ other
+
+
+
+
+
+ Category
+ setForm((f) => ({ ...f, category: e.target.value }))}
+ />
+
+
+
+ setCreateOpen(false)}>
+ Cancel
+
+ createMut.mutate(form)}
+ >
+ {createMut.isPending ? (
+ <>
+ Adding…
+ >
+ ) : (
+ "Add"
+ )}
+
+
+
+
);
}
diff --git a/src/pages/admin/AcademicYearManager.tsx b/src/pages/admin/AcademicYearManager.tsx
index 4cbc372..1274db8 100644
--- a/src/pages/admin/AcademicYearManager.tsx
+++ b/src/pages/admin/AcademicYearManager.tsx
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
-import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -87,7 +87,10 @@ export default function AcademicYearManager() {
Create Academic Year
- Create Academic Year
+
+ Create Academic Year
+ Define the academic year range and term structure.
+
Name
diff --git a/src/pages/admin/AdminActivities.tsx b/src/pages/admin/AdminActivities.tsx
index 57383ab..4a9789f 100644
--- a/src/pages/admin/AdminActivities.tsx
+++ b/src/pages/admin/AdminActivities.tsx
@@ -256,13 +256,14 @@ export default function AdminActivities() {
Cancel
createType.mutate(
- { name: typeName },
+ { name: typeName.trim() },
{
onSuccess: () => {
setTypeOpen(false);
+ setTypeName("");
toast({ title: "Created successfully" });
},
onError: (err: Error) =>
diff --git a/src/pages/admin/AdminCourses.tsx b/src/pages/admin/AdminCourses.tsx
index 11a5fb9..c6e1947 100644
--- a/src/pages/admin/AdminCourses.tsx
+++ b/src/pages/admin/AdminCourses.tsx
@@ -1,50 +1,480 @@
-import { useState } from "react";
+import { useState, useEffect } from "react";
import { Card, CardContent } from "@/components/ui/card";
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 { Textarea } from "@/components/ui/textarea";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
-import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
-import { useCourses, useCreateCourse } from "@/hooks/queries";
-import { lmsService } from "@/services";
-import { useQueryClient } from "@tanstack/react-query";
-import { Search, Plus, Trash2 } from "lucide-react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
+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 { 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 { ResourceTag } from "@/types/adaptive";
+
+const DIFFICULTY_OPTIONS = [
+ { value: "beginner", label: "Beginner" },
+ { value: "intermediate", label: "Intermediate" },
+ { value: "advanced", label: "Advanced" },
+];
+
+const CEFR_OPTIONS = [
+ { value: "pre_a1", label: "Pre-A1" },
+ { value: "a1", label: "A1" },
+ { value: "a2", label: "A2" },
+ { value: "b1", label: "B1" },
+ { value: "b2", label: "B2" },
+ { value: "c1", label: "C1" },
+ { value: "c2", label: "C2" },
+];
+
+interface CourseFormData {
+ title: string;
+ code: string;
+ description: string;
+ max_capacity: number;
+ encoach_subject_id: number | null;
+ topic_ids: number[];
+ learning_objective_ids: number[];
+ tag_ids: number[];
+ difficulty_level: string;
+ cefr_level: string;
+}
+
+const emptyForm: CourseFormData = {
+ title: "",
+ code: "",
+ description: "",
+ max_capacity: 30,
+ encoach_subject_id: null,
+ topic_ids: [],
+ learning_objective_ids: [],
+ tag_ids: [],
+ difficulty_level: "",
+ cefr_level: "",
+};
+
+function CourseFormDialog({
+ open,
+ onOpenChange,
+ initialData,
+ courseId,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ initialData?: CourseFormData;
+ courseId?: number;
+}) {
+ const { toast } = useToast();
+ const qc = useQueryClient();
+ const createMut = useCreateCourse();
+ const [form, setForm] = useState(initialData ?? emptyForm);
+ const [saving, setSaving] = useState(false);
+
+ useEffect(() => {
+ if (open) {
+ setForm(initialData ?? emptyForm);
+ }
+ }, [open, initialData]);
+
+ const { data: subjects = [] } = useQuery({
+ queryKey: ["taxonomy", "subjects"],
+ queryFn: () => taxonomyService.listSubjects(),
+ });
+
+ const { data: topicsData } = useQuery({
+ queryKey: ["taxonomy", "topics", form.encoach_subject_id],
+ queryFn: () =>
+ form.encoach_subject_id
+ ? taxonomyService.listTopics({ subject_id: form.encoach_subject_id })
+ : Promise.resolve([]),
+ enabled: !!form.encoach_subject_id,
+ });
+ const topics = topicsData ?? [];
+
+ const { data: objectivesData } = useQuery({
+ queryKey: ["lms", "objectives", form.topic_ids.join(",")],
+ queryFn: () =>
+ form.topic_ids.length > 0
+ ? lmsService.listLearningObjectives({ topic_ids: form.topic_ids.join(",") })
+ : Promise.resolve({ items: [], total: 0 }),
+ enabled: form.topic_ids.length > 0,
+ });
+ const objectives = objectivesData?.items ?? [];
+
+ const { data: allTags = [] } = useQuery({
+ queryKey: ["resource-tags"],
+ queryFn: () => resourcesService.listTags(),
+ });
+
+ function handleSubjectChange(val: string) {
+ const sid = val === "none" ? null : Number(val);
+ setForm((f) => ({
+ ...f,
+ encoach_subject_id: sid,
+ topic_ids: [],
+ learning_objective_ids: [],
+ }));
+ }
+
+ function toggleTopic(id: number) {
+ setForm((f) => {
+ const next = f.topic_ids.includes(id)
+ ? f.topic_ids.filter((t) => t !== id)
+ : [...f.topic_ids, id];
+ const validObjectiveTopics = new Set(
+ topics.filter((t) => next.includes(t.id)).flatMap((t) => [t.id])
+ );
+ return {
+ ...f,
+ topic_ids: next,
+ learning_objective_ids: f.learning_objective_ids.filter((oid) =>
+ objectives.some(
+ (o) => o.id === oid && validObjectiveTopics.has(o.topic_id)
+ )
+ ),
+ };
+ });
+ }
+
+ function toggleObjective(id: number) {
+ setForm((f) => ({
+ ...f,
+ learning_objective_ids: f.learning_objective_ids.includes(id)
+ ? f.learning_objective_ids.filter((o) => o !== id)
+ : [...f.learning_objective_ids, id],
+ }));
+ }
+
+ function toggleTag(id: number) {
+ setForm((f) => ({
+ ...f,
+ tag_ids: f.tag_ids.includes(id)
+ ? f.tag_ids.filter((t) => t !== id)
+ : [...f.tag_ids, id],
+ }));
+ }
+
+ async function handleSave() {
+ if (!form.title.trim()) return;
+ const payload: Partial = {
+ title: form.title.trim(),
+ code:
+ form.code.trim() ||
+ form.title.trim().toUpperCase().replace(/\s+/g, "-").slice(0, 16),
+ description: form.description,
+ max_capacity: form.max_capacity,
+ encoach_subject_id: form.encoach_subject_id ?? undefined,
+ topic_ids: form.topic_ids,
+ learning_objective_ids: form.learning_objective_ids,
+ tag_ids: form.tag_ids,
+ difficulty_level: (form.difficulty_level as CourseCreateRequest["difficulty_level"]) || undefined,
+ cefr_level: form.cefr_level || undefined,
+ };
+
+ if (courseId) {
+ setSaving(true);
+ try {
+ await lmsService.updateCourse(courseId, payload);
+ qc.invalidateQueries({ queryKey: ["lms", "courses"] });
+ toast({ title: "Course updated" });
+ onOpenChange(false);
+ } catch (e: unknown) {
+ toast({ title: "Error", description: String(e instanceof Error ? e.message : e), variant: "destructive" });
+ }
+ setSaving(false);
+ } else {
+ createMut.mutate(payload as CourseCreateRequest, {
+ onSuccess: () => {
+ onOpenChange(false);
+ toast({ title: "Course created" });
+ },
+ onError: (e) =>
+ toast({ title: "Error", description: String(e.message || e), variant: "destructive" }),
+ });
+ }
+ }
+
+ const isPending = saving || createMut.isPending;
+
+ return (
+
+
+
+
+ {courseId ? "Edit Course" : "Create New Course"}
+
+
+
+
+
+
+ Description
+
+
+
+
+ Max Capacity
+ setForm((f) => ({ ...f, max_capacity: Number(e.target.value) || 30 }))}
+ />
+
+
+ Difficulty
+ setForm((f) => ({ ...f, difficulty_level: v === "none" ? "" : v }))}
+ >
+
+
+ None
+ {DIFFICULTY_OPTIONS.map((d) => (
+ {d.label}
+ ))}
+
+
+
+
+ CEFR Level
+ setForm((f) => ({ ...f, cefr_level: v === "none" ? "" : v }))}
+ >
+
+
+ None
+ {CEFR_OPTIONS.map((c) => (
+ {c.label}
+ ))}
+
+
+
+
+
+
+ Subject
+
+
+
+ None
+ {subjects.map((s) => (
+ {s.name}
+ ))}
+
+
+
+
+ {form.encoach_subject_id && topics.length > 0 && (
+
+
Topics ({form.topic_ids.length} selected)
+
+ {topics.map((t) => (
+
+ toggleTopic(t.id)}
+ />
+ {t.name}
+ {t.domain_name}
+
+ ))}
+
+
+ )}
+
+ {form.topic_ids.length > 0 && objectives.length > 0 && (
+
+
Learning Objectives ({form.learning_objective_ids.length} selected)
+
+ {objectives.map((o) => (
+
+ toggleObjective(o.id)}
+ />
+ {o.name}
+ {o.bloom_level && (
+
+ {o.bloom_level}
+
+ )}
+
+ ))}
+
+
+ )}
+
+ {allTags.length > 0 && (
+
+
Tags
+
+ {allTags.map((t: ResourceTag) => (
+ toggleTag(t.id)}
+ >
+ {t.name}
+
+ ))}
+
+
+ )}
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {isPending && }
+ {courseId ? "Save Changes" : "Create Course"}
+
+
+
+
+ );
+}
+
+function courseToFormData(c: Course): CourseFormData {
+ return {
+ title: c.title,
+ code: c.code,
+ description: c.description,
+ max_capacity: c.max_capacity,
+ encoach_subject_id: c.encoach_subject_id ?? null,
+ topic_ids: c.topic_ids ?? [],
+ learning_objective_ids: c.learning_objective_ids ?? [],
+ tag_ids: c.tag_ids ?? [],
+ difficulty_level: c.difficulty_level ?? "",
+ cefr_level: c.cefr_level ?? "",
+ };
+}
export default function AdminCourses() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
- const [form, setForm] = useState({ title: "", code: "", description: "", max_capacity: 30 });
+ const [editingCourse, setEditingCourse] = useState(null);
+ const [enrollOpen, setEnrollOpen] = useState(false);
+ const [enrollCourseId, setEnrollCourseId] = useState(null);
+ const [selectedStudentIds, setSelectedStudentIds] = useState([]);
+ const [enrollTab, setEnrollTab] = useState<"students" | "classroom">("students");
+ const [selectedBatchId, setSelectedBatchId] = useState(null);
const { data: coursesData, isLoading } = useCourses();
- const createMut = useCreateCourse();
+ const { data: studentsData } = useStudents({ size: 200 });
+ const { data: batchesData } = useBatches({ size: 200 });
+ const enrollMut = useBulkEnroll();
const qc = useQueryClient();
const { toast } = useToast();
const courses = coursesData?.items ?? [];
+ const allStudents = studentsData?.items ?? [];
+ const allBatches = batchesData?.items ?? [];
- if (isLoading) return ;
+ if (isLoading)
+ return (
+
+ );
- const filtered = courses.filter(c => c.title.toLowerCase().includes(search.toLowerCase()));
+ const filtered = courses.filter((c) =>
+ c.title.toLowerCase().includes(search.toLowerCase())
+ );
- function handleCreate() {
- if (!form.title.trim()) return;
- createMut.mutate(
- { title: form.title.trim(), code: form.code.trim() || form.title.trim().toUpperCase().replace(/\s+/g, "-").slice(0, 16), description: form.description, max_capacity: form.max_capacity },
- {
- onSuccess: () => {
- setCreateOpen(false);
- setForm({ title: "", code: "", description: "", max_capacity: 30 });
- toast({ title: "Course created" });
+ function openEnrollDialog(courseId: number) {
+ setEnrollCourseId(courseId);
+ setSelectedStudentIds([]);
+ setSelectedBatchId(null);
+ setEnrollTab("students");
+ setEnrollOpen(true);
+ }
+
+ function handleEnroll() {
+ if (!enrollCourseId) return;
+
+ if (enrollTab === "classroom" && selectedBatchId) {
+ enrollMut.mutate(
+ { courseId: enrollCourseId, data: { batch_id: selectedBatchId } },
+ {
+ onSuccess: (res) => {
+ toast({ title: `Enrolled ${res.total_enrolled} student(s) from classroom` });
+ setEnrollOpen(false);
+ qc.invalidateQueries({ queryKey: ["lms", "batches"] });
+ },
+ onError: (e) =>
+ toast({ title: "Enrollment failed", description: String(e.message || e), variant: "destructive" }),
},
- onError: (e) => toast({ title: "Error", description: String(e.message || e), variant: "destructive" }),
- }
+ );
+ } else if (enrollTab === "students" && selectedStudentIds.length > 0) {
+ enrollMut.mutate(
+ { courseId: enrollCourseId, data: { student_ids: selectedStudentIds } },
+ {
+ onSuccess: (res) => {
+ toast({ title: `Enrolled ${res.total_enrolled} student(s)` });
+ setEnrollOpen(false);
+ },
+ onError: (e) =>
+ toast({ title: "Enrollment failed", description: String(e.message || e), variant: "destructive" }),
+ },
+ );
+ }
+ }
+
+ function toggleStudentSelection(sid: number) {
+ setSelectedStudentIds((prev) =>
+ prev.includes(sid) ? prev.filter((x) => x !== sid) : [...prev, sid]
);
}
+ const canEnroll =
+ (enrollTab === "students" && selectedStudentIds.length > 0) ||
+ (enrollTab === "classroom" && selectedBatchId !== null);
+ const enrollLabel =
+ enrollTab === "classroom"
+ ? `Enroll Classroom${selectedBatchId ? ` (${allBatches.find((b) => b.id === selectedBatchId)?.student_count ?? 0} students)` : ""}`
+ : `Enroll ${selectedStudentIds.length} Student(s)`;
+
async function handleDelete(id: number, title: string) {
if (!window.confirm(`Delete course "${title}"?`)) return;
try {
@@ -60,47 +490,268 @@ export default function AdminCourses() {
return (
-
Courses Manage all platform courses.
+
+
Courses
+
Manage all platform courses.
+
-
setCreateOpen(true)}> New Course
+
setCreateOpen(true)}>
+
+ New Course
+
-
setSearch(e.target.value)} className="pl-9" />
-
-
- Course Code Level Enrolled / Capacity Status
-
- {filtered.map(c => (
-
- {c.title}
- {c.code}
- {c.level}
- {c.enrolled} / {c.max_capacity}
- {c.status}
-
- handleDelete(c.id, c.title)}>
-
-
- ))}
- {filtered.length === 0 && No courses found. }
-
-
-
+
+
+ setSearch(e.target.value)}
+ className="pl-9"
+ />
+
+
+
+
+
+
+
+ Course
+ Subject / Tags
+ Difficulty
+ Chapters
+ Enrolled / Cap
+ Status
+
+
+
+
+ {filtered.map((c) => (
+
+
+
+ {c.title}
+ {c.code}
+
+ {c.topic_names && c.topic_names.length > 0 && (
+
+ {c.topic_names.slice(0, 3).join(", ")}
+ {c.topic_names.length > 3 && ` +${c.topic_names.length - 3}`}
+
+ )}
+
+
+
+ {c.encoach_subject_name && (
+
+
+ {c.encoach_subject_name}
+
+ )}
+
+ {(c.tags ?? []).slice(0, 3).map((t) => (
+
+ {t.name}
+
+ ))}
+
+
+
+
+ {c.difficulty_level && (
+
+ {c.difficulty_level}
+
+ )}
+ {c.cefr_level && (
+
+ {c.cefr_level}
+
+ )}
+
+
+
+
+ {c.chapter_count ?? 0}
+ {(c.objective_count ?? 0) > 0 && (
+
+
+ {c.objective_count}
+
+ )}
+
+
+
+ {c.enrolled} / {c.max_capacity}
+
+
+
+ {c.status}
+
+
+
+
+
setEditingCourse(c)}
+ >
+
+
+
openEnrollDialog(c.id)}
+ >
+
+
+
+
+
+
+
+
handleDelete(c.id, c.title)}
+ >
+
+
+
+
+
+ ))}
+ {filtered.length === 0 && (
+
+
+ No courses found.
+
+
+ )}
+
+
+
+
+
+
+
+ {editingCourse && (
+
{
+ if (!open) setEditingCourse(null);
+ }}
+ initialData={courseToFormData(editingCourse)}
+ courseId={editingCourse.id}
+ />
+ )}
+
+
+
+
+ Enroll Students
+
+ Enroll in: {courses.find((c) => c.id === enrollCourseId)?.title}
+
+
+
+ setEnrollTab(v as "students" | "classroom")}>
+
+
+ Individual Students
+
+
+ By Classroom
+
+
+
+
+
+ {allStudents.length === 0 ? (
+
No students found.
+ ) : (
+ allStudents.map((s) => (
+
+ toggleStudentSelection(s.id)}
+ />
+
+
+ ))
+ )}
+
+
+
+
+
+ {allBatches.length === 0 ? (
+
+ No classrooms found. Create one on the Classrooms page first.
+
+ ) : (
+ allBatches.map((b) => (
+
setSelectedBatchId(selectedBatchId === b.id ? null : b.id)}
+ >
+ setSelectedBatchId(b.id)}
+ className="accent-primary"
+ />
+
+
{b.name}
+
+ {b.course_name && {b.course_name} · }
+ {b.start_date && {b.start_date} — {b.end_date} }
+
+
+
+ {b.student_count} students
+
+
+ ))
+ )}
+
+ {selectedBatchId && (
+
+ All students in this classroom will be enrolled in the course with the classroom linked.
+
+ )}
+
+
-
-
- Create New Course
-
-
Title * setForm(f => ({ ...f, title: e.target.value }))} placeholder="e.g. IELTS Academic Preparation" />
-
Code setForm(f => ({ ...f, code: e.target.value }))} placeholder="Auto-generated if empty" />
-
Description
-
Max Capacity setForm(f => ({ ...f, max_capacity: Number(e.target.value) || 30 }))} />
-
- setCreateOpen(false)}>Cancel
- {createMut.isPending ? "Creating..." : "Create Course"}
+ setEnrollOpen(false)}>Cancel
+
+ {enrollMut.isPending ? "Enrolling..." : enrollLabel}
+
diff --git a/src/pages/admin/AdminFacilities.tsx b/src/pages/admin/AdminFacilities.tsx
index 5acf6de..d52b894 100644
--- a/src/pages/admin/AdminFacilities.tsx
+++ b/src/pages/admin/AdminFacilities.tsx
@@ -5,14 +5,16 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
-import { useFacilities, useCreateFacility, useDeleteFacility, useAssets, useCreateAsset, useDeleteAsset } from "@/hooks/queries";
-import { Search, Plus, Trash2, Building } from "lucide-react";
+import { useFacilities, useCreateFacility, useUpdateFacility, useDeleteFacility, useAssets, useCreateAsset, useDeleteAsset } from "@/hooks/queries";
+import { Search, Plus, Trash2, Edit, Building } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
export default function AdminFacilities() {
const [search, setSearch] = useState("");
const [section, setSection] = useState<"facilities" | "assets">("facilities");
const [facOpen, setFacOpen] = useState(false);
+ const [facEditOpen, setFacEditOpen] = useState(false);
+ const [facEditId, setFacEditId] = useState(null);
const [assetOpen, setAssetOpen] = useState(false);
const [facForm, setFacForm] = useState({ name: "", code: "" });
const [assetForm, setAssetForm] = useState({ name: "", code: "" });
@@ -21,6 +23,7 @@ export default function AdminFacilities() {
const facQ = useFacilities();
const assetQ = useAssets();
const createFac = useCreateFacility();
+ const updateFac = useUpdateFacility();
const delFac = useDeleteFacility();
const createAsset = useCreateAsset();
const delAsset = useDeleteAsset();
@@ -104,21 +107,34 @@ export default function AdminFacilities() {
{f.name}
{f.code}
- {
- if (!window.confirm("Delete this facility?")) return;
- delFac.mutate(f.id, {
- onSuccess: () => toast({ title: "Deleted" }),
- onError: (err: Error) =>
- toast({ title: "Error", description: err.message, variant: "destructive" }),
- });
- }}
- >
-
-
+
+ {
+ setFacEditId(f.id);
+ setFacForm({ name: f.name || "", code: f.code || "" });
+ setFacEditOpen(true);
+ }}
+ >
+
+
+ {
+ if (!window.confirm("Delete this facility?")) return;
+ delFac.mutate(f.id, {
+ onSuccess: () => toast({ title: "Deleted" }),
+ onError: (err: Error) =>
+ toast({ title: "Error", description: err.message, variant: "destructive" }),
+ });
+ }}
+ >
+
+
+
))}
@@ -179,13 +195,14 @@ export default function AdminFacilities() {
Cancel
createFac.mutate(
- { name: facForm.name, code: facForm.code || undefined },
+ { name: facForm.name.trim(), code: facForm.code || undefined },
{
onSuccess: () => {
setFacOpen(false);
+ setFacForm({ name: "", code: "" });
toast({ title: "Created successfully" });
},
onError: (err: Error) =>
@@ -200,6 +217,50 @@ export default function AdminFacilities() {
+
+
+
+ Edit facility
+
+
+
+ setFacEditOpen(false)}>
+ Cancel
+
+ {
+ if (!facEditId) return;
+ updateFac.mutate(
+ { id: facEditId, data: { name: facForm.name.trim(), code: facForm.code || undefined } },
+ {
+ onSuccess: () => {
+ setFacEditOpen(false);
+ setFacEditId(null);
+ setFacForm({ name: "", code: "" });
+ toast({ title: "Updated successfully" });
+ },
+ onError: (err: Error) =>
+ toast({ title: "Error", description: err.message, variant: "destructive" }),
+ },
+ );
+ }}
+ >
+ Save
+
+
+
+
+
@@ -226,13 +287,14 @@ export default function AdminFacilities() {
Cancel
createAsset.mutate(
- { name: assetForm.name, code: assetForm.code || undefined },
+ { name: assetForm.name.trim(), code: assetForm.code || undefined },
{
onSuccess: () => {
setAssetOpen(false);
+ setAssetForm({ name: "", code: "" });
toast({ title: "Created successfully" });
},
onError: (err: Error) =>
diff --git a/src/pages/admin/AdminFees.tsx b/src/pages/admin/AdminFees.tsx
index 09f57f3..9ef74fb 100644
--- a/src/pages/admin/AdminFees.tsx
+++ b/src/pages/admin/AdminFees.tsx
@@ -1,40 +1,102 @@
import { useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
-import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
-import { useFeesPlans, useStudentFees } from "@/hooks/queries";
+import { Label } from "@/components/ui/label";
+import {
+ Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
+} from "@/components/ui/table";
+import {
+ Dialog, DialogContent, DialogDescription, DialogFooter,
+ DialogHeader, DialogTitle,
+} from "@/components/ui/dialog";
+import { useFeesPlans, useStudentFees, useFeesPlan } from "@/hooks/queries";
import { feesService } from "@/services/fees.service";
import { useToast } from "@/hooks/use-toast";
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
-import { useFeesPlan } from "@/hooks/queries";
-import { Search, DollarSign, CreditCard, Eye } from "lucide-react";
+import { Search, DollarSign, CreditCard, Eye, FileText, Wallet, Loader2 } from "lucide-react";
+import type { FeesPlan } from "@/types/fees";
-function planStateBadge(state: string) {
- const s = state?.toLowerCase();
- if (s === "draft") return {state} ;
- if (s === "ongoing") return {state} ;
- if (s === "done")
- return (
-
- {state}
-
- );
- return {state} ;
+function stateBadge(state: string) {
+ const s = (state || "").toLowerCase();
+ if (s === "draft") return Draft ;
+ if (s === "invoice")
+ return Invoiced ;
+ if (s === "cancel") return Cancelled ;
+ return {state || "—"} ;
+}
+
+function paymentBadge(state?: string) {
+ const s = (state || "").toLowerCase();
+ if (s === "paid")
+ return Paid ;
+ if (s === "in_payment")
+ return In payment ;
+ if (s === "partial")
+ return Partial ;
+ if (s === "reversed") return Reversed ;
+ if (s === "not_paid")
+ return Not paid ;
+ return {state || "—"} ;
+}
+
+function money(n: number | undefined, currency?: string) {
+ const v = Number(n ?? 0);
+ return `${v.toFixed(2)}${currency ? ` ${currency}` : ""}`;
}
export default function AdminFees() {
const [search, setSearch] = useState("");
const [section, setSection] = useState<"plans" | "student">("plans");
const [detailId, setDetailId] = useState(null);
+ const [paymentFor, setPaymentFor] = useState(null);
+ const [paymentAmount, setPaymentAmount] = useState("");
+ const [paymentMemo, setPaymentMemo] = useState("");
const { toast } = useToast();
- const plansQ = useFeesPlans();
- const feesQ = useStudentFees();
+ const qc = useQueryClient();
+
+ const plansQ = useFeesPlans({ q: search || undefined, size: 200 });
+ const feesQ = useStudentFees({ q: search || undefined, size: 200 });
const detailQ = useFeesPlan(detailId ?? 0);
+
+ const plans = (plansQ.data?.data ?? plansQ.data?.items ?? []) as FeesPlan[];
+ const fees = (feesQ.data?.data ?? feesQ.data?.items ?? []) as FeesPlan[];
const loading = section === "plans" ? plansQ.isLoading : feesQ.isLoading;
- const plans = plansQ.data?.data ?? plansQ.data?.items ?? [];
- const fees = feesQ.data?.data ?? feesQ.data?.items ?? [];
+
+ const invalidateAll = () => {
+ qc.invalidateQueries({ queryKey: ["fees-plans"] });
+ qc.invalidateQueries({ queryKey: ["student-fees"] });
+ if (detailId) qc.invalidateQueries({ queryKey: ["fees-plan", detailId] });
+ };
+
+ const invoiceMut = useMutation({
+ mutationFn: (id: number) => feesService.createInvoice(id),
+ onSuccess: () => {
+ toast({ title: "Invoice created", description: "Accounting entry has been generated." });
+ invalidateAll();
+ },
+ onError: (e: Error) => toast({ title: "Invoice failed", description: e.message, variant: "destructive" }),
+ });
+
+ const paymentMut = useMutation({
+ mutationFn: async () => {
+ if (!paymentFor) throw new Error("No plan selected");
+ const amt = Number(paymentAmount);
+ return feesService.registerPayment(paymentFor.id, {
+ amount: Number.isFinite(amt) && amt > 0 ? amt : undefined,
+ memo: paymentMemo || undefined,
+ });
+ },
+ onSuccess: () => {
+ toast({ title: "Payment registered", description: "The invoice balance has been updated." });
+ setPaymentFor(null);
+ setPaymentAmount("");
+ setPaymentMemo("");
+ invalidateAll();
+ },
+ onError: (e: Error) => toast({ title: "Payment failed", description: e.message, variant: "destructive" }),
+ });
if (loading) {
return (
@@ -45,18 +107,27 @@ export default function AdminFees() {
}
const q = search.toLowerCase();
- const filteredPlans = plans.filter(
+ const rows = section === "plans" ? plans : fees;
+ const filtered = rows.filter(
(p) =>
- p.student_name?.toLowerCase().includes(q) || p.course_name?.toLowerCase().includes(q),
+ (p.student_name || "").toLowerCase().includes(q) ||
+ (p.course_name || "").toLowerCase().includes(q) ||
+ (p.product_name || "").toLowerCase().includes(q) ||
+ (p.invoice_number || "").toLowerCase().includes(q),
);
- const filteredFees = fees.filter((f) => f.student_name?.toLowerCase().includes(q));
return (
-
Fees
-
Fee plans and student fee lines.
+
+
+ Fees & Payments
+
+
+ Fee plans, linked invoices and payment status. Paid and remaining balances are pulled live from accounting.
+
+
setSection("plans")}>
@@ -67,101 +138,174 @@ export default function AdminFees() {
Student fees
+
setSearch(e.target.value)}
className="pl-9"
/>
- {section === "plans" ? (
-
-
-
-
+
+
+
+
+
+
+ #
+ Student
+ Item / Course
+ Invoice
+ Total
+ Paid
+ Remaining
+ Plan state
+ Payment
+ Actions
+
+
+
+ {filtered.length === 0 && (
- #
- Student
- Course
- Total
- Paid
- Remaining
- State
- Actions
+
+ No fees records match your search.
+
-
-
- {filteredPlans.map((p, i) => (
+ )}
+ {filtered.map((p, i) => {
+ const hasInvoice = !!p.invoice_id;
+ const isPaid = (p.payment_state || "").toLowerCase() === "paid";
+ const canInvoice = !hasInvoice && p.state !== "cancel";
+ const canPay = hasInvoice && !isPaid;
+ return (
{i + 1}
- {p.student_name}
- {p.course_name}
- {p.total_amount}
- {p.paid_amount}
- {p.remaining_amount}
- {planStateBadge(p.state)}
-
- setDetailId(p.id)}>
+ {p.student_name || "—"}
+
+ {p.product_name || p.course_name || "—"}
+
+
+ {p.invoice_number ? p.invoice_number : — }
+
+ {money(p.total_amount, p.currency)}
+ {money(p.paid_amount, p.currency)}
+ {money(p.remaining_amount, p.currency)}
+ {stateBadge(p.state)}
+ {paymentBadge(p.payment_state)}
+
+ setDetailId(p.id)} title="View">
+ invoiceMut.mutate(p.id)}
+ title="Create invoice"
+ >
+ {invoiceMut.isPending && invoiceMut.variables === p.id ? (
+
+ ) : (
+
+ )}
+
+ {
+ setPaymentFor(p);
+ setPaymentAmount(String(p.remaining_amount ?? ""));
+ setPaymentMemo(`Payment for ${p.product_name || p.course_name || "fees"}`);
+ }}
+ title="Register payment"
+ >
+
+
- ))}
-
-
-
-
- ) : (
-
-
-
-
-
- #
- Student
- Amount
- Date
- State
-
-
-
- {filteredFees.map((f, i) => (
-
- {i + 1}
- {f.student_name}
- {f.amount}
- {f.date}
-
-
- {f.state}
-
-
-
- ))}
-
-
-
-
- )}
+ );
+ })}
+
+
+
+
+
{ if (!v) setDetailId(null); }}>
- Fee Plan Details
+
+ Fee Plan Details
+ Accounting-backed balance for this fee line.
+
{detailQ.isLoading ? (
-
+
+
+
) : detailQ.data ? (
-
Student: {detailQ.data.student_name}
-
Course: {detailQ.data.course_name}
-
Total: {detailQ.data.total_amount}
-
Paid: {detailQ.data.paid_amount}
-
Remaining: {detailQ.data.remaining_amount}
-
State: {planStateBadge(detailQ.data.state)}
+
Student: {detailQ.data.student_name || "—"}
+
Item / Course: {detailQ.data.product_name || detailQ.data.course_name || "—"}
+
Invoice: {detailQ.data.invoice_number || "Not created"}
+
Total: {money(detailQ.data.total_amount, detailQ.data.currency)}
+
Paid: {money(detailQ.data.paid_amount, detailQ.data.currency)}
+
Remaining: {money(detailQ.data.remaining_amount, detailQ.data.currency)}
+
+ State: {stateBadge(detailQ.data.state)}
+ ·
+ {paymentBadge(detailQ.data.payment_state)}
+
) : null}
+
+
{ if (!v) { setPaymentFor(null); setPaymentAmount(""); setPaymentMemo(""); } }}>
+
+
+ Register payment
+
+ {paymentFor
+ ? `Invoice ${paymentFor.invoice_number || ""} for ${paymentFor.student_name} · remaining ${money(paymentFor.remaining_amount, paymentFor.currency)}`
+ : ""}
+
+
+
+
+ setPaymentFor(null)}>Cancel
+ paymentMut.mutate()}
+ >
+ {paymentMut.isPending ? (
+ <> Registering...>
+ ) : (
+ "Register payment"
+ )}
+
+
+
+
);
}
diff --git a/src/pages/admin/AdminGradebook.tsx b/src/pages/admin/AdminGradebook.tsx
index 9c2be0a..79f0540 100644
--- a/src/pages/admin/AdminGradebook.tsx
+++ b/src/pages/admin/AdminGradebook.tsx
@@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { useGradebooks, useGradingAssignments, useCreateGradingAssignment, useUpdateGradingAssignment, useDeleteGradingAssignment, useCourses, useSubjects } from "@/hooks/queries";
+import { useGradebooks, useGradebookLines, useGradingAssignments, useCreateGradingAssignment, useUpdateGradingAssignment, useDeleteGradingAssignment, useCourses, useSubjects } from "@/hooks/queries";
import { Search, Plus, BookOpen, Pencil, Trash2 } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
@@ -17,6 +17,7 @@ export default function AdminGradebook() {
const [createOpen, setCreateOpen] = useState(false);
const [editId, setEditId] = useState(null);
const [form, setForm] = useState({ name: "", course_id: "", subject_id: "", issued_date: "" });
+ const [drillId, setDrillId] = useState(null);
const { toast } = useToast();
const booksQ = useGradebooks();
const assignQ = useGradingAssignments();
@@ -30,6 +31,9 @@ export default function AdminGradebook() {
const loading = section === "books" ? booksQ.isLoading : assignQ.isLoading;
const books = booksQ.data?.data ?? booksQ.data?.items ?? [];
const assignments = assignQ.data?.data ?? assignQ.data?.items ?? [];
+ const drillQ = useGradebookLines(drillId ? { gradebook_id: drillId, size: 200 } : undefined);
+ const drillLines = drillQ.data?.data ?? drillQ.data?.items ?? [];
+ const drillBook = drillId ? books.find((b) => b.id === drillId) : null;
if (loading) {
return (
@@ -100,7 +104,7 @@ export default function AdminGradebook() {
{filteredBooks.map((b, i) => (
-
+ setDrillId(b.id)}>
{i + 1}
{b.student_name}
{b.course_name}
@@ -157,6 +161,49 @@ export default function AdminGradebook() {
)}
+ { if (!v) setDrillId(null); }}>
+
+
+
+ Gradebook{drillBook ? ` — ${drillBook.student_name} · ${drillBook.course_name}` : ""}
+
+
+ {drillQ.isLoading ? (
+
+ ) : drillLines.length === 0 ? (
+ No gradebook lines recorded yet.
+ ) : (
+
+
+
+ #
+ Assignment
+ Marks
+ %
+ State
+
+
+
+ {drillLines.map((l, i) => (
+
+ {i + 1}
+ {l.assignment_name || "—"}
+ {typeof l.marks === "number" ? l.marks : "—"}
+ {typeof l.percentage === "number" ? l.percentage.toFixed(1) : "—"}
+ {l.state || "draft"}
+
+ ))}
+
+
+ )}
+
+ setDrillId(null)}>Close
+
+
+
+
{ setCreateOpen(v); if (!v) setEditId(null); }}>
diff --git a/src/pages/admin/AdminLessons.tsx b/src/pages/admin/AdminLessons.tsx
index 1e515e2..ac4eb5d 100644
--- a/src/pages/admin/AdminLessons.tsx
+++ b/src/pages/admin/AdminLessons.tsx
@@ -54,9 +54,9 @@ export default function AdminLessons() {
const openEdit = (l: Lesson) => {
setEditing(l);
setForm({
- lesson_topic: l.name,
- course_id: "",
- batch_id: "",
+ lesson_topic: l.lesson_topic || l.name,
+ course_id: l.course_id ? String(l.course_id) : "",
+ batch_id: l.batch_id ? String(l.batch_id) : "",
subject_id: String(l.subject_id ?? ""),
});
setEditOpen(true);
@@ -220,6 +220,26 @@ export default function AdminLessons() {
Lesson Topic
setForm((f) => ({ ...f, lesson_topic: e.target.value }))} />
+
+ Course
+ setForm((f) => ({ ...f, course_id: v === "__none__" ? "" : v, batch_id: "" }))}>
+
+
+ — Select —
+ {courses.map((c) => {c.title} )}
+
+
+
+
+ Batch
+ setForm((f) => ({ ...f, batch_id: v === "__none__" ? "" : v }))} disabled={!form.course_id}>
+
+
+ — Select —
+ {batchesForCourse.map((b) => {b.name} )}
+
+
+
Subject
setForm((f) => ({ ...f, subject_id: v === "__none__" ? "" : v }))}>
@@ -245,6 +265,8 @@ export default function AdminLessons() {
data: {
lesson_topic: form.lesson_topic,
name: form.lesson_topic,
+ course_id: form.course_id ? Number(form.course_id) : undefined,
+ batch_id: form.batch_id ? Number(form.batch_id) : undefined,
subject_id: form.subject_id ? Number(form.subject_id) : undefined,
},
},
diff --git a/src/pages/admin/AdminLibrary.tsx b/src/pages/admin/AdminLibrary.tsx
index 8ed313d..b33a55d 100644
--- a/src/pages/admin/AdminLibrary.tsx
+++ b/src/pages/admin/AdminLibrary.tsx
@@ -6,7 +6,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
-import { useLibraryMedia, useCreateMedia, useDeleteMedia, useLibraryMovements, useCreateMovement, useReturnMovement, useLibraryCards, useCreateCard } from "@/hooks/queries";
+import { useLibraryMedia, useCreateMedia, useDeleteMedia, useLibraryMovements, useCreateMovement, useReturnMovement, useLibraryCards, useCreateCard, useStudents } from "@/hooks/queries";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Search, Plus, Book, ArrowUpDown, Trash2, RotateCcw } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
@@ -31,6 +32,8 @@ export default function AdminLibrary() {
const createMoveMut = useCreateMovement();
const returnMoveMut = useReturnMovement();
const createCardMut = useCreateCard();
+ const studentsQ = useStudents({ size: 500 });
+ const students = studentsQ.data?.items ?? [];
const loading =
tab === "media" ? mediaQ.isLoading : tab === "movements" ? movQ.isLoading : cardsQ.isLoading;
@@ -258,11 +261,11 @@ export default function AdminLibrary() {
Cancel
createMediaMut.mutate(
{
- name: mediaForm.name,
+ name: mediaForm.name.trim(),
isbn: mediaForm.isbn || undefined,
author: mediaForm.author || undefined,
edition: mediaForm.edition || undefined,
@@ -270,6 +273,7 @@ export default function AdminLibrary() {
{
onSuccess: () => {
setMediaOpen(false);
+ setMediaForm({ name: "", isbn: "", author: "", edition: "" });
toast({ title: "Created successfully" });
},
onError: (err: Error) =>
@@ -291,17 +295,38 @@ export default function AdminLibrary() {
setMoveOpen(false)}>Cancel
- createMoveMut.mutate({ media_id: Number(moveForm.media_id), student_id: Number(moveForm.student_id) }, { onSuccess: () => { setMoveOpen(false); toast({ title: "Issued" }); }, onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }) })}>
+ createMoveMut.mutate(
+ { media_id: Number(moveForm.media_id), student_id: Number(moveForm.student_id) },
+ {
+ onSuccess: () => { setMoveOpen(false); setMoveForm({ media_id: "", student_id: "" }); toast({ title: "Issued" }); },
+ onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
+ },
+ )}
+ >
Issue
@@ -312,8 +337,14 @@ export default function AdminLibrary() {
Create library card
- Student ID
- setCardStudentId(e.target.value)} />
+ Student
+ setCardStudentId(v === "__none__" ? "" : v)}>
+
+
+ — Select —
+ {students.map((s) => ({s.name} ))}
+
+
setCardOpen(false)}>Cancel
diff --git a/src/pages/admin/AdminStudentLeave.tsx b/src/pages/admin/AdminStudentLeave.tsx
index 2197ea5..4bbc85b 100644
--- a/src/pages/admin/AdminStudentLeave.tsx
+++ b/src/pages/admin/AdminStudentLeave.tsx
@@ -234,6 +234,7 @@ export default function AdminStudentLeave() {
{
onSuccess: () => {
setCreateOpen(false);
+ setForm({ student_id: "", leave_type: "", start_date: "", end_date: "", description: "" });
toast({ title: "Created successfully" });
},
onError: (err: Error) =>
diff --git a/src/pages/admin/AdminStudentProgress.tsx b/src/pages/admin/AdminStudentProgress.tsx
index e2f7df2..ee22767 100644
--- a/src/pages/admin/AdminStudentProgress.tsx
+++ b/src/pages/admin/AdminStudentProgress.tsx
@@ -1,14 +1,27 @@
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
+import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
-import { useStudentProgressList } from "@/hooks/queries";
-import { Search, TrendingUp } from "lucide-react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { useStudentProgressList, useStudentProgressDetail } from "@/hooks/queries";
+import { Search, TrendingUp, Loader2 } from "lucide-react";
+import type { StudentProgression } from "@/types/student-progress";
+
+function rateColor(rate: number | null | undefined): string {
+ if (rate == null) return "bg-muted text-muted-foreground";
+ if (rate >= 85) return "bg-emerald-100 text-emerald-700 border-emerald-200";
+ if (rate >= 60) return "bg-amber-100 text-amber-700 border-amber-200";
+ return "bg-red-100 text-red-700 border-red-200";
+}
export default function AdminStudentProgress() {
const [search, setSearch] = useState("");
- const { data, isLoading } = useStudentProgressList();
- const items = data?.data ?? data?.items ?? [];
+ const [drillId, setDrillId] = useState(null);
+ const { data, isLoading } = useStudentProgressList({ q: search || undefined, size: 200 });
+ const items = (data?.data ?? data?.items ?? []) as StudentProgression[];
+
+ const { data: detail, isLoading: loadingDetail } = useStudentProgressDetail(drillId);
if (isLoading) {
return (
@@ -18,9 +31,6 @@ export default function AdminStudentProgress() {
);
}
- const q = search.toLowerCase();
- const filtered = items.filter((r) => r.student_name?.toLowerCase().includes(q));
-
return (
@@ -28,12 +38,14 @@ export default function AdminStudentProgress() {
Student progress
-
Attendance, assignments, and marksheets per student.
+
+ Per-student attendance, assignments and marksheet activity across the institution. Click a row for the breakdown.
+
setSearch(e.target.value)}
className="pl-9"
@@ -44,27 +56,159 @@ export default function AdminStudentProgress() {
- #
+ #
Student
- Total Attendance
- Total Assignments
- Total Marksheets
+ GR No.
+ Attendance
+ Assignments
+ Marksheet lines
+ Avg marks
- {filtered.map((r, i) => (
-
+ {items.length === 0 && (
+
+
+ No students match your search.
+
+
+ )}
+ {items.map((r, i) => (
+ setDrillId(r.id)}
+ >
{i + 1}
- {r.student_name}
- {r.total_attendance}
- {r.total_assignment}
- {r.total_marksheet_line}
+ {r.student_name || "—"}
+ {r.gr_no || "—"}
+
+
+
+ {r.total_attendance_present ?? 0}/{r.total_attendance ?? 0}
+
+ {r.attendance_rate != null && (
+
+ {r.attendance_rate}%
+
+ )}
+
+
+ {r.total_assignment ?? 0}
+ {r.total_marksheet_line ?? 0}
+
+ {r.avg_marks != null ? r.avg_marks : "—"}
+
))}
+
+
!open && setDrillId(null)}>
+
+
+
+ {detail?.student_name || "Student breakdown"}
+ {detail?.gr_no ? (
+ GR {detail.gr_no}
+ ) : null}
+
+
+ {loadingDetail && (
+
+ )}
+ {detail && !loadingDetail && (
+
+
+ Recent attendance ({detail.attendance.length})
+
+
+
+
+ Sheet
+ Present
+
+
+
+ {detail.attendance.length === 0 && (
+ No attendance records.
+ )}
+ {detail.attendance.map((a) => (
+
+ {a.sheet || "—"}
+
+ {a.present == null ? "—" : a.present ? (
+ Present
+ ) : (
+ Absent
+ )}
+
+
+ ))}
+
+
+
+
+
+
+ Recent assignments ({detail.assignments.length})
+
+
+
+
+ Assignment
+ Marks
+ State
+
+
+
+ {detail.assignments.length === 0 && (
+ No assignment submissions.
+ )}
+ {detail.assignments.map((a) => (
+
+ {a.assignment || "—"}
+ {a.marks ?? 0}
+ {a.state || "—"}
+
+ ))}
+
+
+
+
+
+
+ Marksheet lines ({detail.marksheets.length})
+
+
+
+
+ Marksheet
+ Marks
+ Grade
+
+
+
+ {detail.marksheets.length === 0 && (
+ No marksheet lines.
+ )}
+ {detail.marksheets.map((m) => (
+
+ {m.marksheet || "—"}
+ {m.marks ?? 0}
+ {m.grade || "—"}
+
+ ))}
+
+
+
+
+
+ )}
+
+
);
}
diff --git a/src/pages/admin/AdmissionRegisterPage.tsx b/src/pages/admin/AdmissionRegisterPage.tsx
index a528349..beb599a 100644
--- a/src/pages/admin/AdmissionRegisterPage.tsx
+++ b/src/pages/admin/AdmissionRegisterPage.tsx
@@ -50,7 +50,7 @@ export default function AdmissionRegisterPage() {
qc.invalidateQueries({ queryKey: ["admission-registers"] });
toast({ title: "Register created" });
setShowCreate(false);
- setForm({ name: "", course_id: 0, start_date: "", end_date: "" });
+ setForm({ name: "", course_id: 0, start_date: "", end_date: "", min_count: undefined, max_count: undefined });
},
onError: () => toast({ title: "Error", description: "Failed to create register", variant: "destructive" }),
});
diff --git a/src/pages/admin/MarksheetManager.tsx b/src/pages/admin/MarksheetManager.tsx
index d23cd35..a71ab02 100644
--- a/src/pages/admin/MarksheetManager.tsx
+++ b/src/pages/admin/MarksheetManager.tsx
@@ -194,11 +194,11 @@ export default function MarksheetManager() {
{tpl.result_date}
- {tpl.state.replace("_", " ")}
+ {(tpl.state ?? "draft").replace("_", " ")}
- {tpl.state === "draft" && (
+ {(tpl.state ?? "draft") === "draft" && (
generateMutation.mutate(tpl.id)} disabled={generateMutation.isPending}>
Generate Marksheets
@@ -288,7 +288,7 @@ export default function MarksheetManager() {
{line.student_name}
{line.total_marks}
- {line.percentage.toFixed(1)}%
+ {typeof line.percentage === "number" ? line.percentage.toFixed(1) : "0.0"}%
{line.grade ?? "—"}
{line.status}
diff --git a/src/pages/admin/ResourceManager.tsx b/src/pages/admin/ResourceManager.tsx
index 8f0c1cc..fdbe562 100644
--- a/src/pages/admin/ResourceManager.tsx
+++ b/src/pages/admin/ResourceManager.tsx
@@ -1,17 +1,25 @@
import { useState } from "react";
-import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
-import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { Upload, Search, FileText, Video, Link2, Download, Trash2, CheckCircle2, Clock, XCircle, Loader2 } from "lucide-react";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import {
+ Upload, Search, FileText, Video, Link2, Download, Trash2,
+ CheckCircle2, Clock, XCircle, Loader2, BookOpen, Music, Image,
+ Tag, Plus, Pencil, X, CalendarDays,
+} from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { resourcesService } from "@/services/resources.service";
+import { coursewareService } from "@/services/courseware.service";
+import { taxonomyService } from "@/services/taxonomy.service";
import { useToast } from "@/hooks/use-toast";
-import type { Resource } from "@/types";
+import { TaxonomyCascade } from "@/components/TaxonomyCascade";
+import type { Resource, ResourceTag } from "@/types";
const statusBadge: Record = {
approved: { variant: "default", icon: },
@@ -25,163 +33,597 @@ const typeIcons: Record = {
link: ,
document: ,
interactive: ,
+ article: ,
+ audio: ,
+ image: ,
};
+const TAG_COLORS = [
+ "#3b82f6", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6",
+ "#ec4899", "#06b6d4", "#f97316", "#6366f1", "#14b8a6",
+];
+
+function pickColor() {
+ return TAG_COLORS[Math.floor(Math.random() * TAG_COLORS.length)];
+}
+
+// ── Reusable Tag Picker with inline create ──────────────────────────
+function TagPicker({
+ allTags, selectedIds, onToggle, onTagCreated,
+}: {
+ allTags: ResourceTag[];
+ selectedIds: number[];
+ onToggle: (id: number) => void;
+ onTagCreated: (tag: ResourceTag) => void;
+}) {
+ const [newName, setNewName] = useState("");
+ const [creating, setCreating] = useState(false);
+
+ const handleCreate = async () => {
+ const name = newName.trim();
+ if (!name) return;
+ setCreating(true);
+ try {
+ const tag = await resourcesService.createTag({ name, color: pickColor() });
+ onTagCreated(tag);
+ onToggle(tag.id);
+ setNewName("");
+ } catch { /* ignore */ }
+ setCreating(false);
+ };
+
+ return (
+
+
Tags
+
+ {allTags.map((t) => (
+ onToggle(t.id)}
+ >
+ {t.name}
+
+ ))}
+
+
+
setNewName(e.target.value)}
+ onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleCreate(); } }}
+ className="h-8 text-sm flex-1"
+ />
+
+ {creating ? : <> Add>}
+
+
+
+ );
+}
+
+// ── Edit Resource Dialog ────────────────────────────────────────────
+function EditResourceDialog({
+ resource, open, onOpenChange, allTags, onTagCreated,
+}: {
+ resource: Resource;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ allTags: ResourceTag[];
+ onTagCreated: (tag: ResourceTag) => void;
+}) {
+ const { toast } = useToast();
+ const qc = useQueryClient();
+ const [name, setName] = useState(resource.name);
+ const [type, setType] = useState(resource.type || resource.resource_type || "document");
+ const [status, setStatus] = useState(resource.review_status || "approved");
+ const [tagIds, setTagIds] = useState(resource.tag_ids ?? resource.tags?.map((t) => t.id) ?? []);
+
+ const [editSubjectId, setEditSubjectId] = useState(resource.subject_id ? String(resource.subject_id) : "none");
+ const [editDomainId, setEditDomainId] = useState(resource.domain_id ? String(resource.domain_id) : "none");
+ const [editTopicIds, setEditTopicIds] = useState(resource.topic_ids ?? []);
+ const [editObjectiveIds, setEditObjectiveIds] = useState(resource.learning_objective_ids ?? []);
+
+ const updateMutation = useMutation({
+ mutationFn: (data: Record) => resourcesService.update(resource.id, data as Partial),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["resources"] });
+ toast({ title: "Resource updated" });
+ onOpenChange(false);
+ },
+ onError: () => toast({ title: "Error", description: "Failed to update", variant: "destructive" }),
+ });
+
+ const handleSave = () => {
+ updateMutation.mutate({
+ name,
+ type,
+ subject_id: editSubjectId !== "none" ? Number(editSubjectId) : null,
+ domain_id: editDomainId !== "none" ? Number(editDomainId) : null,
+ topic_ids: editTopicIds,
+ learning_objective_ids: editObjectiveIds,
+ review_status: status,
+ tag_ids: tagIds,
+ });
+ };
+
+ const toggleTag = (id: number) => setTagIds((prev) => prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]);
+
+ return (
+
+
+ Edit Resource
+
+
Name setName(e.target.value)} />
+
+
+ Type
+
+
+
+ PDF
+ Video
+ Link
+ Document
+ Interactive
+ Audio
+ Image
+ Article
+
+
+
+
+ Status
+
+
+
+ Pending
+ Approved
+ Rejected
+
+
+
+
+
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {updateMutation.isPending && }
+ Save Changes
+
+
+
+
+ );
+}
+
+// ── Main Component ──────────────────────────────────────────────────
export default function ResourceManager() {
const { toast } = useToast();
const qc = useQueryClient();
+
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
+ const [subjectFilter, setSubjectFilter] = useState("all");
+ const [tagFilter, setTagFilter] = useState("all");
+ const [dateFrom, setDateFrom] = useState("");
+ const [dateTo, setDateTo] = useState("");
+ const [activeTab, setActiveTab] = useState("all");
+
const [showUpload, setShowUpload] = useState(false);
const [uploadType, setUploadType] = useState("pdf");
+ const [uploadSubjectId, setUploadSubjectId] = useState("none");
+ const [uploadDomainId, setUploadDomainId] = useState("none");
+ const [uploadTopicIds, setUploadTopicIds] = useState([]);
+ const [uploadObjectiveIds, setUploadObjectiveIds] = useState([]);
+ const [uploadTagIds, setUploadTagIds] = useState([]);
- const { data: resourcesData, isLoading } = useQuery({
- queryKey: ["resources", "list", { search, resource_type: typeFilter === "all" ? undefined : typeFilter, review_status: statusFilter === "all" ? undefined : statusFilter }],
- queryFn: () => resourcesService.list({ search: search || undefined, resource_type: typeFilter === "all" ? undefined : typeFilter, review_status: statusFilter === "all" ? undefined : statusFilter }),
+ const [showTagManager, setShowTagManager] = useState(false);
+ const [newTagName, setNewTagName] = useState("");
+ const [newTagColor, setNewTagColor] = useState(TAG_COLORS[0]);
+ const [editingTag, setEditingTag] = useState(null);
+
+ const [editingResource, setEditingResource] = useState(null);
+
+ const { data: subjects } = useQuery({
+ queryKey: ["taxonomy", "subjects"],
+ queryFn: () => taxonomyService.listSubjects(),
+ });
+
+ const { data: tags } = useQuery({
+ queryKey: ["resource-tags"],
+ queryFn: () => resourcesService.listTags(),
+ });
+
+ const { data: resourcesData, isLoading: loadingResources } = useQuery({
+ queryKey: ["resources", "list", { search, resource_type: typeFilter === "all" ? undefined : typeFilter, review_status: statusFilter === "all" ? undefined : statusFilter, subject_id: subjectFilter === "all" ? undefined : subjectFilter, tag_id: tagFilter === "all" ? undefined : tagFilter, date_from: dateFrom || undefined, date_to: dateTo || undefined }],
+ queryFn: () => resourcesService.list({
+ search: search || undefined,
+ resource_type: typeFilter === "all" ? undefined : typeFilter,
+ review_status: statusFilter === "all" ? undefined : statusFilter,
+ subject_id: subjectFilter === "all" ? undefined : Number(subjectFilter),
+ tag_id: tagFilter === "all" ? undefined : Number(tagFilter),
+ date_from: dateFrom || undefined,
+ date_to: dateTo || undefined,
+ }),
});
const resources = resourcesData?.items ?? [];
+ const { data: materialsData, isLoading: loadingMaterials } = useQuery({
+ queryKey: ["materials", "search", { search, type: typeFilter }],
+ queryFn: () => coursewareService.searchMaterials({ search: search || undefined, type: typeFilter === "all" ? undefined : typeFilter }),
+ });
+ const courseMaterials = materialsData?.items ?? [];
+
const deleteMutation = useMutation({
mutationFn: (id: number) => resourcesService.delete(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Resource deleted" }); },
- onError: () => toast({ title: "Error", description: "Failed to delete resource", variant: "destructive" }),
+ onError: () => toast({ title: "Error", description: "Failed to delete", variant: "destructive" }),
});
const uploadMutation = useMutation({
mutationFn: (formData: FormData) => resourcesService.upload(formData),
- onSuccess: () => { qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Resource uploaded" }); setShowUpload(false); },
+ onSuccess: () => { qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Resource uploaded" }); setShowUpload(false); setUploadTagIds([]); setUploadSubjectId("none"); setUploadDomainId("none"); setUploadTopicIds([]); setUploadObjectiveIds([]); },
onError: () => toast({ title: "Error", description: "Upload failed", variant: "destructive" }),
});
+ const createTagMutation = useMutation({
+ mutationFn: (data: { name: string; color: string }) => resourcesService.createTag(data),
+ onSuccess: () => { qc.invalidateQueries({ queryKey: ["resource-tags"] }); setNewTagName(""); toast({ title: "Tag created" }); },
+ onError: () => toast({ title: "Error", description: "Failed to create tag", variant: "destructive" }),
+ });
+
+ const updateTagMutation = useMutation({
+ mutationFn: ({ id, ...data }: { id: number; name: string; color: string }) => resourcesService.updateTag(id, data),
+ onSuccess: () => { qc.invalidateQueries({ queryKey: ["resource-tags"] }); qc.invalidateQueries({ queryKey: ["resources"] }); setEditingTag(null); toast({ title: "Tag updated" }); },
+ onError: () => toast({ title: "Error", description: "Failed to update tag", variant: "destructive" }),
+ });
+
+ const deleteTagMutation = useMutation({
+ mutationFn: (id: number) => resourcesService.deleteTag(id),
+ onSuccess: () => { qc.invalidateQueries({ queryKey: ["resource-tags"] }); qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Tag deleted" }); },
+ onError: () => toast({ title: "Error", description: "Failed to delete tag", variant: "destructive" }),
+ });
+
const handleUpload = (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
+ if (uploadTagIds.length > 0) formData.set("tag_ids", uploadTagIds.join(","));
+ if (uploadDomainId !== "none") formData.set("domain_id", uploadDomainId);
+ if (uploadTopicIds.length > 0) formData.set("topic_ids", uploadTopicIds.join(","));
+ if (uploadObjectiveIds.length > 0) formData.set("learning_objective_ids", uploadObjectiveIds.join(","));
uploadMutation.mutate(formData);
};
- if (isLoading) return ;
+ const toggleUploadTag = (id: number) => setUploadTagIds((prev) => prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]);
+
+ const handleInlineTagCreated = () => qc.invalidateQueries({ queryKey: ["resource-tags"] });
+
+ const clearFilters = () => {
+ setSearch(""); setTypeFilter("all"); setStatusFilter("all");
+ setSubjectFilter("all"); setTagFilter("all"); setDateFrom(""); setDateTo("");
+ };
+ const hasActiveFilters = search || typeFilter !== "all" || statusFilter !== "all" || subjectFilter !== "all" || tagFilter !== "all" || dateFrom || dateTo;
+ const isLoading = loadingResources || loadingMaterials;
return (
+ {/* Header */}
Resource Manager
-
Upload, manage, and review learning resources.
+
All learning content — uploaded resources and course materials — available for AI generation.
+
+
+
setShowTagManager(true)}> Manage Tags
+
{ setShowUpload(o); if (!o) { setUploadTagIds([]); setUploadSubjectId("none"); setUploadDomainId("none"); setUploadTopicIds([]); setUploadObjectiveIds([]); } }}>
+ Upload Resource
+
+ Upload New Resource
+
+
+
-
-
- Upload Resource
-
-
- Upload New Resource
-
-
-
+ {/* Stats */}
+
+
setActiveTab("all")}>
+ {resources.length + courseMaterials.length}
Total Content Items
+
+
setActiveTab("resources")}>
+ {resources.length}
Library Resources
+
+
setActiveTab("materials")}>
+ {courseMaterials.length}
Course Materials
+
+
+
+ {/* Filters + Table */}
-
-
-
+
+
+
- setSearch(e.target.value)} className="pl-9" />
+ setSearch(e.target.value)} className="pl-9" />
+
+
+
+ All Subjects
+ {(subjects ?? []).map((s) => ({s.name} ))}
+
+
+
+
+
+ All Tags
+ {(tags ?? []).map((t) => (
+
+ {t.name}
+
+ ))}
+
+
-
+
All Types
- PDF
- Video
- Link
-
-
-
-
-
- All Status
- Pending
- Approved
- Rejected
+ PDF Video Link
+ Article Document Audio
+ {activeTab !== "materials" && (
+
+
+
+ All Status
+ Pending Approved Rejected
+
+
+ )}
+
+
-
-
-
- Resource
- Type
- Topics
- Author
- Status
- Actions
-
-
-
- {resources.map((resource: Resource) => {
- const sb = statusBadge[resource.review_status] ?? statusBadge.pending;
- return (
-
-
-
- {typeIcons[resource.resource_type] ?? }
- {resource.name}
-
-
- {resource.resource_type}
-
-
- {resource.topic_names.slice(0, 2).map((t, i) => {t} )}
- {resource.topic_names.length > 2 && +{resource.topic_names.length - 2} }
-
-
- {resource.author_name}
-
- {sb.icon}{resource.review_status}
-
-
-
- { try { const blob = await resourcesService.download(resource.id); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = resource.name || "resource"; a.click(); URL.revokeObjectURL(url); } catch { toast({ title: "Download failed", variant: "destructive" }); } }}>
- { if (!window.confirm(`Delete "${resource.name}"?`)) return; deleteMutation.mutate(resource.id); }} disabled={deleteMutation.isPending}>
-
-
-
-
-
- );
- })}
- {resources.length === 0 && (
-
- No resources found.
-
- )}
-
-
+ {isLoading ? (
+
+ ) : (
+
+
+ All ({resources.length + courseMaterials.length})
+ Library Resources ({resources.length})
+ Course Materials ({courseMaterials.length})
+
+
+ { if (window.confirm("Delete this resource?")) deleteMutation.mutate(id); }} onEditResource={setEditingResource} deletePending={deleteMutation.isPending} toast={toast} />
+
+
+ { if (window.confirm("Delete this resource?")) deleteMutation.mutate(id); }} onEditResource={setEditingResource} deletePending={deleteMutation.isPending} toast={toast} />
+
+
+ {}} onEditResource={() => {}} deletePending={false} toast={toast} />
+
+
+ )}
+
+ {/* Tag Manager Dialog */}
+
{ setShowTagManager(o); if (!o) setEditingTag(null); }}>
+
+ Manage Resource Tags
+
+
+
setNewTagName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && newTagName.trim()) createTagMutation.mutate({ name: newTagName.trim(), color: newTagColor }); }} className="flex-1" />
+
+ {TAG_COLORS.slice(0, 5).map((c) => (
+ setNewTagColor(c)} />
+ ))}
+
+
createTagMutation.mutate({ name: newTagName.trim(), color: newTagColor })}>
+ {createTagMutation.isPending ? : }
+
+
+
+ {(tags ?? []).length === 0 &&
No tags yet. Create one above.
}
+ {(tags ?? []).map((t) => (
+
+ {editingTag?.id === t.id ? (
+
+
setEditingTag({ ...editingTag, name: e.target.value })} className="h-8 text-sm flex-1" />
+
+ {TAG_COLORS.slice(0, 5).map((c) => (
+ setEditingTag({ ...editingTag, color: c })} />
+ ))}
+
+
updateTagMutation.mutate({ id: editingTag.id, name: editingTag.name, color: editingTag.color })} disabled={updateTagMutation.isPending}>
+
setEditingTag(null)}>
+
+ ) : (
+ <>
+
+
+ {t.name}
+ {t.resource_count ?? 0}
+
+
+
setEditingTag(t)}>
+
{ if (window.confirm(`Delete tag "${t.name}"?`)) deleteTagMutation.mutate(t.id); }} disabled={deleteTagMutation.isPending}>
+
+ >
+ )}
+
+ ))}
+
+
+ setShowTagManager(false)}>Done
+
+
+
+ {/* Edit Resource Dialog */}
+ {editingResource && (
+
{ if (!o) setEditingResource(null); }}
+ allTags={tags ?? []}
+ onTagCreated={handleInlineTagCreated}
+ />
+ )}
);
}
+
+// ── Content Table ───────────────────────────────────────────────────
+interface CourseMaterialItem {
+ id: number; name: string; type: string; course_name: string;
+ chapter_name: string; source: string; file_url: string | null;
+ url: string | null; description: string | null;
+}
+
+function ContentTable({
+ resources, materials, showSource, showCourseInfo, onDeleteResource, onEditResource, deletePending, toast,
+}: {
+ resources: Resource[]; materials: CourseMaterialItem[];
+ showSource?: boolean; showCourseInfo?: boolean;
+ onDeleteResource: (id: number) => void;
+ onEditResource: (r: Resource) => void;
+ deletePending: boolean;
+ toast: ReturnType
["toast"];
+}) {
+ type UnifiedRow = {
+ key: string; name: string; type: string; source: "resource" | "material";
+ context: string; tags?: { id: number; name: string; color: string }[];
+ status?: string; createdAt?: string; resourceId?: number; raw?: Resource;
+ };
+
+ const rows: UnifiedRow[] = [
+ ...resources.map((r): UnifiedRow => ({
+ key: `r-${r.id}`, name: r.name, type: r.resource_type, source: "resource",
+ context: [r.subject_name, ...(r.topic_names?.slice(0, 2) ?? [])].filter(Boolean).join(" › ") || "",
+ tags: r.tags ?? [], status: r.review_status, createdAt: r.created_at, resourceId: r.id, raw: r,
+ })),
+ ...materials.map((m): UnifiedRow => ({
+ key: `m-${m.id}`, name: m.name, type: m.type, source: "material",
+ context: showCourseInfo || showSource ? `${m.course_name} › ${m.chapter_name}` : m.chapter_name || "",
+ })),
+ ];
+
+ const formatDate = (iso?: string) => {
+ if (!iso) return "";
+ try { return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); } catch { return ""; }
+ };
+
+ return (
+
+
+
+ Name
+ Type
+ {showSource && Source }
+ Subject / Tags
+ Status
+ Uploaded
+ Actions
+
+
+
+ {rows.map((row) => {
+ const sb = row.status ? (statusBadge[row.status] ?? statusBadge.pending) : null;
+ return (
+
+ {typeIcons[row.type] ?? }{row.name}
+ {row.type}
+ {showSource && (
+
+
+ {row.source === "resource" ? "Library" : <> Course>}
+
+
+ )}
+
+
+ {row.context &&
{row.context} }
+ {row.tags && row.tags.length > 0 && (
+
+ {row.tags.map((t) => ({t.name} ))}
+
+ )}
+ {!row.context && (!row.tags || row.tags.length === 0) &&
— }
+
+
+ {sb ? ({sb.icon}{row.status} ) : ( indexed )}
+ {formatDate(row.createdAt)}
+
+
+ {row.source === "resource" && row.resourceId && (
+ <>
+
row.raw && onEditResource(row.raw)}>
+
{
+ try { const blob = await resourcesService.download(row.resourceId!); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = row.name || "resource"; a.click(); URL.revokeObjectURL(url); } catch { toast({ title: "Download failed", variant: "destructive" }); }
+ }}>
+
onDeleteResource(row.resourceId!)} disabled={deletePending}>
+ >
+ )}
+
+
+
+ );
+ })}
+ {rows.length === 0 && (No content found. )}
+
+
+ );
+}
diff --git a/src/pages/admin/TaxonomyManager.tsx b/src/pages/admin/TaxonomyManager.tsx
index 02104cb..ff1a8b5 100644
--- a/src/pages/admin/TaxonomyManager.tsx
+++ b/src/pages/admin/TaxonomyManager.tsx
@@ -1,5 +1,5 @@
import { useState } from "react";
-import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -7,80 +7,146 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { Plus, ChevronRight, BookOpen, Layers, FileText, Sparkles, Loader2, Trash2, X } from "lucide-react";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Plus, ChevronRight, BookOpen, Layers, FileText, Sparkles,
+ Loader2, Trash2, X, Pencil, Check, Library, GraduationCap, Link2,
+} from "lucide-react";
import { useSubjects, useTaxonomyTree } from "@/hooks/queries";
import { taxonomyService } from "@/services/taxonomy.service";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
+import { useNavigate } from "react-router-dom";
import type { Domain, Topic } from "@/types";
+function RelBadge({ count, label, icon: Icon, onClick }: { count: number; label: string; icon: React.ElementType; onClick?: () => void }) {
+ return (
+ 0 ? "default" : "outline"}
+ className={`text-xs gap-1 ${onClick ? "cursor-pointer hover:bg-accent" : ""} ${count === 0 ? "text-muted-foreground" : ""}`}
+ onClick={(e) => { e.stopPropagation(); onClick?.(); }}
+ >
+
+ {count} {label}
+
+ );
+}
+
export default function TaxonomyManager() {
const { toast } = useToast();
const qc = useQueryClient();
+ const navigate = useNavigate();
const { data: rawSubjects, isLoading } = useSubjects();
const subjects = Array.isArray(rawSubjects) ? rawSubjects : [];
const [selectedSubjectId, setSelectedSubjectId] = useState(null);
const { data: tree, isLoading: loadingTree } = useTaxonomyTree(selectedSubjectId ?? 0);
+
const [showAddSubject, setShowAddSubject] = useState(false);
const [newSubjectName, setNewSubjectName] = useState("");
const [newSubjectCode, setNewSubjectCode] = useState("");
+
const [showAddDomain, setShowAddDomain] = useState(false);
const [newDomainName, setNewDomainName] = useState("");
+
const [showAddTopic, setShowAddTopic] = useState(null);
const [newTopicName, setNewTopicName] = useState("");
const [newTopicDifficulty, setNewTopicDifficulty] = useState("medium");
const [newTopicHours, setNewTopicHours] = useState("1");
+ const [editingSubjectId, setEditingSubjectId] = useState(null);
+ const [editSubjectName, setEditSubjectName] = useState("");
+ const [editSubjectCode, setEditSubjectCode] = useState("");
+
+ const [editingDomainId, setEditingDomainId] = useState(null);
+ const [editDomainName, setEditDomainName] = useState("");
+
+ const [editingTopicId, setEditingTopicId] = useState(null);
+ const [editTopicName, setEditTopicName] = useState("");
+ const [editTopicDifficulty, setEditTopicDifficulty] = useState("medium");
+ const [editTopicHours, setEditTopicHours] = useState("1");
+ const [editTopicDesc, setEditTopicDesc] = useState("");
+
+ const invalidate = () => qc.invalidateQueries({ queryKey: ["taxonomy"] });
+
const createSubjectMutation = useMutation({
mutationFn: () => taxonomyService.createSubject({ name: newSubjectName, code: newSubjectCode, is_active: true, mastery_threshold: 80, grading_scale: "percentage", diagnostic_config: { questions_per_domain: 5, total_question_cap: 30, time_limit_minutes: 45, starting_difficulty: "medium", mastery_per_correct: 10, mastery_per_incorrect: -5 } }),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: ["taxonomy", "subjects"] });
- toast({ title: "Subject Created" });
- setShowAddSubject(false);
- setNewSubjectName("");
- setNewSubjectCode("");
- },
+ onSuccess: () => { invalidate(); toast({ title: "Subject Created" }); setShowAddSubject(false); setNewSubjectName(""); setNewSubjectCode(""); },
onError: () => toast({ title: "Error", description: "Failed to create subject", variant: "destructive" }),
});
- const aiSuggestMutation = useMutation({
- mutationFn: (domainId: number) => taxonomyService.aiSuggestTopics(domainId),
- onSuccess: (data) => {
- toast({ title: "AI Suggestions", description: `${data.suggestions.length} topics suggested. Review and add them.` });
- },
- onError: () => toast({ title: "Error", description: "AI suggestion failed", variant: "destructive" }),
- });
-
- const createDomainMutation = useMutation({
- mutationFn: (data: Partial) => taxonomyService.createDomain(data),
- onSuccess: () => { qc.invalidateQueries({ queryKey: ["taxonomy"] }); setShowAddDomain(false); setNewDomainName(""); toast({ title: "Domain created" }); },
- onError: () => toast({ title: "Error", description: "Failed to create domain", variant: "destructive" }),
- });
-
- const deleteDomainMutation = useMutation({
- mutationFn: (id: number) => taxonomyService.deleteDomain(id),
- onSuccess: () => { qc.invalidateQueries({ queryKey: ["taxonomy"] }); toast({ title: "Domain deleted" }); },
- onError: () => toast({ title: "Error", description: "Failed to delete domain", variant: "destructive" }),
- });
-
- const createTopicMutation = useMutation({
- mutationFn: (data: Partial) => taxonomyService.createTopic(data),
- onSuccess: () => { qc.invalidateQueries({ queryKey: ["taxonomy"] }); setShowAddTopic(null); setNewTopicName(""); toast({ title: "Topic created" }); },
- onError: () => toast({ title: "Error", description: "Failed to create topic", variant: "destructive" }),
- });
-
- const deleteTopicMutation = useMutation({
- mutationFn: (id: number) => taxonomyService.deleteTopic(id),
- onSuccess: () => { qc.invalidateQueries({ queryKey: ["taxonomy"] }); toast({ title: "Topic deleted" }); },
- onError: () => toast({ title: "Error", description: "Failed to delete topic", variant: "destructive" }),
+ const updateSubjectMutation = useMutation({
+ mutationFn: ({ id, data }: { id: number; data: Partial<{ name: string; code: string }> }) => taxonomyService.updateSubject(id, data),
+ onSuccess: () => { invalidate(); toast({ title: "Subject updated" }); setEditingSubjectId(null); },
+ onError: () => toast({ title: "Error", description: "Failed to update subject", variant: "destructive" }),
});
const deleteSubjectMutation = useMutation({
mutationFn: (id: number) => taxonomyService.deleteSubject(id),
- onSuccess: () => { qc.invalidateQueries({ queryKey: ["taxonomy"] }); if (selectedSubjectId === id) setSelectedSubjectId(null); toast({ title: "Subject deleted" }); },
+ onSuccess: (_d, id) => { invalidate(); if (selectedSubjectId === id) setSelectedSubjectId(null); toast({ title: "Subject deleted" }); },
onError: () => toast({ title: "Error", description: "Failed to delete subject", variant: "destructive" }),
});
+ const createDomainMutation = useMutation({
+ mutationFn: (data: Partial) => taxonomyService.createDomain(data),
+ onSuccess: () => { invalidate(); setShowAddDomain(false); setNewDomainName(""); toast({ title: "Domain created" }); },
+ onError: () => toast({ title: "Error", description: "Failed to create domain", variant: "destructive" }),
+ });
+
+ const updateDomainMutation = useMutation({
+ mutationFn: ({ id, data }: { id: number; data: Partial }) => taxonomyService.updateDomain(id, data),
+ onSuccess: () => { invalidate(); toast({ title: "Domain updated" }); setEditingDomainId(null); },
+ onError: () => toast({ title: "Error", description: "Failed to update domain", variant: "destructive" }),
+ });
+
+ const deleteDomainMutation = useMutation({
+ mutationFn: (id: number) => taxonomyService.deleteDomain(id),
+ onSuccess: () => { invalidate(); toast({ title: "Domain deleted" }); },
+ onError: () => toast({ title: "Error", description: "Failed to delete domain", variant: "destructive" }),
+ });
+
+ const aiSuggestMutation = useMutation({
+ mutationFn: (domainId: number) => taxonomyService.aiSuggestTopics(domainId),
+ onSuccess: (data) => toast({ title: "AI Suggestions", description: `${data.suggestions.length} topics suggested.` }),
+ onError: () => toast({ title: "Error", description: "AI suggestion failed", variant: "destructive" }),
+ });
+
+ const createTopicMutation = useMutation({
+ mutationFn: (data: Partial) => taxonomyService.createTopic(data),
+ onSuccess: () => { invalidate(); setShowAddTopic(null); setNewTopicName(""); toast({ title: "Topic created" }); },
+ onError: () => toast({ title: "Error", description: "Failed to create topic", variant: "destructive" }),
+ });
+
+ const updateTopicMutation = useMutation({
+ mutationFn: ({ id, data }: { id: number; data: Partial }) => taxonomyService.updateTopic(id, data),
+ onSuccess: () => { invalidate(); toast({ title: "Topic updated" }); setEditingTopicId(null); },
+ onError: () => toast({ title: "Error", description: "Failed to update topic", variant: "destructive" }),
+ });
+
+ const deleteTopicMutation = useMutation({
+ mutationFn: (id: number) => taxonomyService.deleteTopic(id),
+ onSuccess: () => { invalidate(); toast({ title: "Topic deleted" }); },
+ onError: () => toast({ title: "Error", description: "Failed to delete topic", variant: "destructive" }),
+ });
+
+ const startEditSubject = (s: { id: number; name: string; code: string }) => {
+ setEditingSubjectId(s.id);
+ setEditSubjectName(s.name);
+ setEditSubjectCode(s.code);
+ };
+
+ const startEditDomain = (d: { id: number; name: string }) => {
+ setEditingDomainId(d.id);
+ setEditDomainName(d.name);
+ };
+
+ const startEditTopic = (t: { id: number; name: string; difficulty_level?: string; estimated_hours?: number; description?: string }) => {
+ setEditingTopicId(t.id);
+ setEditTopicName(t.name);
+ setEditTopicDifficulty(t.difficulty_level ?? "medium");
+ setEditTopicHours(String(t.estimated_hours ?? 1));
+ setEditTopicDesc(t.description ?? "");
+ };
+
if (isLoading) return ;
return (
@@ -115,25 +181,59 @@ export default function TaxonomyManager() {
+ {/* Subjects sidebar */}
Subjects
{subjects.map(s => (
-
-
setSelectedSubjectId(s.id)} className="flex items-center gap-2 flex-1 min-w-0 text-left">
-
-
-
{s.name}
-
{s.code} · {s.domain_count ?? 0} domains · {s.topic_count ?? 0} topics
+
+ {editingSubjectId === s.id ? (
+
-
-
{ if (!window.confirm(`Delete subject "${s.name}"?`)) return; deleteSubjectMutation.mutate(s.id); }}>
-
-
+ ) : (
+ <>
+
+
setSelectedSubjectId(s.id)} className="flex items-center gap-2 flex-1 min-w-0 text-left">
+
+
+
{s.name}
+
{s.code} · {s.domain_count ?? 0} domains · {s.topic_count ?? 0} topics
+
+
+
+
startEditSubject(s)}>
+
+
+
{ if (!window.confirm(`Delete subject "${s.name}"?`)) return; deleteSubjectMutation.mutate(s.id); }}>
+
+
+
+
+
+ navigate("/admin/courses")} />
+ navigate("/admin/resources")} />
+
+ >
+ )}
))}
{subjects.length === 0 &&
No subjects yet.
}
+ {/* Domain + Topic tree */}
{!selectedSubjectId ? (
Select a subject to view its taxonomy tree.
@@ -141,41 +241,98 @@ export default function TaxonomyManager() {
) : tree ? (
-
-
setShowAddDomain(true)}>
- Add Domain
-
-
+ {/* Relationship summary for selected subject */}
+ {(() => {
+ const sel = subjects.find(s => s.id === selectedSubjectId);
+ if (!sel) return null;
+ return (
+
+
+
+
+
Relationships:
+
navigate("/admin/courses")} className="flex items-center gap-1.5 hover:underline">
+
+ {sel.course_count ?? 0}
+ Courses
+
+
·
+
navigate("/admin/resources")} className="flex items-center gap-1.5 hover:underline">
+
+ {sel.resource_count ?? 0}
+ Resources
+
+
·
+
+
+ {sel.domain_count ?? 0}
+ Domains
+
+
·
+
+
+ {sel.topic_count ?? 0}
+ Topics
+
+
+
setShowAddDomain(true)}>
+ Add Domain
+
+
+
+
+ );
+ })()}
{(Array.isArray(tree.domains) ? tree.domains : []).map(domain => (
-
-
-
-
- {domain.name}
- {(Array.isArray(domain.topics) ? domain.topics : []).length} topics
-
- e.stopPropagation()}>
+
+
+
+
+
+ {editingDomainId === domain.id ? (
+
e.stopPropagation()}>
+ setEditDomainName(e.target.value)} className="h-7 text-sm w-48" />
+ updateDomainMutation.mutate({ id: domain.id, data: { name: editDomainName } })}>
+
+
+ setEditingDomainId(null)}>
+
+
+
+ ) : (
+
{domain.name}
+ )}
+
{(Array.isArray(domain.topics) ? domain.topics : []).length} topics
+
+
+
aiSuggestMutation.mutate(domain.id)} disabled={aiSuggestMutation.isPending}>
AI Suggest
{ setShowAddTopic(domain.id); setNewTopicName(""); }}>
Topic
+ {editingDomainId !== domain.id && (
+
startEditDomain(domain)}>
+
+
+ )}
{ if (!window.confirm(`Delete domain "${domain.name}"?`)) return; deleteDomainMutation.mutate(domain.id); }}>
-
+
{showAddTopic === domain.id && (
- setNewTopicName(e.target.value)} className="h-8 text-sm" />
+ setNewTopicName(e.target.value)} className="h-8 text-sm" />
@@ -184,28 +341,62 @@ export default function TaxonomyManager() {
Hard
- setNewTopicHours(e.target.value)} className="w-16 h-8 text-sm" />
+ setNewTopicHours(e.target.value)} className="w-16 h-8 text-sm" />
createTopicMutation.mutate({ name: newTopicName, domain_id: domain.id, difficulty_level: newTopicDifficulty, estimated_hours: Number(newTopicHours) || 1 })}>Add
setShowAddTopic(null)}>
)}
{(Array.isArray(domain.topics) ? domain.topics : []).map(topic => (
-
-
-
- {topic.name}
- {topic.difficulty_level}
- {(Array.isArray(topic.objectives) ? topic.objectives : []).length} objectives
-
-
- ~{topic.estimated_hours}h
- { if (!window.confirm(`Delete topic "${topic.name}"?`)) return; deleteTopicMutation.mutate(topic.id); }}>
-
-
-
+
+ {editingTopicId === topic.id ? (
+
+ ) : (
+
+
+
+ {topic.name}
+ {topic.difficulty_level}
+ {(Array.isArray(topic.objectives) ? topic.objectives : []).length} objectives
+ navigate("/admin/resources")} />
+ navigate("/admin/courses")} />
+
+
+
~{topic.estimated_hours}h
+
startEditTopic(topic)}>
+
+
+
{ if (!window.confirm(`Delete topic "${topic.name}"?`)) return; deleteTopicMutation.mutate(topic.id); }}>
+
+
+
+
+ )}
))}
- {(Array.isArray(domain.topics) ? domain.topics : []).length === 0 && !showAddTopic &&
No topics in this domain yet.
}
+ {(Array.isArray(domain.topics) ? domain.topics : []).length === 0 && showAddTopic !== domain.id &&
No topics in this domain yet.
}
@@ -223,7 +414,7 @@ export default function TaxonomyManager() {
Add Domain
Domain Name
- setNewDomainName(e.target.value)} />
+ setNewDomainName(e.target.value)} />
setShowAddDomain(false)}>Cancel
diff --git a/src/pages/student/StudentAssignments.tsx b/src/pages/student/StudentAssignments.tsx
index b4661a2..104c98a 100644
--- a/src/pages/student/StudentAssignments.tsx
+++ b/src/pages/student/StudentAssignments.tsx
@@ -5,33 +5,38 @@ import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
-import { useAssignments } from "@/hooks/queries";
-import { Upload } from "lucide-react";
+import { useCourseAssignments } from "@/hooks/queries";
+import { Upload, ClipboardList } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import AiWritingHelper from "@/components/ai/AiWritingHelper";
import AiTipBanner from "@/components/ai/AiTipBanner";
+import type { CourseAssignment } from "@/types";
export default function StudentAssignments() {
const [submitOpen, setSubmitOpen] = useState(false);
- const [selectedAssignment, setSelectedAssignment] = useState
(null);
+ const [selectedAssignment, setSelectedAssignment] = useState(null);
const [draftText, setDraftText] = useState("");
const { toast } = useToast();
- const { data: assignmentsData, isLoading } = useAssignments();
- const assignments = assignmentsData?.items ?? [];
+ const { data: assignmentsData, isLoading } = useCourseAssignments();
+ const assignments: CourseAssignment[] = assignmentsData?.items ?? [];
if (isLoading) return ;
const handleSubmit = () => {
setSubmitOpen(false);
+ setSelectedAssignment(null);
toast({ title: "Assignment Submitted", description: "Your work has been submitted successfully." });
};
- const statusFilter = (status: string) => assignments.filter(a => status === "all" ? true : a.status === status);
+ const stateVariant = (s: string) => s === "finish" ? "default" : s === "cancel" ? "destructive" : "secondary";
+ const stateLabel = (s: string) => s === "publish" ? "Active" : s === "finish" ? "Completed" : s === "cancel" ? "Cancelled" : "Draft";
+
+ const stateFilter = (state: string) => assignments.filter(a => state === "all" ? true : a.state === state);
return (
Assignments
-
View and submit your assignments.
+
View and submit your course assignments.
@@ -39,26 +44,33 @@ export default function StudentAssignments() {
All ({assignments.length})
- Pending ({statusFilter("pending").length})
- Submitted ({statusFilter("submitted").length})
- Graded ({statusFilter("graded").length})
+ Active ({stateFilter("publish").length})
+ Completed ({stateFilter("finish").length})
- {["all", "pending", "submitted", "graded", "overdue"].map(tab => (
+ {["all", "publish", "finish"].map(tab => (
- {statusFilter(tab).map(a => (
+ {stateFilter(tab).length === 0 ? (
+
+
+
No assignments found.
+
+ ) : stateFilter(tab).map(a => (
-
{a.title}
-
{a.courseName} · Due: {a.dueDate}
- {a.grade !== undefined &&
Grade: {a.grade}/{a.maxGrade}
}
- {a.feedback &&
"{a.feedback}"
}
+
{a.name}
+
+ {a.course_name} · Due: {a.submission_date}
+
+
+ Marks: {a.marks} · {a.assignment_type_name}
+
- {a.status}
- {a.status === "pending" && (
+ {stateLabel(a.state)}
+ {a.state === "publish" && (
{ setSelectedAssignment(a.id); setSubmitOpen(true); }}>Submit
)}
diff --git a/src/pages/student/StudentChapterView.tsx b/src/pages/student/StudentChapterView.tsx
index 1555553..9684bfe 100644
--- a/src/pages/student/StudentChapterView.tsx
+++ b/src/pages/student/StudentChapterView.tsx
@@ -1,13 +1,15 @@
+import { useState } from "react";
import { useParams } from "react-router-dom";
-import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
-import { FileText, Video, Image, Link2, Music, Download, CheckCircle2, Loader2 } from "lucide-react";
+import { FileText, Video, Image, Link2, Music, Download, CheckCircle2, Loader2, Eye } from "lucide-react";
import { useChapter, useChapterMaterials, useChapterProgress, useCompleteChapter, useMarkMaterialViewed } from "@/hooks/queries";
import { coursewareService } from "@/services/courseware.service";
import { useToast } from "@/hooks/use-toast";
-import type { MaterialType } from "@/types/courseware";
+import MaterialViewer from "@/components/MaterialViewer";
+import type { ChapterMaterial, MaterialType } from "@/types/courseware";
const typeIcons: Record
= {
pdf: ,
@@ -29,6 +31,9 @@ export default function StudentChapterView() {
const completeChapter = useCompleteChapter();
const markViewed = useMarkMaterialViewed();
+ const [activeMaterial, setActiveMaterial] = useState(null);
+ const [viewerOpen, setViewerOpen] = useState(false);
+
const completionPct = progress ? Math.round((progress.materials_completed / Math.max(progress.materials_total, 1)) * 100) : 0;
const handleDownload = async (materialId: number, name: string) => {
@@ -46,14 +51,16 @@ export default function StudentChapterView() {
};
const handleMarkComplete = () => {
- completeChapter.mutate(chId, {
+ completeChapter.mutate({ chapterId: chId, courseId: Number(courseId) }, {
onSuccess: () => toast({ title: "Chapter Completed!" }),
onError: () => toast({ title: "Error", description: "Failed to mark complete", variant: "destructive" }),
});
};
- const handleOpenMaterial = (materialId: number) => {
- markViewed.mutate({ id: materialId, chapterId: chId });
+ const handleOpenMaterial = (material: ChapterMaterial) => {
+ markViewed.mutate({ id: material.id, chapterId: chId });
+ setActiveMaterial(material);
+ setViewerOpen(true);
};
if (lc || lm || lp) return ;
@@ -95,7 +102,7 @@ export default function StudentChapterView() {
{materials
.sort((a, b) => a.sequence - b.sequence)
.map(m => (
- handleOpenMaterial(m.id)}>
+ handleOpenMaterial(m)}>
@@ -103,13 +110,23 @@ export default function StudentChapterView() {
{m.name}
-
{m.type}
+
+ {m.type}
+
+ {m.description && (
+
{m.description}
+ )}
- {m.allow_download && (
-
{ e.stopPropagation(); handleDownload(m.id, m.name); }}>
-
+
+ { e.stopPropagation(); handleOpenMaterial(m); }}>
+
- )}
+ {m.allow_download && (
+ { e.stopPropagation(); handleDownload(m.id, m.name); }}>
+
+
+ )}
+
@@ -121,6 +138,13 @@ export default function StudentChapterView() {
)}
+
+
);
}
diff --git a/src/pages/student/StudentCourseDetail.tsx b/src/pages/student/StudentCourseDetail.tsx
index bc6aed5..8cae969 100644
--- a/src/pages/student/StudentCourseDetail.tsx
+++ b/src/pages/student/StudentCourseDetail.tsx
@@ -4,30 +4,26 @@ import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
-import { useCourses, useAssignments, useAttendance, useGrades } from "@/hooks/queries";
-import { ArrowLeft, CheckCircle, Circle, FileText, Video, HelpCircle } from "lucide-react";
-
-const iconMap = { video: Video, reading: FileText, quiz: HelpCircle };
+import { useCourse, useChapters, useGrades, useCourseCompletion } from "@/hooks/queries";
+import { ArrowLeft, BookOpen, Lock, ChevronRight, Play, CheckCircle2, Trophy } from "lucide-react";
export default function StudentCourseDetail() {
const { id } = useParams();
- const { data: coursesData, isLoading: lc } = useCourses();
- const { data: assignmentsData, isLoading: la } = useAssignments();
- const { data: attendanceData, isLoading: lat } = useAttendance();
- const { data: gradesData, isLoading: lg } = useGrades();
- const courses = coursesData?.items ?? [];
- const assignments = assignmentsData?.items ?? [];
- const attendanceRecords = attendanceData ?? [];
+ const courseId = Number(id);
+ const { data: course, isLoading: lc } = useCourse(courseId);
+ const { data: chapters = [], isLoading: lch } = useChapters(courseId);
+ const { data: gradesData, isLoading: lg } = useGrades({ course_id: courseId });
+ const { data: completion } = useCourseCompletion(courseId);
const gradeRecords = gradesData ?? [];
- if (lc || la || lat || lg) return
;
-
- const course = courses.find(c => c.id === id);
+ if (lc || lch || lg) return
;
if (!course) return
Course not found.
;
- const courseAssignments = assignments.filter(a => a.courseId === id);
- const courseAttendance = attendanceRecords.filter(a => a.courseId === id);
- const courseGrades = gradeRecords.filter(g => g.courseId === id);
+ const sortedChapters = [...chapters].sort((a, b) => a.sequence - b.sequence);
+ const completedChapters = completion?.chapters_completed ?? 0;
+ const progress = completion?.progress_percent ?? (chapters.length > 0 ? Math.round((completedChapters / chapters.length) * 100) : 0);
+ const isCompleted = completion?.status === "completed";
+ const postTestAvailable = completion?.post_test_available ?? false;
return (
@@ -35,91 +31,116 @@ export default function StudentCourseDetail() {
{course.title}
-
{course.instructor} · {course.code}
+
{course.code} · {chapters.length} chapters
+ {isCompleted && (
+
+
+
+
+
+
Course Completed!
+
You've finished all {chapters.length} chapters.
+
+
+ {postTestAvailable && (
+
+ Take Post-Test
+
+ )}
+
+
+ )}
+
Overall Progress
- {course.progress}%
+ {progress}%
-
+
+ {completedChapters} of {chapters.length} chapters completed
-
+
- Materials
- Assignments ({courseAssignments.length})
- Attendance
- Grades
+ Chapters ({chapters.length})
+ Grades ({gradeRecords.length})
+ About
-
- {course.modules.length === 0 ? (
- No modules available yet.
- ) : course.modules.map(mod => (
-
-
- {mod.title}
-
-
- {mod.lessons.map(lesson => {
- const Icon = iconMap[lesson.type];
- return (
-
- {lesson.completed ? : }
-
- {lesson.title}
- {lesson.duration}
-
- );
- })}
-
-
- ))}
-
-
-
- {courseAssignments.map(a => (
-
-
-
-
{a.title}
-
Due: {a.dueDate}
-
-
- {a.status}
-
-
-
- ))}
-
-
-
- {courseAttendance.map(a => (
-
-
{a.date}
-
- {a.status}
-
+
+ {sortedChapters.length === 0 ? (
+
+
+
No chapters available yet.
+ ) : sortedChapters.map((ch, idx) => (
+
+
+
+
+ {idx + 1}
+
+
+
+
{ch.name}
+ {!ch.is_unlocked &&
}
+
+
+ {ch.material_count} materials
+ {ch.description ? ` · ${ch.description.slice(0, 60)}${ch.description.length > 60 ? "..." : ""}` : ""}
+
+
+
+ {ch.is_unlocked ? (
+
+ Open
+
+ ) : (
+
Locked
+ )}
+
+
+
+
+
))}
- {courseGrades.map(g => (
+ {gradeRecords.length === 0 ? (
+ No grades yet for this course.
+ ) : gradeRecords.map(g => (
-
{g.assignmentTitle}
+
{g.assignment_title}
{g.date}
-
{g.grade}/{g.maxGrade}
+
{g.grade}/{g.max_grade}
))}
+
+
+
+ About this Course
+
+ {course.description || "No description available."}
+
+
Enrolled Students: {course.enrolled}
+
Max Capacity: {course.max_capacity}
+
+
+
+
);
diff --git a/src/pages/student/StudentCourses.tsx b/src/pages/student/StudentCourses.tsx
index 66196ce..387b2ae 100644
--- a/src/pages/student/StudentCourses.tsx
+++ b/src/pages/student/StudentCourses.tsx
@@ -1,18 +1,17 @@
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
+import { Button } from "@/components/ui/button";
import { Link } from "react-router-dom";
-import { useCourses } from "@/hooks/queries";
-import { BookOpen, Users, Clock } from "lucide-react";
+import { useMyEnrolledCourses } from "@/hooks/queries";
+import { BookOpen, Users, Play } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
export default function StudentCourses() {
- const { data: coursesData, isLoading } = useCourses();
- const courses = coursesData?.items ?? [];
+ const { data: enrolledData, isLoading } = useMyEnrolledCourses();
+ const myCourses = enrolledData?.items ?? [];
if (isLoading) return ;
- const myCourses = courses.filter(c => ["c1", "c2", "c5", "c8"].includes(c.id));
-
return (
@@ -22,37 +21,50 @@ export default function StudentCourses() {
-
- {myCourses.map((c) => (
-
-
-
-
-
-
-
{c.level}
-
{c.code}
+ {myCourses.length === 0 ? (
+
+
+
No Enrolled Courses
+
You haven't been enrolled in any courses yet. Contact your administrator.
+
+ ) : (
+
+ {myCourses.map((c) => (
+
+
+
+
+
+
+
+ {c.progress === 100 ? "Completed" : c.progress > 0 ? "In Progress" : "Not Started"}
+
+ {c.code}
+
+
{c.title || c.name}
+
{c.description}
- {c.title}
- {c.description}
-
-
- {c.modules.length} modules
- {c.enrolled} students
-
-
-
-
Progress
-
{c.progress}%
+
+ {c.chapter_count} chapters
+ {c.student_count} students
-
-
-
{c.instructor}
-
-
-
- ))}
-
+
+
+ Progress
+ {c.progress}%
+
+
+
+
+
+ {c.progress > 0 ? "Continue Learning" : "Start Learning"}
+
+
+
+
+ ))}
+
+ )}
);
}
diff --git a/src/pages/student/StudentDashboard.tsx b/src/pages/student/StudentDashboard.tsx
index fd2dea4..e052600 100644
--- a/src/pages/student/StudentDashboard.tsx
+++ b/src/pages/student/StudentDashboard.tsx
@@ -2,35 +2,39 @@ 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 } from "lucide-react";
+import { BookOpen, ClipboardList, BarChart3, Calendar, ArrowRight, Play } from "lucide-react";
import { Link } from "react-router-dom";
-import { useCourses, useAssignments, useGrades } from "@/hooks/queries";
+import { useMyEnrolledCourses, useGrades } from "@/hooks/queries";
+import { useAuth } from "@/contexts/AuthContext";
import AiStudyCoach from "@/components/ai/AiStudyCoach";
import AiTipBanner from "@/components/ai/AiTipBanner";
export default function StudentDashboard() {
- const { data: coursesData, isLoading: lc } = useCourses();
- const { data: assignmentsData, isLoading: la } = useAssignments();
+ const { user } = useAuth();
+ const { data: enrolledData, isLoading: lc } = useMyEnrolledCourses();
const { data: gradesData, isLoading: lg } = useGrades();
- const courses = coursesData?.items ?? [];
- const assignments = assignmentsData?.items ?? [];
+ const myCourses = enrolledData?.items ?? [];
const gradeRecords = gradesData ?? [];
- if (lc || la || lg) return ;
+ if (lc || lg) return ;
- const myCourses = courses.filter(c => ["c1", "c2", "c5", "c8"].includes(c.id));
- const upcomingAssignments = assignments.filter(a => a.status === "pending").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 avgGrade = gradeRecords.length > 0
+ ? Math.round(gradeRecords.reduce((s, g) => s + (g.grade / g.max_grade) * 100, 0) / gradeRecords.length)
+ : 0;
+ const firstName = user?.name?.split(" ")[0] || "Student";
+
const stats = [
{ label: "Enrolled Courses", value: String(myCourses.length), icon: BookOpen, color: "text-primary" },
- { label: "Pending Assignments", value: String(assignments.filter(a => a.status === "pending").length), icon: ClipboardList, color: "text-warning" },
- { label: "Average Grade", value: "78%", icon: BarChart3, color: "text-success" },
- { label: "Attendance Rate", value: "94%", icon: Calendar, color: "text-info" },
+ { label: "Overall Progress", value: `${avgProgress}%`, icon: ClipboardList, color: "text-warning" },
+ { label: "Average Grade", value: gradeRecords.length > 0 ? `${avgGrade}%` : "N/A", icon: BarChart3, color: "text-success" },
+ { label: "Total Chapters", value: String(myCourses.reduce((s, c) => s + c.chapter_count, 0)), icon: Calendar, color: "text-info" },
];
return (
-
Welcome back, Sarah!
+
Welcome back, {firstName}!
Here's an overview of your learning progress.
@@ -61,12 +65,14 @@ export default function StudentDashboard() {
View All
- {myCourses.map((c) => (
+ {myCourses.length === 0 ? (
+ No enrolled courses yet.
+ ) : myCourses.map((c) => (
-
{c.title}
-
{c.instructor}
+
{c.title || c.name}
+
{c.chapter_count} chapters · {c.total_materials} materials
@@ -81,16 +87,20 @@ export default function StudentDashboard() {
- Upcoming Assignments
- View All
+ Quick Actions
- {upcomingAssignments.map((a) => (
-
+ {myCourses.slice(0, 3).map(c => (
+
+
+
+
{c.progress > 0 ? "Continue" : "Start"} {c.title || c.name}
+
{c.completed_chapters}/{c.chapter_count} chapters done
+
+ {c.progress}%
+
))}
+ {myCourses.length === 0 && Enroll in a course to get started.
}
@@ -100,10 +110,12 @@ export default function StudentDashboard() {
View All
- {recentGrades.map((g) => (
+ {recentGrades.length === 0 ? (
+ No grades yet.
+ ) : recentGrades.map((g) => (
-
{g.assignmentTitle}
{g.courseName}
-
{g.grade}/{g.maxGrade}
+
{g.assignment_title}
{g.course_name}
+
{g.grade}/{g.max_grade}
))}
diff --git a/src/pages/teacher/ChapterDetail.tsx b/src/pages/teacher/ChapterDetail.tsx
index 8d1e42a..5d2888e 100644
--- a/src/pages/teacher/ChapterDetail.tsx
+++ b/src/pages/teacher/ChapterDetail.tsx
@@ -8,11 +8,13 @@ import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
-import { Plus, Trash2, Loader2, FileText, Video, Image, Link2, Upload, Music } from "lucide-react";
-import { useChapter, useChapterMaterials, useUploadMaterial, useDeleteMaterial } from "@/hooks/queries";
+import { Plus, Trash2, Loader2, FileText, Video, Image, Link2, Upload, Music, Library, Search, CheckCircle2 } from "lucide-react";
+import { useChapter, useChapterMaterials, useUploadMaterial, useDeleteMaterial, useCreateMaterialFromResource } from "@/hooks/queries";
import { coursewareService } from "@/services/courseware.service";
-import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { resourcesService } from "@/services/resources.service";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import type { MaterialType } from "@/types/courseware";
@@ -35,15 +37,32 @@ export default function ChapterDetail() {
const { data: materials = [], isLoading: loadingMaterials } = useChapterMaterials(chId);
const uploadMaterial = useUploadMaterial();
const deleteMaterial = useDeleteMaterial();
+ const fromResource = useCreateMaterialFromResource();
const fileRef = useRef
(null);
const [showUpload, setShowUpload] = useState(false);
+ const [dialogTab, setDialogTab] = useState("library");
const [matName, setMatName] = useState("");
const [matType, setMatType] = useState("pdf");
const [matUrl, setMatUrl] = useState("");
const [matFile, setMatFile] = useState(null);
const [allowDownload, setAllowDownload] = useState(true);
+ const [libSearch, setLibSearch] = useState("");
+ const [libTypeFilter, setLibTypeFilter] = useState("all");
+ const [selectedResourceId, setSelectedResourceId] = useState(null);
+
+ const { data: resourcesData, isLoading: loadingResources } = useQuery({
+ queryKey: ["resources", "list", { search: libSearch, resource_type: libTypeFilter === "all" ? undefined : libTypeFilter }],
+ queryFn: () => resourcesService.list({
+ search: libSearch || undefined,
+ resource_type: libTypeFilter === "all" ? undefined : libTypeFilter,
+ limit: 50,
+ }),
+ enabled: showUpload,
+ });
+ const libraryResources = resourcesData?.items ?? [];
+
const toggleDownload = useMutation({
mutationFn: ({ id, allow }: { id: number; allow: boolean }) =>
coursewareService.updateMaterial(id, { allow_download: allow }),
@@ -51,7 +70,11 @@ export default function ChapterDetail() {
onError: () => toast({ title: "Error", description: "Failed to update material", variant: "destructive" }),
});
- const resetForm = () => { setMatName(""); setMatType("pdf"); setMatUrl(""); setMatFile(null); setAllowDownload(true); };
+ const resetForm = () => {
+ setMatName(""); setMatType("pdf"); setMatUrl(""); setMatFile(null);
+ setAllowDownload(true); setSelectedResourceId(null); setLibSearch("");
+ setLibTypeFilter("all"); setDialogTab("library");
+ };
const handleUpload = () => {
const formData = new FormData();
@@ -64,17 +87,28 @@ export default function ChapterDetail() {
uploadMaterial.mutate(
{ chapterId: chId, formData },
{
- onSuccess: () => { toast({ title: "Material Uploaded" }); setShowUpload(false); resetForm(); },
+ onSuccess: () => { toast({ title: "Material uploaded" }); setShowUpload(false); resetForm(); },
onError: () => toast({ title: "Error", description: "Failed to upload material", variant: "destructive" }),
},
);
};
+ const handleAddFromLibrary = () => {
+ if (!selectedResourceId) return;
+ fromResource.mutate(
+ { chapterId: chId, resourceId: selectedResourceId },
+ {
+ onSuccess: () => { toast({ title: "Material added from library" }); setShowUpload(false); resetForm(); },
+ onError: () => toast({ title: "Error", description: "Failed to add material from library", variant: "destructive" }),
+ },
+ );
+ };
+
const handleDelete = (id: number) => {
deleteMaterial.mutate(
{ id, chapterId: chId },
{
- onSuccess: () => toast({ title: "Material Deleted" }),
+ onSuccess: () => toast({ title: "Material deleted" }),
onError: () => toast({ title: "Error", description: "Failed to delete material", variant: "destructive" }),
},
);
@@ -105,49 +139,139 @@ export default function ChapterDetail() {
Materials
-
+ { setShowUpload(open); if (!open) resetForm(); }}>
- Upload Material
+ Add Material
-
- Upload Material
-
-
- Name
- setMatName(e.target.value)} />
-
-
- Type
- setMatType(v as MaterialType)}>
-
-
- PDF
- Document
- Video
- Audio
- Image
- Link
- Article
-
-
-
-
- File
- setMatFile(e.target.files?.[0] ?? null)} />
-
-
- Or URL
- setMatUrl(e.target.value)} />
-
-
- setAllowDownload(!!v)} />
- Allow Download
-
-
- {uploadMaterial.isPending && }
- Upload
-
-
+
+ Add Material
+
+
+
+ From Library
+
+
+ Upload Manual
+
+
+
+ {/* Tab: From Library */}
+
+
+
+
+ setLibSearch(e.target.value)}
+ className="pl-9"
+ />
+
+
+
+
+ All Types
+ PDF
+ Video
+ Document
+ Link
+
+
+
+
+
+ {loadingResources ? (
+
+
+
+ ) : libraryResources.length === 0 ? (
+
+ No resources found. Try a different search or upload manually.
+
+ ) : (
+
+ {libraryResources.map(r => {
+ const selected = selectedResourceId === r.id;
+ return (
+
setSelectedResourceId(selected ? null : r.id)}
+ className={`w-full text-left px-4 py-3 flex items-center gap-3 transition-colors hover:bg-muted/50 ${
+ selected ? "bg-primary/10 ring-1 ring-primary/30" : ""
+ }`}
+ >
+
+ {selected ? (
+
+ ) : (
+ typeIcons[r.resource_type as MaterialType] ??
+ )}
+
+
+
{r.name}
+
+ {r.resource_type} {r.topic_names?.length ? `· ${r.topic_names.slice(0, 2).join(", ")}` : ""}
+
+
+ {r.resource_type}
+
+ );
+ })}
+
+ )}
+
+
+
+ {fromResource.isPending && }
+
+ Add Selected Resource
+
+
+
+ {/* Tab: Manual Upload */}
+
+
+ Name
+ setMatName(e.target.value)} />
+
+
+ Type
+ setMatType(v as MaterialType)}>
+
+
+ PDF
+ Document
+ Video
+ Audio
+ Image
+ Link
+ Article
+
+
+
+
+ File
+ setMatFile(e.target.files?.[0] ?? null)} />
+
+
+ Or URL
+ setMatUrl(e.target.value)} />
+
+
+ setAllowDownload(!!v)} />
+ Allow Download
+
+
+ {uploadMaterial.isPending && }
+ Upload
+
+
+
@@ -189,7 +313,7 @@ export default function ChapterDetail() {
))}
{materials.length === 0 && (
- No materials uploaded yet.
+ No materials added yet.
)}
diff --git a/src/pages/teacher/CourseChapters.tsx b/src/pages/teacher/CourseChapters.tsx
index 4834e11..984885c 100644
--- a/src/pages/teacher/CourseChapters.tsx
+++ b/src/pages/teacher/CourseChapters.tsx
@@ -1,23 +1,28 @@
import { useState } from "react";
-import { useParams } from "react-router-dom";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { useParams, useNavigate } from "react-router-dom";
+import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
+import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
-import { Plus, GripVertical, Lock, Unlock, Trash2, Loader2, BookOpen } from "lucide-react";
-import { useChapters, useCreateChapter, useDeleteChapter, useUnlockChapter, useLockChapter } from "@/hooks/queries";
+import { Plus, GripVertical, Lock, Unlock, Trash2, Loader2, BookOpen, Target } from "lucide-react";
+import { useChapters, useCreateChapter, useDeleteChapter, useUnlockChapter, useLockChapter, useCourse } from "@/hooks/queries";
+import { useQuery } from "@tanstack/react-query";
+import { taxonomyService, lmsService } from "@/services";
import { useToast } from "@/hooks/use-toast";
import type { ChapterUnlockMode } from "@/types/courseware";
export default function CourseChapters() {
const { courseId } = useParams<{ courseId: string }>();
const cid = Number(courseId);
+ const navigate = useNavigate();
const { toast } = useToast();
const { data: chapters = [], isLoading } = useChapters(cid);
+ const { data: course } = useCourse(cid);
const createChapter = useCreateChapter();
const deleteChapter = useDeleteChapter();
const unlockChapter = useUnlockChapter();
@@ -28,12 +33,45 @@ export default function CourseChapters() {
const [description, setDescription] = useState("");
const [startDate, setStartDate] = useState("");
const [unlockMode, setUnlockMode] = useState("manual");
+ const [topicId, setTopicId] = useState(null);
+ const [selectedObjectiveIds, setSelectedObjectiveIds] = useState([]);
- const resetForm = () => { setName(""); setDescription(""); setStartDate(""); setUnlockMode("manual"); };
+ const subjectId = course?.encoach_subject_id;
+
+ const { data: topics = [] } = useQuery({
+ queryKey: ["taxonomy", "topics", subjectId],
+ queryFn: () => subjectId ? taxonomyService.listTopics({ subject_id: subjectId }) : Promise.resolve([]),
+ enabled: !!subjectId,
+ });
+
+ const { data: objectivesData } = useQuery({
+ queryKey: ["lms", "objectives", topicId],
+ queryFn: () => topicId ? lmsService.listLearningObjectives({ topic_id: topicId }) : Promise.resolve({ items: [], total: 0 }),
+ enabled: !!topicId,
+ });
+ const objectives = objectivesData?.items ?? [];
+
+ const selectedTopic = topics.find((t) => t.id === topicId);
+
+ const resetForm = () => {
+ setName(""); setDescription(""); setStartDate(""); setUnlockMode("manual");
+ setTopicId(null); setSelectedObjectiveIds([]);
+ };
const handleCreate = () => {
createChapter.mutate(
- { courseId: cid, data: { name, course_id: cid, description, start_date: startDate || undefined, unlock_mode: unlockMode } },
+ {
+ courseId: cid,
+ data: {
+ name,
+ course_id: cid,
+ description,
+ start_date: startDate || undefined,
+ unlock_mode: unlockMode,
+ topic_id: topicId ?? undefined,
+ learning_objective_ids: selectedObjectiveIds.length > 0 ? selectedObjectiveIds : undefined,
+ },
+ },
{
onSuccess: () => { toast({ title: "Chapter Created" }); setShowAdd(false); resetForm(); },
onError: () => toast({ title: "Error", description: "Failed to create chapter", variant: "destructive" }),
@@ -75,7 +113,7 @@ export default function CourseChapters() {
Add Chapter
-
+
Add New Chapter
@@ -86,21 +124,81 @@ export default function CourseChapters() {
Description
-
- Start Date
- setStartDate(e.target.value)} />
-
-
-
Unlock Mode
-
setUnlockMode(v as ChapterUnlockMode)}>
-
-
- Manual
- Auto (Date)
- Prerequisite
-
-
+
+
+ Start Date
+ setStartDate(e.target.value)} />
+
+
+ Unlock Mode
+ setUnlockMode(v as ChapterUnlockMode)}>
+
+
+ Manual
+ Auto (Date)
+ Prerequisite
+
+
+
+
+ {topics.length > 0 && (
+
+
Topic
+
{
+ setTopicId(v === "none" ? null : Number(v));
+ setSelectedObjectiveIds([]);
+ }}
+ >
+
+
+ None
+ {topics.map((t) => (
+ {t.name}
+ ))}
+
+
+ {selectedTopic && (
+
+ Domain: {selectedTopic.domain_name}
+
+ )}
+
+ )}
+
+ {topicId && objectives.length > 0 && (
+
+
+
+ Learning Objectives ({selectedObjectiveIds.length} selected)
+
+
+ {objectives.map((o) => (
+
+
+ setSelectedObjectiveIds((prev) =>
+ prev.includes(o.id)
+ ? prev.filter((x) => x !== o.id)
+ : [...prev, o.id]
+ )
+ }
+ />
+ {o.name}
+ {o.bloom_level && (
+
+ {o.bloom_level}
+
+ )}
+
+ ))}
+
+
+ )}
+
{createChapter.isPending && }
Create Chapter
@@ -114,17 +212,29 @@ export default function CourseChapters() {
{chapters
.sort((a, b) => a.sequence - b.sequence)
.map(ch => (
-
+ navigate(`/teacher/courses/${cid}/chapters/${ch.id}`)}>
-
+ e.stopPropagation()} />
{ch.name}
+ {ch.topic_name && (
+ {ch.topic_name}
+ )}
+ {ch.domain_name && (
+ ({ch.domain_name})
+ )}
{ch.start_date && {new Date(ch.start_date).toLocaleDateString()} }
{ch.material_count} material{ch.material_count !== 1 ? "s" : ""}
+ {(ch.learning_objective_ids?.length ?? 0) > 0 && (
+
+
+ {ch.learning_objective_ids?.length} objective{(ch.learning_objective_ids?.length ?? 0) !== 1 ? "s" : ""}
+
+ )}
@@ -133,7 +243,7 @@ export default function CourseChapters() {
handleToggleLock(ch.id, ch.is_unlocked)}
+ onClick={(e) => { e.stopPropagation(); handleToggleLock(ch.id, ch.is_unlocked); }}
disabled={unlockChapter.isPending || lockChapter.isPending}
>
{ch.is_unlocked ? : }
@@ -141,7 +251,7 @@ export default function CourseChapters() {
handleDelete(ch.id)}
+ onClick={(e) => { e.stopPropagation(); handleDelete(ch.id); }}
disabled={deleteChapter.isPending}
>
diff --git a/src/pages/teacher/TeacherAssignments.tsx b/src/pages/teacher/TeacherAssignments.tsx
index 0c90441..3937ddc 100644
--- a/src/pages/teacher/TeacherAssignments.tsx
+++ b/src/pages/teacher/TeacherAssignments.tsx
@@ -1,22 +1,22 @@
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { Button } from "@/components/ui/button";
-import { useAssignments } from "@/hooks/queries";
-import { Link } from "react-router-dom";
+import { useCourseAssignments } from "@/hooks/queries";
+import { ClipboardList } from "lucide-react";
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
import AiGeneratorModal from "@/components/ai/AiGeneratorModal";
import AiTipBanner from "@/components/ai/AiTipBanner";
+import type { CourseAssignment } from "@/types";
export default function TeacherAssignments() {
- const { data: assignmentsData, isLoading } = useAssignments();
- const assignments = assignmentsData?.items ?? [];
- const submissions: unknown[] = [];
+ const { data: assignmentsData, isLoading } = useCourseAssignments();
+ const assignments: CourseAssignment[] = assignmentsData?.items ?? [];
if (isLoading) return ;
- const teacherAssignments = assignments;
- const pendingSubs = (submissions as { id: string; studentName: string; submittedAt: string; status: string; feedback?: string; grade?: number }[]).filter(s => s.status === "pending");
- const gradedSubs = (submissions as { id: string; studentName: string; submittedAt: string; status: string; feedback?: string; grade?: number }[]).filter(s => s.status === "graded");
+ const stateVariant = (s: string) => s === "finish" ? "default" : s === "cancel" ? "destructive" : "secondary";
+ const stateLabel = (s: string) => s === "publish" ? "Active" : s === "finish" ? "Completed" : s === "cancel" ? "Cancelled" : "Draft";
+
+ const stateFilter = (state: string) => assignments.filter(a => state === "all" ? true : a.state === state);
return (
@@ -35,48 +35,40 @@ export default function TeacherAssignments() {
- All ({teacherAssignments.length})
- Pending Review ({pendingSubs.length})
- Graded ({gradedSubs.length})
+ All ({assignments.length})
+ Active ({stateFilter("publish").length})
+ Completed ({stateFilter("finish").length})
+ Drafts ({stateFilter("draft").length})
-
- {teacherAssignments.map(a => (
-
-
+ {["all", "publish", "finish", "draft"].map(tab => (
+
+ {stateFilter(tab).length === 0 ? (
+
+
+
No assignments found.
+
+ ) : stateFilter(tab).map(a => (
+
- {a.title}
{a.entity_name} · Due: {a.end_date}
-
-
{a.state}
-
= a.assignee_count && a.assignee_count > 0 ? "default" : "secondary"} className="capitalize">{a.state}
+
+
{a.name}
+
+ {a.course_name} · {a.batch_name} · Due: {a.submission_date}
+
+
+ {a.submission_count}/{a.allocation_count} submissions · Marks: {a.marks}
+
+
+
+ {a.assignment_type_name}
+ {stateLabel(a.state)}
-
- ))}
-
-
-
- {pendingSubs.map(s => (
-
-
- {s.studentName}
Submitted: {s.submittedAt}
- Pending
-
-
- ))}
-
-
-
- {gradedSubs.map(s => (
-
-
- {s.studentName}
{s.feedback}
- {s.grade}
-
-
- ))}
-
+ ))}
+
+ ))}
);
diff --git a/src/pages/teacher/TeacherCourses.tsx b/src/pages/teacher/TeacherCourses.tsx
index 96607da..5ba1b2b 100644
--- a/src/pages/teacher/TeacherCourses.tsx
+++ b/src/pages/teacher/TeacherCourses.tsx
@@ -1,16 +1,95 @@
+import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { useCourses } from "@/hooks/queries";
-import { Link } from "react-router-dom";
-import { Plus, Users, BookOpen, Archive } from "lucide-react";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
+import { useCourses, useChapters } from "@/hooks/queries";
+import { lmsService } from "@/services";
+import { useQueryClient } from "@tanstack/react-query";
+import { Link, useNavigate } from "react-router-dom";
+import { Plus, Users, BookOpen, Pencil, FolderOpen, Wand2 } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
+import type { Course } from "@/types";
+
+function CourseCard({ course }: { course: Course }) {
+ const [editOpen, setEditOpen] = useState(false);
+ const [form, setForm] = useState({ title: course.title, code: course.code, description: course.description, max_capacity: course.max_capacity });
+ const { data: chapters = [] } = useChapters(course.id);
+ const qc = useQueryClient();
+ const { toast } = useToast();
+ const navigate = useNavigate();
+ const [saving, setSaving] = useState(false);
+
+ async function handleSave() {
+ setSaving(true);
+ try {
+ await lmsService.updateCourse(course.id, form);
+ qc.invalidateQueries({ queryKey: ["lms", "courses"] });
+ toast({ title: "Course updated" });
+ setEditOpen(false);
+ } catch (e: unknown) {
+ toast({ title: "Error", description: e instanceof Error ? e.message : String(e), variant: "destructive" });
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ const totalMaterials = chapters.reduce((s, ch) => s + (ch.material_count || 0), 0);
+
+ return (
+ <>
+
+
+
+
+ {course.status}
+ {course.code}
+
+ {course.title}
+ {course.description && {course.description}
}
+
+ {course.enrolled} students
+ {chapters.length} chapters · {totalMaterials} materials
+
+
+
{ setForm({ title: course.title, code: course.code, description: course.description, max_capacity: course.max_capacity }); setEditOpen(true); }}>
+ Edit
+
+
navigate(`/teacher/courses/${course.id}/chapters`)}>
+ Chapters & Materials
+
+
navigate(`/teacher/courses/${course.id}/workbench`)}>
+ AI Workbench
+
+
+
+
+
+
+
+ Edit Course
+
+
Title * setForm(f => ({ ...f, title: e.target.value }))} />
+
Code setForm(f => ({ ...f, code: e.target.value }))} />
+
Description
+
Max Capacity setForm(f => ({ ...f, max_capacity: Number(e.target.value) || 30 }))} />
+
+
+ setEditOpen(false)}>Cancel
+ {saving ? "Saving..." : "Save Changes"}
+
+
+
+ >
+ );
+}
export default function TeacherCourses() {
const { data: coursesData, isLoading } = useCourses();
const courses = coursesData?.items ?? [];
- const teacherCourses = courses;
- const { toast } = useToast();
if (isLoading) return ;
@@ -24,29 +103,18 @@ export default function TeacherCourses() {
New Course
-
- {teacherCourses.map(c => (
-
-
-
-
- {c.status}
- {c.code}
-
- {c.title}
- {c.description}
-
- {c.enrolled} students
- {c.modules.length} modules
-
-
-
Edit
-
toast({ title: "Course archived" })}>
-
-
-
- ))}
-
+ {courses.length === 0 ? (
+
+
+
No Courses Yet
+
Create your first course to get started.
+
Create Course
+
+ ) : (
+
+ {courses.map(c => )}
+
+ )}
);
}
diff --git a/src/pages/teacher/TeacherLibrary.tsx b/src/pages/teacher/TeacherLibrary.tsx
new file mode 100644
index 0000000..d3385a3
--- /dev/null
+++ b/src/pages/teacher/TeacherLibrary.tsx
@@ -0,0 +1,515 @@
+import { useState, useRef } from "react";
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import {
+ Search, Upload, FileText, Video, Link2, Download, Trash2,
+ CheckCircle2, Clock, XCircle, Loader2, BookOpen, Music, Image,
+ Library, Plus, CalendarDays, X,
+} from "lucide-react";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { resourcesService } from "@/services/resources.service";
+import { coursewareService } from "@/services/courseware.service";
+import { taxonomyService } from "@/services/taxonomy.service";
+import { useToast } from "@/hooks/use-toast";
+import { TaxonomyCascade } from "@/components/TaxonomyCascade";
+import type { Resource, ResourceTag } from "@/types";
+
+const statusBadge: Record
= {
+ approved: { variant: "default", icon: },
+ pending: { variant: "secondary", icon: },
+ rejected: { variant: "destructive", icon: },
+};
+
+const typeIcons: Record = {
+ pdf: ,
+ video: ,
+ link: ,
+ document: ,
+ interactive: ,
+ article: ,
+ audio: ,
+ image: ,
+};
+
+interface CourseMaterialItem {
+ id: number;
+ name: string;
+ type: string;
+ course_name: string;
+ chapter_name: string;
+ source: string;
+ file_url: string | null;
+ url: string | null;
+ description: string | null;
+ course_id: number;
+}
+
+export default function TeacherLibrary() {
+ const { toast } = useToast();
+ const qc = useQueryClient();
+
+ const [search, setSearch] = useState("");
+ const [typeFilter, setTypeFilter] = useState("all");
+ const [subjectFilter, setSubjectFilter] = useState("all");
+ const [tagFilter, setTagFilter] = useState("all");
+ const [dateFrom, setDateFrom] = useState("");
+ const [dateTo, setDateTo] = useState("");
+ const [activeTab, setActiveTab] = useState("all");
+
+ const [showUpload, setShowUpload] = useState(false);
+ const [uploadType, setUploadType] = useState("pdf");
+ const [uploadSubjectId, setUploadSubjectId] = useState("none");
+ const [uploadDomainId, setUploadDomainId] = useState("none");
+ const [uploadTopicIds, setUploadTopicIds] = useState([]);
+ const [uploadObjectiveIds, setUploadObjectiveIds] = useState([]);
+ const [uploadTagIds, setUploadTagIds] = useState([]);
+ const fileRef = useRef(null);
+
+ const { data: subjects } = useQuery({
+ queryKey: ["taxonomy", "subjects"],
+ queryFn: () => taxonomyService.listSubjects(),
+ });
+
+ const { data: tags } = useQuery({
+ queryKey: ["resource-tags"],
+ queryFn: () => resourcesService.listTags(),
+ });
+
+ const { data: resourcesData, isLoading: loadingResources } = useQuery({
+ queryKey: ["resources", "list", { search, resource_type: typeFilter === "all" ? undefined : typeFilter, subject_id: subjectFilter === "all" ? undefined : subjectFilter, tag_id: tagFilter === "all" ? undefined : tagFilter, date_from: dateFrom || undefined, date_to: dateTo || undefined }],
+ queryFn: () => resourcesService.list({
+ search: search || undefined,
+ resource_type: typeFilter === "all" ? undefined : typeFilter,
+ subject_id: subjectFilter === "all" ? undefined : Number(subjectFilter),
+ tag_id: tagFilter === "all" ? undefined : Number(tagFilter),
+ date_from: dateFrom || undefined,
+ date_to: dateTo || undefined,
+ }),
+ });
+ const resources = resourcesData?.items ?? [];
+
+ const { data: materialsData, isLoading: loadingMaterials } = useQuery({
+ queryKey: ["materials", "search", { search, type: typeFilter }],
+ queryFn: () => coursewareService.searchMaterials({
+ search: search || undefined,
+ type: typeFilter === "all" ? undefined : typeFilter,
+ }),
+ });
+ const courseMaterials = materialsData?.items ?? [];
+
+ const uploadMutation = useMutation({
+ mutationFn: (formData: FormData) => resourcesService.upload(formData),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["resources"] });
+ toast({ title: "Resource uploaded successfully" });
+ setShowUpload(false);
+ setUploadTagIds([]);
+ setUploadSubjectId("none");
+ setUploadDomainId("none");
+ setUploadTopicIds([]);
+ setUploadObjectiveIds([]);
+ },
+ onError: () => toast({ title: "Error", description: "Upload failed", variant: "destructive" }),
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: (id: number) => resourcesService.delete(id),
+ onSuccess: () => { qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Resource deleted" }); },
+ onError: () => toast({ title: "Error", description: "Delete failed", variant: "destructive" }),
+ });
+
+ const handleUpload = (e: React.FormEvent) => {
+ e.preventDefault();
+ const formData = new FormData(e.currentTarget);
+ if (uploadTagIds.length > 0) formData.set("tag_ids", uploadTagIds.join(","));
+ if (uploadDomainId !== "none") formData.set("domain_id", uploadDomainId);
+ if (uploadTopicIds.length > 0) formData.set("topic_ids", uploadTopicIds.join(","));
+ if (uploadObjectiveIds.length > 0) formData.set("learning_objective_ids", uploadObjectiveIds.join(","));
+ uploadMutation.mutate(formData);
+ };
+
+ const toggleUploadTag = (id: number) => {
+ setUploadTagIds((prev) => prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]);
+ };
+
+ const clearFilters = () => {
+ setSearch(""); setTypeFilter("all"); setSubjectFilter("all");
+ setTagFilter("all"); setDateFrom(""); setDateTo("");
+ };
+ const hasActiveFilters = search || typeFilter !== "all" || subjectFilter !== "all" || tagFilter !== "all" || dateFrom || dateTo;
+
+ const isLoading = loadingResources || loadingMaterials;
+
+ const formatDate = (iso?: string) => {
+ if (!iso) return "";
+ try { return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); } catch { return ""; }
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+ Resource Library
+
+
Browse shared resources and your course materials. Upload new content or pick existing resources when building courses.
+
+
setShowUpload(true)}>
+ Upload Resource
+
+
+
+ {/* Stats */}
+
+
setActiveTab("all")}>
+
+
+
+
{resources.length + courseMaterials.length}
+
Total Resources
+
+
+
+
setActiveTab("library")}>
+
+
+
+
{resources.length}
+
Shared Library
+
+
+
+
setActiveTab("materials")}>
+
+
+
+
{courseMaterials.length}
+
Course Materials
+
+
+
+
+
+ {/* Filters + Table */}
+
+
+
+
+
+ setSearch(e.target.value)} className="pl-9" />
+
+
+
+
+ All Subjects
+ {(subjects ?? []).map((s) => (
+ {s.name}
+ ))}
+
+
+
+
+
+ All Tags
+ {(tags ?? []).map((t) => (
+
+
+
+ {t.name}
+
+
+ ))}
+
+
+
+
+
+ All Types
+ PDF
+ Video
+ Link
+ Document
+ Article
+ Audio
+ Image
+
+
+
+
+
+
+ {isLoading ? (
+
+ ) : (
+
+
+ All ({resources.length + courseMaterials.length})
+ Shared Library ({resources.length})
+ Course Materials ({courseMaterials.length})
+
+
+
+ { if (window.confirm("Delete?")) deleteMutation.mutate(id); }} deletePending={deleteMutation.isPending} toast={toast} formatDate={formatDate} />
+
+
+ { if (window.confirm("Delete?")) deleteMutation.mutate(id); }} deletePending={deleteMutation.isPending} toast={toast} formatDate={formatDate} />
+
+
+ {}} deletePending={false} toast={toast} formatDate={formatDate} />
+
+
+ )}
+
+
+
+ {/* Upload Dialog */}
+
{ setShowUpload(o); if (!o) { setUploadTagIds([]); setUploadSubjectId("none"); setUploadDomainId("none"); setUploadTopicIds([]); setUploadObjectiveIds([]); } }}>
+
+
+ Upload New Resource
+
+
+
+
+
+ );
+}
+
+function ResourceTable({
+ resources, materials, showSource, showCourseInfo, onDelete, deletePending, toast, formatDate,
+}: {
+ resources: Resource[];
+ materials: CourseMaterialItem[];
+ showSource?: boolean;
+ showCourseInfo?: boolean;
+ onDelete: (id: number) => void;
+ deletePending: boolean;
+ toast: ReturnType["toast"];
+ formatDate: (iso?: string) => string;
+}) {
+ type Row = {
+ key: string; name: string; type: string; source: "library" | "course";
+ context: string; tags?: { id: number; name: string; color: string }[];
+ status?: string; createdAt?: string; resourceId?: number;
+ };
+
+ const rows: Row[] = [
+ ...resources.map((r): Row => ({
+ key: `r-${r.id}`, name: r.name, type: r.resource_type, source: "library",
+ context: [r.subject_name, ...(r.topic_names?.slice(0, 2) ?? [])].filter(Boolean).join(" › ") || "",
+ tags: r.tags ?? [], status: r.review_status, createdAt: r.created_at, resourceId: r.id,
+ })),
+ ...materials.map((m): Row => ({
+ key: `m-${m.id}`, name: m.name, type: m.type, source: "course",
+ context: showCourseInfo || showSource ? `${m.course_name} › ${m.chapter_name}` : m.chapter_name || "",
+ })),
+ ];
+
+ if (rows.length === 0) {
+ return (
+
+
+
No resources found
+
Upload a resource or add materials to your courses.
+
+ );
+ }
+
+ return (
+
+
+
+ Name
+ Type
+ {showSource && Source }
+ Subject / Tags
+ Status
+ Uploaded
+ Actions
+
+
+
+ {rows.map((row) => {
+ const sb = row.status ? (statusBadge[row.status] ?? statusBadge.pending) : null;
+ return (
+
+
+
+ {typeIcons[row.type] ?? }
+ {row.name}
+
+
+ {row.type}
+ {showSource && (
+
+
+ {row.source === "library" ? "Library" : <> Course>}
+
+
+ )}
+
+
+ {row.context &&
{row.context} }
+ {row.tags && row.tags.length > 0 && (
+
+ {row.tags.map((t) => (
+ {t.name}
+ ))}
+
+ )}
+ {!row.context && (!row.tags || row.tags.length === 0) &&
— }
+
+
+
+ {sb ? (
+ {sb.icon}{row.status}
+ ) : (
+ indexed
+ )}
+
+ {formatDate(row.createdAt)}
+
+
+ {row.source === "library" && row.resourceId && (
+ <>
+ {
+ try {
+ const blob = await resourcesService.download(row.resourceId!);
+ const url = URL.createObjectURL(blob); const a = document.createElement("a");
+ a.href = url; a.download = row.name || "resource"; a.click(); URL.revokeObjectURL(url);
+ } catch { toast({ title: "Download failed", variant: "destructive" }); }
+ }}>
+
+
+ onDelete(row.resourceId!)} disabled={deletePending}>
+
+
+ >
+ )}
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+const INLINE_TAG_COLORS = ["#3b82f6", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6", "#ec4899", "#06b6d4"];
+
+function InlineTagPicker({
+ allTags, selectedIds, onToggle, onTagCreated,
+}: {
+ allTags: ResourceTag[];
+ selectedIds: number[];
+ onToggle: (id: number) => void;
+ onTagCreated: (tag: ResourceTag) => void;
+}) {
+ const [newName, setNewName] = useState("");
+ const [creating, setCreating] = useState(false);
+
+ const handleCreate = async () => {
+ const name = newName.trim();
+ if (!name) return;
+ setCreating(true);
+ try {
+ const color = INLINE_TAG_COLORS[Math.floor(Math.random() * INLINE_TAG_COLORS.length)];
+ const tag = await resourcesService.createTag({ name, color });
+ onTagCreated(tag);
+ onToggle(tag.id);
+ setNewName("");
+ } catch { /* ignore */ }
+ setCreating(false);
+ };
+
+ return (
+
+
Tags
+
+ {allTags.map((t) => (
+ onToggle(t.id)}
+ >
+ {t.name}
+
+ ))}
+
+
+
setNewName(e.target.value)}
+ onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleCreate(); } }}
+ className="h-8 text-sm flex-1"
+ />
+
+ {creating ? : <> Add>}
+
+
+
+ );
+}
diff --git a/src/services/courseware.service.ts b/src/services/courseware.service.ts
index c0560b4..abcea25 100644
--- a/src/services/courseware.service.ts
+++ b/src/services/courseware.service.ts
@@ -51,6 +51,18 @@ export const coursewareService = {
return api.get(`/chapters/${id}/progress`);
},
+ async getCourseCompletion(courseId: number): Promise<{
+ course_id: number;
+ status: string;
+ chapters_total: number;
+ chapters_completed: number;
+ progress_percent: number;
+ completed_at?: string | null;
+ post_test_available: boolean;
+ }> {
+ return api.get(`/courses/${courseId}/completion`);
+ },
+
async markChapterComplete(id: number): Promise {
return api.post(`/chapters/${id}/progress/complete`);
},
@@ -67,6 +79,13 @@ export const coursewareService = {
return api.patch(`/materials/${id}`, data);
},
+ async createMaterialFromResource(chapterId: number, resourceId: number, name?: string): Promise {
+ return api.post(`/chapters/${chapterId}/materials/from-resource`, {
+ resource_id: resourceId,
+ ...(name ? { name } : {}),
+ });
+ },
+
async deleteMaterial(id: number): Promise {
return api.delete(`/materials/${id}`);
},
@@ -87,6 +106,27 @@ export const coursewareService = {
return api.patch(`/chapters/${chapterId}/materials/reorder`, body);
},
+ async searchMaterials(params?: { search?: string; type?: string; course_id?: number; limit?: number; offset?: number }): Promise<{
+ items: Array<{
+ id: number;
+ name: string;
+ chapter_id: number;
+ type: string;
+ file_url: string | null;
+ url: string | null;
+ description: string | null;
+ sequence: number;
+ allow_download: boolean;
+ course_id: number;
+ course_name: string;
+ chapter_name: string;
+ source: string;
+ }>;
+ total: number;
+ }> {
+ return api.get("/materials/search", params as Record);
+ },
+
async generateOutline(data: WorkbenchGenerateRequest): Promise {
return api.post("/workbench/generate-outline", data);
},
diff --git a/src/services/fees.service.ts b/src/services/fees.service.ts
index 0dad9e2..ab9df40 100644
--- a/src/services/fees.service.ts
+++ b/src/services/fees.service.ts
@@ -2,20 +2,51 @@ import { api } from "@/lib/api-client";
import type { FeesPlan, StudentFeesDetail, FeesTerms } from "@/types/fees";
import type { PaginatedResponse, PaginationParams } from "@/types";
+export interface FeesPlanFilters extends PaginationParams {
+ q?: string;
+ state?: string;
+ payment_state?: string;
+ student_id?: number;
+}
+
+export interface RegisterPaymentBody {
+ amount?: number;
+ journal_id?: number;
+ payment_date?: string;
+ memo?: string;
+}
+
export const feesService = {
- async listFeesPlans(params?: PaginationParams): Promise> {
- return api.get>("/fees-plans", params as Record);
+ async listFeesPlans(params?: FeesPlanFilters): Promise> {
+ return api.get>(
+ "/fees-plans",
+ params as Record,
+ );
},
async getFeesPlan(id: number): Promise {
return api.get(`/fees-plans/${id}`);
},
- async listStudentFees(params?: PaginationParams): Promise> {
- return api.get>("/student-fees", params as Record);
+ async createInvoice(id: number): Promise {
+ return api.post(`/fees-plans/${id}/create-invoice`, {});
+ },
+
+ async registerPayment(id: number, body: RegisterPaymentBody = {}): Promise {
+ return api.post(`/fees-plans/${id}/register-payment`, body);
+ },
+
+ async listStudentFees(params?: FeesPlanFilters): Promise> {
+ return api.get>(
+ "/student-fees",
+ params as Record,
+ );
},
async listFeesTerms(params?: PaginationParams): Promise> {
- return api.get>("/fees-terms", params as Record);
+ return api.get>(
+ "/fees-terms",
+ params as Record,
+ );
},
};
diff --git a/src/services/generation.service.ts b/src/services/generation.service.ts
index b7cc7c2..ff0e862 100644
--- a/src/services/generation.service.ts
+++ b/src/services/generation.service.ts
@@ -16,15 +16,36 @@ export interface GenerationParams {
question_count?: number;
}
-export interface PassageGenerationParams {
+export interface PersonaContext {
+ exam_mode?: "official" | "practice" | string;
+ exam_title?: string;
+ exam_label?: string;
+ structure_name?: string;
+ passage_type?: string;
+ task_type?: string;
+ section_type?: string;
+ part?: string;
+ category?: string;
+ subject_name?: string;
+ subject_id?: number;
+ entity_id?: number;
+ entity_name?: string;
+ rubric_name?: string;
+ rubric_id?: number | string;
+ grading_system?: string;
+ access_type?: string;
+ course_id?: number;
+ module?: string;
+}
+
+export interface PassageGenerationParams extends PersonaContext {
topic?: string;
difficulty?: string;
word_count?: number;
- category?: string;
type?: string;
}
-export interface ExerciseConfig {
+export interface ExerciseConfig extends PersonaContext {
passage_index: number;
exercise_types: string[];
count_per_type?: number;
@@ -106,28 +127,43 @@ export const generationService = {
});
},
- generateExercises(module: ExamModule, config: ExerciseConfig & { passage_text?: string; type_counts?: Record; difficulty?: string }): Promise<{ questions: unknown[] }> {
+ generateExercises(
+ module: ExamModule,
+ config: ExerciseConfig & {
+ passage_text?: string;
+ type_counts?: Record;
+ type_instructions?: Record;
+ type_difficulties?: Record;
+ difficulty?: string;
+ },
+ ): Promise<{ questions: unknown[] }> {
return api.post(`/exam/${module}/generate`, {
...config,
generate_exercises: true,
});
},
- generateWritingInstructions(params: { topic?: string; difficulty?: string; task_type?: string }): Promise<{ instructions: string }> {
+ generateWritingInstructions(
+ params: PersonaContext & { topic?: string; difficulty?: string; task_type?: string; word_limit?: number },
+ ): Promise<{ instructions: string }> {
return api.post("/exam/writing/generate", {
...params,
generate_instructions: true,
});
},
- generateSpeakingScript(params: { topics?: string[]; difficulty?: string; part?: string }): Promise<{ script: string }> {
+ generateSpeakingScript(
+ params: PersonaContext & { topics?: string[]; difficulty?: string; part?: string },
+ ): Promise<{ script: string }> {
return api.post("/exam/speaking/generate", {
...params,
generate_script: true,
});
},
- generateListeningContext(params: { topic?: string; section_type?: string }): Promise<{ context: string }> {
+ generateListeningContext(
+ params: PersonaContext & { topic?: string; section_type?: string; difficulty?: string },
+ ): Promise<{ context: string }> {
return api.post("/exam/listening/generate", {
...params,
generate_context: true,
diff --git a/src/services/gradebook.service.ts b/src/services/gradebook.service.ts
index f20c0ea..a54dd23 100644
--- a/src/services/gradebook.service.ts
+++ b/src/services/gradebook.service.ts
@@ -7,7 +7,7 @@ export const gradebookService = {
return api.get>("/gradebooks", params as Record);
},
- async listGradebookLines(params?: PaginationParams): Promise> {
+ async listGradebookLines(params?: PaginationParams & { gradebook_id?: number }): Promise> {
return api.get>("/gradebook-lines", params as Record);
},
diff --git a/src/services/lms.service.ts b/src/services/lms.service.ts
index 11494cc..d5a167d 100644
--- a/src/services/lms.service.ts
+++ b/src/services/lms.service.ts
@@ -15,6 +15,9 @@ import type {
LmsTeacherRecord,
LmsStudentCreateRequest,
LmsTeacherCreateRequest,
+ MyEnrolledCourse,
+ EnrollStudentRequest,
+ BulkEnrollRequest,
} from "@/types";
function mapCourseRaw(r: Record): Course {
@@ -27,6 +30,16 @@ function mapCourseRaw(r: Record): Course {
code: (r.code as string) || "",
subject_id: Array.isArray(r.subject_ids) && (r.subject_ids as number[]).length ? (r.subject_ids as number[])[0] : undefined,
subject_name: Array.isArray(r.subject_names) ? String((r.subject_names as string[])[0] || "") : "",
+ encoach_subject_id: (r.encoach_subject_id as number | null) ?? null,
+ encoach_subject_name: (r.encoach_subject_name as string) || "",
+ topic_ids: (r.topic_ids as number[]) || [],
+ topic_names: (r.topic_names as string[]) || [],
+ learning_objective_ids: (r.learning_objective_ids as number[]) || [],
+ learning_objective_names: (r.learning_objective_names as string[]) || [],
+ tag_ids: (r.tag_ids as number[]) || [],
+ tags: (r.tags as { id: number; name: string; color: string }[]) || [],
+ difficulty_level: (r.difficulty_level as Course["difficulty_level"]) || "",
+ cefr_level: (r.cefr_level as string) || "",
instructor_id: 0,
instructor_name: "",
description: (r.description as string) || "",
@@ -37,6 +50,9 @@ function mapCourseRaw(r: Record): Course {
status,
start_date: "",
end_date: "",
+ chapter_count: Number(r.chapter_count ?? 0),
+ resource_count: Number(r.resource_count ?? 0),
+ objective_count: Number(r.objective_count ?? 0),
};
}
@@ -54,8 +70,8 @@ function mapBatchRaw(r: Record): Batch {
course_name: String(r.course_name || ""),
teacher_id: 0,
teacher_name: "",
- student_ids: [],
- student_count: 0,
+ student_ids: ((r.students as { id: number }[]) ?? []).map((s) => s.id),
+ student_count: Number(r.student_count ?? 0),
start_date: String(r.start_date || ""),
end_date: String(r.end_date || ""),
schedule: "",
@@ -101,6 +117,18 @@ export function lmsTeacherToUser(t: LmsTeacherRecord): User {
}
export const lmsService = {
+ async getMyEnrolledCourses(): Promise<{ items: MyEnrolledCourse[]; total: number }> {
+ return api.get("/student/my-courses");
+ },
+
+ async enrollStudent(studentId: number, data: EnrollStudentRequest): Promise<{ success: boolean; enrolled_course_ids: number[] }> {
+ return api.post(`/students/${studentId}/enroll`, data);
+ },
+
+ async bulkEnrollInCourse(courseId: number, data: BulkEnrollRequest): Promise<{ success: boolean; enrolled_student_ids: number[]; total_enrolled: number }> {
+ return api.post(`/courses/${courseId}/enroll`, data);
+ },
+
async listCourses(params?: PaginationParams & { status?: string }): Promise> {
const raw = await api.get("/courses", params as Record);
const p = asPaginated>(raw);
@@ -113,13 +141,20 @@ export const lmsService = {
},
async createCourse(data: CourseCreateRequest): Promise {
- const raw = await api.post("/courses", {
+ const body: Record = {
name: data.title,
code: data.code,
description: data.description,
max_capacity: data.max_capacity,
status: "draft",
- });
+ };
+ if (data.encoach_subject_id) body.encoach_subject_id = data.encoach_subject_id;
+ if (data.topic_ids?.length) body.topic_ids = data.topic_ids;
+ if (data.learning_objective_ids?.length) body.learning_objective_ids = data.learning_objective_ids;
+ if (data.tag_ids?.length) body.tag_ids = data.tag_ids;
+ if (data.difficulty_level) body.difficulty_level = data.difficulty_level;
+ if (data.cefr_level) body.cefr_level = data.cefr_level;
+ const raw = await api.post("/courses", body);
return mapCourseRaw(asRecordData>(raw));
},
@@ -129,6 +164,12 @@ export const lmsService = {
if (data.code != null) body.code = data.code;
if (data.description != null) body.description = data.description;
if (data.max_capacity != null) body.max_capacity = data.max_capacity;
+ if (data.encoach_subject_id !== undefined) body.encoach_subject_id = data.encoach_subject_id || null;
+ if (data.topic_ids !== undefined) body.topic_ids = data.topic_ids;
+ if (data.learning_objective_ids !== undefined) body.learning_objective_ids = data.learning_objective_ids;
+ if (data.tag_ids !== undefined) body.tag_ids = data.tag_ids;
+ if (data.difficulty_level !== undefined) body.difficulty_level = data.difficulty_level;
+ if (data.cefr_level !== undefined) body.cefr_level = data.cefr_level;
const raw = await api.patch(`/courses/${id}`, body);
return mapCourseRaw(asRecordData>(raw));
},
@@ -196,6 +237,26 @@ export const lmsService = {
return asRecordData(raw);
},
+ async deleteTeacher(id: number): Promise {
+ return api.delete(`/teachers/${id}`);
+ },
+
+ async getBatchStudents(batchId: number): Promise<{ data: { id: number; name: string; email: string; course_id: number; course_name: string }[]; total: number }> {
+ return api.get(`/batches/${batchId}/students`);
+ },
+
+ async addStudentsToBatch(batchId: number, studentIds: number[]): Promise<{ success: boolean; added_ids: number[]; total: number }> {
+ return api.post(`/batches/${batchId}/students`, { student_ids: studentIds });
+ },
+
+ async removeStudentsFromBatch(batchId: number, studentIds: number[]): Promise<{ success: boolean; removed_ids: number[]; total: number }> {
+ return api.post(`/batches/${batchId}/students/remove`, { student_ids: studentIds });
+ },
+
+ async deleteStudent(id: number): Promise {
+ return api.delete(`/students/${id}`);
+ },
+
async getTimetable(params?: { course_id?: number; teacher_id?: number; batch_id?: number }): Promise {
const raw = await api.get<{ data: TimetableSession[] } | TimetableSession[]>("/timetable", params as Record);
return Array.isArray(raw) ? raw : (raw as { data: TimetableSession[] }).data ?? [];
@@ -222,4 +283,34 @@ export const lmsService = {
const raw = await api.get<{ data: GradeRecord[] } | GradeRecord[]>("/grades", params as Record);
return Array.isArray(raw) ? raw : (raw as { data: GradeRecord[] }).data ?? [];
},
+
+ async getCourseTaxonomy(courseId: number): Promise<{
+ subject: { id: number; name: string; code: string } | null;
+ domains: { id: number; name: string }[];
+ topics: { id: number; name: string; domain_id: number; domain_name: string }[];
+ objectives: { id: number; name: string; topic_id: number; topic_name: string; bloom_level: string }[];
+ tags: { id: number; name: string; color: string }[];
+ }> {
+ return api.get(`/courses/${courseId}/taxonomy`);
+ },
+
+ async getSubjectCourses(subjectId: number): Promise<{ items: Course[]; total: number }> {
+ const raw = await api.get(`/subjects/${subjectId}/courses`);
+ const p = asPaginated>(raw);
+ return { ...p, items: p.items.map(mapCourseRaw) };
+ },
+
+ async listLearningObjectives(params?: { topic_id?: number; topic_ids?: string; subject_id?: number }): Promise<{
+ items: { id: number; name: string; description: string; topic_id: number; topic_name: string; bloom_level: string; cefr_level: string }[];
+ total: number;
+ }> {
+ return api.get("/learning-objectives", params as Record);
+ },
+
+ async listDomains(params?: { subject_id?: number }): Promise<{
+ items: { id: number; name: string; subject_id: number; subject_name: string; description: string }[];
+ total: number;
+ }> {
+ return api.get("/domains", params as Record);
+ },
};
diff --git a/src/services/payments.service.ts b/src/services/payments.service.ts
new file mode 100644
index 0000000..c0ebd37
--- /dev/null
+++ b/src/services/payments.service.ts
@@ -0,0 +1,63 @@
+import { api } from "@/lib/api-client";
+
+export interface PaymentRecord {
+ id: number;
+ ref: string;
+ student_id: number | null;
+ student_name: string;
+ course_id: number | null;
+ course_name: string;
+ product_name: string;
+ amount: number;
+ after_discount_amount: number;
+ currency: string;
+ state: string;
+ paid: boolean;
+ date: string;
+ type: string;
+ invoice_id: number | null;
+ invoice_number: string;
+ payment_state: string;
+}
+
+export interface PaymentTotals {
+ count: number;
+ paid: number;
+ unpaid: number;
+ amount: number;
+}
+
+export interface PaymentListResponse {
+ data: PaymentRecord[];
+ items: PaymentRecord[];
+ total: number;
+ totals: PaymentTotals;
+}
+
+export interface PaymobOrder {
+ id: string | number;
+ status: string;
+ user: string;
+ email: string;
+ amount: number;
+}
+
+export interface PaymobListResponse {
+ data: PaymobOrder[];
+ items: PaymobOrder[];
+ total: number;
+ message?: string;
+}
+
+export const paymentsService = {
+ async list(params?: { status?: string; paid?: string; student_id?: number }) {
+ return api.get(
+ "/payment-records",
+ params as Record,
+ );
+ },
+
+ async paymobOrders() {
+ return api.get("/paymob-orders");
+ },
+};
diff --git a/src/services/platformSettings.service.ts b/src/services/platformSettings.service.ts
new file mode 100644
index 0000000..8fc07b8
--- /dev/null
+++ b/src/services/platformSettings.service.ts
@@ -0,0 +1,59 @@
+import { api } from "@/lib/api-client";
+
+export interface RegistrationCode {
+ id: number;
+ code: string;
+ code_type: "individual" | "corporate";
+ user_type: "student" | "teacher" | "corporate";
+ max_uses: number;
+ uses: number;
+ used: boolean;
+ expiry_date: string;
+ created: string;
+ entity_id: number | null;
+}
+
+export interface GeneratePayload {
+ count?: number;
+ code_type?: "individual" | "corporate";
+ user_type?: "student" | "teacher" | "corporate";
+ max_uses?: number;
+ expiry_date?: string;
+}
+
+export interface PlatformPackage {
+ id: number;
+ name: string;
+ price: number;
+ duration: string;
+ discount: number;
+}
+
+export interface GradingConfig {
+ min_score: number;
+ max_score: number;
+ increment: number;
+}
+
+export const platformSettingsService = {
+ listCodes: () =>
+ api.get<{ items: RegistrationCode[]; total: number }>("/codes"),
+ generateCodes: (payload: GeneratePayload) =>
+ api.post<{ data: RegistrationCode[]; count: number }>(
+ "/codes/generate",
+ payload,
+ ),
+ deleteCode: (id: number) => api.delete<{ success: boolean }>(`/codes/${id}`),
+
+ listPackages: () =>
+ api.get<{ items: PlatformPackage[]; total: number }>("/packages"),
+ createPackage: (p: Omit) =>
+ api.post("/packages", p),
+ updatePackage: (id: number, p: Partial) =>
+ api.patch(`/packages/${id}`, p),
+ deletePackage: (id: number) =>
+ api.delete<{ success: boolean }>(`/packages/${id}`),
+
+ getGrading: () => api.get("/grading-config"),
+ setGrading: (cfg: GradingConfig) => api.patch("/grading-config", cfg),
+};
diff --git a/src/services/resources.service.ts b/src/services/resources.service.ts
index 5659ab8..ffd12f7 100644
--- a/src/services/resources.service.ts
+++ b/src/services/resources.service.ts
@@ -1,11 +1,15 @@
import { api, API_BASE_URL } from "@/lib/api-client";
import { asPaginated, asRecordData } from "@/lib/odoo-api";
-import type { Resource, ResourceCompletion, PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types";
+import type { Resource, ResourceTag, ResourceCompletion, PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types";
export interface ResourceListParams extends PaginationParams {
+ subject_id?: number;
topic_id?: number;
+ tag_id?: number;
resource_type?: string;
review_status?: string;
+ date_from?: string;
+ date_to?: string;
search?: string;
}
@@ -45,4 +49,24 @@ export const resourcesService = {
if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
return res.blob();
},
+
+ // Tag management
+ async listTags(): Promise {
+ const res = await api.get<{ data?: ResourceTag[]; items?: ResourceTag[] }>("/resource-tags");
+ return res.data ?? res.items ?? [];
+ },
+
+ async createTag(data: { name: string; color?: string; description?: string }): Promise {
+ const res = await api.post<{ data: ResourceTag }>("/resource-tags", data);
+ return res.data;
+ },
+
+ async updateTag(id: number, data: Partial): Promise {
+ const res = await api.patch<{ data: ResourceTag }>(`/resource-tags/${id}`, data);
+ return res.data;
+ },
+
+ async deleteTag(id: number): Promise {
+ return api.delete(`/resource-tags/${id}`);
+ },
};
diff --git a/src/services/student-progress.service.ts b/src/services/student-progress.service.ts
index 9db8355..b726378 100644
--- a/src/services/student-progress.service.ts
+++ b/src/services/student-progress.service.ts
@@ -1,9 +1,18 @@
import { api } from "@/lib/api-client";
-import type { StudentProgression } from "@/types/student-progress";
+import type { StudentProgression, StudentProgressDetail } from "@/types/student-progress";
import type { PaginatedResponse, PaginationParams } from "@/types";
+export interface StudentProgressFilters extends PaginationParams {
+ q?: string;
+ batch_id?: number;
+ course_id?: number;
+}
+
export const studentProgressService = {
- async listProgress(params?: PaginationParams): Promise> {
+ async listProgress(params?: StudentProgressFilters): Promise> {
return api.get>("/student-progress", params as Record);
},
+ async getProgress(id: number): Promise {
+ return api.get(`/student-progress/${id}`);
+ },
};
diff --git a/src/services/taxonomy.service.ts b/src/services/taxonomy.service.ts
index b261768..7c7bd87 100644
--- a/src/services/taxonomy.service.ts
+++ b/src/services/taxonomy.service.ts
@@ -3,8 +3,10 @@ import type { Subject, Domain, Topic, LearningObjective, TaxonomyTree, Paginated
export const taxonomyService = {
async listSubjects(): Promise