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:
216
src/pages/AdminDashboard.tsx
Normal file
216
src/pages/AdminDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
205
src/pages/AdmissionApplication.tsx
Normal file
205
src/pages/AdmissionApplication.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
src/pages/ApprovalWorkflowsPage.tsx
Normal file
119
src/pages/ApprovalWorkflowsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
src/pages/AssignmentsPage.tsx
Normal file
118
src/pages/AssignmentsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
src/pages/ClassroomsPage.tsx
Normal file
133
src/pages/ClassroomsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
src/pages/EmailVerification.tsx
Normal file
191
src/pages/EmailVerification.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
95
src/pages/EntitiesPage.tsx
Normal file
95
src/pages/EntitiesPage.tsx
Normal 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
54
src/pages/ExamPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
src/pages/ExamStructuresPage.tsx
Normal file
85
src/pages/ExamStructuresPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
src/pages/ExamsListPage.tsx
Normal file
76
src/pages/ExamsListPage.tsx
Normal 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
96
src/pages/FaqPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/pages/ForgotPassword.tsx
Normal file
59
src/pages/ForgotPassword.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
src/pages/GenerationPage.tsx
Normal file
95
src/pages/GenerationPage.tsx
Normal 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
94
src/pages/GrammarPage.tsx
Normal 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
14
src/pages/Index.tsx
Normal 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
141
src/pages/Login.tsx
Normal 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
24
src/pages/NotFound.tsx
Normal 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;
|
||||
88
src/pages/OfficialExamAccess.tsx
Normal file
88
src/pages/OfficialExamAccess.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
572
src/pages/OnboardingWizard.tsx
Normal file
572
src/pages/OnboardingWizard.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
120
src/pages/PaymentRecordPage.tsx
Normal file
120
src/pages/PaymentRecordPage.tsx
Normal 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
75
src/pages/ProfilePage.tsx
Normal 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
68
src/pages/RecordPage.tsx
Normal 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
289
src/pages/Register.tsx
Normal 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
147
src/pages/ResetPassword.tsx
Normal 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}'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
122
src/pages/RubricsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
src/pages/ScoreVerification.tsx
Normal file
50
src/pages/ScoreVerification.tsx
Normal 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
103
src/pages/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
src/pages/StatsCorporatePage.tsx
Normal file
133
src/pages/StatsCorporatePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
src/pages/StudentPerformancePage.tsx
Normal file
79
src/pages/StudentPerformancePage.tsx
Normal 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
164
src/pages/TicketsPage.tsx
Normal 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
335
src/pages/UsersPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
src/pages/VocabularyPage.tsx
Normal file
96
src/pages/VocabularyPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
209
src/pages/admin/AcademicYearManager.tsx
Normal file
209
src/pages/admin/AcademicYearManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
src/pages/admin/AdaptiveDashboard.tsx
Normal file
158
src/pages/admin/AdaptiveDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
src/pages/admin/AdaptiveStudentDetail.tsx
Normal file
105
src/pages/admin/AdaptiveStudentDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
281
src/pages/admin/AdminActivities.tsx
Normal file
281
src/pages/admin/AdminActivities.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
src/pages/admin/AdminBatchDetail.tsx
Normal file
89
src/pages/admin/AdminBatchDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
src/pages/admin/AdminBatches.tsx
Normal file
95
src/pages/admin/AdminBatches.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
src/pages/admin/AdminCourses.tsx
Normal file
109
src/pages/admin/AdminCourses.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
251
src/pages/admin/AdminFacilities.tsx
Normal file
251
src/pages/admin/AdminFacilities.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
167
src/pages/admin/AdminFees.tsx
Normal file
167
src/pages/admin/AdminFees.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
219
src/pages/admin/AdminGradebook.tsx
Normal file
219
src/pages/admin/AdminGradebook.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
269
src/pages/admin/AdminLessons.tsx
Normal file
269
src/pages/admin/AdminLessons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
328
src/pages/admin/AdminLibrary.tsx
Normal file
328
src/pages/admin/AdminLibrary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
202
src/pages/admin/AdminLmsDashboard.tsx
Normal file
202
src/pages/admin/AdminLmsDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
src/pages/admin/AdminProfile.tsx
Normal file
69
src/pages/admin/AdminProfile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src/pages/admin/AdminReports.tsx
Normal file
97
src/pages/admin/AdminReports.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
src/pages/admin/AdminSettings.tsx
Normal file
80
src/pages/admin/AdminSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
252
src/pages/admin/AdminStudentLeave.tsx
Normal file
252
src/pages/admin/AdminStudentLeave.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/pages/admin/AdminStudentProgress.tsx
Normal file
70
src/pages/admin/AdminStudentProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
334
src/pages/admin/AdminStudents.tsx
Normal file
334
src/pages/admin/AdminStudents.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
src/pages/admin/AdminTeachers.tsx
Normal file
178
src/pages/admin/AdminTeachers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
src/pages/admin/AdminTimetable.tsx
Normal file
146
src/pages/admin/AdminTimetable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
173
src/pages/admin/AdmissionDetail.tsx
Normal file
173
src/pages/admin/AdmissionDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
src/pages/admin/AdmissionList.tsx
Normal file
111
src/pages/admin/AdmissionList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
src/pages/admin/AdmissionRegisterPage.tsx
Normal file
182
src/pages/admin/AdmissionRegisterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
src/pages/admin/AiEnglishQuality.tsx
Normal file
136
src/pages/admin/AiEnglishQuality.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
src/pages/admin/AiEnglishTaxonomy.tsx
Normal file
108
src/pages/admin/AiEnglishTaxonomy.tsx
Normal 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>;
|
||||
}
|
||||
207
src/pages/admin/AiIeltsValidation.tsx
Normal file
207
src/pages/admin/AiIeltsValidation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
222
src/pages/admin/ApprovalWorkflowConfig.tsx
Normal file
222
src/pages/admin/ApprovalWorkflowConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
348
src/pages/admin/AuthorityMatrix.tsx
Normal file
348
src/pages/admin/AuthorityMatrix.tsx
Normal 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);
|
||||
}
|
||||
218
src/pages/admin/BulkStudentUpload.tsx
Normal file
218
src/pages/admin/BulkStudentUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
367
src/pages/admin/CourseConfig.tsx
Normal file
367
src/pages/admin/CourseConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
206
src/pages/admin/CredentialDashboard.tsx
Normal file
206
src/pages/admin/CredentialDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
597
src/pages/admin/CustomExamCreate.tsx
Normal file
597
src/pages/admin/CustomExamCreate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
src/pages/admin/DepartmentManager.tsx
Normal file
155
src/pages/admin/DepartmentManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
209
src/pages/admin/ExamTemplateSelection.tsx
Normal file
209
src/pages/admin/ExamTemplateSelection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
285
src/pages/admin/FaqManager.tsx
Normal file
285
src/pages/admin/FaqManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
227
src/pages/admin/GradingQueue.tsx
Normal file
227
src/pages/admin/GradingQueue.tsx
Normal 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">0–9</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>
|
||||
);
|
||||
}
|
||||
353
src/pages/admin/IeltsContentPool.tsx
Normal file
353
src/pages/admin/IeltsContentPool.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
426
src/pages/admin/IeltsExamCreate.tsx
Normal file
426
src/pages/admin/IeltsExamCreate.tsx
Normal 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 today’s 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 13–14 questions each (60 minutes).</li>
|
||||
<li>Writing: 2 tasks (60 minutes).</li>
|
||||
<li>Speaking: 3 parts (11–14 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>
|
||||
);
|
||||
}
|
||||
246
src/pages/admin/IeltsExamValidation.tsx
Normal file
246
src/pages/admin/IeltsExamValidation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
210
src/pages/admin/IeltsSkillConfig.tsx
Normal file
210
src/pages/admin/IeltsSkillConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
274
src/pages/admin/InstitutionalExamSessions.tsx
Normal file
274
src/pages/admin/InstitutionalExamSessions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
src/pages/admin/LevelMappingConfig.tsx
Normal file
170
src/pages/admin/LevelMappingConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
312
src/pages/admin/MarksheetManager.tsx
Normal file
312
src/pages/admin/MarksheetManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
335
src/pages/admin/ModuleBuilder.tsx
Normal file
335
src/pages/admin/ModuleBuilder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
196
src/pages/admin/NotificationRules.tsx
Normal file
196
src/pages/admin/NotificationRules.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
src/pages/admin/ResourceManager.tsx
Normal file
187
src/pages/admin/ResourceManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
381
src/pages/admin/RolesPermissions.tsx
Normal file
381
src/pages/admin/RolesPermissions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
193
src/pages/admin/ScoreApprovalQueue.tsx
Normal file
193
src/pages/admin/ScoreApprovalQueue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
238
src/pages/admin/TaxonomyManager.tsx
Normal file
238
src/pages/admin/TaxonomyManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
383
src/pages/admin/UserRoles.tsx
Normal file
383
src/pages/admin/UserRoles.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
285
src/pages/admin/WhiteLabelBranding.tsx
Normal file
285
src/pages/admin/WhiteLabelBranding.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
291
src/pages/student/AiEnglishCourse.tsx
Normal file
291
src/pages/student/AiEnglishCourse.tsx
Normal 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'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 },
|
||||
];
|
||||
}
|
||||
317
src/pages/student/AiIeltsCourse.tsx
Normal file
317
src/pages/student/AiIeltsCourse.tsx
Normal 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 },
|
||||
];
|
||||
}
|
||||
204
src/pages/student/CourseDelivery.tsx
Normal file
204
src/pages/student/CourseDelivery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/pages/student/DiagnosticTest.tsx
Normal file
127
src/pages/student/DiagnosticTest.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
src/pages/student/ExamResults.tsx
Normal file
178
src/pages/student/ExamResults.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
458
src/pages/student/ExamSession.tsx
Normal file
458
src/pages/student/ExamSession.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
src/pages/student/ExamStatus.tsx
Normal file
111
src/pages/student/ExamStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
210
src/pages/student/GapAnalysis.tsx
Normal file
210
src/pages/student/GapAnalysis.tsx
Normal 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&examId=…</code> or{" "}
|
||||
<code className="rounded bg-muted px-1">?from=placement&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>
|
||||
);
|
||||
}
|
||||
122
src/pages/student/LearningPlan.tsx
Normal file
122
src/pages/student/LearningPlan.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
src/pages/student/PlacementAccess.tsx
Normal file
135
src/pages/student/PlacementAccess.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
src/pages/student/PlacementBriefing.tsx
Normal file
132
src/pages/student/PlacementBriefing.tsx
Normal 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">20–30 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>
|
||||
);
|
||||
}
|
||||
281
src/pages/student/PlacementResults.tsx
Normal file
281
src/pages/student/PlacementResults.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
364
src/pages/student/PlacementTest.tsx
Normal file
364
src/pages/student/PlacementTest.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
117
src/pages/student/ProficiencyProfile.tsx
Normal file
117
src/pages/student/ProficiencyProfile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/pages/student/StudentAnnouncements.tsx
Normal file
65
src/pages/student/StudentAnnouncements.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
src/pages/student/StudentAssignments.tsx
Normal file
93
src/pages/student/StudentAssignments.tsx
Normal 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
Reference in New Issue
Block a user