feat(v3): restructure project + add complete frontend

- Restructure: move backend from new_project/ to backend/
- Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks)
- Add docs/ with SRS specs, user stories, and workflow documentation
- Update .gitignore for new directory layout

Workflows implemented:
  WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration,
  WF4 General English Exam, WF5 Course Generation,
  WF6 Entity Student Onboarding, AI Course Generation,
  Adaptive Learning Engine UI, White-Label Branding, Score Release

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-10 17:26:42 +04:00
commit 11a7265460
392 changed files with 62287 additions and 0 deletions

View File

@@ -0,0 +1,216 @@
import { Link } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Users, GraduationCap, Building2, UserCog, BarChart3, School, Building, Ticket, BookOpen, DollarSign } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { useStudents, useTeachers } from "@/hooks/queries";
import { useEntities } from "@/hooks/queries/useEntities";
import AiInsightsPanel from "@/components/ai/AiInsightsPanel";
interface PlatformStats {
total_students?: number;
total_teachers?: number;
total_entities?: number;
total_courses?: number;
active_courses?: number;
total_batches?: number;
active_batches?: number;
total_classrooms?: number;
total_tickets?: number;
open_tickets?: number;
total_exams?: number;
total_assignments?: number;
total_revenue?: number;
total_payments?: number;
total_subjects?: number;
total_resources?: number;
total_exam_sessions?: number;
}
export default function AdminDashboard() {
const { data: stats } = useQuery<PlatformStats>({
queryKey: ["platform", "stats"],
queryFn: async () => {
const res = await api.get<{ data: PlatformStats }>("/stats");
return (res as { data: PlatformStats }).data ?? res;
},
staleTime: 30_000,
});
const { data: studentsData, isLoading: ls } = useStudents({ size: 5 });
const { data: teachersData, isLoading: lt } = useTeachers({ size: 5 });
const { data: entitiesData, isLoading: le } = useEntities({ size: 50 });
const students = studentsData?.items ?? [];
const teachers = teachersData?.items ?? [];
const entities = entitiesData?.items ?? [];
const loading = ls || lt || le;
const statCards = [
{ label: "Students", count: stats?.total_students ?? 0, icon: GraduationCap, color: "bg-primary/10 text-primary" },
{ label: "Teachers", count: stats?.total_teachers ?? 0, icon: UserCog, color: "bg-info/10 text-info" },
{ label: "Entities", count: stats?.total_entities ?? 0, icon: Building2, color: "bg-warning/10 text-warning" },
{ label: "Active Courses", count: stats?.active_courses ?? 0, icon: BookOpen, color: "bg-success/10 text-success" },
];
const shortcuts = [
{ label: "Exams", count: stats?.total_exams ?? 0, url: "/admin/exams", icon: BarChart3 },
{ label: "Classrooms", count: stats?.total_classrooms ?? 0, url: "/admin/classrooms", icon: School },
{ label: "Entities", count: stats?.total_entities ?? 0, url: "/admin/entities", icon: Building2 },
{ label: "Tickets", count: `${stats?.open_tickets ?? 0} open`, url: "/admin/tickets", icon: Ticket },
];
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Platform Dashboard</h1>
<p className="text-muted-foreground">Overview of platform activity and user statistics.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{statCards.map((s) => (
<Card key={s.label} className="border-0 shadow-sm">
<CardContent className="p-5 flex items-center gap-4">
<div className={`h-12 w-12 rounded-xl flex items-center justify-center ${s.color}`}>
<s.icon className="h-6 w-6" />
</div>
<div>
<p className="text-2xl font-bold">{s.count.toLocaleString()}</p>
<p className="text-sm text-muted-foreground">{s.label}</p>
</div>
</CardContent>
</Card>
))}
</div>
<AiInsightsPanel />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{shortcuts.map((s) => (
<Link key={s.label} to={s.url}>
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow cursor-pointer">
<CardContent className="p-5 flex items-center justify-between">
<div className="flex items-center gap-3">
<s.icon className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">{s.label}</span>
</div>
<Badge variant="secondary">{s.count}</Badge>
</CardContent>
</Card>
</Link>
))}
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ label: "Batches", value: `${stats?.active_batches ?? 0} / ${stats?.total_batches ?? 0}` },
{ label: "Subjects", value: stats?.total_subjects ?? 0 },
{ label: "Resources", value: stats?.total_resources ?? 0 },
{ label: "Revenue", value: `$${(stats?.total_revenue ?? 0).toLocaleString()}` },
].map(s => (
<Card key={s.label} className="border-0 shadow-sm">
<CardContent className="p-4">
<p className="text-lg font-bold">{s.value}</p>
<p className="text-xs text-muted-foreground">{s.label}</p>
</CardContent>
</Card>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base font-semibold">Recent Students</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{students.length === 0 && <p className="text-sm text-muted-foreground">No students found.</p>}
{students.map((s) => (
<div key={s.id} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary">
{(s.name || "?").split(" ").map(n => n[0]).join("").slice(0, 2).toUpperCase()}
</div>
<div>
<p className="text-sm font-medium">{s.name}</p>
<p className="text-xs text-muted-foreground">{s.email || "—"}</p>
</div>
</div>
<Badge variant="outline">{s.status || "active"}</Badge>
</div>
))}
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base font-semibold">Recent Teachers</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{teachers.length === 0 && <p className="text-sm text-muted-foreground">No teachers found.</p>}
{teachers.map((t) => (
<div key={t.id} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-info/10 flex items-center justify-center text-xs font-semibold text-info">
{(t.name || "?").split(" ").map(n => n[0]).join("").slice(0, 2).toUpperCase()}
</div>
<div>
<p className="text-sm font-medium">{t.name}</p>
<p className="text-xs text-muted-foreground">{t.email || "—"}</p>
</div>
</div>
<Badge variant="outline">{t.department_name || "—"}</Badge>
</div>
))}
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base font-semibold">Entities</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{entities.length === 0 && <p className="text-sm text-muted-foreground">No entities found.</p>}
{entities.map((e) => (
<div key={e.id} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Building className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium">{e.name}</p>
</div>
<Badge variant="outline">{e.entity_type || e.type || "—"}</Badge>
</div>
))}
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base font-semibold">Platform Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{[
{ label: "Exam Sessions", value: stats?.total_exam_sessions ?? 0 },
{ label: "Assignments", value: stats?.total_assignments ?? 0 },
{ label: "Total Tickets", value: stats?.total_tickets ?? 0 },
{ label: "Completed Payments", value: stats?.total_payments ?? 0 },
].map(item => (
<div key={item.label} className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{item.label}</p>
<p className="text-sm font-semibold">{item.value}</p>
</div>
))}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,205 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { CheckCircle2, Loader2 } from "lucide-react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { admissionService } from "@/services/admission.service";
import { useToast } from "@/hooks/use-toast";
import type { AdmissionCreateRequest } from "@/types/admission";
type Step = "register" | "personal" | "review" | "success";
export default function AdmissionApplication() {
const { toast } = useToast();
const [step, setStep] = useState<Step>("register");
const [selectedRegisterId, setSelectedRegisterId] = useState<number>(0);
const [form, setForm] = useState<Omit<AdmissionCreateRequest, "register_id">>({
first_name: "",
last_name: "",
email: "",
gender: "m",
});
const [applicationNumber, setApplicationNumber] = useState("");
const { data: registersData, isLoading: loadingRegisters } = useQuery({
queryKey: ["admission-registers", "list"],
queryFn: () => admissionService.listRegisters(),
});
const registers = registersData?.items ?? [];
const selectedRegister = registers.find((r: { id: number }) => r.id === selectedRegisterId);
const submitMutation = useMutation({
mutationFn: async () => {
const admission = await admissionService.createAdmission({ ...form, register_id: selectedRegisterId });
await admissionService.submitAdmission(admission.id);
return admission;
},
onSuccess: (data) => {
setApplicationNumber(data.application_number);
setStep("success");
},
onError: () => toast({ title: "Error", description: "Submission failed. Please try again.", variant: "destructive" }),
});
const canProceedFromRegister = selectedRegisterId > 0;
const canProceedFromPersonal = form.first_name && form.last_name && form.email;
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-lg">
{step !== "success" && (
<div className="flex items-center justify-center gap-2 mb-8">
{["register", "personal", "review"].map((s, i) => (
<div key={s} className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
s === step ? "bg-primary text-primary-foreground" :
["register", "personal", "review"].indexOf(step) > i ? "bg-primary/20 text-primary" : "bg-muted text-muted-foreground"
}`}>
{i + 1}
</div>
{i < 2 && <div className="w-12 h-px bg-border" />}
</div>
))}
</div>
)}
{step === "register" && (
<Card>
<CardHeader>
<CardTitle>Select Course</CardTitle>
<CardDescription>Choose the admission register you want to apply to.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loadingRegisters ? (
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : (
<div className="space-y-2">
{registers
.filter((r: { state: string }) => r.state === "confirm")
.map((r: { id: number; name: string; course_name: string; start_date: string; end_date: string }) => (
<button
key={r.id}
onClick={() => setSelectedRegisterId(r.id)}
className={`w-full text-left p-4 rounded-lg border transition-colors ${selectedRegisterId === r.id ? "bg-primary/10 border-primary" : "hover:bg-accent"}`}
>
<p className="font-medium">{r.name}</p>
<p className="text-sm text-muted-foreground">{r.course_name} · {r.start_date} {r.end_date}</p>
</button>
))}
{registers.filter((r: { state: string }) => r.state === "confirm").length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">No open admissions available.</p>
)}
</div>
)}
<Button className="w-full" onClick={() => setStep("personal")} disabled={!canProceedFromRegister}>
Next
</Button>
</CardContent>
</Card>
)}
{step === "personal" && (
<Card>
<CardHeader>
<CardTitle>Personal Information</CardTitle>
<CardDescription>Fill in your personal details.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>First Name *</Label>
<Input value={form.first_name} onChange={e => setForm({ ...form, first_name: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Last Name *</Label>
<Input value={form.last_name} onChange={e => setForm({ ...form, last_name: e.target.value })} />
</div>
</div>
<div className="space-y-2">
<Label>Middle Name</Label>
<Input value={form.middle_name ?? ""} onChange={e => setForm({ ...form, middle_name: e.target.value || undefined })} />
</div>
<div className="space-y-2">
<Label>Email *</Label>
<Input type="email" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Phone</Label>
<Input value={form.phone ?? ""} onChange={e => setForm({ ...form, phone: e.target.value || undefined })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Birth Date</Label>
<Input type="date" value={form.birth_date ?? ""} onChange={e => setForm({ ...form, birth_date: e.target.value || undefined })} />
</div>
<div className="space-y-2">
<Label>Gender *</Label>
<Select value={form.gender} onValueChange={v => setForm({ ...form, gender: v as "m" | "f" | "o" })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="m">Male</SelectItem>
<SelectItem value="f">Female</SelectItem>
<SelectItem value="o">Other</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => setStep("register")}>Back</Button>
<Button className="flex-1" onClick={() => setStep("review")} disabled={!canProceedFromPersonal}>Next</Button>
</div>
</CardContent>
</Card>
)}
{step === "review" && (
<Card>
<CardHeader>
<CardTitle>Review & Submit</CardTitle>
<CardDescription>Please review your application before submitting.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border p-4 space-y-2">
<p className="text-sm font-medium">Course</p>
<p className="text-sm text-muted-foreground">{selectedRegister?.name} {selectedRegister?.course_name}</p>
</div>
<div className="rounded-lg border p-4 space-y-2">
<p className="text-sm font-medium">Applicant</p>
<p className="text-sm text-muted-foreground">{form.first_name} {form.middle_name ?? ""} {form.last_name}</p>
<p className="text-sm text-muted-foreground">{form.email} {form.phone ? `· ${form.phone}` : ""}</p>
<p className="text-sm text-muted-foreground capitalize">{form.gender === "m" ? "Male" : form.gender === "f" ? "Female" : "Other"} {form.birth_date ? `· ${form.birth_date}` : ""}</p>
</div>
<div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => setStep("personal")}>Back</Button>
<Button className="flex-1" onClick={() => submitMutation.mutate()} disabled={submitMutation.isPending}>
{submitMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Submit Application
</Button>
</div>
</CardContent>
</Card>
)}
{step === "success" && (
<Card>
<CardContent className="pt-8 pb-8 text-center space-y-4">
<CheckCircle2 className="h-16 w-16 text-green-500 mx-auto" />
<div>
<h2 className="text-xl font-bold">Application Submitted!</h2>
<p className="text-muted-foreground mt-1">Your application has been received.</p>
</div>
<div className="rounded-lg bg-muted p-4">
<p className="text-sm text-muted-foreground">Application Number</p>
<p className="text-2xl font-bold">{applicationNumber}</p>
</div>
<p className="text-sm text-muted-foreground">Please save this number for future reference.</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,119 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, CheckCircle, XCircle, Clock } from "lucide-react";
import AiGradingAssistant from "@/components/ai/AiGradingAssistant";
import { useToast } from "@/hooks/use-toast";
const workflows = [
{
id: 1, name: "Exam Content Review", status: "In Progress",
steps: [
{ name: "Initial Review", assignee: "Dr. Smith", status: "Approved" },
{ name: "Quality Check", assignee: "Prof. Lee", status: "Pending" },
{ name: "Final Approval", assignee: "Admin", status: "Waiting" },
],
},
{
id: 2, name: "Rubric Approval", status: "Completed",
steps: [
{ name: "Draft Review", assignee: "Mr. Kim", status: "Approved" },
{ name: "Academic Board", assignee: "Dr. Smith", status: "Approved" },
],
},
{
id: 3, name: "New Exam Structure", status: "Rejected",
steps: [
{ name: "Content Review", assignee: "Prof. Lee", status: "Approved" },
{ name: "Standards Check", assignee: "Dr. Smith", status: "Rejected" },
],
},
];
const statusIcon = (s: string) => {
if (s === "Approved") return <CheckCircle className="h-4 w-4 text-success" />;
if (s === "Rejected") return <XCircle className="h-4 w-4 text-destructive" />;
return <Clock className="h-4 w-4 text-warning" />;
};
export default function ApprovalWorkflowsPage() {
const { toast } = useToast();
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Approval Workflows</h1>
<p className="text-muted-foreground">Manage multi-step approval processes for exam content.</p>
</div>
<Dialog>
<DialogTrigger asChild>
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Workflow</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Workflow</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Workflow Name</Label><Input placeholder="e.g. Exam Content Review" /></div>
<div className="space-y-2">
<Label>Step 1 Assignee</Label>
<Select><SelectTrigger><SelectValue placeholder="Select assignee" /></SelectTrigger>
<SelectContent><SelectItem value="smith">Dr. Smith</SelectItem><SelectItem value="lee">Prof. Lee</SelectItem><SelectItem value="kim">Mr. Kim</SelectItem></SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Step 2 Assignee</Label>
<Select><SelectTrigger><SelectValue placeholder="Select assignee" /></SelectTrigger>
<SelectContent><SelectItem value="smith">Dr. Smith</SelectItem><SelectItem value="lee">Prof. Lee</SelectItem><SelectItem value="admin">Admin</SelectItem></SelectContent>
</Select>
</div>
<Button className="w-full">Create</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="space-y-4">
{workflows.map((wf) => (
<Card key={wf.id} className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold">{wf.name}</CardTitle>
<Badge variant={wf.status === "Completed" ? "default" : wf.status === "Rejected" ? "destructive" : "secondary"}>{wf.status}</Badge>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
{wf.steps.map((step, i) => (
<div key={i} className="flex items-center gap-2">
<div className="flex items-center gap-2 rounded-lg border p-3 bg-muted/30 min-w-[160px]">
{statusIcon(step.status)}
<div>
<p className="text-sm font-medium">{step.name}</p>
<p className="text-xs text-muted-foreground">{step.assignee}</p>
</div>
</div>
{i < wf.steps.length - 1 && <div className="w-8 h-0.5 bg-border" />}
</div>
))}
</div>
{wf.status === "In Progress" && (
<div className="space-y-4 mt-4">
<AiGradingAssistant onAccept={(marks, feedback) => {
toast({ title: "AI Grade Accepted", description: `Marks: ${marks}/100 applied with AI feedback.` });
}} />
<div className="flex gap-2">
<Button size="sm" variant="default">Approve</Button>
<Button size="sm" variant="outline">Reject</Button>
</div>
</div>
)}
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
import { useState } from "react";
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search, Plus, Trash2 } from "lucide-react";
import { useAssignments, useCreateAssignment } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
export default function AssignmentsPage() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ title: "", entity_id: "", start_date: "", end_date: "" });
const { toast } = useToast();
const assignmentsQ = useAssignments({ size: 200 });
const createMut = useCreateAssignment();
const items = assignmentsQ.data?.items ?? assignmentsQ.data?.data ?? [];
const assignments = Array.isArray(items) ? items : [];
const q = search.toLowerCase();
const filtered = assignments.filter(a =>
a.title?.toLowerCase().includes(q) || a.entity_name?.toLowerCase().includes(q),
);
const loading = assignmentsQ.isLoading;
function handleCreate() {
createMut.mutate(
{ title: form.title, entity_id: Number(form.entity_id) || 0, start_date: form.start_date, end_date: form.end_date },
{
onSuccess: () => { setCreateOpen(false); setForm({ title: "", entity_id: "", start_date: "", end_date: "" }); toast({ title: "Assignment created" }); },
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
},
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Assignments</h1>
<p className="text-muted-foreground">Create and manage assignments.</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Create Assignment
</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search assignments..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
{loading ? (
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
) : (
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Entity</TableHead>
<TableHead>Start</TableHead>
<TableHead>End</TableHead>
<TableHead>State</TableHead>
<TableHead>Assignees</TableHead>
<TableHead>Completed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No assignments found.</TableCell></TableRow>
)}
{filtered.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">{a.title}</TableCell>
<TableCell>{a.entity_name || "—"}</TableCell>
<TableCell>{a.start_date || "—"}</TableCell>
<TableCell>{a.end_date || "—"}</TableCell>
<TableCell><Badge variant={a.state === "active" ? "default" : "secondary"} className="capitalize">{a.state}</Badge></TableCell>
<TableCell>{a.assignee_count ?? 0}</TableCell>
<TableCell>{a.completed_count ?? 0}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create Assignment</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Title</Label><Input value={form.title} onChange={(e) => setForm(f => ({ ...f, title: e.target.value }))} placeholder="e.g. IELTS Prep Q2" /></div>
<div className="space-y-2"><Label>Entity ID</Label><Input type="number" value={form.entity_id} onChange={(e) => setForm(f => ({ ...f, entity_id: e.target.value }))} /></div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2"><Label>Start Date</Label><Input type="date" value={form.start_date} onChange={(e) => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
<div className="space-y-2"><Label>End Date</Label><Input type="date" value={form.end_date} onChange={(e) => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button disabled={createMut.isPending || !form.title} onClick={handleCreate}>
{createMut.isPending ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,133 @@
import { useState } from "react";
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search, Plus, Trash2, Users } from "lucide-react";
import { useBatches, useCourses } from "@/hooks/queries";
import { lmsService } from "@/services/lms.service";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
export default function ClassroomsPage() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" });
const { toast } = useToast();
const qc = useQueryClient();
const batchesQ = useBatches({ size: 200 });
const coursesQ = useCourses({ size: 200 });
const batches = batchesQ.data?.items ?? [];
const courses = coursesQ.data?.items ?? [];
const createMut = useMutation({
mutationFn: (data: Parameters<typeof lmsService.createBatch>[0]) => lmsService.createBatch(data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "batches"] }); setCreateOpen(false); setForm({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" }); toast({ title: "Classroom created" }); },
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => lmsService.deleteBatch(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "batches"] }); toast({ title: "Deleted" }); },
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const q = search.toLowerCase();
const filtered = batches.filter(b => b.name.toLowerCase().includes(q) || b.course_name?.toLowerCase().includes(q));
const loading = batchesQ.isLoading;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Classrooms</h1>
<p className="text-muted-foreground">Create and manage batches (classrooms).</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Create Classroom
</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search classrooms..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
{loading ? (
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
) : (
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Course</TableHead>
<TableHead>Students</TableHead>
<TableHead>Capacity</TableHead>
<TableHead>Dates</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No classrooms found.</TableCell></TableRow>
)}
{filtered.map((b) => (
<TableRow key={b.id}>
<TableCell className="font-medium">{b.name}</TableCell>
<TableCell>{b.course_name}</TableCell>
<TableCell><Badge variant="secondary"><Users className="h-3 w-3 mr-1" />{b.student_count}</Badge></TableCell>
<TableCell>{b.capacity}</TableCell>
<TableCell className="text-sm">{b.start_date} {b.end_date}</TableCell>
<TableCell><Badge variant={b.status === "active" ? "default" : "secondary"} className="capitalize">{b.status}</Badge></TableCell>
<TableCell>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => { if (window.confirm(`Delete "${b.name}"?`)) deleteMut.mutate(b.id); }}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create Classroom</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Name</Label><Input value={form.name} onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))} placeholder="e.g. IELTS Prep Group A" /></div>
<div className="space-y-2">
<Label>Course</Label>
<Select value={form.course_id} onValueChange={(v) => setForm(f => ({ ...f, course_id: v }))}>
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
<SelectContent>
{courses.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.title || c.code}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2"><Label>Start Date</Label><Input type="date" value={form.start_date} onChange={(e) => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
<div className="space-y-2"><Label>End Date</Label><Input type="date" value={form.end_date} onChange={(e) => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
</div>
<div className="space-y-2"><Label>Max Students</Label><Input type="number" value={form.max_students} onChange={(e) => setForm(f => ({ ...f, max_students: e.target.value }))} placeholder="30" /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button disabled={createMut.isPending || !form.name} onClick={() => createMut.mutate({ name: form.name, course_id: Number(form.course_id) || undefined, start_date: form.start_date || undefined, end_date: form.end_date || undefined, capacity: Number(form.max_students) || undefined } as never)}>
{createMut.isPending ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,191 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import { GraduationCap, Loader2 } from "lucide-react";
import { useResendOtp, useVerifyEmail } from "@/hooks/queries/useSignup";
import { ApiError } from "@/lib/api-client";
const RESEND_LIMIT = 3;
const COOLDOWN_SEC = 60;
function errorMessage(err: unknown): string {
if (err instanceof ApiError) return err.message;
if (err instanceof Error) return err.message;
return "Verification failed";
}
export default function EmailVerification() {
const [searchParams] = useSearchParams();
const email = searchParams.get("email")?.trim() || "";
const navigate = useNavigate();
const verify = useVerifyEmail();
const resend = useResendOtp();
const [otp, setOtp] = useState("");
const [error, setError] = useState<string | null>(null);
const [resendAttempts, setResendAttempts] = useState(0);
const [cooldown, setCooldown] = useState(0);
const submittingRef = useRef(false);
const lastSubmittedOtp = useRef<string | null>(null);
useEffect(() => {
if (otp.length < 6) lastSubmittedOtp.current = null;
}, [otp]);
useEffect(() => {
if (cooldown <= 0) return;
const t = window.setInterval(() => {
setCooldown((c) => (c <= 1 ? 0 : c - 1));
}, 1000);
return () => window.clearInterval(t);
}, [cooldown]);
const runVerify = useCallback(
async (code: string) => {
if (!email || code.length !== 6) return;
setError(null);
submittingRef.current = true;
try {
await verify.mutateAsync({ email, otp: code });
navigate("/onboarding", { replace: true });
} catch (e) {
lastSubmittedOtp.current = null;
setError(errorMessage(e));
} finally {
submittingRef.current = false;
}
},
[email, navigate, verify],
);
useEffect(() => {
if (otp.length !== 6 || submittingRef.current || verify.isPending) return;
if (lastSubmittedOtp.current === otp) return;
lastSubmittedOtp.current = otp;
void runVerify(otp);
}, [otp, runVerify, verify.isPending]);
const handleResend = async () => {
if (!email || cooldown > 0 || resendAttempts >= RESEND_LIMIT || resend.isPending) return;
setError(null);
try {
await resend.mutateAsync(email);
setResendAttempts((n) => n + 1);
setCooldown(COOLDOWN_SEC);
} catch (e) {
setError(errorMessage(e));
}
};
if (!email) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-muted/40 to-background p-4">
<Card className="w-full max-w-md border-0 shadow-lg">
<CardHeader className="text-center">
<CardTitle>Missing email</CardTitle>
<CardDescription>Add an email query parameter or return to registration.</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<Button asChild variant="outline">
<Link to="/register">Back to registration</Link>
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-muted/40 to-background p-4">
<div className="w-full max-w-md space-y-8">
<div className="flex items-center justify-center gap-2">
<div className="h-10 w-10 rounded-xl bg-primary flex items-center justify-center shadow-sm">
<GraduationCap className="h-6 w-6 text-primary-foreground" />
</div>
<h1 className="text-2xl font-bold tracking-tight">Verify your email</h1>
</div>
<Card className="border-0 shadow-xl bg-card/80 backdrop-blur-sm">
<CardHeader className="text-center space-y-2">
<CardTitle className="text-lg">Check your inbox</CardTitle>
<CardDescription className="text-base leading-relaxed">
We&apos;ve sent a 6-digit verification code to{" "}
<span className="font-medium text-foreground">{email}</span>
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-col items-center gap-4">
<InputOTP maxLength={6} value={otp} onChange={setOtp} disabled={verify.isPending}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
{verify.isPending && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Verifying
</div>
)}
</div>
{error && (
<p className="text-center text-sm text-destructive bg-destructive/10 rounded-md py-2 px-3">{error}</p>
)}
<Button
className="w-full"
size="lg"
onClick={() => void runVerify(otp)}
disabled={otp.length !== 6 || verify.isPending}
>
{verify.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying
</>
) : (
"Verify"
)}
</Button>
<div className="text-center text-sm space-y-2">
<p className="text-muted-foreground">
Resend attempts:{" "}
<span className="font-medium text-foreground">
{resendAttempts}/{RESEND_LIMIT}
</span>
</p>
<button
type="button"
onClick={() => void handleResend()}
disabled={cooldown > 0 || resendAttempts >= RESEND_LIMIT || resend.isPending}
className="text-primary font-medium hover:underline disabled:opacity-50 disabled:no-underline"
>
{resend.isPending ? (
"Sending…"
) : cooldown > 0 ? (
`Resend code in ${cooldown}s`
) : (
"Resend code"
)}
</button>
</div>
<p className="text-center text-sm text-muted-foreground">
Wrong address?{" "}
<Link to="/register" className="text-primary font-medium hover:underline">
Change email
</Link>
</p>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Search, Plus, Building2, Trash2 } from "lucide-react";
import { useDepartments, useCreateDepartment, useDeleteDepartment } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
export default function EntitiesPage() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: "", code: "" });
const { toast } = useToast();
const deptsQ = useDepartments({ size: 200 });
const createMut = useCreateDepartment();
const deleteMut = useDeleteDepartment();
const depts = deptsQ.data?.items ?? deptsQ.data?.data ?? [];
const departments = Array.isArray(depts) ? depts : [];
const filtered = departments.filter(d => d.name?.toLowerCase().includes(search.toLowerCase()));
const loading = deptsQ.isLoading;
function handleCreate() {
createMut.mutate(
{ name: form.name, code: form.code || undefined },
{
onSuccess: () => { setCreateOpen(false); setForm({ name: "", code: "" }); toast({ title: "Department created" }); },
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
},
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Entities / Departments</h1>
<p className="text-muted-foreground">Manage organizational departments.</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Create Department
</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search departments..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
{loading ? (
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.length === 0 && <p className="col-span-full text-center text-muted-foreground py-8">No departments found.</p>}
{filtered.map((d) => (
<Card key={d.id} className="border-0 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Building2 className="h-4 w-4 text-primary" />
{d.name}
</CardTitle>
<Button size="sm" variant="ghost" className="text-destructive" onClick={() => { if (window.confirm(`Delete "${d.name}"?`)) deleteMut.mutate(d.id, { onSuccess: () => toast({ title: "Deleted" }), onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }) }); }}>
<Trash2 className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{d.code || "No code"}</p>
</CardContent>
</Card>
))}
</div>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create Department</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Name</Label><Input value={form.name} onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))} placeholder="Department name" /></div>
<div className="space-y-2"><Label>Code</Label><Input value={form.code} onChange={(e) => setForm(f => ({ ...f, code: e.target.value }))} placeholder="DEPT01" /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button disabled={createMut.isPending || !form.name} onClick={handleCreate}>
{createMut.isPending ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

54
src/pages/ExamPage.tsx Normal file
View File

@@ -0,0 +1,54 @@
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { GraduationCap, Play, Sparkles } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
export default function ExamPage() {
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] gap-4 max-w-md mx-auto">
<AiTipBanner tip="Based on your practice history, focus on Reading Part 3 (sentence completion) — your accuracy there is 58% vs 82% average. Budget 20 min for the writing section." variant="recommendation" />
<Card className="border-0 shadow-sm w-full">
<CardContent className="p-8 text-center space-y-6">
<div className="mx-auto h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
<GraduationCap className="h-8 w-8 text-primary" />
</div>
<div>
<h2 className="text-xl font-bold mb-1">Ready to Start?</h2>
<p className="text-muted-foreground text-sm">IELTS Academic Mock Exam</p>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Student</span>
<span className="font-medium">Sarah Johnson</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Level</span>
<Badge variant="outline">B2</Badge>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Modules</span>
<span className="font-medium">4</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Duration</span>
<span className="font-medium">180 min</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Mode</span>
<Badge variant="secondary">Practice</Badge>
</div>
</div>
<div className="rounded-lg bg-primary/5 border border-primary/20 p-3 text-left">
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-1"><Sparkles className="h-3 w-3" /> AI Pre-Exam Tip</p>
<p className="text-xs text-muted-foreground">Your last mock scored 7.5. To target 8.0, focus on time management in Writing Task 2 you spent 45 min last time vs recommended 40 min.</p>
</div>
<Button className="w-full" size="lg">
<Play className="h-4 w-4 mr-2" /> Begin Exam
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
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 { Search, Plus, Layers, Trash2 } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
const structures = [
{ id: 1, name: "Standard IELTS Academic", entity: "Global", industry: "General", modules: ["Reading", "Listening", "Writing", "Speaking"] },
{ id: 2, name: "Corporate English Assessment", entity: "Acme Corp", industry: "Technology", modules: ["Reading", "Writing", "Speaking"] },
{ id: 3, name: "Hospitality English Test", entity: "EduGroup", industry: "Hospitality", modules: ["Listening", "Speaking"] },
{ id: 4, name: "Medical English Proficiency", entity: "Global", industry: "Healthcare", modules: ["Reading", "Listening", "Writing", "Speaking"] },
];
export default function ExamStructuresPage() {
const [search, setSearch] = useState("");
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Exam Structures</h1>
<p className="text-muted-foreground">Define exam structure templates by entity and industry.</p>
</div>
<div className="flex gap-2">
<AiCreationAssistant type="exam" />
<Dialog>
<DialogTrigger asChild>
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Structure</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Exam Structure</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Structure Name</Label><Input placeholder="e.g. Corporate Writing Test" /></div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2"><Label>Entity</Label><Select><SelectTrigger><SelectValue placeholder="Entity" /></SelectTrigger><SelectContent><SelectItem value="global">Global</SelectItem><SelectItem value="acme">Acme Corp</SelectItem></SelectContent></Select></div>
<div className="space-y-2"><Label>Industry</Label><Select><SelectTrigger><SelectValue placeholder="Industry" /></SelectTrigger><SelectContent><SelectItem value="general">General</SelectItem><SelectItem value="tech">Technology</SelectItem><SelectItem value="health">Healthcare</SelectItem></SelectContent></Select></div>
</div>
<Button className="w-full">Create</Button>
</div>
</DialogContent>
</Dialog>
</div>
</div>
<AiTipBanner context="exam-structures" variant="insight" />
<div className="flex gap-3 items-center">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search structures..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Select><SelectTrigger className="w-[140px]"><SelectValue placeholder="Entity" /></SelectTrigger><SelectContent><SelectItem value="all">All</SelectItem></SelectContent></Select>
<Select><SelectTrigger className="w-[140px]"><SelectValue placeholder="Industry" /></SelectTrigger><SelectContent><SelectItem value="all">All</SelectItem></SelectContent></Select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{structures.map((s) => (
<Card key={s.id} className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Layers className="h-4 w-4 text-primary" />{s.name}
</CardTitle>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"><Trash2 className="h-4 w-4" /></Button>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-3">
<span>Entity: <span className="text-foreground font-medium">{s.entity}</span></span>
<span>Industry: <span className="text-foreground font-medium">{s.industry}</span></span>
</div>
<div className="flex gap-1.5 flex-wrap">{s.modules.map(m => <Badge key={m} variant="outline">{m}</Badge>)}</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { useState } from "react";
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search } from "lucide-react";
import { useInstitutionalExamSessions } from "@/hooks/queries";
export default function ExamsListPage() {
const [search, setSearch] = useState("");
const sessionsQ = useInstitutionalExamSessions();
const items = sessionsQ.data?.data ?? sessionsQ.data?.items ?? [];
const sessions = Array.isArray(items) ? items : [];
const q = search.toLowerCase();
const filtered = sessions.filter(s =>
s.name?.toLowerCase().includes(q) || s.course_name?.toLowerCase().includes(q),
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Exams List</h1>
<p className="text-muted-foreground">Browse and manage all exam sessions.</p>
</div>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search exams..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
{sessionsQ.isLoading ? (
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
) : (
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Name</TableHead>
<TableHead>Course</TableHead>
<TableHead>Batch</TableHead>
<TableHead>Start</TableHead>
<TableHead>End</TableHead>
<TableHead>State</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No exam sessions found.</TableCell></TableRow>
)}
{filtered.map((s, i) => (
<TableRow key={s.id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell>{s.course_name || "—"}</TableCell>
<TableCell>{s.batch_name || "—"}</TableCell>
<TableCell>{s.start_date || "—"}</TableCell>
<TableCell>{s.end_date || "—"}</TableCell>
<TableCell>
<Badge variant={s.state === "done" ? "default" : s.state === "schedule" ? "secondary" : "outline"} className="capitalize">{s.state}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
}

96
src/pages/FaqPage.tsx Normal file
View File

@@ -0,0 +1,96 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Search, HelpCircle, Video } from "lucide-react";
import { useFaqCategories, useFaqItems } from "@/hooks/queries";
export default function FaqPage() {
const [search, setSearch] = useState("");
const { data: categories = [], isLoading: lc } = useFaqCategories();
const { data: allItems = [], isLoading: li } = useFaqItems(search ? { search } : undefined);
const filteredCategories = categories.filter(cat =>
allItems.some(item => item.category_id === cat.id),
);
const getItemsForCategory = (catId: number) =>
allItems.filter(item => item.category_id === catId);
if (lc || li) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6 max-w-3xl mx-auto">
<div className="text-center">
<h1 className="text-3xl font-bold">Frequently Asked Questions</h1>
<p className="text-muted-foreground mt-2">Find answers to common questions about EnCoach.</p>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
className="pl-10"
placeholder="Search questions..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
{filteredCategories.length > 0 ? (
<div className="space-y-6">
{filteredCategories.map(cat => {
const catItems = getItemsForCategory(cat.id);
if (catItems.length === 0) return null;
return (
<Card key={cat.id}>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<HelpCircle className="h-5 w-5 text-primary" />
<CardTitle className="text-lg">{cat.name}</CardTitle>
<Badge variant="secondary" className="text-xs">{cat.item_count} questions</Badge>
</div>
</CardHeader>
<CardContent>
<Accordion type="multiple">
{catItems
.sort((a, b) => a.sequence - b.sequence)
.map(item => (
<AccordionItem key={item.id} value={String(item.id)}>
<AccordionTrigger className="text-left text-sm font-medium">
{item.question}
</AccordionTrigger>
<AccordionContent>
<div className="space-y-2">
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{item.answer}</p>
{item.video_url && (
<a
href={item.video_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<Video className="h-4 w-4" /> Watch video
</a>
)}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CardContent>
</Card>
);
})}
</div>
) : (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<HelpCircle className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>{search ? "No questions match your search." : "No FAQ items available yet."}</p>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { GraduationCap, ArrowLeft, CheckCircle } from "lucide-react";
export default function ForgotPassword() {
const [sent, setSent] = useState(false);
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<div className="flex items-center justify-center gap-2 mb-8">
<div className="h-10 w-10 rounded-xl bg-primary flex items-center justify-center">
<GraduationCap className="h-6 w-6 text-primary-foreground" />
</div>
<h1 className="text-2xl font-bold tracking-tight">EnCoach</h1>
</div>
<Card className="shadow-lg border-0 bg-card">
<CardHeader className="text-center pb-4">
{sent ? (
<>
<div className="mx-auto mb-2 h-12 w-12 rounded-full bg-success/10 flex items-center justify-center">
<CheckCircle className="h-6 w-6 text-success" />
</div>
<CardTitle className="text-xl">Check your email</CardTitle>
<CardDescription>We've sent a password reset link to your email address.</CardDescription>
</>
) : (
<>
<CardTitle className="text-xl">Forgot password?</CardTitle>
<CardDescription>Enter your email and we'll send you a reset link.</CardDescription>
</>
)}
</CardHeader>
<CardContent>
{!sent ? (
<form onSubmit={(e) => { e.preventDefault(); setSent(true); }} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="your@email.com" />
</div>
<Button type="submit" className="w-full">Send Reset Link</Button>
</form>
) : null}
<div className="mt-6 text-center">
<Link to="/login" className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
<ArrowLeft className="h-3 w-3" /> Back to sign in
</Link>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { useState } from "react";
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 { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Wand2, CheckCircle } from "lucide-react";
import AiGeneratorModal from "@/components/ai/AiGeneratorModal";
import AiTipBanner from "@/components/ai/AiTipBanner";
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
export default function GenerationPage() {
const [submitted, setSubmitted] = useState(false);
if (submitted) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Card className="border-0 shadow-sm max-w-md w-full">
<CardContent className="p-8 text-center">
<div className="mx-auto mb-4 h-16 w-16 rounded-full bg-success/10 flex items-center justify-center">
<CheckCircle className="h-8 w-8 text-success" />
</div>
<h2 className="text-xl font-bold mb-2">Exam Generated!</h2>
<p className="text-muted-foreground mb-6">Your exam has been created successfully and is ready for review.</p>
<Button onClick={() => setSubmitted(false)}>Generate Another</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="text-2xl font-bold tracking-tight">Exam Generation</h1>
<p className="text-muted-foreground">Generate a new exam from structure templates.</p>
</div>
<AiTipBanner tip="AI can auto-generate a full exam with questions, rubric linkage, and difficulty balancing. Try 'AI Generate Exam' for instant creation." variant="recommendation" />
<div className="flex gap-2">
<AiGeneratorModal />
<AiCreationAssistant type="exam" />
</div>
<Card className="border-0 shadow-sm">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2"><Wand2 className="h-4 w-4 text-primary" /> Generation Form</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={(e) => { e.preventDefault(); setSubmitted(true); }} className="space-y-5">
<div className="space-y-2"><Label>Exam Title</Label><Input placeholder="e.g. Q2 2025 IELTS Mock" /></div>
<div className="space-y-2"><Label>Exam Label</Label><Input placeholder="e.g. MOCK-Q2-2025" /></div>
<div className="space-y-2">
<Label>Exam Structure</Label>
<Select><SelectTrigger><SelectValue placeholder="Select structure" /></SelectTrigger>
<SelectContent>
<SelectItem value="standard">Standard IELTS Academic</SelectItem>
<SelectItem value="corporate">Corporate English Assessment</SelectItem>
<SelectItem value="hospitality">Hospitality English Test</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Modules</Label>
<div className="flex flex-wrap gap-4">
{["Reading", "Listening", "Writing", "Speaking"].map((m) => (
<div key={m} className="flex items-center gap-2">
<Checkbox id={`gen-${m}`} defaultChecked /><Label htmlFor={`gen-${m}`} className="text-sm font-normal">{m}</Label>
</div>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Level</Label>
<Select><SelectTrigger><SelectValue placeholder="Level" /></SelectTrigger>
<SelectContent>{["A1","A2","B1","B2","C1","C2"].map(l => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Industry</Label>
<Select><SelectTrigger><SelectValue placeholder="Industry" /></SelectTrigger>
<SelectContent><SelectItem value="general">General</SelectItem><SelectItem value="tech">Technology</SelectItem><SelectItem value="health">Healthcare</SelectItem></SelectContent>
</Select>
</div>
</div>
<Button type="submit" className="w-full"><Wand2 className="h-4 w-4 mr-2" /> Generate Exam</Button>
</form>
</CardContent>
</Card>
</div>
);
}

94
src/pages/GrammarPage.tsx Normal file
View File

@@ -0,0 +1,94 @@
import { useState } from "react";
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, PenTool, Sparkles } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
const grammarItems = [
{ rule: "Conditional Sentences (Type 2 & 3)", description: "If + past simple / past perfect for hypothetical situations", level: "B2", completed: true },
{ rule: "Passive Voice in Academic Writing", description: "Using passive constructions in formal reports", level: "B2", completed: false },
{ rule: "Relative Clauses", description: "Defining vs non-defining relative clauses", level: "B1", completed: true },
{ rule: "Subject-Verb Agreement", description: "Complex subjects with prepositional phrases", level: "B1", completed: false },
{ rule: "Modal Verbs for Speculation", description: "Must have, could have, might have + past participle", level: "C1", completed: false },
{ rule: "Reported Speech", description: "Tense changes and time references in indirect speech", level: "B2", completed: true },
];
export default function GrammarPage() {
const [showCompleted, setShowCompleted] = useState(false);
const completed = grammarItems.filter(g => g.completed).length;
const filtered = showCompleted ? grammarItems : grammarItems.filter(g => !g.completed);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Grammar Training</h1>
<p className="text-muted-foreground">Master grammar rules essential for IELTS.</p>
</div>
<AiTipBanner tip="You've completed 50% of grammar topics. Focus on Passive Voice next — it appears in 73% of IELTS Writing Task 1 questions and will boost your band score." variant="recommendation" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Progress</CardTitle>
<span className="text-sm text-muted-foreground">{completed}/{grammarItems.length} completed</span>
</div>
</CardHeader>
<CardContent>
<Progress value={(completed / grammarItems.length) * 100} className="h-3" />
</CardContent>
</Card>
<div className="flex items-center gap-2">
<Switch id="show" checked={showCompleted} onCheckedChange={setShowCompleted} />
<Label htmlFor="show" className="text-sm">Show completed</Label>
</div>
<div className="space-y-2">
{filtered.map((g) => (
<Card key={g.rule} className="border-0 shadow-sm">
<CardContent className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
{g.completed && <CheckCircle className="h-4 w-4 text-success shrink-0" />}
<div>
<p className="font-semibold text-sm">{g.rule}</p>
<p className="text-xs text-muted-foreground">{g.description}</p>
</div>
</div>
<Badge variant="outline">{g.level}</Badge>
</CardContent>
</Card>
))}
</div>
</div>
<div className="space-y-4">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2"><Sparkles className="h-4 w-4 text-primary" /> AI Recommendations</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Practice passive voice transformations high exam frequency</li>
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Review modal verbs for IELTS Writing Task 1</li>
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Focus on complex sentence structures for C1 readiness</li>
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Your conditional sentences are strong try advanced mixed conditionals</li>
</ul>
</CardContent>
</Card>
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
<CardContent className="p-4">
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-2"><Sparkles className="h-3 w-3" /> AI Study Plan</p>
<p className="text-sm text-muted-foreground">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.</p>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

14
src/pages/Index.tsx Normal file
View File

@@ -0,0 +1,14 @@
// Update this page (the content is just a fallback if you fail to update the page)
const Index = () => {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">Welcome to Your Blank App</h1>
<p className="text-xl text-muted-foreground">Start building your amazing project here!</p>
</div>
</div>
);
};
export default Index;

141
src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,141 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Eye, EyeOff, Loader2 } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { useToast } from "@/hooks/use-toast";
import { ApiError } from "@/lib/api-client";
import type { UserRole } from "@/types/auth";
/** Keep in sync with `ProtectedRoute` post-login targets */
function getRoleDashboard(role: string): string {
switch (role as UserRole) {
case "student":
return "/student/dashboard";
case "teacher":
return "/teacher/dashboard";
case "admin":
case "developer":
return "/admin/dashboard";
case "corporate":
case "mastercorporate":
case "agent":
return "/admin/platform";
default:
return "/admin/dashboard";
}
}
function loginErrorMessage(err: unknown): string {
if (err instanceof ApiError && err.data && typeof err.data === "object" && err.data !== null && "error" in err.data) {
return String((err.data as { error: string }).error);
}
if (err instanceof Error) {
return err.message;
}
return "Login failed";
}
export default function Login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { login } = useAuth();
const { toast } = useToast();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) {
toast({ title: "Error", description: "Please enter email and password", variant: "destructive" });
return;
}
setLoading(true);
try {
const user = await login(email, password);
navigate(getRoleDashboard(user.user_type));
} catch (err: unknown) {
toast({ title: "Login Failed", description: loginErrorMessage(err), variant: "destructive" });
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<div className="flex items-center justify-center gap-3 mb-8">
<img src="/logo.png" alt="EnCoach" className="h-12 w-12 rounded-xl object-contain" />
<h1 className="text-2xl font-bold tracking-tight">
<span className="text-[hsl(42,40%,62%)]">En</span>
<span className="text-[hsl(240,20%,30%)]">Coach</span>
</h1>
</div>
<Card className="shadow-lg border-0 bg-card">
<CardHeader className="text-center pb-4">
<CardTitle className="text-xl">Welcome back</CardTitle>
<CardDescription>Sign in to your account to continue</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox id="remember" />
<Label htmlFor="remember" className="text-sm font-normal cursor-pointer">Remember me</Label>
</div>
<Link to="/forgot-password" className="text-sm text-primary hover:underline">Forgot password?</Link>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Sign in
</Button>
</form>
<p className="mt-6 text-center text-sm text-muted-foreground">
Don't have an account?{" "}
<Link to="/register" className="text-primary font-medium hover:underline">Sign up</Link>
</p>
</CardContent>
</Card>
</div>
</div>
);
}

24
src/pages/NotFound.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { useLocation } from "react-router-dom";
import { useEffect } from "react";
const NotFound = () => {
const location = useLocation();
useEffect(() => {
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
}, [location.pathname]);
return (
<div className="flex min-h-screen items-center justify-center bg-muted">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">404</h1>
<p className="mb-4 text-xl text-muted-foreground">Oops! Page not found</p>
<a href="/" className="text-primary underline hover:text-primary/90">
Return to Home
</a>
</div>
</div>
);
};
export default NotFound;

View File

@@ -0,0 +1,88 @@
import { useSearchParams, useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Loader2, Clock, AlertCircle, ClipboardList } from "lucide-react";
import { API_BASE_URL } from "@/lib/api-client";
import { examsService } from "@/services/exams.service";
import { useQuery } from "@tanstack/react-query";
export default function OfficialExamAccess() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get("token");
const { data: examInfo, isLoading, error } = useQuery({
queryKey: ["exam", "access", token],
queryFn: async () => {
const res = await fetch(`${API_BASE_URL}/exam/access/${token}`);
if (!res.ok) throw new Error(res.status === 404 ? "Invalid or expired token" : "Failed to load exam");
return res.json() as Promise<{ id: number; name: string; duration_minutes: number; start_time: string; module: string }>;
},
enabled: !!token,
retry: false,
});
const handleStart = () => {
if (examInfo) {
navigate(`/exam/${examInfo.module}/${examInfo.id}?token=${token}`);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
{!token ? (
<CardContent className="py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-3" />
<h2 className="text-lg font-semibold">No Access Token</h2>
<p className="text-sm text-muted-foreground mt-1">Please use the link provided by your institution to access the exam.</p>
</CardContent>
) : isLoading ? (
<CardContent className="py-12 text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
<p className="text-sm text-muted-foreground mt-3">Loading exam information...</p>
</CardContent>
) : error ? (
<CardContent className="py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-3" />
<h2 className="text-lg font-semibold">Access Denied</h2>
<p className="text-sm text-muted-foreground mt-1">{(error as Error).message}</p>
</CardContent>
) : examInfo ? (
<>
<CardHeader className="text-center">
<div className="h-14 w-14 rounded-xl bg-primary/10 flex items-center justify-center mx-auto mb-2">
<ClipboardList className="h-7 w-7 text-primary" />
</div>
<CardTitle>{examInfo.name}</CardTitle>
<CardDescription>Official Exam Access</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3 p-4 rounded-lg border bg-muted/30">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Duration</span>
<div className="flex items-center gap-1">
<Clock className="h-4 w-4" />
<span className="font-medium">{examInfo.duration_minutes} minutes</span>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Start Time</span>
<span className="font-medium">{new Date(examInfo.start_time).toLocaleString()}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Module</span>
<Badge variant="outline">{examInfo.module}</Badge>
</div>
</div>
<Button className="w-full" size="lg" onClick={handleStart}>
Start Exam
</Button>
</CardContent>
</>
) : null}
</Card>
</div>
);
}

View File

@@ -0,0 +1,572 @@
import { useMemo, useState, type ComponentType } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
BookOpen,
GraduationCap,
Headphones,
Layers,
Loader2,
Mic,
PenLine,
Sparkles,
Users,
} from "lucide-react";
import { format } from "date-fns";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Progress } from "@/components/ui/progress";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Slider } from "@/components/ui/slider";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { cn } from "@/lib/utils";
import { useAuth } from "@/contexts/AuthContext";
import { useCompleteOnboarding, useGoals } from "@/hooks/queries/useSignup";
import { ApiError } from "@/lib/api-client";
import type { CEFRLevel, OnboardingData, StudyMode } from "@/types";
import type { UserRole } from "@/types/auth";
const steps = ["Goal", "Target", "Study", "Placement"] as const;
const cefrOptions: { value: CEFRLevel; label: string }[] = [
{ 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" },
];
const formSchema = z.object({
goal: z.string().min(1, "Choose a goal"),
targetBand: z.number().min(4).max(9),
targetLevel: z.string().min(1, "Select a CEFR level"),
examDate: z.date().optional(),
noExamDate: z.boolean(),
hoursPerWeek: z.number().min(1).max(40),
studyMode: z.enum(["self_study", "with_teacher"]),
learningVisual: z.boolean(),
learningAudio: z.boolean(),
learningReading: z.boolean(),
learningMixed: z.boolean(),
placementDecision: z.enum(["take_now", "skip"]),
});
type FormValues = z.infer<typeof formSchema>;
const iconMap: Record<string, ComponentType<{ className?: string }>> = {
BookOpen,
Sparkles,
GraduationCap,
Layers,
Mic,
Headphones,
PenLine,
Users,
};
function GoalIcon({ name }: { name: string }) {
const Icon = iconMap[name] || BookOpen;
return <Icon className="h-6 w-6" />;
}
function dashboardForRole(role: UserRole): string {
switch (role) {
case "student":
return "/student/dashboard";
case "teacher":
return "/teacher/dashboard";
case "admin":
case "developer":
return "/admin/dashboard";
case "corporate":
case "mastercorporate":
case "agent":
return "/admin/platform";
default:
return "/student/dashboard";
}
}
function toOnboardingPayload(values: FormValues): OnboardingData {
const learning_style: OnboardingData["learning_style"] = [];
if (values.learningVisual) learning_style.push("visual");
if (values.learningAudio) learning_style.push("audio");
if (values.learningReading) learning_style.push("reading");
if (values.learningMixed) learning_style.push("mixed");
return {
goal: values.goal,
target_band: values.targetBand,
target_level: values.targetLevel,
exam_date: values.noExamDate ? undefined : values.examDate ? format(values.examDate, "yyyy-MM-dd") : undefined,
hours_per_week: values.hoursPerWeek,
study_mode: values.studyMode as StudyMode,
learning_style,
placement_decision: values.placementDecision,
};
}
export default function OnboardingWizard() {
const { user, isLoading: authLoading } = useAuth();
const navigate = useNavigate();
const { data: goalsData, isLoading: goalsLoading, error: goalsError } = useGoals();
const complete = useCompleteOnboarding();
const [step, setStep] = useState(0);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
goal: "",
targetBand: 6.5,
targetLevel: "b2",
examDate: undefined,
noExamDate: false,
hoursPerWeek: 8,
studyMode: "self_study",
learningVisual: true,
learningAudio: false,
learningReading: false,
learningMixed: true,
placementDecision: "take_now",
},
mode: "onChange",
});
const progress = useMemo(() => ((step + 1) / steps.length) * 100, [step]);
const next = async () => {
const fields: (keyof FormValues)[][] = [
["goal"],
["targetBand", "targetLevel", "examDate", "noExamDate"],
["hoursPerWeek", "studyMode", "learningVisual", "learningAudio", "learningReading", "learningMixed"],
["placementDecision"],
];
if (step === 1) {
const noExam = form.getValues("noExamDate");
const exam = form.getValues("examDate");
if (!noExam && !exam) {
form.setError("examDate", { message: "Pick a date or check no exam date" });
return;
}
if (noExam) form.clearErrors("examDate");
}
if (step === 2) {
const v = form.getValues();
const styles = [v.learningVisual, v.learningAudio, v.learningReading, v.learningMixed].filter(Boolean);
if (styles.length === 0) {
form.setError("learningVisual", { message: "Select at least one learning style" });
return;
}
}
const ok =
step === steps.length - 1
? await form.trigger(undefined, { shouldFocus: true })
: await form.trigger(fields[step] as (keyof FormValues)[], { shouldFocus: true });
if (!ok) return;
if (step < steps.length - 1) setStep((s) => s + 1);
else {
const values = form.getValues();
try {
await complete.mutateAsync(toOnboardingPayload(values));
if (values.placementDecision === "take_now" && user.user_type === "student") {
navigate("/student/placement");
} else {
navigate(dashboardForRole(user.user_type));
}
} catch (e) {
form.setError("root", {
message: e instanceof ApiError ? e.message : "Could not save onboarding",
});
}
}
};
const back = () => {
if (step > 0) setStep((s) => s - 1);
};
if (authLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
if (!user.is_verified) {
return <Navigate to={`/verify-email?email=${encodeURIComponent(user.email)}`} replace />;
}
return (
<div className="min-h-screen bg-gradient-to-b from-muted/30 to-background py-10 px-4">
<div className="max-w-2xl mx-auto space-y-8">
<div className="text-center space-y-2">
<h1 className="text-2xl font-bold tracking-tight">Welcome to EnCoach</h1>
<p className="text-muted-foreground text-sm">Tailor your learning path in a few steps.</p>
</div>
<div className="space-y-2">
<div className="flex justify-between text-xs font-medium text-muted-foreground uppercase tracking-wide">
<span>
Step {step + 1} of {steps.length}
</span>
<span>{steps[step]}</span>
</div>
<Progress value={progress} className="h-2" />
</div>
<Card className="border-0 shadow-lg">
<CardHeader>
<CardTitle>{steps[step]}</CardTitle>
<CardDescription>
{step === 0 && "What is your primary learning goal?"}
{step === 1 && "Set your target band, level, and exam timeline."}
{step === 2 && "How do you prefer to study?"}
{step === 3 && "Start with a placement test or jump in later."}
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form className="space-y-6" onSubmit={(e) => e.preventDefault()}>
{goalsError && (
<Alert variant="destructive">
<AlertTitle>Could not load goals</AlertTitle>
<AlertDescription>Refresh the page or try again shortly.</AlertDescription>
</Alert>
)}
{step === 0 && (
<FormField
control={form.control}
name="goal"
render={({ field }) => (
<FormItem>
<FormControl>
{goalsLoading ? (
<div className="grid gap-3 sm:grid-cols-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-24 rounded-xl bg-muted animate-pulse" />
))}
</div>
) : (
<RadioGroup
onValueChange={field.onChange}
value={field.value}
className="grid gap-3 sm:grid-cols-2"
>
{goalsData?.goals.map((g) => (
<label
key={g.id}
className={cn(
"flex cursor-pointer items-start gap-3 rounded-xl border p-4 transition-colors hover:bg-muted/50",
field.value === g.id && "border-primary ring-2 ring-primary/20 bg-primary/5",
)}
>
<RadioGroupItem value={g.id} className="mt-1" />
<div className="flex gap-3 flex-1">
<div className="rounded-lg bg-primary/10 p-2 text-primary">
<GoalIcon name={g.icon} />
</div>
<div>
<div className="font-medium leading-tight">{g.title}</div>
<p className="text-sm text-muted-foreground mt-1">{g.description}</p>
</div>
</div>
</label>
))}
</RadioGroup>
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{step === 1 && (
<div className="space-y-8">
<FormField
control={form.control}
name="targetBand"
render={({ field }) => (
<FormItem>
<FormLabel>Target band (IELTS)</FormLabel>
<div className="pt-4 pb-2">
<Slider
min={4}
max={9}
step={0.5}
value={[field.value]}
onValueChange={(v) => field.onChange(v[0])}
/>
</div>
<p className="text-sm text-muted-foreground text-center font-medium tabular-nums">
{field.value.toFixed(1)}
</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="targetLevel"
render={({ field }) => (
<FormItem>
<FormLabel>Target CEFR level</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select level" />
</SelectTrigger>
</FormControl>
<SelectContent>
{cefrOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="noExamDate"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-2 space-y-0">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={(v) => field.onChange(!!v)} />
</FormControl>
<FormLabel className="font-normal">I don&apos;t have an exam date yet</FormLabel>
</FormItem>
)}
/>
{!form.watch("noExamDate") && (
<FormField
control={form.control}
name="examDate"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Exam date</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button variant="outline" className="w-full justify-start text-left font-normal">
{field.value ? format(field.value, "PPP") : "Pick a date"}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
disabled={(d) => d < new Date()}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
)}
{step === 2 && (
<div className="space-y-8">
<FormField
control={form.control}
name="hoursPerWeek"
render={({ field }) => (
<FormItem>
<FormLabel>Hours per week</FormLabel>
<div className="pt-4 pb-2">
<Slider
min={1}
max={40}
step={1}
value={[field.value]}
onValueChange={(v) => field.onChange(v[0])}
/>
</div>
<p className="text-sm text-muted-foreground text-center font-medium">{field.value} h</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="studyMode"
render={({ field }) => (
<FormItem>
<FormLabel>Study mode</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
value={field.value}
className="grid gap-3 sm:grid-cols-2"
>
<label
className={cn(
"flex cursor-pointer items-center gap-3 rounded-xl border p-4",
field.value === "self_study" && "border-primary ring-2 ring-primary/20",
)}
>
<RadioGroupItem value="self_study" />
<span>Self-study</span>
</label>
<label
className={cn(
"flex cursor-pointer items-center gap-3 rounded-xl border p-4",
field.value === "with_teacher" && "border-primary ring-2 ring-primary/20",
)}
>
<RadioGroupItem value="with_teacher" />
<span>With a teacher</span>
</label>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-3">
<FormLabel>Learning style</FormLabel>
<div className="grid sm:grid-cols-2 gap-3">
{(
[
["learningVisual", "Visual"],
["learningAudio", "Audio"],
["learningReading", "Reading"],
["learningMixed", "Mixed"],
] as const
).map(([name, label]) => (
<FormField
key={name}
control={form.control}
name={name}
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-2 space-y-0 rounded-lg border p-3">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormLabel className="font-normal cursor-pointer">{label}</FormLabel>
</FormItem>
)}
/>
))}
</div>
<FormField
control={form.control}
name="learningVisual"
render={() => <FormMessage />}
/>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-6">
<FormField
control={form.control}
name="placementDecision"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
value={field.value}
className="grid gap-4"
>
<label
className={cn(
"flex cursor-pointer flex-col gap-2 rounded-xl border p-5 transition-colors",
field.value === "take_now" && "border-primary bg-primary/5 ring-2 ring-primary/20",
)}
>
<div className="flex items-center gap-3">
<RadioGroupItem value="take_now" />
<span className="font-semibold">Take Placement Test Now</span>
</div>
<p className="text-sm text-muted-foreground pl-7">
Get a precise starting level across grammar, vocabulary, reading, and speaking.
</p>
</label>
<label
className={cn(
"flex cursor-pointer flex-col gap-2 rounded-xl border p-5 transition-colors",
field.value === "skip" && "border-primary bg-primary/5 ring-2 ring-primary/20",
)}
>
<div className="flex items-center gap-3">
<RadioGroupItem value="skip" />
<span className="font-semibold">Skip for Now</span>
</div>
<p className="text-sm text-muted-foreground pl-7">
You can take the placement test later from your dashboard. Your path may be less
personalized until you do.
</p>
</label>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.watch("placementDecision") === "skip" && (
<Alert>
<AlertTitle>Heads up</AlertTitle>
<AlertDescription>
Skipping the placement test means we estimate your level from your profile only. You can
always start the test when you are ready.
</AlertDescription>
</Alert>
)}
</div>
)}
{form.formState.errors.root && (
<p className="text-sm text-destructive text-center">{form.formState.errors.root.message}</p>
)}
<div className="flex justify-between gap-3 pt-2">
<Button type="button" variant="outline" onClick={back} disabled={step === 0 || complete.isPending}>
Back
</Button>
<Button type="button" onClick={() => void next()} disabled={complete.isPending}>
{complete.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving
</>
) : step === steps.length - 1 ? (
"Finish"
) : (
"Next"
)}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
import { useState } from "react";
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 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 },
];
export default function PaymentRecordPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Payment Record</h1>
<p className="text-muted-foreground">Manage payments, commissions, and Paymob orders.</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm"><Download className="h-4 w-4 mr-1" /> Export CSV</Button>
<Dialog>
<DialogTrigger asChild>
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> New Payment</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Payment Record</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Corporate</Label><Select><SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger><SelectContent><SelectItem value="acme">Acme Corp</SelectItem></SelectContent></Select></div>
<div className="space-y-2"><Label>Amount (USD)</Label><Input type="number" placeholder="5000" /></div>
<div className="space-y-2"><Label>Type</Label><Select><SelectTrigger><SelectValue placeholder="Type" /></SelectTrigger><SelectContent><SelectItem value="corporate">Corporate</SelectItem><SelectItem value="commission">Commission</SelectItem></SelectContent></Select></div>
<Button className="w-full">Create</Button>
</div>
</DialogContent>
</Dialog>
</div>
</div>
<AiTipBanner tip="PAY-003 (Tech Co) is unpaid and overdue. PMB-1004 failed — AI recommends sending an automated retry notification to Emma Brown." variant="recommendation" />
<Tabs defaultValue="payments">
<TabsList>
<TabsTrigger value="payments">Payments</TabsTrigger>
<TabsTrigger value="paymob">Paymob</TabsTrigger>
</TabsList>
<TabsContent value="payments" className="mt-4 space-y-4">
<AiReportNarrative narrative="Total revenue collected: $13,500 from 2 corporate payments. One commission of $2,000 remains unpaid. Collection rate: 67%. Trend: Q1 payments are on track but Tech Co requires follow-up." />
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead><TableHead>Corporate</TableHead><TableHead>Manager</TableHead>
<TableHead>Amount</TableHead><TableHead>Type</TableHead><TableHead>Paid</TableHead><TableHead>Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{payments.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-mono text-xs">{p.id}</TableCell>
<TableCell>{p.corporate}</TableCell>
<TableCell>{p.manager}</TableCell>
<TableCell className="font-semibold">${p.amount.toLocaleString()}</TableCell>
<TableCell><Badge variant="outline">{p.type}</Badge></TableCell>
<TableCell><Badge variant={p.paid ? "default" : "destructive"}>{p.paid ? "Paid" : "Unpaid"}</Badge></TableCell>
<TableCell>{p.date}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="paymob" className="mt-4">
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Order ID</TableHead><TableHead>Status</TableHead><TableHead>User</TableHead>
<TableHead>Email</TableHead><TableHead>Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paymobOrders.map((o) => (
<TableRow key={o.id}>
<TableCell className="font-mono text-xs">{o.id}</TableCell>
<TableCell><Badge variant={o.status === "Paid" ? "default" : o.status === "Pending" ? "secondary" : "destructive"}>{o.status}</Badge></TableCell>
<TableCell>{o.user}</TableCell>
<TableCell>{o.email}</TableCell>
<TableCell className="font-semibold">${o.amount.toFixed(2)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

75
src/pages/ProfilePage.tsx Normal file
View File

@@ -0,0 +1,75 @@
import { useState } from "react";
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 { User } from "lucide-react";
import { api } from "@/lib/api-client";
import { useMutation } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/contexts/AuthContext";
export default function ProfilePage() {
const { user } = useAuth();
const { toast } = useToast();
const nameParts = (user?.name || "Admin User").split(" ");
const [first, setFirst] = useState(nameParts[0] || "");
const [last, setLast] = useState(nameParts.slice(1).join(" ") || "");
const [email, setEmail] = useState(user?.email || "");
const [curPw, setCurPw] = useState("");
const [newPw, setNewPw] = useState("");
const [confirmPw, setConfirmPw] = useState("");
const saveMut = useMutation({
mutationFn: () => api.patch("/user", { first_name: first, last_name: last, email }),
onSuccess: () => toast({ title: "Profile saved" }),
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const pwMut = useMutation({
mutationFn: () => api.post("/user/change-password", { current_password: curPw, new_password: newPw }),
onSuccess: () => { setCurPw(""); setNewPw(""); setConfirmPw(""); toast({ title: "Password updated" }); },
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="text-2xl font-bold tracking-tight">Profile</h1>
<p className="text-muted-foreground">Manage your account information.</p>
</div>
<Card className="border-0 shadow-sm">
<CardHeader><CardTitle className="text-base">Personal Information</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4 mb-4">
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
<User className="h-8 w-8 text-primary" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"><Label>First Name</Label><Input value={first} onChange={(e) => setFirst(e.target.value)} /></div>
<div className="space-y-2"><Label>Last Name</Label><Input value={last} onChange={(e) => setLast(e.target.value)} /></div>
</div>
<div className="space-y-2"><Label>Email</Label><Input value={email} onChange={(e) => setEmail(e.target.value)} type="email" /></div>
<Button onClick={() => saveMut.mutate()} disabled={saveMut.isPending}>
{saveMut.isPending ? "Saving..." : "Save Changes"}
</Button>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardHeader><CardTitle className="text-base">Change Password</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2"><Label>Current Password</Label><Input type="password" value={curPw} onChange={(e) => setCurPw(e.target.value)} placeholder="••••••••" /></div>
<div className="space-y-2"><Label>New Password</Label><Input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} placeholder="••••••••" /></div>
<div className="space-y-2"><Label>Confirm New Password</Label><Input type="password" value={confirmPw} onChange={(e) => setConfirmPw(e.target.value)} placeholder="••••••••" /></div>
{newPw && confirmPw && newPw !== confirmPw && <p className="text-sm text-destructive">Passwords do not match.</p>}
<Button onClick={() => pwMut.mutate()} disabled={pwMut.isPending || !curPw || !newPw || newPw !== confirmPw}>
{pwMut.isPending ? "Updating..." : "Update Password"}
</Button>
</CardContent>
</Card>
</div>
);
}

68
src/pages/RecordPage.tsx Normal file
View File

@@ -0,0 +1,68 @@
import { Card, CardContent } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import AiTipBanner from "@/components/ai/AiTipBanner";
import AiReportNarrative from "@/components/ai/AiReportNarrative";
const records = [
{ id: 1, assignment: "IELTS Prep Q1", exam: "EX-001", date: "2025-03-01", score: 7.5, duration: "175 min", status: "Completed" },
{ id: 2, assignment: "Speaking Workshop", exam: "EX-005", date: "2025-03-05", score: 6.0, duration: "14 min", status: "Completed" },
{ id: 3, assignment: "Full Mock Exam", exam: "EX-006", date: "2025-03-10", score: null, duration: "120 min", status: "In Progress" },
{ id: 4, assignment: "Listening Bootcamp", exam: "EX-003", date: "2025-02-20", score: 5.5, duration: "28 min", status: "Completed" },
];
export default function RecordPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Record</h1>
<p className="text-muted-foreground">Browse assignment and exam attempt history.</p>
</div>
<AiTipBanner tip="The student's scores show an upward trend from 5.5 → 6.0 → 7.5 over the last 3 completed exams. Listening remains the weakest module — recommend targeted practice." variant="insight" />
<AiReportNarrative narrative="3 of 4 attempts completed with an average score of 6.3. Time management is good — all exams finished within allocated time. The Full Mock Exam is still in progress (67% time used). Strongest area: Reading (7.5), weakest: Listening (5.5)." />
<div className="flex flex-wrap gap-3 items-center">
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="Entity" /></SelectTrigger>
<SelectContent><SelectItem value="acme">Acme Corp</SelectItem><SelectItem value="global">Global Ltd</SelectItem></SelectContent>
</Select>
<Select><SelectTrigger className="w-[180px]"><SelectValue placeholder="Select User" /></SelectTrigger>
<SelectContent><SelectItem value="sarah">Sarah Johnson</SelectItem><SelectItem value="ahmed">Ahmed Hassan</SelectItem></SelectContent>
</Select>
<div className="flex gap-1">
{["Day", "Week", "Month"].map(t => (
<Button key={t} variant="outline" size="sm">{t}</Button>
))}
</div>
</div>
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Assignment</TableHead><TableHead>Exam</TableHead><TableHead>Date</TableHead>
<TableHead>Score</TableHead><TableHead>Duration</TableHead><TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.assignment}</TableCell>
<TableCell className="font-mono text-xs">{r.exam}</TableCell>
<TableCell>{r.date}</TableCell>
<TableCell>{r.score ? r.score.toFixed(1) : "—"}</TableCell>
<TableCell>{r.duration}</TableCell>
<TableCell><Badge variant={r.status === "Completed" ? "default" : "secondary"}>{r.status}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

289
src/pages/Register.tsx Normal file
View File

@@ -0,0 +1,289 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Progress } from "@/components/ui/progress";
import { GraduationCap, Eye, EyeOff, Loader2 } from "lucide-react";
import { useRegister, useCheckEmail } from "@/hooks/queries/useSignup";
import { ApiError } from "@/lib/api-client";
import type { RegisterRequest } from "@/types";
import { cn } from "@/lib/utils";
const passwordStrength = (pwd: string): number => {
let s = 0;
if (pwd.length >= 8) s += 1;
if (pwd.length >= 12) s += 1;
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) s += 1;
if (/\d/.test(pwd)) s += 1;
if (/[^A-Za-z0-9]/.test(pwd)) s += 1;
return Math.min(100, s * 25);
};
const schema = z
.object({
name: z.string().min(2, "Enter your full name"),
email: z.string().email("Enter a valid email"),
password: z
.string()
.min(8, "At least 8 characters")
.regex(/[A-Za-z]/, "Include letters")
.regex(/\d/, "Include a number"),
confirm: z.string(),
role: z.enum(["academic_student", "self_learner", "teacher"]),
})
.refine((d) => d.password === d.confirm, { message: "Passwords do not match", path: ["confirm"] });
type FormValues = z.infer<typeof schema>;
export default function Register() {
const navigate = useNavigate();
const register = useRegister();
const checkEmail = useCheckEmail();
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
name: "",
email: "",
password: "",
confirm: "",
role: "self_learner",
},
mode: "onBlur",
});
const pwd = form.watch("password");
const strength = passwordStrength(pwd || "");
const onSubmit = async (values: FormValues) => {
const payload: RegisterRequest = {
name: values.name.trim(),
email: values.email.trim(),
password: values.password,
role: values.role,
captcha_token: "demo-placeholder",
};
try {
await register.mutateAsync(payload);
navigate(`/verify-email?email=${encodeURIComponent(values.email.trim())}`);
} catch (e) {
form.setError("root", {
message: e instanceof ApiError ? e.message : "Registration failed",
});
}
};
const verifyEmailBlur = async () => {
const ok = await form.trigger("email");
if (!ok) return;
const email = form.getValues("email").trim();
if (!email) return;
try {
const res = await checkEmail.mutateAsync(email);
if (res.exists) {
form.setError("email", { message: "This email is already registered" });
} else {
form.clearErrors("email");
}
} catch {
form.setError("email", { message: "Could not verify email" });
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-muted/40 to-background p-4">
<div className="w-full max-w-md">
<div className="flex items-center justify-center gap-2 mb-8">
<div className="h-10 w-10 rounded-xl bg-primary flex items-center justify-center shadow-sm">
<GraduationCap className="h-6 w-6 text-primary-foreground" />
</div>
<h1 className="text-2xl font-bold tracking-tight">EnCoach</h1>
</div>
<Card className="shadow-lg border-0 bg-card/90 backdrop-blur-sm">
<CardHeader className="text-center pb-4">
<CardTitle className="text-xl">Create an account</CardTitle>
<CardDescription>Start your IELTS preparation journey</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form className="space-y-5" onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Full name</FormLabel>
<FormControl>
<Input placeholder="Jane Doe" autoComplete="name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="you@example.com"
autoComplete="email"
{...field}
onBlur={(e) => {
field.onBlur();
void verifyEmailBlur();
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
placeholder="••••••••"
autoComplete="new-password"
className="pr-10"
{...field}
/>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</FormControl>
<div className="space-y-1 pt-1">
<Progress value={strength} className="h-1.5" />
<p className="text-xs text-muted-foreground">Strength: {strength}%</p>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirm"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm password</FormLabel>
<FormControl>
<div className="relative">
<Input
type={showConfirm ? "text" : "password"}
placeholder="••••••••"
autoComplete="new-password"
className="pr-10"
{...field}
/>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowConfirm((v) => !v)}
aria-label={showConfirm ? "Hide password" : "Show password"}
>
{showConfirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<FormControl>
<RadioGroup onValueChange={field.onChange} value={field.value} className="grid gap-2">
{(
[
["academic_student", "Academic student"],
["self_learner", "Self learner"],
["teacher", "Teacher"],
] as const
).map(([value, label]) => (
<label
key={value}
className={cn(
"flex items-center gap-3 rounded-lg border p-3 cursor-pointer transition-colors",
field.value === value && "border-primary bg-primary/5",
)}
>
<RadioGroupItem value={value} id={`role-${value}`} />
<span className="text-sm font-medium">{label}</span>
</label>
))}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="rounded-lg border border-dashed bg-muted/30 p-4 space-y-2">
<p className="text-sm font-medium">CAPTCHA</p>
<div className="h-16 rounded-md bg-muted flex items-center justify-center text-xs text-muted-foreground">
Verification widget placeholder
</div>
</div>
{form.formState.errors.root && (
<p className="text-sm text-destructive text-center bg-destructive/10 rounded-md py-2 px-3">
{form.formState.errors.root.message}
</p>
)}
<Button type="submit" className="w-full" size="lg" disabled={register.isPending}>
{register.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating account
</>
) : (
"Create account"
)}
</Button>
</form>
</Form>
<p className="text-center text-sm text-muted-foreground mt-6">
Already have an account?{" "}
<Link to="/login" className="text-primary font-medium hover:underline">
Sign in
</Link>
</p>
</CardContent>
</Card>
</div>
</div>
);
}

147
src/pages/ResetPassword.tsx Normal file
View File

@@ -0,0 +1,147 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authService } from "@/services/auth.service";
import { brandingService } from "@/services/branding.service";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
const schema = z
.object({
password: z.string().min(8, "At least 8 characters"),
confirm: z.string(),
})
.refine((d) => d.password === d.confirm, { message: "Passwords must match", path: ["confirm"] });
type FormValues = z.infer<typeof schema>;
function strengthLabel(p: string): "weak" | "medium" | "strong" {
let s = 0;
if (p.length >= 8) s++;
if (p.length >= 12) s++;
if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++;
if (/\d/.test(p)) s++;
if (/[^A-Za-z0-9]/.test(p)) s++;
if (s <= 2) return "weak";
if (s <= 4) return "medium";
return "strong";
}
export default function ResetPassword() {
const [searchParams] = useSearchParams();
const token = searchParams.get("token") ?? "";
const navigate = useNavigate();
const { data: branding } = useQuery({
queryKey: ["branding", "current"],
queryFn: () => brandingService.getCurrentBranding(),
});
const entityName = branding?.entity_name ?? "your organization";
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { password: "", confirm: "" },
});
const pwd = form.watch("password");
const strength = useMemo(() => strengthLabel(pwd), [pwd]);
useEffect(() => {
const root = document.documentElement;
if (branding?.primary_color) root.style.setProperty("--encoach-brand-primary", branding.primary_color);
if (branding?.secondary_color) root.style.setProperty("--encoach-brand-secondary", branding.secondary_color);
if (branding?.background_color) root.style.setProperty("--encoach-brand-bg", branding.background_color);
return () => {
root.style.removeProperty("--encoach-brand-primary");
root.style.removeProperty("--encoach-brand-secondary");
root.style.removeProperty("--encoach-brand-bg");
};
}, [branding]);
const submitMut = useMutation({
mutationFn: (values: FormValues) => authService.setInvitePassword({ token, password: values.password }),
onSuccess: () => {
toast.success("Password saved.");
navigate("/student/placement", { replace: true });
},
onError: (e) => toast.error(e instanceof Error ? e.message : "Could not set password"),
});
const onSubmit = (values: FormValues) => {
if (!token) {
toast.error("Missing invite token.");
return;
}
submitMut.mutate(values);
};
return (
<div
className="min-h-screen flex items-center justify-center p-6"
style={{
backgroundColor: "var(--encoach-brand-bg, hsl(var(--background)))",
}}
>
<Card className="w-full max-w-md border shadow-lg">
<CardHeader className="text-center space-y-1">
<CardTitle style={{ color: "var(--encoach-brand-primary, hsl(var(--primary)))" }}>
Welcome to {entityName}&apos;s Learning Platform
</CardTitle>
<CardDescription>Set a new password</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>New password</FormLabel>
<FormControl>
<Input type="password" autoComplete="new-password" {...field} />
</FormControl>
<div
className={cn(
"text-xs font-medium",
strength === "weak" && "text-red-600",
strength === "medium" && "text-amber-600",
strength === "strong" && "text-green-600",
)}
>
{pwd ? `${strength.charAt(0).toUpperCase() + strength.slice(1)} password` : " "}
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirm"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm password</FormLabel>
<FormControl>
<Input type="password" autoComplete="new-password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={submitMut.isPending}>
Set Password & Continue
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
);
}

122
src/pages/RubricsPage.tsx Normal file
View File

@@ -0,0 +1,122 @@
import { useState } from "react";
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 { Textarea } from "@/components/ui/textarea";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search, Plus } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
const rubrics = [
{ id: 1, name: "IELTS Writing Task 2", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 4, created: "2025-01-05" },
{ id: 2, name: "Speaking Fluency", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 3, created: "2025-01-10" },
{ id: 3, name: "Reading Comprehension", levels: ["A1","A2","B1","B2","C1"], criteria: 5, created: "2025-02-01" },
];
const rubricGroups = [
{ id: 1, name: "Academic IELTS Full", rubrics: ["IELTS Writing Task 2", "Speaking Fluency", "Reading Comprehension"], created: "2025-02-15" },
{ id: 2, name: "Business English", rubrics: ["Speaking Fluency"], created: "2025-03-01" },
];
export default function RubricsPage() {
const [search, setSearch] = useState("");
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Rubrics</h1>
<p className="text-muted-foreground">Create and manage evaluation rubrics with level-based descriptors.</p>
</div>
<div className="flex gap-2">
<AiCreationAssistant type="rubric" />
<Dialog>
<DialogTrigger asChild>
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Rubric</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader><DialogTitle>Create Rubric</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Rubric Name</Label><Input placeholder="e.g. IELTS Writing Task 1" /></div>
<div className="space-y-3">
<Label>Level Descriptors</Label>
{["A1","A2","B1","B2","C1","C2"].map(level => (
<div key={level} className="space-y-1">
<Label className="text-xs text-muted-foreground">{level}</Label>
<Textarea placeholder={`Descriptor for level ${level}...`} className="h-16" />
</div>
))}
</div>
<Button className="w-full">Create Rubric</Button>
</div>
</DialogContent>
</Dialog>
</div>
</div>
<AiTipBanner context="rubrics" variant="recommendation" />
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search rubrics..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Tabs defaultValue="rubrics">
<TabsList>
<TabsTrigger value="rubrics">Rubrics List</TabsTrigger>
<TabsTrigger value="groups">Rubric Groups</TabsTrigger>
</TabsList>
<TabsContent value="rubrics" className="mt-4">
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead><TableHead>Levels</TableHead><TableHead>Criteria</TableHead><TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rubrics.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.name}</TableCell>
<TableCell><div className="flex gap-1">{r.levels.map(l => <Badge key={l} variant="outline" className="text-xs">{l}</Badge>)}</div></TableCell>
<TableCell>{r.criteria}</TableCell>
<TableCell>{r.created}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="groups" className="mt-4">
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Group Name</TableHead><TableHead>Rubrics</TableHead><TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rubricGroups.map((g) => (
<TableRow key={g.id}>
<TableCell className="font-medium">{g.name}</TableCell>
<TableCell><div className="flex gap-1 flex-wrap">{g.rubrics.map(r => <Badge key={r} variant="secondary" className="text-xs">{r}</Badge>)}</div></TableCell>
<TableCell>{g.created}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, XCircle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { queryKeys } from "@/hooks/queries/keys";
import { verificationService } from "@/services/verification.service";
export default function ScoreVerification() {
const { verificationHash } = useParams<{ verificationHash: string }>();
const hash = verificationHash ?? "";
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.verification.verify(hash),
queryFn: () => verificationService.verify(hash),
enabled: !!hash,
retry: false,
});
const valid = data?.valid === true;
return (
<div className="min-h-screen flex items-center justify-center p-6 bg-muted/40">
<Card className="w-full max-w-md border shadow-md">
<CardContent className="pt-8 pb-8 space-y-6">
<div className="text-center font-semibold tracking-tight text-lg">EnCoach</div>
{isLoading ? (
<p className="text-center text-muted-foreground text-sm">Verifying</p>
) : isError || !valid ? (
<div className="flex flex-col items-center gap-3 text-center">
<XCircle className="h-14 w-14 text-destructive" />
<p className="text-sm text-muted-foreground">This verification code is invalid or has expired.</p>
</div>
) : (
<div className="flex flex-col items-center gap-4 text-center">
<CheckCircle2 className="h-14 w-14 text-green-600" />
<div className="space-y-1">
<p className="font-medium text-lg">{data.student_name}</p>
<p className="text-sm text-muted-foreground capitalize">{data.exam_type}</p>
<p className="text-2xl font-bold">{data.overall_score}</p>
<p className="text-xs text-muted-foreground">
Issued {data.issued_at ? new Date(data.issued_at).toLocaleDateString() : "—"}
</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}

103
src/pages/SettingsPage.tsx Normal file
View File

@@ -0,0 +1,103 @@
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 { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Plus, Trash2, Copy } 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 },
];
export default function SettingsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<p className="text-muted-foreground">Manage codes, packages, discounts, and grading system.</p>
</div>
<Tabs defaultValue="codes">
<TabsList>
<TabsTrigger value="codes">Codes</TabsTrigger>
<TabsTrigger value="packages">Packages & Discounts</TabsTrigger>
<TabsTrigger value="grading">Grading System</TabsTrigger>
</TabsList>
<TabsContent value="codes" className="mt-4 space-y-4">
<AiTipBanner tip="2 batch codes have been unused for over 30 days. Consider sending reminder emails to the assigned entities or recycling unused codes." variant="insight" />
<div className="flex gap-2">
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Generate Single</Button>
<Button size="sm" variant="outline"><Copy className="h-4 w-4 mr-1" /> Generate Batch</Button>
</div>
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow><TableHead>Code</TableHead><TableHead>Type</TableHead><TableHead>Used</TableHead><TableHead>Created</TableHead><TableHead className="w-10"></TableHead></TableRow>
</TableHeader>
<TableBody>
{codes.map((c) => (
<TableRow key={c.code}>
<TableCell className="font-mono text-xs">{c.code}</TableCell>
<TableCell><Badge variant="outline">{c.type}</Badge></TableCell>
<TableCell><Badge variant={c.used ? "secondary" : "default"}>{c.used ? "Used" : "Available"}</Badge></TableCell>
<TableCell>{c.created}</TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"><Trash2 className="h-4 w-4" /></Button></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="packages" className="mt-4 space-y-4">
<AiTipBanner tip="Based on conversion data, the IELTS Pro package has the highest ROI. Consider increasing the Corporate Bundle discount to 30% to boost enterprise sign-ups." variant="recommendation" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{packages.map((p) => (
<Card key={p.id} className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base">{p.name}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-2xl font-bold">${p.price}</p>
<p className="text-sm text-muted-foreground">{p.duration}</p>
{p.discount > 0 && <Badge variant="default">{p.discount}% OFF</Badge>}
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="grading" className="mt-4 space-y-4">
<AiTipBanner tip="Current 0.5 increment scoring aligns with official IELTS band scoring. AI recommends keeping this configuration for standardised assessment." variant="tip" />
<Card className="border-0 shadow-sm max-w-lg">
<CardHeader><CardTitle className="text-base">Scoring Scale</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2"><Label>Min Score</Label><Input type="number" defaultValue="0" /></div>
<div className="space-y-2"><Label>Max Score</Label><Input type="number" defaultValue="9" /></div>
</div>
<div className="space-y-2"><Label>Score Increment</Label><Input type="number" defaultValue="0.5" step="0.5" /></div>
<Button>Save Grading Configuration</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,133 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, PieChart, Pie, Cell } from "recharts";
import AiReportNarrative from "@/components/ai/AiReportNarrative";
const tabNarratives: Record<string, string> = {
overview: "Writing scores (61%) are significantly lower than other modules. Consider allocating more teaching resources to writing workshops. Reading leads at 72%, suggesting current materials are effective.",
trends: "Scores have shown a consistent upward trend of +14 points over 6 months. The plateau in April correlates with mid-term exam stress. June's 72% is the highest recorded average this year.",
distribution: "B1 is the largest cohort at 30%, indicating most students are at intermediate level. Only 3% reach C2 — consider creating more advanced pathways to support progression from C1.",
comparison: "Attendance dropped 8% in the second week of March, correlating with the mid-term assignment deadline. Consider spacing deadlines more evenly across the term.",
};
const thresholds = ["0%", "50%", "70%", "90%"];
const barData = [
{ module: "Reading", score: 72 },
{ module: "Listening", score: 68 },
{ module: "Writing", score: 61 },
{ module: "Speaking", score: 65 },
];
const trendData = [
{ month: "Jan", avg: 58 }, { month: "Feb", avg: 62 }, { month: "Mar", avg: 65 },
{ month: "Apr", avg: 64 }, { month: "May", avg: 69 }, { month: "Jun", avg: 72 },
];
const distData = [
{ name: "A1", value: 15, color: "hsl(0, 72%, 51%)" },
{ name: "A2", value: 22, color: "hsl(38, 92%, 50%)" },
{ name: "B1", value: 30, color: "hsl(199, 89%, 48%)" },
{ name: "B2", value: 20, color: "hsl(243, 75%, 59%)" },
{ name: "C1", value: 10, color: "hsl(142, 71%, 45%)" },
{ name: "C2", value: 3, color: "hsl(280, 65%, 50%)" },
];
export default function StatsCorporatePage() {
const [threshold, setThreshold] = useState("0%");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Corporate Statistics</h1>
<p className="text-muted-foreground">Entity-level performance analytics and reports.</p>
</div>
<div className="flex flex-wrap gap-3 items-center">
<div className="flex gap-1">
{thresholds.map(t => (
<Button key={t} variant={threshold === t ? "default" : "outline"} size="sm" onClick={() => setThreshold(t)}>{t}</Button>
))}
</div>
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="All Entities" /></SelectTrigger>
<SelectContent><SelectItem value="all">All</SelectItem><SelectItem value="acme">Acme Corp</SelectItem></SelectContent>
</Select>
<Select><SelectTrigger className="w-[180px]"><SelectValue placeholder="All Assignments" /></SelectTrigger>
<SelectContent><SelectItem value="all">All Assignments</SelectItem></SelectContent>
</Select>
</div>
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="trends">Trends</TabsTrigger>
<TabsTrigger value="distribution">Distribution</TabsTrigger>
<TabsTrigger value="comparison">Comparison</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4">
<AiReportNarrative narrative={tabNarratives.overview} />
<Card className="border-0 shadow-sm">
<CardHeader><CardTitle className="text-base">Average Score by Module</CardTitle></CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={barData}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(220, 16%, 90%)" />
<XAxis dataKey="module" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Bar dataKey="score" fill="hsl(243, 75%, 59%)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="trends" className="mt-4">
<AiReportNarrative narrative={tabNarratives.trends} />
<Card className="border-0 shadow-sm">
<CardHeader><CardTitle className="text-base">Score Trend Over Time</CardTitle></CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={trendData}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(220, 16%, 90%)" />
<XAxis dataKey="month" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Line type="monotone" dataKey="avg" stroke="hsl(243, 75%, 59%)" strokeWidth={2} dot={{ r: 4 }} />
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="distribution" className="mt-4">
<AiReportNarrative narrative={tabNarratives.distribution} />
<Card className="border-0 shadow-sm">
<CardHeader><CardTitle className="text-base">Level Distribution</CardTitle></CardHeader>
<CardContent className="flex justify-center">
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie data={distData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={100} label>
{distData.map((entry, i) => <Cell key={i} fill={entry.color} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="comparison" className="mt-4">
<AiReportNarrative narrative={tabNarratives.comparison} />
<Card className="border-0 shadow-sm">
<CardContent className="p-8 text-center text-muted-foreground">Entity comparison charts will appear here based on selected filters.</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useState } from "react";
import AiStudyCoach from "@/components/ai/AiStudyCoach";
import AiGradeExplainer from "@/components/ai/AiGradeExplainer";
const students = [
{ name: "Sarah Johnson", entity: "Acme Corp", reading: 7.5, listening: 8.0, writing: 7.0, speaking: 7.5, overall: 7.5, level: "B2" },
{ name: "Ahmed Hassan", entity: "Global Ltd", reading: 5.5, listening: 6.0, writing: 5.0, speaking: 5.5, overall: 5.5, level: "A2" },
{ name: "Maria Garcia", entity: "Acme Corp", reading: 8.5, listening: 8.0, writing: 8.0, speaking: 8.5, overall: 8.25, level: "C1" },
{ name: "Li Wei", entity: "Tech Co", reading: 6.5, listening: 6.0, writing: 6.0, speaking: 6.5, overall: 6.25, level: "B1" },
{ name: "Emma Brown", entity: "Acme Corp", reading: 4.5, listening: 5.0, writing: 4.0, speaking: 4.5, overall: 4.5, level: "A1" },
{ name: "John Park", entity: "Global Ltd", reading: 7.0, listening: 7.5, writing: 6.5, speaking: 7.0, overall: 7.0, level: "B2" },
];
function ScoreBadge({ score }: { score: number }) {
const color = score >= 7.5 ? "bg-success/10 text-success" : score >= 6.0 ? "bg-warning/10 text-warning" : "bg-destructive/10 text-destructive";
return <span className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold ${color}`}>{score.toFixed(1)}</span>;
}
export default function StudentPerformancePage() {
const [showUtilisation, setShowUtilisation] = useState(false);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Student Performance</h1>
<p className="text-muted-foreground">Track student scores across all IELTS modules.</p>
</div>
<AiStudyCoach />
<div className="flex flex-wrap gap-3 items-center">
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="All Entities" /></SelectTrigger>
<SelectContent><SelectItem value="all">All Entities</SelectItem><SelectItem value="acme">Acme Corp</SelectItem><SelectItem value="global">Global Ltd</SelectItem></SelectContent>
</Select>
<div className="flex items-center gap-2 ml-auto">
<Switch id="util" checked={showUtilisation} onCheckedChange={setShowUtilisation} />
<Label htmlFor="util" className="text-sm">Show Utilisation</Label>
</div>
</div>
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead><TableHead>Entity</TableHead><TableHead>Level</TableHead>
<TableHead className="text-center">Reading</TableHead><TableHead className="text-center">Listening</TableHead>
<TableHead className="text-center">Writing</TableHead><TableHead className="text-center">Speaking</TableHead>
<TableHead className="text-center">Overall</TableHead>
<TableHead className="w-10">AI</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{students.map((s) => (
<TableRow key={s.name}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell>{s.entity}</TableCell>
<TableCell><Badge variant="outline">{s.level}</Badge></TableCell>
<TableCell className="text-center"><ScoreBadge score={s.reading} /></TableCell>
<TableCell className="text-center"><ScoreBadge score={s.listening} /></TableCell>
<TableCell className="text-center"><ScoreBadge score={s.writing} /></TableCell>
<TableCell className="text-center"><ScoreBadge score={s.speaking} /></TableCell>
<TableCell className="text-center"><ScoreBadge score={s.overall} /></TableCell>
<TableCell><AiGradeExplainer studentName={s.name} /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

164
src/pages/TicketsPage.tsx Normal file
View File

@@ -0,0 +1,164 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Plus, Search, Loader2 } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { ticketsService } from "@/services/tickets.service";
import type { Ticket } from "@/types";
export default function TicketsPage() {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [typeFilter, setTypeFilter] = useState("all");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ subject: "", type: "bug", description: "" });
const { toast } = useToast();
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["tickets", statusFilter, typeFilter, search],
queryFn: () =>
ticketsService.list({
...(statusFilter !== "all" ? { status: statusFilter } : {}),
...(typeFilter !== "all" ? { type: typeFilter } : {}),
...(search ? { search } : {}),
}),
});
const tickets: Ticket[] = data?.data ?? [];
const createMut = useMutation({
mutationFn: ticketsService.create,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["tickets"] });
setCreateOpen(false);
setForm({ subject: "", type: "bug", description: "" });
toast({ title: "Ticket created successfully" });
},
onError: () => {
toast({ title: "Failed to create ticket", variant: "destructive" });
},
});
function handleCreate() {
if (!form.subject.trim()) return;
createMut.mutate({
subject: form.subject.trim(),
type: form.type as Ticket["type"],
description: form.description.trim(),
});
}
const statusVariant = (s: string) =>
s === "open" ? "default" : s === "in_progress" ? "secondary" : "outline";
const typeVariant = (t: string) =>
t === "bug" ? "destructive" : t === "feature" ? "secondary" : "outline";
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Tickets</h1>
<p className="text-muted-foreground">Manage support tickets and feature requests.</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Create Ticket
</Button>
</div>
<div className="flex flex-wrap gap-3 items-center">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search tickets..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[130px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="resolved">Resolved</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[120px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="bug">Bug</SelectItem>
<SelectItem value="feature">Feature</SelectItem>
<SelectItem value="support">Support</SelectItem>
<SelectItem value="feedback">Feedback</SelectItem>
</SelectContent>
</Select>
</div>
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead><TableHead>Type</TableHead><TableHead>Reporter</TableHead>
<TableHead>Date</TableHead><TableHead>Subject</TableHead><TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading && (
<TableRow><TableCell colSpan={6} className="text-center py-8"><Loader2 className="h-5 w-5 animate-spin mx-auto" /></TableCell></TableRow>
)}
{!isLoading && tickets.length === 0 && (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">No tickets found.</TableCell></TableRow>
)}
{tickets.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-mono text-xs">#{t.id}</TableCell>
<TableCell><Badge variant={typeVariant(t.type)}>{t.type}</Badge></TableCell>
<TableCell>{t.reporter_name}</TableCell>
<TableCell>{t.created_at ? new Date(t.created_at).toLocaleDateString() : "—"}</TableCell>
<TableCell className="font-medium max-w-[200px] truncate">{t.subject}</TableCell>
<TableCell><Badge variant={statusVariant(t.status)}>{t.status?.replace("_", " ")}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create Ticket</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Subject</Label><Input value={form.subject} onChange={(e) => setForm(f => ({ ...f, subject: e.target.value }))} placeholder="Brief description" /></div>
<div className="space-y-2">
<Label>Type</Label>
<Select value={form.type} onValueChange={(v) => setForm(f => ({ ...f, type: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="bug">Bug</SelectItem>
<SelectItem value="feature">Feature</SelectItem>
<SelectItem value="support">Support</SelectItem>
<SelectItem value="feedback">Feedback</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2"><Label>Description</Label><Textarea value={form.description} onChange={(e) => setForm(f => ({ ...f, description: e.target.value }))} placeholder="Describe the issue..." className="h-24" /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button disabled={!form.subject.trim() || createMut.isPending} onClick={handleCreate}>
{createMut.isPending ? <><Loader2 className="h-4 w-4 mr-1 animate-spin" /> Creating...</> : "Submit Ticket"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

335
src/pages/UsersPage.tsx Normal file
View File

@@ -0,0 +1,335 @@
import { useState } from "react";
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 { 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 { 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 { 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.";
}
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" };
}
export default function UsersPage() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ first_name: "", last_name: "", email: "", role: "student", phone: "", gender: "" });
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();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms"] });
toast({ title: "Done" });
},
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,
);
}
}
function resetForm() {
setForm({ first_name: "", last_name: "", email: "", role: "student", phone: "", gender: "" });
}
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 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.click();
URL.revokeObjectURL(url);
}
const isPending = createStudentMut.isPending || createTeacherMut.isPending;
const loading = studentsQ.isLoading || teachersQ.isLoading;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Users</h1>
<p className="text-muted-foreground">Manage platform users across all roles.</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={exportCsv}>
<Download className="h-4 w-4 mr-1" /> Export
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Create User
</Button>
</div>
</div>
<div className="flex gap-3 items-center">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search users..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
</div>
{loading ? (
<div className="flex items-center justify-center min-h-[300px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : (
<Tabs defaultValue="students">
<TabsList>
<TabsTrigger value="students">Students ({students.length})</TabsTrigger>
<TabsTrigger value="teachers">Teachers ({teachers.length})</TabsTrigger>
</TabsList>
<TabsContent value="students" className="mt-4">
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Phone</TableHead>
<TableHead>Batch</TableHead>
<TableHead>Status</TableHead>
<TableHead>Enrollment</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{students.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No students found.</TableCell></TableRow>
)}
{students.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell>{s.email}</TableCell>
<TableCell>{s.phone || "—"}</TableCell>
<TableCell>{s.batch_name || "—"}</TableCell>
<TableCell>
<Badge variant={s.status === "active" ? "default" : "secondary"} className="capitalize">{s.status || "active"}</Badge>
</TableCell>
<TableCell>{s.enrollment_date || "—"}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className="text-destructive" onClick={() => { if (window.confirm(`Deactivate student "${s.name}"?`)) deleteMut.mutate({ type: "student", id: s.id }); }}>
Deactivate
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="teachers" className="mt-4">
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Phone</TableHead>
<TableHead>Department</TableHead>
<TableHead>Specialization</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{teachers.length === 0 && (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">No teachers found.</TableCell></TableRow>
)}
{teachers.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell>{t.email}</TableCell>
<TableCell>{t.phone || "—"}</TableCell>
<TableCell>{t.department_name || "—"}</TableCell>
<TableCell>{t.specialization || "—"}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className="text-destructive" onClick={() => { if (window.confirm(`Remove teacher "${t.name}"?`)) deleteMut.mutate({ type: "teacher", id: t.id }); }}>
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
)}
<Dialog open={createOpen} onOpenChange={(v) => { setCreateOpen(v); if (!v) resetForm(); }}>
<DialogContent>
<DialogHeader><DialogTitle>Create New User</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>First Name</Label>
<Input value={form.first_name} onChange={(e) => setForm(f => ({ ...f, first_name: e.target.value }))} placeholder="John" />
</div>
<div className="space-y-2">
<Label>Last Name</Label>
<Input value={form.last_name} onChange={(e) => setForm(f => ({ ...f, last_name: e.target.value }))} placeholder="Doe" />
</div>
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input type="email" value={form.email} onChange={(e) => setForm(f => ({ ...f, email: e.target.value }))} placeholder="john@email.com" />
<p className="text-xs text-muted-foreground">
Must be unique in Odoo for students and teachers. If create fails, the address may already exist outside the current list.
</p>
</div>
<div className="space-y-2">
<Label>Phone</Label>
<Input value={form.phone} onChange={(e) => setForm(f => ({ ...f, phone: e.target.value }))} placeholder="+1234567890" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Role</Label>
<Select value={form.role} onValueChange={(v) => setForm(f => ({ ...f, role: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="student">Student</SelectItem>
<SelectItem value="teacher">Teacher</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Gender</Label>
<Select value={form.gender} onValueChange={(v) => setForm(f => ({ ...f, gender: v }))}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
<SelectItem value="male">Male</SelectItem>
<SelectItem value="female">Female</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setCreateOpen(false); resetForm(); }}>Cancel</Button>
<Button
disabled={isPending || !form.first_name.trim() || !form.last_name.trim() || !form.email.trim()}
onClick={handleCreate}
>
{isPending ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { useState } from "react";
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 AiTipBanner from "@/components/ai/AiTipBanner";
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 },
];
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);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Vocabulary Training</h1>
<p className="text-muted-foreground">Build your vocabulary for IELTS success.</p>
</div>
<AiTipBanner context="vocabulary" variant="recommendation" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Progress</CardTitle>
<span className="text-sm text-muted-foreground">{completed}/{vocabItems.length} completed</span>
</div>
</CardHeader>
<CardContent>
<Progress value={(completed / vocabItems.length) * 100} className="h-3" />
</CardContent>
</Card>
<div className="flex items-center gap-2">
<Switch id="show" checked={showCompleted} onCheckedChange={setShowCompleted} />
<Label htmlFor="show" className="text-sm">Show completed</Label>
</div>
<div className="space-y-2">
{filtered.map((v) => (
<Card key={v.word} className="border-0 shadow-sm">
<CardContent className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
{v.completed && <CheckCircle className="h-4 w-4 text-success shrink-0" />}
<div>
<p className="font-semibold text-sm">{v.word}</p>
<p className="text-xs text-muted-foreground">{v.meaning}</p>
</div>
</div>
<Badge variant="outline">{v.level}</Badge>
</CardContent>
</Card>
))}
</div>
</div>
<div className="space-y-4">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2"><Sparkles className="h-4 w-4 text-primary" /> AI Recommendations</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Learn "coherent" + "concise" together they share academic writing context</li>
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Review C1-level vocabulary for IELTS Task 2</li>
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Focus on collocations with 'make' and 'do'</li>
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Try using "meticulous" in your next writing practice</li>
</ul>
</CardContent>
</Card>
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
<CardContent className="p-4">
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-2"><Sparkles className="h-3 w-3" /> AI Vocabulary Goal</p>
<p className="text-sm text-muted-foreground">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.</p>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,209 @@
import { useState } from "react";
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";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, 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";
import { Plus, Trash2, ChevronRight, RefreshCw, Loader2 } from "lucide-react";
import {
useAcademicYears,
useCreateAcademicYear,
useDeleteAcademicYear,
useGenerateTerms,
useAcademicTerms,
} from "@/hooks/queries/useAcademic";
import { useToast } from "@/hooks/use-toast";
import type { AcademicYear, AcademicYearCreateRequest } from "@/types/academic";
const TERM_STRUCTURES: { value: AcademicYear["term_structure"]; label: string }[] = [
{ value: "two_sem", label: "Two Semesters" },
{ value: "two_sem_qua", label: "Two Semesters + Quarter" },
{ value: "three_sem", label: "Three Semesters" },
{ value: "four_Quarter", label: "Four Quarters" },
{ value: "final_year", label: "Final Year" },
{ value: "others", label: "Others" },
];
export default function AcademicYearManager() {
const { toast } = useToast();
const { data: yearsData, isLoading } = useAcademicYears();
const years = yearsData?.items ?? [];
const createYear = useCreateAcademicYear();
const deleteYear = useDeleteAcademicYear();
const generateTerms = useGenerateTerms();
const [showCreate, setShowCreate] = useState(false);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [form, setForm] = useState<AcademicYearCreateRequest>({
name: "",
start_date: "",
end_date: "",
term_structure: "two_sem",
});
const { data: termsData } = useAcademicTerms(expandedId ?? undefined);
const terms = termsData?.items ?? [];
const handleCreate = () => {
createYear.mutate(form, {
onSuccess: () => {
toast({ title: "Academic year created" });
setShowCreate(false);
setForm({ name: "", start_date: "", end_date: "", term_structure: "two_sem" });
},
onError: () => toast({ title: "Error", description: "Failed to create academic year", variant: "destructive" }),
});
};
const handleDelete = (id: number) => {
if (!window.confirm("Delete this academic year?")) return;
deleteYear.mutate(id, {
onSuccess: () => toast({ title: "Academic year deleted" }),
onError: () => toast({ title: "Error", description: "Failed to delete", variant: "destructive" }),
});
};
const handleGenerateTerms = (yearId: number) => {
generateTerms.mutate(yearId, {
onSuccess: () => toast({ title: "Terms generated" }),
onError: () => toast({ title: "Error", description: "Failed to generate terms", variant: "destructive" }),
});
};
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Academic Years</h1>
<p className="text-muted-foreground">Manage academic years and their terms.</p>
</div>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Create Academic Year</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Academic Year</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="e.g. 2025-2026" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Start Date</Label>
<Input type="date" value={form.start_date} onChange={e => setForm({ ...form, start_date: e.target.value })} />
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input type="date" value={form.end_date} onChange={e => setForm({ ...form, end_date: e.target.value })} />
</div>
</div>
<div className="space-y-2">
<Label>Term Structure</Label>
<Select value={form.term_structure} onValueChange={v => setForm({ ...form, term_structure: v as AcademicYear["term_structure"] })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{TERM_STRUCTURES.map(ts => (
<SelectItem key={ts.value} value={ts.value}>{ts.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button className="w-full" onClick={handleCreate} disabled={createYear.isPending || !form.name || !form.start_date || !form.end_date}>
{createYear.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead />
<TableHead>Name</TableHead>
<TableHead>Start Date</TableHead>
<TableHead>End Date</TableHead>
<TableHead>Term Structure</TableHead>
<TableHead>Terms</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{years.map((year: AcademicYear) => (
<Collapsible key={year.id} asChild open={expandedId === year.id} onOpenChange={open => setExpandedId(open ? year.id : null)}>
<>
<TableRow>
<TableCell>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<ChevronRight className={`h-4 w-4 transition-transform ${expandedId === year.id ? "rotate-90" : ""}`} />
</Button>
</CollapsibleTrigger>
</TableCell>
<TableCell className="font-medium">{year.name}</TableCell>
<TableCell>{year.start_date}</TableCell>
<TableCell>{year.end_date}</TableCell>
<TableCell>
<Badge variant="outline">{TERM_STRUCTURES.find(t => t.value === year.term_structure)?.label ?? year.term_structure}</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary">{year.terms?.length ?? 0}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => handleGenerateTerms(year.id)} disabled={generateTerms.isPending}>
<RefreshCw className="mr-1 h-3 w-3" /> Generate Terms
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDelete(year.id)} disabled={deleteYear.isPending}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
<CollapsibleContent asChild>
<TableRow>
<TableCell colSpan={7} className="bg-muted/50 p-4">
{expandedId === year.id && (
<div className="space-y-2">
<h4 className="text-sm font-medium">Terms</h4>
{terms.length > 0 ? (
<div className="grid gap-2">
{terms.map(term => (
<div key={term.id} className="flex items-center justify-between rounded border p-3 bg-background">
<span className="text-sm font-medium">{term.name}</span>
<span className="text-sm text-muted-foreground">{term.term_start_date} {term.term_end_date}</span>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No terms yet. Click "Generate Terms" to auto-create.</p>
)}
</div>
)}
</TableCell>
</TableRow>
</CollapsibleContent>
</>
</Collapsible>
))}
{years.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">No academic years found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,158 @@
import { useState, type ComponentType } from "react";
import { Link } from "react-router-dom";
import { Activity, AlertTriangle, Brain, Gauge, Users } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { useAdaptiveDashboard, useAdaptiveStudents } from "@/hooks/queries/useAdaptiveEngine";
import type { AdaptiveDashboardMetrics, AdaptiveEngineStudentRow } from "@/types";
import { LineChart, Line, ResponsiveContainer } from "recharts";
export default function AdaptiveDashboard() {
const [page, setPage] = useState(1);
const limit = 10;
const { data: metrics } = useAdaptiveDashboard();
const { data: studentsPage, isLoading } = useAdaptiveStudents({ page, limit });
const m: AdaptiveDashboardMetrics = metrics ?? {
active_students: 0,
engine_phase: "—",
decisions_today: 0,
average_improvement: 0,
alerts_count: 0,
};
const rows: AdaptiveEngineStudentRow[] = studentsPage?.data ?? [];
const total = studentsPage?.pagination?.total ?? rows.length;
const pages = Math.max(1, Math.ceil(total / limit));
return (
<div className="space-y-8 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Adaptive engine</h1>
<p className="text-muted-foreground text-sm mt-1">Live decisions and learner risk</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<MetricCard icon={Users} label="Active students" value={String(m.active_students)} />
<MetricCard icon={Brain} label="Engine phase" value={m.engine_phase} />
<MetricCard icon={Activity} label="Decisions today" value={String(m.decisions_today)} />
<MetricCard icon={Gauge} label="Avg improvement" value={`${m.average_improvement}%`} />
<MetricCard icon={AlertTriangle} label="Alerts" value={String(m.alerts_count)} />
</div>
<Card>
<CardHeader>
<CardTitle>Students</CardTitle>
<CardDescription>Signals, pacing, and trajectory</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-muted-foreground py-8 text-center">Loading</p>
) : (
<>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Subject</TableHead>
<TableHead>Current level</TableHead>
<TableHead>Target</TableHead>
<TableHead>Days active</TableHead>
<TableHead>Last decision</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Alert</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={r.student_id}>
<TableCell className="font-medium">{r.name}</TableCell>
<TableCell>{r.subject}</TableCell>
<TableCell>{r.current_level}</TableCell>
<TableCell>{r.target}</TableCell>
<TableCell>{r.days_active}</TableCell>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{new Date(r.last_decision_at).toLocaleString()}
</TableCell>
<TableCell className="w-32">
<Spark vals={r.progress_sparkline} />
</TableCell>
<TableCell>
{r.alert ? (
<Badge variant="destructive" className="text-xs">
{r.alert}
</Badge>
) : (
"—"
)}
</TableCell>
<TableCell>
<Button variant="link" className="px-0" asChild>
<Link to={`/admin/adaptive/student/${r.student_id}`}>Open</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between mt-4 text-sm">
<span className="text-muted-foreground">
Page {page} of {pages}
</span>
<div className="flex gap-2">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
Previous
</Button>
<Button size="sm" variant="outline" disabled={page >= pages} onClick={() => setPage((p) => p + 1)}>
Next
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
</div>
);
}
function MetricCard({
icon: Icon,
label,
value,
}: {
icon: ComponentType<{ className?: string }>;
label: string;
value: string;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{label}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
</CardContent>
</Card>
);
}
function Spark({ vals }: { vals: number[] }) {
const data = (vals?.length ? vals : [0, 0, 0, 0, 0]).map((v, i) => ({ i, v }));
return (
<div className="h-10 w-28">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<Line type="monotone" dataKey="v" stroke="hsl(var(--primary))" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { useParams } from "react-router-dom";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useStudentAbility, useStudentSignals } from "@/hooks/queries/useAdaptiveEngine";
import {
CartesianGrid,
LineChart,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
export default function AdaptiveStudentDetail() {
const { studentId: sid } = useParams<{ studentId: string }>();
const studentId = Number(sid);
const { data: signals, isLoading: ls } = useStudentSignals(Number.isFinite(studentId) ? studentId : undefined);
const { data: ability, isLoading: la } = useStudentAbility(Number.isFinite(studentId) ? studentId : undefined);
const rows = signals ?? [];
const traj = ability?.trajectory?.length
? ability.trajectory
: [
{ t: "W1", theta: 0.2 },
{ t: "W2", theta: 0.35 },
{ t: "W3", theta: 0.5 },
{ t: "W4", theta: 0.62 },
];
if (ls || la) {
return <div className="p-6 text-muted-foreground">Loading student</div>;
}
return (
<div className="space-y-8 p-6 max-w-5xl mx-auto">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Adaptive trace</h1>
<p className="text-muted-foreground text-sm mt-1">Student #{studentId}</p>
</div>
<Card>
<CardHeader>
<CardTitle>Signal timeline</CardTitle>
<CardDescription>Inputs the engine reacted to</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Signal</TableHead>
<TableHead>Value</TableHead>
<TableHead>Decision made</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r, i) => (
<TableRow key={i}>
<TableCell className="whitespace-nowrap">{new Date(r.time).toLocaleString()}</TableCell>
<TableCell>{r.signal}</TableCell>
<TableCell>{r.value}</TableCell>
<TableCell>{r.decision_made}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Ability model (phase 2+)</CardTitle>
<CardDescription>Theta, SEM, and trajectory</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-wrap gap-8 text-sm">
<div>
<div className="text-muted-foreground">Theta</div>
<div className="text-2xl font-semibold">{ability?.theta?.toFixed(3) ?? "—"}</div>
</div>
<div>
<div className="text-muted-foreground">SEM</div>
<div className="text-2xl font-semibold">{ability?.sem?.toFixed(3) ?? "—"}</div>
</div>
</div>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={traj}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="t" />
<YAxis domain={["auto", "auto"]} />
<Tooltip />
<Line type="monotone" dataKey="theta" stroke="hsl(var(--primary))" strokeWidth={2} dot />
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,281 @@
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
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 { 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 {
useActivities,
useActivityTypes,
useCreateActivity,
useDeleteActivity,
useCreateActivityType,
useDeleteActivityType,
useStudents,
} from "@/hooks/queries";
import { Search, Plus, Trash2, Activity } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
export default function AdminActivities() {
const [search, setSearch] = useState("");
const [section, setSection] = useState<"activities" | "types">("activities");
const [actOpen, setActOpen] = useState(false);
const [typeOpen, setTypeOpen] = useState(false);
const [actForm, setActForm] = useState({ student_id: "", type_id: "", date: "", description: "" });
const [typeName, setTypeName] = useState("");
const { toast } = useToast();
const actQ = useActivities();
const typesQ = useActivityTypes();
const { data: studentsData } = useStudents({ size: 500 });
const studentsList = studentsData?.items ?? [];
const createAct = useCreateActivity();
const delAct = useDeleteActivity();
const createType = useCreateActivityType();
const delType = useDeleteActivityType();
const loading = section === "activities" ? actQ.isLoading : typesQ.isLoading;
const activities = actQ.data?.data ?? actQ.data?.items ?? [];
const types = typesQ.data?.data ?? typesQ.data?.items ?? [];
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
const q = search.toLowerCase();
const filteredAct = activities.filter(
(a) =>
a.student_name?.toLowerCase().includes(q) ||
a.type_name?.toLowerCase().includes(q) ||
a.description?.toLowerCase().includes(q),
);
const filteredTypes = types.filter((t) => t.name?.toLowerCase().includes(q));
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Activity className="h-7 w-7" />
Activities
</h1>
<p className="text-muted-foreground">Student activities and activity types.</p>
</div>
<Button
onClick={() => (section === "activities" ? setActOpen(true) : setTypeOpen(true))}
>
<Plus className="mr-2 h-4 w-4" />
{section === "activities" ? "New activity" : "New type"}
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant={section === "activities" ? "default" : "outline"}
onClick={() => setSection("activities")}
>
Activities
</Button>
<Button variant={section === "types" ? "default" : "outline"} onClick={() => setSection("types")}>
Activity types
</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
{section === "activities" ? (
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Student</TableHead>
<TableHead>Type</TableHead>
<TableHead>Date</TableHead>
<TableHead>Description</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAct.map((a, i) => (
<TableRow key={a.id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{a.student_name}</TableCell>
<TableCell>{a.type_name}</TableCell>
<TableCell>{a.date}</TableCell>
<TableCell className="max-w-xs truncate">{a.description}</TableCell>
<TableCell>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => {
if (!window.confirm("Delete this activity?")) return;
delAct.mutate(a.id, {
onSuccess: () => toast({ title: "Deleted" }),
onError: (err: Error) =>
toast({ title: "Error", description: err.message, variant: "destructive" }),
});
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Name</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredTypes.map((t, i) => (
<TableRow key={t.id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell>
<Button size="sm" variant="ghost" className="text-destructive" onClick={() => { if (!window.confirm("Delete this activity type?")) return; delType.mutate(t.id, { onSuccess: () => toast({ title: "Deleted" }), onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }) }); }}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<Dialog open={actOpen} onOpenChange={setActOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create activity</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Student *</Label>
<Select value={actForm.student_id || "__none__"} onValueChange={(v) => setActForm((f) => ({ ...f, student_id: v === "__none__" ? "" : v }))}>
<SelectTrigger><SelectValue placeholder="Select student" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Select </SelectItem>
{studentsList.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Activity Type *</Label>
<Select value={actForm.type_id || "__none__"} onValueChange={(v) => setActForm((f) => ({ ...f, type_id: v === "__none__" ? "" : v }))}>
<SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Select </SelectItem>
{types.map((t) => <SelectItem key={t.id} value={String(t.id)}>{t.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Date</Label>
<Input
type="date"
value={actForm.date}
onChange={(e) => setActForm((f) => ({ ...f, date: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={actForm.description}
onChange={(e) => setActForm((f) => ({ ...f, description: e.target.value }))}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setActOpen(false)}>
Cancel
</Button>
<Button
disabled={createAct.isPending || !actForm.student_id || !actForm.type_id || !actForm.date}
onClick={() =>
createAct.mutate(
{
student_id: Number(actForm.student_id),
type_id: Number(actForm.type_id),
date: actForm.date,
description: actForm.description || undefined,
},
{
onSuccess: () => {
setActOpen(false);
toast({ title: "Created successfully" });
},
onError: (err: Error) =>
toast({ title: "Error", description: err.message, variant: "destructive" }),
},
)
}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={typeOpen} onOpenChange={setTypeOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create activity type</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label>Name</Label>
<Input value={typeName} onChange={(e) => setTypeName(e.target.value)} />
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setTypeOpen(false)}>
Cancel
</Button>
<Button
disabled={createType.isPending}
onClick={() =>
createType.mutate(
{ name: typeName },
{
onSuccess: () => {
setTypeOpen(false);
toast({ title: "Created successfully" });
},
onError: (err: Error) =>
toast({ title: "Error", description: err.message, variant: "destructive" }),
},
)
}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { useParams, Link } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useBatches, useStudents, useTimetable } from "@/hooks/queries";
import { ArrowLeft, Users, Calendar, BookOpen } from "lucide-react";
export default function AdminBatchDetail() {
const { id } = useParams();
const batchId = id ? Number(id) : NaN;
const { data: batchesData, isLoading: lb } = useBatches();
const { data: studentsData, isLoading: ls } = useStudents(
Number.isFinite(batchId) ? { batch_id: batchId, size: 500 } : { size: 500 },
);
const { data: timetableData, isLoading: ltt } = useTimetable();
const batches = batchesData?.items ?? [];
const students = studentsData?.items ?? [];
const timetableSessions = Array.isArray(timetableData) ? timetableData : [];
if (lb || ls || ltt) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const batch = batches.find(b => String(b.id) === id);
if (!batch) return <div className="p-8 text-center text-muted-foreground">Batch not found.</div>;
const studentIds = Array.isArray(batch.student_ids) ? batch.student_ids : [];
const batchStudents = Number.isFinite(batchId)
? students.filter((s) => s.batch_id === batchId)
: students.filter((s) => studentIds.includes(s.id));
const coursePrefix = (batch.course_name || "").split(" ")[0];
const batchSessions = timetableSessions.filter(s =>
s.batch_name === batch.name || (coursePrefix && (s.course_name || "").includes(coursePrefix)),
);
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild><Link to="/admin/batches"><ArrowLeft className="h-4 w-4" /></Link></Button>
<div>
<h1 className="text-2xl font-bold">{batch.name}</h1>
<p className="text-muted-foreground">{batch.course_name} · {batch.teacher_name}</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
<Card><CardContent className="pt-4"><Users className="h-4 w-4 text-primary mb-1" /><p className="text-lg font-bold">{batch.student_count}/{batch.capacity}</p><p className="text-xs text-muted-foreground">Students</p></CardContent></Card>
<Card><CardContent className="pt-4"><Calendar className="h-4 w-4 text-info mb-1" /><p className="text-sm font-bold">{batch.schedule}</p><p className="text-xs text-muted-foreground">Schedule</p></CardContent></Card>
<Card><CardContent className="pt-4"><BookOpen className="h-4 w-4 text-success mb-1" /><p className="text-sm font-bold">{batch.start_date} {batch.end_date}</p><p className="text-xs text-muted-foreground">Duration</p></CardContent></Card>
<Card><CardContent className="pt-4"><Badge variant={batch.status === "active" ? "default" : "secondary"} className="capitalize">{batch.status}</Badge><p className="text-xs text-muted-foreground mt-1">Status</p></CardContent></Card>
</div>
<Card>
<CardHeader><CardTitle className="text-lg">Enrolled Students</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Student</TableHead><TableHead>Attendance</TableHead><TableHead>Avg Grade</TableHead><TableHead>Risk</TableHead></TableRow></TableHeader>
<TableBody>
{batchStudents.map(s => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell><Badge variant="secondary" className="capitalize"></Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-lg">Timetable</CardTitle></CardHeader>
<CardContent className="space-y-2">
{batchSessions.length === 0 ? (
<p className="text-muted-foreground text-sm">No sessions scheduled.</p>
) : batchSessions.map(s => (
<div key={s.id} className="flex items-center justify-between p-3 rounded border">
<div>
<p className="text-sm font-medium">{s.day} {s.start_time}{s.end_time}</p>
<p className="text-xs text-muted-foreground">{s.room}</p>
</div>
<span className="text-xs px-2 py-1 rounded text-white bg-primary">{s.course_name}</span>
</div>
))}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +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 { 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useBatches, useCourses } from "@/hooks/queries";
import { lmsService } from "@/services/lms.service";
import { api } from "@/lib/api-client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Search, Plus, Trash2 } from "lucide-react";
import { Link } from "react-router-dom";
import { useToast } from "@/hooks/use-toast";
import AiBatchOptimizer from "@/components/ai/AiBatchOptimizer";
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
import AiTipBanner from "@/components/ai/AiTipBanner";
export default function AdminBatches() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: "", course_id: "", start_date: "", end_date: "", max_students: "30" });
const { toast } = useToast();
const qc = useQueryClient();
const { data: batchesData, isLoading } = useBatches();
const { data: coursesData } = useCourses({ size: 200 });
const batches = batchesData?.items ?? [];
const courses = coursesData?.items ?? [];
const createMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => lmsService.createBatch(data as never),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "batches"] }); setCreateOpen(false); toast({ title: "Batch created" }); setForm({ name: "", course_id: "", start_date: "", end_date: "", max_students: "30" }); },
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
async function handleDelete(id: number, name: string) {
if (!window.confirm(`Delete batch "${name}"?`)) return;
try {
await api.delete(`/batches/${id}`);
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
toast({ title: "Batch deleted" });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
toast({ title: "Delete failed", description: msg, variant: "destructive" });
}
}
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const filtered = batches.filter(b => b.name.toLowerCase().includes(search.toLowerCase()));
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div><h1 className="text-2xl font-bold">Batches</h1><p className="text-muted-foreground">Manage student batches and groups.</p></div>
<div className="flex gap-2">
<AiBatchOptimizer batchId={batches[0]?.id} />
<AiCreationAssistant type="batch" />
<Button onClick={() => setCreateOpen(true)}><Plus className="mr-2 h-4 w-4" />Create Batch</Button>
</div>
</div>
<AiTipBanner context="admin-batches" variant="recommendation" />
<div className="relative max-w-sm"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="Search batches..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9" /></div>
<Card><CardContent className="pt-6"><Table><TableHeader><TableRow><TableHead>Batch</TableHead><TableHead>Course</TableHead><TableHead>Teacher</TableHead><TableHead>Students</TableHead><TableHead>Schedule</TableHead><TableHead>Status</TableHead><TableHead></TableHead></TableRow></TableHeader><TableBody>{filtered.map(b => (<TableRow key={b.id}><TableCell className="font-medium">{b.name}</TableCell><TableCell>{b.course_name}</TableCell><TableCell>{b.teacher_name}</TableCell><TableCell>{b.student_count}/{b.capacity}</TableCell><TableCell className="text-xs">{b.schedule}</TableCell><TableCell><Badge variant={b.status === "active" ? "default" : "secondary"} className="capitalize">{b.status}</Badge></TableCell><TableCell><div className="flex gap-1"><Button size="sm" variant="ghost" asChild><Link to={`/admin/batches/${b.id}`}>View</Link></Button><Button variant="ghost" size="icon" onClick={() => handleDelete(b.id, b.name)}><Trash2 className="h-4 w-4 text-destructive" /></Button></div></TableCell></TableRow>))}</TableBody></Table></CardContent></Card>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create Batch</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Batch Name *</Label><Input placeholder="e.g. IELTS Morning B" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} /></div>
<div className="space-y-2">
<Label>Course *</Label>
<Select value={form.course_id || "__none__"} onValueChange={v => setForm(f => ({ ...f, course_id: v === "__none__" ? "" : v }))}>
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Select </SelectItem>
{courses.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.title}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"><Label>Start Date *</Label><Input type="date" value={form.start_date} onChange={e => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
<div className="space-y-2"><Label>End Date *</Label><Input type="date" value={form.end_date} onChange={e => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
</div>
<div className="space-y-2"><Label>Capacity</Label><Input type="number" placeholder="30" value={form.max_students} onChange={e => setForm(f => ({ ...f, max_students: e.target.value }))} /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button disabled={createMutation.isPending || !form.name || !form.course_id || !form.start_date || !form.end_date} onClick={() => createMutation.mutate({ name: form.name, course_id: Number(form.course_id), start_date: form.start_date, end_date: form.end_date, max_students: Number(form.max_students) || 30 })}>Create</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,109 @@
import { useState } 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 { 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 AiCreationAssistant from "@/components/ai/AiCreationAssistant";
import AiTipBanner from "@/components/ai/AiTipBanner";
import { useToast } from "@/hooks/use-toast";
export default function AdminCourses() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ title: "", code: "", description: "", max_capacity: 30 });
const { data: coursesData, isLoading } = useCourses();
const createMut = useCreateCourse();
const qc = useQueryClient();
const { toast } = useToast();
const courses = coursesData?.items ?? [];
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const filtered = 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" });
},
onError: (e) => toast({ title: "Error", description: String(e.message || e), variant: "destructive" }),
}
);
}
async function handleDelete(id: number, title: string) {
if (!window.confirm(`Delete course "${title}"?`)) return;
try {
await lmsService.deleteCourse(id);
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
toast({ title: "Course deleted" });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
toast({ title: "Delete failed", description: msg, variant: "destructive" });
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div><h1 className="text-2xl font-bold">Courses</h1><p className="text-muted-foreground">Manage all platform courses.</p></div>
<div className="flex gap-2">
<AiCreationAssistant type="course" />
<Button onClick={() => setCreateOpen(true)}><Plus className="mr-2 h-4 w-4" />New Course</Button>
</div>
</div>
<AiTipBanner context="admin-courses" variant="recommendation" />
<div className="relative max-w-sm"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="Search courses..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9" /></div>
<Card><CardContent className="pt-6">
<Table>
<TableHeader><TableRow><TableHead>Course</TableHead><TableHead>Code</TableHead><TableHead>Level</TableHead><TableHead>Enrolled / Capacity</TableHead><TableHead>Status</TableHead><TableHead className="w-[60px]" /></TableRow></TableHeader>
<TableBody>
{filtered.map(c => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.title}</TableCell>
<TableCell>{c.code}</TableCell>
<TableCell><Badge variant="outline">{c.level}</Badge></TableCell>
<TableCell>{c.enrolled} / {c.max_capacity}</TableCell>
<TableCell><Badge variant={c.status === "active" ? "default" : "secondary"} className="capitalize">{c.status}</Badge></TableCell>
<TableCell>
<Button variant="ghost" size="icon" onClick={() => handleDelete(c.id, c.title)}><Trash2 className="h-4 w-4 text-destructive" /></Button>
</TableCell>
</TableRow>
))}
{filtered.length === 0 && <TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">No courses found.</TableCell></TableRow>}
</TableBody>
</Table>
</CardContent></Card>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create New Course</DialogTitle></DialogHeader>
<div className="space-y-4 py-2">
<div><Label>Title *</Label><Input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} placeholder="e.g. IELTS Academic Preparation" /></div>
<div><Label>Code</Label><Input value={form.code} onChange={e => setForm(f => ({ ...f, code: e.target.value }))} placeholder="Auto-generated if empty" /></div>
<div><Label>Description</Label><Textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} rows={3} /></div>
<div><Label>Max Capacity</Label><Input type="number" value={form.max_capacity} onChange={e => setForm(f => ({ ...f, max_capacity: Number(e.target.value) || 30 }))} /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button onClick={handleCreate} disabled={createMut.isPending}>{createMut.isPending ? "Creating..." : "Create Course"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,251 @@
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
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 { 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 [assetOpen, setAssetOpen] = useState(false);
const [facForm, setFacForm] = useState({ name: "", code: "" });
const [assetForm, setAssetForm] = useState({ name: "", code: "" });
const { toast } = useToast();
const facQ = useFacilities();
const assetQ = useAssets();
const createFac = useCreateFacility();
const delFac = useDeleteFacility();
const createAsset = useCreateAsset();
const delAsset = useDeleteAsset();
const loading = section === "facilities" ? facQ.isLoading : assetQ.isLoading;
const facilities = facQ.data?.data ?? facQ.data?.items ?? [];
const assets = assetQ.data?.data ?? assetQ.data?.items ?? [];
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
const q = search.toLowerCase();
const filteredFac = facilities.filter(
(f) => f.name?.toLowerCase().includes(q) || f.code?.toLowerCase().includes(q),
);
const filteredAssets = assets.filter(
(a) =>
a.name?.toLowerCase().includes(q) ||
a.code?.toLowerCase().includes(q) ||
a.product_name?.toLowerCase().includes(q),
);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Building className="h-7 w-7" />
Facilities
</h1>
<p className="text-muted-foreground">Facilities and fixed assets.</p>
</div>
<Button
onClick={() => (section === "facilities" ? setFacOpen(true) : setAssetOpen(true))}
>
<Plus className="mr-2 h-4 w-4" />
{section === "facilities" ? "New facility" : "New asset"}
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant={section === "facilities" ? "default" : "outline"}
onClick={() => setSection("facilities")}
>
Facilities
</Button>
<Button variant={section === "assets" ? "default" : "outline"} onClick={() => setSection("assets")}>
Assets
</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
{section === "facilities" ? (
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Name</TableHead>
<TableHead>Code</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredFac.map((f, i) => (
<TableRow key={f.id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{f.name}</TableCell>
<TableCell>{f.code}</TableCell>
<TableCell>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => {
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" }),
});
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Name</TableHead>
<TableHead>Code</TableHead>
<TableHead>Product</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAssets.map((a, i) => (
<TableRow key={a.id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{a.name}</TableCell>
<TableCell>{a.code}</TableCell>
<TableCell>{a.product_name}</TableCell>
<TableCell>
<Button size="sm" variant="ghost" className="text-destructive" onClick={() => { if (!window.confirm("Delete this asset?")) return; delAsset.mutate(a.id, { onSuccess: () => toast({ title: "Deleted" }), onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }) }); }}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<Dialog open={facOpen} onOpenChange={setFacOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create facility</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input value={facForm.name} onChange={(e) => setFacForm((f) => ({ ...f, name: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>Code</Label>
<Input value={facForm.code} onChange={(e) => setFacForm((f) => ({ ...f, code: e.target.value }))} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setFacOpen(false)}>
Cancel
</Button>
<Button
disabled={createFac.isPending}
onClick={() =>
createFac.mutate(
{ name: facForm.name, code: facForm.code || undefined },
{
onSuccess: () => {
setFacOpen(false);
toast({ title: "Created successfully" });
},
onError: (err: Error) =>
toast({ title: "Error", description: err.message, variant: "destructive" }),
},
)
}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={assetOpen} onOpenChange={setAssetOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create asset</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={assetForm.name}
onChange={(e) => setAssetForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Code</Label>
<Input
value={assetForm.code}
onChange={(e) => setAssetForm((f) => ({ ...f, code: e.target.value }))}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAssetOpen(false)}>
Cancel
</Button>
<Button
disabled={createAsset.isPending}
onClick={() =>
createAsset.mutate(
{ name: assetForm.name, code: assetForm.code || undefined },
{
onSuccess: () => {
setAssetOpen(false);
toast({ title: "Created successfully" });
},
onError: (err: Error) =>
toast({ title: "Error", description: err.message, variant: "destructive" }),
},
)
}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,167 @@
import { useState } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useFeesPlans, useStudentFees } 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";
function planStateBadge(state: string) {
const s = state?.toLowerCase();
if (s === "draft") return <Badge variant="secondary">{state}</Badge>;
if (s === "ongoing") return <Badge variant="default">{state}</Badge>;
if (s === "done")
return (
<Badge variant="outline" className="border-green-600 text-green-700">
{state}
</Badge>
);
return <Badge variant="outline">{state}</Badge>;
}
export default function AdminFees() {
const [search, setSearch] = useState("");
const [section, setSection] = useState<"plans" | "student">("plans");
const [detailId, setDetailId] = useState<number | null>(null);
const { toast } = useToast();
const plansQ = useFeesPlans();
const feesQ = useStudentFees();
const detailQ = useFeesPlan(detailId ?? 0);
const loading = section === "plans" ? plansQ.isLoading : feesQ.isLoading;
const plans = plansQ.data?.data ?? plansQ.data?.items ?? [];
const fees = feesQ.data?.data ?? feesQ.data?.items ?? [];
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
const q = search.toLowerCase();
const filteredPlans = plans.filter(
(p) =>
p.student_name?.toLowerCase().includes(q) || p.course_name?.toLowerCase().includes(q),
);
const filteredFees = fees.filter((f) => f.student_name?.toLowerCase().includes(q));
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Fees</h1>
<p className="text-muted-foreground">Fee plans and student fee lines.</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant={section === "plans" ? "default" : "outline"} onClick={() => setSection("plans")}>
<DollarSign className="mr-2 h-4 w-4" />
Fee plans
</Button>
<Button variant={section === "student" ? "default" : "outline"} onClick={() => setSection("student")}>
<CreditCard className="mr-2 h-4 w-4" />
Student fees
</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={section === "plans" ? "Search plans..." : "Search student fees..."}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
{section === "plans" ? (
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Student</TableHead>
<TableHead>Course</TableHead>
<TableHead>Total</TableHead>
<TableHead>Paid</TableHead>
<TableHead>Remaining</TableHead>
<TableHead>State</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredPlans.map((p, i) => (
<TableRow key={p.id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{p.student_name}</TableCell>
<TableCell>{p.course_name}</TableCell>
<TableCell>{p.total_amount}</TableCell>
<TableCell>{p.paid_amount}</TableCell>
<TableCell>{p.remaining_amount}</TableCell>
<TableCell>{planStateBadge(p.state)}</TableCell>
<TableCell>
<Button size="sm" variant="ghost" onClick={() => setDetailId(p.id)}>
<Eye className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Student</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Date</TableHead>
<TableHead>State</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredFees.map((f, i) => (
<TableRow key={f.id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{f.student_name}</TableCell>
<TableCell>{f.amount}</TableCell>
<TableCell>{f.date}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{f.state}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<Dialog open={!!detailId} onOpenChange={(v) => { if (!v) setDetailId(null); }}>
<DialogContent>
<DialogHeader><DialogTitle>Fee Plan Details</DialogTitle></DialogHeader>
{detailQ.isLoading ? (
<div className="flex justify-center py-4"><div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" /></div>
) : detailQ.data ? (
<div className="space-y-2 text-sm">
<p><span className="font-medium">Student:</span> {detailQ.data.student_name}</p>
<p><span className="font-medium">Course:</span> {detailQ.data.course_name}</p>
<p><span className="font-medium">Total:</span> {detailQ.data.total_amount}</p>
<p><span className="font-medium">Paid:</span> {detailQ.data.paid_amount}</p>
<p><span className="font-medium">Remaining:</span> {detailQ.data.remaining_amount}</p>
<p><span className="font-medium">State:</span> {planStateBadge(detailQ.data.state)}</p>
</div>
) : null}
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,219 @@
import { useState } 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 { 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 { Search, Plus, BookOpen, Pencil, Trash2 } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
export default function AdminGradebook() {
const [search, setSearch] = useState("");
const [section, setSection] = useState<"books" | "assignments">("books");
const [createOpen, setCreateOpen] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [form, setForm] = useState({ name: "", course_id: "", subject_id: "", issued_date: "" });
const { toast } = useToast();
const booksQ = useGradebooks();
const assignQ = useGradingAssignments();
const { data: coursesData } = useCourses({ size: 200 });
const { data: subjectsData } = useSubjects();
const coursesList = coursesData?.items ?? [];
const subjectsList = Array.isArray(subjectsData) ? subjectsData : subjectsData?.data ?? subjectsData?.items ?? [];
const createMutation = useCreateGradingAssignment();
const updateMutation = useUpdateGradingAssignment();
const deleteMutation = useDeleteGradingAssignment();
const loading = section === "books" ? booksQ.isLoading : assignQ.isLoading;
const books = booksQ.data?.data ?? booksQ.data?.items ?? [];
const assignments = assignQ.data?.data ?? assignQ.data?.items ?? [];
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
const q = search.toLowerCase();
const filteredBooks = books.filter(
(b) =>
b.student_name?.toLowerCase().includes(q) || b.course_name?.toLowerCase().includes(q),
);
const filteredAssign = assignments.filter(
(a) =>
a.name?.toLowerCase().includes(q) ||
a.course_name?.toLowerCase().includes(q) ||
a.subject_name?.toLowerCase().includes(q),
);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Gradebook</h1>
<p className="text-muted-foreground">Gradebooks and grading assignments.</p>
</div>
{section === "assignments" && (
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
New assignment
</Button>
)}
</div>
<div className="flex flex-wrap gap-2">
<Button variant={section === "books" ? "default" : "outline"} onClick={() => setSection("books")}>
<BookOpen className="mr-2 h-4 w-4" />
Gradebooks
</Button>
<Button
variant={section === "assignments" ? "default" : "outline"}
onClick={() => setSection("assignments")}
>
Grading assignments
</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
{section === "books" ? (
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Student</TableHead>
<TableHead>Course</TableHead>
<TableHead>Academic Year</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredBooks.map((b, i) => (
<TableRow key={b.id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{b.student_name}</TableCell>
<TableCell>{b.course_name}</TableCell>
<TableCell>{b.academic_year_name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Name</TableHead>
<TableHead>Course</TableHead>
<TableHead>Subject</TableHead>
<TableHead>State</TableHead>
<TableHead>Issued Date</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAssign.map((a, i) => (
<TableRow key={a.id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{a.name}</TableCell>
<TableCell>{a.course_name}</TableCell>
<TableCell>{a.subject_name}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{a.state}
</Badge>
</TableCell>
<TableCell>{a.issued_date}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={() => { setEditId(a.id); setForm({ name: a.name || "", course_id: String(a.course_id || ""), subject_id: String(a.subject_id || ""), issued_date: a.issued_date || "" }); setCreateOpen(true); }}>
<Pencil className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" className="text-destructive" onClick={() => { if (!window.confirm("Delete this assignment?")) return; deleteMutation.mutate(a.id, { onSuccess: () => toast({ title: "Deleted" }), onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }) }); }}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<Dialog open={createOpen} onOpenChange={(v) => { setCreateOpen(v); if (!v) setEditId(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editId ? "Edit grading assignment" : "Create grading assignment"}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>Course *</Label>
<Select value={form.course_id || "__none__"} onValueChange={(v) => setForm((f) => ({ ...f, course_id: v === "__none__" ? "" : v }))}>
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Select </SelectItem>
{coursesList.map((c) => <SelectItem key={c.id} value={String(c.id)}>{c.title}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Subject</Label>
<Select value={form.subject_id || "__none__"} onValueChange={(v) => setForm((f) => ({ ...f, subject_id: v === "__none__" ? "" : v }))}>
<SelectTrigger><SelectValue placeholder="Select subject" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Select </SelectItem>
{subjectsList.map((s: { id: number; name: string }) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Issued date</Label>
<Input
type="date"
value={form.issued_date}
onChange={(e) => setForm((f) => ({ ...f, issued_date: e.target.value }))}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setCreateOpen(false); setEditId(null); }}>
Cancel
</Button>
<Button
disabled={createMutation.isPending || updateMutation.isPending || !form.name || !form.course_id}
onClick={() => {
const payload = { name: form.name, course_id: Number(form.course_id), subject_id: form.subject_id ? Number(form.subject_id) : undefined, issued_date: form.issued_date };
const cb = { onSuccess: () => { setCreateOpen(false); setEditId(null); toast({ title: editId ? "Updated" : "Created" }); }, onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }) };
if (editId) { updateMutation.mutate({ id: editId, data: payload }, cb); }
else { createMutation.mutate(payload, cb); }
}}
>
{editId ? "Save" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,269 @@
import { useState, useMemo } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useLessons, useCreateLesson, useUpdateLesson, useDeleteLesson, useCourses, useBatches, useSubjects } from "@/hooks/queries";
import { Search, Plus, Trash2, Edit } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import type { Lesson } from "@/types/lesson";
export default function AdminLessons() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState<Lesson | null>(null);
const [form, setForm] = useState({ lesson_topic: "", course_id: "", batch_id: "", subject_id: "" });
const { toast } = useToast();
const { data, isLoading } = useLessons();
const items = data?.data ?? data?.items ?? [];
const createMutation = useCreateLesson();
const updateMutation = useUpdateLesson();
const deleteMutation = useDeleteLesson();
const { data: coursesData } = useCourses({ size: 200 });
const { data: batchesData } = useBatches({ size: 200 });
const { data: subjectsData } = useSubjects();
const courses = coursesData?.items ?? [];
const allBatches = batchesData?.items ?? [];
const subjects = Array.isArray(subjectsData) ? subjectsData : subjectsData?.data ?? subjectsData?.items ?? [];
const batchesForCourse = useMemo(
() => (form.course_id ? allBatches.filter((b) => b.course_id === Number(form.course_id)) : allBatches),
[allBatches, form.course_id],
);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
const q = search.toLowerCase();
const filtered = items.filter(
(l) =>
l.name?.toLowerCase().includes(q) ||
l.subject_name?.toLowerCase().includes(q) ||
l.session_name?.toLowerCase().includes(q),
);
const openEdit = (l: Lesson) => {
setEditing(l);
setForm({
lesson_topic: l.name,
course_id: "",
batch_id: "",
subject_id: String(l.subject_id ?? ""),
});
setEditOpen(true);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Lessons</h1>
<p className="text-muted-foreground">Manage lessons, subjects, and sessions.</p>
</div>
<Button
onClick={() => {
setForm({ lesson_topic: "", course_id: "", batch_id: "", subject_id: "" });
setCreateOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
Create lesson
</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search lessons..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Name</TableHead>
<TableHead>Subject</TableHead>
<TableHead>Session</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((l, i) => (
<TableRow key={l.id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{l.name}</TableCell>
<TableCell>{l.subject_name}</TableCell>
<TableCell>{l.session_name}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={() => openEdit(l)}>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => {
if (!window.confirm("Delete this lesson?")) return;
deleteMutation.mutate(l.id, {
onSuccess: () => toast({ title: "Deleted" }),
onError: (err: Error) =>
toast({ title: "Error", description: err.message, variant: "destructive" }),
});
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create lesson</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Lesson Topic *</Label>
<Input value={form.lesson_topic} onChange={(e) => setForm((f) => ({ ...f, lesson_topic: e.target.value }))} placeholder="e.g. Introduction to Algebra" />
</div>
<div className="space-y-2">
<Label>Course *</Label>
<Select value={form.course_id || "__none__"} onValueChange={(v) => { setForm((f) => ({ ...f, course_id: v === "__none__" ? "" : v, batch_id: "" })); }}>
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Select </SelectItem>
{courses.map((c) => <SelectItem key={c.id} value={String(c.id)}>{c.title}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Batch *</Label>
<Select value={form.batch_id || "__none__"} onValueChange={(v) => setForm((f) => ({ ...f, batch_id: v === "__none__" ? "" : v }))} disabled={!form.course_id}>
<SelectTrigger><SelectValue placeholder={form.course_id ? "Select batch" : "Pick course first"} /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Select </SelectItem>
{batchesForCourse.map((b) => <SelectItem key={b.id} value={String(b.id)}>{b.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Subject *</Label>
<Select value={form.subject_id || "__none__"} onValueChange={(v) => setForm((f) => ({ ...f, subject_id: v === "__none__" ? "" : v }))}>
<SelectTrigger><SelectValue placeholder="Select subject" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Select </SelectItem>
{subjects.map((s: { id: number; name: string }) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button
disabled={createMutation.isPending || !form.lesson_topic || !form.course_id || !form.batch_id || !form.subject_id}
onClick={() =>
createMutation.mutate(
{
lesson_topic: form.lesson_topic,
name: form.lesson_topic,
course_id: Number(form.course_id),
batch_id: Number(form.batch_id),
subject_id: Number(form.subject_id),
} as Record<string, unknown>,
{
onSuccess: () => {
setCreateOpen(false);
setForm({ lesson_topic: "", course_id: "", batch_id: "", subject_id: "" });
toast({ title: "Created successfully" });
},
onError: (err: Error) =>
toast({ title: "Error", description: err.message, variant: "destructive" }),
},
)
}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit lesson</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Lesson Topic</Label>
<Input value={form.lesson_topic} onChange={(e) => setForm((f) => ({ ...f, lesson_topic: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>Subject</Label>
<Select value={form.subject_id || "__none__"} onValueChange={(v) => setForm((f) => ({ ...f, subject_id: v === "__none__" ? "" : v }))}>
<SelectTrigger><SelectValue placeholder="Select subject" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Select </SelectItem>
{subjects.map((s: { id: number; name: string }) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditOpen(false)}>
Cancel
</Button>
<Button
disabled={updateMutation.isPending || !editing}
onClick={() => {
if (!editing) return;
updateMutation.mutate(
{
id: editing.id,
data: {
lesson_topic: form.lesson_topic,
name: form.lesson_topic,
subject_id: form.subject_id ? Number(form.subject_id) : undefined,
},
},
{
onSuccess: () => {
setEditOpen(false);
toast({ title: "Updated successfully" });
},
onError: (err: Error) =>
toast({ title: "Error", description: err.message, variant: "destructive" }),
},
);
}}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,328 @@
import { useState } 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 { 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 { Search, Plus, Book, ArrowUpDown, Trash2, RotateCcw } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
type Tab = "media" | "movements" | "cards";
export default function AdminLibrary() {
const [search, setSearch] = useState("");
const [tab, setTab] = useState<Tab>("media");
const [mediaOpen, setMediaOpen] = useState(false);
const [moveOpen, setMoveOpen] = useState(false);
const [mediaForm, setMediaForm] = useState({ name: "", isbn: "", author: "", edition: "" });
const [moveForm, setMoveForm] = useState({ media_id: "", student_id: "" });
const [cardOpen, setCardOpen] = useState(false);
const [cardStudentId, setCardStudentId] = useState("");
const { toast } = useToast();
const mediaQ = useLibraryMedia();
const movQ = useLibraryMovements();
const cardsQ = useLibraryCards();
const createMediaMut = useCreateMedia();
const deleteMediaMut = useDeleteMedia();
const createMoveMut = useCreateMovement();
const returnMoveMut = useReturnMovement();
const createCardMut = useCreateCard();
const loading =
tab === "media" ? mediaQ.isLoading : tab === "movements" ? movQ.isLoading : cardsQ.isLoading;
const media = mediaQ.data?.data ?? mediaQ.data?.items ?? [];
const movements = movQ.data?.data ?? movQ.data?.items ?? [];
const cards = cardsQ.data?.data ?? cardsQ.data?.items ?? [];
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
const q = search.toLowerCase();
const filteredMedia = media.filter(
(m) =>
m.name?.toLowerCase().includes(q) ||
m.isbn?.toLowerCase().includes(q) ||
m.author?.toLowerCase().includes(q),
);
const filteredMov = movements.filter(
(m) =>
m.media_name?.toLowerCase().includes(q) ||
m.student_name?.toLowerCase().includes(q) ||
m.faculty_name?.toLowerCase().includes(q),
);
const filteredCards = cards.filter(
(c) => c.number?.toLowerCase().includes(q) || c.student_name?.toLowerCase().includes(q),
);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Library</h1>
<p className="text-muted-foreground">Media, circulation, and library cards.</p>
</div>
<div className="flex gap-2">
{tab === "media" && (
<Button onClick={() => setMediaOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add media
</Button>
)}
{tab === "movements" && (
<Button onClick={() => setMoveOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Issue
</Button>
)}
{tab === "cards" && (
<Button onClick={() => setCardOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
New card
</Button>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant={tab === "media" ? "default" : "outline"} onClick={() => setTab("media")}>
<Book className="mr-2 h-4 w-4" />
Media catalog
</Button>
<Button variant={tab === "movements" ? "default" : "outline"} onClick={() => setTab("movements")}>
<ArrowUpDown className="mr-2 h-4 w-4" />
Movements
</Button>
<Button variant={tab === "cards" ? "default" : "outline"} onClick={() => setTab("cards")}>
Library cards
</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
{tab === "media" && (
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Name</TableHead>
<TableHead>ISBN</TableHead>
<TableHead>Author</TableHead>
<TableHead>Edition</TableHead>
<TableHead>Type</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredMedia.map((m, i) => (
<TableRow key={m.id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{m.name}</TableCell>
<TableCell>{m.isbn}</TableCell>
<TableCell>{m.author}</TableCell>
<TableCell>{m.edition}</TableCell>
<TableCell>{m.media_type}</TableCell>
<TableCell>
<Button size="sm" variant="ghost" className="text-destructive" onClick={() => { if (!window.confirm("Delete this media?")) return; deleteMediaMut.mutate(m.id, { onSuccess: () => toast({ title: "Deleted" }), onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }) }); }}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{tab === "movements" && (
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Media</TableHead>
<TableHead>Student</TableHead>
<TableHead>Faculty</TableHead>
<TableHead>Issued Date</TableHead>
<TableHead>Return Date</TableHead>
<TableHead>State</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredMov.map((m, i) => (
<TableRow key={m.id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{m.media_name}</TableCell>
<TableCell>{m.student_name}</TableCell>
<TableCell>{m.faculty_name}</TableCell>
<TableCell>{m.issued_date}</TableCell>
<TableCell>{m.return_date}</TableCell>
<TableCell>
<Badge variant="outline">{m.state}</Badge>
</TableCell>
<TableCell>
{m.state === "issue" && (
<Button size="sm" variant="ghost" onClick={() => returnMoveMut.mutate(m.id, { onSuccess: () => toast({ title: "Returned" }), onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }) })}>
<RotateCcw className="h-4 w-4 mr-1" /> Return
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{tab === "cards" && (
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Number</TableHead>
<TableHead>Student</TableHead>
<TableHead>Faculty</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCards.map((c, i) => (
<TableRow key={c.id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{c.number}</TableCell>
<TableCell>{c.student_name}</TableCell>
<TableCell>{c.faculty_name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<Dialog open={mediaOpen} onOpenChange={setMediaOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add media</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={mediaForm.name}
onChange={(e) => setMediaForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>ISBN</Label>
<Input
value={mediaForm.isbn}
onChange={(e) => setMediaForm((f) => ({ ...f, isbn: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Author</Label>
<Input
value={mediaForm.author}
onChange={(e) => setMediaForm((f) => ({ ...f, author: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Edition</Label>
<Input
value={mediaForm.edition}
onChange={(e) => setMediaForm((f) => ({ ...f, edition: e.target.value }))}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setMediaOpen(false)}>
Cancel
</Button>
<Button
disabled={createMediaMut.isPending}
onClick={() =>
createMediaMut.mutate(
{
name: mediaForm.name,
isbn: mediaForm.isbn || undefined,
author: mediaForm.author || undefined,
edition: mediaForm.edition || undefined,
},
{
onSuccess: () => {
setMediaOpen(false);
toast({ title: "Created successfully" });
},
onError: (err: Error) =>
toast({ title: "Error", description: err.message, variant: "destructive" }),
},
)
}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={moveOpen} onOpenChange={setMoveOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Issue movement</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Media ID</Label>
<Input type="number" value={moveForm.media_id} onChange={(e) => setMoveForm((f) => ({ ...f, media_id: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>Student ID</Label>
<Input type="number" value={moveForm.student_id} onChange={(e) => setMoveForm((f) => ({ ...f, student_id: e.target.value }))} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setMoveOpen(false)}>Cancel</Button>
<Button disabled={createMoveMut.isPending} onClick={() => 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" }) })}>
Issue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={cardOpen} onOpenChange={setCardOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create library card</DialogTitle></DialogHeader>
<div className="space-y-2">
<Label>Student ID</Label>
<Input type="number" value={cardStudentId} onChange={(e) => setCardStudentId(e.target.value)} />
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCardOpen(false)}>Cancel</Button>
<Button disabled={createCardMut.isPending || !cardStudentId} onClick={() => createCardMut.mutate({ student_id: Number(cardStudentId) }, { onSuccess: () => { setCardOpen(false); setCardStudentId(""); toast({ title: "Card created" }); }, onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }) })}>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,202 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Users, BookOpen, GraduationCap, Layers, Ticket, DollarSign, Plus, BarChart3, FolderOpen } from "lucide-react";
import { Link } from "react-router-dom";
import { useCourses, useBatches, useStudents, useTeachers } from "@/hooks/queries";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
import AiInsightsPanel from "@/components/ai/AiInsightsPanel";
import AiTipBanner from "@/components/ai/AiTipBanner";
interface DashboardStats {
total_students?: number;
total_teachers?: number;
total_courses?: number;
active_courses?: number;
total_batches?: number;
active_batches?: number;
total_entities?: number;
total_departments?: number;
total_classrooms?: number;
total_exams?: number;
total_assignments?: number;
total_tickets?: number;
open_tickets?: number;
total_subjects?: number;
total_resources?: number;
total_revenue?: number;
total_payments?: number;
total_exam_sessions?: number;
}
export default function AdminLmsDashboard() {
const { data: coursesData, isLoading: lc } = useCourses();
const { data: studentsData, isLoading: ls } = useStudents({ size: 500 });
const { data: teachersData, isLoading: lt } = useTeachers({ size: 500 });
const { data: batchesData, isLoading: lb } = useBatches();
const { data: dbStats } = useQuery<DashboardStats>({
queryKey: ["dashboard", "stats"],
queryFn: async () => {
const res = await api.get<{ data: DashboardStats }>("/stats");
return (res as { data: DashboardStats }).data ?? res;
},
staleTime: 30_000,
});
const courses = coursesData?.items ?? [];
const students = studentsData?.items ?? [];
const teachers = teachersData?.items ?? [];
const batches = batchesData?.items ?? [];
if (lc || ls || lt || lb) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const revenue = dbStats?.total_revenue ?? 0;
const openTickets = dbStats?.open_tickets ?? 0;
const statCards = [
{ label: "Total Students", value: String(students.length), icon: Users, color: "text-primary" },
{ label: "Active Courses", value: `${courses.filter(c => c.status === "active").length} / ${courses.length}`, icon: BookOpen, color: "text-info" },
{ label: "Teachers", value: String(teachers.length), icon: GraduationCap, color: "text-success" },
{ label: "Active Batches", value: `${batches.filter(b => b.status === "active").length} / ${batches.length}`, icon: Layers, color: "text-warning" },
{ label: "Open Tickets", value: String(openTickets), icon: Ticket, color: "text-destructive" },
{ label: "Revenue", value: `$${revenue.toLocaleString()}`, icon: DollarSign, color: "text-success" },
];
const courseChartData = courses.map(c => {
const label = c.title || "";
const short = label.length > 14 ? label.substring(0, 14) + "…" : label;
return { course: short, capacity: c.max_capacity ?? 0, enrolled: c.enrolled ?? 0 };
});
const batchChartData = batches.map(b => {
const label = b.name || "";
const short = label.length > 12 ? label.substring(0, 12) + "…" : label;
return { batch: short, capacity: b.capacity ?? 0 };
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
<p className="text-muted-foreground">Platform overview and key metrics.</p>
</div>
<div className="flex gap-2">
<Button variant="outline" asChild><Link to="/admin/students"><Plus className="mr-1 h-3 w-3" />Add Student</Link></Button>
<Button asChild><Link to="/admin/courses"><Plus className="mr-1 h-3 w-3" />New Course</Link></Button>
</div>
</div>
<AiTipBanner context="admin-lms-dashboard" variant="insight" />
<AiInsightsPanel />
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
{statCards.map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-4">
<s.icon className={`h-5 w-5 ${s.color} mb-1`} />
<p className="text-lg font-bold">{s.value}</p>
<p className="text-xs text-muted-foreground">{s.label}</p>
</CardContent>
</Card>
))}
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ label: "Departments", value: dbStats?.total_departments ?? 0, icon: FolderOpen },
{ label: "Classrooms", value: dbStats?.total_classrooms ?? 0, icon: BarChart3 },
{ label: "Subjects", value: dbStats?.total_subjects ?? 0, icon: BookOpen },
{ label: "Resources", value: dbStats?.total_resources ?? 0, icon: Layers },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-3 pb-3 flex items-center gap-3">
<s.icon className="h-4 w-4 text-muted-foreground" />
<div>
<p className="text-sm font-semibold">{s.value}</p>
<p className="text-xs text-muted-foreground">{s.label}</p>
</div>
</CardContent>
</Card>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader><CardTitle className="text-lg">Courses Overview</CardTitle></CardHeader>
<CardContent>
{courseChartData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={courseChartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="course" className="fill-muted-foreground" tick={{ fontSize: 10 }} />
<YAxis className="fill-muted-foreground" tick={{ fontSize: 12 }} />
<Tooltip />
<Bar dataKey="capacity" name="Capacity" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
<Bar dataKey="enrolled" name="Enrolled" fill="hsl(var(--info))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-sm text-muted-foreground py-8 text-center">No course data available.</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-lg">Batch Capacity</CardTitle></CardHeader>
<CardContent>
{batchChartData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={batchChartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="batch" className="fill-muted-foreground" tick={{ fontSize: 10 }} />
<YAxis className="fill-muted-foreground" tick={{ fontSize: 12 }} />
<Tooltip />
<Bar dataKey="capacity" name="Capacity" fill="hsl(var(--info))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-sm text-muted-foreground py-8 text-center">No batch data available.</p>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg">Quick Summary</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Module</TableHead><TableHead className="text-right">Count</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">Exams</TableCell>
<TableCell className="text-right">{dbStats?.total_exams ?? 0}</TableCell>
<TableCell className="text-muted-foreground">{dbStats?.total_exam_sessions ?? 0} sessions</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Assignments</TableCell>
<TableCell className="text-right">{dbStats?.total_assignments ?? 0}</TableCell>
<TableCell className="text-muted-foreground">across courses</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Support Tickets</TableCell>
<TableCell className="text-right">{dbStats?.total_tickets ?? 0}</TableCell>
<TableCell className="text-muted-foreground">{openTickets} open</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Payments</TableCell>
<TableCell className="text-right">{dbStats?.total_payments ?? 0}</TableCell>
<TableCell className="text-muted-foreground">${revenue.toLocaleString()} total</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/contexts/AuthContext";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/lib/api-client";
import { useMutation } from "@tanstack/react-query";
export default function AdminProfile() {
const { user } = useAuth();
const { toast } = useToast();
const [firstName, setFirstName] = useState(user?.name?.split(" ")[0] ?? "");
const [lastName, setLastName] = useState(user?.name?.split(" ").slice(1).join(" ") ?? "");
const [email, setEmail] = useState(user?.email ?? "");
const [currentPw, setCurrentPw] = useState("");
const [newPw, setNewPw] = useState("");
const [confirmPw, setConfirmPw] = useState("");
const saveMutation = useMutation({
mutationFn: () => api.patch("/user", { name: `${firstName} ${lastName}`.trim(), email }),
onSuccess: () => toast({ title: "Profile updated" }),
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const pwMutation = useMutation({
mutationFn: () => api.post("/user/change-password", { current_password: currentPw, new_password: newPw }),
onSuccess: () => { toast({ title: "Password updated" }); setCurrentPw(""); setNewPw(""); setConfirmPw(""); },
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="text-2xl font-bold">Profile</h1>
<p className="text-muted-foreground">Manage your account information.</p>
</div>
<Card>
<CardHeader><CardTitle className="text-lg">Personal Information</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4 mb-4">
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
<span className="text-lg font-bold text-primary">{user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??"}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"><Label>First Name</Label><Input value={firstName} onChange={e => setFirstName(e.target.value)} /></div>
<div className="space-y-2"><Label>Last Name</Label><Input value={lastName} onChange={e => setLastName(e.target.value)} /></div>
</div>
<div className="space-y-2"><Label>Email</Label><Input value={email} onChange={e => setEmail(e.target.value)} type="email" /></div>
<Button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>Save Changes</Button>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-lg">Change Password</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2"><Label>Current Password</Label><Input type="password" value={currentPw} onChange={e => setCurrentPw(e.target.value)} /></div>
<div className="space-y-2"><Label>New Password</Label><Input type="password" value={newPw} onChange={e => setNewPw(e.target.value)} /></div>
<div className="space-y-2"><Label>Confirm Password</Label><Input type="password" value={confirmPw} onChange={e => setConfirmPw(e.target.value)} /></div>
<Button disabled={pwMutation.isPending || !newPw || newPw !== confirmPw} onClick={() => pwMutation.mutate()}>Update Password</Button>
{newPw && confirmPw && newPw !== confirmPw && <p className="text-sm text-destructive">Passwords do not match</p>}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCourses, useStudents } from "@/hooks/queries";
import { Download } from "lucide-react";
import { BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts";
import AiReportNarrative from "@/components/ai/AiReportNarrative";
export default function AdminReports() {
const { data: coursesData, isLoading: lc } = useCourses();
const { data: studentsData, isLoading: ls } = useStudents({ size: 500 });
const courses = coursesData?.items ?? [];
const students = studentsData?.items ?? [];
const enrollmentByCourse = courses.map(c => ({
name: c.title.length > 12 ? c.title.slice(0, 12) + "…" : c.title,
enrolled: c.enrolled ?? 0,
capacity: c.max_capacity ?? 0,
}));
const capacityByCourse = courses.filter(c => c.status === "active").map(c => {
const pct = c.max_capacity ? Math.round((c.enrolled / c.max_capacity) * 100) : 0;
return { name: c.title.length > 12 ? c.title.slice(0, 12) + "…" : c.title, fillRate: pct };
});
const statusPie = [
{ name: "Active", value: students.filter((s) => s.status === "active").length, color: "hsl(142, 71%, 45%)" },
{ name: "Inactive", value: students.filter((s) => s.status !== "active").length, color: "hsl(38, 92%, 50%)" },
].filter(d => d.value > 0);
const enrollmentReportData = { series: enrollmentByCourse, studentCount: students.length, courseCount: courses.length };
const performanceReportData = { byCourse: capacityByCourse };
const riskReportData = { distribution: statusPie, studentCount: students.length };
const attendanceReportData = { note: "No attendance data recorded yet." };
const exportCsv = useCallback(() => {
const rows = [
["Type", "Label", "Enrolled", "Capacity"],
...enrollmentByCourse.map(r => ["Enrollment", r.name, String(r.enrolled), String(r.capacity)]),
...capacityByCourse.map(r => ["Fill Rate", r.name, String(r.fillRate) + "%", ""]),
...statusPie.map(r => ["Status", r.name, String(r.value), ""]),
];
const csv = 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 = "encoach-report.csv";
a.click();
URL.revokeObjectURL(url);
}, [enrollmentByCourse, capacityByCourse, statusPie]);
if (lc || ls) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div><h1 className="text-2xl font-bold">Reports</h1><p className="text-muted-foreground">Analytics and reporting dashboard.</p></div>
<Button variant="outline" onClick={exportCsv}><Download className="mr-2 h-4 w-4" />Export CSV</Button>
</div>
<Tabs defaultValue="enrollment">
<TabsList><TabsTrigger value="enrollment">Enrollment</TabsTrigger><TabsTrigger value="performance">Performance</TabsTrigger><TabsTrigger value="risk">Student Risk</TabsTrigger><TabsTrigger value="attendance">Attendance</TabsTrigger></TabsList>
<TabsContent value="enrollment" className="mt-4 space-y-4">
<AiReportNarrative report_type="enrollment" data={enrollmentReportData} />
<Card><CardHeader><CardTitle className="text-lg">Enrollment by Course</CardTitle></CardHeader><CardContent>
{enrollmentByCourse.length > 0 ? (
<ResponsiveContainer width="100%" height={300}><BarChart data={enrollmentByCourse}><CartesianGrid strokeDasharray="3 3" className="stroke-border" /><XAxis dataKey="name" tick={{fontSize:11}} /><YAxis /><Tooltip /><Bar dataKey="enrolled" name="Enrolled" fill="hsl(192, 82%, 32%)" radius={[4,4,0,0]} /><Bar dataKey="capacity" name="Capacity" fill="hsl(192, 82%, 32%, 0.3)" radius={[4,4,0,0]} /></BarChart></ResponsiveContainer>
) : <p className="text-center text-muted-foreground py-8">No courses available.</p>}
</CardContent></Card>
</TabsContent>
<TabsContent value="performance" className="mt-4 space-y-4">
<AiReportNarrative report_type="performance" data={performanceReportData} />
<Card><CardHeader><CardTitle className="text-lg">Course Fill Rate (%)</CardTitle></CardHeader><CardContent>
{capacityByCourse.length > 0 ? (
<ResponsiveContainer width="100%" height={300}><BarChart data={capacityByCourse}><CartesianGrid strokeDasharray="3 3" className="stroke-border" /><XAxis dataKey="name" tick={{fontSize:11}} /><YAxis domain={[0,100]} /><Tooltip /><Bar dataKey="fillRate" name="Fill %" fill="hsl(187, 72%, 55%)" radius={[4,4,0,0]} /></BarChart></ResponsiveContainer>
) : <p className="text-center text-muted-foreground py-8">No active courses.</p>}
</CardContent></Card>
</TabsContent>
<TabsContent value="risk" className="mt-4 space-y-4">
<AiReportNarrative report_type="risk" data={riskReportData} />
<Card><CardHeader><CardTitle className="text-lg">Student Status Distribution</CardTitle></CardHeader><CardContent className="flex justify-center">
{statusPie.length > 0 ? (
<ResponsiveContainer width="100%" height={300}><PieChart><Pie data={statusPie} dataKey="value" cx="50%" cy="50%" outerRadius={100} label>{statusPie.map((d,i) => <Cell key={i} fill={d.color} />)}</Pie><Tooltip /><Legend /></PieChart></ResponsiveContainer>
) : <p className="text-center text-muted-foreground py-8">No students available.</p>}
</CardContent></Card>
</TabsContent>
<TabsContent value="attendance" className="mt-4 space-y-4">
<AiReportNarrative report_type="attendance" data={attendanceReportData} />
<Card><CardHeader><CardTitle className="text-lg">Attendance</CardTitle></CardHeader><CardContent>
<p className="text-center text-muted-foreground py-8">No attendance data recorded yet. Attendance tracking will appear here once sessions are recorded.</p>
</CardContent></Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useToast } from "@/hooks/use-toast";
function loadSettings() {
try { return JSON.parse(localStorage.getItem("encoach_settings") || "{}"); } catch { return {}; }
}
export default function AdminSettings() {
const { toast } = useToast();
const saved = loadSettings();
const [platformName, setPlatformName] = useState<string>(saved.platformName ?? "EnCoach");
const [supportEmail, setSupportEmail] = useState<string>(saved.supportEmail ?? "support@encoach.com");
const [maxStudents, setMaxStudents] = useState<string>(saved.maxStudents ?? "30");
const [aiEnabled, setAiEnabled] = useState<boolean>(saved.aiEnabled ?? true);
const [selfReg, setSelfReg] = useState<boolean>(saved.selfReg ?? true);
const [emailNotif, setEmailNotif] = useState<boolean>(saved.emailNotif ?? true);
const [minScore, setMinScore] = useState<string>(saved.minScore ?? "0");
const [maxScore, setMaxScore] = useState<string>(saved.maxScore ?? "100");
const [passThreshold, setPassThreshold] = useState<string>(saved.passThreshold ?? "60");
const handleSave = () => {
const data = { platformName, supportEmail, maxStudents, aiEnabled, selfReg, emailNotif, minScore, maxScore, passThreshold };
localStorage.setItem("encoach_settings", JSON.stringify(data));
toast({ title: "Settings saved", description: "Platform settings have been updated." });
};
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="text-2xl font-bold">Platform Settings</h1>
<p className="text-muted-foreground">Configure platform-wide settings.</p>
</div>
<Card>
<CardHeader><CardTitle className="text-lg">General</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2"><Label>Platform Name</Label><Input value={platformName} onChange={e => setPlatformName(e.target.value)} /></div>
<div className="space-y-2"><Label>Support Email</Label><Input value={supportEmail} onChange={e => setSupportEmail(e.target.value)} type="email" /></div>
<div className="space-y-2"><Label>Max Students per Batch</Label><Input value={maxStudents} onChange={e => setMaxStudents(e.target.value)} type="number" /></div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-lg">Features</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div><Label>AI Features</Label><p className="text-xs text-muted-foreground">Enable AI-powered insights and suggestions</p></div>
<Switch checked={aiEnabled} onCheckedChange={setAiEnabled} />
</div>
<div className="flex items-center justify-between">
<div><Label>Student Self-Registration</Label><p className="text-xs text-muted-foreground">Allow students to register themselves</p></div>
<Switch checked={selfReg} onCheckedChange={setSelfReg} />
</div>
<div className="flex items-center justify-between">
<div><Label>Email Notifications</Label><p className="text-xs text-muted-foreground">Send email notifications for assignments and grades</p></div>
<Switch checked={emailNotif} onCheckedChange={setEmailNotif} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-lg">Grading</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2"><Label>Min Score</Label><Input value={minScore} onChange={e => setMinScore(e.target.value)} type="number" /></div>
<div className="space-y-2"><Label>Max Score</Label><Input value={maxScore} onChange={e => setMaxScore(e.target.value)} type="number" /></div>
<div className="space-y-2"><Label>Pass Threshold</Label><Input value={passThreshold} onChange={e => setPassThreshold(e.target.value)} type="number" /></div>
</div>
</CardContent>
</Card>
<Button onClick={handleSave}>Save Settings</Button>
</div>
);
}

View File

@@ -0,0 +1,252 @@
import { useState } 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 { 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 {
useStudentLeaves,
useStudentLeaveTypes,
useCreateStudentLeave,
useApproveStudentLeave,
useRejectStudentLeave,
useStudents,
} from "@/hooks/queries";
import { Search, Plus, Check, X } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
function leaveBadge(state: string) {
switch (state) {
case "draft":
return <Badge variant="secondary">draft</Badge>;
case "confirm":
return <Badge variant="outline">confirm</Badge>;
case "approve":
case "validate":
return (
<Badge variant="default" className="bg-green-600 hover:bg-green-600">
{state}
</Badge>
);
case "refuse":
return <Badge variant="destructive">refuse</Badge>;
case "cancel":
return <Badge variant="secondary">cancel</Badge>;
default:
return <Badge variant="outline">{state}</Badge>;
}
}
export default function AdminStudentLeave() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({
student_id: "",
leave_type: "",
start_date: "",
end_date: "",
description: "",
});
const { toast } = useToast();
const { data, isLoading } = useStudentLeaves();
const items = data?.data ?? data?.items ?? [];
const createMutation = useCreateStudentLeave();
const approveMutation = useApproveStudentLeave();
const rejectMutation = useRejectStudentLeave();
const { data: studentsData } = useStudents({ size: 500 });
const studentsList = studentsData?.items ?? [];
const { data: leaveTypesData } = useStudentLeaveTypes();
const leaveTypes = leaveTypesData?.data ?? leaveTypesData?.items ?? [];
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
const q = search.toLowerCase();
const filtered = items.filter(
(r) =>
r.student_name?.toLowerCase().includes(q) ||
r.leave_type?.toLowerCase().includes(q) ||
r.description?.toLowerCase().includes(q),
);
const canAct = (state: string) => !["approve", "refuse", "cancel"].includes(state);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Student leave</h1>
<p className="text-muted-foreground">Review and create leave requests.</p>
</div>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
New request
</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by student or type..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Student</TableHead>
<TableHead>Leave Type</TableHead>
<TableHead>Start Date</TableHead>
<TableHead>End Date</TableHead>
<TableHead>Duration</TableHead>
<TableHead>State</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((row, i) => (
<TableRow key={row.id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{row.student_name}</TableCell>
<TableCell>{row.leave_type}</TableCell>
<TableCell>{row.start_date}</TableCell>
<TableCell>{row.end_date}</TableCell>
<TableCell>{row.duration}</TableCell>
<TableCell>{leaveBadge(row.state)}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
size="sm"
variant="outline"
disabled={!canAct(row.state) || approveMutation.isPending}
onClick={() =>
approveMutation.mutate(row.id, {
onSuccess: () => toast({ title: "Approved" }),
onError: (err: Error) =>
toast({ title: "Error", description: err.message, variant: "destructive" }),
})
}
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="destructive"
disabled={!canAct(row.state) || rejectMutation.isPending}
onClick={() =>
rejectMutation.mutate(row.id, {
onSuccess: () => toast({ title: "Rejected" }),
onError: (err: Error) =>
toast({ title: "Error", description: err.message, variant: "destructive" }),
})
}
>
<X className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create leave request</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Student *</Label>
<Select value={form.student_id || "__none__"} onValueChange={(v) => setForm((f) => ({ ...f, student_id: v === "__none__" ? "" : v }))}>
<SelectTrigger><SelectValue placeholder="Select student" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Select </SelectItem>
{studentsList.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Leave Type *</Label>
<Select value={form.leave_type || "__none__"} onValueChange={(v) => setForm((f) => ({ ...f, leave_type: v === "__none__" ? "" : v }))}>
<SelectTrigger><SelectValue placeholder="Select leave type" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Select </SelectItem>
{leaveTypes.map((t: { id: number; name: string }) => <SelectItem key={t.id} value={String(t.id)}>{t.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Start date</Label>
<Input
type="date"
value={form.start_date}
onChange={(e) => setForm((f) => ({ ...f, start_date: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>End date</Label>
<Input
type="date"
value={form.end_date}
onChange={(e) => setForm((f) => ({ ...f, end_date: e.target.value }))}
/>
</div>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button
disabled={createMutation.isPending || !form.student_id || !form.leave_type || !form.start_date || !form.end_date}
onClick={() => {
createMutation.mutate(
{
student_id: Number(form.student_id),
leave_type: Number(form.leave_type),
start_date: form.start_date,
end_date: form.end_date,
description: form.description || undefined,
},
{
onSuccess: () => {
setCreateOpen(false);
toast({ title: "Created successfully" });
},
onError: (err: Error) =>
toast({ title: "Error", description: err.message, variant: "destructive" }),
},
);
}}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useStudentProgressList } from "@/hooks/queries";
import { Search, TrendingUp } from "lucide-react";
export default function AdminStudentProgress() {
const [search, setSearch] = useState("");
const { data, isLoading } = useStudentProgressList();
const items = data?.data ?? data?.items ?? [];
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
const q = search.toLowerCase();
const filtered = items.filter((r) => r.student_name?.toLowerCase().includes(q));
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<TrendingUp className="h-7 w-7" />
Student progress
</h1>
<p className="text-muted-foreground">Attendance, assignments, and marksheets per student.</p>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search students..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Student</TableHead>
<TableHead>Total Attendance</TableHead>
<TableHead>Total Assignments</TableHead>
<TableHead>Total Marksheets</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((r, i) => (
<TableRow key={r.id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{r.student_name}</TableCell>
<TableCell>{r.total_attendance}</TableCell>
<TableCell>{r.total_assignment}</TableCell>
<TableCell>{r.total_marksheet_line}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,334 @@
import { useState, useMemo } 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 { 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 { Checkbox } from "@/components/ui/checkbox";
import { useStudents, useCreateStudent, useCourses, useBatches } from "@/hooks/queries";
import { lmsStudentToUser } from "@/services/lms.service";
import { api } from "@/lib/api-client";
import { useQueryClient } from "@tanstack/react-query";
import { Search, Plus, Trash2 } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import AiRiskBadge from "@/components/ai/AiRiskBadge";
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
import AiTipBanner from "@/components/ai/AiTipBanner";
export default function AdminStudents() {
const [search, setSearch] = useState("");
const [addOpen, setAddOpen] = useState(false);
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [gender, setGender] = useState<string>("m");
const [courseIdStr, setCourseIdStr] = useState<string>("");
const [batchIdStr, setBatchIdStr] = useState<string>("");
const [createPortal, setCreatePortal] = useState(true);
const { toast } = useToast();
const { data: studentsData, isLoading } = useStudents({ size: 500 });
const { data: coursesData } = useCourses({ size: 200 });
const { data: batchesData } = useBatches({ size: 200 });
const createStudent = useCreateStudent();
const qc = useQueryClient();
const courses = coursesData?.items ?? [];
const batches = batchesData?.items ?? [];
const courseId = courseIdStr ? Number(courseIdStr) : undefined;
const batchesForCourse = useMemo(
() => (courseId ? batches.filter((b) => b.course_id === courseId) : batches),
[batches, courseId],
);
const rows = studentsData?.items ?? [];
const displayRows = search.trim()
? rows.filter(
(s) =>
s.name.toLowerCase().includes(search.toLowerCase()) ||
(s.email || "").toLowerCase().includes(search.toLowerCase()),
)
: rows;
const resetForm = () => {
setFirstName("");
setLastName("");
setEmail("");
setPassword("");
setGender("m");
setCourseIdStr("");
setBatchIdStr("");
setCreatePortal(true);
};
const handleAdd = () => {
if (!email.trim()) {
toast({ title: "Email required", variant: "destructive" });
return;
}
if (!firstName.trim() && !lastName.trim()) {
toast({ title: "Name required", description: "Enter first or last name.", variant: "destructive" });
return;
}
const course_id = courseIdStr ? Number(courseIdStr) : undefined;
const batch_id = batchIdStr ? Number(batchIdStr) : undefined;
if ((course_id && !batch_id) || (!course_id && batch_id)) {
toast({
title: "Course and batch",
description: "Select both course and batch to enroll, or leave both empty.",
variant: "destructive",
});
return;
}
createStudent.mutate(
{
first_name: firstName.trim(),
last_name: lastName.trim() || "-",
email: email.trim().toLowerCase(),
gender,
...(password.trim() ? { password: password.trim() } : {}),
create_portal_user: createPortal,
...(course_id && batch_id ? { course_id, batch_id } : {}),
},
{
onSuccess: () => {
setAddOpen(false);
resetForm();
toast({
title: "Student created",
description: createPortal
? "Saved in OpenEduCat. Portal user created — student can log in with this email and password."
: "Saved in OpenEduCat (op.student) only.",
});
},
onError: (e: Error) => {
toast({ title: "Could not create student", description: e.message, variant: "destructive" });
},
},
);
};
async function handleDeleteStudent(id: number, name: string) {
if (!window.confirm(`Delete student "${name}"?`)) return;
try {
await api.delete(`/students/${id}`);
qc.invalidateQueries({ queryKey: ["lms", "students"] });
toast({ title: "Student deleted" });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
toast({ title: "Delete failed", description: msg, variant: "destructive" });
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Students</h1>
<p className="text-muted-foreground">OpenEduCat students same records as in Odoo LMS.</p>
</div>
<div className="flex gap-2">
<AiCreationAssistant type="student" />
<Button onClick={() => setAddOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Student
</Button>
</div>
</div>
<AiTipBanner context="admin-students" variant="insight" />
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search students..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Batch</TableHead>
<TableHead>Attendance</TableHead>
<TableHead>Avg Grade</TableHead>
<TableHead>AI Risk</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{displayRows.map((s) => {
const u = lmsStudentToUser(s);
const initials =
u.name
?.split(" ")
.map((w) => w[0])
.join("")
.slice(0, 2)
.toUpperCase() ?? "??";
return (
<TableRow key={s.id}>
<TableCell>
<div className="flex items-center gap-2">
<div className="h-7 w-7 rounded-full bg-primary/10 flex items-center justify-center text-xs font-medium text-primary">
{initials}
</div>
<div>
<p className="text-sm font-medium">{s.name}</p>
<p className="text-xs text-muted-foreground">{s.email || "—"}</p>
</div>
</div>
</TableCell>
<TableCell>{s.batch_name || "—"}</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell>
<AiRiskBadge />
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{s.status || "active"}
</Badge>
</TableCell>
<TableCell>
<Button variant="ghost" size="icon" onClick={() => handleDeleteStudent(s.id, s.name)}><Trash2 className="h-4 w-4 text-destructive" /></Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog
open={addOpen}
onOpenChange={(o) => {
setAddOpen(o);
if (!o) resetForm();
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Add Student</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="fn">First Name</Label>
<Input id="fn" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="ln">Last Name</Label>
<Input id="ln" value={lastName} onChange={(e) => setLastName(e.target.value)} />
</div>
</div>
<div className="space-y-2">
<Label>Gender</Label>
<Select value={gender} onValueChange={setGender}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="m">Male</SelectItem>
<SelectItem value="f">Female</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="em">Email (login)</Label>
<Input id="em" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="pw">Password</Label>
<Input
id="pw"
type="password"
autoComplete="new-password"
placeholder="Empty = Student123!"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox id="portal" checked={createPortal} onCheckedChange={(v) => setCreatePortal(v === true)} />
<Label htmlFor="portal" className="text-sm font-normal cursor-pointer">
Create portal login (res.users) for the SPA
</Label>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Course (optional)</Label>
<Select
value={courseIdStr || "__none__"}
onValueChange={(v) => {
setCourseIdStr(v === "__none__" ? "" : v);
setBatchIdStr("");
}}
>
<SelectTrigger>
<SelectValue placeholder="Select course" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> None </SelectItem>
{courses.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Batch (optional)</Label>
<Select
value={batchIdStr || "__none__"}
onValueChange={(v) => setBatchIdStr(v === "__none__" ? "" : v)}
disabled={!courseIdStr}
>
<SelectTrigger>
<SelectValue placeholder={courseIdStr ? "Select batch" : "Pick a course first"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> None </SelectItem>
{batchesForCourse.map((b) => (
<SelectItem key={b.id} value={String(b.id)}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">
Creates <code className="text-xs">op.student</code> in Odoo. With portal enabled, also{" "}
<code className="text-xs">res.users</code> (<code className="text-xs">base.group_portal</code>) linked via{" "}
<code className="text-xs">op_student_id</code>.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddOpen(false)}>
Cancel
</Button>
<Button onClick={handleAdd} disabled={createStudent.isPending}>
{createStudent.isPending ? "Saving…" : "Add"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,178 @@
import { useState } 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 { 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 { useTeachers, useCreateTeacher } from "@/hooks/queries";
import { api } from "@/lib/api-client";
import { useQueryClient } from "@tanstack/react-query";
import { Search, Plus, Trash2 } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
export default function AdminTeachers() {
const [search, setSearch] = useState("");
const [addOpen, setAddOpen] = useState(false);
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [gender, setGender] = useState("male");
const [specialization, setSpecialization] = useState("");
const { toast } = useToast();
const qc = useQueryClient();
const { data: teachersData, isLoading } = useTeachers({ size: 500 });
const createTeacher = useCreateTeacher();
const teachers = teachersData?.items ?? [];
const filtered = teachers.filter(
(t) =>
t.name.toLowerCase().includes(search.toLowerCase()) ||
(t.email || "").toLowerCase().includes(search.toLowerCase()),
);
const handleAdd = () => {
if (!firstName.trim() || !lastName.trim()) {
toast({ title: "First and last name required", variant: "destructive" });
return;
}
if (!email.trim()) {
toast({ title: "Email required", variant: "destructive" });
return;
}
createTeacher.mutate(
{
first_name: firstName.trim(),
last_name: lastName.trim(),
email: email.trim().toLowerCase(),
gender,
specialization: specialization.trim() || undefined,
},
{
onSuccess: () => {
setAddOpen(false);
setFirstName("");
setLastName("");
setEmail("");
setGender("male");
setSpecialization("");
toast({ title: "Teacher created" });
},
onError: (e: Error) => {
toast({ title: "Could not create teacher", description: e.message, variant: "destructive" });
},
},
);
};
async function handleDelete(id: number, name: string) {
if (!window.confirm(`Delete teacher "${name}"?`)) return;
try {
await api.delete(`/teachers/${id}`);
qc.invalidateQueries({ queryKey: ["lms", "teachers"] });
toast({ title: "Teacher deleted" });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
toast({ title: "Delete failed", description: msg, variant: "destructive" });
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Teachers</h1>
<p className="text-muted-foreground">Manage faculty members.</p>
</div>
<Button onClick={() => setAddOpen(true)}>
<Plus className="mr-2 h-4 w-4" />Add Teacher
</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search teachers..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9" />
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Teacher</TableHead>
<TableHead>Specialization</TableHead>
<TableHead>Subjects</TableHead>
<TableHead>Department</TableHead>
<TableHead>Gender</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((t) => {
const initials = t.name?.split(" ").map((w) => w[0]).join("").slice(0, 2).toUpperCase();
return (
<TableRow key={t.id}>
<TableCell>
<div className="flex items-center gap-2">
<div className="h-7 w-7 rounded-full bg-primary/10 flex items-center justify-center text-xs font-medium text-primary">{initials}</div>
<div>
<p className="text-sm font-medium">{t.name}</p>
<p className="text-xs text-muted-foreground">{t.email || "—"}</p>
</div>
</div>
</TableCell>
<TableCell>{t.specialization || "—"}</TableCell>
<TableCell>{t.subject_names?.length ? t.subject_names.join(", ") : "—"}</TableCell>
<TableCell>{t.department_name || "—"}</TableCell>
<TableCell className="capitalize">{t.gender || "—"}</TableCell>
<TableCell>
<Button variant="ghost" size="icon" onClick={() => handleDelete(t.id, t.name)}><Trash2 className="h-4 w-4 text-destructive" /></Button>
</TableCell>
</TableRow>
);
})}
{filtered.length === 0 && <TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">No teachers found.</TableCell></TableRow>}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Add Teacher</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"><Label>First Name *</Label><Input value={firstName} onChange={(e) => setFirstName(e.target.value)} /></div>
<div className="space-y-2"><Label>Last Name *</Label><Input value={lastName} onChange={(e) => setLastName(e.target.value)} /></div>
</div>
<div className="space-y-2"><Label>Email *</Label><Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
<div className="space-y-2">
<Label>Gender</Label>
<Select value={gender} onValueChange={setGender}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="male">Male</SelectItem>
<SelectItem value="female">Female</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2"><Label>Specialization</Label><Input value={specialization} onChange={(e) => setSpecialization(e.target.value)} placeholder="e.g. IELTS Writing" /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddOpen(false)}>Cancel</Button>
<Button onClick={handleAdd} disabled={createTeacher.isPending}>{createTeacher.isPending ? "Saving…" : "Add Teacher"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,146 @@
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
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 { useTimetable, useCourses } from "@/hooks/queries";
import { lmsService } from "@/services/lms.service";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import { Plus, Trash2 } from "lucide-react";
const days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] as const;
const hours = ["08:00", "09:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00", "16:00", "17:00"];
const SESSION_COLOR = "hsl(192, 82%, 32%)";
export default function AdminTimetable() {
const { data: rawSessions, isLoading } = useTimetable();
const timetableSessions = Array.isArray(rawSessions) ? rawSessions : [];
const [createOpen, setCreateOpen] = useState(false);
const [selectedSession, setSelectedSession] = useState<(typeof timetableSessions)[number] | null>(null);
const [form, setForm] = useState({ course_id: "", day: "Monday" as string, start_time: "08:00", end_time: "09:00", room: "" });
const { toast } = useToast();
const qc = useQueryClient();
const { data: coursesData } = useCourses({ size: 200 });
const courses = coursesData?.items ?? [];
const createMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => lmsService.createTimetableSession(data as never),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "timetable"] }); setCreateOpen(false); toast({ title: "Session created" }); },
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => lmsService.deleteTimetableSession(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "timetable"] }); setSelectedSession(null); toast({ title: "Session deleted" }); },
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
function isStart(day: string, hour: string) {
return timetableSessions.find(s => s.day === day && s.start_time === hour);
}
function getSessionAt(day: string, hour: string) {
return timetableSessions.find(s => s.day === day && s.start_time <= hour && s.end_time > hour);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div><h1 className="text-2xl font-bold">Master Timetable</h1><p className="text-muted-foreground">View and manage all scheduled sessions.</p></div>
<Button onClick={() => setCreateOpen(true)}><Plus className="mr-2 h-4 w-4" />Add Session</Button>
</div>
<Card>
<CardContent className="pt-6 overflow-x-auto">
<div className="min-w-[700px]">
<div className="grid grid-cols-6 gap-1">
<div className="text-xs font-medium text-muted-foreground p-2" />
{days.map(d => <div key={d} className="text-xs font-semibold text-center p-2 bg-muted rounded">{d}</div>)}
</div>
{hours.map(hour => (
<div key={hour} className="grid grid-cols-6 gap-1 min-h-[48px]">
<div className="text-xs text-muted-foreground p-2 flex items-start">{hour}</div>
{days.map(day => {
const session = isStart(day, hour);
const occupied = getSessionAt(day, hour);
if (session) {
return (
<div key={day} className="rounded-md p-2 text-xs text-white cursor-pointer hover:opacity-90" style={{ backgroundColor: SESSION_COLOR }} onClick={() => setSelectedSession(session)}>
<p className="font-semibold">{session.course_name}</p>
<p className="opacity-80">{session.batch_name}</p>
<p className="opacity-70">{session.teacher_name}</p>
<p className="opacity-70">{session.room}</p>
</div>
);
}
if (occupied) return <div key={day} />;
return <div key={day} className="border border-dashed border-border/50 rounded-md" />;
})}
</div>
))}
</div>
</CardContent>
</Card>
<Dialog open={!!selectedSession} onOpenChange={(v) => { if (!v) setSelectedSession(null); }}>
<DialogContent>
<DialogHeader><DialogTitle>Session Details</DialogTitle></DialogHeader>
{selectedSession && (
<div className="space-y-2 text-sm">
<p><span className="font-medium">Course:</span> {selectedSession.course_name}</p>
<p><span className="font-medium">Batch:</span> {selectedSession.batch_name}</p>
<p><span className="font-medium">Teacher:</span> {selectedSession.teacher_name}</p>
<p><span className="font-medium">Day:</span> {selectedSession.day}</p>
<p><span className="font-medium">Time:</span> {selectedSession.start_time} {selectedSession.end_time}</p>
<p><span className="font-medium">Room:</span> {selectedSession.room}</p>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setSelectedSession(null)}>Close</Button>
<Button variant="destructive" disabled={deleteMutation.isPending} onClick={() => { if (!selectedSession || !window.confirm("Delete this session?")) return; deleteMutation.mutate(selectedSession.id); }}>
<Trash2 className="mr-2 h-4 w-4" /> Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Add Timetable Session</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Course *</Label>
<Select value={form.course_id || "__none__"} onValueChange={v => setForm(f => ({ ...f, course_id: v === "__none__" ? "" : v }))}>
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Select </SelectItem>
{courses.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.title}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Day</Label>
<Select value={form.day} onValueChange={v => setForm(f => ({ ...f, day: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{days.map(d => <SelectItem key={d} value={d}>{d}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"><Label>Start Time</Label><Input type="time" value={form.start_time} onChange={e => setForm(f => ({ ...f, start_time: e.target.value }))} /></div>
<div className="space-y-2"><Label>End Time</Label><Input type="time" value={form.end_time} onChange={e => setForm(f => ({ ...f, end_time: e.target.value }))} /></div>
</div>
<div className="space-y-2"><Label>Room</Label><Input value={form.room} onChange={e => setForm(f => ({ ...f, room: e.target.value }))} placeholder="e.g. Room A101" /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button disabled={createMutation.isPending || !form.course_id} onClick={() => createMutation.mutate({ course_id: Number(form.course_id), day: form.day, start_time: form.start_time, end_time: form.end_time, room: form.room })}>Create</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,173 @@
import { useParams, useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ArrowLeft, Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { admissionService } from "@/services/admission.service";
import { useToast } from "@/hooks/use-toast";
const stateBadgeVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
submit: "secondary",
confirm: "default",
admission: "default",
reject: "destructive",
cancel: "destructive",
done: "secondary",
};
export default function AdmissionDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const qc = useQueryClient();
const admissionId = Number(id);
const { data: admission, isLoading } = useQuery({
queryKey: ["admissions", admissionId],
queryFn: () => admissionService.getAdmission(admissionId),
enabled: !!admissionId,
});
const makeTransition = (action: "submit" | "confirm" | "admit" | "reject") => {
const fns = {
submit: admissionService.submitAdmission,
confirm: admissionService.confirmAdmission,
admit: admissionService.admitAdmission,
reject: admissionService.rejectAdmission,
};
return fns[action];
};
const transitionMutation = useMutation({
mutationFn: ({ action }: { action: "submit" | "confirm" | "admit" | "reject" }) =>
makeTransition(action)(admissionId),
onSuccess: (_, { action }) => {
qc.invalidateQueries({ queryKey: ["admissions"] });
toast({ title: `Admission ${action}${action === "submit" ? "ted" : action === "reject" ? "ed" : "ed"}` });
},
onError: () => toast({ title: "Error", description: "Action failed", variant: "destructive" }),
});
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
if (!admission) return <div className="text-center py-12 text-muted-foreground">Admission not found.</div>;
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/admissions")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">
<h1 className="text-2xl font-bold">{admission.first_name} {admission.last_name}</h1>
<p className="text-muted-foreground">Application #{admission.application_number}</p>
</div>
<Badge variant={stateBadgeVariant[admission.state] ?? "outline"} className="capitalize text-sm px-3 py-1">
{admission.state}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader><CardTitle>Applicant Information</CardTitle></CardHeader>
<CardContent>
<dl className="space-y-3">
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Full Name</dt>
<dd className="text-sm font-medium">{admission.first_name} {admission.middle_name ?? ""} {admission.last_name}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Email</dt>
<dd className="text-sm font-medium">{admission.email}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Phone</dt>
<dd className="text-sm font-medium">{admission.phone ?? "—"}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Birth Date</dt>
<dd className="text-sm font-medium">{admission.birth_date ?? "—"}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Gender</dt>
<dd className="text-sm font-medium capitalize">{admission.gender === "m" ? "Male" : admission.gender === "f" ? "Female" : "Other"}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Nationality</dt>
<dd className="text-sm font-medium">{admission.nationality_name ?? "—"}</dd>
</div>
</dl>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Application Details</CardTitle></CardHeader>
<CardContent>
<dl className="space-y-3">
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Course</dt>
<dd className="text-sm font-medium">{admission.course_name}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Register</dt>
<dd className="text-sm font-medium">{admission.register_name}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Batch</dt>
<dd className="text-sm font-medium">{admission.batch_name ?? "—"}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Fees</dt>
<dd className="text-sm font-medium">{admission.fees}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Applied On</dt>
<dd className="text-sm font-medium">{new Date(admission.created_at).toLocaleDateString()}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
<Card>
<CardHeader><CardTitle>Actions</CardTitle></CardHeader>
<CardContent>
<div className="flex items-center gap-3">
{admission.state === "draft" && (
<Button onClick={() => transitionMutation.mutate({ action: "submit" })} disabled={transitionMutation.isPending}>
{transitionMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Submit Application
</Button>
)}
{admission.state === "submit" && (
<>
<Button onClick={() => transitionMutation.mutate({ action: "confirm" })} disabled={transitionMutation.isPending}>
{transitionMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Confirm
</Button>
<Button variant="destructive" onClick={() => transitionMutation.mutate({ action: "reject" })} disabled={transitionMutation.isPending}>
Reject
</Button>
</>
)}
{admission.state === "confirm" && (
<>
<Button onClick={() => transitionMutation.mutate({ action: "admit" })} disabled={transitionMutation.isPending}>
{transitionMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Admit
</Button>
<Button variant="destructive" onClick={() => transitionMutation.mutate({ action: "reject" })} disabled={transitionMutation.isPending}>
Reject
</Button>
</>
)}
{["admission", "reject", "done"].includes(admission.state) && (
<p className="text-sm text-muted-foreground">No further actions available.</p>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
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 { Search } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { admissionService } from "@/services/admission.service";
import type { Admission, AdmissionState } from "@/types/admission";
const STATE_TABS: { value: string; label: string }[] = [
{ value: "all", label: "All" },
{ value: "draft", label: "Draft" },
{ value: "submit", label: "Submitted" },
{ value: "confirm", label: "Confirmed" },
{ value: "admission", label: "Admitted" },
{ value: "reject", label: "Rejected" },
];
const stateBadgeVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
submit: "secondary",
confirm: "default",
admission: "default",
reject: "destructive",
cancel: "destructive",
done: "secondary",
};
export default function AdmissionList() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [stateFilter, setStateFilter] = useState("all");
const { data, isLoading } = useQuery({
queryKey: ["admissions", "list", { search, state: stateFilter === "all" ? undefined : stateFilter }],
queryFn: () => admissionService.listAdmissions({
search: search || undefined,
state: stateFilter === "all" ? undefined : stateFilter,
} as Parameters<typeof admissionService.listAdmissions>[0]),
});
const admissions = data?.items ?? [];
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Admissions</h1>
<p className="text-muted-foreground">View and manage student admission applications.</p>
</div>
<div className="flex items-center gap-2">
{STATE_TABS.map(tab => (
<Button
key={tab.value}
variant={stateFilter === tab.value ? "default" : "outline"}
size="sm"
onClick={() => setStateFilter(tab.value)}
>
{tab.label}
</Button>
))}
</div>
<Card>
<CardHeader>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search by name..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9" />
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Application #</TableHead>
<TableHead>Name</TableHead>
<TableHead>Course</TableHead>
<TableHead>Register</TableHead>
<TableHead>State</TableHead>
<TableHead>Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{admissions.map((adm: Admission) => (
<TableRow key={adm.id} className="cursor-pointer hover:bg-accent/50" onClick={() => navigate(`/admin/admissions/${adm.id}`)}>
<TableCell className="font-medium">{adm.application_number}</TableCell>
<TableCell>{adm.first_name} {adm.last_name}</TableCell>
<TableCell>{adm.course_name}</TableCell>
<TableCell className="text-muted-foreground">{adm.register_name}</TableCell>
<TableCell>
<Badge variant={stateBadgeVariant[adm.state] ?? "outline"} className="capitalize">{adm.state}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{new Date(adm.created_at).toLocaleDateString()}</TableCell>
</TableRow>
))}
{admissions.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">No admissions found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,182 @@
import { useState } from "react";
import { Card, CardContent } 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 { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, CheckCircle2, XCircle, Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { admissionService } from "@/services/admission.service";
import { useCourses } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { AdmissionRegister, AdmissionRegisterCreateRequest } from "@/types/admission";
const stateBadgeVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
confirm: "default",
cancel: "destructive",
done: "secondary",
};
export default function AdmissionRegisterPage() {
const { toast } = useToast();
const qc = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const { data: registersData, isLoading } = useQuery({
queryKey: ["admission-registers", "list"],
queryFn: () => admissionService.listRegisters(),
});
const registers = registersData?.items ?? [];
const { data: coursesData } = useCourses();
const courses = coursesData?.items ?? [];
const [form, setForm] = useState<AdmissionRegisterCreateRequest>({
name: "",
course_id: 0,
start_date: "",
end_date: "",
min_count: undefined,
max_count: undefined,
});
const createMutation = useMutation({
mutationFn: (data: AdmissionRegisterCreateRequest) => admissionService.createRegister(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admission-registers"] });
toast({ title: "Register created" });
setShowCreate(false);
setForm({ name: "", course_id: 0, start_date: "", end_date: "" });
},
onError: () => toast({ title: "Error", description: "Failed to create register", variant: "destructive" }),
});
const confirmMutation = useMutation({
mutationFn: (id: number) => admissionService.confirmRegister(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["admission-registers"] }); toast({ title: "Register confirmed" }); },
onError: () => toast({ title: "Error", description: "Failed to confirm", variant: "destructive" }),
});
const closeMutation = useMutation({
mutationFn: (id: number) => admissionService.closeRegister(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["admission-registers"] }); toast({ title: "Register closed" }); },
onError: () => toast({ title: "Error", description: "Failed to close", variant: "destructive" }),
});
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Admission Registers</h1>
<p className="text-muted-foreground">Manage admission registers and their status.</p>
</div>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Create Register</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Admission Register</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="e.g. Fall 2025 Intake" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Course</Label>
<Select value={form.course_id ? form.course_id.toString() : ""} onValueChange={v => setForm({ ...form, course_id: Number(v) })}>
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
<SelectContent>
{courses.map((c) => (
<SelectItem key={c.id} value={c.id.toString()}>{(c as { title?: string; name?: string }).title || (c as { name?: string }).name || `Course #${c.id}`}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Start Date</Label>
<Input type="date" value={form.start_date} onChange={e => setForm({ ...form, start_date: e.target.value })} />
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input type="date" value={form.end_date} onChange={e => setForm({ ...form, end_date: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Min Count</Label>
<Input type="number" value={form.min_count ?? ""} onChange={e => setForm({ ...form, min_count: e.target.value ? Number(e.target.value) : undefined })} />
</div>
<div className="space-y-2">
<Label>Max Count</Label>
<Input type="number" value={form.max_count ?? ""} onChange={e => setForm({ ...form, max_count: e.target.value ? Number(e.target.value) : undefined })} />
</div>
</div>
<Button className="w-full" onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.name || !form.course_id}>
{createMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Register
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Course</TableHead>
<TableHead>Dates</TableHead>
<TableHead>Capacity</TableHead>
<TableHead>Applications</TableHead>
<TableHead>State</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{registers.map((reg: AdmissionRegister) => (
<TableRow key={reg.id}>
<TableCell className="font-medium">{reg.name}</TableCell>
<TableCell>{reg.course_name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{reg.start_date} {reg.end_date}</TableCell>
<TableCell>{reg.min_count}{reg.max_count}</TableCell>
<TableCell><Badge variant="secondary">{reg.application_count}</Badge></TableCell>
<TableCell>
<Badge variant={stateBadgeVariant[reg.state] ?? "outline"} className="capitalize">{reg.state}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{reg.state === "draft" && (
<Button variant="ghost" size="sm" onClick={() => confirmMutation.mutate(reg.id)} disabled={confirmMutation.isPending}>
<CheckCircle2 className="mr-1 h-3 w-3" /> Confirm
</Button>
)}
{reg.state === "confirm" && (
<Button variant="ghost" size="sm" onClick={() => closeMutation.mutate(reg.id)} disabled={closeMutation.isPending}>
<XCircle className="mr-1 h-3 w-3" /> Close
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
{registers.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">No admission registers found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,136 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useApproveQuality, useQualityGate, useRejectQuality } from "@/hooks/queries/useAiCourse";
import { CheckCircle2, XCircle } from "lucide-react";
import { toast } from "sonner";
export default function AiEnglishQuality() {
const { courseId: cid } = useParams<{ courseId: string }>();
const courseId = Number(cid);
const { data, isLoading, refetch } = useQualityGate(Number.isFinite(courseId) ? courseId : undefined);
const approve = useApproveQuality();
const reject = useRejectQuality();
const [rejectOpen, setRejectOpen] = useState(false);
const [notes, setNotes] = useState("");
const checks = data?.checks ?? [];
if (isLoading) {
return <div className="p-6 text-muted-foreground">Loading quality gate</div>;
}
return (
<div className="space-y-6 p-6 max-w-5xl mx-auto">
<div>
<h1 className="text-2xl font-semibold tracking-tight">English course quality</h1>
<p className="text-muted-foreground text-sm mt-1">
Attempts {data?.attempts ?? 0} / {data?.max_attempts ?? 0}
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Quality gate checks</CardTitle>
<CardDescription>Readability, calibration, grammar, and length</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Check</TableHead>
<TableHead>Status</TableHead>
<TableHead>Detail</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{checks.map((c) => (
<TableRow key={c.name}>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell>
<span className="inline-flex items-center gap-1">
{c.status === "pass" ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-destructive" />
)}
{c.status}
</span>
</TableCell>
<TableCell className="text-muted-foreground max-w-md">{c.detail}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex flex-wrap gap-3">
<Button
onClick={() =>
approve.mutate(courseId, {
onSuccess: () => {
toast.success("Approved.");
refetch();
},
onError: (e) => toast.error(e instanceof Error ? e.message : "Failed"),
})
}
disabled={!Number.isFinite(courseId) || approve.isPending}
>
Approve
</Button>
<Button variant="destructive" onClick={() => setRejectOpen(true)}>
Reject & Regenerate
</Button>
<Button variant="outline" onClick={() => refetch()}>
Edit
</Button>
</div>
</CardContent>
</Card>
<Dialog open={rejectOpen} onOpenChange={setRejectOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Regeneration notes</DialogTitle>
</DialogHeader>
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="What should change?" rows={4} />
<DialogFooter>
<Button variant="outline" onClick={() => setRejectOpen(false)}>
Cancel
</Button>
<Button
onClick={() =>
reject.mutate(
{ courseId, notes },
{
onSuccess: () => {
toast.success("Rejected; regeneration requested.");
setRejectOpen(false);
refetch();
},
onError: (e) => toast.error(e instanceof Error ? e.message : "Failed"),
},
)
}
disabled={reject.isPending}
>
Submit
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useEnglishTaxonomy } from "@/hooks/queries/useAiCourse";
type DimKey = "grammar_by_cefr" | "vocabulary_bands" | "skill_areas" | "topic_categories";
const LABELS: Record<DimKey, string> = {
grammar_by_cefr: "Grammar topics by CEFR",
vocabulary_bands: "Vocabulary bands",
skill_areas: "Skill areas",
topic_categories: "Topic categories",
};
export default function AiEnglishTaxonomy() {
const { data, isLoading } = useEnglishTaxonomy();
const dims = (data ?? {}) as Record<string, unknown[] | Record<string, unknown[]>>;
return (
<div className="space-y-6 p-6 max-w-5xl mx-auto">
<div>
<h1 className="text-2xl font-semibold tracking-tight">English taxonomy reference</h1>
<p className="text-muted-foreground text-sm mt-1">Read-only curriculum dimensions</p>
</div>
<Card>
<CardHeader>
<CardTitle>Taxonomy dimensions</CardTitle>
<CardDescription>Four reference views</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-muted-foreground">Loading</p>
) : (
<Accordion type="multiple" className="w-full">
{(Object.keys(LABELS) as DimKey[]).map((key) => (
<AccordionItem key={key} value={key}>
<AccordionTrigger>{LABELS[key]}</AccordionTrigger>
<AccordionContent>
<TaxonomyBlock value={dims[key]} />
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)}
</CardContent>
</Card>
</div>
);
}
function TaxonomyBlock({ value }: { value: unknown }) {
if (value == null) {
return <p className="text-sm text-muted-foreground">No data.</p>;
}
if (Array.isArray(value)) {
return (
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Entry</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{value.map((row, i) => (
<TableRow key={i}>
<TableCell>{typeof row === "object" && row !== null ? JSON.stringify(row) : String(row)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown[]>);
return (
<div className="space-y-4">
{entries.map(([k, rows]) => (
<div key={k}>
<h4 className="text-sm font-semibold mb-2">{k}</h4>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(Array.isArray(rows) ? rows : []).map((row, i) => (
<TableRow key={i}>
<TableCell className="text-sm">
{typeof row === "object" && row !== null ? JSON.stringify(row) : String(row)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
))}
</div>
);
}
return <p className="text-sm">{String(value)}</p>;
}

View File

@@ -0,0 +1,207 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useIeltsValidation, useSubmitExaminerReview } from "@/hooks/queries/useAiCourse";
import type { ExaminerReviewItem } from "@/types";
import { CheckCircle2, XCircle } from "lucide-react";
import { toast } from "sonner";
export default function AiIeltsValidation() {
const { courseId: cid } = useParams<{ courseId: string }>();
const courseId = Number(cid);
const { data, isLoading, refetch } = useIeltsValidation(Number.isFinite(courseId) ? courseId : undefined);
const submitReview = useSubmitExaminerReview();
const [preview, setPreview] = useState<ExaminerReviewItem | null>(null);
const [notes, setNotes] = useState("");
const [checklist, setChecklist] = useState({
topic_accuracy: true,
difficulty_appropriate: true,
ielts_aligned: true,
culturally_sensitive: true,
});
const layer1 = data?.layer1_checks ?? [];
const layer2 = data?.layer2_items ?? [];
const send = (approved: boolean) => {
if (!preview) return;
const payload: Parameters<typeof submitReview.mutate>[0] = {
item_id: preview.id,
approved,
notes: approved ? undefined : notes,
checklist,
};
submitReview.mutate(payload, {
onSuccess: () => {
toast.success(approved ? "Approved." : "Rejected with notes.");
setPreview(null);
setNotes("");
refetch();
},
onError: (e) => toast.error(e instanceof Error ? e.message : "Failed"),
});
};
if (isLoading) {
return <div className="p-6 text-muted-foreground">Loading validation</div>;
}
return (
<div className="space-y-8 p-6 max-w-6xl mx-auto">
<div>
<h1 className="text-2xl font-semibold tracking-tight">IELTS content validation</h1>
<p className="text-muted-foreground text-sm mt-1">Layer 1 automation · Layer 2 examiner queue</p>
</div>
<Card>
<CardHeader>
<CardTitle>Layer 1 Automated checks</CardTitle>
<CardDescription>Format, calibration, answer keys</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Check</TableHead>
<TableHead>Status</TableHead>
<TableHead>Detail</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{layer1.map((c) => (
<TableRow key={c.name}>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell>
<span className="inline-flex items-center gap-1">
{c.status === "pass" ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-destructive" />
)}
{c.status}
</span>
</TableCell>
<TableCell className="text-muted-foreground">{c.detail}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Layer 2 Examiner review queue</CardTitle>
<CardDescription>Approve or reject with structured notes</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Skill</TableHead>
<TableHead>Content type</TableHead>
<TableHead>Target band</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{layer2.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.skill}</TableCell>
<TableCell>{item.content_type}</TableCell>
<TableCell>{item.target_band}</TableCell>
<TableCell>{item.status}</TableCell>
<TableCell className="text-right">
<Button size="sm" variant="outline" onClick={() => setPreview(item)}>
Review
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<Dialog open={!!preview} onOpenChange={(o) => !o && setPreview(null)}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Content preview</DialogTitle>
</DialogHeader>
{preview && (
<div className="space-y-4 text-sm">
<p>
<span className="font-medium">Skill:</span> {preview.skill} ·{" "}
<span className="font-medium">Type:</span> {preview.content_type} ·{" "}
<span className="font-medium">Band:</span> {preview.target_band}
</p>
<div className="rounded-md border bg-muted/40 p-4 min-h-[120px] text-muted-foreground">
Full content preview loads here from the authoring pipeline.
</div>
<div className="space-y-3">
<Label className="text-base">Examiner checklist</Label>
<div className="space-y-2">
{(
[
["topic_accuracy", "Accuracy"],
["difficulty_appropriate", "Difficulty"],
["ielts_aligned", "IELTS alignment"],
["culturally_sensitive", "Cultural sensitivity"],
] as const
).map(([key, label]) => (
<div key={key} className="flex items-center gap-2">
<Checkbox
id={key}
checked={checklist[key]}
onCheckedChange={(c) => setChecklist((prev) => ({ ...prev, [key]: !!c }))}
/>
<Label htmlFor={key} className="font-normal cursor-pointer">
{label}
</Label>
</div>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Rejection notes</Label>
<Textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Required when rejecting"
rows={3}
/>
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => send(true)} disabled={submitReview.isPending}>
Approve
</Button>
<Button variant="destructive" onClick={() => send(false)} disabled={submitReview.isPending}>
Reject with Notes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,222 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } 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 { Switch } from "@/components/ui/switch";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Plus, Trash2, Loader2, ChevronRight, Settings2, ArrowRight } from "lucide-react";
import { approvalsService, type ApprovalWorkflow } from "@/services/approvals.service";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
interface WorkflowStage {
order: number;
approver_id: number;
approver_name: string;
max_days?: number;
notification_email?: string;
auto_escalate?: boolean;
}
export default function ApprovalWorkflowConfig() {
const { toast } = useToast();
const qc = useQueryClient();
const { data: workflowsData, isLoading } = useQuery({
queryKey: ["approvals", "list"],
queryFn: () => approvalsService.list(),
});
const workflows = workflowsData?.results ?? [];
const createWorkflow = useMutation({
mutationFn: (data: Partial<ApprovalWorkflow>) => approvalsService.create(data),
onSuccess: () => qc.invalidateQueries({ queryKey: ["approvals"] }),
onError: () => toast({ title: "Error", description: "Failed to create workflow", variant: "destructive" }),
});
const updateWorkflow = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<ApprovalWorkflow> }) => approvalsService.update(id, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ["approvals"] }),
onError: () => toast({ title: "Error", description: "Failed to update workflow", variant: "destructive" }),
});
const deleteWorkflow = useMutation({
mutationFn: (id: number) => approvalsService.delete(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["approvals"] }),
onError: () => toast({ title: "Error", description: "Failed to delete workflow", variant: "destructive" }),
});
const [showCreate, setShowCreate] = useState(false);
const [wfName, setWfName] = useState("");
const [stages, setStages] = useState<WorkflowStage[]>([{ order: 1, approver_id: 0, approver_name: "", max_days: 3, auto_escalate: false }]);
const [bypassEnabled, setBypassEnabled] = useState(false);
const resetForm = () => {
setWfName("");
setStages([{ order: 1, approver_id: 0, approver_name: "", max_days: 3, auto_escalate: false }]);
setBypassEnabled(false);
};
const addStage = () => {
setStages(prev => [...prev, { order: prev.length + 1, approver_id: 0, approver_name: "", max_days: 3, auto_escalate: false }]);
};
const removeStage = (idx: number) => {
setStages(prev => prev.filter((_, i) => i !== idx).map((s, i) => ({ ...s, order: i + 1 })));
};
const updateStage = (idx: number, field: keyof WorkflowStage, value: string | number | boolean) => {
setStages(prev => prev.map((s, i) => i === idx ? { ...s, [field]: value } : s));
};
const handleCreate = () => {
createWorkflow.mutate(
{ name: wfName, steps: stages.map(s => ({ order: s.order, approver_id: s.approver_id, approver_name: s.approver_name })), status: "draft" } as Partial<ApprovalWorkflow>,
{ onSuccess: () => { toast({ title: "Workflow Created" }); setShowCreate(false); resetForm(); } },
);
};
const handleDelete = (id: number) => {
if (!window.confirm("Delete this approval workflow?")) return;
deleteWorkflow.mutate(id, {
onSuccess: () => toast({ title: "Workflow Deleted" }),
});
};
const handleToggleStatus = (wf: ApprovalWorkflow) => {
const newStatus = wf.status === "active" ? "draft" : "active";
updateWorkflow.mutate(
{ id: wf.id, data: { status: newStatus } },
{ onSuccess: () => toast({ title: `Workflow ${newStatus === "active" ? "Activated" : "Deactivated"}` }) },
);
};
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Approval Workflows</h1>
<p className="text-muted-foreground">Configure multi-stage approval workflows with escalation rules.</p>
</div>
<Dialog open={showCreate} onOpenChange={open => { setShowCreate(open); if (!open) resetForm(); }}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Create Workflow</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader><DialogTitle>Create Approval Workflow</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Workflow Name</Label>
<Input placeholder="e.g. Leave Approval" value={wfName} onChange={e => setWfName(e.target.value)} />
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Stages</Label>
<Button variant="outline" size="sm" onClick={addStage}><Plus className="mr-2 h-3 w-3" /> Add Stage</Button>
</div>
{stages.map((stage, idx) => (
<Card key={idx}>
<CardContent className="py-3 space-y-3">
<div className="flex items-center justify-between">
<Badge variant="outline">Stage {stage.order}</Badge>
{stages.length > 1 && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => removeStage(idx)}>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Approver Name</Label>
<Input value={stage.approver_name} onChange={e => updateStage(idx, "approver_name", e.target.value)} placeholder="Name" />
</div>
<div className="space-y-1">
<Label className="text-xs">Approver ID</Label>
<Input type="number" value={stage.approver_id || ""} onChange={e => updateStage(idx, "approver_id", Number(e.target.value))} placeholder="ID" />
</div>
<div className="space-y-1">
<Label className="text-xs">Max Days</Label>
<Input type="number" min={1} value={stage.max_days ?? 3} onChange={e => updateStage(idx, "max_days", Number(e.target.value))} />
</div>
<div className="space-y-1">
<Label className="text-xs">Email</Label>
<Input type="email" value={stage.notification_email ?? ""} onChange={e => updateStage(idx, "notification_email", e.target.value)} placeholder="email@..." />
</div>
</div>
<div className="flex items-center gap-2">
<Switch checked={!!stage.auto_escalate} onCheckedChange={v => updateStage(idx, "auto_escalate", v)} />
<Label className="text-xs">Auto-escalate after max days</Label>
</div>
</CardContent>
</Card>
))}
</div>
<div className="flex items-center gap-2 p-3 rounded-lg border">
<Switch checked={bypassEnabled} onCheckedChange={setBypassEnabled} />
<Label>Allow bypass (skip all stages)</Label>
</div>
<Button className="w-full" onClick={handleCreate} disabled={createWorkflow.isPending || !wfName || stages.length === 0}>
{createWorkflow.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Workflow
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="space-y-4">
{workflows.map(wf => (
<Card key={wf.id}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Settings2 className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-base">{wf.name}</CardTitle>
<CardDescription>{wf.entity_name}</CardDescription>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={wf.status === "active" ? "default" : "secondary"}>{wf.status}</Badge>
<Switch checked={wf.status === "active"} onCheckedChange={() => handleToggleStatus(wf)} />
<Button variant="ghost" size="icon" onClick={() => handleDelete(wf.id)} disabled={deleteWorkflow.isPending}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 flex-wrap">
{wf.steps.map((step, i) => (
<div key={i} className="flex items-center gap-2">
<div className="flex items-center gap-1 px-3 py-1.5 rounded-full border bg-muted/50 text-sm">
<span className="h-5 w-5 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium">{step.order}</span>
<span>{step.approver_name}</span>
</div>
{i < wf.steps.length - 1 && <ArrowRight className="h-4 w-4 text-muted-foreground" />}
</div>
))}
</div>
<p className="text-xs text-muted-foreground mt-2">Created: {new Date(wf.created_at).toLocaleDateString()}</p>
</CardContent>
</Card>
))}
{workflows.length === 0 && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Settings2 className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>No approval workflows configured.</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,348 @@
import { useState, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
Search, Loader2, Grid3X3, Check, X, Shield, Download, Users,
} from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { rolesService } from "@/services/roles.service";
import type { Permission, AuthorityMatrixRole, UserAuthorityMatrixUser } from "@/types/role-permission";
const CATEGORY_COLORS: Record<string, string> = {
exam: "border-violet-400 bg-violet-50 dark:bg-violet-950",
user: "border-blue-400 bg-blue-50 dark:bg-blue-950",
entity: "border-emerald-400 bg-emerald-50 dark:bg-emerald-950",
assignment: "border-amber-400 bg-amber-50 dark:bg-amber-950",
classroom: "border-rose-400 bg-rose-50 dark:bg-rose-950",
payment: "border-cyan-400 bg-cyan-50 dark:bg-cyan-950",
report: "border-orange-400 bg-orange-50 dark:bg-orange-950",
};
const CATEGORY_BADGE: Record<string, string> = {
exam: "bg-violet-100 text-violet-700",
user: "bg-blue-100 text-blue-700",
entity: "bg-emerald-100 text-emerald-700",
assignment: "bg-amber-100 text-amber-700",
classroom: "bg-rose-100 text-rose-700",
payment: "bg-cyan-100 text-cyan-700",
report: "bg-orange-100 text-orange-700",
};
function initials(name: string) {
return name.split(" ").map(w => w[0]).join("").toUpperCase().slice(0, 2);
}
function filterCategories(categories: Record<string, Permission[]>, query: string) {
if (!query) return categories;
const q = query.toLowerCase();
const result: Record<string, Permission[]> = {};
for (const [cat, perms] of Object.entries(categories)) {
const filtered = perms.filter(p =>
p.name.toLowerCase().includes(q) || p.code.toLowerCase().includes(q) || cat.toLowerCase().includes(q),
);
if (filtered.length > 0) result[cat] = filtered;
}
return result;
}
// ── Shared matrix grid component ──
function MatrixGrid<T extends { id: number; granted_permission_ids: number[] }>({
columns, categories, search, renderColumnHeader, onCellClick, pendingCells,
}: {
columns: T[];
categories: Record<string, Permission[]>;
search: string;
renderColumnHeader: (col: T) => React.ReactNode;
onCellClick?: (colId: number, permId: number) => void;
pendingCells: Set<string>;
}) {
const filtered = useMemo(() => filterCategories(categories, search), [categories, search]);
if (columns.length === 0) {
return <Card><CardContent className="py-16 text-center text-muted-foreground">No data available.</CardContent></Card>;
}
if (Object.keys(filtered).length === 0) {
return <Card><CardContent className="py-16 text-center text-muted-foreground">No permissions match your search.</CardContent></Card>;
}
return (
<div className="space-y-6">
{Object.entries(filtered).map(([cat, perms]) => (
<Card key={cat} className={`border-l-4 ${CATEGORY_COLORS[cat.toLowerCase()] ?? "border-l-gray-400 bg-gray-50 dark:bg-gray-950"}`}>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Badge className={`${CATEGORY_BADGE[cat.toLowerCase()] ?? "bg-gray-100 text-gray-700"} font-medium text-xs`}>{cat}</Badge>
<span className="text-muted-foreground font-normal">{perms.length} permission{perms.length !== 1 ? "s" : ""}</span>
</CardTitle>
</CardHeader>
<CardContent className="overflow-x-auto pb-4">
<table className="w-full text-sm">
<thead>
<tr>
<th className="text-left font-medium text-muted-foreground pb-2 pr-4 min-w-[200px] sticky left-0 bg-card z-10">Permission</th>
{columns.map(col => (
<th key={col.id} className="text-center font-medium text-muted-foreground pb-2 px-2 min-w-[100px]">
{renderColumnHeader(col)}
</th>
))}
</tr>
</thead>
<tbody>
{perms.map((p, idx) => (
<tr key={p.id} className={idx % 2 === 0 ? "" : "bg-muted/30"}>
<td className="py-1.5 pr-4 sticky left-0 bg-card z-10">
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 cursor-default">
<code className="text-[10px] font-mono text-muted-foreground bg-muted px-1 rounded">{p.code}</code>
<span className="text-xs font-medium truncate max-w-[140px]">{p.name}</span>
</div>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p className="font-medium">{p.name}</p>
<p className="text-xs text-muted-foreground">{p.description || "No description"}</p>
</TooltipContent>
</Tooltip>
</td>
{columns.map(col => {
const granted = col.granted_permission_ids.includes(p.id);
const cellKey = `${col.id}-${p.id}`;
const pending = pendingCells.has(cellKey);
return (
<td key={col.id} className="text-center py-1.5 px-2">
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onCellClick?.(col.id, p.id)}
disabled={pending || !onCellClick}
className={`
inline-flex items-center justify-center h-7 w-7 rounded-md border transition-all duration-150
${pending ? "opacity-50 cursor-wait" : onCellClick ? "cursor-pointer hover:scale-110" : "cursor-default"}
${granted
? "bg-emerald-500/20 border-emerald-500/40 hover:bg-emerald-500/30"
: "bg-muted/50 border-border hover:bg-muted hover:border-muted-foreground/30"
}
`}
>
{pending ? (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
) : granted ? (
<Check className="h-3.5 w-3.5 text-emerald-600" />
) : (
<X className="h-3 w-3 text-muted-foreground/30" />
)}
</button>
</TooltipTrigger>
<TooltipContent>
{granted ? "Granted" : "Not granted"}: <strong>{p.code}</strong>
</TooltipContent>
</Tooltip>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
))}
</div>
);
}
export default function AuthorityMatrix() {
const { toast } = useToast();
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [tab, setTab] = useState("roles");
const [pendingCells, setPendingCells] = useState<Set<string>>(new Set());
const roleMatrixQ = useQuery({ queryKey: ["authority-matrix"], queryFn: rolesService.getAuthorityMatrix });
const userMatrixQ = useQuery({ queryKey: ["user-authority-matrix"], queryFn: rolesService.getUserAuthorityMatrix, enabled: tab === "users" });
const toggleMut = useMutation({
mutationFn: ({ roleId, permId }: { roleId: number; permId: number }) =>
rolesService.toggleMatrixCell(roleId, permId),
onMutate: ({ roleId, permId }) => {
setPendingCells(prev => new Set(prev).add(`${roleId}-${permId}`));
},
onSuccess: (result) => {
qc.invalidateQueries({ queryKey: ["authority-matrix"] });
qc.invalidateQueries({ queryKey: ["user-authority-matrix"] });
setPendingCells(prev => { const s = new Set(prev); s.delete(`${result.role_id}-${result.permission_id}`); return s; });
},
onError: (_err, { roleId, permId }) => {
setPendingCells(prev => { const s = new Set(prev); s.delete(`${roleId}-${permId}`); return s; });
toast({ title: "Failed to toggle", variant: "destructive" });
},
});
const roles: AuthorityMatrixRole[] = roleMatrixQ.data?.roles ?? [];
const roleCategories: Record<string, Permission[]> = roleMatrixQ.data?.categories ?? {};
const matrixUsers: UserAuthorityMatrixUser[] = userMatrixQ.data?.users ?? [];
const userCategories: Record<string, Permission[]> = userMatrixQ.data?.categories ?? {};
const totalPerms = Object.values(roleCategories).reduce((s, p) => s + p.length, 0);
const totalGranted = roles.reduce((s, r) => s + r.granted_permission_ids.length, 0);
const totalCells = roles.length * totalPerms;
const coveragePercent = totalCells > 0 ? Math.round((totalGranted / totalCells) * 100) : 0;
const handleExportCsv = () => {
const cats = tab === "roles" ? roleCategories : userCategories;
const allPerms = Object.values(cats).flat();
if (tab === "roles") {
const header = ["Permission Code", "Permission Name", "Category", ...roles.map(r => r.name)];
const rows = allPerms.map(p => {
const cells = roles.map(r => r.granted_permission_ids.includes(p.id) ? "Yes" : "No");
return [p.code, p.name, p.category, ...cells];
});
downloadCsv([header, ...rows], "authority-matrix-roles.csv");
} else {
const header = ["Permission Code", "Permission Name", "Category", ...matrixUsers.map(u => u.name)];
const rows = allPerms.map(p => {
const cells = matrixUsers.map(u => u.granted_permission_ids.includes(p.id) ? "Yes" : "No");
return [p.code, p.name, p.category, ...cells];
});
downloadCsv([header, ...rows], "authority-matrix-users.csv");
}
};
const loading = roleMatrixQ.isLoading || (tab === "users" && userMatrixQ.isLoading);
if (roleMatrixQ.isLoading) {
return <div className="flex items-center justify-center min-h-[400px]"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>;
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Grid3X3 className="h-7 w-7 text-primary" />
Authority Matrix
</h1>
<p className="text-muted-foreground">Visual overview of permission assignments across roles and users.</p>
</div>
<Button variant="outline" onClick={handleExportCsv}><Download className="mr-2 h-4 w-4" /> Export CSV</Button>
</div>
{/* Summary */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-5 pb-4 flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center"><Shield className="h-5 w-5 text-primary" /></div>
<div><p className="text-xl font-bold">{roles.length}</p><p className="text-xs text-muted-foreground">Roles</p></div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 pb-4 flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-amber-500/10 flex items-center justify-center"><Grid3X3 className="h-5 w-5 text-amber-500" /></div>
<div><p className="text-xl font-bold">{totalPerms}</p><p className="text-xs text-muted-foreground">Permissions</p></div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 pb-4 flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-emerald-500/10 flex items-center justify-center"><Check className="h-5 w-5 text-emerald-500" /></div>
<div><p className="text-xl font-bold">{totalGranted}</p><p className="text-xs text-muted-foreground">Granted</p></div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 pb-4 flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
<span className="text-sm font-bold text-blue-500">{coveragePercent}%</span>
</div>
<div><p className="text-xl font-bold">Coverage</p><p className="text-xs text-muted-foreground">{totalGranted}/{totalCells} cells</p></div>
</CardContent>
</Card>
</div>
{/* Tabs & Search */}
<Tabs value={tab} onValueChange={setTab}>
<div className="flex flex-wrap items-center justify-between gap-3">
<TabsList>
<TabsTrigger value="roles" className="gap-1.5"><Shield className="h-4 w-4" /> Roles ({roles.length})</TabsTrigger>
<TabsTrigger value="users" className="gap-1.5"><Users className="h-4 w-4" /> Users ({matrixUsers.length || "..."})</TabsTrigger>
</TabsList>
<div className="relative max-w-xs w-full">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Filter permissions..." className="pl-9" value={search} onChange={e => setSearch(e.target.value)} />
</div>
</div>
{/* Legend */}
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground mt-4">
<span className="font-medium text-foreground">Legend:</span>
<span className="flex items-center gap-1.5">
<span className="inline-flex h-5 w-5 rounded bg-emerald-500/20 border border-emerald-500/40 items-center justify-center"><Check className="h-3 w-3 text-emerald-600" /></span>
Granted
</span>
<span className="flex items-center gap-1.5">
<span className="inline-flex h-5 w-5 rounded bg-muted border border-border items-center justify-center"><X className="h-3 w-3 text-muted-foreground/40" /></span>
Not granted
</span>
{tab === "roles" && <span className="ml-auto">Click any cell to toggle</span>}
{tab === "users" && <span className="ml-auto text-muted-foreground/70">User permissions are derived from roles (read-only)</span>}
</div>
{/* ── Roles Matrix Tab ── */}
<TabsContent value="roles" className="mt-4">
<MatrixGrid
columns={roles}
categories={roleCategories}
search={search}
pendingCells={pendingCells}
onCellClick={(colId, permId) => toggleMut.mutate({ roleId: colId, permId })}
renderColumnHeader={(r) => (
<div className="flex flex-col items-center gap-0.5">
<Shield className="h-3.5 w-3.5 text-primary/60" />
<span className="text-xs leading-tight">{r.name}</span>
</div>
)}
/>
</TabsContent>
{/* ── Users Matrix Tab ── */}
<TabsContent value="users" className="mt-4">
{userMatrixQ.isLoading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>
) : matrixUsers.length === 0 ? (
<Card><CardContent className="py-16 text-center text-muted-foreground">No users with roles assigned. Assign roles in the User Roles page first.</CardContent></Card>
) : (
<MatrixGrid
columns={matrixUsers}
categories={userCategories}
search={search}
pendingCells={new Set()}
renderColumnHeader={(u) => (
<div className="flex flex-col items-center gap-0.5">
<Avatar className="h-6 w-6">
<AvatarFallback className="text-[9px] bg-primary/10 text-primary font-medium">{initials(u.name)}</AvatarFallback>
</Avatar>
<span className="text-[10px] leading-tight max-w-[80px] truncate">{u.name}</span>
<span className="text-[9px] text-muted-foreground">{u.roles.length} role{u.roles.length !== 1 ? "s" : ""}</span>
</div>
)}
/>
)}
</TabsContent>
</Tabs>
</div>
);
}
function downloadCsv(rows: string[][], filename: string) {
const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).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 = filename; a.click();
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,218 @@
import { useCallback, useState } from "react";
import { Upload, FileSpreadsheet, CheckCircle2, AlertCircle, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useBulkCreate, useSendCredentials, useValidateCSV } from "@/hooks/queries/useEntityOnboarding";
import type { BulkCreateResult, CSVValidationReport, EntityStudent } from "@/types";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
export default function BulkStudentUpload() {
const [step, setStep] = useState<1 | 2 | 3>(1);
const [report, setReport] = useState<CSVValidationReport | null>(null);
const [created, setCreated] = useState<BulkCreateResult | null>(null);
const [dragOver, setDragOver] = useState(false);
const validateMut = useValidateCSV();
const bulkMut = useBulkCreate();
const sendMut = useSendCredentials();
const onFile = useCallback(
(file: File | null) => {
if (!file || !file.name.toLowerCase().endsWith(".csv")) {
toast.error("Please upload a CSV file.");
return;
}
validateMut.mutate(file, {
onSuccess: (data) => {
setReport(data);
setStep(2);
},
onError: (e) => toast.error(e instanceof Error ? e.message : "Validation failed"),
});
},
[validateMut],
);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files[0];
onFile(f ?? null);
};
const summary = report
? `${report.valid_count} valid, ${report.error_count} error${report.error_count === 1 ? "" : "s"}, ${report.warning_count} warning${report.warning_count === 1 ? "" : "s"}`
: "";
const confirmCreate = () => {
bulkMut.mutate(
{ validate_session_id: report?.validate_session_id },
{
onSuccess: (data) => {
setCreated(data);
setStep(3);
toast.success("Accounts created.");
},
onError: (e) => toast.error(e instanceof Error ? e.message : "Bulk create failed"),
},
);
};
const sendEmails = () => {
const ids = created?.accounts.map((a) => a.id) ?? [];
if (!ids.length) return;
sendMut.mutate(ids, {
onSuccess: () => toast.success("Credential emails queued."),
onError: (e) => toast.error(e instanceof Error ? e.message : "Send failed"),
});
};
const statusIcon = (s: string) => {
if (s === "valid") return <CheckCircle2 className="h-4 w-4 text-green-600" />;
if (s === "error") return <AlertCircle className="h-4 w-4 text-destructive" />;
return <AlertTriangle className="h-4 w-4 text-amber-500" />;
};
return (
<div className="space-y-6 p-6 max-w-5xl mx-auto">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Bulk student upload</h1>
<p className="text-muted-foreground text-sm mt-1">CSV import for your entity</p>
</div>
{step === 1 && (
<Card>
<CardHeader>
<CardTitle>Step 1 Upload CSV</CardTitle>
<CardDescription>Drag and drop a file or click to browse. CSV only.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div
className={cn(
"border-2 border-dashed rounded-lg p-12 text-center transition-colors",
dragOver ? "border-primary bg-primary/5" : "border-muted-foreground/25",
)}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
>
<Upload className="h-10 w-10 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground mb-4">Drop your CSV here</p>
<label className="inline-flex cursor-pointer">
<input
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={(e) => onFile(e.target.files?.[0] ?? null)}
/>
<Button type="button" variant="secondary">
Browse files
</Button>
</label>
</div>
<div className="flex items-center gap-2 text-sm">
<FileSpreadsheet className="h-4 w-4" />
<a href="/templates/students.csv" className="text-primary underline-offset-4 hover:underline" download>
Download Template
</a>
</div>
</CardContent>
</Card>
)}
{step === 2 && report && (
<Card>
<CardHeader>
<CardTitle>Step 2 Validation report</CardTitle>
<CardDescription>Review each row before creating accounts.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border bg-muted/40 px-4 py-3 text-sm font-medium">{summary}</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Row#</TableHead>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Status</TableHead>
<TableHead>Issue</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{report.rows.map((r) => (
<TableRow key={r.row_number}>
<TableCell>{r.row_number}</TableCell>
<TableCell>{r.student_name}</TableCell>
<TableCell>{r.email}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{statusIcon(r.status)}
<span className="capitalize">{r.status}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground">{r.issue ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex flex-wrap gap-3">
<Button
variant="outline"
onClick={() => {
setStep(1);
setReport(null);
}}
>
Fix & Re-upload
</Button>
<Button onClick={confirmCreate} disabled={bulkMut.isPending}>
Confirm & Create Accounts
</Button>
</div>
</CardContent>
</Card>
)}
{step === 3 && created && (
<Card>
<CardHeader>
<CardTitle>Step 3 Accounts created</CardTitle>
<CardDescription>{created.created_count} new accounts are ready.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{created.accounts.map((a: EntityStudent) => (
<TableRow key={a.id}>
<TableCell>{a.name}</TableCell>
<TableCell>{a.email}</TableCell>
<TableCell>{a.account_status}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Button onClick={sendEmails} disabled={sendMut.isPending}>
Send Credential Emails
</Button>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,367 @@
import { useEffect } from "react";
import { Link, useParams, useSearchParams } from "react-router-dom";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useCourseDetail, useUpdateCourse, useCreateCourse } from "@/hooks/queries/useCourseGeneration";
import type { ProgressionModel } from "@/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Slider } from "@/components/ui/slider";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
const SKILL_OPTIONS = ["Listening", "Reading", "Writing", "Speaking"];
const schema = z.object({
title: z.string().min(1),
exam_type: z.string().optional(),
target_band: z.coerce.number().min(0).max(9).optional(),
target_level: z.string().optional(),
duration_weeks: z.coerce.number().min(1),
study_hours_week: z.coerce.number().min(1),
skills: z.array(z.string()).min(1),
skill_weightings: z.record(z.string(), z.number().min(0).max(100)),
progression_model: z.enum(["linear", "parallel", "adaptive"]),
});
type FormValues = z.infer<typeof schema>;
export default function CourseConfig() {
const { courseId: courseIdParam } = useParams();
const courseId = Number(courseIdParam);
const [searchParams] = useSearchParams();
const individual = searchParams.get("path") === "individual";
const { data: course, isLoading } = useCourseDetail(courseId);
const updateCourse = useUpdateCourse();
const createCourse = useCreateCourse();
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
title: "",
exam_type: "IELTS",
duration_weeks: 8,
study_hours_week: 5,
skills: ["Listening", "Reading"],
skill_weightings: { Listening: 25, Reading: 25, Writing: 25, Speaking: 25 },
progression_model: "adaptive",
},
});
useEffect(() => {
if (!course) return;
const w: Record<string, number> = {};
SKILL_OPTIONS.forEach((s) => {
w[s] = course.skill_weightings[s] ?? 25;
});
form.reset({
title: course.title,
exam_type: course.exam_type ?? "IELTS",
target_band: course.target_band,
target_level: course.target_level,
duration_weeks: course.duration_weeks,
study_hours_week: course.study_hours_week,
skills: course.skills?.length ? course.skills : SKILL_OPTIONS,
skill_weightings: w,
progression_model: course.progression_model,
});
}, [course, form]);
const onSubmit = (values: FormValues) => {
if (!Number.isFinite(courseId) || courseId <= 0) return;
updateCourse.mutate({
courseId,
payload: {
title: values.title,
exam_type: values.exam_type,
target_band: values.target_band,
target_level: values.target_level,
duration_weeks: values.duration_weeks,
study_hours_week: values.study_hours_week,
skills: values.skills,
skill_weightings: values.skill_weightings,
progression_model: values.progression_model as ProgressionModel,
},
});
};
if (isLoading) {
return (
<div className="p-6">
<Skeleton className="h-10 w-64 mb-4" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (individual) {
return (
<div className="mx-auto max-w-3xl space-y-6 p-6">
<Card>
<CardHeader>
<CardTitle>{course?.title ?? "Course Preview"}</CardTitle>
<CardDescription>Auto-generated course preview</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ScrollArea className="h-64 rounded-md border p-4 text-sm">
<p className="mb-2 font-medium">Modules</p>
<ul className="list-inside list-disc space-y-1">
{(course?.modules ?? []).map((m) => (
<li key={m.id}>
{m.title} · {m.estimated_hours}h · {m.skill}
</li>
))}
</ul>
</ScrollArea>
<div className="flex flex-wrap gap-2">
<Button type="button" asChild>
<Link to={`/student/course/${course?.id ?? courseId}`}>Start Course</Link>
</Button>
<Button type="button" variant="outline" asChild>
<Link to={`/admin/course/configure/${course?.id ?? courseId}?path=entity`}>Customize</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="mx-auto max-w-3xl space-y-6 p-6">
<div>
<h1 className="text-2xl font-bold">Configure course</h1>
<p className="text-muted-foreground">Course #{courseId}</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Basics</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Course Title</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="exam_type"
render={({ field }) => (
<FormItem>
<FormLabel>Exam Type</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="target_band"
render={({ field }) => (
<FormItem>
<FormLabel>Target Band</FormLabel>
<FormControl>
<Input type="number" step="0.5" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="target_level"
render={({ field }) => (
<FormItem>
<FormLabel>Target Level</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="duration_weeks"
render={({ field }) => (
<FormItem>
<FormLabel>Duration (weeks)</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="study_hours_week"
render={({ field }) => (
<FormItem>
<FormLabel>Study Hours / Week</FormLabel>
<FormControl>
<Slider
min={1}
max={40}
step={1}
value={[field.value]}
onValueChange={(v) => field.onChange(v[0])}
/>
</FormControl>
<p className="text-sm text-muted-foreground">{field.value} h</p>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Skills</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="skills"
render={() => (
<FormItem>
<div className="space-y-2">
{SKILL_OPTIONS.map((s) => (
<FormField
key={s}
control={form.control}
name="skills"
render={({ field }) => (
<FormItem className="flex items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value.includes(s)}
onCheckedChange={(ck) => {
const on = ck === true;
if (on) field.onChange([...field.value, s]);
else field.onChange(field.value.filter((x) => x !== s));
}}
/>
</FormControl>
<FormLabel className="font-normal">{s}</FormLabel>
</FormItem>
)}
/>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
{SKILL_OPTIONS.map((s) => (
<FormField
key={s}
control={form.control}
name={`skill_weightings.${s}`}
render={({ field }) => (
<FormItem>
<FormLabel>{s} weight</FormLabel>
<FormControl>
<Slider
min={0}
max={100}
step={5}
value={[field.value ?? 0]}
onValueChange={(v) => field.onChange(v[0])}
/>
</FormControl>
<p className="text-sm text-muted-foreground">{field.value ?? 0}%</p>
<FormMessage />
</FormItem>
)}
/>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Progression</CardTitle>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="progression_model"
render={({ field }) => (
<FormItem className="space-y-3">
<FormControl>
<RadioGroup value={field.value} onValueChange={field.onChange} className="grid gap-3 md:grid-cols-3">
{(["linear", "parallel", "adaptive"] as const).map((m) => (
<div key={m} className="flex items-start space-x-3 rounded-lg border p-4">
<RadioGroupItem value={m} id={`prog-${m}`} />
<div className="space-y-1 leading-none">
<Label htmlFor={`prog-${m}`} className="font-medium capitalize">
{m}
</Label>
<p className="text-xs text-muted-foreground">
{m === "linear" && "Sequential modules"}
{m === "parallel" && "Multiple tracks"}
{m === "adaptive" && "Adjusts to performance"}
</p>
</div>
</div>
))}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={updateCourse.isPending}>
Save changes
</Button>
<Button
type="button"
variant="secondary"
onClick={() =>
createCourse.mutate({
title: `${form.getValues("title")} (copy)`,
duration_weeks: form.getValues("duration_weeks"),
study_hours_week: form.getValues("study_hours_week"),
skills: form.getValues("skills"),
skill_weightings: form.getValues("skill_weightings"),
progression_model: form.getValues("progression_model"),
})
}
disabled={createCourse.isPending}
>
Duplicate as new
</Button>
</div>
</form>
</Form>
</div>
);
}

View File

@@ -0,0 +1,206 @@
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
useCredentialStatuses,
useResendAllPendingCredentials,
useResendCredential,
} from "@/hooks/queries/useEntityOnboarding";
import type { CredentialStatus } from "@/types";
import { toast } from "sonner";
export default function CredentialDashboard() {
const [accountFilter, setAccountFilter] = useState<string>("all");
const [emailSentFilter, setEmailSentFilter] = useState<string>("all");
const [placementFilter, setPlacementFilter] = useState<string>("all");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [selected, setSelected] = useState<Set<number>>(new Set());
const filters = useMemo(
() => ({
account_status: accountFilter === "all" ? undefined : accountFilter,
email_sent:
emailSentFilter === "all" ? undefined : emailSentFilter === "yes" ? true : emailSentFilter === "no" ? false : undefined,
placement_status: placementFilter === "all" ? undefined : placementFilter,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
}),
[accountFilter, emailSentFilter, placementFilter, dateFrom, dateTo],
);
const { data, isLoading } = useCredentialStatuses(filters);
const resendOne = useResendCredential();
const resendAll = useResendAllPendingCredentials();
const rows: CredentialStatus[] = data?.data ?? [];
const toggle = (id: number) => {
setSelected((prev) => {
const n = new Set(prev);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
});
};
const toggleAll = (checked: boolean) => {
if (!checked) setSelected(new Set());
else setSelected(new Set(rows.map((r) => r.student_id)));
};
return (
<div className="space-y-6 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Credential dashboard</h1>
<p className="text-muted-foreground text-sm mt-1">Track onboarding emails and placement</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Filters</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label>Account status</Label>
<Select value={accountFilter} onValueChange={setAccountFilter}>
<SelectTrigger>
<SelectValue placeholder="All" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="pending_first_login">Pending first login</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="password_reset_required">Password reset required</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Email sent</Label>
<Select value={emailSentFilter} onValueChange={setEmailSentFilter}>
<SelectTrigger>
<SelectValue placeholder="All" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="yes">Yes</SelectItem>
<SelectItem value="no">No</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Placement status</Label>
<Select value={placementFilter} onValueChange={setPlacementFilter}>
<SelectTrigger>
<SelectValue placeholder="All" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="not_started">Not started</SelectItem>
<SelectItem value="in_progress">In progress</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="bypassed">Bypassed</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2 md:col-span-2 lg:col-span-1">
<Label>Date range</Label>
<div className="flex gap-2">
<Input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} />
<Input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} />
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button
variant="secondary"
onClick={() =>
resendAll.mutate(undefined, {
onSuccess: () => toast.success("Bulk resend started."),
onError: (e) => toast.error(e instanceof Error ? e.message : "Failed"),
})
}
disabled={resendAll.isPending}
>
Resend All Pending
</Button>
</div>
<Card>
<CardContent className="pt-6">
{isLoading ? (
<div className="flex justify-center py-12 text-muted-foreground">Loading</div>
) : (
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={rows.length > 0 && selected.size === rows.length}
onCheckedChange={(c) => toggleAll(!!c)}
/>
</TableHead>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Account Status</TableHead>
<TableHead>Email Sent</TableHead>
<TableHead>First Login</TableHead>
<TableHead>Placement Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={r.student_id}>
<TableCell>
<Checkbox
checked={selected.has(r.student_id)}
onCheckedChange={() => toggle(r.student_id)}
/>
</TableCell>
<TableCell className="font-medium">{r.name}</TableCell>
<TableCell>{r.email}</TableCell>
<TableCell>{r.account_status}</TableCell>
<TableCell>{r.email_sent ? "Yes" : "No"}</TableCell>
<TableCell>{r.first_login_at ? new Date(r.first_login_at).toLocaleString() : "—"}</TableCell>
<TableCell>{r.placement_status}</TableCell>
<TableCell className="text-right">
<Button
size="sm"
variant="outline"
onClick={() =>
resendOne.mutate(r.student_id, {
onSuccess: () => toast.success("Email resent."),
onError: (e) => toast.error(e instanceof Error ? e.message : "Failed"),
})
}
disabled={resendOne.isPending}
>
Resend Email
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,597 @@
import { useState, useId } from "react";
import { useForm, useFieldArray, useWatch, type Control, type UseFormReturn } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useNavigate } from "react-router-dom";
import { ChevronDown, ChevronUp, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Slider } from "@/components/ui/slider";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useCreateCustomExam, useSaveAsTemplate } from "@/hooks/queries/useExamTemplates";
import { useSubjects } from "@/hooks/queries/useAdaptive";
import type { CustomExamCreateRequest, CustomScoringMethod } from "@/types";
import { toast } from "sonner";
const sectionSchema = z.object({
title: z.string().min(1),
skill: z.string().optional(),
question_count: z.coerce.number().min(1),
time_limit_min: z.coerce.number().optional(),
scoring_method: z.enum(["auto", "rubric", "mixed"]),
sequence: z.number(),
question_ids: z.array(z.number()),
});
const formSchema = z.object({
title: z.string().min(2),
subject_id: z.coerce.number().min(1),
description: z.string().optional(),
total_time_min: z.coerce.number().min(1),
pass_threshold: z.coerce.number().min(0).max(100),
results_release_mode: z.enum(["auto", "manual_approval"]),
randomize: z.boolean(),
sections: z.array(sectionSchema).min(1),
save_as_template: z.boolean(),
template_name: z.string().optional(),
});
type FormValues = z.infer<typeof formSchema>;
const SKILL_OPTIONS = ["Reading", "Listening", "Writing", "Speaking", "Mixed"];
export default function CustomExamCreate() {
const navigate = useNavigate();
const [step, setStep] = useState(1);
const subjectsQ = useSubjects();
const createExam = useCreateCustomExam();
const saveTemplate = useSaveAsTemplate();
const subjects = subjectsQ.data ?? [];
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
subject_id: 0,
description: "",
total_time_min: 60,
pass_threshold: 50,
results_release_mode: "auto",
randomize: false,
sections: [
{
title: "Section A",
skill: "Reading",
question_count: 10,
time_limit_min: 20,
scoring_method: "auto",
sequence: 0,
question_ids: [],
},
],
save_as_template: false,
template_name: "",
},
});
const { fields, append, remove, move } = useFieldArray({ control: form.control, name: "sections" });
const validateStep = async (s: number) => {
if (s === 1) {
return form.trigger(["title", "subject_id", "total_time_min", "pass_threshold", "results_release_mode", "randomize"]);
}
if (s === 2) {
return form.trigger("sections");
}
if (s === 3) {
return form.trigger("sections");
}
return true;
};
const next = async () => {
const ok = await validateStep(step);
if (ok) setStep((x) => Math.min(4, x + 1));
};
const back = () => setStep((x) => Math.max(1, x - 1));
const buildPayload = (): CustomExamCreateRequest => {
const v = form.getValues();
return {
title: v.title,
subject_id: v.subject_id,
description: v.description,
total_time_min: v.total_time_min,
pass_threshold: v.pass_threshold,
results_release_mode: v.results_release_mode,
randomize: v.randomize,
sections: v.sections.map((sec, i) => ({
title: sec.title,
skill: sec.skill,
question_count: sec.question_count,
time_limit_min: sec.time_limit_min,
scoring_method: sec.scoring_method as CustomScoringMethod,
sequence: i,
question_ids: sec.question_ids,
})),
};
};
const onSaveDraft = async () => {
try {
const payload = buildPayload();
await createExam.mutateAsync(payload);
toast.success("Draft saved.");
} catch {
toast.error("Could not save.");
}
};
const onPublish = async () => {
try {
const payload = buildPayload();
const res = await createExam.mutateAsync(payload);
const raw = res as { exam_id?: number; data?: { exam_id?: number } };
const id = raw.exam_id ?? raw.data?.exam_id;
if (form.getValues("save_as_template")) {
const name = form.getValues("template_name")?.trim() || form.getValues("title");
await saveTemplate.mutateAsync({
name,
structure: { sections: buildPayload().sections },
editable: true,
type: "custom",
});
}
toast.success("Exam published.");
if (id != null) navigate(`/admin/exam`);
} catch {
toast.error("Publish failed.");
}
};
return (
<div className="p-6 max-w-3xl mx-auto space-y-8">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Custom exam</h1>
<p className="text-muted-foreground mt-1">Build sections, attach questions, then review.</p>
</div>
<div className="flex gap-2">
{[1, 2, 3, 4].map((n) => (
<button
key={n}
type="button"
onClick={() => setStep(n)}
className={`flex-1 rounded-md border px-2 py-2 text-sm ${
step === n ? "border-primary bg-primary/5 font-medium" : "border-muted text-muted-foreground"
}`}
>
{n === 1 && "Properties"}
{n === 2 && "Sections"}
{n === 3 && "Questions"}
{n === 4 && "Review"}
</button>
))}
</div>
<Form {...form}>
<form className="space-y-6">
{step === 1 ? (
<Card>
<CardHeader>
<CardTitle>Properties</CardTitle>
<CardDescription>Basics and scoring policy.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subject_id"
render={({ field }) => (
<FormItem>
<FormLabel>Subject</FormLabel>
<Select
onValueChange={(v) => field.onChange(Number(v))}
value={field.value ? String(field.value) : ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select subject" />
</SelectTrigger>
</FormControl>
<SelectContent>
{subjects.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea rows={3} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="total_time_min"
render={({ field }) => (
<FormItem>
<FormLabel>Total time (minutes)</FormLabel>
<FormControl>
<Input type="number" min={1} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="pass_threshold"
render={({ field }) => (
<FormItem>
<FormLabel>Pass threshold ({field.value}%)</FormLabel>
<FormControl>
<Slider
min={0}
max={100}
step={1}
value={[field.value]}
onValueChange={(v) => field.onChange(v[0] ?? 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="results_release_mode"
render={({ field }) => (
<FormItem>
<FormLabel>Score release</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="auto">Automatic</SelectItem>
<SelectItem value="manual_approval">Manual approval</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="randomize"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div>
<FormLabel>Randomize</FormLabel>
<FormDescription>Shuffle questions where allowed.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
</CardContent>
</Card>
) : null}
{step === 2 ? (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div>
<CardTitle>Section builder</CardTitle>
<CardDescription>Add, remove, and order sections.</CardDescription>
</div>
<Button
type="button"
size="sm"
onClick={() =>
append({
title: `Section ${fields.length + 1}`,
skill: "Reading",
question_count: 5,
time_limit_min: 15,
scoring_method: "auto",
sequence: fields.length,
question_ids: [],
})
}
>
<Plus className="h-4 w-4 mr-1" />
Add section
</Button>
</CardHeader>
<CardContent className="space-y-4">
{fields.map((f, index) => (
<div key={f.id} className="rounded-lg border p-4 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-medium">Section {index + 1}</span>
<div className="flex gap-1">
<Button type="button" size="icon" variant="outline" disabled={index === 0} onClick={() => move(index, index - 1)}>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
type="button"
size="icon"
variant="outline"
disabled={index === fields.length - 1}
onClick={() => move(index, index + 1)}
>
<ChevronDown className="h-4 w-4" />
</Button>
<Button type="button" size="icon" variant="ghost" onClick={() => remove(index)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
<FormField
control={form.control}
name={`sections.${index}.title`}
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`sections.${index}.skill`}
render={({ field }) => (
<FormItem>
<FormLabel>Skill</FormLabel>
<Select onValueChange={field.onChange} value={field.value ?? ""}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{SKILL_OPTIONS.map((sk) => (
<SelectItem key={sk} value={sk}>
{sk}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-3 sm:grid-cols-2">
<FormField
control={form.control}
name={`sections.${index}.question_count`}
render={({ field }) => (
<FormItem>
<FormLabel>Question count</FormLabel>
<FormControl>
<Input type="number" min={1} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`sections.${index}.time_limit_min`}
render={({ field }) => (
<FormItem>
<FormLabel>Time limit (min)</FormLabel>
<FormControl>
<Input type="number" min={1} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name={`sections.${index}.scoring_method`}
render={({ field }) => (
<FormItem>
<FormLabel>Scoring method</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="rubric">Rubric</SelectItem>
<SelectItem value="mixed">Mixed</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
))}
</CardContent>
</Card>
) : null}
{step === 3 ? (
<Card>
<CardHeader>
<CardTitle>Question assignment</CardTitle>
<CardDescription>Attach pool items or create new ones per section.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{fields.map((f, index) => (
<div key={f.id} className="rounded-lg border p-4 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-medium">{form.watch(`sections.${index}.title`)}</span>
<SectionQuestionCount control={form.control} index={index} />
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" size="sm" onClick={() => navigate("/admin/resources")}>
Browse content pool
</Button>
</div>
<NewQuestionInline sectionIndex={index} form={form} />
</div>
))}
</CardContent>
</Card>
) : null}
{step === 4 ? (
<Card>
<CardHeader>
<CardTitle>Review & publish</CardTitle>
<CardDescription>Confirm details before students see this exam.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ScrollArea className="h-48 rounded-md border p-3 text-sm">
<pre className="whitespace-pre-wrap font-sans">{JSON.stringify(buildPayload(), null, 2)}</pre>
</ScrollArea>
<FormField
control={form.control}
name="save_as_template"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Save as reusable template</FormLabel>
<FormDescription>Stores section layout for future exams.</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="template_name"
render={({ field }) => (
<FormItem>
<FormLabel>Template name</FormLabel>
<FormControl>
<Input placeholder="Optional; defaults to exam title" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
) : null}
<Separator />
<div className="flex flex-wrap justify-between gap-3">
<Button type="button" variant="outline" onClick={back} disabled={step === 1}>
Back
</Button>
<div className="flex flex-wrap gap-2">
{step < 4 ? (
<Button type="button" onClick={next}>
Next
</Button>
) : (
<>
<Button type="button" variant="secondary" onClick={onSaveDraft}>
Save draft
</Button>
<Button type="button" onClick={onPublish} disabled={createExam.isPending || saveTemplate.isPending}>
Publish
</Button>
</>
)}
</div>
</div>
</form>
</Form>
</div>
);
}
function SectionQuestionCount({ control, index }: { control: Control<FormValues>; index: number }) {
const ids = useWatch({ control, name: `sections.${index}.question_ids` });
const target = useWatch({ control, name: `sections.${index}.question_count` });
const n = ids?.length ?? 0;
const t = typeof target === "number" ? target : Number(target) || 0;
return (
<span className="text-sm text-muted-foreground">
{n}/{t} selected
</span>
);
}
function NewQuestionInline({ sectionIndex, form }: { sectionIndex: number; form: UseFormReturn<FormValues> }) {
const [stem, setStem] = useState("");
const baseId = useId();
return (
<div className="space-y-2 rounded-md bg-muted/40 p-3">
<Label htmlFor={`${baseId}-stem`}>Create new question</Label>
<Input
id={`${baseId}-stem`}
value={stem}
onChange={(e) => setStem(e.target.value)}
placeholder="Stem or reference label"
/>
<Button
type="button"
size="sm"
onClick={() => {
const cur = form.getValues(`sections.${sectionIndex}.question_ids`) ?? [];
const nextId = Math.floor(Math.random() * 1_000_000_000);
form.setValue(`sections.${sectionIndex}.question_ids`, [...cur, nextId]);
setStem("");
}}
>
Add to section
</Button>
</div>
);
}

View File

@@ -0,0 +1,155 @@
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
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, Pencil, Trash2, Loader2 } from "lucide-react";
import {
useDepartments,
useCreateDepartment,
useUpdateDepartment,
useDeleteDepartment,
} from "@/hooks/queries/useAcademic";
import { useToast } from "@/hooks/use-toast";
import type { Department, DepartmentCreateRequest } from "@/types/academic";
export default function DepartmentManager() {
const { toast } = useToast();
const { data: deptsData, isLoading } = useDepartments();
const depts = deptsData?.items ?? [];
const createDept = useCreateDepartment();
const updateDept = useUpdateDepartment();
const deleteDept = useDeleteDepartment();
const [showDialog, setShowDialog] = useState(false);
const [editing, setEditing] = useState<Department | null>(null);
const [form, setForm] = useState<DepartmentCreateRequest>({ name: "", code: "" });
const openCreate = () => {
setEditing(null);
setForm({ name: "", code: "" });
setShowDialog(true);
};
const openEdit = (dept: Department) => {
setEditing(dept);
setForm({ name: dept.name, code: dept.code, parent_id: dept.parent_id });
setShowDialog(true);
};
const handleSave = () => {
if (editing) {
updateDept.mutate({ id: editing.id, data: form }, {
onSuccess: () => { toast({ title: "Department updated" }); setShowDialog(false); },
onError: () => toast({ title: "Error", description: "Failed to update", variant: "destructive" }),
});
} else {
createDept.mutate(form, {
onSuccess: () => { toast({ title: "Department created" }); setShowDialog(false); setForm({ name: "", code: "" }); },
onError: () => toast({ title: "Error", description: "Failed to create", variant: "destructive" }),
});
}
};
const handleDelete = (id: number) => {
if (!window.confirm("Delete this department?")) return;
deleteDept.mutate(id, {
onSuccess: () => toast({ title: "Department deleted" }),
onError: () => toast({ title: "Error", description: "Failed to delete", variant: "destructive" }),
});
};
const isSaving = createDept.isPending || updateDept.isPending;
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Departments</h1>
<p className="text-muted-foreground">Manage departments and their hierarchy.</p>
</div>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogTrigger asChild>
<Button onClick={openCreate}><Plus className="mr-2 h-4 w-4" /> Add Department</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>{editing ? "Edit Department" : "Create Department"}</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="e.g. Computer Science" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Code</Label>
<Input placeholder="e.g. CS" value={form.code} onChange={e => setForm({ ...form, code: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Parent Department</Label>
<Select value={form.parent_id?.toString() ?? "none"} onValueChange={v => setForm({ ...form, parent_id: v === "none" ? undefined : Number(v) })}>
<SelectTrigger><SelectValue placeholder="None" /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{depts.filter(d => d.id !== editing?.id).map(d => (
<SelectItem key={d.id} value={d.id.toString()}>{d.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button className="w-full" onClick={handleSave} disabled={isSaving || !form.name || !form.code}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{editing ? "Update" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Code</TableHead>
<TableHead>Parent</TableHead>
<TableHead>Courses</TableHead>
<TableHead>Faculty</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{depts.map((dept: Department) => (
<TableRow key={dept.id}>
<TableCell className="font-medium">{dept.name}</TableCell>
<TableCell>{dept.code}</TableCell>
<TableCell className="text-muted-foreground">{dept.parent_name ?? "—"}</TableCell>
<TableCell>{dept.course_count}</TableCell>
<TableCell>{dept.faculty_count}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => openEdit(dept)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDelete(dept.id)} disabled={deleteDept.isPending}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{depts.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">No departments found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,209 @@
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { Globe, BookOpen, GraduationCap, Languages, Plus, Lock, Sparkles } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { useInternationalTemplates, useCustomTemplates } from "@/hooks/queries/useExamTemplates";
import type { ExamTemplate } from "@/types";
function formatTime(min: number): string {
const h = Math.floor(min / 60);
const m = min % 60;
if (h <= 0) return `${m}m`;
return m ? `${h}h ${m}m` : `${h}h`;
}
function templateStats(t: ExamTemplate): { skillCount: number; totalQuestions: number; timeMin: number } {
const skills = t.structure?.skills ?? [];
const skillCount = t.skill_count ?? skills.length;
const totalQuestions =
t.total_questions ??
skills.reduce(
(acc, s) => acc + (s.parts ?? []).reduce((a, p) => a + (p.question_count ?? 0), 0),
0,
);
const timeMin = t.total_time_min ?? skills.reduce((acc, s) => acc + (s.total_time_min ?? 0), 0);
return { skillCount, totalQuestions, timeMin };
}
const iconForName = (name: string) => {
const n = name.toLowerCase();
if (n.includes("general")) return GraduationCap;
if (n.includes("academic")) return BookOpen;
if (n.includes("toefl")) return Languages;
return Globe;
};
export default function ExamTemplateSelection() {
const navigate = useNavigate();
const intlQ = useInternationalTemplates();
const customQ = useCustomTemplates();
const international = useMemo(() => {
const raw = intlQ.data;
if (Array.isArray(raw)) return raw;
if (raw && typeof raw === "object" && "data" in raw && Array.isArray((raw as { data: unknown }).data)) {
return (raw as { data: ExamTemplate[] }).data;
}
if (raw && typeof raw === "object" && "items" in raw && Array.isArray((raw as { items: unknown }).items)) {
return (raw as { items: ExamTemplate[] }).items;
}
return [] as ExamTemplate[];
}, [intlQ.data]);
const myTemplates = useMemo(() => {
const raw = customQ.data;
if (Array.isArray(raw)) return raw;
if (raw && typeof raw === "object" && "data" in raw && Array.isArray((raw as { data: unknown }).data)) {
return (raw as { data: ExamTemplate[] }).data;
}
if (raw && typeof raw === "object" && "items" in raw && Array.isArray((raw as { items: unknown }).items)) {
return (raw as { items: ExamTemplate[] }).items;
}
return [] as ExamTemplate[];
}, [customQ.data]);
return (
<div className="space-y-10 p-6 max-w-6xl mx-auto">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Create exam</h1>
<p className="text-muted-foreground mt-1">Choose an international template or build a custom exam.</p>
</div>
<section className="space-y-4">
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
<h2 className="text-lg font-medium">International templates</h2>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{intlQ.isLoading
? Array.from({ length: 3 }).map((_, i) => (
<Card key={i} className="overflow-hidden">
<CardHeader className="space-y-2">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-full" />
</CardHeader>
<CardContent>
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
))
: international.map((t) => {
const Icon = iconForName(t.name);
const { skillCount, totalQuestions, timeMin } = templateStats(t);
const locked = !t.editable;
return (
<Card
key={t.id}
className="cursor-pointer transition-shadow hover:shadow-md border-muted"
role="button"
tabIndex={0}
onClick={() => navigate("/admin/exam/ielts/create")}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate("/admin/exam/ielts/create");
}
}}
>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-primary/10 p-2 text-primary">
<Icon className="h-6 w-6" />
</div>
<div>
<CardTitle className="text-base leading-tight">{t.name}</CardTitle>
<CardDescription className="mt-1">Structured international assessment</CardDescription>
</div>
</div>
{locked ? (
<Badge variant="secondary" className="shrink-0 gap-1">
<Lock className="h-3 w-3" />
Locked
</Badge>
) : null}
</CardHeader>
<CardContent className="text-sm text-muted-foreground space-y-1">
<p>
<span className="font-medium text-foreground">{skillCount}</span> skills
</p>
<p>
<span className="font-medium text-foreground">{totalQuestions}</span> questions ·{" "}
<span className="font-medium text-foreground">{formatTime(timeMin)}</span> total
</p>
</CardContent>
</Card>
);
})}
</div>
{!intlQ.isLoading && international.length === 0 ? (
<p className="text-sm text-muted-foreground">No international templates are available yet.</p>
) : null}
</section>
<section className="space-y-4">
<h2 className="text-lg font-medium">Custom exam</h2>
<Card
className="cursor-pointer border-dashed border-2 transition-colors hover:border-primary/60 hover:bg-muted/30"
role="button"
tabIndex={0}
onClick={() => navigate("/admin/exam/custom/create")}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate("/admin/exam/custom/create");
}
}}
>
<CardHeader className="flex flex-row items-center gap-4">
<div className="rounded-full bg-primary/15 p-3 text-primary">
<Plus className="h-8 w-8" />
</div>
<div>
<CardTitle>Create custom exam</CardTitle>
<CardDescription>Define sections, scoring, and your own question mix.</CardDescription>
</div>
</CardHeader>
</Card>
</section>
<Separator />
<section className="space-y-4">
<h2 className="text-lg font-medium">My templates</h2>
{customQ.isLoading ? (
<div className="grid gap-3 sm:grid-cols-2">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
) : myTemplates.length === 0 ? (
<p className="text-sm text-muted-foreground">You have not saved any custom templates yet.</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{myTemplates.map((t) => {
const { skillCount, totalQuestions, timeMin } = templateStats(t);
return (
<Card key={t.id} className="border-muted">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t.name}</CardTitle>
<CardDescription>
{skillCount} skills · {totalQuestions} questions · {formatTime(timeMin)}
</CardDescription>
</CardHeader>
<CardContent className="flex justify-end pt-0">
<Button variant="outline" size="sm" type="button" onClick={() => navigate("/admin/exam/custom/create")}>
Use as starting point
</Button>
</CardContent>
</Card>
);
})}
</div>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,285 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Plus, Trash2, Pencil, Loader2, HelpCircle, FolderOpen, GripVertical } from "lucide-react";
import { useFaqCategories, useFaqItems, useCreateFaqCategory, useUpdateFaqCategory, useDeleteFaqCategory, useCreateFaqItem, useUpdateFaqItem, useDeleteFaqItem } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { FaqAudience, FaqCategory, FaqItem } from "@/types/faq";
export default function FaqManager() {
const { toast } = useToast();
const { data: categories = [], isLoading: lc } = useFaqCategories();
const [selectedCatId, setSelectedCatId] = useState<number | null>(null);
const { data: items = [], isLoading: li } = useFaqItems(selectedCatId ? { category_id: selectedCatId } : undefined);
const createCategory = useCreateFaqCategory();
const updateCategory = useUpdateFaqCategory();
const deleteCategory = useDeleteFaqCategory();
const createItem = useCreateFaqItem();
const updateItem = useUpdateFaqItem();
const deleteItem = useDeleteFaqItem();
const [showAddCat, setShowAddCat] = useState(false);
const [catName, setCatName] = useState("");
const [catAudience, setCatAudience] = useState<FaqAudience>("both");
const [showAddItem, setShowAddItem] = useState(false);
const [editingItem, setEditingItem] = useState<FaqItem | null>(null);
const [itemQuestion, setItemQuestion] = useState("");
const [itemAnswer, setItemAnswer] = useState("");
const [itemAudience, setItemAudience] = useState<FaqAudience>("both");
const [itemVideoUrl, setItemVideoUrl] = useState("");
const [editingCat, setEditingCat] = useState<FaqCategory | null>(null);
const [editCatName, setEditCatName] = useState("");
const resetItemForm = () => { setItemQuestion(""); setItemAnswer(""); setItemAudience("both"); setItemVideoUrl(""); setEditingItem(null); };
const handleCreateCategory = () => {
createCategory.mutate(
{ name: catName, audience: catAudience },
{
onSuccess: () => { toast({ title: "Category Created" }); setShowAddCat(false); setCatName(""); },
onError: () => toast({ title: "Error", description: "Failed to create category", variant: "destructive" }),
},
);
};
const handleUpdateCategory = () => {
if (!editingCat) return;
updateCategory.mutate(
{ id: editingCat.id, data: { name: editCatName } },
{
onSuccess: () => { toast({ title: "Category Updated" }); setEditingCat(null); },
onError: () => toast({ title: "Error", description: "Failed to update category", variant: "destructive" }),
},
);
};
const handleDeleteCategory = (id: number) => {
if (!window.confirm("Delete this FAQ category and all its items?")) return;
deleteCategory.mutate(id, {
onSuccess: () => { toast({ title: "Category Deleted" }); if (selectedCatId === id) setSelectedCatId(null); },
onError: () => toast({ title: "Error", description: "Failed to delete category", variant: "destructive" }),
});
};
const handleSaveItem = () => {
if (editingItem) {
updateItem.mutate(
{ id: editingItem.id, data: { question: itemQuestion, answer: itemAnswer, audience: itemAudience, video_url: itemVideoUrl || undefined } },
{
onSuccess: () => { toast({ title: "Item Updated" }); setShowAddItem(false); resetItemForm(); },
onError: () => toast({ title: "Error", description: "Failed to update item", variant: "destructive" }),
},
);
} else if (selectedCatId) {
createItem.mutate(
{ question: itemQuestion, answer: itemAnswer, category_id: selectedCatId, audience: itemAudience, video_url: itemVideoUrl || undefined },
{
onSuccess: () => { toast({ title: "Item Created" }); setShowAddItem(false); resetItemForm(); },
onError: () => toast({ title: "Error", description: "Failed to create item", variant: "destructive" }),
},
);
}
};
const handleDeleteItem = (id: number) => {
if (!window.confirm("Delete this FAQ item?")) return;
deleteItem.mutate(id, {
onSuccess: () => toast({ title: "Item Deleted" }),
onError: () => toast({ title: "Error", description: "Failed to delete item", variant: "destructive" }),
});
};
const openEditItem = (item: FaqItem) => {
setEditingItem(item);
setItemQuestion(item.question);
setItemAnswer(item.answer);
setItemAudience(item.audience);
setItemVideoUrl(item.video_url ?? "");
setShowAddItem(true);
};
if (lc) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">FAQ Manager</h1>
<p className="text-muted-foreground">Manage FAQ categories and items.</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground">Categories</h3>
<Dialog open={showAddCat} onOpenChange={setShowAddCat}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm"><Plus className="h-4 w-4" /></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>New Category</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="Category name" value={catName} onChange={e => setCatName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Audience</Label>
<Select value={catAudience} onValueChange={v => setCatAudience(v as FaqAudience)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="both">Both</SelectItem>
<SelectItem value="student">Student</SelectItem>
<SelectItem value="entity">Entity</SelectItem>
</SelectContent>
</Select>
</div>
<Button className="w-full" onClick={handleCreateCategory} disabled={createCategory.isPending || !catName}>
{createCategory.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{categories.map(cat => (
<div key={cat.id} className="group relative">
<button
onClick={() => setSelectedCatId(cat.id)}
className={`w-full text-left p-3 rounded-lg border transition-colors ${selectedCatId === cat.id ? "bg-primary/10 border-primary" : "hover:bg-accent"}`}
>
<div className="flex items-center gap-2">
<FolderOpen className="h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{cat.name}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{cat.item_count} items</span>
<Badge variant="outline" className="text-xs">{cat.audience}</Badge>
</div>
</div>
</div>
</button>
<div className="absolute top-2 right-2 hidden group-hover:flex gap-1">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => { setEditingCat(cat); setEditCatName(cat.name); }}>
<Pencil className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeleteCategory(cat.id)}>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
</div>
))}
{categories.length === 0 && <p className="text-sm text-muted-foreground px-1">No categories yet.</p>}
</div>
<div className="md:col-span-2">
{!selectedCatId ? (
<Card><CardContent className="py-12 text-center text-muted-foreground">Select a category to manage its FAQ items.</CardContent></Card>
) : li ? (
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">FAQ Items</h3>
<Button size="sm" onClick={() => { resetItemForm(); setShowAddItem(true); }}>
<Plus className="mr-2 h-4 w-4" /> Add Item
</Button>
</div>
{items
.sort((a, b) => a.sequence - b.sequence)
.map(item => (
<Card key={item.id}>
<CardContent className="py-4">
<div className="flex items-start gap-3">
<GripVertical className="h-5 w-5 text-muted-foreground mt-0.5 cursor-grab shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">{item.question}</p>
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{item.answer}</p>
<div className="flex items-center gap-2 mt-2">
<Badge variant="outline" className="text-xs">{item.audience}</Badge>
{item.video_url && <Badge variant="secondary" className="text-xs">Video</Badge>}
</div>
</div>
<div className="flex gap-1 shrink-0">
<Button variant="ghost" size="icon" onClick={() => openEditItem(item)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDeleteItem(item.id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
{items.length === 0 && (
<Card><CardContent className="py-8 text-center text-muted-foreground">No items in this category.</CardContent></Card>
)}
</div>
)}
</div>
</div>
<Dialog open={!!editingCat} onOpenChange={open => { if (!open) setEditingCat(null); }}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Category</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input value={editCatName} onChange={e => setEditCatName(e.target.value)} />
</div>
<Button className="w-full" onClick={handleUpdateCategory} disabled={updateCategory.isPending || !editCatName}>
{updateCategory.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={showAddItem} onOpenChange={open => { setShowAddItem(open); if (!open) resetItemForm(); }}>
<DialogContent>
<DialogHeader><DialogTitle>{editingItem ? "Edit FAQ Item" : "New FAQ Item"}</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Question</Label>
<Input placeholder="FAQ question" value={itemQuestion} onChange={e => setItemQuestion(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Answer (Markdown)</Label>
<Textarea placeholder="Write the answer..." rows={4} value={itemAnswer} onChange={e => setItemAnswer(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Audience</Label>
<Select value={itemAudience} onValueChange={v => setItemAudience(v as FaqAudience)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="both">Both</SelectItem>
<SelectItem value="student">Student</SelectItem>
<SelectItem value="entity">Entity</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Video URL (optional)</Label>
<Input placeholder="https://..." value={itemVideoUrl} onChange={e => setItemVideoUrl(e.target.value)} />
</div>
<Button className="w-full" onClick={handleSaveItem} disabled={(createItem.isPending || updateItem.isPending) || !itemQuestion || !itemAnswer}>
{(createItem.isPending || updateItem.isPending) && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{editingItem ? "Save Changes" : "Create Item"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,227 @@
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import {
useGradingQueue,
useStudentResponse,
useGradingRubric,
useAIGradeSuggestion,
useSubmitGrade,
} from "@/hooks/queries/useGrading";
import type { GradingQueueItem } from "@/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Slider } from "@/components/ui/slider";
import { AlertTriangle } from "lucide-react";
const WRITING_CRITERIA = ["Task Achievement", "Coherence", "Lexical Resource", "Grammatical Range"];
const SPEAKING_CRITERIA = ["Fluency", "Lexical Resource", "Grammatical Range", "Pronunciation"];
export default function GradingQueue() {
const { examId: examIdParam } = useParams();
const examId = Number(examIdParam);
const { data: queue = [], isLoading } = useGradingQueue({ exam_id: examId });
const filtered = useMemo(() => {
if (!Number.isFinite(examId) || examId <= 0) return queue;
return queue.filter((row) => row.exam_id === examId);
}, [queue, examId]);
const [open, setOpen] = useState(false);
const [active, setActive] = useState<GradingQueueItem | null>(null);
const skill = active?.skill ?? "writing";
const attemptId = active?.id ?? 0;
const responseQ = useStudentResponse(attemptId, skill);
const rubricQ = useGradingRubric(attemptId, skill);
const aiSuggest = useAIGradeSuggestion();
const submitGrade = useSubmitGrade();
const [scores, setScores] = useState<Record<string, number>>({});
const criteriaNames = skill === "speaking" ? SPEAKING_CRITERIA : WRITING_CRITERIA;
const openRow = (row: GradingQueueItem) => {
setActive(row);
setScores({});
setOpen(true);
};
useEffect(() => {
if (!open || !rubricQ.data) return;
const names = skill === "speaking" ? SPEAKING_CRITERIA : WRITING_CRITERIA;
const list = rubricQ.data;
const next: Record<string, number> = {};
list.forEach((c) => {
next[c.name] = typeof c.score === "number" ? c.score : 0;
});
names.forEach((n) => {
if (next[n] === undefined) next[n] = 0;
});
setScores(next);
}, [open, rubricQ.data, skill]);
const handleAISuggest = () => {
if (!active) return;
aiSuggest.mutate(
{ attemptId: active.id, skill },
{
onSuccess: (data) => {
setScores((prev) => {
const next = { ...prev };
data.criteria_scores.forEach((c) => {
next[c.name] = c.score;
});
return next;
});
},
},
);
};
const handleSubmit = () => {
if (!active) return;
submitGrade.mutate(
{
attempt_id: active.id,
skill,
criteria_scores: criteriaNames.map((name) => ({ name, score: scores[name] ?? 0 })),
},
{
onSuccess: () => {
setOpen(false);
setActive(null);
},
},
);
};
return (
<div className="space-y-6 p-6">
<div>
<h1 className="text-2xl font-bold">Grading queue</h1>
<p className="text-muted-foreground">Exam {examIdParam}</p>
</div>
<Card>
<CardHeader>
<CardTitle>Pending submissions</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Student Name</TableHead>
<TableHead>Exam Title</TableHead>
<TableHead>Skill</TableHead>
<TableHead>Submitted At</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((row) => (
<TableRow key={`${row.id}-${row.skill}`}>
<TableCell className="font-medium">{row.student_name}</TableCell>
<TableCell>{row.exam_title}</TableCell>
<TableCell className="capitalize">{row.skill}</TableCell>
<TableCell>{new Date(row.submitted_at).toLocaleString()}</TableCell>
<TableCell>
<Badge variant={row.status === "pending" ? "secondary" : "default"}>{row.status}</Badge>
</TableCell>
<TableCell className="text-right">
<Button type="button" size="sm" onClick={() => openRow(row)}>
Grade Now
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent className="flex w-full flex-col sm:max-w-xl">
<SheetHeader>
<SheetTitle>Grade response</SheetTitle>
</SheetHeader>
<ScrollArea className="flex-1 pr-4">
<div className="space-y-6 py-4">
{responseQ.isLoading ? (
<Skeleton className="h-32 w-full" />
) : responseQ.data ? (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">{responseQ.data.prompt_text}</p>
{responseQ.data.type === "writing" && responseQ.data.text ? (
<div className="rounded-md border bg-muted/30 p-4 text-sm whitespace-pre-wrap">{responseQ.data.text}</div>
) : null}
{responseQ.data.type === "speaking" ? (
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
Audio response placeholder
</div>
) : null}
</div>
) : null}
<Separator />
<div className="space-y-4">
<h3 className="font-semibold">Rubric</h3>
{criteriaNames.map((name) => (
<div key={name} className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor={`score-${name}`}>{name}</Label>
<span className="text-sm text-muted-foreground">09</span>
</div>
<Slider
id={`score-${name}`}
min={0}
max={9}
step={1}
value={[scores[name] ?? 0]}
onValueChange={(v) => setScores((s) => ({ ...s, [name]: v[0] ?? 0 }))}
/>
<p className="text-sm font-medium">{scores[name] ?? 0}</p>
</div>
))}
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" onClick={handleAISuggest} disabled={aiSuggest.isPending || !active}>
AI Grade Suggestion
</Button>
<Button type="button" onClick={handleSubmit} disabled={submitGrade.isPending || !active}>
Submit Grade
</Button>
</div>
{aiSuggest.isError ? (
<p className="flex items-center gap-2 text-sm text-destructive">
<AlertTriangle className="h-4 w-4" />
Suggestion could not be loaded.
</p>
) : null}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
</div>
);
}

View File

@@ -0,0 +1,353 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { useIeltsContentPool, useIeltsAutoAssemble, useIeltsSkills } from "@/hooks/queries/useExamTemplates";
import { ieltsExamService } from "@/services/ielts-exam.service";
import type { ContentPoolFilters, ContentPoolItem, ExamDifficulty, IELTSSkill, IELTSSkillConfig } from "@/types";
import { toast } from "sonner";
const SKILLS: { value: IELTSSkill; label: string }[] = [
{ value: "listening", label: "Listening" },
{ value: "reading", label: "Reading" },
{ value: "writing", label: "Writing" },
{ value: "speaking", label: "Speaking" },
];
function difficultyLabel(d: ExamDifficulty): string {
return d.charAt(0).toUpperCase() + d.slice(1);
}
export default function IeltsContentPool() {
const { examId: examIdParam } = useParams<{ examId: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const examId = Number(examIdParam);
const [skill, setSkill] = useState<IELTSSkill>(() => (searchParams.get("skill") as IELTSSkill) || "listening");
const [part, setPart] = useState<number>(() => Number(searchParams.get("part")) || 1);
const [difficultyIdx, setDifficultyIdx] = useState(1);
const difficulties: ExamDifficulty[] = ["easy", "medium", "hard"];
const difficulty = difficulties[difficultyIdx] ?? "medium";
const [topicInput, setTopicInput] = useState("");
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<number[]>([]);
const [pendingHybrid, setPendingHybrid] = useState<ContentPoolItem[]>([]);
const assemblyMode = useMemo(() => {
if (!Number.isFinite(examId)) return "manual";
return sessionStorage.getItem(`ielts_assembly_${examId}`) ?? "manual";
}, [examId]);
const filters: ContentPoolFilters = useMemo(
() => ({
skill,
part,
difficulty,
topics: topicInput
.split(",")
.map((t) => t.trim())
.filter(Boolean),
search: search.trim() || undefined,
}),
[skill, part, difficulty, topicInput, search],
);
const poolQ = useIeltsContentPool(Number.isFinite(examId) ? examId : 0, filters);
const skillsQ = useIeltsSkills(Number.isFinite(examId) ? examId : 0);
const autoAssemble = useIeltsAutoAssemble();
const items = useMemo(() => {
const raw = poolQ.data;
if (Array.isArray(raw)) return raw;
if (raw && typeof raw === "object" && Array.isArray((raw as { data?: unknown }).data)) {
return (raw as { data: ContentPoolItem[] }).data;
}
return [] as ContentPoolItem[];
}, [poolQ.data]);
const skillsData = useMemo(() => {
const raw = skillsQ.data;
if (Array.isArray(raw)) return raw;
if (raw && typeof raw === "object" && Array.isArray((raw as { data?: unknown }).data)) {
return (raw as { data: IELTSSkillConfig[] }).data;
}
return [];
}, [skillsQ.data]);
const requiredTotal = useMemo(() => {
const cfg = skillsData.find((s) => s.skill === skill);
if (!cfg?.parts?.length) {
if (skill === "listening") return 40;
if (skill === "reading") return 40;
if (skill === "writing") return 2;
return 3;
}
return cfg.parts.reduce((a, p) => a + p.question_count, 0);
}, [skillsData, skill]);
useEffect(() => {
setSearchParams(
(prev) => {
const sp = new URLSearchParams(prev);
sp.set("skill", skill);
sp.set("part", String(part));
return sp;
},
{ replace: true },
);
}, [skill, part, setSearchParams]);
const didAutoRun = useRef(false);
useEffect(() => {
if (assemblyMode !== "auto" || !Number.isFinite(examId) || didAutoRun.current) return;
didAutoRun.current = true;
let cancelled = false;
(async () => {
try {
const res = await autoAssemble.mutateAsync(examId);
if (cancelled) return;
const flat = (Array.isArray(res) ? res : []).flatMap((r) => r.selected_items?.map((i) => i.id) ?? []);
setSelected(flat);
} catch {
toast.error("Auto-assembly could not run.");
}
})();
return () => {
cancelled = true;
};
}, [assemblyMode, autoAssemble, examId]);
useEffect(() => {
if (assemblyMode !== "hybrid" || !Number.isFinite(examId)) return;
let cancelled = false;
(async () => {
try {
const res = await ieltsExamService.suggest(examId);
if (cancelled) return;
const rows = Array.isArray(res) ? res : [];
const forSkill = rows.find((r) => r.skill === skill && r.part === part);
setPendingHybrid(forSkill?.selected_items ?? []);
} catch {
setPendingHybrid([]);
}
})();
return () => {
cancelled = true;
};
}, [assemblyMode, examId, part, skill]);
const toggleSelect = useCallback(
(id: number) => {
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
},
[],
);
const selectedItems = useMemo(() => {
const map = new Map(items.map((i) => [i.id, i]));
return selected.map((id) => map.get(id)).filter(Boolean) as ContentPoolItem[];
}, [items, selected]);
const poolDisabled = assemblyMode === "auto";
const saveContinue = async () => {
try {
await ieltsExamService.saveQuestions(examId, part, selected);
toast.success("Selections saved.");
navigate(`/admin/exam/ielts/${examId}/validate`);
} catch {
toast.error("Save failed.");
}
};
if (!Number.isFinite(examId) || examId <= 0) {
return <p className="p-6 text-destructive">Invalid exam.</p>;
}
return (
<div className="p-6 max-w-[1400px] mx-auto space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Content pool</h1>
<p className="text-muted-foreground mt-1">
Assembly: <span className="text-foreground font-medium capitalize">{assemblyMode}</span>
{assemblyMode === "auto" ? " — pool is read-only; system selections apply." : null}
{assemblyMode === "hybrid" ? " — review suggestions on the right." : null}
</p>
</div>
<div className="grid gap-4 lg:grid-cols-5">
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle>Browse pool</CardTitle>
<CardDescription>Filter and preview candidate items.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>Skill</Label>
<Select value={skill} onValueChange={(v) => setSkill(v as IELTSSkill)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SKILLS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Part</Label>
<Select value={String(part)} onValueChange={(v) => setPart(Number(v))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4].map((n) => (
<SelectItem key={n} value={String(n)}>
Part {n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Difficulty ({difficultyLabel(difficulty)})</Label>
<Slider min={0} max={2} step={1} value={[difficultyIdx]} onValueChange={(v) => setDifficultyIdx(v[0] ?? 1)} />
</div>
<div className="space-y-2">
<Label htmlFor="topics">Topic tags</Label>
<Input
id="topics"
placeholder="education, environment …"
value={topicInput}
onChange={(e) => setTopicInput(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="search">Search</Label>
<Input id="search" placeholder="Keyword in stem" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Separator />
{poolQ.isLoading ? (
<div className="space-y-2">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
) : (
<ScrollArea className="h-[min(520px,55vh)] pr-3">
<div className={`space-y-3 ${poolDisabled ? "opacity-50 pointer-events-none" : ""}`}>
{items.map((item) => (
<Card key={item.id} className="border-muted">
<CardHeader className="py-3 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{difficultyLabel(item.difficulty)}</Badge>
<span className="text-xs text-muted-foreground">Used {item.usage_count}×</span>
</div>
<CardTitle className="text-sm font-normal leading-snug">{item.preview_text}</CardTitle>
<div className="flex flex-wrap gap-1">
{item.topic_tags.map((t) => (
<Badge key={t} variant="secondary" className="text-xs">
{t}
</Badge>
))}
</div>
</CardHeader>
<CardContent className="pt-0 flex justify-end gap-2">
<Button
type="button"
size="sm"
variant={selected.includes(item.id) ? "secondary" : "default"}
disabled={poolDisabled}
onClick={() => toggleSelect(item.id)}
>
{selected.includes(item.id) ? "Remove" : "Add"}
</Button>
</CardContent>
</Card>
))}
{items.length === 0 ? <p className="text-sm text-muted-foreground">No items match these filters.</p> : null}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Selected questions</CardTitle>
<CardDescription>
{selected.length}/{requiredTotal} selected
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ScrollArea className="h-[min(400px,45vh)]">
<ul className="space-y-2 text-sm">
{selectedItems.map((i) => (
<li key={i.id} className="rounded-md border p-2 flex justify-between gap-2">
<span className="line-clamp-2">{i.preview_text}</span>
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => toggleSelect(i.id)}>
<X className="h-4 w-4" />
</Button>
</li>
))}
{selectedItems.length === 0 ? <li className="text-muted-foreground">Nothing selected yet.</li> : null}
</ul>
</ScrollArea>
{assemblyMode === "hybrid" && pendingHybrid.length > 0 ? (
<div className="space-y-2">
<p className="text-sm font-medium">Suggestions</p>
{pendingHybrid.map((item) => (
<div key={item.id} className="flex items-start justify-between gap-2 rounded-md border p-2 text-sm">
<span className="line-clamp-3">{item.preview_text}</span>
<div className="flex gap-1 shrink-0">
<Button
type="button"
size="icon"
variant="outline"
onClick={() => {
if (!selected.includes(item.id)) setSelected((s) => [...s, item.id]);
setPendingHybrid((p) => p.filter((x) => x.id !== item.id));
}}
>
<Check className="h-4 w-4" />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
onClick={() => setPendingHybrid((p) => p.filter((x) => x.id !== item.id))}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
) : null}
<Button className="w-full" type="button" onClick={saveContinue}>
Save & continue
</Button>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,426 @@
import { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { format } from "date-fns";
import { useNavigate } from "react-router-dom";
import { Info } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
import { useCreateIeltsExam } from "@/hooks/queries/useExamTemplates";
import type { IELTSSkill, IELTSExamType, AssemblyMode, ExamDifficulty } from "@/types";
const skillOptions: { id: IELTSSkill; label: string }[] = [
{ id: "listening", label: "Listening" },
{ id: "reading", label: "Reading" },
{ id: "writing", label: "Writing" },
{ id: "speaking", label: "Speaking" },
];
const formSchema = z.object({
exam_type: z.enum(["academic", "general_training"]),
skills: z.array(z.enum(["listening", "reading", "writing", "speaking"])).min(1),
title: z.string().min(2, "Title is required"),
target_band: z.number().min(4).max(9),
difficulty: z.enum(["easy", "medium", "hard"]),
randomize: z.boolean(),
assembly_mode: z.enum(["auto", "manual", "hybrid"]),
results_release_mode: z.enum(["auto", "manual_approval"]),
});
type FormValues = z.infer<typeof formSchema>;
function bandSteps(): number[] {
const out: number[] = [];
for (let v = 4; v <= 9; v += 0.5) out.push(Math.round(v * 10) / 10);
return out;
}
const BAND_STEPS = bandSteps();
function nearestBand(x: number): number {
let best = BAND_STEPS[0];
let d = Math.abs(x - best);
for (const b of BAND_STEPS) {
const nd = Math.abs(x - b);
if (nd < d) {
d = nd;
best = b;
}
}
return best;
}
export default function IeltsExamCreate() {
const navigate = useNavigate();
const createExam = useCreateIeltsExam();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
exam_type: "academic",
skills: ["listening", "reading", "writing", "speaking"],
title: "",
target_band: 7,
difficulty: "medium",
randomize: true,
assembly_mode: "hybrid",
results_release_mode: "auto",
},
});
const examType = form.watch("exam_type");
const targetBand = form.watch("target_band");
const suggestedTitle = useMemo(() => {
const label = examType === "academic" ? "IELTS Academic" : "IELTS General Training";
const band = targetBand.toFixed(1);
const dateStr = format(new Date(), "yyyy-MM-dd");
return `${label} — Band ${band}${dateStr}`;
}, [examType, targetBand]);
useEffect(() => {
const cur = form.getValues("title");
if (!cur.trim()) {
form.setValue("title", suggestedTitle);
}
}, [form, suggestedTitle]);
const onSuggestTitle = () => {
form.setValue("title", suggestedTitle);
};
const onSubmit = async (values: FormValues) => {
const payload = {
exam_type: values.exam_type as IELTSExamType,
skills: values.skills as IELTSSkill[],
title: values.title,
target_band: values.target_band,
difficulty: values.difficulty as ExamDifficulty,
randomize: values.randomize,
assembly_mode: values.assembly_mode as AssemblyMode,
results_release_mode: values.results_release_mode,
};
const raw = await createExam.mutateAsync(payload);
const res = raw as { exam_id?: number; data?: { exam_id?: number } };
const examId = res.exam_id ?? res.data?.exam_id;
if (examId != null) {
sessionStorage.setItem(`ielts_assembly_${examId}`, values.assembly_mode);
navigate(`/admin/exam/ielts/${examId}/skills`);
}
};
return (
<div className="p-6 max-w-4xl mx-auto space-y-8">
<div>
<h1 className="text-2xl font-semibold tracking-tight">IELTS exam Phase 1</h1>
<p className="text-muted-foreground mt-1">Initialization and assembly preferences.</p>
</div>
<div className="flex gap-2">
{["Initialization", "Skills", "Content", "Validate"].map((label, i) => (
<div
key={label}
className={`flex-1 rounded-md border px-3 py-2 text-center text-sm ${
i === 0 ? "border-primary bg-primary/5 font-medium" : "border-muted text-muted-foreground"
}`}
>
{i + 1}. {label}
</div>
))}
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<Card>
<CardHeader>
<CardTitle>Exam type</CardTitle>
<CardDescription>Select the official IELTS paper type.</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="exam_type"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
onValueChange={(v) => field.onChange(v as IELTSExamType)}
value={field.value}
className="grid gap-3 sm:grid-cols-2"
>
<Label
htmlFor="et-academic"
className="flex cursor-pointer items-center gap-3 rounded-lg border p-4 has-[[data-state=checked]]:border-primary"
>
<RadioGroupItem value="academic" id="et-academic" />
<span className="font-medium">IELTS Academic</span>
</Label>
<Label
htmlFor="et-gt"
className="flex cursor-pointer items-center gap-3 rounded-lg border p-4 has-[[data-state=checked]]:border-primary"
>
<RadioGroupItem value="general_training" id="et-gt" />
<span className="font-medium">General Training</span>
</Label>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Target skills</CardTitle>
<CardDescription>Include at least one skill in this build.</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="skills"
render={() => (
<FormItem>
<div className="grid gap-3 sm:grid-cols-2">
{skillOptions.map((s) => (
<FormField
key={s.id}
control={form.control}
name="skills"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value?.includes(s.id)}
onCheckedChange={(checked) => {
const next = new Set(field.value ?? []);
if (checked) next.add(s.id);
else next.delete(s.id);
field.onChange(Array.from(next) as IELTSSkill[]);
}}
/>
</FormControl>
<FormLabel className="font-normal cursor-pointer">{s.label}</FormLabel>
</FormItem>
)}
/>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Exam details</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Exam title</FormLabel>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<FormControl>
<Input placeholder={suggestedTitle} {...field} />
</FormControl>
<Button type="button" variant="secondary" onClick={onSuggestTitle}>
Apply suggested
</Button>
</div>
<FormDescription>Uses type, band, and todays date.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="target_band"
render={({ field }) => (
<FormItem>
<FormLabel>Target band ({field.value.toFixed(1)})</FormLabel>
<FormControl>
<Slider
min={4}
max={9}
step={0.5}
value={[field.value]}
onValueChange={(v) => field.onChange(nearestBand(v[0] ?? 7))}
className="max-w-md"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="difficulty"
render={({ field }) => (
<FormItem>
<FormLabel>Difficulty</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger className="max-w-xs">
<SelectValue placeholder="Select" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="easy">Easy</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="hard">Hard</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="randomize"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Randomization</FormLabel>
<FormDescription>Shuffle order within allowed constraints.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Assembly mode</CardTitle>
<CardDescription>How items are chosen from the content pool.</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="assembly_mode"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
value={field.value}
className="grid gap-4"
>
<Label className="flex cursor-pointer gap-3 rounded-lg border p-4 has-[[data-state=checked]]:border-primary">
<RadioGroupItem value="auto" id="am-auto" />
<div>
<span className="font-medium">Auto</span>
<p className="text-sm text-muted-foreground">
The system fills every slot using rules and pool metadata.
</p>
</div>
</Label>
<Label className="flex cursor-pointer gap-3 rounded-lg border p-4 has-[[data-state=checked]]:border-primary">
<RadioGroupItem value="manual" id="am-manual" />
<div>
<span className="font-medium">Manual</span>
<p className="text-sm text-muted-foreground">You select each question from the pool.</p>
</div>
</Label>
<Label className="flex cursor-pointer gap-3 rounded-lg border p-4 has-[[data-state=checked]]:border-primary">
<RadioGroupItem value="hybrid" id="am-hybrid" />
<div>
<span className="font-medium">Hybrid</span>
<p className="text-sm text-muted-foreground">
Suggested sets with per-item accept or reject.
</p>
</div>
</Label>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Score release</CardTitle>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="results_release_mode"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
value={field.value}
className="grid gap-3 sm:grid-cols-2"
>
<Label className="flex cursor-pointer items-center gap-3 rounded-lg border p-4 has-[[data-state=checked]]:border-primary">
<RadioGroupItem value="auto" id="sr-auto" />
<span>Auto-release</span>
</Label>
<Label className="flex cursor-pointer items-center gap-3 rounded-lg border p-4 has-[[data-state=checked]]:border-primary">
<RadioGroupItem value="manual_approval" id="sr-manual" />
<span>Manual approval</span>
</Label>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Alert>
<Info className="h-4 w-4" />
<AlertTitle>IELTS structure (reference)</AlertTitle>
<AlertDescription className="space-y-2 mt-2">
<ul className="list-disc pl-4 text-sm space-y-1">
<li>Listening: 4 parts, 10 questions each (approx. 30 minutes plus transfer time).</li>
<li>Reading: 3 passages, about 1314 questions each (60 minutes).</li>
<li>Writing: 2 tasks (60 minutes).</li>
<li>Speaking: 3 parts (1114 minutes).</li>
</ul>
</AlertDescription>
</Alert>
<Separator />
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)}>
Back
</Button>
<Button type="submit" disabled={createExam.isPending}>
{createExam.isPending ? "Creating…" : "Next"}
</Button>
</div>
</form>
</Form>
</div>
);
}

View File

@@ -0,0 +1,246 @@
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Eye, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { useIeltsValidation, usePublishIeltsExam, useAssignIeltsExam } from "@/hooks/queries/useExamTemplates";
import { useStudents, useBatches } from "@/hooks/queries/useLms";
import { ieltsExamService } from "@/services/ielts-exam.service";
import type { ExamValidationReport, ValidationCheck } from "@/types";
import { toast } from "sonner";
function statusBadge(check: ValidationCheck) {
if (check.status === "pass") {
return <Badge className="bg-emerald-600 hover:bg-emerald-600">Pass</Badge>;
}
if (check.status === "fail") {
return <Badge variant="destructive">Fail</Badge>;
}
return <Badge className="bg-amber-500 hover:bg-amber-500 text-foreground">Warning</Badge>;
}
export default function IeltsExamValidation() {
const { examId: examIdParam } = useParams<{ examId: string }>();
const navigate = useNavigate();
const examId = Number(examIdParam);
const validationQ = useIeltsValidation(Number.isFinite(examId) ? examId : 0);
const publishMut = usePublishIeltsExam();
const assignMut = useAssignIeltsExam();
const [studentSearch, setStudentSearch] = useState("");
const studentsQ = useStudents({ search: studentSearch || undefined, size: 200 });
const batchesQ = useBatches();
const [selectedStudents, setSelectedStudents] = useState<number[]>([]);
const [selectedBatches, setSelectedBatches] = useState<number[]>([]);
const [accessStart, setAccessStart] = useState("");
const [accessEnd, setAccessEnd] = useState("");
const [assignOpen, setAssignOpen] = useState(false);
const report = useMemo((): ExamValidationReport | undefined => {
const raw = validationQ.data as ExamValidationReport | { data?: ExamValidationReport } | undefined;
if (!raw) return undefined;
if ("checks" in raw && Array.isArray((raw as ExamValidationReport).checks)) {
return raw as ExamValidationReport;
}
if ("data" in raw && raw.data && typeof raw.data === "object" && "checks" in raw.data) {
return raw.data;
}
return undefined;
}, [validationQ.data]);
const checks: ValidationCheck[] = report?.checks ?? [];
const allPass =
report?.can_publish ?? (checks.length > 0 ? !checks.some((c) => c.status === "fail") : false);
const toggleStudent = (id: number) => {
setSelectedStudents((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
};
const toggleBatch = (id: number) => {
setSelectedBatches((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
};
const saveDraft = async () => {
try {
await ieltsExamService.update(examId, { status: "draft" });
toast.success("Saved as draft.");
} catch {
toast.error("Could not save draft.");
}
};
const onPublish = async () => {
try {
await publishMut.mutateAsync(examId);
toast.success("Exam published.");
} catch {
toast.error("Publish failed.");
}
};
const onAssign = async () => {
try {
await assignMut.mutateAsync({
examId,
assignment: {
student_ids: selectedStudents.length ? selectedStudents : undefined,
batch_ids: selectedBatches.length ? selectedBatches : undefined,
access_start: accessStart || undefined,
access_end: accessEnd || undefined,
},
});
toast.success("Assignment created.");
setAssignOpen(false);
} catch {
toast.error("Assignment failed.");
}
};
if (!Number.isFinite(examId) || examId <= 0) {
return <p className="p-6 text-destructive">Invalid exam.</p>;
}
return (
<div className="p-6 max-w-3xl mx-auto space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Validate exam</h1>
<p className="text-muted-foreground mt-1">Confirm structure, media, and scoring before go-live.</p>
</div>
<Button type="button" variant="outline" className="shrink-0 gap-2" onClick={() => navigate(`/admin/exam?preview=${examId}`)}>
<Eye className="h-4 w-4" />
Preview exam
</Button>
</div>
{validationQ.isLoading ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : (
<Card>
<CardHeader>
<CardTitle>Checklist</CardTitle>
<CardDescription>Each rule must pass or be only a warning before publishing.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{checks.length === 0 ? (
<p className="text-sm text-muted-foreground">No validation results yet.</p>
) : (
checks.map((c) => (
<div key={c.name} className="rounded-lg border p-4 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-medium">{c.name}</span>
{statusBadge(c)}
</div>
<p className="text-sm text-muted-foreground">{c.description}</p>
{c.details ? <p className="text-xs text-muted-foreground">{c.details}</p> : null}
</div>
))
)}
</CardContent>
</Card>
)}
<Separator />
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
<Button type="button" variant="secondary" onClick={saveDraft}>
Save as draft
</Button>
<Button type="button" disabled={!allPass || publishMut.isPending} onClick={onPublish}>
{publishMut.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Publish
</Button>
<Dialog open={assignOpen} onOpenChange={setAssignOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline">
Assign to students
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Assign exam</DialogTitle>
<DialogDescription>Pick students or batches and an optional access window.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="stu-search">Search students</Label>
<Input
id="stu-search"
value={studentSearch}
onChange={(e) => setStudentSearch(e.target.value)}
placeholder="Name or email"
/>
</div>
<ScrollArea className="h-40 rounded-md border p-2">
{(studentsQ.data?.items ?? studentsQ.data?.data ?? []).map((s) => (
<label key={s.id} className="flex items-center gap-2 py-1 text-sm cursor-pointer">
<Checkbox
checked={selectedStudents.includes(s.id)}
onCheckedChange={() => toggleStudent(s.id)}
/>
<span>{s.name || s.email || `Student ${s.id}`}</span>
</label>
))}
{(studentsQ.data?.items ?? studentsQ.data?.data ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground p-2">No students found.</p>
) : null}
</ScrollArea>
<div className="space-y-2">
<Label>Batches</Label>
<ScrollArea className="h-28 rounded-md border p-2">
{(batchesQ.data?.items ?? batchesQ.data?.data ?? []).map((b) => (
<label key={b.id} className="flex items-center gap-2 py-1 text-sm cursor-pointer">
<Checkbox checked={selectedBatches.includes(b.id)} onCheckedChange={() => toggleBatch(b.id)} />
<span>{b.name}</span>
</label>
))}
</ScrollArea>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="acc-start">Access start</Label>
<Input id="acc-start" type="datetime-local" value={accessStart} onChange={(e) => setAccessStart(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="acc-end">Access end</Label>
<Input id="acc-end" type="datetime-local" value={accessEnd} onChange={(e) => setAccessEnd(e.target.value)} />
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setAssignOpen(false)}>
Cancel
</Button>
<Button type="button" disabled={assignMut.isPending} onClick={onAssign}>
{assignMut.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Confirm assign
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
);
}

View File

@@ -0,0 +1,210 @@
import { useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import { useIeltsSkills } from "@/hooks/queries/useExamTemplates";
import type { IELTSPartConfig, IELTSSkill, IELTSSkillConfig } from "@/types";
const SKILL_LABEL: Record<IELTSSkill, string> = {
listening: "Listening",
reading: "Reading",
writing: "Writing",
speaking: "Speaking",
};
const STATIC_PARTS: Record<IELTSSkill, Omit<IELTSPartConfig, "part_number">[]> = {
listening: [
{ question_count: 10, content_type: "Audio + MCQ", time_limit_min: 7, description: "Part 1 — everyday social" },
{ question_count: 10, content_type: "Audio + MCQ", time_limit_min: 7, description: "Part 2 — monologue" },
{ question_count: 10, content_type: "Audio + MCQ", time_limit_min: 8, description: "Part 3 — conversation" },
{ question_count: 10, content_type: "Audio + MCQ", time_limit_min: 8, description: "Part 4 — lecture" },
],
reading: [
{ question_count: 13, content_type: "Passage + items", time_limit_min: 20, description: "Passage 1" },
{ question_count: 13, content_type: "Passage + items", time_limit_min: 20, description: "Passage 2" },
{ question_count: 14, content_type: "Passage + items", time_limit_min: 20, description: "Passage 3" },
],
writing: [
{ question_count: 1, content_type: "Task response", time_limit_min: 20, description: "Task 1" },
{ question_count: 1, content_type: "Essay", time_limit_min: 40, description: "Task 2" },
],
speaking: [
{ question_count: 1, content_type: "Interview", time_limit_min: 5, description: "Part 1 — introduction" },
{ question_count: 1, content_type: "Long turn", time_limit_min: 4, description: "Part 2 — cue card" },
{ question_count: 1, content_type: "Discussion", time_limit_min: 5, description: "Part 3 — abstract" },
],
};
function resolveParts(skill: IELTSSkill, cfg?: IELTSSkillConfig): IELTSPartConfig[] {
const fromApi = cfg?.parts?.length ? cfg.parts : [];
if (fromApi.length) return fromApi;
return STATIC_PARTS[skill].map((p, i) => ({
part_number: i + 1,
...p,
}));
}
function requiredQuestions(skill: IELTSSkill, parts: IELTSPartConfig[]): number {
const sum = parts.reduce((a, p) => a + p.question_count, 0);
if (sum > 0) return sum;
if (skill === "listening") return 40;
if (skill === "reading") return 40;
if (skill === "writing") return 2;
return 3;
}
export default function IeltsSkillConfig() {
const { examId: examIdParam } = useParams<{ examId: string }>();
const navigate = useNavigate();
const examId = Number(examIdParam);
const { data, isLoading, isError } = useIeltsSkills(Number.isFinite(examId) ? examId : 0);
const skills = useMemo(() => {
if (!data) return [] as IELTSSkillConfig[];
const arr = Array.isArray(data) ? data : (data as { data?: IELTSSkillConfig[] }).data ?? [];
return arr;
}, [data]);
const defaultTab = (skills.length > 0 ? skills[0]?.skill : "listening") ?? "listening";
if (!Number.isFinite(examId) || examId <= 0) {
return <p className="p-6 text-destructive">Invalid exam.</p>;
}
if (isLoading) {
return (
<div className="p-6 max-w-6xl mx-auto space-y-4">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-96 w-full" />
</div>
);
}
const displaySkills: IELTSSkillConfig[] = skills.length > 0
? skills
: (["listening", "reading", "writing", "speaking"] as IELTSSkill[]).map((s) => ({
skill: s,
parts: STATIC_PARTS[s].map((p, i) => ({ part_number: i + 1, ...p })),
available_count: 0,
}));
return (
<div className="p-6 max-w-6xl mx-auto">
<div className="flex flex-col gap-6 lg:flex-row">
<div className="flex-1 space-y-4 min-w-0">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Skill layout</h1>
<p className="text-muted-foreground mt-1">Review the official structure before choosing content.</p>
</div>
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList className="flex flex-wrap h-auto gap-1">
{displaySkills.map((s) => (
<TabsTrigger key={s.skill} value={s.skill} className="capitalize">
{SKILL_LABEL[s.skill]}
</TabsTrigger>
))}
</TabsList>
{displaySkills.map((s) => {
const parts = resolveParts(s.skill, s);
const required = requiredQuestions(s.skill, parts);
const pool = s.available_count ?? 0;
const pct = required > 0 ? Math.min(100, Math.round((pool / required) * 100)) : 0;
return (
<TabsContent key={s.skill} value={s.skill} className="space-y-4">
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle className="capitalize">{SKILL_LABEL[s.skill]}</CardTitle>
<CardDescription>
Read-only structure. Pool coverage: {pool} items available for ~{required} required questions.
</CardDescription>
</div>
<Badge variant={pct >= 100 ? "default" : "secondary"}>{pct}% pool coverage</Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Part</TableHead>
<TableHead>Questions</TableHead>
<TableHead>Content type</TableHead>
<TableHead>Time (min)</TableHead>
<TableHead className="text-right">Pool</TableHead>
<TableHead className="text-right w-[140px]" />
</TableRow>
</TableHeader>
<TableBody>
{parts.map((p) => (
<TableRow key={`${s.skill}-${p.part_number}`}>
<TableCell className="font-medium">{p.part_number}</TableCell>
<TableCell>{p.question_count}</TableCell>
<TableCell>{p.content_type}</TableCell>
<TableCell>{p.time_limit_min}</TableCell>
<TableCell className="text-right text-muted-foreground">
{Math.max(0, Math.floor(pool / parts.length))}
</TableCell>
<TableCell className="text-right">
<Button
size="sm"
type="button"
onClick={() =>
navigate(
`/admin/exam/ielts/${examId}/content?skill=${s.skill}&part=${p.part_number}`,
)
}
>
Configure content
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
);
})}
</Tabs>
</div>
<aside className="w-full lg:w-80 shrink-0 space-y-3">
<Card>
<CardHeader>
<CardTitle className="text-base">Completion</CardTitle>
<CardDescription>Per-skill readiness for assembly.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<ScrollArea className="h-[min(420px,50vh)]">
{displaySkills.map((s) => {
const parts = resolveParts(s.skill, s);
const required = requiredQuestions(s.skill, parts);
const pool = s.available_count ?? 0;
const ok = pool >= required;
return (
<div
key={s.skill}
className="flex items-center justify-between gap-2 rounded-md border p-3 mb-2 text-sm"
>
<span className="capitalize font-medium">{SKILL_LABEL[s.skill]}</span>
<Badge variant={ok ? "default" : "outline"}>{ok ? "Ready" : "Needs content"}</Badge>
</div>
);
})}
</ScrollArea>
<Button className="w-full" type="button" onClick={() => navigate(`/admin/exam/ielts/${examId}/validate`)}>
Continue to validation
</Button>
</CardContent>
</Card>
</aside>
</div>
</div>
);
}

View File

@@ -0,0 +1,274 @@
import { useState } from "react";
import { Card, CardContent } 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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, ChevronRight, CalendarCheck, CheckCircle2, Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { institutionalExamService } from "@/services/institutional-exam.service";
import { useCourses, useBatches } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { InstitutionalExamSession, InstitutionalExam } from "@/types/institutional-exam";
const stateBadgeVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
schedule: "secondary",
held: "default",
cancel: "destructive",
done: "default",
};
export default function InstitutionalExamSessions() {
const { toast } = useToast();
const qc = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const [expandedId, setExpandedId] = useState<number | null>(null);
const { data: sessionsData, isLoading } = useQuery({
queryKey: ["inst-exam-sessions", "list"],
queryFn: () => institutionalExamService.listSessions(),
});
const sessions = sessionsData?.items ?? [];
const { data: expandedSession } = useQuery({
queryKey: ["inst-exam-sessions", expandedId],
queryFn: () => institutionalExamService.getSession(expandedId!),
enabled: !!expandedId,
});
const { data: examTypesData } = useQuery({
queryKey: ["exam-types", "list"],
queryFn: () => institutionalExamService.listExamTypes(),
});
const examTypes = examTypesData?.items ?? [];
const { data: coursesData } = useCourses();
const courses = coursesData?.items ?? [];
const { data: batchesData } = useBatches();
const batches = batchesData?.items ?? [];
const [form, setForm] = useState({
name: "",
course_id: 0,
batch_id: 0,
start_date: "",
end_date: "",
exam_type_id: 0,
evaluation_type: "normal" as "normal" | "grade",
});
const createMutation = useMutation({
mutationFn: () => institutionalExamService.createSession(form as Parameters<typeof institutionalExamService.createSession>[0]),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["inst-exam-sessions"] });
toast({ title: "Exam session created" });
setShowCreate(false);
setForm({ name: "", course_id: 0, batch_id: 0, start_date: "", end_date: "", exam_type_id: 0, evaluation_type: "normal" });
},
onError: () => toast({ title: "Error", description: "Failed to create session", variant: "destructive" }),
});
const scheduleMutation = useMutation({
mutationFn: (id: number) => institutionalExamService.scheduleSession(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["inst-exam-sessions"] }); toast({ title: "Session scheduled" }); },
onError: () => toast({ title: "Error", description: "Failed to schedule", variant: "destructive" }),
});
const doneMutation = useMutation({
mutationFn: (id: number) => institutionalExamService.markSessionDone(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["inst-exam-sessions"] }); toast({ title: "Session marked as done" }); },
onError: () => toast({ title: "Error", description: "Failed to update", variant: "destructive" }),
});
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Exam Sessions</h1>
<p className="text-muted-foreground">Manage institutional exam sessions and their exams.</p>
</div>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Create Session</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Exam Session</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="e.g. Mid-Term Exams 2025" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Course</Label>
<Select value={form.course_id ? form.course_id.toString() : ""} onValueChange={v => setForm({ ...form, course_id: Number(v) })}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
{courses.map((c) => (
<SelectItem key={c.id} value={c.id.toString()}>{(c as { title?: string; name?: string }).title || (c as { name?: string }).name || `Course #${c.id}`}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Batch</Label>
<Select value={form.batch_id ? form.batch_id.toString() : ""} onValueChange={v => setForm({ ...form, batch_id: Number(v) })}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
{batches.map((b: { id: number; name: string }) => (
<SelectItem key={b.id} value={b.id.toString()}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Start Date</Label>
<Input type="date" value={form.start_date} onChange={e => setForm({ ...form, start_date: e.target.value })} />
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input type="date" value={form.end_date} onChange={e => setForm({ ...form, end_date: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Exam Type</Label>
<Select value={form.exam_type_id ? form.exam_type_id.toString() : ""} onValueChange={v => setForm({ ...form, exam_type_id: Number(v) })}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
{examTypes.map((t: { id: number; name: string }) => (
<SelectItem key={t.id} value={t.id.toString()}>{t.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Evaluation Type</Label>
<Select value={form.evaluation_type} onValueChange={v => setForm({ ...form, evaluation_type: v as "normal" | "grade" })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="grade">Grade</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button className="w-full" onClick={() => createMutation.mutate()} disabled={createMutation.isPending || !form.name || !form.course_id || !form.batch_id}>
{createMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Session
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead />
<TableHead>Name</TableHead>
<TableHead>Course / Batch</TableHead>
<TableHead>Dates</TableHead>
<TableHead>Type</TableHead>
<TableHead>Exams</TableHead>
<TableHead>State</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((session: InstitutionalExamSession) => (
<Collapsible key={session.id} asChild open={expandedId === session.id} onOpenChange={open => setExpandedId(open ? session.id : null)}>
<>
<TableRow>
<TableCell>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<ChevronRight className={`h-4 w-4 transition-transform ${expandedId === session.id ? "rotate-90" : ""}`} />
</Button>
</CollapsibleTrigger>
</TableCell>
<TableCell className="font-medium">{session.name}</TableCell>
<TableCell>
<div>
<span className="text-sm">{session.course_name}</span>
<span className="text-xs text-muted-foreground ml-1">/ {session.batch_name}</span>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{session.start_date} {session.end_date}</TableCell>
<TableCell><Badge variant="outline">{session.exam_type_name}</Badge></TableCell>
<TableCell><Badge variant="secondary">{session.exam_count}</Badge></TableCell>
<TableCell>
<Badge variant={stateBadgeVariant[session.state] ?? "outline"} className="capitalize">{session.state}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{session.state === "draft" && (
<Button variant="ghost" size="sm" onClick={() => scheduleMutation.mutate(session.id)} disabled={scheduleMutation.isPending}>
<CalendarCheck className="mr-1 h-3 w-3" /> Schedule
</Button>
)}
{session.state === "schedule" && (
<Button variant="ghost" size="sm" onClick={() => doneMutation.mutate(session.id)} disabled={doneMutation.isPending}>
<CheckCircle2 className="mr-1 h-3 w-3" /> Done
</Button>
)}
</div>
</TableCell>
</TableRow>
<CollapsibleContent asChild>
<TableRow>
<TableCell colSpan={8} className="bg-muted/50 p-4">
{expandedSession && expandedId === session.id ? (
<div className="space-y-2">
<h4 className="text-sm font-medium">Exams in Session</h4>
{(expandedSession as InstitutionalExamSession & { exams?: InstitutionalExam[] }).exams?.length ? (
<div className="grid gap-2">
{((expandedSession as InstitutionalExamSession & { exams?: InstitutionalExam[] }).exams ?? []).map((exam: InstitutionalExam) => (
<div key={exam.id} className="flex items-center justify-between rounded border p-3 bg-background">
<div>
<span className="text-sm font-medium">{exam.name}</span>
<span className="text-xs text-muted-foreground ml-2">{exam.subject_name}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{exam.total_marks} marks</span>
<Badge variant="outline" className="text-xs capitalize">{exam.state}</Badge>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No exams in this session yet.</p>
)}
</div>
) : (
<div className="flex justify-center py-4"><Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /></div>
)}
</TableCell>
</TableRow>
</CollapsibleContent>
</>
</Collapsible>
))}
{sessions.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">No exam sessions found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,170 @@
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { queryKeys } from "@/hooks/queries/keys";
import { levelMappingService } from "@/services/level-mapping.service";
import type { LevelMappingRow } from "@/types";
import { toast } from "sonner";
function validateRows(rows: LevelMappingRow[]): string | null {
const sorted = [...rows].sort((x, y) => x.cefr_min_score - y.cefr_min_score);
for (const r of sorted) {
if (r.cefr_min_score > r.cefr_max_score) return "Each row must have min ≤ max.";
}
for (let i = 1; i < sorted.length; i++) {
if (sorted[i].cefr_min_score <= sorted[i - 1].cefr_max_score) {
return "CEFR score ranges must not overlap.";
}
}
return null;
}
export default function LevelMappingConfig() {
const { entityId: eid } = useParams<{ entityId: string }>();
const entityId = Number(eid);
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: queryKeys.levelMapping.get(entityId),
queryFn: () => levelMappingService.get(entityId),
enabled: Number.isFinite(entityId),
});
const [rows, setRows] = useState<LevelMappingRow[]>([]);
useEffect(() => {
if (data?.mappings?.length) setRows(data.mappings.map((r) => ({ ...r })));
}, [data]);
const dirty = useMemo(() => JSON.stringify(rows) !== JSON.stringify(data?.mappings ?? []), [rows, data]);
const saveMut = useMutation({
mutationFn: () => levelMappingService.update(entityId, rows),
onSuccess: () => {
toast.success("Saved.");
qc.invalidateQueries({ queryKey: queryKeys.levelMapping.get(entityId) });
},
onError: (e) => toast.error(e instanceof Error ? e.message : "Save failed"),
});
const resetMut = useMutation({
mutationFn: () => levelMappingService.resetToDefault(entityId),
onSuccess: () => {
toast.success("Reset.");
qc.invalidateQueries({ queryKey: queryKeys.levelMapping.get(entityId) });
},
onError: (e) => toast.error(e instanceof Error ? e.message : "Reset failed"),
});
const updateRow = (index: number, patch: Partial<LevelMappingRow>) => {
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, ...patch } : r)));
};
const addRow = () => {
setRows((prev) => [
...prev,
{
cefr_min_score: 0,
cefr_max_score: 0,
internal_level_name: "New level",
cefr_equivalent: "B1",
},
]);
};
const save = () => {
const err = validateRows(rows);
if (err) {
toast.error(err);
return;
}
saveMut.mutate();
};
if (!Number.isFinite(entityId)) {
return <div className="p-6 text-destructive">Invalid entity.</div>;
}
if (isLoading) {
return <div className="p-6 text-muted-foreground">Loading mapping</div>;
}
return (
<div className="space-y-6 p-6 max-w-5xl mx-auto">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Level mapping</h1>
<p className="text-muted-foreground text-sm mt-1">Entity #{entityId}</p>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>CEFR internal levels</CardTitle>
<CardDescription>Editable grid with overlap validation</CardDescription>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={addRow}>
Add Row
</Button>
<Button variant="outline" onClick={() => resetMut.mutate()} disabled={resetMut.isPending}>
Reset to Default
</Button>
<Button onClick={save} disabled={!dirty || saveMut.isPending}>
Save
</Button>
</div>
</CardHeader>
<CardContent>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>CEFR min score</TableHead>
<TableHead>CEFR max score</TableHead>
<TableHead>Internal level name</TableHead>
<TableHead>CEFR equivalent</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, i) => (
<TableRow key={row.id ?? i}>
<TableCell>
<Input
type="number"
value={row.cefr_min_score}
onChange={(e) => updateRow(i, { cefr_min_score: Number(e.target.value) })}
/>
</TableCell>
<TableCell>
<Input
type="number"
value={row.cefr_max_score}
onChange={(e) => updateRow(i, { cefr_max_score: Number(e.target.value) })}
/>
</TableCell>
<TableCell>
<Input
value={row.internal_level_name}
onChange={(e) => updateRow(i, { internal_level_name: e.target.value })}
/>
</TableCell>
<TableCell>
<Input
value={row.cefr_equivalent}
onChange={(e) => updateRow(i, { cefr_equivalent: e.target.value })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,312 @@
import { useState } from "react";
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";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
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, FileSpreadsheet, CheckCircle2, Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { institutionalExamService } from "@/services/institutional-exam.service";
import { useToast } from "@/hooks/use-toast";
import type { ResultTemplate, Marksheet, MarksheetLine, GradeConfig } from "@/types/institutional-exam";
const marksheetStateBadge: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
validated: "default",
cancelled: "destructive",
};
export default function MarksheetManager() {
const { toast } = useToast();
const qc = useQueryClient();
const [activeTab, setActiveTab] = useState<"templates" | "marksheets">("templates");
const [showCreateTemplate, setShowCreateTemplate] = useState(false);
const [selectedMarksheetId, setSelectedMarksheetId] = useState<number | null>(null);
const { data: templatesData, isLoading: loadingTemplates } = useQuery({
queryKey: ["result-templates", "list"],
queryFn: () => institutionalExamService.listResultTemplates(),
});
const templates = templatesData?.items ?? [];
const { data: marksheetsData, isLoading: loadingMarksheets } = useQuery({
queryKey: ["marksheets", "list"],
queryFn: () => institutionalExamService.listMarksheets(),
enabled: activeTab === "marksheets",
});
const marksheets = marksheetsData?.items ?? [];
const { data: selectedMarksheet } = useQuery({
queryKey: ["marksheets", selectedMarksheetId],
queryFn: () => institutionalExamService.getMarksheet(selectedMarksheetId!),
enabled: !!selectedMarksheetId,
});
const { data: sessionsData } = useQuery({
queryKey: ["inst-exam-sessions", "list"],
queryFn: () => institutionalExamService.listSessions(),
});
const sessions = sessionsData?.items ?? [];
const { data: gradeConfigsData } = useQuery({
queryKey: ["grade-configs", "list"],
queryFn: () => institutionalExamService.listGradeConfigs(),
});
const gradeConfigs = gradeConfigsData?.items ?? [];
const [templateForm, setTemplateForm] = useState({
name: "",
exam_session_id: 0,
grade_ids: [] as number[],
});
const createTemplateMutation = useMutation({
mutationFn: () => institutionalExamService.createResultTemplate(templateForm as Parameters<typeof institutionalExamService.createResultTemplate>[0]),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["result-templates"] });
toast({ title: "Template created" });
setShowCreateTemplate(false);
setTemplateForm({ name: "", exam_session_id: 0, grade_ids: [] });
},
onError: () => toast({ title: "Error", description: "Failed to create template", variant: "destructive" }),
});
const generateMutation = useMutation({
mutationFn: (templateId: number) => institutionalExamService.generateMarksheets(templateId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["marksheets"] });
qc.invalidateQueries({ queryKey: ["result-templates"] });
toast({ title: "Marksheets generated" });
},
onError: () => toast({ title: "Error", description: "Generation failed", variant: "destructive" }),
});
const validateMutation = useMutation({
mutationFn: (id: number) => institutionalExamService.validateMarksheet(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["marksheets"] });
toast({ title: "Marksheet validated" });
},
onError: () => toast({ title: "Error", description: "Validation failed", variant: "destructive" }),
});
const isLoading = activeTab === "templates" ? loadingTemplates : loadingMarksheets;
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Marksheet Manager</h1>
<p className="text-muted-foreground">Manage result templates and marksheets.</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant={activeTab === "templates" ? "default" : "outline"} size="sm" onClick={() => setActiveTab("templates")}>
Result Templates
</Button>
<Button variant={activeTab === "marksheets" ? "default" : "outline"} size="sm" onClick={() => setActiveTab("marksheets")}>
Marksheets
</Button>
</div>
{activeTab === "templates" && (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Result Templates</CardTitle>
<Dialog open={showCreateTemplate} onOpenChange={setShowCreateTemplate}>
<DialogTrigger asChild>
<Button size="sm"><Plus className="mr-2 h-4 w-4" /> Create Template</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Result Template</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="e.g. Mid-Term Results" value={templateForm.name} onChange={e => setTemplateForm({ ...templateForm, name: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Exam Session</Label>
<Select value={templateForm.exam_session_id ? templateForm.exam_session_id.toString() : ""} onValueChange={v => setTemplateForm({ ...templateForm, exam_session_id: Number(v) })}>
<SelectTrigger><SelectValue placeholder="Select session" /></SelectTrigger>
<SelectContent>
{sessions.map((s: { id: number; name: string }) => (
<SelectItem key={s.id} value={s.id.toString()}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Grade Configuration</Label>
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-lg p-2">
{gradeConfigs.map((gc: GradeConfig) => (
<label key={gc.id} className="flex items-center gap-2 p-1 hover:bg-accent/50 rounded cursor-pointer">
<input
type="checkbox"
checked={templateForm.grade_ids.includes(gc.id)}
onChange={e => {
setTemplateForm(prev => ({
...prev,
grade_ids: e.target.checked
? [...prev.grade_ids, gc.id]
: prev.grade_ids.filter(id => id !== gc.id),
}));
}}
className="rounded"
/>
<span className="text-sm">{gc.result} ({gc.min_per}%{gc.max_per}%)</span>
</label>
))}
{gradeConfigs.length === 0 && <p className="text-xs text-muted-foreground p-1">No grade configs available.</p>}
</div>
</div>
<Button className="w-full" onClick={() => createTemplateMutation.mutate()} disabled={createTemplateMutation.isPending || !templateForm.name || !templateForm.exam_session_id}>
{createTemplateMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Template
</Button>
</div>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Exam Session</TableHead>
<TableHead>Evaluation</TableHead>
<TableHead>Result Date</TableHead>
<TableHead>State</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templates.map((tpl: ResultTemplate) => (
<TableRow key={tpl.id}>
<TableCell className="font-medium">{tpl.name}</TableCell>
<TableCell>{tpl.exam_session_name}</TableCell>
<TableCell><Badge variant="outline" className="capitalize">{tpl.evaluation_type}</Badge></TableCell>
<TableCell className="text-muted-foreground">{tpl.result_date}</TableCell>
<TableCell>
<Badge variant={tpl.state === "result_generated" ? "default" : "outline"} className="capitalize">
{tpl.state.replace("_", " ")}
</Badge>
</TableCell>
<TableCell className="text-right">
{tpl.state === "draft" && (
<Button variant="ghost" size="sm" onClick={() => generateMutation.mutate(tpl.id)} disabled={generateMutation.isPending}>
<FileSpreadsheet className="mr-1 h-3 w-3" /> Generate Marksheets
</Button>
)}
</TableCell>
</TableRow>
))}
{templates.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">No result templates found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{activeTab === "marksheets" && (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Exam Session</TableHead>
<TableHead>Generated</TableHead>
<TableHead>Pass</TableHead>
<TableHead>Fail</TableHead>
<TableHead>State</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{marksheets.map((ms: Marksheet) => (
<TableRow
key={ms.id}
className="cursor-pointer hover:bg-accent/50"
onClick={() => setSelectedMarksheetId(selectedMarksheetId === ms.id ? null : ms.id)}
>
<TableCell className="font-medium">{ms.name}</TableCell>
<TableCell>{ms.exam_session_name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{ms.generated_date} by {ms.generated_by_name}</TableCell>
<TableCell><Badge variant="default">{ms.total_pass}</Badge></TableCell>
<TableCell><Badge variant="destructive">{ms.total_failed}</Badge></TableCell>
<TableCell>
<Badge variant={marksheetStateBadge[ms.state] ?? "outline"} className="capitalize">{ms.state}</Badge>
</TableCell>
<TableCell className="text-right" onClick={e => e.stopPropagation()}>
{ms.state === "draft" && (
<Button variant="ghost" size="sm" onClick={() => validateMutation.mutate(ms.id)} disabled={validateMutation.isPending}>
<CheckCircle2 className="mr-1 h-3 w-3" /> Validate
</Button>
)}
</TableCell>
</TableRow>
))}
{marksheets.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">No marksheets found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
{selectedMarksheet && (
<Card>
<CardHeader>
<CardTitle>Results {selectedMarksheet.name}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Total Marks</TableHead>
<TableHead>Percentage</TableHead>
<TableHead>Grade</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(selectedMarksheet.lines ?? []).map((line: MarksheetLine) => (
<TableRow key={line.id}>
<TableCell className="font-medium">{line.student_name}</TableCell>
<TableCell>{line.total_marks}</TableCell>
<TableCell>{line.percentage.toFixed(1)}%</TableCell>
<TableCell>{line.grade ?? "—"}</TableCell>
<TableCell>
<Badge variant={line.status === "pass" ? "default" : "destructive"} className="capitalize">{line.status}</Badge>
</TableCell>
</TableRow>
))}
{(selectedMarksheet.lines ?? []).length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">No results in this marksheet.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,335 @@
import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useCourseDetail, useUpdateCourse } from "@/hooks/queries/useCourseGeneration";
import { courseGenerationService } from "@/services/course-generation.service";
import type { ModuleConfig } from "@/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { ChevronDown, GripVertical, Search, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
export default function ModuleBuilder() {
const { courseId: courseIdParam } = useParams();
const courseId = Number(courseIdParam);
const { data: course, isLoading } = useCourseDetail(courseId);
const updateCourse = useUpdateCourse();
const [modules, setModules] = useState<ModuleConfig[] | null>(null);
const [resourceOpen, setResourceOpen] = useState(false);
const [aiOpen, setAiOpen] = useState(false);
const [aiForm, setAiForm] = useState({ topic: "", difficulty: "B2", type: "reading" });
const [dragId, setDragId] = useState<number | null>(null);
const mergedModules = modules ?? course?.modules ?? [];
const bySkill = useMemo(() => {
const map = new Map<string, ModuleConfig[]>();
mergedModules.forEach((m) => {
const k = m.skill || "General";
if (!map.has(k)) map.set(k, []);
map.get(k)!.push(m);
});
return map;
}, [mergedModules]);
const totalHours = mergedModules.reduce((s, m) => s + m.estimated_hours, 0);
const syncModules = (next: ModuleConfig[]) => {
setModules(next);
if (course) {
updateCourse.mutate({
courseId,
payload: { modules: next },
});
}
};
const onDragStart = (id: number) => setDragId(id);
const onDrop = (targetId: number) => {
if (dragId === null || dragId === targetId) return;
const list = [...mergedModules];
const from = list.findIndex((m) => m.id === dragId);
const to = list.findIndex((m) => m.id === targetId);
if (from < 0 || to < 0) return;
const [item] = list.splice(from, 1);
list.splice(to, 0, item);
syncModules(list);
setDragId(null);
};
const publish = () => {
updateCourse.mutate({ courseId, payload: { status: "published" } });
};
if (isLoading) {
return (
<div className="p-6">
<Skeleton className="mb-4 h-10 w-64" />
<Skeleton className="h-96 w-full" />
</div>
);
}
return (
<div className="mx-auto max-w-5xl space-y-6 p-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Module builder</h1>
<p className="text-muted-foreground">
{course?.title ?? `Course #${courseId}`} · {totalHours}h total · {mergedModules.length} modules
</p>
</div>
<Button type="button" onClick={publish} disabled={updateCourse.isPending}>
Publish Course
</Button>
</div>
<div className="space-y-4">
{mergedModules.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Sparkles className="h-10 w-10 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-1">No modules yet</h3>
<p className="text-sm text-muted-foreground mb-4">
Add modules manually or use the AI assistant to auto-generate a module structure.
</p>
<div className="flex gap-2">
<Button
type="button"
onClick={() => {
const next: ModuleConfig[] = [
{ id: Date.now(), title: "New Module", skill: "Listening", estimated_hours: 2, resources: [], assessment_type: "quiz" },
];
syncModules(next);
}}
>
Add Module
</Button>
<Button type="button" variant="outline" onClick={() => setAiOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
AI Generate
</Button>
</div>
</CardContent>
</Card>
)}
{Array.from(bySkill.entries()).map(([skill, mods]) => (
<Collapsible key={skill} defaultOpen>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="flex cursor-pointer flex-row items-center justify-between">
<CardTitle className="text-lg">
{skill} · {mods.reduce((s, m) => s + m.estimated_hours, 0)}h · {mods.length} modules
</CardTitle>
<ChevronDown className="h-5 w-5" />
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4 pt-0">
{mods.map((mod, idx) => (
<div
key={mod.id}
draggable
onDragStart={() => onDragStart(mod.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => onDrop(mod.id)}
className={cn("rounded-lg border p-4", dragId === mod.id && "opacity-60")}
>
<div className="mb-3 flex items-center gap-2">
<GripVertical className="h-5 w-5 text-muted-foreground" />
<span className="font-medium">
Module {idx + 1}: {mod.title}
</span>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`t-${mod.id}`}>Title</Label>
<Input
id={`t-${mod.id}`}
defaultValue={mod.title}
onBlur={(e) => {
const next = mergedModules.map((m) =>
m.id === mod.id ? { ...m, title: e.target.value } : m,
);
syncModules(next);
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`h-${mod.id}`}>Hours</Label>
<Input
id={`h-${mod.id}`}
type="number"
defaultValue={mod.estimated_hours}
onBlur={(e) => {
const v = Number(e.target.value);
const next = mergedModules.map((m) =>
m.id === mod.id ? { ...m, estimated_hours: v } : m,
);
syncModules(next);
}}
/>
</div>
</div>
<div className="mt-3 space-y-2">
<Label>Resources</Label>
<div className="flex flex-wrap gap-2">
{mod.resources.map((r) => (
<span key={r.id} className="rounded-md bg-muted px-2 py-1 text-xs">
{r.title} ({r.type})
</span>
))}
<Button type="button" size="sm" variant="outline" onClick={() => setResourceOpen(true)}>
<Search className="mr-1 h-3 w-3" />
Browse Resources
</Button>
</div>
</div>
<div className="mt-3 space-y-2">
<Label htmlFor={`ex-${mod.id}`}>Practice exercises</Label>
<Textarea id={`ex-${mod.id}`} placeholder="Describe linked exercises" rows={2} />
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>Completion criteria</Label>
<Select
defaultValue={mod.completion_criteria}
onValueChange={(val) => {
const v = val as ModuleConfig["completion_criteria"];
const next = mergedModules.map((m) =>
m.id === mod.id ? { ...m, completion_criteria: v } : m,
);
syncModules(next);
}}
>
<SelectTrigger>
<SelectValue placeholder="Criteria" />
</SelectTrigger>
<SelectContent>
<SelectItem value="complete_all">complete_all</SelectItem>
<SelectItem value="score_threshold">score_threshold</SelectItem>
<SelectItem value="teacher_approval">teacher_approval</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor={`pre-${mod.id}`}>Prerequisite module id</Label>
<Input
id={`pre-${mod.id}`}
type="number"
defaultValue={mod.prerequisite_module_id ?? ""}
onBlur={(e) => {
const v = e.target.value ? Number(e.target.value) : undefined;
const next = mergedModules.map((m) =>
m.id === mod.id ? { ...m, prerequisite_module_id: v } : m,
);
syncModules(next);
}}
/>
</div>
</div>
<Separator className="my-3" />
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => setAiOpen(true)}
>
<Sparkles className="mr-1 h-3 w-3" />
Generate AI Content
</Button>
</div>
))}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
))}
</div>
<Dialog open={resourceOpen} onOpenChange={setResourceOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Resource search</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Input placeholder="Search title, skill, or tag" />
<ScrollArea className="h-48 rounded-md border p-2 text-sm text-muted-foreground">
Matching resources will list here from your library.
</ScrollArea>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setResourceOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={aiOpen} onOpenChange={setAiOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Generate AI content</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="ai-topic">Topic</Label>
<Input
id="ai-topic"
value={aiForm.topic}
onChange={(e) => setAiForm((f) => ({ ...f, topic: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-diff">Difficulty</Label>
<Input
id="ai-diff"
value={aiForm.difficulty}
onChange={(e) => setAiForm((f) => ({ ...f, difficulty: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-type">Type</Label>
<Input
id="ai-type"
value={aiForm.type}
onChange={(e) => setAiForm((f) => ({ ...f, type: e.target.value }))}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
onClick={() => {
void courseGenerationService
.generateAIResource({
topic: aiForm.topic,
difficulty: aiForm.difficulty,
type: aiForm.type,
})
.then(() => setAiOpen(false));
}}
>
Generate
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,196 @@
import { useState } from "react";
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 { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Plus, Pencil, Trash2, Loader2, Bell } from "lucide-react";
import { useNotificationRules, useCreateNotificationRule, useUpdateNotificationRule, useDeleteNotificationRule } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { NotificationChannel, ReminderFrequency, NotificationRule, NotificationRuleCreateRequest } from "@/types/notification";
export default function NotificationRules() {
const { toast } = useToast();
const { data: rules = [], isLoading } = useNotificationRules();
const createRule = useCreateNotificationRule();
const updateRule = useUpdateNotificationRule();
const deleteRule = useDeleteNotificationRule();
const [showForm, setShowForm] = useState(false);
const [editingRule, setEditingRule] = useState<NotificationRule | null>(null);
const [name, setName] = useState("");
const [eventType, setEventType] = useState("");
const [daysBefore, setDaysBefore] = useState(1);
const [frequency, setFrequency] = useState<ReminderFrequency>("once");
const [channel, setChannel] = useState<NotificationChannel>("in_app");
const resetForm = () => { setName(""); setEventType(""); setDaysBefore(1); setFrequency("once"); setChannel("in_app"); setEditingRule(null); };
const openEdit = (rule: NotificationRule) => {
setEditingRule(rule);
setName(rule.name);
setEventType(rule.event_type);
setDaysBefore(rule.days_before);
setFrequency(rule.frequency);
setChannel(rule.channel);
setShowForm(true);
};
const handleSave = () => {
const data = { name, event_type: eventType, days_before: daysBefore, frequency, channel };
if (editingRule) {
updateRule.mutate(
{ id: editingRule.id, data },
{
onSuccess: () => { toast({ title: "Rule Updated" }); setShowForm(false); resetForm(); },
onError: () => toast({ title: "Error", description: "Failed to update rule", variant: "destructive" }),
},
);
} else {
createRule.mutate(data, {
onSuccess: () => { toast({ title: "Rule Created" }); setShowForm(false); resetForm(); },
onError: () => toast({ title: "Error", description: "Failed to create rule", variant: "destructive" }),
});
}
};
const handleDelete = (id: number) => {
if (!window.confirm("Delete this notification rule?")) return;
deleteRule.mutate(id, {
onSuccess: () => toast({ title: "Rule Deleted" }),
onError: () => toast({ title: "Error", description: "Failed to delete rule", variant: "destructive" }),
});
};
const handleToggleActive = (rule: NotificationRule) => {
updateRule.mutate(
{ id: rule.id, data: { active: !rule.active } as Partial<NotificationRuleCreateRequest> & { active?: boolean } },
{
onSuccess: () => toast({ title: rule.active ? "Rule Deactivated" : "Rule Activated" }),
onError: () => toast({ title: "Error", description: "Failed to toggle rule", variant: "destructive" }),
},
);
};
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Notification Rules</h1>
<p className="text-muted-foreground">Configure automated notification rules and reminders.</p>
</div>
<Dialog open={showForm} onOpenChange={open => { setShowForm(open); if (!open) resetForm(); }}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Create Rule</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>{editingRule ? "Edit Rule" : "Create Notification Rule"}</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="Rule name" value={name} onChange={e => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Event Type</Label>
<Select value={eventType} onValueChange={setEventType}>
<SelectTrigger><SelectValue placeholder="Select event" /></SelectTrigger>
<SelectContent>
<SelectItem value="deadline">Deadline</SelectItem>
<SelectItem value="chapter_unlock">Chapter Unlock</SelectItem>
<SelectItem value="result_release">Result Release</SelectItem>
<SelectItem value="assignment">Assignment</SelectItem>
<SelectItem value="exam">Exam</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Days Before</Label>
<Input type="number" min={0} value={daysBefore} onChange={e => setDaysBefore(Number(e.target.value))} />
</div>
<div className="space-y-2">
<Label>Frequency</Label>
<Select value={frequency} onValueChange={v => setFrequency(v as ReminderFrequency)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="once">Once</SelectItem>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Channel</Label>
<Select value={channel} onValueChange={v => setChannel(v as NotificationChannel)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="in_app">In-App</SelectItem>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="both">Both</SelectItem>
</SelectContent>
</Select>
</div>
<Button className="w-full" onClick={handleSave} disabled={(createRule.isPending || updateRule.isPending) || !name || !eventType}>
{(createRule.isPending || updateRule.isPending) && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{editingRule ? "Save Changes" : "Create Rule"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Event</TableHead>
<TableHead>Days Before</TableHead>
<TableHead>Frequency</TableHead>
<TableHead>Channel</TableHead>
<TableHead>Active</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rules.map(rule => (
<TableRow key={rule.id}>
<TableCell className="font-medium">{rule.name}</TableCell>
<TableCell><Badge variant="outline">{rule.event_type}</Badge></TableCell>
<TableCell>{rule.days_before}</TableCell>
<TableCell>{rule.frequency}</TableCell>
<TableCell><Badge variant="secondary">{rule.channel}</Badge></TableCell>
<TableCell>
<Switch checked={rule.active} onCheckedChange={() => handleToggleActive(rule)} />
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="icon" onClick={() => openEdit(rule)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDelete(rule.id)} disabled={deleteRule.isPending}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{rules.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
<Bell className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>No notification rules configured.</p>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
</div>
);
}

View File

@@ -0,0 +1,187 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } 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 { 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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { resourcesService } from "@/services/resources.service";
import { useToast } from "@/hooks/use-toast";
import type { Resource } from "@/types";
const statusBadge: Record<string, { variant: "default" | "secondary" | "destructive"; icon: React.ReactNode }> = {
approved: { variant: "default", icon: <CheckCircle2 className="h-3 w-3 mr-1" /> },
pending: { variant: "secondary", icon: <Clock className="h-3 w-3 mr-1" /> },
rejected: { variant: "destructive", icon: <XCircle className="h-3 w-3 mr-1" /> },
};
const typeIcons: Record<string, React.ReactNode> = {
pdf: <FileText className="h-4 w-4 text-red-500" />,
video: <Video className="h-4 w-4 text-blue-500" />,
link: <Link2 className="h-4 w-4 text-green-500" />,
document: <FileText className="h-4 w-4 text-orange-500" />,
interactive: <FileText className="h-4 w-4 text-purple-500" />,
};
export default function ResourceManager() {
const { toast } = useToast();
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [showUpload, setShowUpload] = useState(false);
const [uploadType, setUploadType] = useState<string>("pdf");
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 resources = resourcesData?.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" }),
});
const uploadMutation = useMutation({
mutationFn: (formData: FormData) => resourcesService.upload(formData),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Resource uploaded" }); setShowUpload(false); },
onError: () => toast({ title: "Error", description: "Upload failed", variant: "destructive" }),
});
const handleUpload = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
uploadMutation.mutate(formData);
};
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Resource Manager</h1>
<p className="text-muted-foreground">Upload, manage, and review learning resources.</p>
</div>
<Dialog open={showUpload} onOpenChange={setShowUpload}>
<DialogTrigger asChild>
<Button><Upload className="mr-2 h-4 w-4" /> Upload Resource</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Upload New Resource</DialogTitle></DialogHeader>
<form onSubmit={handleUpload} className="space-y-4 pt-4">
<input type="hidden" name="resource_type" value={uploadType} />
<div className="space-y-2"><Label>Name</Label><Input name="name" placeholder="Resource title" required /></div>
<div className="space-y-2">
<Label>Type</Label>
<Select value={uploadType} onValueChange={setUploadType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="link">Link</SelectItem>
<SelectItem value="document">Document</SelectItem>
<SelectItem value="interactive">Interactive</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2"><Label>File or URL</Label><Input name="file" type="file" /></div>
<Button type="submit" className="w-full" disabled={uploadMutation.isPending}>
{uploadMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Upload
</Button>
</form>
</DialogContent>
</Dialog>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search resources..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9" />
</div>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[140px]"><SelectValue placeholder="Type" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="link">Link</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px]"><SelectValue placeholder="Status" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Resource</TableHead>
<TableHead>Type</TableHead>
<TableHead>Topics</TableHead>
<TableHead>Author</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{resources.map((resource: Resource) => {
const sb = statusBadge[resource.review_status] ?? statusBadge.pending;
return (
<TableRow key={resource.id}>
<TableCell>
<div className="flex items-center gap-2">
{typeIcons[resource.resource_type] ?? <FileText className="h-4 w-4" />}
<span className="font-medium">{resource.name}</span>
</div>
</TableCell>
<TableCell><Badge variant="outline" className="capitalize">{resource.resource_type}</Badge></TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{resource.topic_names.slice(0, 2).map((t, i) => <Badge key={i} variant="secondary" className="text-xs">{t}</Badge>)}
{resource.topic_names.length > 2 && <Badge variant="secondary" className="text-xs">+{resource.topic_names.length - 2}</Badge>}
</div>
</TableCell>
<TableCell className="text-sm">{resource.author_name}</TableCell>
<TableCell>
<Badge variant={sb.variant} className="flex items-center w-fit">{sb.icon}{resource.review_status}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" onClick={async () => { 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" }); } }}><Download className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon" onClick={() => { if (!window.confirm(`Delete "${resource.name}"?`)) return; deleteMutation.mutate(resource.id); }} disabled={deleteMutation.isPending}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
{resources.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">No resources found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,381 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } 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 { Switch } from "@/components/ui/switch";
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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
Plus, Search, Trash2, Pencil, Shield, Key, Users, Loader2, ChevronRight,
} from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { rolesService } from "@/services/roles.service";
import type { Role, Permission, RoleCreateRequest, PermissionCreateRequest } from "@/types/role-permission";
const CATEGORY_COLORS: Record<string, string> = {
exam: "bg-violet-100 text-violet-700 dark:bg-violet-900 dark:text-violet-300",
user: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
entity: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
assignment: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
classroom: "bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-300",
payment: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300",
report: "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300",
};
function categoryBadgeClass(cat: string) {
return CATEGORY_COLORS[cat.toLowerCase()] ?? "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300";
}
export default function RolesPermissions() {
const { toast } = useToast();
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [tab, setTab] = useState("roles");
const [roleDialogOpen, setRoleDialogOpen] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [roleForm, setRoleForm] = useState<RoleCreateRequest>({ name: "", description: "" });
const [permDialogOpen, setPermDialogOpen] = useState(false);
const [permForm, setPermForm] = useState<PermissionCreateRequest>({ name: "", code: "", description: "", category: "" });
const [permAssignOpen, setPermAssignOpen] = useState(false);
const [assigningRole, setAssigningRole] = useState<Role | null>(null);
const [selectedPermIds, setSelectedPermIds] = useState<Set<number>>(new Set());
const rolesQ = useQuery({ queryKey: ["roles", search], queryFn: () => rolesService.listRoles(search ? { search } : undefined) });
const permsQ = useQuery({ queryKey: ["permissions"], queryFn: () => rolesService.listPermissions() });
const roles: Role[] = rolesQ.data?.data ?? [];
const permissions: Permission[] = permsQ.data?.data ?? [];
const createRoleMut = useMutation({
mutationFn: (d: RoleCreateRequest) => rolesService.createRole(d),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["roles"] }); setRoleDialogOpen(false); toast({ title: "Role created" }); },
onError: () => toast({ title: "Failed to create role", variant: "destructive" }),
});
const updateRoleMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<RoleCreateRequest> }) => rolesService.updateRole(id, data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["roles"] }); setRoleDialogOpen(false); setEditingRole(null); toast({ title: "Role updated" }); },
onError: () => toast({ title: "Failed to update role", variant: "destructive" }),
});
const deleteRoleMut = useMutation({
mutationFn: rolesService.deleteRole,
onSuccess: () => { qc.invalidateQueries({ queryKey: ["roles"] }); toast({ title: "Role deleted" }); },
onError: () => toast({ title: "Failed to delete role", variant: "destructive" }),
});
const assignPermsMut = useMutation({
mutationFn: ({ id, permIds }: { id: number; permIds: number[] }) => rolesService.updateRolePermissions(id, permIds),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["roles"] }); setPermAssignOpen(false); toast({ title: "Permissions updated" }); },
onError: () => toast({ title: "Failed to update permissions", variant: "destructive" }),
});
const createPermMut = useMutation({
mutationFn: (d: PermissionCreateRequest) => rolesService.createPermission(d),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["permissions"] }); setPermDialogOpen(false); toast({ title: "Permission created" }); },
onError: () => toast({ title: "Failed to create permission", variant: "destructive" }),
});
const deletePermMut = useMutation({
mutationFn: rolesService.deletePermission,
onSuccess: () => { qc.invalidateQueries({ queryKey: ["permissions"] }); toast({ title: "Permission deleted" }); },
onError: () => toast({ title: "Failed to delete permission", variant: "destructive" }),
});
const loading = rolesQ.isLoading || permsQ.isLoading;
const openNewRole = () => { setEditingRole(null); setRoleForm({ name: "", description: "" }); setRoleDialogOpen(true); };
const openEditRole = (r: Role) => { setEditingRole(r); setRoleForm({ name: r.name, description: r.description }); setRoleDialogOpen(true); };
const openAssignPerms = (r: Role) => { setAssigningRole(r); setSelectedPermIds(new Set(r.permission_ids)); setPermAssignOpen(true); };
const openNewPerm = () => { setPermForm({ name: "", code: "", description: "", category: "" }); setPermDialogOpen(true); };
const handleSaveRole = () => {
if (!roleForm.name.trim()) return;
if (editingRole) {
updateRoleMut.mutate({ id: editingRole.id, data: roleForm });
} else {
createRoleMut.mutate(roleForm);
}
};
const togglePerm = (pid: number) => {
setSelectedPermIds(prev => { const s = new Set(prev); if (s.has(pid)) s.delete(pid); else s.add(pid); return s; });
};
const categorizedPerms = permissions.reduce<Record<string, Permission[]>>((acc, p) => {
const cat = p.category || "General";
(acc[cat] ??= []).push(p);
return acc;
}, {});
const filteredPerms = search
? permissions.filter(p => p.name.toLowerCase().includes(search.toLowerCase()) || p.code.toLowerCase().includes(search.toLowerCase()))
: permissions;
if (loading) {
return <div className="flex items-center justify-center min-h-[400px]"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>;
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Shield className="h-7 w-7 text-primary" />
Roles & Permissions
</h1>
<p className="text-muted-foreground">Manage platform roles and their permission assignments.</p>
</div>
<div className="flex gap-2">
{tab === "roles" ? (
<Button onClick={openNewRole}><Plus className="mr-2 h-4 w-4" /> New Role</Button>
) : (
<Button onClick={openNewPerm}><Plus className="mr-2 h-4 w-4" /> New Permission</Button>
)}
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card className="border-l-4 border-l-primary">
<CardContent className="pt-5 pb-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center"><Shield className="h-5 w-5 text-primary" /></div>
<div><p className="text-2xl font-bold">{roles.length}</p><p className="text-xs text-muted-foreground">Total Roles</p></div>
</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-amber-500">
<CardContent className="pt-5 pb-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-amber-500/10 flex items-center justify-center"><Key className="h-5 w-5 text-amber-500" /></div>
<div><p className="text-2xl font-bold">{permissions.length}</p><p className="text-xs text-muted-foreground">Total Permissions</p></div>
</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-emerald-500">
<CardContent className="pt-5 pb-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-emerald-500/10 flex items-center justify-center"><Users className="h-5 w-5 text-emerald-500" /></div>
<div><p className="text-2xl font-bold">{Object.keys(categorizedPerms).length}</p><p className="text-xs text-muted-foreground">Permission Categories</p></div>
</div>
</CardContent>
</Card>
</div>
{/* Tabs */}
<Tabs value={tab} onValueChange={setTab}>
<div className="flex flex-wrap items-center justify-between gap-3">
<TabsList>
<TabsTrigger value="roles" className="gap-1.5"><Shield className="h-4 w-4" /> Roles ({roles.length})</TabsTrigger>
<TabsTrigger value="permissions" className="gap-1.5"><Key className="h-4 w-4" /> Permissions ({permissions.length})</TabsTrigger>
</TabsList>
<div className="relative max-w-xs w-full">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder={tab === "roles" ? "Search roles..." : "Search permissions..."} className="pl-9" value={search} onChange={e => setSearch(e.target.value)} />
</div>
</div>
{/* ── Roles Tab ── */}
<TabsContent value="roles" className="mt-4">
{roles.length === 0 ? (
<Card><CardContent className="py-16 text-center text-muted-foreground">No roles configured yet. Create one to get started.</CardContent></Card>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{roles.map(r => (
<Card key={r.id} className="group hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className="h-9 w-9 rounded-lg bg-primary/10 flex items-center justify-center">
<Shield className="h-4 w-4 text-primary" />
</div>
<div>
<CardTitle className="text-base">{r.name}</CardTitle>
<CardDescription className="text-xs">{r.user_count} user{r.user_count !== 1 ? "s" : ""} assigned</CardDescription>
</div>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Tooltip><TooltipTrigger asChild>
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={() => openEditRole(r)}><Pencil className="h-3.5 w-3.5" /></Button>
</TooltipTrigger><TooltipContent>Edit</TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild>
<Button size="icon" variant="ghost" className="h-8 w-8 text-destructive" onClick={() => { if (window.confirm(`Delete role "${r.name}"?`)) deleteRoleMut.mutate(r.id); }}><Trash2 className="h-3.5 w-3.5" /></Button>
</TooltipTrigger><TooltipContent>Delete</TooltipContent></Tooltip>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{r.description && <p className="text-sm text-muted-foreground line-clamp-2">{r.description}</p>}
<div className="flex flex-wrap gap-1.5">
{r.permissions.slice(0, 5).map(p => (
<Badge key={p.id} variant="outline" className={`text-[10px] px-1.5 py-0 ${categoryBadgeClass(p.category)}`}>{p.code}</Badge>
))}
{r.permissions.length > 5 && <Badge variant="secondary" className="text-[10px] px-1.5 py-0">+{r.permissions.length - 5}</Badge>}
{r.permissions.length === 0 && <span className="text-xs text-muted-foreground italic">No permissions</span>}
</div>
<Button variant="outline" size="sm" className="w-full mt-2" onClick={() => openAssignPerms(r)}>
<Key className="mr-1.5 h-3.5 w-3.5" /> Manage Permissions <ChevronRight className="ml-auto h-3.5 w-3.5" />
</Button>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
{/* ── Permissions Tab ── */}
<TabsContent value="permissions" className="mt-4">
{Object.entries(categorizedPerms).length === 0 ? (
<Card><CardContent className="py-16 text-center text-muted-foreground">No permissions defined yet.</CardContent></Card>
) : (
<div className="space-y-4">
{Object.entries(categorizedPerms).map(([cat, perms]) => {
const visible = search ? perms.filter(p => filteredPerms.includes(p)) : perms;
if (visible.length === 0) return null;
return (
<Card key={cat}>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Badge className={`${categoryBadgeClass(cat)} font-medium`}>{cat}</Badge>
<span className="text-muted-foreground text-xs font-normal">{visible.length} permission{visible.length !== 1 ? "s" : ""}</span>
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]">Code</TableHead>
<TableHead>Name</TableHead>
<TableHead className="hidden md:table-cell">Description</TableHead>
<TableHead className="w-[80px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map(p => (
<TableRow key={p.id}>
<TableCell><code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono">{p.code}</code></TableCell>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell className="hidden md:table-cell text-muted-foreground text-sm max-w-xs truncate">{p.description || "—"}</TableCell>
<TableCell>
<Button size="icon" variant="ghost" className="h-8 w-8 text-destructive" onClick={() => { if (window.confirm(`Delete permission "${p.code}"?`)) deletePermMut.mutate(p.id); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
})}
</div>
)}
</TabsContent>
</Tabs>
{/* ── Create / Edit Role Dialog ── */}
<Dialog open={roleDialogOpen} onOpenChange={o => { setRoleDialogOpen(o); if (!o) setEditingRole(null); }}>
<DialogContent>
<DialogHeader><DialogTitle>{editingRole ? "Edit Role" : "Create New Role"}</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Role Name</Label><Input placeholder="e.g. Exam Manager" value={roleForm.name} onChange={e => setRoleForm(f => ({ ...f, name: e.target.value }))} /></div>
<div className="space-y-2"><Label>Description</Label><Textarea placeholder="What does this role do?" value={roleForm.description} onChange={e => setRoleForm(f => ({ ...f, description: e.target.value }))} className="h-20" /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRoleDialogOpen(false)}>Cancel</Button>
<Button disabled={!roleForm.name.trim() || createRoleMut.isPending || updateRoleMut.isPending} onClick={handleSaveRole}>
{(createRoleMut.isPending || updateRoleMut.isPending) ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
{editingRole ? "Save Changes" : "Create Role"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Create Permission Dialog ── */}
<Dialog open={permDialogOpen} onOpenChange={setPermDialogOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create Permission</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2"><Label>Name</Label><Input placeholder="View Exams" value={permForm.name} onChange={e => setPermForm(f => ({ ...f, name: e.target.value }))} /></div>
<div className="space-y-2"><Label>Code</Label><Input placeholder="exam.view" value={permForm.code} onChange={e => setPermForm(f => ({ ...f, code: e.target.value }))} /></div>
</div>
<div className="space-y-2"><Label>Category</Label><Input placeholder="exam" value={permForm.category} onChange={e => setPermForm(f => ({ ...f, category: e.target.value }))} /></div>
<div className="space-y-2"><Label>Description</Label><Textarea placeholder="Optional description" value={permForm.description} onChange={e => setPermForm(f => ({ ...f, description: e.target.value }))} className="h-16" /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPermDialogOpen(false)}>Cancel</Button>
<Button disabled={!permForm.name.trim() || !permForm.code.trim() || createPermMut.isPending} onClick={() => createPermMut.mutate(permForm)}>
{createPermMut.isPending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
Create Permission
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Assign Permissions Dialog ── */}
<Dialog open={permAssignOpen} onOpenChange={o => { setPermAssignOpen(o); if (!o) setAssigningRole(null); }}>
<DialogContent className="max-w-2xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
Permissions for "{assigningRole?.name}"
</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto max-h-[55vh] space-y-4 pr-1">
{Object.entries(categorizedPerms).map(([cat, catPerms]) => (
<div key={cat} className="space-y-2">
<div className="flex items-center justify-between">
<Badge className={`${categoryBadgeClass(cat)} font-medium`}>{cat}</Badge>
<Button variant="ghost" size="sm" className="text-xs h-6" onClick={() => {
const allInCat = catPerms.map(p => p.id);
const allSelected = allInCat.every(id => selectedPermIds.has(id));
setSelectedPermIds(prev => {
const s = new Set(prev);
allInCat.forEach(id => allSelected ? s.delete(id) : s.add(id));
return s;
});
}}>
{catPerms.every(p => selectedPermIds.has(p.id)) ? "Deselect All" : "Select All"}
</Button>
</div>
<div className="grid gap-1.5">
{catPerms.map(p => (
<label key={p.id} className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-muted/50 cursor-pointer transition-colors">
<Switch checked={selectedPermIds.has(p.id)} onCheckedChange={() => togglePerm(p.id)} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{p.name}</span>
<code className="text-[10px] bg-muted px-1 rounded font-mono text-muted-foreground">{p.code}</code>
</div>
{p.description && <p className="text-xs text-muted-foreground truncate">{p.description}</p>}
</div>
</label>
))}
</div>
</div>
))}
{permissions.length === 0 && <p className="text-sm text-muted-foreground text-center py-8">No permissions available. Create some first.</p>}
</div>
<DialogFooter>
<div className="flex items-center gap-2 mr-auto text-sm text-muted-foreground">
<Key className="h-4 w-4" /> {selectedPermIds.size} of {permissions.length} selected
</div>
<Button variant="outline" onClick={() => setPermAssignOpen(false)}>Cancel</Button>
<Button disabled={assignPermsMut.isPending} onClick={() => { if (assigningRole) assignPermsMut.mutate({ id: assigningRole.id, permIds: Array.from(selectedPermIds) }); }}>
{assignPermsMut.isPending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
Save Permissions
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,193 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
useBulkRelease,
usePendingScores,
useRejectScore,
useReleaseScore,
} from "@/hooks/queries/useScoreRelease";
import type { PendingScore } from "@/types";
import { toast } from "sonner";
export default function ScoreApprovalQueue() {
const { data, isLoading, refetch } = usePendingScores();
const release = useReleaseScore();
const reject = useRejectScore();
const bulk = useBulkRelease();
const [selected, setSelected] = useState<Set<number>>(new Set());
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectTarget, setRejectTarget] = useState<PendingScore | null>(null);
const [reason, setReason] = useState("");
const rows: PendingScore[] = data?.data ?? [];
const toggle = (attemptId: number) => {
setSelected((prev) => {
const n = new Set(prev);
if (n.has(attemptId)) n.delete(attemptId);
else n.add(attemptId);
return n;
});
};
const toggleAll = (checked: boolean) => {
if (!checked) setSelected(new Set());
else setSelected(new Set(rows.map((r) => r.attempt_id)));
};
const approveOne = (attemptId: number) => {
release.mutate(attemptId, {
onSuccess: () => {
toast.success("Released.");
refetch();
},
onError: (e) => toast.error(e instanceof Error ? e.message : "Failed"),
});
};
const openReject = (row: PendingScore) => {
setRejectTarget(row);
setReason("");
setRejectOpen(true);
};
const submitReject = () => {
if (!rejectTarget) return;
reject.mutate(
{ attemptId: rejectTarget.attempt_id, reason },
{
onSuccess: () => {
toast.success("Rejected.");
setRejectOpen(false);
refetch();
},
onError: (e) => toast.error(e instanceof Error ? e.message : "Failed"),
},
);
};
const bulkApprove = () => {
const ids = [...selected];
if (!ids.length) return;
bulk.mutate(ids, {
onSuccess: () => {
toast.success("Bulk release complete.");
setSelected(new Set());
refetch();
},
onError: (e) => toast.error(e instanceof Error ? e.message : "Failed"),
});
};
return (
<div className="space-y-6 p-6 max-w-6xl mx-auto">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Pending score releases</h1>
<p className="text-muted-foreground text-sm mt-1">Approve before students see results</p>
</div>
<Button onClick={bulkApprove} disabled={!selected.size || bulk.isPending}>
Approve All Selected
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Queue</CardTitle>
<CardDescription>Official and practice attempts awaiting approval</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-muted-foreground py-8 text-center">Loading</p>
) : (
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={rows.length > 0 && selected.size === rows.length}
onCheckedChange={(c) => toggleAll(!!c)}
/>
</TableHead>
<TableHead>Student</TableHead>
<TableHead>Exam</TableHead>
<TableHead>Type</TableHead>
<TableHead>Submitted</TableHead>
<TableHead>Score</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={r.id}>
<TableCell>
<Checkbox
checked={selected.has(r.attempt_id)}
onCheckedChange={() => toggle(r.attempt_id)}
/>
</TableCell>
<TableCell className="font-medium">{r.student_name}</TableCell>
<TableCell>{r.exam_title}</TableCell>
<TableCell className="capitalize">{r.exam_type}</TableCell>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{new Date(r.submitted_at).toLocaleString()}
</TableCell>
<TableCell>{r.overall_score}</TableCell>
<TableCell>{r.status}</TableCell>
<TableCell className="text-right space-x-2 whitespace-nowrap">
<Button size="sm" onClick={() => approveOne(r.attempt_id)} disabled={release.isPending}>
Approve
</Button>
<Button size="sm" variant="destructive" onClick={() => openReject(r)}>
Reject
</Button>
<Button size="sm" variant="outline">
View Details
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
<Dialog open={rejectOpen} onOpenChange={setRejectOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reject release</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="reason">Reason</Label>
<Textarea id="reason" value={reason} onChange={(e) => setReason(e.target.value)} rows={4} />
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRejectOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={submitReject} disabled={reject.isPending}>
Confirm reject
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,238 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
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 { 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 type { Domain, Topic } from "@/types";
export default function TaxonomyManager() {
const { toast } = useToast();
const qc = useQueryClient();
const { data: rawSubjects, isLoading } = useSubjects();
const subjects = Array.isArray(rawSubjects) ? rawSubjects : [];
const [selectedSubjectId, setSelectedSubjectId] = useState<number | null>(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<number | null>(null);
const [newTopicName, setNewTopicName] = useState("");
const [newTopicDifficulty, setNewTopicDifficulty] = useState("medium");
const [newTopicHours, setNewTopicHours] = useState("1");
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("");
},
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<Domain>) => 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<Topic>) => 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 deleteSubjectMutation = useMutation({
mutationFn: (id: number) => taxonomyService.deleteSubject(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["taxonomy"] }); if (selectedSubjectId === id) setSelectedSubjectId(null); toast({ title: "Subject deleted" }); },
onError: () => toast({ title: "Error", description: "Failed to delete subject", variant: "destructive" }),
});
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Taxonomy Manager</h1>
<p className="text-muted-foreground">Manage subjects, domains, topics, and learning objectives.</p>
</div>
<Dialog open={showAddSubject} onOpenChange={setShowAddSubject}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Add Subject</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create New Subject</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Subject Name</Label>
<Input placeholder="e.g. Mathematics" value={newSubjectName} onChange={e => setNewSubjectName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Code</Label>
<Input placeholder="e.g. MATH" value={newSubjectCode} onChange={e => setNewSubjectCode(e.target.value)} />
</div>
<Button className="w-full" onClick={() => createSubjectMutation.mutate()} disabled={createSubjectMutation.isPending || !newSubjectName}>
{createSubjectMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Subject
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground px-1">Subjects</h3>
{subjects.map(s => (
<div key={s.id} className={`w-full text-left p-3 rounded-lg border transition-colors flex items-center gap-2 ${selectedSubjectId === s.id ? "bg-primary/10 border-primary" : "hover:bg-accent"}`}>
<button onClick={() => setSelectedSubjectId(s.id)} className="flex items-center gap-2 flex-1 min-w-0 text-left">
<BookOpen className="h-4 w-4 shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{s.name}</p>
<p className="text-xs text-muted-foreground">{s.code} · {s.domain_count ?? 0} domains · {s.topic_count ?? 0} topics</p>
</div>
</button>
<Button size="sm" variant="ghost" className="text-destructive shrink-0" onClick={() => { if (!window.confirm(`Delete subject "${s.name}"?`)) return; deleteSubjectMutation.mutate(s.id); }}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
{subjects.length === 0 && <p className="text-sm text-muted-foreground px-1">No subjects yet.</p>}
</div>
<div className="md:col-span-3">
{!selectedSubjectId ? (
<Card><CardContent className="py-12 text-center text-muted-foreground">Select a subject to view its taxonomy tree.</CardContent></Card>
) : loadingTree ? (
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
) : tree ? (
<div className="space-y-4">
<div className="flex justify-end">
<Button size="sm" variant="outline" onClick={() => setShowAddDomain(true)}>
<Plus className="h-3 w-3 mr-1" /> Add Domain
</Button>
</div>
{(Array.isArray(tree.domains) ? tree.domains : []).map(domain => (
<Collapsible key={domain.id} defaultOpen>
<Card>
<CardHeader className="pb-3">
<CollapsibleTrigger className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<ChevronRight className="h-4 w-4 transition-transform group-data-[state=open]:rotate-90" />
<Layers className="h-4 w-4 text-primary" />
<CardTitle className="text-base">{domain.name}</CardTitle>
<Badge variant="secondary">{(Array.isArray(domain.topics) ? domain.topics : []).length} topics</Badge>
</div>
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="sm" onClick={() => aiSuggestMutation.mutate(domain.id)} disabled={aiSuggestMutation.isPending}>
<Sparkles className="h-3 w-3 mr-1" /> AI Suggest
</Button>
<Button variant="ghost" size="sm" onClick={() => { setShowAddTopic(domain.id); setNewTopicName(""); }}>
<Plus className="h-3 w-3 mr-1" /> Topic
</Button>
<Button variant="ghost" size="sm" className="text-destructive" onClick={() => { if (!window.confirm(`Delete domain "${domain.name}"?`)) return; deleteDomainMutation.mutate(domain.id); }}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</CollapsibleTrigger>
</CardHeader>
<CollapsibleContent>
<CardContent className="pt-0">
<div className="space-y-2">
{showAddTopic === domain.id && (
<div className="flex items-center gap-2 p-2 rounded border bg-muted/50">
<Input placeholder="Topic name" value={newTopicName} onChange={(e) => setNewTopicName(e.target.value)} className="h-8 text-sm" />
<Select value={newTopicDifficulty} onValueChange={setNewTopicDifficulty}>
<SelectTrigger className="w-28 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="easy">Easy</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="hard">Hard</SelectItem>
</SelectContent>
</Select>
<Input placeholder="Hrs" type="number" value={newTopicHours} onChange={(e) => setNewTopicHours(e.target.value)} className="w-16 h-8 text-sm" />
<Button size="sm" disabled={createTopicMutation.isPending || !newTopicName} onClick={() => createTopicMutation.mutate({ name: newTopicName, domain_id: domain.id, difficulty_level: newTopicDifficulty, estimated_hours: Number(newTopicHours) || 1 })}>Add</Button>
<Button size="sm" variant="ghost" onClick={() => setShowAddTopic(null)}><X className="h-3 w-3" /></Button>
</div>
)}
{(Array.isArray(domain.topics) ? domain.topics : []).map(topic => (
<div key={topic.id} className="flex items-center justify-between p-2 rounded border hover:bg-accent/50">
<div className="flex items-center gap-2">
<FileText className="h-3 w-3 text-muted-foreground" />
<span className="text-sm">{topic.name}</span>
<Badge variant="outline" className="text-xs">{topic.difficulty_level}</Badge>
<span className="text-xs text-muted-foreground">{(Array.isArray(topic.objectives) ? topic.objectives : []).length} objectives</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">~{topic.estimated_hours}h</span>
<Button size="sm" variant="ghost" className="text-destructive h-6 w-6 p-0" onClick={() => { if (!window.confirm(`Delete topic "${topic.name}"?`)) return; deleteTopicMutation.mutate(topic.id); }}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
{(Array.isArray(domain.topics) ? domain.topics : []).length === 0 && !showAddTopic && <p className="text-sm text-muted-foreground py-2">No topics in this domain yet.</p>}
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
))}
{(Array.isArray(tree.domains) ? tree.domains : []).length === 0 && <Card><CardContent className="py-8 text-center text-muted-foreground">No domains defined yet for this subject.</CardContent></Card>}
</div>
) : null}
</div>
</div>
<Dialog open={showAddDomain} onOpenChange={setShowAddDomain}>
<DialogContent>
<DialogHeader><DialogTitle>Add Domain</DialogTitle></DialogHeader>
<div className="space-y-2">
<Label>Domain Name</Label>
<Input placeholder="e.g. Algebra" value={newDomainName} onChange={(e) => setNewDomainName(e.target.value)} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setShowAddDomain(false)}>Cancel</Button>
<Button disabled={createDomainMutation.isPending || !newDomainName || !selectedSubjectId} onClick={() => createDomainMutation.mutate({ name: newDomainName, subject_id: selectedSubjectId! })}>
{createDomainMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} Create
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,383 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
Search, Loader2, UserCog, Shield, Key, Eye, ChevronRight, Users,
} from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { rolesService } from "@/services/roles.service";
import type { UserWithRoles, UserRoleDetail, Role, Permission } from "@/types/role-permission";
const ROLE_COLORS = [
"bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
"bg-violet-100 text-violet-700 dark:bg-violet-900 dark:text-violet-300",
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
"bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
"bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-300",
"bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300",
"bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300",
];
function roleColor(idx: number) {
return ROLE_COLORS[idx % ROLE_COLORS.length];
}
function initials(name: string) {
return name.split(" ").map(w => w[0]).join("").toUpperCase().slice(0, 2);
}
const CATEGORY_BADGE: Record<string, string> = {
exam: "bg-violet-100 text-violet-700",
user: "bg-blue-100 text-blue-700",
entity: "bg-emerald-100 text-emerald-700",
assignment: "bg-amber-100 text-amber-700",
classroom: "bg-rose-100 text-rose-700",
payment: "bg-cyan-100 text-cyan-700",
report: "bg-orange-100 text-orange-700",
};
function catBadge(cat: string) {
return CATEGORY_BADGE[cat.toLowerCase()] ?? "bg-gray-100 text-gray-700";
}
export default function UserRoles() {
const { toast } = useToast();
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState("all");
const [editOpen, setEditOpen] = useState(false);
const [editUserId, setEditUserId] = useState<number | null>(null);
const [editRoleIds, setEditRoleIds] = useState<Set<number>>(new Set());
const [viewOpen, setViewOpen] = useState(false);
const [viewUserId, setViewUserId] = useState<number | null>(null);
const usersQ = useQuery({
queryKey: ["users-with-roles", search, roleFilter],
queryFn: () => rolesService.listUsersWithRoles({
...(search ? { search } : {}),
...(roleFilter !== "all" ? { role_id: Number(roleFilter) } : {}),
size: 200,
}),
});
const rolesQ = useQuery({ queryKey: ["roles"], queryFn: () => rolesService.listRoles() });
const viewQ = useQuery({
queryKey: ["user-roles-detail", viewUserId],
queryFn: () => rolesService.getUserRoles(viewUserId!),
enabled: viewUserId !== null,
});
const users: UserWithRoles[] = usersQ.data?.data ?? [];
const allRoles: Role[] = rolesQ.data?.data ?? [];
const saveRolesMut = useMutation({
mutationFn: ({ userId, roleIds }: { userId: number; roleIds: number[] }) =>
rolesService.updateUserRoles(userId, roleIds),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["users-with-roles"] });
qc.invalidateQueries({ queryKey: ["roles"] });
setEditOpen(false);
toast({ title: "Roles updated successfully" });
},
onError: () => toast({ title: "Failed to update roles", variant: "destructive" }),
});
const loading = usersQ.isLoading || rolesQ.isLoading;
const totalWithRoles = users.filter(u => u.role_ids.length > 0).length;
const totalWithoutRoles = users.filter(u => u.role_ids.length === 0).length;
const openEditRoles = (u: UserWithRoles) => {
setEditUserId(u.id);
setEditRoleIds(new Set(u.role_ids));
setEditOpen(true);
};
const openViewPerms = (u: UserWithRoles) => {
setViewUserId(u.id);
setViewOpen(true);
};
const toggleRole = (rid: number) => {
setEditRoleIds(prev => {
const s = new Set(prev);
if (s.has(rid)) s.delete(rid); else s.add(rid);
return s;
});
};
const handleSave = () => {
if (editUserId !== null) {
saveRolesMut.mutate({ userId: editUserId, roleIds: Array.from(editRoleIds) });
}
};
const viewDetail: UserRoleDetail | undefined = viewQ.data;
const effectivePerms: Permission[] = viewDetail?.effective_permissions ?? [];
const permsByCategory = effectivePerms.reduce<Record<string, Permission[]>>((acc, p) => {
const cat = p.category || "General";
(acc[cat] ??= []).push(p);
return acc;
}, {});
if (loading) {
return <div className="flex items-center justify-center min-h-[400px]"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>;
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<UserCog className="h-7 w-7 text-primary" />
User Roles
</h1>
<p className="text-muted-foreground">Assign roles to users and view their effective permissions.</p>
</div>
</div>
{/* Summary */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-5 pb-4 flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center"><Users className="h-5 w-5 text-primary" /></div>
<div><p className="text-xl font-bold">{users.length}</p><p className="text-xs text-muted-foreground">Total Users</p></div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 pb-4 flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-emerald-500/10 flex items-center justify-center"><Shield className="h-5 w-5 text-emerald-500" /></div>
<div><p className="text-xl font-bold">{totalWithRoles}</p><p className="text-xs text-muted-foreground">With Roles</p></div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 pb-4 flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-amber-500/10 flex items-center justify-center"><UserCog className="h-5 w-5 text-amber-500" /></div>
<div><p className="text-xl font-bold">{totalWithoutRoles}</p><p className="text-xs text-muted-foreground">No Roles</p></div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-5 pb-4 flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-violet-500/10 flex items-center justify-center"><Key className="h-5 w-5 text-violet-500" /></div>
<div><p className="text-xl font-bold">{allRoles.length}</p><p className="text-xs text-muted-foreground">Available Roles</p></div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3">
<div className="relative max-w-xs w-full">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search users..." className="pl-9" value={search} onChange={e => setSearch(e.target.value)} />
</div>
<Select value={roleFilter} onValueChange={setRoleFilter}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Filter by role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Roles</SelectItem>
{allRoles.map(r => (
<SelectItem key={r.id} value={String(r.id)}>{r.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Users Table */}
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Email / Login</TableHead>
<TableHead>Assigned Roles</TableHead>
<TableHead className="text-center">Permissions</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 && (
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-12">No users found.</TableCell></TableRow>
)}
{users.map(u => (
<TableRow key={u.id} className="group">
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs bg-primary/10 text-primary font-medium">{initials(u.name)}</AvatarFallback>
</Avatar>
<span className="font-medium">{u.name}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">{u.email || u.login}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{u.roles.length === 0 && <span className="text-xs text-muted-foreground italic">No roles</span>}
{u.roles.map((r, i) => (
<Badge key={r.id} variant="outline" className={`text-[11px] px-1.5 py-0 ${roleColor(i)}`}>
<Shield className="h-2.5 w-2.5 mr-0.5" />{r.name}
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-center">
<Badge variant="secondary" className="text-xs">{u.effective_permission_count}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Tooltip><TooltipTrigger asChild>
<Button size="sm" variant="ghost" className="h-8 gap-1 text-xs" onClick={() => openViewPerms(u)}>
<Eye className="h-3.5 w-3.5" /> View
</Button>
</TooltipTrigger><TooltipContent>View effective permissions</TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild>
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs" onClick={() => openEditRoles(u)}>
<UserCog className="h-3.5 w-3.5" /> Assign <ChevronRight className="h-3 w-3" />
</Button>
</TooltipTrigger><TooltipContent>Manage role assignments</TooltipContent></Tooltip>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* ── Assign Roles Dialog ── */}
<Dialog open={editOpen} onOpenChange={o => { setEditOpen(o); if (!o) setEditUserId(null); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserCog className="h-5 w-5 text-primary" />
Assign Roles
</DialogTitle>
<CardDescription>
Toggle roles for user: <strong>{users.find(u => u.id === editUserId)?.name}</strong>
</CardDescription>
</DialogHeader>
<div className="space-y-2 max-h-[50vh] overflow-y-auto pr-1">
{allRoles.length === 0 && <p className="text-sm text-muted-foreground text-center py-8">No roles available. Create roles first.</p>}
{allRoles.map((r, i) => (
<label key={r.id} className={`flex items-center gap-3 px-4 py-3 rounded-lg border cursor-pointer transition-all ${
editRoleIds.has(r.id)
? "border-primary/40 bg-primary/5 shadow-sm"
: "border-border hover:border-muted-foreground/30 hover:bg-muted/30"
}`}>
<Switch checked={editRoleIds.has(r.id)} onCheckedChange={() => toggleRole(r.id)} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Badge variant="outline" className={`text-[10px] px-1.5 py-0 ${roleColor(i)}`}>
<Shield className="h-2.5 w-2.5 mr-0.5" />{r.name}
</Badge>
<span className="text-xs text-muted-foreground">{r.permissions.length} permission{r.permissions.length !== 1 ? "s" : ""}</span>
</div>
{r.description && <p className="text-xs text-muted-foreground mt-0.5 truncate">{r.description}</p>}
</div>
</label>
))}
</div>
<DialogFooter>
<div className="flex items-center gap-2 mr-auto text-sm text-muted-foreground">
<Shield className="h-4 w-4" /> {editRoleIds.size} of {allRoles.length} selected
</div>
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
<Button disabled={saveRolesMut.isPending} onClick={handleSave}>
{saveRolesMut.isPending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
Save Roles
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── View Effective Permissions Dialog ── */}
<Dialog open={viewOpen} onOpenChange={o => { setViewOpen(o); if (!o) setViewUserId(null); }}>
<DialogContent className="max-w-2xl max-h-[85vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Key className="h-5 w-5 text-primary" />
Effective Permissions
</DialogTitle>
{viewDetail && (
<CardDescription>
<strong>{viewDetail.user.name}</strong> ({viewDetail.user.login})
</CardDescription>
)}
</DialogHeader>
{viewQ.isLoading ? (
<div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-primary" /></div>
) : viewDetail ? (
<div className="space-y-4 overflow-y-auto max-h-[60vh] pr-1">
{/* Assigned roles summary */}
<div>
<p className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Assigned Roles</p>
<div className="flex flex-wrap gap-1.5">
{viewDetail.roles.filter(r => r.assigned).map((r, i) => (
<Badge key={r.id} variant="outline" className={`${roleColor(i)} text-xs px-2 py-0.5`}>
<Shield className="h-3 w-3 mr-1" />{r.name}
<span className="ml-1 text-[10px] opacity-70">({r.permission_count})</span>
</Badge>
))}
{viewDetail.roles.filter(r => r.assigned).length === 0 && (
<span className="text-sm text-muted-foreground italic">No roles assigned</span>
)}
</div>
</div>
{/* Permissions grouped by category */}
<div>
<p className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">
Effective Permissions ({effectivePerms.length})
</p>
{effectivePerms.length === 0 ? (
<p className="text-sm text-muted-foreground italic py-4 text-center">No permissions (assign roles to grant permissions)</p>
) : (
<div className="space-y-3">
{Object.entries(permsByCategory).map(([cat, perms]) => (
<Card key={cat} className="border-muted">
<CardHeader className="py-2 px-4">
<div className="flex items-center gap-2">
<Badge className={`${catBadge(cat)} font-medium text-[10px]`}>{cat}</Badge>
<span className="text-xs text-muted-foreground">{perms.length}</span>
</div>
</CardHeader>
<CardContent className="px-4 pb-3 pt-0">
<div className="grid gap-1">
{perms.map(p => (
<div key={p.id} className="flex items-center gap-2 py-1">
<div className="h-5 w-5 rounded bg-emerald-500/15 flex items-center justify-center">
<Key className="h-2.5 w-2.5 text-emerald-600" />
</div>
<code className="text-[10px] font-mono bg-muted px-1 rounded text-muted-foreground">{p.code}</code>
<span className="text-xs">{p.name}</span>
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => setViewOpen(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,285 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { queryKeys } from "@/hooks/queries/keys";
import { brandingService } from "@/services/branding.service";
import type { BrandingConfig } from "@/types";
import { toast } from "sonner";
const schema = z.object({
primary_color: z.string(),
secondary_color: z.string(),
background_color: z.string(),
custom_subdomain: z.string().optional(),
login_title: z.string().optional(),
login_description: z.string().optional(),
});
type FormValues = z.infer<typeof schema>;
export default function WhiteLabelBranding() {
const { entityId: eid } = useParams<{ entityId: string }>();
const entityId = Number(eid);
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: queryKeys.branding.get(entityId),
queryFn: () => brandingService.get(entityId),
enabled: Number.isFinite(entityId),
});
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
primary_color: "#2563eb",
secondary_color: "#64748b",
background_color: "#f8fafc",
custom_subdomain: "",
login_title: "",
login_description: "",
},
});
useEffect(() => {
if (data) {
form.reset({
primary_color: data.primary_color,
secondary_color: data.secondary_color,
background_color: data.background_color,
custom_subdomain: data.custom_subdomain ?? "",
login_title: data.login_title ?? "",
login_description: data.login_description ?? "",
});
}
}, [data, form]);
const [logoUrl, setLogoUrl] = useState<string | undefined>(undefined);
const [faviconUrl, setFaviconUrl] = useState<string | undefined>(undefined);
useEffect(() => {
setLogoUrl(data?.logo_url);
setFaviconUrl(data?.favicon_url);
}, [data?.logo_url, data?.favicon_url]);
const saveMut = useMutation({
mutationFn: async (values: FormValues) => {
const payload: BrandingConfig = {
...values,
logo_url: logoUrl,
favicon_url: faviconUrl,
};
return brandingService.update(entityId, payload);
},
onSuccess: () => {
toast.success("Branding saved.");
qc.invalidateQueries({ queryKey: queryKeys.branding.get(entityId) });
},
onError: (e) => toast.error(e instanceof Error ? e.message : "Save failed"),
});
const resetMut = useMutation({
mutationFn: () => brandingService.resetToDefault(entityId),
onSuccess: () => {
toast.success("Defaults restored.");
qc.invalidateQueries({ queryKey: queryKeys.branding.get(entityId) });
},
onError: (e) => toast.error(e instanceof Error ? e.message : "Reset failed"),
});
const onLogo = (file: File | null) => {
if (!file) return;
brandingService.uploadLogo(entityId, file).then((r) => setLogoUrl(r.url)).catch((e) => toast.error(String(e)));
};
const onFavicon = (file: File | null) => {
if (!file) return;
brandingService
.uploadFavicon(entityId, file)
.then((r) => setFaviconUrl(r.url))
.catch((e) => toast.error(String(e)));
};
const v = form.watch();
if (!Number.isFinite(entityId)) {
return <div className="p-6 text-destructive">Invalid entity.</div>;
}
if (isLoading && !data) {
return <div className="p-6 text-muted-foreground">Loading branding</div>;
}
return (
<div className="space-y-6 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">White-label branding</h1>
<p className="text-muted-foreground text-sm mt-1">Entity #{entityId}</p>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Brand assets</CardTitle>
<CardDescription>Logo, favicon, palette, and login copy</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit((vals) => saveMut.mutate(vals))}
className="space-y-4"
>
<div className="space-y-2">
<FormLabel>Logo</FormLabel>
<Input type="file" accept="image/*" onChange={(e) => onLogo(e.target.files?.[0] ?? null)} />
{logoUrl ? (
<img src={logoUrl} alt="" className="h-12 object-contain" />
) : null}
</div>
<div className="space-y-2">
<FormLabel>Favicon</FormLabel>
<Input type="file" accept="image/*" onChange={(e) => onFavicon(e.target.files?.[0] ?? null)} />
{faviconUrl ? (
<img src={faviconUrl} alt="" className="h-8 w-8 object-contain" />
) : null}
</div>
<FormField
control={form.control}
name="primary_color"
render={({ field }) => (
<FormItem>
<FormLabel>Primary</FormLabel>
<div className="flex gap-2">
<FormControl>
<Input type="color" className="h-10 w-14 p-1" {...field} />
</FormControl>
<Input {...field} />
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secondary_color"
render={({ field }) => (
<FormItem>
<FormLabel>Secondary</FormLabel>
<div className="flex gap-2">
<FormControl>
<Input type="color" className="h-10 w-14 p-1" {...field} />
</FormControl>
<Input {...field} />
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="background_color"
render={({ field }) => (
<FormItem>
<FormLabel>Background</FormLabel>
<div className="flex gap-2">
<FormControl>
<Input type="color" className="h-10 w-14 p-1" {...field} />
</FormControl>
<Input {...field} />
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="custom_subdomain"
render={({ field }) => (
<FormItem>
<FormLabel>Subdomain</FormLabel>
<FormControl>
<Input placeholder="your-school" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="login_title"
render={({ field }) => (
<FormItem>
<FormLabel>Login title</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="login_description"
render={({ field }) => (
<FormItem>
<FormLabel>Login description</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2 pt-2">
<Button type="submit" disabled={saveMut.isPending}>
Save Branding
</Button>
<Button type="button" variant="outline" onClick={() => resetMut.mutate()} disabled={resetMut.isPending}>
Reset to Default
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
<Card className="lg:sticky lg:top-6 h-fit">
<CardHeader>
<CardTitle>Live preview</CardTitle>
<CardDescription>Mock login with current colors</CardDescription>
</CardHeader>
<CardContent>
<div
className="rounded-xl border shadow-sm p-8 min-h-[320px] flex flex-col items-center justify-center gap-4"
style={{ backgroundColor: v.background_color }}
>
{logoUrl ? <img src={logoUrl} alt="" className="h-10 object-contain" /> : null}
<h2 className="text-xl font-semibold text-center" style={{ color: v.primary_color }}>
{v.login_title || data?.entity_name || "Sign in"}
</h2>
<p className="text-sm text-center max-w-sm" style={{ color: v.secondary_color }}>
{v.login_description || "Access your learning workspace."}
</p>
<div
className="w-full max-w-xs space-y-3 rounded-lg border bg-background/80 p-4 backdrop-blur"
style={{ borderColor: v.secondary_color }}
>
<Input placeholder="Email" disabled className="bg-background" />
<Input type="password" placeholder="Password" disabled className="bg-background" />
<Button className="w-full" style={{ backgroundColor: v.primary_color }}>
Continue
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,291 @@
import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import {
BookOpen,
CheckCircle2,
CircleDot,
Lock,
Sparkles,
TrendingUp,
Clock,
Percent,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { useAiCourse, useAiCourseTracks, useCreateEnglishCourse } from "@/hooks/queries/useAiCourse";
import { queryKeys } from "@/hooks/queries/keys";
import type { AICourseTrack, AIGenerationStep, AITrackModule } from "@/types";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
} from "recharts";
import { toast } from "sonner";
const DEFAULT_STEPS: AIGenerationStep[] = [
{ name: "Profile Analysis", status: "complete" },
{ name: "Reading Content", status: "in_progress" },
{ name: "Grammar Exercises", status: "pending" },
{ name: "Speaking Prompts", status: "pending" },
{ name: "Vocabulary Sets", status: "pending" },
];
const COLORS = ["hsl(var(--primary))", "hsl(var(--chart-2))", "hsl(var(--chart-3))"];
export default function AiEnglishCourse() {
const { courseId: cid } = useParams<{ courseId: string }>();
const courseId = Number(cid);
const qc = useQueryClient();
const { data: course, isLoading } = useAiCourse(Number.isFinite(courseId) ? courseId : undefined);
const { data: tracksRaw } = useAiCourseTracks(Number.isFinite(courseId) ? courseId : undefined);
const createEnglish = useCreateEnglishCourse();
const [target, setTarget] = useState("");
const [learningStyle, setLearningStyle] = useState("");
const [levelUpOpen, setLevelUpOpen] = useState(false);
const steps = course?.generation_steps?.length ? course.generation_steps : DEFAULT_STEPS;
const stepProgress = useMemo(() => {
const done = steps.filter((s) => s.status === "complete").length;
return Math.round((done / Math.max(steps.length, 1)) * 100);
}, [steps]);
const tracks: AICourseTrack[] = useMemo(() => {
if (tracksRaw?.length) return tracksRaw;
return [
{ name: "Grammar Track", progress_percent: 40, modules: placeholderModules("grammar") },
{ name: "Skills Track", progress_percent: 55, modules: placeholderModules("skills") },
{ name: "Vocabulary Track", progress_percent: 20, modules: placeholderModules("vocab") },
];
}, [tracksRaw]);
const cefrChart = [
{ label: "Start", value: 3.2 },
{ label: "Now", value: 4.1 },
{ label: "Target", value: 5.0 },
];
const timePerTrack = tracks.map((t) => ({ name: t.name.replace(" Track", ""), hours: t.progress_percent / 10 }));
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[320px] text-muted-foreground">
Loading course
</div>
);
}
const ls = learningStyle || course?.learning_style?.join(", ") || "visual, mixed";
const tgt = target || course?.target_level || "B2";
return (
<div className="space-y-8 p-6 max-w-7xl mx-auto">
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">AI English course</h1>
<p className="text-muted-foreground text-sm">Personalized pathway to your target level</p>
</div>
<Button onClick={() => setLevelUpOpen(true)} variant="secondary" size="sm" className="gap-2">
<Sparkles className="h-4 w-4" />
Preview celebration
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Profile confirmation</CardTitle>
<CardDescription>Adjust targets before continuing.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div>
<Label className="text-muted-foreground">Current CEFR</Label>
<p className="text-lg font-semibold mt-1">{course?.current_level ?? "B1"}</p>
</div>
<div className="space-y-2">
<Label htmlFor="target">Target CEFR</Label>
<Input id="target" value={tgt} onChange={(e) => setTarget(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="style">Learning style</Label>
<Input id="style" value={ls} onChange={(e) => setLearningStyle(e.target.value)} />
</div>
<div>
<Label className="text-muted-foreground">Duration (weeks)</Label>
<p className="text-lg font-semibold mt-1">{course?.estimated_duration_weeks ?? 8}</p>
</div>
</CardContent>
<CardContent className="pt-0">
<Button
disabled={createEnglish.isPending}
onClick={() => {
if (!course) return;
const styles =
ls.split(",")
.map((x) => x.trim())
.filter(Boolean).length > 0
? ls.split(",")
.map((x) => x.trim())
.filter(Boolean)
: course?.learning_style ?? ["visual"];
createEnglish.mutate(
{ current_level: course?.current_level ?? "B1", target_level: tgt, learning_style: styles },
{
onSuccess: () => {
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.course(courseId) });
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.tracks(courseId) });
toast.success("AI course started.");
},
onError: (e) => toast.error(e instanceof Error ? e.message : "Could not start"),
},
);
}}
>
Start AI Course
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Generation progress</CardTitle>
<CardDescription>Content pipeline for this course</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between text-sm">
<span>Overall</span>
<span>{stepProgress}%</span>
</div>
<Progress value={stepProgress} />
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
{steps.map((s) => (
<div key={s.name} className="rounded-lg border p-3 text-sm">
<div className="font-medium leading-tight">{s.name}</div>
<div className="text-muted-foreground capitalize mt-1">{s.status.replace("_", " ")}</div>
</div>
))}
</div>
</CardContent>
</Card>
<div>
<h2 className="text-lg font-semibold mb-4">Learning tracks</h2>
<div className="grid gap-4 lg:grid-cols-3">
{tracks.map((track) => (
<Card key={track.name}>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<BookOpen className="h-4 w-4" />
{track.name}
</CardTitle>
<CardDescription>{track.progress_percent}% complete</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Progress value={track.progress_percent} />
<Separator className="my-2" />
<ul className="space-y-2">
{track.modules.map((m) => (
<li key={m.id} className="flex items-start gap-2 text-sm">
<ModuleIcon status={m.status} />
<div>
<div className="font-medium">{m.title}</div>
<div className="text-xs text-muted-foreground">~{m.estimated_hours}h</div>
</div>
</li>
))}
</ul>
</CardContent>
</Card>
))}
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Progress report</CardTitle>
<CardDescription>CEFR trajectory and effort</CardDescription>
</CardHeader>
<CardContent className="grid gap-8 lg:grid-cols-2">
<div className="h-64">
<div className="flex items-center gap-2 text-sm font-medium mb-2">
<TrendingUp className="h-4 w-4" />
CEFR gain
</div>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={cefrChart}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="label" />
<YAxis domain={[0, 6]} />
<Tooltip />
<Bar dataKey="value" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="h-64">
<div className="flex items-center gap-2 text-sm font-medium mb-2">
<Clock className="h-4 w-4" />
Time per track (relative)
</div>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={timePerTrack} dataKey="hours" nameKey="name" cx="50%" cy="50%" outerRadius={80}>
{timePerTrack.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div className="lg:col-span-2 flex items-center gap-2 text-sm text-muted-foreground">
<Percent className="h-4 w-4" />
Completion rates follow each track&apos;s module checklist above.
</div>
</CardContent>
</Card>
<Dialog open={levelUpOpen} onOpenChange={setLevelUpOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-amber-500" />
Level up!
</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
You reached <strong>{course?.target_level ?? tgt}</strong>. The next modules unlock with richer tasks and feedback.
</p>
<DialogFooter>
<Button onClick={() => setLevelUpOpen(false)}>Continue</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function ModuleIcon({ status }: { status: AITrackModule["status"] }) {
if (status === "completed") return <CheckCircle2 className="h-4 w-4 text-green-600 shrink-0 mt-0.5" />;
if (status === "in_progress") return <CircleDot className="h-4 w-4 text-primary shrink-0 mt-0.5" />;
return <Lock className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />;
}
function placeholderModules(prefix: string): AITrackModule[] {
return [
{ id: 1, title: `${prefix} — Foundations`, status: "completed", estimated_hours: 2 },
{ id: 2, title: `${prefix} — Practice set`, status: "in_progress", estimated_hours: 3 },
{ id: 3, title: `${prefix} — Mastery`, status: "locked", estimated_hours: 4 },
];
}

View File

@@ -0,0 +1,317 @@
import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import {
Headphones,
Mic,
BookMarked,
PenLine,
CheckCircle2,
CircleDot,
Lock,
ClipboardCheck,
Award,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { useAiCourse, useAiCourseTracks, useCreateIeltsCourse } from "@/hooks/queries/useAiCourse";
import { queryKeys } from "@/hooks/queries/keys";
import type { AICourseTrack, AIGenerationStep, AITrackModule } from "@/types";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
LineChart,
Line,
} from "recharts";
import { toast } from "sonner";
const SKILL_ICONS = [PenLine, BookMarked, Mic, Headphones];
export default function AiIeltsCourse() {
const { courseId: cid } = useParams<{ courseId: string }>();
const courseId = Number(cid);
const qc = useQueryClient();
const { data: course, isLoading } = useAiCourse(Number.isFinite(courseId) ? courseId : undefined);
const { data: tracksRaw } = useAiCourseTracks(Number.isFinite(courseId) ? courseId : undefined);
const createIelts = useCreateIeltsCourse();
const [targetBand, setTargetBand] = useState("");
const [readinessOpen, setReadinessOpen] = useState(false);
const skillsRanked =
course?.skills_ranked_by_gap && course.skills_ranked_by_gap.length > 0
? course.skills_ranked_by_gap
: [
{ skill: "Writing", gap: 1.2 },
{ skill: "Speaking", gap: 0.9 },
{ skill: "Reading", gap: 0.4 },
{ skill: "Listening", gap: 0.3 },
];
const weakTypes = course?.weak_question_types ?? {
Writing: ["Task 2 argument depth"],
Reading: ["True/False/Not Given"],
Speaking: ["Part 2 long turn"],
Listening: ["Form completion"],
};
const skillPanels: { skill: string; steps: AIGenerationStep[]; progress_percent: number }[] =
course?.ielts_generation_by_skill?.length
? course.ielts_generation_by_skill
: ["Writing", "Reading", "Speaking", "Listening"].map((skill, i) => ({
skill,
steps: [
{ name: "Blueprint", status: i === 0 ? ("in_progress" as const) : ("pending" as const) },
{ name: "Items", status: "pending" as const },
{ name: "Rubric alignment", status: "pending" as const },
],
progress_percent: [35, 20, 10, 5][i] ?? 0,
}));
const tracks: AICourseTrack[] = useMemo(() => {
if (tracksRaw?.length) return tracksRaw;
return ["Writing", "Reading", "Speaking", "Listening"].map((name, ti) => ({
name: `${name} skill`,
progress_percent: 15 + ti * 8,
modules: ieltsModules(name),
}));
}, [tracksRaw]);
const bandSeries = [
{ label: "L", v: course?.current_bands?.Listening ?? 6.0 },
{ label: "R", v: course?.current_bands?.Reading ?? 6.5 },
{ label: "W", v: course?.current_bands?.Writing ?? 5.5 },
{ label: "S", v: course?.current_bands?.Speaking ?? 6.0 },
];
if (isLoading) {
return <div className="flex justify-center py-24 text-muted-foreground">Loading</div>;
}
const tgt = targetBand || String(course?.target_level ?? "6.5");
return (
<div className="space-y-8 p-6 max-w-7xl mx-auto">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">AI IELTS course</h1>
<p className="text-muted-foreground text-sm">Band-focused practice generated for you</p>
</div>
<Button variant="secondary" size="sm" onClick={() => setReadinessOpen(true)}>
<ClipboardCheck className="h-4 w-4 mr-2" />
Practice exam readiness
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>IELTS gap profile</CardTitle>
<CardDescription>Exam context and targets</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div>
<Label className="text-muted-foreground">Exam type</Label>
<p className="text-lg font-semibold mt-1">{course?.exam_type ?? "Academic"}</p>
</div>
<div>
<Label className="text-muted-foreground">Current bands</Label>
<p className="text-sm mt-1">
{Object.entries(course?.current_bands ?? { L: 6, R: 6.5, W: 5.5, S: 6 })
.map(([k, v]) => `${k}: ${v}`)
.join(" · ")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="tgt">Target band</Label>
<Input id="tgt" value={tgt} onChange={(e) => setTargetBand(e.target.value)} />
</div>
<div>
<Label className="text-muted-foreground">Ranked by gap</Label>
<ol className="mt-2 text-sm list-decimal list-inside space-y-1">
{skillsRanked.map((s) => (
<li key={s.skill}>
{s.skill} (Δ {s.gap.toFixed(1)})
</li>
))}
</ol>
</div>
</CardContent>
<CardContent className="pt-0 space-y-3">
<Label className="text-muted-foreground">Weak question types</Label>
<div className="grid sm:grid-cols-2 gap-3 text-sm">
{Object.entries(weakTypes).map(([skill, items]) => (
<div key={skill} className="rounded-md border p-3">
<div className="font-medium mb-1">{skill}</div>
<ul className="list-disc list-inside text-muted-foreground">
{items.map((x) => (
<li key={x}>{x}</li>
))}
</ul>
</div>
))}
</div>
<Button
disabled={createIelts.isPending}
onClick={() => {
const band = Number(targetBand || course?.target_level || 7);
createIelts.mutate(
{
exam_type: course?.exam_type ?? "academic",
target_band: Number.isFinite(band) ? band : 7,
skills: skillsRanked.map((s) => s.skill),
},
{
onSuccess: () => {
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.course(courseId) });
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.tracks(courseId) });
toast.success("AI IELTS course started.");
},
onError: (e) => toast.error(e instanceof Error ? e.message : "Could not start"),
},
);
}}
>
Start AI IELTS Course
</Button>
</CardContent>
</Card>
<div>
<h2 className="text-lg font-semibold mb-4">Per-skill generation</h2>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{skillPanels.map((panel, idx) => {
const Icon = SKILL_ICONS[idx % SKILL_ICONS.length];
return (
<Card key={panel.skill}>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Icon className="h-4 w-4" />
{panel.skill}
</CardTitle>
<CardDescription>{panel.progress_percent}%</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Progress value={panel.progress_percent} />
<ul className="text-sm space-y-1">
{panel.steps.map((s) => (
<li key={s.name} className="flex justify-between gap-2">
<span>{s.name}</span>
<span className="text-muted-foreground capitalize">{s.status.replace("_", " ")}</span>
</li>
))}
</ul>
</CardContent>
</Card>
);
})}
</div>
</div>
<div>
<h2 className="text-lg font-semibold mb-4">Skill columns</h2>
<div className="grid gap-4 lg:grid-cols-4">
{tracks.map((track) => (
<Card key={track.name}>
<CardHeader className="pb-2">
<CardTitle className="text-sm">{track.name}</CardTitle>
<CardDescription>{track.progress_percent}%</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Progress value={track.progress_percent} />
<Separator />
<ul className="space-y-2 text-sm">
{track.modules.map((m) => (
<li key={m.id} className="flex gap-2">
<ModIcon st={m.status} />
<span>{m.title}</span>
</li>
))}
</ul>
</CardContent>
</Card>
))}
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Award className="h-5 w-5" />
Band improvement report
</CardTitle>
<CardDescription>Current skill snapshot vs trajectory</CardDescription>
</CardHeader>
<CardContent className="grid gap-8 lg:grid-cols-2">
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={bandSeries}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="label" />
<YAxis domain={[0, 9]} />
<Tooltip />
<Bar dataKey="v" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={[
{ w: 1, b: 5.6 },
{ w: 2, b: 6.1 },
{ w: 3, b: 6.4 },
{ w: 4, b: 6.8 },
]}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="w" />
<YAxis domain={[5, 8]} />
<Tooltip />
<Line type="monotone" dataKey="b" stroke="hsl(var(--primary))" strokeWidth={2} dot />
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Dialog open={readinessOpen} onOpenChange={setReadinessOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Practice exam readiness</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
When each skill column reaches the practice threshold, you unlock a full timed paper with authentic pacing and
AI feedback.
</p>
<DialogFooter>
<Button onClick={() => setReadinessOpen(false)}>Got it</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function ModIcon({ st }: { st: AITrackModule["status"] }) {
if (st === "completed") return <CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />;
if (st === "in_progress") return <CircleDot className="h-4 w-4 text-primary shrink-0" />;
return <Lock className="h-4 w-4 text-muted-foreground shrink-0" />;
}
function ieltsModules(skill: string): AITrackModule[] {
return [
{ id: 1, title: `${skill} — Drill set A`, status: "completed", estimated_hours: 2 },
{ id: 2, title: `${skill} — Drill set B`, status: "in_progress", estimated_hours: 2 },
{ id: 3, title: `${skill} — Exam-style pack`, status: "locked", estimated_hours: 3 },
];
}

View File

@@ -0,0 +1,204 @@
import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useCourseDetail, useTrackProgress } from "@/hooks/queries/useCourseGeneration";
import type { ModuleConfig, ModuleResource } from "@/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
BookOpen,
Check,
ChevronDown,
FileText,
Headphones,
Lock,
Sparkles,
Video,
} from "lucide-react";
import { cn } from "@/lib/utils";
function ResourceIcon({ type }: { type: ModuleResource["type"] }) {
switch (type) {
case "pdf":
return <FileText className="h-4 w-4" />;
case "video":
return <Video className="h-4 w-4" />;
case "audio":
return <Headphones className="h-4 w-4" />;
default:
return <BookOpen className="h-4 w-4" />;
}
}
export default function CourseDelivery() {
const { courseId: courseIdParam } = useParams();
const courseId = Number(courseIdParam);
const { data: course, isLoading } = useCourseDetail(courseId);
const track = useTrackProgress();
const [activeResource, setActiveResource] = useState<{ module: ModuleConfig; resource: ModuleResource } | null>(null);
const bySkill = useMemo(() => {
const map = new Map<string, ModuleConfig[]>();
course?.modules.forEach((m) => {
const k = m.skill || "General";
if (!map.has(k)) map.set(k, []);
map.get(k)!.push(m);
});
return map;
}, [course]);
const overallProgress = useMemo(() => {
if (!course?.modules.length) return 0;
const sum = course.modules.reduce((s, m) => s + (m.progress_percent ?? 0), 0);
return Math.round(sum / course.modules.length);
}, [course]);
const weeksLeft = course ? Math.max(0, course.duration_weeks - 2) : 0;
const markResource = (mod: ModuleConfig, res: ModuleResource) => {
track.mutate({
courseId,
progress: {
resource_id: res.id,
completion_percent: 100,
time_spent_ms: 60000,
},
});
setActiveResource({ module: mod, resource: res });
};
if (isLoading) {
return (
<div className="p-6">
<Skeleton className="mb-4 h-12 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
return (
<div className="mx-auto max-w-5xl space-y-8 p-6">
<Card>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<CardTitle className="text-2xl">{course?.title ?? `Course #${courseId}`}</CardTitle>
<CardDescription>
{course?.exam_type ?? "IELTS"} · Target {course?.target_band ?? course?.target_level ?? "—"}
</CardDescription>
</div>
<Badge variant="secondary">{course?.progression_model ?? "linear"}</Badge>
</div>
<div className="mt-4 space-y-2">
<div className="flex justify-between text-sm">
<span>Overall progress</span>
<span>{overallProgress}%</span>
</div>
<Progress value={overallProgress} />
<p className="text-sm text-muted-foreground">About {weeksLeft} weeks remaining at your current pace</p>
</div>
</CardHeader>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-4">
{Array.from(bySkill.entries()).map(([skill, mods]) => (
<Collapsible key={skill} defaultOpen>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="flex cursor-pointer flex-row items-center justify-between py-3">
<CardTitle className="text-base">{skill}</CardTitle>
<ChevronDown className="h-5 w-5" />
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4 pt-0">
{mods.map((mod) => (
<div key={mod.id} className="rounded-lg border p-4">
<div className="mb-2 flex flex-wrap items-center gap-2">
<h3 className="font-semibold">{mod.title}</h3>
{mod.status === "locked" ? <Lock className="h-4 w-4 text-muted-foreground" /> : null}
{mod.id === course?.modules.find((m) => m.status === "in_progress")?.id ? (
<Badge>Recommended Next</Badge>
) : null}
{mod.status === "skipped" ? <Badge variant="outline">Skipped</Badge> : null}
</div>
<div className="mb-2 text-xs text-muted-foreground">
{mod.estimated_hours}h · {mod.cefr_target ?? "CEFR target"}
</div>
<Progress value={mod.progress_percent ?? 0} className="mb-3 h-2" />
<div className="space-y-2">
{mod.resources.map((r) => (
<button
key={r.id}
type="button"
onClick={() => markResource(mod, r)}
className="flex w-full items-center justify-between rounded-md border bg-background px-3 py-2 text-left text-sm hover:bg-muted/50"
>
<span className="flex items-center gap-2">
<ResourceIcon type={r.type} />
{r.title}
</span>
{r.completed ? <Check className="h-4 w-4 text-green-600" /> : null}
</button>
))}
</div>
<div className="mt-3 flex gap-2">
<Button type="button" size="sm" disabled={mod.status === "locked"}>
{mod.status === "in_progress" || mod.progress_percent ? "Continue" : "Start"}
</Button>
</div>
</div>
))}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
))}
</div>
<Card className="min-h-[320px]">
<CardHeader>
<CardTitle className="text-base">Resource viewer</CardTitle>
<CardDescription>
{activeResource ? activeResource.resource.title : "Select a resource to open the viewer"}
</CardDescription>
</CardHeader>
<CardContent>
{!activeResource ? (
<p className="text-sm text-muted-foreground">PDF, video, audio, and exercise viewers render here.</p>
) : (
<div className="space-y-3">
<div
className={cn(
"flex min-h-[200px] items-center justify-center rounded-lg border bg-muted/30 p-6 text-center text-sm text-muted-foreground",
)}
>
{activeResource.resource.type === "pdf" && "PDF viewer placeholder"}
{activeResource.resource.type === "video" && "Video player placeholder"}
{activeResource.resource.type === "audio" && "Audio player placeholder"}
{activeResource.resource.type === "exercise" && "Interactive exercise placeholder"}
{activeResource.resource.type === "ai_generated" && (
<span className="flex items-center gap-2">
<Sparkles className="h-4 w-4" /> AI-generated content placeholder
</span>
)}
</div>
<Separator />
<ScrollArea className="h-32 text-xs text-muted-foreground">
Module: {activeResource.module.title}. Completion: {activeResource.module.completion_criteria}.
</ScrollArea>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Loader2, CheckCircle2, ArrowRight, Clock } from "lucide-react";
import { useStartDiagnostic, useAnswerDiagnostic } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { DiagnosticQuestion } from "@/types";
export default function DiagnosticTest() {
const { subjectId } = useParams<{ subjectId: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const startMutation = useStartDiagnostic();
const answerMutation = useAnswerDiagnostic();
const [sessionId, setSessionId] = useState<number | null>(null);
const [currentQuestion, setCurrentQuestion] = useState<DiagnosticQuestion | null>(null);
const [selectedAnswer, setSelectedAnswer] = useState("");
const [questionIndex, setQuestionIndex] = useState(0);
const [totalQuestions, setTotalQuestions] = useState(0);
const [completed, setCompleted] = useState(false);
const handleStart = () => {
startMutation.mutate(Number(subjectId), {
onSuccess: (data) => {
setSessionId(data.session.id);
setCurrentQuestion(data.first_question);
setTotalQuestions(data.session.total_questions);
setQuestionIndex(1);
},
onError: () => toast({ title: "Error", description: "Failed to start diagnostic", variant: "destructive" }),
});
};
const handleAnswer = () => {
if (!sessionId || !currentQuestion || !selectedAnswer) return;
answerMutation.mutate(
{ session_id: sessionId, question_id: currentQuestion.id, answer: selectedAnswer },
{
onSuccess: (data) => {
setSelectedAnswer("");
if (data.completed) {
setCompleted(true);
} else if (data.next_question) {
setCurrentQuestion(data.next_question);
setQuestionIndex((i) => i + 1);
}
},
onError: () => toast({ title: "Error", description: "Failed to submit answer", variant: "destructive" }),
}
);
};
if (completed) {
return (
<div className="max-w-lg mx-auto text-center py-16 space-y-6">
<CheckCircle2 className="h-16 w-16 text-green-500 mx-auto" />
<h1 className="text-2xl font-bold">Diagnostic Complete!</h1>
<p className="text-muted-foreground">Your proficiency profile has been generated.</p>
<div className="flex gap-3 justify-center">
<Button onClick={() => navigate(`/student/proficiency/${subjectId}`)}>View Proficiency</Button>
<Button variant="outline" onClick={() => navigate(`/student/plan/${subjectId}`)}>See Learning Plan</Button>
</div>
</div>
);
}
if (!sessionId) {
return (
<div className="max-w-lg mx-auto text-center py-16 space-y-6">
<Clock className="h-16 w-16 text-primary mx-auto opacity-60" />
<h1 className="text-2xl font-bold">Diagnostic Assessment</h1>
<p className="text-muted-foreground">This adaptive test will assess your current knowledge across all topics. Questions adapt to your performance in real time.</p>
<Button size="lg" onClick={handleStart} disabled={startMutation.isPending}>
{startMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Begin Assessment
</Button>
</div>
);
}
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="space-y-2">
<div className="flex justify-between text-sm text-muted-foreground">
<span>Question {questionIndex} of ~{totalQuestions}</span>
<Badge variant="outline">{currentQuestion?.difficulty}</Badge>
</div>
<Progress value={(questionIndex / totalQuestions) * 100} className="h-2" />
</div>
{currentQuestion && (
<Card>
<CardHeader>
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<span>{currentQuestion.domain_name}</span>
<span>·</span>
<span>{currentQuestion.topic_name}</span>
</div>
<CardTitle className="text-lg">{currentQuestion.question_text}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{currentQuestion.options && (
<RadioGroup value={selectedAnswer} onValueChange={setSelectedAnswer}>
{currentQuestion.options.map((opt, i) => (
<div key={i} className="flex items-center space-x-3 rounded-lg border p-3 hover:bg-accent/50 transition-colors">
<RadioGroupItem value={opt} id={`opt-${i}`} />
<Label htmlFor={`opt-${i}`} className="flex-1 cursor-pointer">{opt}</Label>
</div>
))}
</RadioGroup>
)}
<Button className="w-full" onClick={handleAnswer} disabled={!selectedAnswer || answerMutation.isPending}>
{answerMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Submit Answer <ArrowRight className="ml-1 h-4 w-4" />
</Button>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,178 @@
import { Link, useParams, useSearchParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import {
Radar,
RadarChart,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
ResponsiveContainer,
} from "recharts";
import { Award, BookOpen, Download, RefreshCw } from "lucide-react";
const SKILLS = [
{ skill: "Listening", band: 7.5, cefr: "C1", gap: 0.5, target: 8 },
{ skill: "Reading", band: 8, cefr: "C1", gap: 0, target: 8 },
{ skill: "Writing", band: 6.5, cefr: "B2", gap: 1.5, target: 8 },
{ skill: "Speaking", band: 7, cefr: "C1", gap: 1, target: 8 },
];
const RADAR_DATA = SKILLS.map((s) => ({ skill: s.skill, band: s.band }));
export default function ExamResults() {
const { examId } = useParams();
const [searchParams] = useSearchParams();
const practice = searchParams.get("mode") === "practice";
const overall = 7.5;
const cefr = "C1";
const passed = overall >= 7;
return (
<div className="mx-auto max-w-5xl space-y-8 p-6">
<div className="text-center">
<p className="text-sm text-muted-foreground">Your overall result</p>
<div className="mt-2 flex items-center justify-center gap-3">
<Award className="h-12 w-12 text-primary" />
<span className="text-5xl font-bold tabular-nums">{overall}</span>
<Badge variant="secondary" className="text-lg">
Band
</Badge>
</div>
<p className="mt-2 text-lg text-muted-foreground">CEFR equivalent: {cefr}</p>
{practice ? <Badge className="mt-2">Practice mode</Badge> : null}
</div>
<Card>
<CardHeader>
<CardTitle>Skill profile</CardTitle>
<CardDescription>Per-skill performance vs maximum scale</CardDescription>
</CardHeader>
<CardContent>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={RADAR_DATA} cx="50%" cy="50%" outerRadius="80%">
<PolarGrid />
<PolarAngleAxis dataKey="skill" />
<PolarRadiusAxis angle={30} domain={[0, 9]} tickCount={6} />
<Radar name="Band" dataKey="band" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.35} />
</RadarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Skills breakdown</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Skill</TableHead>
<TableHead>Band</TableHead>
<TableHead>CEFR</TableHead>
<TableHead>Gap to Target</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{SKILLS.map((s) => (
<TableRow key={s.skill}>
<TableCell className="font-medium">{s.skill}</TableCell>
<TableCell>{s.band}</TableCell>
<TableCell>{s.cefr}</TableCell>
<TableCell>{s.gap > 0 ? `${s.gap}` : "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<div>
<h2 className="mb-3 text-lg font-semibold">Section feedback</h2>
<Accordion type="multiple" className="w-full">
{["Listening", "Reading", "Writing", "Speaking"].map((name) => (
<AccordionItem key={name} value={name}>
<AccordionTrigger>{name}</AccordionTrigger>
<AccordionContent className="text-muted-foreground">
Detailed feedback for {name} will appear here once released by your instructor.
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
<Card>
<CardHeader>
<CardTitle>Areas to improve</CardTitle>
<CardDescription>Focus recommendations based on this attempt</CardDescription>
</CardHeader>
<CardContent>
<ul className="list-inside list-disc space-y-2 text-sm">
<li>Strengthen task response structure in Writing Task 2.</li>
<li>Extend range of cohesive devices in argumentative essays.</li>
<li>Maintain fluency while reducing hesitation in Speaking Part 2.</li>
</ul>
</CardContent>
</Card>
<div className="flex flex-wrap gap-3">
<Button type="button" variant="outline">
<Download className="mr-2 h-4 w-4" />
Download PDF Report
</Button>
<Button type="button" asChild>
<Link to={`/student/course/generate?from=exam&examId=${examId ?? ""}`}>
<BookOpen className="mr-2 h-4 w-4" />
View Recommended Course
</Link>
</Button>
<Button type="button" variant="secondary" asChild>
<Link to={`/student/exam/${examId}/session?mode=practice`}>
<RefreshCw className="mr-2 h-4 w-4" />
Retake Exam
</Link>
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card className={passed ? "border-green-600/40 bg-green-500/5" : ""}>
<CardHeader>
<CardTitle>Pass path</CardTitle>
<CardDescription>Congratulations you are on track for your goal.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm">Consider registering for the official test while skills are strong.</p>
<Button type="button" size="sm">
Register for official exam
</Button>
</CardContent>
</Card>
<Card className={!passed ? "border-amber-600/40 bg-amber-500/5" : ""}>
<CardHeader>
<CardTitle>Below threshold</CardTitle>
<CardDescription>Close the gap with targeted training.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm">Start adaptive training tailored to your weakest skills.</p>
<Button type="button" size="sm" variant="secondary" asChild>
<Link to={`/student/course/generate?from=exam&examId=${examId ?? ""}`}>Start adaptive training</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,458 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useExamSession, useExamAutoSave, useExamSubmit } from "@/hooks/queries/useExamSession";
import type { ExamAnswer, ExamQuestion, ExamSessionSection } from "@/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Flag, ChevronLeft, ChevronRight, Pause, Play } from "lucide-react";
import { cn } from "@/lib/utils";
function normalizeType(t: string) {
return t.toLowerCase().replace(/\s+/g, "_");
}
function countWords(s: string) {
return s.trim() ? s.trim().split(/\s+/).length : 0;
}
function buildAnswerMap(sections: ExamSessionSection[]) {
const map = new Map<number, ExamAnswer>();
for (const sec of sections) {
for (const q of sec.questions) {
map.set(q.id, {
question_id: q.id,
answer: null,
flagged: false,
time_spent_ms: 0,
});
}
}
return map;
}
export default function ExamSession() {
const { examId: examIdParam } = useParams();
const examId = Number(examIdParam);
const navigate = useNavigate();
const { data: session, isLoading, isError } = useExamSession(examId);
const autoSave = useExamAutoSave();
const submitMut = useExamSubmit();
const [sectionIdx, setSectionIdx] = useState(0);
const [questionIdx, setQuestionIdx] = useState(0);
const [answers, setAnswers] = useState<Map<number, ExamAnswer>>(new Map());
const [sectionGate, setSectionGate] = useState<{ open: boolean; sec: number; remaining: number } | null>(null);
const [reviewOpen, setReviewOpen] = useState(false);
const [playing, setPlaying] = useState(false);
const totalSecRef = useRef(0);
const qStartRef = useRef(Date.now());
useEffect(() => {
if (!session) return;
setAnswers(buildAnswerMap(session.sections));
totalSecRef.current = session.total_time_min * 60;
setTick(0);
}, [session]);
const section = session?.sections[sectionIdx];
const question = section?.questions[questionIdx];
const totalQuestions = useMemo(
() => session?.sections.reduce((n, s) => n + s.questions.length, 0) ?? 0,
[session],
);
const [tick, setTick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => setTick((t) => t + 1), 1000);
return () => window.clearInterval(id);
}, []);
useEffect(() => {
qStartRef.current = Date.now();
}, [question?.id]);
const answeredCount = useMemo(() => {
let n = 0;
answers.forEach((a) => {
if (a.answer === null || a.answer === "") return;
if (Array.isArray(a.answer) && a.answer.length === 0) return;
n += 1;
});
return n;
}, [answers]);
const progressPct = totalQuestions ? (answeredCount / totalQuestions) * 100 : 0;
const updateAnswer = useCallback(
(questionId: number, patch: Partial<ExamAnswer>) => {
setAnswers((prev) => {
const next = new Map(prev);
const cur = next.get(questionId);
if (!cur) return prev;
const spent = Date.now() - qStartRef.current;
next.set(questionId, { ...cur, ...patch, time_spent_ms: cur.time_spent_ms + spent });
qStartRef.current = Date.now();
return next;
});
},
[],
);
const currentSectionAnswers = useCallback((): ExamAnswer[] => {
if (!section) return [];
return section.questions.map((q) => answers.get(q.id)!).filter(Boolean);
}, [section, answers]);
useEffect(() => {
if (!session || !section) return;
const id = window.setInterval(() => {
autoSave.mutate({
examId,
payload: { section_id: section.id, answers: currentSectionAnswers() },
});
}, 10000);
return () => window.clearInterval(id);
}, [session, section, examId, autoSave, currentSectionAnswers]);
const handleFlag = () => {
if (!question) return;
const cur = answers.get(question.id);
if (!cur) return;
updateAnswer(question.id, { flagged: !cur.flagged });
};
const goNext = () => {
if (!session || !section) return;
if (questionIdx < section.questions.length - 1) {
setQuestionIdx((i) => i + 1);
return;
}
if (sectionIdx < session.sections.length - 1) {
setSectionGate({ open: true, sec: sectionIdx + 1, remaining: 5 });
return;
}
setReviewOpen(true);
};
const goPrev = () => {
if (questionIdx > 0) {
setQuestionIdx((i) => i - 1);
return;
}
if (sectionIdx > 0) {
const prevSec = session!.sections[sectionIdx - 1];
setSectionIdx((s) => s - 1);
setQuestionIdx(prevSec.questions.length - 1);
}
};
useEffect(() => {
if (!sectionGate?.open) return;
if (sectionGate.remaining <= 0) {
setSectionIdx(sectionGate.sec);
setQuestionIdx(0);
setSectionGate(null);
return;
}
const t = window.setTimeout(
() => setSectionGate((g) => (g ? { ...g, remaining: g.remaining - 1 } : null)),
1000,
);
return () => window.clearTimeout(t);
}, [sectionGate]);
const summary = useMemo(() => {
let answered = 0;
let unanswered = 0;
let flagged = 0;
session?.sections.forEach((sec) => {
sec.questions.forEach((q) => {
const a = answers.get(q.id);
if (!a) {
unanswered += 1;
return;
}
if (a.flagged) flagged += 1;
const empty =
a.answer === null ||
a.answer === "" ||
(Array.isArray(a.answer) && a.answer.length === 0);
if (empty) unanswered += 1;
else answered += 1;
});
});
return { answered, unanswered, flagged, total: totalQuestions };
}, [session, answers, totalQuestions]);
const renderQuestion = (q: ExamQuestion) => {
const a = answers.get(q.id);
if (!a) return null;
const nt = normalizeType(q.type);
if (nt.includes("listen") || q.audio_url) {
return (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-lg border bg-muted/40 p-4">
<Button type="button" variant="outline" size="icon" onClick={() => setPlaying((p) => !p)}>
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
</Button>
<div className="h-2 flex-1 rounded-full bg-muted">
<div className="h-2 w-1/3 rounded-full bg-primary" />
</div>
<span className="text-sm text-muted-foreground">0:00 / 3:42</span>
</div>
{q.options?.length ? (
<RadioGroup
value={typeof a.answer === "string" ? a.answer : ""}
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
className="space-y-2"
>
{q.options.map((o) => (
<div key={o.label} className="flex items-center space-x-2">
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
</div>
))}
</RadioGroup>
) : null}
</div>
);
}
if (nt.includes("multi") || nt === "mcq_multi") {
const selected = Array.isArray(a.answer) ? a.answer : [];
return (
<div className="space-y-3">
{q.options?.map((o) => (
<div key={o.label} className="flex items-center space-x-2">
<Checkbox
id={`${q.id}-${o.label}`}
checked={selected.includes(o.label)}
onCheckedChange={(ck) => {
const on = ck === true;
const next = on ? [...selected, o.label] : selected.filter((x) => x !== o.label);
updateAnswer(q.id, { answer: next });
}}
/>
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
</div>
))}
</div>
);
}
if (nt.includes("gap")) {
return (
<Input
value={typeof a.answer === "string" ? a.answer : ""}
onChange={(e) => updateAnswer(q.id, { answer: e.target.value })}
placeholder="Your answer"
className="max-w-md"
/>
);
}
if (nt.includes("true") || nt.includes("tfng") || nt === "yes_no_not_given") {
const opts = q.options?.length
? q.options
: [
{ label: "T", text: "True" },
{ label: "F", text: "False" },
{ label: "NG", text: "Not Given" },
];
return (
<RadioGroup
value={typeof a.answer === "string" ? a.answer : ""}
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
className="space-y-2"
>
{opts.map((o) => (
<div key={o.label} className="flex items-center space-x-2">
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
</div>
))}
</RadioGroup>
);
}
if (nt.includes("writing") || nt.includes("essay")) {
const text = typeof a.answer === "string" ? a.answer : "";
const wc = countWords(text);
const min = q.min_words ?? 150;
return (
<div className="space-y-2">
<Textarea
value={text}
onChange={(e) => updateAnswer(q.id, { answer: e.target.value })}
className="min-h-[200px]"
/>
<p className={cn("text-sm", wc < min ? "text-amber-600" : "text-muted-foreground")}>
{wc} words · minimum {min}
</p>
</div>
);
}
if (nt.includes("speak") || nt.includes("record") || nt.includes("audio")) {
return (
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
Recording interface will appear here.
</div>
);
}
return (
<RadioGroup
value={typeof a.answer === "string" ? a.answer : ""}
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
className="space-y-2"
>
{q.options?.map((o) => (
<div key={o.label} className="flex items-center space-x-2">
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
</div>
))}
</RadioGroup>
);
};
if (isLoading) {
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-background">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
if (isError || !session) {
return (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center gap-4 bg-background p-6">
<p className="text-destructive text-lg font-medium">Unable to load this exam session.</p>
<p className="text-muted-foreground text-sm">The exam may not exist or the server is unreachable.</p>
<Button variant="outline" onClick={() => navigate(-1)}>Go Back</Button>
</div>
);
}
const remainOverall = Math.max(0, totalSecRef.current - tick);
const mm = Math.floor(remainOverall / 60);
const ss = remainOverall % 60;
return (
<div className="fixed inset-0 z-[100] flex flex-col bg-background">
<header className="flex flex-wrap items-center justify-between gap-4 border-b px-6 py-3">
<div>
<h1 className="text-lg font-semibold leading-tight">{session.title}</h1>
<p className="text-sm text-muted-foreground">{section?.title}</p>
</div>
<div className="flex flex-wrap items-center gap-6">
<span className="font-mono text-lg tabular-nums">
{String(mm).padStart(2, "0")}:{String(ss).padStart(2, "0")}
</span>
<div className="w-48 space-y-1">
<Progress value={progressPct} className="h-2" />
<p className="text-xs text-muted-foreground">Overall progress</p>
</div>
</div>
</header>
<div className="flex flex-1 min-h-0 flex-col md:flex-row">
<ScrollArea className="flex-1 p-6">
{question ? (
<Card className="border-none shadow-none">
<CardContent className="space-y-6 p-0">
{question.passage_text ? (
<div className="rounded-lg bg-muted/50 p-4 text-sm leading-relaxed">{question.passage_text}</div>
) : null}
<div className="prose prose-sm dark:prose-invert max-w-none">
<p className="font-medium">{question.stem}</p>
</div>
{renderQuestion(question)}
</CardContent>
</Card>
) : null}
</ScrollArea>
</div>
<footer className="flex flex-wrap items-center justify-between gap-3 border-t px-6 py-4">
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={goPrev} disabled={sectionIdx === 0 && questionIdx === 0}>
<ChevronLeft className="mr-1 h-4 w-4" />
Previous
</Button>
<Button type="button" variant="outline" onClick={handleFlag}>
<Flag className="mr-1 h-4 w-4" />
Flag for Review
</Button>
</div>
<div className="flex gap-2">
<Button type="button" onClick={goNext}>
Next
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
</footer>
<Dialog open={!!sectionGate?.open} onOpenChange={() => setSectionGate(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Next section</DialogTitle>
<DialogDescription>
The next section begins in{" "}
<span className="font-mono font-semibold">{sectionGate?.remaining ?? 0}</span>s.
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
<Dialog open={reviewOpen} onOpenChange={setReviewOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Review and submit</DialogTitle>
<DialogDescription>
{summary.answered} answered · {summary.unanswered} unanswered · {summary.flagged} flagged ·{" "}
{summary.total} total
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={() => setReviewOpen(false)}>
Back
</Button>
<Button
type="button"
onClick={() => {
submitMut.mutate(examId, {
onSuccess: () => {
setReviewOpen(false);
navigate(`/student/exam/${examId}/status`);
},
});
}}
disabled={submitMut.isPending}
>
Submit exam
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useExamStatus } from "@/hooks/queries/useExamSession";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
export default function ExamStatus() {
const { examId: examIdParam } = useParams();
const examId = Number(examIdParam);
const navigate = useNavigate();
const { data, isLoading, isFetching } = useExamStatus(examId, true);
useEffect(() => {
if (!data) return;
const s = (data.status ?? "").toLowerCase();
if (data.scores_available || s.includes("complete") || s.includes("released")) {
navigate(`/student/exam/${examId}/results`, { replace: true });
}
}, [data, examId, navigate]);
if (isLoading && !data) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-6">
<div className="h-12 w-12 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-muted-foreground">Loading status</p>
</div>
);
}
const status = (data?.status ?? "").toLowerCase();
let view: "scoring" | "partial" | "manual" | "generic" = "generic";
if (status.includes("scoring") || status === "processing") view = "scoring";
else if (status.includes("manual") || status.includes("approval") || status.includes("under_review"))
view = "manual";
else if (
status.includes("partial") ||
status.includes("pending_ws") ||
status.includes("lr_scored") ||
(status.includes("listening") && status.includes("reading") && status.includes("pending"))
)
view = "partial";
return (
<div className="mx-auto flex min-h-screen max-w-lg flex-col justify-center gap-6 p-6">
{view === "scoring" ? (
<Card>
<CardHeader className="text-center">
<div className="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<CardTitle>Scores are being calculated</CardTitle>
<CardDescription>This page refreshes every few seconds.</CardDescription>
</CardHeader>
<CardContent className="text-center text-xs text-muted-foreground">
{isFetching ? "Checking…" : "Waiting for next update…"}
</CardContent>
</Card>
) : null}
{view === "partial" ? (
<Card>
<CardHeader>
<CardTitle>Partial results</CardTitle>
<CardDescription>Listening and Reading are scored; Writing and Speaking are still pending review.</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span>Listening</span>
<Badge variant="secondary">Band 7.5</Badge>
</div>
<div className="flex justify-between">
<span>Reading</span>
<Badge variant="secondary">Band 8.0</Badge>
</div>
<div className="flex justify-between">
<span>Writing</span>
<Badge>Pending Review</Badge>
</div>
<div className="flex justify-between">
<span>Speaking</span>
<Badge>Pending Review</Badge>
</div>
</CardContent>
</Card>
) : null}
{view === "manual" ? (
<Card>
<CardHeader>
<CardTitle>Results under review</CardTitle>
<CardDescription>Your institution will release Writing and Speaking results after approval.</CardDescription>
</CardHeader>
<CardContent>
<Button type="button" variant="outline" onClick={() => navigate("/student/dashboard")}>
Back to dashboard
</Button>
</CardContent>
</Card>
) : null}
{view === "generic" ? (
<Card>
<CardHeader>
<CardTitle>Status</CardTitle>
<CardDescription>{data?.status ?? "Unknown"}</CardDescription>
</CardHeader>
</Card>
) : null}
</div>
);
}

View File

@@ -0,0 +1,210 @@
import { useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import { useAuth } from "@/contexts/AuthContext";
import { useGapAnalysis, useAutoGenerateCourse } from "@/hooks/queries/useCourseGeneration";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Skeleton } from "@/components/ui/skeleton";
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
export default function GapAnalysis() {
const [searchParams] = useSearchParams();
const { user } = useAuth();
const fromParam = searchParams.get("from");
const source = fromParam === "placement" ? "placement" : "exam";
const examId = Number(searchParams.get("examId"));
const sessionId = Number(searchParams.get("sessionId"));
const sourceId = source === "exam" ? examId : sessionId;
const entityStudent = searchParams.get("entity") === "1";
const enabled = source === "exam" ? Number.isFinite(examId) && examId > 0 : Number.isFinite(sessionId) && sessionId > 0;
const { data, isLoading, isError } = useGapAnalysis(source, sourceId);
const generate = useAutoGenerateCourse();
const chartData = useMemo(() => {
if (!data?.skill_gaps?.length) return [];
return [...data.skill_gaps]
.sort((a, b) => b.gap - a.gap)
.map((g) => ({ skill: g.skill, gap: g.gap }));
}, [data]);
const canGenerate = user?.user_type === "student" && !entityStudent;
return (
<div className="mx-auto max-w-5xl space-y-8 p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Skill gap analysis</h1>
<p className="text-muted-foreground">
{source === "exam" ? `From exam ${examId || "—"}` : `Placement session ${sessionId || "—"}`}
</p>
</div>
{canGenerate ? (
<Button
type="button"
onClick={() => generate.mutate(sourceId)}
disabled={!enabled || generate.isPending}
>
Generate Course
</Button>
) : null}
</div>
{entityStudent ? (
<Alert>
<AlertTitle>Entity-managed students</AlertTitle>
<AlertDescription>
Gap analysis is view-only for your organization. Contact your coordinator to assign a course.
</AlertDescription>
</Alert>
) : null}
{!enabled ? (
<Alert variant="destructive">
<AlertTitle>Missing source</AlertTitle>
<AlertDescription>
Add <code className="rounded bg-muted px-1">?from=exam&amp;examId=</code> or{" "}
<code className="rounded bg-muted px-1">?from=placement&amp;sessionId=</code> to the URL.
</AlertDescription>
</Alert>
) : null}
{isLoading ? (
<Skeleton className="h-96 w-full" />
) : isError || !data ? (
<p className="text-destructive">Could not load gap analysis.</p>
) : (
<>
<Card>
<CardHeader>
<CardTitle>Skills ranked by gap</CardTitle>
<CardDescription>Larger bars need more attention</CardDescription>
</CardHeader>
<CardContent>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} layout="vertical" margin={{ left: 16 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis type="number" domain={[0, "dataMax"]} />
<YAxis type="category" dataKey="skill" width={100} tickLine={false} axisLine={false} />
<Tooltip />
<Bar dataKey="gap" fill="hsl(var(--primary))" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Gap table</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Skill</TableHead>
<TableHead>Current</TableHead>
<TableHead>Target</TableHead>
<TableHead>Gap</TableHead>
<TableHead>Priority</TableHead>
<TableHead>Est. Hours</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.skill_gaps.map((g) => (
<TableRow key={g.skill}>
<TableCell className="font-medium">{g.skill}</TableCell>
<TableCell>{g.current_band ?? g.current_level}</TableCell>
<TableCell>{g.target_band ?? g.target_level}</TableCell>
<TableCell>{g.gap}</TableCell>
<TableCell className="capitalize">{g.priority}</TableCell>
<TableCell>{g.estimated_hours}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<div>
<h2 className="mb-3 text-lg font-semibold">Question-type weaknesses</h2>
<Accordion type="multiple" className="w-full">
{Object.entries(data.question_type_weaknesses).map(([skill, rows]) => (
<AccordionItem key={skill} value={skill}>
<AccordionTrigger>{skill}</AccordionTrigger>
<AccordionContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Accuracy</TableHead>
<TableHead>Correct</TableHead>
<TableHead>Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={r.question_type}>
<TableCell>{r.question_type}</TableCell>
<TableCell>{Math.round(r.accuracy * 100)}%</TableCell>
<TableCell>{r.correct}</TableCell>
<TableCell>{r.total}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
<div>
<h2 className="mb-3 text-lg font-semibold">Topic weaknesses</h2>
<div className="space-y-4">
{Object.entries(data.topic_weaknesses).map(([cat, topics]) => (
<Card key={cat}>
<CardHeader className="py-3">
<CardTitle className="text-base">{cat}</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Topic</TableHead>
<TableHead>Errors</TableHead>
<TableHead>Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topics.map((t) => (
<TableRow key={t.topic}>
<TableCell>{t.topic}</TableCell>
<TableCell>{t.errors}</TableCell>
<TableCell>{t.total}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
))}
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,122 @@
import type { ReactNode } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { Loader2, Lock, CheckCircle2, PlayCircle, Circle, ArrowRight, Sparkles } from "lucide-react";
import { useLearningPlan, useGenerateLearningPlan } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { PlanItemStatus } from "@/types";
const statusIcons: Record<PlanItemStatus, ReactNode> = {
locked: <Lock className="h-4 w-4 text-muted-foreground" />,
available: <Circle className="h-4 w-4 text-blue-500" />,
in_progress: <PlayCircle className="h-4 w-4 text-yellow-500" />,
completed: <CheckCircle2 className="h-4 w-4 text-green-500" />,
};
const statusColors: Record<PlanItemStatus, string> = {
locked: "bg-muted text-muted-foreground",
available: "bg-blue-50 text-blue-700 border-blue-200",
in_progress: "bg-yellow-50 text-yellow-700 border-yellow-200",
completed: "bg-green-50 text-green-700 border-green-200",
};
export default function LearningPlanPage() {
const { subjectId } = useParams<{ subjectId: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const { data: plan, isLoading } = useLearningPlan({ subject_id: Number(subjectId) });
const generateMutation = useGenerateLearningPlan();
const handleGenerate = () => {
generateMutation.mutate(
{ subject_id: Number(subjectId) },
{
onSuccess: () => toast({ title: "Plan Generated", description: "Your personalized learning plan is ready." }),
onError: () => toast({ title: "Error", description: "Failed to generate plan", variant: "destructive" }),
}
);
};
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
if (!plan) {
return (
<div className="max-w-lg mx-auto text-center py-16 space-y-6">
<Sparkles className="h-16 w-16 text-primary mx-auto opacity-60" />
<h1 className="text-2xl font-bold">No Learning Plan Yet</h1>
<p className="text-muted-foreground">Complete a diagnostic assessment first, then generate your personalized learning plan.</p>
<div className="flex gap-3 justify-center">
<Button variant="outline" onClick={() => navigate(`/student/diagnostic/${subjectId}`)}>Take Diagnostic</Button>
<Button onClick={handleGenerate} disabled={generateMutation.isPending}>
{generateMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Generate Plan
</Button>
</div>
</div>
);
}
const items = plan.items.sort((a, b) => a.sequence - b.sequence);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Learning Plan: {plan.subject_name}</h1>
<p className="text-muted-foreground">{plan.ai_summary}</p>
</div>
<Badge variant={plan.status === "active" ? "default" : "secondary"}>{plan.status}</Badge>
</div>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-muted-foreground">Overall Progress</span>
<span className="text-sm font-medium">{Math.round(plan.overall_progress)}%</span>
</div>
<Progress value={plan.overall_progress} className="h-3" />
</CardContent>
</Card>
<div className="space-y-3">
{items.map((item, index) => (
<Card key={item.id} className={`border ${statusColors[item.status]}`}>
<CardContent className="py-4">
<div className="flex items-center gap-4">
<div className="flex flex-col items-center gap-1">
<div className="text-xs text-muted-foreground font-mono">{index + 1}</div>
{statusIcons[item.status]}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{item.topic_name}</span>
<Badge variant="outline" className="text-xs">{item.domain_name}</Badge>
</div>
<div className="flex items-center gap-4 mt-1 text-xs text-muted-foreground">
<span>~{item.estimated_hours}h estimated</span>
{item.actual_hours > 0 && <span>{item.actual_hours}h spent</span>}
<span>Mastery: {Math.round(item.mastery)}%</span>
</div>
{item.status !== "locked" && (
<Progress value={item.mastery} className="h-1 mt-2" />
)}
</div>
{(item.status === "available" || item.status === "in_progress") && (
<Button size="sm" onClick={() => navigate(`/student/topic/${item.topic_id}`)}>
{item.status === "in_progress" ? "Continue" : "Start"} <ArrowRight className="ml-1 h-3 w-3" />
</Button>
)}
{item.status === "completed" && (
<Button variant="ghost" size="sm" onClick={() => navigate(`/student/topic/${item.topic_id}`)}>Review</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,135 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Check, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { placementService } from "@/services/placement.service";
import { ApiError } from "@/lib/api-client";
const fullFeatures = [
"Unlimited adaptive lessons",
"Speaking feedback & analytics",
"Full placement history",
"Priority instructor Q&A",
];
const trialFeatures = ["7 days full access", "Placement results saved", "Email reminders before trial ends"];
const skipFeatures = ["Dashboard access", "Limited preview content", "Upgrade any time"];
export default function PlacementAccess() {
const navigate = useNavigate();
const [trialLoading, setTrialLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const startTrial = async () => {
setError(null);
setTrialLoading(true);
try {
await placementService.startTrial();
navigate("/student/dashboard");
} catch (e) {
setError(e instanceof ApiError ? e.message : "Could not start trial");
} finally {
setTrialLoading(false);
}
};
return (
<div className="max-w-6xl mx-auto py-12 px-4">
<div className="text-center mb-10 space-y-2">
<h1 className="text-3xl font-bold tracking-tight">Choose how you want to continue</h1>
<p className="text-muted-foreground max-w-2xl mx-auto">
Unlock the full adaptive pathway or explore on your own terms.
</p>
</div>
{error && (
<p className="text-center text-sm text-destructive mb-6 bg-destructive/10 rounded-lg py-2 px-4 max-w-xl mx-auto">
{error}
</p>
)}
<div className="grid gap-6 md:grid-cols-3 items-stretch">
<Card className="border-2 border-primary shadow-lg flex flex-col">
<CardHeader>
<CardTitle className="text-xl">Full access</CardTitle>
<CardDescription>Best value for serious exam prep</CardDescription>
</CardHeader>
<CardContent className="flex-1 space-y-4">
<ul className="space-y-2">
{fullFeatures.map((f) => (
<li key={f} className="flex gap-2 text-sm">
<Check className="h-4 w-4 shrink-0 text-primary mt-0.5" />
<span>{f}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<Button className="w-full" size="lg" onClick={() => navigate("/student/subscription")}>
Subscribe now
</Button>
</CardFooter>
</Card>
<Card className="border-0 shadow-md bg-muted/40 flex flex-col">
<CardHeader>
<CardTitle className="text-xl">Free trial</CardTitle>
<CardDescription>Try everything for seven days</CardDescription>
</CardHeader>
<CardContent className="flex-1 space-y-4">
<ul className="space-y-2">
{trialFeatures.map((f) => (
<li key={f} className="flex gap-2 text-sm">
<Check className="h-4 w-4 shrink-0 text-primary mt-0.5" />
<span>{f}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<Button
className="w-full"
size="lg"
variant="secondary"
disabled={trialLoading}
onClick={() => void startTrial()}
>
{trialLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Starting
</>
) : (
"Try free for 7 days"
)}
</Button>
</CardFooter>
</Card>
<Card className="border border-dashed flex flex-col">
<CardHeader>
<CardTitle className="text-xl">Skip for now</CardTitle>
<CardDescription>Head straight to your dashboard</CardDescription>
</CardHeader>
<CardContent className="flex-1 space-y-4">
<ul className="space-y-2">
{skipFeatures.map((f) => (
<li key={f} className="flex gap-2 text-sm text-muted-foreground">
<Check className="h-4 w-4 shrink-0 mt-0.5" />
<span>{f}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<Button className="w-full" size="lg" variant="outline" onClick={() => navigate("/student/dashboard")}>
Continue to dashboard
</Button>
</CardFooter>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Loader2, Clock, Layers, Sparkles } from "lucide-react";
import { usePlacementStart } from "@/hooks/queries/usePlacement";
import type { PlacementSubject } from "@/types";
import { ApiError } from "@/lib/api-client";
import { useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
const dimensions = [
{ name: "Grammar", detail: "~30 MCQ", time: "~8 min" },
{ name: "Vocabulary", detail: "~20 items", time: "~5 min" },
{ name: "Reading", detail: "~10 Q / 2 passages", time: "~10 min" },
{ name: "Speaking", detail: "3 prompts", time: "~5 min" },
];
export default function PlacementBriefing() {
const navigate = useNavigate();
const start = usePlacementStart();
const [error, setError] = useState<string | null>(null);
const handleBegin = async () => {
setError(null);
const subject: PlacementSubject = "english";
try {
const res = await start.mutateAsync(subject);
sessionStorage.setItem(
`placement_session_${res.session_id}`,
JSON.stringify({ question: res.first_question }),
);
navigate(`/student/placement/test?session=${encodeURIComponent(res.session_id)}`, {
state: { question: res.first_question, sessionId: res.session_id },
});
} catch (e) {
setError(e instanceof ApiError ? e.message : "Could not start the test");
}
};
return (
<div className="space-y-8 max-w-4xl mx-auto py-6 px-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Placement test</h1>
<p className="text-muted-foreground mt-1">Adaptive English placement across four skills.</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-0 shadow-md bg-gradient-to-br from-primary/10 to-transparent">
<CardHeader className="pb-2">
<Clock className="h-5 w-5 text-primary mb-1" />
<CardTitle className="text-base">Duration</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold tabular-nums">2030 min</p>
<p className="text-xs text-muted-foreground mt-1">Pace yourself; timer tracks elapsed time.</p>
</CardContent>
</Card>
<Card className="border-0 shadow-md">
<CardHeader className="pb-2">
<Layers className="h-5 w-5 text-primary mb-1" />
<CardTitle className="text-base">Sections</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">4</p>
<p className="text-xs text-muted-foreground mt-1">Grammar, vocabulary, reading, speaking.</p>
</CardContent>
</Card>
<Card className="border-0 shadow-md">
<CardHeader className="pb-2">
<Sparkles className="h-5 w-5 text-primary mb-1" />
<CardTitle className="text-base">Adaptive</CardTitle>
</CardHeader>
<CardContent>
<Badge variant="secondary" className="mb-1">
CAT-style
</Badge>
<p className="text-xs text-muted-foreground">Difficulty adjusts based on your responses.</p>
</CardContent>
</Card>
</div>
<Card className="border-0 shadow-lg">
<CardHeader>
<CardTitle>What to expect</CardTitle>
<CardDescription>Approximate structure per dimension</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Dimension</TableHead>
<TableHead>Format</TableHead>
<TableHead className="text-right">Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dimensions.map((row) => (
<TableRow key={row.name}>
<TableCell className="font-medium">{row.name}</TableCell>
<TableCell className="text-muted-foreground">{row.detail}</TableCell>
<TableCell className="text-right tabular-nums">{row.time}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{error && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex justify-end">
<Button size="lg" className="min-w-[160px]" onClick={() => void handleBegin()} disabled={start.isPending}>
{start.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Starting
</>
) : (
"Begin Test"
)}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,281 @@
import { useMemo } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import {
Radar,
RadarChart,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
ResponsiveContainer,
} from "recharts";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ChevronDown, Loader2 } from "lucide-react";
import { usePlacementResults, useLearningPath, useSpeakingStatus } from "@/hooks/queries/usePlacement";
import type { CEFRLevel } from "@/types";
import { cn } from "@/lib/utils";
function cefrBadgeClass(level: CEFRLevel): string {
switch (level) {
case "c2":
case "c1":
return "bg-violet-600 hover:bg-violet-600 text-white border-0";
case "b2":
case "b1":
return "bg-emerald-600 hover:bg-emerald-600 text-white border-0";
case "a2":
case "a1":
case "pre_a1":
return "bg-amber-600 hover:bg-amber-600 text-white border-0";
default:
return "";
}
}
const CEFR_LABEL: Record<CEFRLevel, string> = {
pre_a1: "Pre-A1",
a1: "A1",
a2: "A2",
b1: "B1",
b2: "B2",
c1: "C1",
c2: "C2",
};
function formatCefr(level: CEFRLevel): string {
return CEFR_LABEL[level] ?? level;
}
export default function PlacementResults() {
const [searchParams] = useSearchParams();
const sessionId = searchParams.get("session") || "";
const navigate = useNavigate();
const results = usePlacementResults(sessionId);
const learningPath = useLearningPath(sessionId);
const speakingPending = results.data?.speaking_status === "pending";
const speakingPoll = useSpeakingStatus(sessionId, speakingPending);
const radarData = useMemo(() => {
if (!results.data?.dimensions.length) return [];
return results.data.dimensions.map((d) => ({
dimension: d.dimension,
score: d.score,
fullMark: 100,
}));
}, [results.data]);
if (!sessionId) {
return (
<div className="max-w-lg mx-auto py-16 px-4 text-center">
<p className="text-muted-foreground mb-4">Missing session.</p>
<Button variant="outline" onClick={() => navigate("/student/placement")}>
Back to placement
</Button>
</div>
);
}
return (
<div className="max-w-5xl mx-auto py-8 px-4 space-y-10">
<div>
<h1 className="text-3xl font-bold tracking-tight">Your placement results</h1>
<p className="text-muted-foreground mt-2">Personalized snapshot before your course begins.</p>
</div>
{results.isLoading && (
<div className="space-y-6">
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" />
</div>
)}
{results.error && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="pt-6">
<p className="text-destructive text-sm">{results.error.message}</p>
<Button className="mt-4" variant="outline" onClick={() => results.refetch()}>
Retry
</Button>
</CardContent>
</Card>
)}
{results.data && (
<>
<Card className="border-0 shadow-lg overflow-hidden">
<div className="bg-gradient-to-r from-primary/15 via-primary/5 to-transparent p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6">
<div>
<p className="text-sm font-medium text-muted-foreground uppercase tracking-wide">Overall level</p>
<p className="text-4xl md:text-5xl font-bold mt-2 tabular-nums">
{formatCefr(results.data.overall_cefr)}
</p>
</div>
<Badge className={cn("text-lg px-4 py-2", cefrBadgeClass(results.data.overall_cefr))}>
CEFR {formatCefr(results.data.overall_cefr)}
</Badge>
</div>
</Card>
<div className="grid gap-8 lg:grid-cols-2">
<Card className="border-0 shadow-md">
<CardHeader>
<CardTitle>Skill profile</CardTitle>
<CardDescription>Relative strength across dimensions</CardDescription>
</CardHeader>
<CardContent className="h-[320px]">
{radarData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={radarData}>
<PolarGrid />
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 11 }} />
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={false} />
<Radar
name="Score"
dataKey="score"
stroke="hsl(var(--primary))"
fill="hsl(var(--primary))"
fillOpacity={0.35}
/>
</RadarChart>
</ResponsiveContainer>
) : (
<p className="text-sm text-muted-foreground">No dimension data.</p>
)}
</CardContent>
</Card>
<Card className="border-0 shadow-md">
<CardHeader>
<CardTitle>Detailed scores</CardTitle>
<CardDescription>Gap to target highlights where to focus first</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Dimension</TableHead>
<TableHead className="text-right">Score</TableHead>
<TableHead>CEFR</TableHead>
<TableHead>Gap</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{results.data.dimensions.map((row) => (
<TableRow key={row.dimension}>
<TableCell className="font-medium">{row.dimension}</TableCell>
<TableCell className="text-right tabular-nums">{row.score.toFixed(1)}</TableCell>
<TableCell>
{row.cefr_level ? (
<Badge variant="outline">{formatCefr(row.cefr_level)}</Badge>
) : (
"—"
)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">{row.gap_to_target}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="mt-4 flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Speaking:</span>
{(speakingPoll.data?.status === "scored" || results.data.speaking_status === "scored") && (
<Badge variant="secondary">Scored</Badge>
)}
{speakingPending && speakingPoll.data?.status !== "scored" && (
<Badge variant="outline" className="gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
Pending
</Badge>
)}
</div>
</CardContent>
</Card>
</div>
<Card className="border-0 shadow-lg">
<CardHeader>
<CardTitle>Learning path preview</CardTitle>
<CardDescription>Aligned modules based on your gaps</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{learningPath.isLoading && (
<div className="space-y-2">
<Skeleton className="h-6 w-2/3" />
<Skeleton className="h-20 w-full" />
</div>
)}
{learningPath.error && (
<p className="text-sm text-destructive">{learningPath.error.message}</p>
)}
{learningPath.data && (
<>
<div className="rounded-xl border bg-muted/30 p-6 space-y-2">
<h3 className="text-lg font-semibold">{learningPath.data.course_title}</h3>
<p className="text-sm text-muted-foreground">
~{learningPath.data.estimated_duration_weeks} weeks · {learningPath.data.module_count} modules ·{" "}
{learningPath.data.study_hours_per_week} h/week suggested
</p>
</div>
<div>
<h4 className="font-medium mb-3">Skill gap modules</h4>
<ul className="space-y-3">
{[...learningPath.data.modules]
.sort((a, b) => {
const pr = { high: 0, medium: 1, low: 2 };
return pr[a.priority] - pr[b.priority];
})
.map((m) => (
<li
key={m.name}
className="flex flex-wrap items-baseline justify-between gap-2 rounded-lg border p-3"
>
<span className="font-medium">{m.name}</span>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge
variant={
m.priority === "high" ? "destructive" : m.priority === "medium" ? "default" : "secondary"
}
>
{m.priority}
</Badge>
<span>{m.estimated_hours}h</span>
<span className="capitalize">{m.difficulty}</span>
</div>
</li>
))}
</ul>
</div>
<Collapsible>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium text-primary hover:underline">
<ChevronDown className="h-4 w-4" />
View full course plan
</CollapsibleTrigger>
<CollapsibleContent className="mt-4 rounded-lg border bg-card p-4 text-sm text-muted-foreground leading-relaxed">
{learningPath.data.modules.map((m) => (
<p key={`${m.name}-detail`} className="mb-2 last:mb-0">
<span className="font-medium text-foreground">{m.name}</span> {m.resource_types.join(", ")}
</p>
))}
</CollapsibleContent>
</Collapsible>
</>
)}
</CardContent>
</Card>
<div className="flex justify-end">
<Button size="lg" onClick={() => navigate("/student/placement/access")}>
Continue
</Button>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,364 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams, useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Skeleton } from "@/components/ui/skeleton";
import { Progress } from "@/components/ui/progress";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Mic, Loader2 } from "lucide-react";
import { usePlacementAnswer, usePlacementAutoSave } from "@/hooks/queries/usePlacement";
import type { CATQuestion, CATSection } from "@/types";
import { cn } from "@/lib/utils";
type LocationState = { question?: CATQuestion; sessionId?: string };
function sectionTitle(s: CATSection): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
export default function PlacementTest() {
const [searchParams] = useSearchParams();
const sessionId = searchParams.get("session") || "";
const navigate = useNavigate();
const location = useLocation();
const locState = location.state as LocationState | undefined;
const answerMut = usePlacementAnswer();
const autoSave = usePlacementAutoSave();
const [question, setQuestion] = useState<CATQuestion | null>(null);
const [initializing, setInitializing] = useState(true);
const [betweenQuestions, setBetweenQuestions] = useState(false);
const [progressInfo, setProgressInfo] = useState<{ current: number; estimated_total: number } | null>(null);
const [sectionBridge, setSectionBridge] = useState<{
from: CATSection;
to: CATSection;
nextQuestion: CATQuestion;
} | null>(null);
const [singleAnswer, setSingleAnswer] = useState("");
const [multiAnswer, setMultiAnswer] = useState<string[]>([]);
const questionStartedAt = useRef<number>(Date.now());
const draftRef = useRef<{ question_id: number; answer: string | string[] } | null>(null);
useEffect(() => {
if (!sessionId) {
navigate("/student/placement", { replace: true });
return;
}
const fromState = locState?.question;
const fromStorage = sessionStorage.getItem(`placement_session_${sessionId}`);
let parsed: CATQuestion | null = null;
if (fromStorage) {
try {
const j = JSON.parse(fromStorage) as { question?: CATQuestion };
parsed = j.question ?? null;
} catch {
parsed = null;
}
}
const q = fromState ?? parsed;
if (q) {
setQuestion(q);
questionStartedAt.current = Date.now();
}
setInitializing(false);
}, [sessionId, navigate, locState]);
useEffect(() => {
if (!question) return;
setSingleAnswer("");
setMultiAnswer([]);
questionStartedAt.current = Date.now();
}, [question?.id]);
useEffect(() => {
if (!question) return;
const id = question.id;
if (question.type === "mcq_multi") {
draftRef.current = { question_id: id, answer: multiAnswer };
} else {
draftRef.current = { question_id: id, answer: singleAnswer };
}
}, [question, singleAnswer, multiAnswer]);
useEffect(() => {
if (!sessionId || !draftRef.current) return;
const tick = window.setInterval(() => {
const d = draftRef.current;
if (!d) return;
void autoSave.mutate({ session_id: sessionId, current_answer: d });
}, 10000);
return () => window.clearInterval(tick);
}, [sessionId, autoSave]);
const elapsedRef = useRef(0);
const [, setTick] = useState(0);
useEffect(() => {
const t = window.setInterval(() => {
elapsedRef.current += 1;
setTick((x) => x + 1);
}, 1000);
return () => window.clearInterval(t);
}, []);
const formatElapsed = (sec: number) => {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
};
const buildAnswerPayload = (): string | string[] => {
if (!question) return "";
if (question.type === "mcq_multi") return multiAnswer;
if (question.type === "gap_fill") return singleAnswer.trim();
return singleAnswer;
};
const submit = async () => {
if (!question || !sessionId) return;
const timeSpent = Date.now() - questionStartedAt.current;
setBetweenQuestions(true);
try {
const res = await answerMut.mutateAsync({
session_id: sessionId,
question_id: question.id,
answer: buildAnswerPayload(),
time_spent_ms: timeSpent,
});
if (res.progress) setProgressInfo(res.progress);
if (res.test_complete) {
navigate(`/student/placement/results?session=${encodeURIComponent(sessionId)}`);
return;
}
if (res.section_complete && res.next_question) {
setSectionBridge({
from: question.section,
to: res.next_question.section,
nextQuestion: res.next_question,
});
return;
}
if (res.next_question) {
setQuestion(res.next_question);
sessionStorage.setItem(`placement_session_${sessionId}`, JSON.stringify({ question: res.next_question }));
}
} finally {
setBetweenQuestions(false);
}
};
const continueAfterSection = () => {
if (!sectionBridge || !sessionId) return;
setQuestion(sectionBridge.nextQuestion);
sessionStorage.setItem(
`placement_session_${sessionId}`,
JSON.stringify({ question: sectionBridge.nextQuestion }),
);
setSectionBridge(null);
};
const toggleMulti = (label: string) => {
setMultiAnswer((prev) => (prev.includes(label) ? prev.filter((x) => x !== label) : [...prev, label]));
};
const displayProgress = progressInfo ?? {
current: question?.estimated_total ? 1 : 0,
estimated_total: question?.estimated_total ?? 0,
};
const approxFinal =
displayProgress.estimated_total > 0 && displayProgress.current >= displayProgress.estimated_total;
if (!sessionId) return null;
if (!initializing && !question && !sectionBridge) {
return (
<div className="min-h-screen flex items-center justify-center p-6 bg-background">
<Card className="max-w-md w-full border-0 shadow-lg">
<CardHeader>
<CardTitle>Session unavailable</CardTitle>
<CardDescription>Return to the briefing and start the test again.</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => navigate("/student/placement")}>Back</Button>
</CardContent>
</Card>
</div>
);
}
if (sectionBridge) {
return (
<div className="min-h-screen flex flex-col bg-background">
<header className="border-b px-6 py-4 flex items-center justify-between bg-card/80 backdrop-blur">
<span className="text-sm font-medium text-muted-foreground">Section complete</span>
<span className="text-sm tabular-nums">{formatElapsed(elapsedRef.current)}</span>
</header>
<main className="flex-1 flex items-center justify-center p-6">
<Card className="max-w-lg w-full border-0 shadow-xl text-center">
<CardHeader>
<CardTitle>
{sectionTitle(sectionBridge.from)} complete. Next: {sectionTitle(sectionBridge.to)}
</CardTitle>
<CardDescription>Take a short breath continue when you&apos;re ready.</CardDescription>
</CardHeader>
<CardContent>
<Button size="lg" className="w-full" onClick={continueAfterSection}>
Continue
</Button>
</CardContent>
</Card>
</main>
</div>
);
}
return (
<div className="min-h-screen flex flex-col bg-gradient-to-b from-muted/20 to-background">
<header className="sticky top-0 z-10 border-b px-4 md:px-8 py-3 flex flex-wrap items-center gap-4 justify-between bg-card/90 backdrop-blur supports-[backdrop-filter]:bg-card/80">
<div className="flex items-center gap-4 min-w-0">
<span className="text-sm font-semibold tabular-nums text-primary">{formatElapsed(elapsedRef.current)}</span>
<span className="text-sm font-medium truncate">{question ? sectionTitle(question.section) : "…"}</span>
</div>
<div className="flex items-center gap-3 flex-1 max-w-md min-w-[200px]">
<Progress
value={
displayProgress.estimated_total
? Math.min(100, (displayProgress.current / displayProgress.estimated_total) * 100)
: 0
}
className="h-2 flex-1"
/>
<span className="text-xs text-muted-foreground whitespace-nowrap tabular-nums">
Question {displayProgress.current} of ~{displayProgress.estimated_total || "?"}
</span>
</div>
</header>
<main className="flex-1 flex items-stretch justify-center p-4 md:p-8">
<div className="w-full max-w-3xl">
{initializing || betweenQuestions || !question ? (
<div className="space-y-4">
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : (
<Card className="border-0 shadow-lg">
<CardContent className="pt-8 space-y-8">
{question.passage_text && (
<div className="rounded-lg bg-muted/50 p-4 text-sm leading-relaxed whitespace-pre-wrap">
{question.passage_text}
</div>
)}
<p className="text-lg font-medium leading-snug">{question.stem}</p>
{(question.type === "mcq" || question.type === "definition_match") && question.options && (
<RadioGroup value={singleAnswer} onValueChange={setSingleAnswer} className="space-y-3">
{question.options.map((o) => (
<label
key={o.label}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-colors",
singleAnswer === o.label && "border-primary bg-primary/5",
)}
>
<RadioGroupItem value={o.label} id={`opt-${o.label}`} />
<span className="text-sm leading-snug">{o.text}</span>
</label>
))}
</RadioGroup>
)}
{question.type === "mcq_multi" && question.options && (
<div className="space-y-3">
{question.options.map((o) => (
<label
key={o.label}
className="flex cursor-pointer items-center gap-3 rounded-lg border p-4"
>
<Checkbox
checked={multiAnswer.includes(o.label)}
onCheckedChange={() => toggleMulti(o.label)}
/>
<span className="text-sm">{o.text}</span>
</label>
))}
</div>
)}
{question.type === "gap_fill" && (
<Input
value={singleAnswer}
onChange={(e) => setSingleAnswer(e.target.value)}
placeholder="Your answer"
className="text-base"
/>
)}
{question.type === "tfng" && (
<RadioGroup value={singleAnswer} onValueChange={setSingleAnswer} className="space-y-3">
{(question.options?.length
? question.options
: [
{ label: "T", text: "True" },
{ label: "F", text: "False" },
{ label: "NG", text: "Not Given" },
]
).map((o) => (
<label
key={o.label}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-lg border p-4",
singleAnswer === o.label && "border-primary bg-primary/5",
)}
>
<RadioGroupItem value={o.label} id={`tfng-${o.label}`} />
<span className="text-sm">{o.text}</span>
</label>
))}
</RadioGroup>
)}
{question.type === "audio_recording" && (
<div className="flex flex-col items-center gap-4 py-8 rounded-xl border border-dashed bg-muted/30">
<Mic className="h-12 w-12 text-muted-foreground" />
<p className="text-sm text-muted-foreground text-center max-w-sm">
Recording will be available in a future update. Use the button below to proceed for now.
</p>
<Button type="button" variant="secondary" size="lg" disabled>
Record (placeholder)
</Button>
</div>
)}
<div className="flex justify-end pt-4">
<Button
size="lg"
className="min-w-[160px]"
disabled={answerMut.isPending || betweenQuestions}
onClick={() => void submit()}
>
{answerMut.isPending || betweenQuestions ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving
</>
) : approxFinal ? (
"Finish Test"
) : (
"Submit answer"
)}
</Button>
</div>
</CardContent>
</Card>
)}
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,117 @@
import { useParams, useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { ArrowRight, Target, TrendingUp } from "lucide-react";
import { useProficiency } from "@/hooks/queries";
function getMasteryColor(mastery: number): string {
if (mastery >= 80) return "text-green-600";
if (mastery >= 60) return "text-blue-600";
if (mastery >= 40) return "text-yellow-600";
if (mastery >= 20) return "text-orange-600";
return "text-red-600";
}
function getMasteryBg(mastery: number): string {
if (mastery >= 80) return "bg-green-100";
if (mastery >= 60) return "bg-blue-100";
if (mastery >= 40) return "bg-yellow-100";
if (mastery >= 20) return "bg-orange-100";
return "bg-red-100";
}
export default function ProficiencyProfile() {
const { subjectId } = useParams<{ subjectId: string }>();
const navigate = useNavigate();
const { data: proficiencies = [], isLoading } = useProficiency({ subject_id: Number(subjectId) });
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const domains = [...new Set(proficiencies.map(p => p.domain_name))];
const overallMastery = proficiencies.length > 0 ? proficiencies.reduce((sum, p) => sum + p.mastery, 0) / proficiencies.length : 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Proficiency Profile</h1>
<p className="text-muted-foreground">Your knowledge map across all topics.</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => navigate(`/student/diagnostic/${subjectId}`)}>Retake Diagnostic</Button>
<Button onClick={() => navigate(`/student/plan/${subjectId}`)}>Learning Plan <ArrowRight className="ml-1 h-4 w-4" /></Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6 text-center">
<Target className="h-8 w-8 mx-auto text-primary mb-2" />
<div className="text-3xl font-bold">{Math.round(overallMastery)}%</div>
<p className="text-sm text-muted-foreground">Overall Mastery</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<TrendingUp className="h-8 w-8 mx-auto text-green-500 mb-2" />
<div className="text-3xl font-bold">{proficiencies.filter(p => p.mastery >= 80).length}</div>
<p className="text-sm text-muted-foreground">Topics Mastered</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<div className="h-8 w-8 mx-auto text-orange-500 mb-2 text-2xl font-bold">{proficiencies.filter(p => p.mastery < 40).length}</div>
<p className="text-sm text-muted-foreground">Topics Needing Work</p>
</CardContent>
</Card>
</div>
{domains.map((domain) => {
const topics = proficiencies.filter(p => p.domain_name === domain);
const domainMastery = topics.reduce((s, t) => s + t.mastery, 0) / topics.length;
return (
<Card key={domain}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{domain}</CardTitle>
<Badge variant="outline" className={getMasteryColor(domainMastery)}>{Math.round(domainMastery)}%</Badge>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{topics.map((topic) => (
<div
key={topic.topic_id}
className={`p-3 rounded-lg border ${getMasteryBg(topic.mastery)} cursor-pointer hover:shadow-sm transition-shadow`}
onClick={() => navigate(`/student/topic/${topic.topic_id}`)}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium truncate">{topic.topic_name}</span>
<span className={`text-sm font-semibold ${getMasteryColor(topic.mastery)}`}>{Math.round(topic.mastery)}%</span>
</div>
<Progress value={topic.mastery} className="h-1.5" />
<div className="flex items-center justify-between mt-1">
<Badge variant="secondary" className="text-xs">{topic.mastery_level}</Badge>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
})}
{proficiencies.length === 0 && (
<Card>
<CardContent className="text-center py-12 text-muted-foreground">
<p>No proficiency data yet. Take a diagnostic assessment first.</p>
<Button className="mt-4" onClick={() => navigate(`/student/diagnostic/${subjectId}`)}>Start Diagnostic</Button>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Megaphone, AlertTriangle, AlertCircle, Info } from "lucide-react";
import { useAnnouncements } from "@/hooks/queries";
import type { Announcement } from "@/types/communication";
const priorityConfig: Record<Announcement["priority"], { variant: "destructive" | "default" | "secondary"; icon: React.ReactNode }> = {
urgent: { variant: "destructive", icon: <AlertTriangle className="h-4 w-4" /> },
important: { variant: "default", icon: <AlertCircle className="h-4 w-4" /> },
normal: { variant: "secondary", icon: <Info className="h-4 w-4" /> },
};
export default function StudentAnnouncements() {
const { data: announcements = [], isLoading } = useAnnouncements();
const sorted = [...announcements]
.filter(a => a.is_published)
.sort((a, b) => new Date(b.published_at ?? b.expires_at ?? "").getTime() - new Date(a.published_at ?? a.expires_at ?? "").getTime());
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Announcements</h1>
<p className="text-muted-foreground">Stay up to date with the latest announcements from your courses.</p>
</div>
<div className="space-y-3">
{sorted.map(a => {
const cfg = priorityConfig[a.priority];
return (
<Card key={a.id} className={a.priority === "urgent" ? "border-destructive/30" : ""}>
<CardContent className="flex items-start gap-4 py-4">
<div className={`h-10 w-10 rounded-lg flex items-center justify-center shrink-0 ${a.priority === "urgent" ? "bg-destructive/10 text-destructive" : a.priority === "important" ? "bg-yellow-500/10 text-yellow-600" : "bg-primary/10 text-primary"}`}>
{cfg.icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{a.title}</span>
<Badge variant={cfg.variant}>{a.priority}</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">{a.content}</p>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
<span>{a.author_name}</span>
{a.course_name && <><span>·</span><span>{a.course_name}</span></>}
{a.published_at && <><span>·</span><span>{new Date(a.published_at).toLocaleDateString()}</span></>}
</div>
</div>
</CardContent>
</Card>
);
})}
{sorted.length === 0 && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Megaphone className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>No announcements right now.</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
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 { useToast } from "@/hooks/use-toast";
import AiWritingHelper from "@/components/ai/AiWritingHelper";
import AiTipBanner from "@/components/ai/AiTipBanner";
export default function StudentAssignments() {
const [submitOpen, setSubmitOpen] = useState(false);
const [selectedAssignment, setSelectedAssignment] = useState<string | null>(null);
const [draftText, setDraftText] = useState("");
const { toast } = useToast();
const { data: assignmentsData, isLoading } = useAssignments();
const assignments = assignmentsData?.items ?? [];
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const handleSubmit = () => {
setSubmitOpen(false);
toast({ title: "Assignment Submitted", description: "Your work has been submitted successfully." });
};
const statusFilter = (status: string) => assignments.filter(a => status === "all" ? true : a.status === status);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Assignments</h1>
<p className="text-muted-foreground">View and submit your assignments.</p>
</div>
<AiTipBanner context="student-assignments" variant="recommendation" />
<Tabs defaultValue="all">
<TabsList>
<TabsTrigger value="all">All ({assignments.length})</TabsTrigger>
<TabsTrigger value="pending">Pending ({statusFilter("pending").length})</TabsTrigger>
<TabsTrigger value="submitted">Submitted ({statusFilter("submitted").length})</TabsTrigger>
<TabsTrigger value="graded">Graded ({statusFilter("graded").length})</TabsTrigger>
</TabsList>
{["all", "pending", "submitted", "graded", "overdue"].map(tab => (
<TabsContent key={tab} value={tab} className="mt-4 space-y-3">
{statusFilter(tab).map(a => (
<Card key={a.id}>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="font-medium text-sm">{a.title}</p>
<p className="text-xs text-muted-foreground">{a.courseName} · Due: {a.dueDate}</p>
{a.grade !== undefined && <p className="text-xs font-medium text-primary mt-1">Grade: {a.grade}/{a.maxGrade}</p>}
{a.feedback && <p className="text-xs text-muted-foreground mt-0.5">"{a.feedback}"</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant={a.status === "graded" ? "default" : a.status === "overdue" ? "destructive" : "secondary"}>{a.status}</Badge>
{a.status === "pending" && (
<Button size="sm" onClick={() => { setSelectedAssignment(a.id); setSubmitOpen(true); }}>Submit</Button>
)}
</div>
</div>
</CardContent>
</Card>
))}
</TabsContent>
))}
</Tabs>
<Dialog open={submitOpen} onOpenChange={setSubmitOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Submit Assignment</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="border-2 border-dashed rounded-lg p-8 text-center text-muted-foreground">
<Upload className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">Drag & drop your file here or click to browse</p>
<p className="text-xs mt-1">PDF, DOC, DOCX up to 10MB</p>
</div>
<Textarea placeholder="Add a comment (optional)..." rows={3} value={draftText} onChange={(e) => setDraftText(e.target.value)} />
<AiWritingHelper text={draftText} task_type="ielts_writing" />
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSubmitOpen(false)}>Cancel</Button>
<Button onClick={handleSubmit}>Submit</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More