feat(v3): restructure project + add complete frontend

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

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

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-10 17:26:42 +04:00
parent a3e12f62fa
commit f1c4953a63
731 changed files with 67205 additions and 139 deletions

View File

@@ -0,0 +1,198 @@
import { useEffect } from "react";
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 { Slider } from "@/components/ui/slider";
import { useAdaptiveSettings, useUpdateAdaptiveSettings } from "@/hooks/queries/useAdaptiveEngine";
import { toast } from "sonner";
const schema = z.object({
difficulty_step_up_threshold: z.number().min(0).max(100),
difficulty_step_down_threshold: z.number().min(0).max(100),
micro_lesson_trigger: z.number().min(0).max(50),
module_skip_threshold: z.number().min(0).max(100),
no_progress_alert_days: z.number().min(1).max(90),
max_retries_per_exercise: z.number().min(0).max(20),
});
type FormValues = z.infer<typeof schema>;
const defaults: FormValues = {
difficulty_step_up_threshold: 85,
difficulty_step_down_threshold: 50,
micro_lesson_trigger: 2,
module_skip_threshold: 95,
no_progress_alert_days: 3,
max_retries_per_exercise: 3,
};
export default function AdaptiveSettings() {
const { data, isLoading } = useAdaptiveSettings();
const update = useUpdateAdaptiveSettings();
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
useEffect(() => {
if (data) {
form.reset({
difficulty_step_up_threshold: data.difficulty_step_up_threshold,
difficulty_step_down_threshold: data.difficulty_step_down_threshold,
micro_lesson_trigger: data.micro_lesson_trigger,
module_skip_threshold: data.module_skip_threshold,
no_progress_alert_days: data.no_progress_alert_days,
max_retries_per_exercise: data.max_retries_per_exercise,
});
}
}, [data, form]);
const onSubmit = (values: FormValues) => {
update.mutate(values, {
onSuccess: () => toast.success("Settings saved."),
onError: (e) => toast.error(e instanceof Error ? e.message : "Save failed"),
});
};
if (isLoading && !data) {
return <div className="p-6 text-muted-foreground">Loading settings</div>;
}
return (
<div className="space-y-6 p-6 max-w-xl mx-auto">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Adaptive thresholds</h1>
<p className="text-muted-foreground text-sm mt-1">Tune pacing and escalation</p>
</div>
<Card>
<CardHeader>
<CardTitle>Engine parameters</CardTitle>
<CardDescription>Sliders use whole percentages unless noted</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="difficulty_step_up_threshold"
render={({ field }) => (
<FormItem>
<FormLabel>Difficulty step-up threshold ({field.value}%)</FormLabel>
<FormControl>
<Slider
min={0}
max={100}
step={1}
value={[field.value]}
onValueChange={(v) => field.onChange(v[0])}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="difficulty_step_down_threshold"
render={({ field }) => (
<FormItem>
<FormLabel>Difficulty step-down threshold ({field.value}%)</FormLabel>
<FormControl>
<Slider
min={0}
max={100}
step={1}
value={[field.value]}
onValueChange={(v) => field.onChange(v[0])}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="micro_lesson_trigger"
render={({ field }) => (
<FormItem>
<FormLabel>Micro-lesson trigger</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="module_skip_threshold"
render={({ field }) => (
<FormItem>
<FormLabel>Module skip threshold ({field.value}%)</FormLabel>
<FormControl>
<Slider
min={0}
max={100}
step={1}
value={[field.value]}
onValueChange={(v) => field.onChange(v[0])}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="no_progress_alert_days"
render={({ field }) => (
<FormItem>
<FormLabel>No-progress alert (days)</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="max_retries_per_exercise"
render={({ field }) => (
<FormItem>
<FormLabel>Max retries per exercise</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={update.isPending}>
Save Settings
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,201 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
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 { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Loader2, Sparkles, ChevronRight, CheckCircle2, BookOpen } from "lucide-react";
import { useGenerateOutline, useGenerateChapterContent, usePublishWorkbench } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { WorkbenchGeneratedOutline, WorkbenchGeneratedChapter } from "@/types/courseware";
type Complexity = "beginner" | "intermediate" | "advanced";
export default function AiWorkbench() {
const { courseId } = useParams<{ courseId: string }>();
const cid = Number(courseId);
const { toast } = useToast();
const [step, setStep] = useState(1);
const [topic, setTopic] = useState("");
const [objectives, setObjectives] = useState("");
const [complexity, setComplexity] = useState<Complexity>("intermediate");
const [outline, setOutline] = useState<WorkbenchGeneratedOutline | null>(null);
const [generatedContent, setGeneratedContent] = useState<WorkbenchGeneratedChapter | null>(null);
const generateOutline = useGenerateOutline();
const generateContent = useGenerateChapterContent();
const publishWorkbench = usePublishWorkbench();
const handleGenerateOutline = () => {
generateOutline.mutate(
{ course_id: cid, topic, objectives, complexity },
{
onSuccess: (data) => { setOutline(data); setStep(2); },
onError: () => toast({ title: "Error", description: "Failed to generate outline", variant: "destructive" }),
},
);
};
const handleGenerateContent = () => {
generateContent.mutate(
{ course_id: cid, topic, objectives, complexity },
{
onSuccess: (data) => { setGeneratedContent(data); setStep(3); },
onError: () => toast({ title: "Error", description: "Failed to generate content", variant: "destructive" }),
},
);
};
const handlePublish = () => {
publishWorkbench.mutate(
{ course_id: cid, topic, objectives, complexity, outline, content: generatedContent },
{
onSuccess: () => { toast({ title: "Published Successfully" }); setStep(4); },
onError: () => toast({ title: "Error", description: "Failed to publish", variant: "destructive" }),
},
);
};
const steps = [
{ num: 1, label: "Requirements" },
{ num: 2, label: "Review Outline" },
{ num: 3, label: "Generated Content" },
{ num: 4, label: "Published" },
];
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">AI Workbench</h1>
<p className="text-muted-foreground">Generate course content using AI assistance.</p>
</div>
<div className="flex items-center gap-2">
{steps.map((s, i) => (
<div key={s.num} className="flex items-center">
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm ${step >= s.num ? "bg-primary/10 text-primary font-medium" : "text-muted-foreground"}`}>
{step > s.num ? <CheckCircle2 className="h-4 w-4" /> : <span className="h-5 w-5 rounded-full border flex items-center justify-center text-xs">{s.num}</span>}
{s.label}
</div>
{i < steps.length - 1 && <ChevronRight className="h-4 w-4 text-muted-foreground mx-1" />}
</div>
))}
</div>
{step === 1 && (
<Card>
<CardHeader>
<CardTitle>Input Requirements</CardTitle>
<CardDescription>Describe what content you want the AI to generate.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Topic</Label>
<Input placeholder="e.g. Introduction to Calculus" value={topic} onChange={e => setTopic(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Learning Objectives</Label>
<Textarea placeholder="List the key objectives, one per line..." rows={4} value={objectives} onChange={e => setObjectives(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Complexity Level</Label>
<Select value={complexity} onValueChange={v => setComplexity(v as Complexity)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="beginner">Beginner</SelectItem>
<SelectItem value="intermediate">Intermediate</SelectItem>
<SelectItem value="advanced">Advanced</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleGenerateOutline} disabled={generateOutline.isPending || !topic || !objectives}>
{generateOutline.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Sparkles className="mr-2 h-4 w-4" /> Generate Outline
</Button>
</CardContent>
</Card>
)}
{step === 2 && outline && (
<Card>
<CardHeader>
<CardTitle>Review Generated Outline</CardTitle>
<CardDescription>Review the AI-generated chapter outline before generating detailed content.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{outline.chapters.map((ch, i) => (
<div key={i} className="p-4 rounded-lg border space-y-2">
<div className="flex items-center gap-2">
<BookOpen className="h-4 w-4 text-primary" />
<span className="font-medium">{ch.name}</span>
<Badge variant="outline">{ch.estimated_duration_hours}h</Badge>
</div>
<p className="text-sm text-muted-foreground">{ch.description}</p>
<div className="flex flex-wrap gap-1">
{ch.learning_outcomes.map((lo, j) => (
<Badge key={j} variant="secondary" className="text-xs">{lo}</Badge>
))}
</div>
</div>
))}
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep(1)}>Back</Button>
<Button onClick={handleGenerateContent} disabled={generateContent.isPending}>
{generateContent.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Generate Content
</Button>
</div>
</CardContent>
</Card>
)}
{step === 3 && generatedContent && (
<Card>
<CardHeader>
<CardTitle>Generated Content</CardTitle>
<CardDescription>Review the detailed content before publishing.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="prose prose-sm max-w-none p-4 rounded-lg border bg-muted/30" dangerouslySetInnerHTML={{ __html: generatedContent.content }} />
{generatedContent.exercises.length > 0 && (
<div className="space-y-2">
<h3 className="font-medium">Exercises ({generatedContent.exercises.length})</h3>
{generatedContent.exercises.map((ex, i) => (
<div key={i} className="p-3 rounded border text-sm">
<p className="font-medium">{ex.question}</p>
<p className="text-muted-foreground mt-1">{ex.answer}</p>
<Badge variant="outline" className="mt-1 text-xs">{ex.type}</Badge>
</div>
))}
</div>
)}
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep(2)}>Back</Button>
<Button onClick={handlePublish} disabled={publishWorkbench.isPending}>
{publishWorkbench.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Publish
</Button>
</div>
</CardContent>
</Card>
)}
{step === 4 && (
<Card>
<CardContent className="py-12 text-center">
<CheckCircle2 className="h-12 w-12 text-green-500 mx-auto mb-3" />
<h2 className="text-xl font-semibold">Content Published!</h2>
<p className="text-muted-foreground mt-1">The AI-generated content has been added to your course.</p>
<Button variant="outline" className="mt-4" onClick={() => { setStep(1); setOutline(null); setGeneratedContent(null); setTopic(""); setObjectives(""); }}>
Generate More
</Button>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,200 @@
import { useState, useRef } from "react";
import { useParams } 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Plus, Trash2, Loader2, FileText, Video, Image, Link2, Upload, Music } from "lucide-react";
import { useChapter, useChapterMaterials, useUploadMaterial, useDeleteMaterial } from "@/hooks/queries";
import { coursewareService } from "@/services/courseware.service";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import type { MaterialType } from "@/types/courseware";
const typeIcons: Record<MaterialType, React.ReactNode> = {
pdf: <FileText className="h-4 w-4" />,
document: <FileText className="h-4 w-4" />,
video: <Video className="h-4 w-4" />,
audio: <Music className="h-4 w-4" />,
image: <Image className="h-4 w-4" />,
link: <Link2 className="h-4 w-4" />,
article: <FileText className="h-4 w-4" />,
};
export default function ChapterDetail() {
const { courseId, chapterId } = useParams<{ courseId: string; chapterId: string }>();
const chId = Number(chapterId);
const { toast } = useToast();
const qc = useQueryClient();
const { data: chapter, isLoading: loadingChapter } = useChapter(chId);
const { data: materials = [], isLoading: loadingMaterials } = useChapterMaterials(chId);
const uploadMaterial = useUploadMaterial();
const deleteMaterial = useDeleteMaterial();
const fileRef = useRef<HTMLInputElement>(null);
const [showUpload, setShowUpload] = useState(false);
const [matName, setMatName] = useState("");
const [matType, setMatType] = useState<MaterialType>("pdf");
const [matUrl, setMatUrl] = useState("");
const [matFile, setMatFile] = useState<File | null>(null);
const [allowDownload, setAllowDownload] = useState(true);
const toggleDownload = useMutation({
mutationFn: ({ id, allow }: { id: number; allow: boolean }) =>
coursewareService.updateMaterial(id, { allow_download: allow }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["chapter-materials", chId] }),
onError: () => toast({ title: "Error", description: "Failed to update material", variant: "destructive" }),
});
const resetForm = () => { setMatName(""); setMatType("pdf"); setMatUrl(""); setMatFile(null); setAllowDownload(true); };
const handleUpload = () => {
const formData = new FormData();
formData.append("name", matName);
formData.append("type", matType);
formData.append("allow_download", String(allowDownload));
if (matFile) formData.append("file", matFile);
if (matUrl) formData.append("url", matUrl);
uploadMaterial.mutate(
{ chapterId: chId, formData },
{
onSuccess: () => { toast({ title: "Material Uploaded" }); setShowUpload(false); resetForm(); },
onError: () => toast({ title: "Error", description: "Failed to upload material", variant: "destructive" }),
},
);
};
const handleDelete = (id: number) => {
deleteMaterial.mutate(
{ id, chapterId: chId },
{
onSuccess: () => toast({ title: "Material Deleted" }),
onError: () => toast({ title: "Error", description: "Failed to delete material", variant: "destructive" }),
},
);
};
if (loadingChapter || loadingMaterials) 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">
{chapter && (
<Card>
<CardHeader>
<CardTitle>{chapter.name}</CardTitle>
<CardDescription>{chapter.description}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-4 text-sm">
{chapter.start_date && <span className="text-muted-foreground">Starts: {new Date(chapter.start_date).toLocaleDateString()}</span>}
{chapter.end_date && <span className="text-muted-foreground">Ends: {new Date(chapter.end_date).toLocaleDateString()}</span>}
<Badge variant={chapter.is_unlocked ? "default" : "secondary"}>
{chapter.is_unlocked ? "Unlocked" : "Locked"}
</Badge>
<Badge variant="outline">{chapter.unlock_mode}</Badge>
</div>
</CardContent>
</Card>
)}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Materials</h2>
<Dialog open={showUpload} onOpenChange={setShowUpload}>
<DialogTrigger asChild>
<Button size="sm"><Plus className="mr-2 h-4 w-4" /> Upload Material</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Upload Material</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="Material name" value={matName} onChange={e => setMatName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Type</Label>
<Select value={matType} onValueChange={v => setMatType(v as MaterialType)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="document">Document</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="audio">Audio</SelectItem>
<SelectItem value="image">Image</SelectItem>
<SelectItem value="link">Link</SelectItem>
<SelectItem value="article">Article</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>File</Label>
<Input ref={fileRef} type="file" onChange={e => setMatFile(e.target.files?.[0] ?? null)} />
</div>
<div className="space-y-2">
<Label>Or URL</Label>
<Input placeholder="https://..." value={matUrl} onChange={e => setMatUrl(e.target.value)} />
</div>
<div className="flex items-center gap-2">
<Checkbox id="allow-dl" checked={allowDownload} onCheckedChange={v => setAllowDownload(!!v)} />
<Label htmlFor="allow-dl">Allow Download</Label>
</div>
<Button className="w-full" onClick={handleUpload} disabled={uploadMaterial.isPending || !matName}>
{uploadMaterial.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Upload className="mr-2 h-4 w-4" /> Upload
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Download</TableHead>
<TableHead className="w-16">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{materials
.sort((a, b) => a.sequence - b.sequence)
.map(m => (
<TableRow key={m.id}>
<TableCell className="text-muted-foreground">{m.sequence}</TableCell>
<TableCell className="flex items-center gap-2">
{typeIcons[m.type]}
<span>{m.name}</span>
</TableCell>
<TableCell><Badge variant="outline">{m.type}</Badge></TableCell>
<TableCell>
<Checkbox
checked={m.allow_download}
onCheckedChange={v => toggleDownload.mutate({ id: m.id, allow: !!v })}
/>
</TableCell>
<TableCell>
<Button variant="ghost" size="icon" onClick={() => handleDelete(m.id)} disabled={deleteMaterial.isPending}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
{materials.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">No materials uploaded yet.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
</div>
);
}

View File

@@ -0,0 +1,119 @@
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useNavigate } from "react-router-dom";
import { useToast } from "@/hooks/use-toast";
import { useCreateCourse } from "@/hooks/queries";
const steps = ["Basic Info", "Schedule", "Review"];
export default function CourseBuilder() {
const [step, setStep] = useState(0);
const navigate = useNavigate();
const { toast } = useToast();
const createMut = useCreateCourse();
const [form, setForm] = useState({
title: "", code: "", description: "",
level: "Beginner" as "Beginner" | "Intermediate" | "Advanced",
start_date: "", end_date: "", max_capacity: "",
});
function handlePublish() {
createMut.mutate(
{
title: form.title,
code: form.code,
description: form.description,
level: form.level,
instructor_id: 0,
max_capacity: Number(form.max_capacity) || 30,
start_date: form.start_date,
end_date: form.end_date,
},
{
onSuccess: () => {
toast({ title: "Course Created", description: "Your new course has been saved." });
navigate("/admin/courses");
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
},
);
}
return (
<div className="space-y-6 max-w-3xl">
<div>
<h1 className="text-2xl font-bold">Course Builder</h1>
<p className="text-muted-foreground">Create a new course in {steps.length} steps.</p>
</div>
<div className="flex gap-2 mb-6">
{steps.map((s, i) => (
<div key={s} className={`flex-1 h-2 rounded-full ${i <= step ? "bg-primary" : "bg-muted"}`} />
))}
</div>
<p className="text-sm font-medium mb-4">Step {step + 1}: {steps[step]}</p>
<Card>
<CardContent className="pt-6 space-y-4">
{step === 0 && (
<>
<div className="space-y-2"><Label>Course Title</Label><Input value={form.title} onChange={(e) => setForm(f => ({ ...f, title: e.target.value }))} placeholder="e.g. IELTS Academic Writing" /></div>
<div className="space-y-2"><Label>Course Code</Label><Input value={form.code} onChange={(e) => setForm(f => ({ ...f, code: e.target.value }))} placeholder="e.g. IELTS-W101" /></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 course..." rows={4} /></div>
<div className="space-y-2">
<Label>Level</Label>
<Select value={form.level} onValueChange={(v) => setForm(f => ({ ...f, level: v as typeof form.level }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Beginner">Beginner</SelectItem>
<SelectItem value="Intermediate">Intermediate</SelectItem>
<SelectItem value="Advanced">Advanced</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
{step === 1 && (
<>
<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>Max Capacity</Label><Input type="number" value={form.max_capacity} onChange={(e) => setForm(f => ({ ...f, max_capacity: e.target.value }))} placeholder="30" /></div>
</>
)}
{step === 2 && (
<div className="space-y-3 py-4">
<p className="font-medium text-lg text-center">Review & Publish</p>
<div className="grid grid-cols-2 gap-2 text-sm">
<p className="text-muted-foreground">Title:</p><p className="font-medium">{form.title || "—"}</p>
<p className="text-muted-foreground">Code:</p><p className="font-medium">{form.code || "—"}</p>
<p className="text-muted-foreground">Level:</p><p className="font-medium">{form.level}</p>
<p className="text-muted-foreground">Capacity:</p><p className="font-medium">{form.max_capacity || "30"}</p>
<p className="text-muted-foreground">Dates:</p><p className="font-medium">{form.start_date || "—"} {form.end_date || "—"}</p>
</div>
{form.description && <p className="text-sm mt-2 text-muted-foreground">{form.description}</p>}
</div>
)}
</CardContent>
</Card>
<div className="flex justify-between">
<Button variant="outline" disabled={step === 0} onClick={() => setStep(s => s - 1)}>Previous</Button>
{step < steps.length - 1 ? (
<Button onClick={() => setStep(s => s + 1)} disabled={step === 0 && !form.title}>Next</Button>
) : (
<Button onClick={handlePublish} disabled={createMut.isPending || !form.title}>
{createMut.isPending ? "Publishing..." : "Publish Course"}
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,163 @@
import { useState } from "react";
import { useParams } 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 { 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, GripVertical, Lock, Unlock, Trash2, Loader2, BookOpen } from "lucide-react";
import { useChapters, useCreateChapter, useDeleteChapter, useUnlockChapter, useLockChapter } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { ChapterUnlockMode } from "@/types/courseware";
export default function CourseChapters() {
const { courseId } = useParams<{ courseId: string }>();
const cid = Number(courseId);
const { toast } = useToast();
const { data: chapters = [], isLoading } = useChapters(cid);
const createChapter = useCreateChapter();
const deleteChapter = useDeleteChapter();
const unlockChapter = useUnlockChapter();
const lockChapter = useLockChapter();
const [showAdd, setShowAdd] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [startDate, setStartDate] = useState("");
const [unlockMode, setUnlockMode] = useState<ChapterUnlockMode>("manual");
const resetForm = () => { setName(""); setDescription(""); setStartDate(""); setUnlockMode("manual"); };
const handleCreate = () => {
createChapter.mutate(
{ courseId: cid, data: { name, course_id: cid, description, start_date: startDate || undefined, unlock_mode: unlockMode } },
{
onSuccess: () => { toast({ title: "Chapter Created" }); setShowAdd(false); resetForm(); },
onError: () => toast({ title: "Error", description: "Failed to create chapter", variant: "destructive" }),
},
);
};
const handleDelete = (id: number) => {
deleteChapter.mutate(
{ id, courseId: cid },
{
onSuccess: () => toast({ title: "Chapter Deleted" }),
onError: () => toast({ title: "Error", description: "Failed to delete chapter", variant: "destructive" }),
},
);
};
const handleToggleLock = (id: number, isUnlocked: boolean) => {
const mutation = isUnlocked ? lockChapter : unlockChapter;
mutation.mutate(
{ id, courseId: cid },
{
onSuccess: () => toast({ title: isUnlocked ? "Chapter Locked" : "Chapter Unlocked" }),
onError: () => toast({ title: "Error", description: "Failed to update lock status", 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">Course Chapters</h1>
<p className="text-muted-foreground">Manage chapters and their sequence for this course.</p>
</div>
<Dialog open={showAdd} onOpenChange={setShowAdd}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Add Chapter</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Add New Chapter</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="Chapter name" value={name} onChange={e => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea placeholder="Brief description" value={description} onChange={e => setDescription(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Start Date</Label>
<Input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Unlock Mode</Label>
<Select value={unlockMode} onValueChange={v => setUnlockMode(v as ChapterUnlockMode)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="auto_date">Auto (Date)</SelectItem>
<SelectItem value="prerequisite">Prerequisite</SelectItem>
</SelectContent>
</Select>
</div>
<Button className="w-full" onClick={handleCreate} disabled={createChapter.isPending || !name}>
{createChapter.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Chapter
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="space-y-3">
{chapters
.sort((a, b) => a.sequence - b.sequence)
.map(ch => (
<Card key={ch.id} className="hover:shadow-sm transition-shadow">
<CardContent className="flex items-center gap-4 py-4">
<GripVertical className="h-5 w-5 text-muted-foreground shrink-0 cursor-grab" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<BookOpen className="h-4 w-4 text-primary shrink-0" />
<span className="font-medium truncate">{ch.name}</span>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
{ch.start_date && <span>{new Date(ch.start_date).toLocaleDateString()}</span>}
<span>{ch.material_count} material{ch.material_count !== 1 ? "s" : ""}</span>
</div>
</div>
<Badge variant={ch.is_unlocked ? "default" : "secondary"}>
{ch.is_unlocked ? "Unlocked" : "Locked"}
</Badge>
<Button
variant="ghost"
size="icon"
onClick={() => handleToggleLock(ch.id, ch.is_unlocked)}
disabled={unlockChapter.isPending || lockChapter.isPending}
>
{ch.is_unlocked ? <Lock className="h-4 w-4" /> : <Unlock className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(ch.id)}
disabled={deleteChapter.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</CardContent>
</Card>
))}
{chapters.length === 0 && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<BookOpen className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>No chapters yet. Add your first chapter to get started.</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,200 @@
import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { courseGenerationService } from "@/services/course-generation.service";
import type { CourseConfig } from "@/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { AlertTriangle } from "lucide-react";
type ProgressRow = {
student_name: string;
overall_progress: number;
current_module: string;
time_spent_hours: number;
predicted_band_delta: number;
last_activity: string;
days_idle: number;
};
type CourseWithProgress = CourseConfig & { student_progress?: ProgressRow[] };
export default function CourseProgress() {
const { courseId: courseIdParam } = useParams();
const courseId = Number(courseIdParam);
const [sortKey, setSortKey] = useState<keyof ProgressRow>("student_name");
const [filter, setFilter] = useState("");
const [selected, setSelected] = useState<ProgressRow | null>(null);
const { data, isLoading } = useQuery({
queryKey: ["course", courseId, "teacher-progress"],
queryFn: () => courseGenerationService.getCourse(courseId) as Promise<CourseWithProgress>,
enabled: Number.isFinite(courseId) && courseId > 0,
});
const rows = useMemo(() => {
const raw = data?.student_progress;
if (raw?.length) return raw;
return [
{
student_name: "Amina Rahman",
overall_progress: 62,
current_module: "Writing · Task Achievement",
time_spent_hours: 14,
predicted_band_delta: 0.5,
last_activity: new Date().toISOString(),
days_idle: 1,
},
{
student_name: "Leo Park",
overall_progress: 12,
current_module: "Listening · Section 2",
time_spent_hours: 3,
predicted_band_delta: 0.1,
last_activity: new Date(Date.now() - 4 * 86400000).toISOString(),
days_idle: 4,
},
] satisfies ProgressRow[];
}, [data]);
const sorted = useMemo(() => {
const f = filter.trim().toLowerCase();
const list = f ? rows.filter((r) => r.student_name.toLowerCase().includes(f)) : [...rows];
list.sort((a, b) => {
const av = a[sortKey];
const bv = b[sortKey];
if (typeof av === "number" && typeof bv === "number") return bv - av;
return String(av).localeCompare(String(bv));
});
return list;
}, [rows, sortKey, filter]);
if (isLoading) {
return (
<div className="p-6">
<Skeleton className="mb-4 h-10 w-64" />
<Skeleton className="h-72 w-full" />
</div>
);
}
return (
<div className="mx-auto max-w-6xl space-y-6 p-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Course progress</h1>
<p className="text-muted-foreground">{data?.title ?? `Course ${courseId}`}</p>
</div>
<Input
placeholder="Filter by name"
className="max-w-xs"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
<Card>
<CardHeader>
<CardTitle>Students</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="cursor-pointer" onClick={() => setSortKey("student_name")}>
Student Name
</TableHead>
<TableHead className="cursor-pointer" onClick={() => setSortKey("overall_progress")}>
Overall Progress
</TableHead>
<TableHead>Current Module</TableHead>
<TableHead className="cursor-pointer" onClick={() => setSortKey("time_spent_hours")}>
Time Spent (h)
</TableHead>
<TableHead className="cursor-pointer" onClick={() => setSortKey("predicted_band_delta")}>
Predicted Δ Band
</TableHead>
<TableHead className="cursor-pointer" onClick={() => setSortKey("last_activity")}>
Last Activity
</TableHead>
<TableHead className="w-12">Alert</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sorted.map((row) => {
const alert = row.days_idle >= 3;
return (
<TableRow key={row.student_name}>
<TableCell>
<Button
type="button"
variant="link"
className="h-auto p-0 font-medium"
onClick={() => setSelected(row)}
>
{row.student_name}
</Button>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress value={row.overall_progress} className="h-2 w-28" />
<span className="text-xs tabular-nums">{row.overall_progress}%</span>
</div>
</TableCell>
<TableCell className="max-w-[200px] truncate">{row.current_module}</TableCell>
<TableCell>{row.time_spent_hours}</TableCell>
<TableCell>+{row.predicted_band_delta.toFixed(1)}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{new Date(row.last_activity).toLocaleString()}
</TableCell>
<TableCell>
{alert ? (
<span className="text-amber-600" title="No progress 3+ days">
<AlertTriangle className="h-4 w-4" />
</span>
) : (
<Badge variant="outline">OK</Badge>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
{selected ? (
<Card>
<CardHeader>
<CardTitle>{selected.student_name}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<p>
<span className="text-muted-foreground">Current module:</span> {selected.current_module}
</p>
<p>
<span className="text-muted-foreground">Time spent:</span> {selected.time_spent_hours} h
</p>
<p>
<span className="text-muted-foreground">Predicted band improvement:</span> +
{selected.predicted_band_delta.toFixed(1)}
</p>
</CardContent>
</Card>
) : null}
</div>
);
}

View File

@@ -0,0 +1,155 @@
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 { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Plus, Megaphone, Loader2, Send } from "lucide-react";
import { useAnnouncements, useCreateAnnouncement, usePublishAnnouncement, useCourses } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { Announcement } from "@/types/communication";
const priorityColors: Record<Announcement["priority"], string> = {
urgent: "destructive",
important: "default",
normal: "secondary",
};
export default function TeacherAnnouncements() {
const { toast } = useToast();
const { data: announcements = [], isLoading } = useAnnouncements();
const { data: coursesData } = useCourses();
const courses = coursesData?.results ?? [];
const createAnnouncement = useCreateAnnouncement();
const publishAnnouncement = usePublishAnnouncement();
const [showCreate, setShowCreate] = useState(false);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [courseId, setCourseId] = useState<string>("");
const [priority, setPriority] = useState<Announcement["priority"]>("normal");
const [sendEmail, setSendEmail] = useState(false);
const resetForm = () => { setTitle(""); setContent(""); setCourseId(""); setPriority("normal"); setSendEmail(false); };
const handleCreate = () => {
createAnnouncement.mutate(
{ title, content, course_id: courseId ? Number(courseId) : undefined, priority, send_email: sendEmail },
{
onSuccess: () => { toast({ title: "Announcement Created" }); setShowCreate(false); resetForm(); },
onError: () => toast({ title: "Error", description: "Failed to create announcement", variant: "destructive" }),
},
);
};
const handlePublish = (id: number) => {
publishAnnouncement.mutate(id, {
onSuccess: () => toast({ title: "Announcement Published" }),
onError: () => toast({ title: "Error", description: "Failed to publish announcement", 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">Announcements</h1>
<p className="text-muted-foreground">Create and manage announcements for your courses.</p>
</div>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Create Announcement</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>New Announcement</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Title</Label>
<Input placeholder="Announcement title" value={title} onChange={e => setTitle(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Content</Label>
<Textarea placeholder="Write your announcement..." rows={4} value={content} onChange={e => setContent(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Course</Label>
<Select value={courseId} onValueChange={setCourseId}>
<SelectTrigger><SelectValue placeholder="All courses" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Courses</SelectItem>
{courses.map(c => (
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Priority</Label>
<Select value={priority} onValueChange={v => setPriority(v as Announcement["priority"])}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="important">Important</SelectItem>
<SelectItem value="urgent">Urgent</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Checkbox id="send-email" checked={sendEmail} onCheckedChange={v => setSendEmail(!!v)} />
<Label htmlFor="send-email">Send email notification</Label>
</div>
<Button className="w-full" onClick={handleCreate} disabled={createAnnouncement.isPending || !title || !content}>
{createAnnouncement.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="space-y-3">
{announcements.map(a => (
<Card key={a.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<Megaphone className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{a.title}</span>
<Badge variant={priorityColors[a.priority] as "default" | "secondary" | "destructive"}>{a.priority}</Badge>
{a.is_published && <Badge variant="outline">Published</Badge>}
</div>
<p className="text-sm text-muted-foreground line-clamp-1 mt-0.5">{a.content}</p>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
{a.course_name && <span>{a.course_name}</span>}
{a.published_at && <span>· {new Date(a.published_at).toLocaleDateString()}</span>}
</div>
</div>
{!a.is_published && (
<Button size="sm" onClick={() => handlePublish(a.id)} disabled={publishAnnouncement.isPending}>
{publishAnnouncement.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
Publish
</Button>
)}
</CardContent>
</Card>
))}
{announcements.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 yet.</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { useState } from "react";
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 { 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 { useAssignments } from "@/hooks/queries";
import { ArrowLeft } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
export default function TeacherAssignmentDetail() {
const { id } = useParams();
const { data: assignmentsData, isLoading } = useAssignments();
const assignments = assignmentsData?.items ?? [];
const submissions: { id: string; assignmentId: string; studentId: string; studentName: string; submittedAt: string; status: string; grade?: number; feedback?: string; file?: string }[] = [];
const [gradeOpen, setGradeOpen] = useState(false);
const [selectedStudent, setSelectedStudent] = useState<string>("");
const { toast } = useToast();
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 assignment = assignments.find(a => String(a.id) === id);
const subs = submissions.filter(s => s.assignmentId === id);
const maxGrade = 100;
if (!assignment) return <div className="p-8 text-center text-muted-foreground">Assignment not found.</div>;
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild><Link to="/teacher/assignments"><ArrowLeft className="h-4 w-4" /></Link></Button>
<div>
<h1 className="text-2xl font-bold">{assignment.title}</h1>
<p className="text-muted-foreground">{assignment.entity_name} · Due: {assignment.end_date}</p>
</div>
</div>
<Card>
<CardHeader><CardTitle className="text-lg">Submissions ({subs.length})</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Submitted</TableHead>
<TableHead>Status</TableHead>
<TableHead>Grade</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subs.map(s => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.studentName}</TableCell>
<TableCell className="text-sm">{s.submittedAt}</TableCell>
<TableCell><Badge variant={s.status === "graded" ? "default" : "secondary"} className="capitalize">{s.status}</Badge></TableCell>
<TableCell>{s.grade ?? "—"}</TableCell>
<TableCell>
{s.status === "pending" && (
<Button size="sm" onClick={() => { setSelectedStudent(s.studentName); setGradeOpen(true); }}>Grade</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={gradeOpen} onOpenChange={setGradeOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Grade Submission {selectedStudent}</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Grade (out of {maxGrade})</Label><Input type="number" placeholder="0" /></div>
<div className="space-y-2"><Label>Feedback</Label><Textarea placeholder="Write feedback..." rows={4} /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setGradeOpen(false)}>Cancel</Button>
<Button onClick={() => { setGradeOpen(false); toast({ title: "Grade Saved", description: `${selectedStudent}'s submission has been graded.` }); }}>Save Grade</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { useAssignments } from "@/hooks/queries";
import { Link } from "react-router-dom";
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
import AiGeneratorModal from "@/components/ai/AiGeneratorModal";
import AiTipBanner from "@/components/ai/AiTipBanner";
export default function TeacherAssignments() {
const { data: assignmentsData, isLoading } = useAssignments();
const assignments = assignmentsData?.items ?? [];
const submissions: unknown[] = [];
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 teacherAssignments = assignments;
const pendingSubs = (submissions as { id: string; studentName: string; submittedAt: string; status: string; feedback?: string; grade?: number }[]).filter(s => s.status === "pending");
const gradedSubs = (submissions as { id: string; studentName: string; submittedAt: string; status: string; feedback?: string; grade?: number }[]).filter(s => s.status === "graded");
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Assignments</h1>
<p className="text-muted-foreground">Manage and grade student assignments.</p>
</div>
<div className="flex gap-2">
<AiCreationAssistant type="assignment" />
<AiGeneratorModal />
</div>
</div>
<AiTipBanner context="teacher-assignments" variant="insight" />
<Tabs defaultValue="all">
<TabsList>
<TabsTrigger value="all">All ({teacherAssignments.length})</TabsTrigger>
<TabsTrigger value="pending">Pending Review ({pendingSubs.length})</TabsTrigger>
<TabsTrigger value="graded">Graded ({gradedSubs.length})</TabsTrigger>
</TabsList>
<TabsContent value="all" className="mt-4 space-y-3">
{teacherAssignments.map(a => (
<Link to={`/teacher/assignments/${a.id}`} key={a.id}>
<Card className="hover:bg-muted/30 transition-colors">
<CardContent className="pt-4 flex items-center justify-between">
<div><p className="font-medium text-sm">{a.title}</p><p className="text-xs text-muted-foreground">{a.entity_name} · Due: {a.end_date}</p></div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="capitalize text-xs">{a.state}</Badge>
<Badge variant={a.completed_count >= a.assignee_count && a.assignee_count > 0 ? "default" : "secondary"} className="capitalize">{a.state}</Badge>
</div>
</CardContent>
</Card>
</Link>
))}
</TabsContent>
<TabsContent value="pending" className="mt-4 space-y-3">
{pendingSubs.map(s => (
<Card key={s.id}>
<CardContent className="pt-4 flex items-center justify-between">
<div><p className="font-medium text-sm">{s.studentName}</p><p className="text-xs text-muted-foreground">Submitted: {s.submittedAt}</p></div>
<Badge variant="secondary">Pending</Badge>
</CardContent>
</Card>
))}
</TabsContent>
<TabsContent value="graded" className="mt-4 space-y-3">
{gradedSubs.map(s => (
<Card key={s.id}>
<CardContent className="pt-4 flex items-center justify-between">
<div><p className="font-medium text-sm">{s.studentName}</p><p className="text-xs text-muted-foreground">{s.feedback}</p></div>
<span className="font-bold text-primary">{s.grade}</span>
</CardContent>
</Card>
))}
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,126 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
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 { useBatches, useStudents } from "@/hooks/queries";
import { lmsService } from "@/services/lms.service";
import { useMutation } from "@tanstack/react-query";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
import { useToast } from "@/hooks/use-toast";
import AiAlertBanner from "@/components/ai/AiAlertBanner";
import AiRiskBadge from "@/components/ai/AiRiskBadge";
const trendData = [
{ week: "W1", rate: 92 }, { week: "W2", rate: 88 }, { week: "W3", rate: 95 },
{ week: "W4", rate: 85 }, { week: "W5", rate: 90 }, { week: "W6", rate: 87 },
];
export default function TeacherAttendance() {
const { data: batchesData, isLoading: lb } = useBatches();
const { data: studentsData, isLoading: ls } = useStudents({ size: 500 });
const batches = batchesData?.items ?? [];
const students = studentsData?.items ?? [];
const [batchIdStr, setBatchIdStr] = useState("");
const [markOpen, setMarkOpen] = useState(false);
const [attendanceMap, setAttendanceMap] = useState<Record<number, string>>({});
const { toast } = useToast();
const saveMut = useMutation({
mutationFn: (data: { course_id: number; date: string; records: { student_id: number; status: string }[] }) =>
lmsService.recordAttendance(data),
onSuccess: () => { setMarkOpen(false); toast({ title: "Attendance saved" }); },
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
if (lb || 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>;
const effectiveBatchId = batchIdStr ? Number(batchIdStr) : batches[0]?.id;
const batch = batches.find(b => b.id === effectiveBatchId);
const batchStudents = students.filter((s) => s.batch_id === effectiveBatchId);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div><h1 className="text-2xl font-bold">Attendance</h1><p className="text-muted-foreground">Track and mark student attendance.</p></div>
<Button onClick={() => setMarkOpen(true)}>Mark Attendance</Button>
</div>
<AiAlertBanner />
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">Batch:</span>
<Select value={effectiveBatchId != null ? String(effectiveBatchId) : ""} onValueChange={setBatchIdStr}>
<SelectTrigger className="w-64"><SelectValue /></SelectTrigger>
<SelectContent>{batches.map(b => <SelectItem key={b.id} value={String(b.id)}>{b.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader><CardTitle className="text-lg">Attendance Trend</CardTitle></CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={trendData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="week" className="fill-muted-foreground" tick={{ fontSize: 12 }} />
<YAxis domain={[70, 100]} className="fill-muted-foreground" tick={{ fontSize: 12 }} />
<Tooltip />
<Line type="monotone" dataKey="rate" stroke="hsl(192, 82%, 32%)" strokeWidth={2} dot={{ r: 4 }} />
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-lg">Students {batch?.name}</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Student</TableHead><TableHead>Rate</TableHead><TableHead>AI Risk</TableHead></TableRow></TableHeader>
<TableBody>
{batchStudents.map(s => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell></TableCell>
<TableCell><AiRiskBadge /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
<Dialog open={markOpen} onOpenChange={setMarkOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Mark Attendance {batch?.name}</DialogTitle></DialogHeader>
<div className="space-y-3">
{batchStudents.map(s => (
<div key={s.id} className="flex items-center justify-between p-2 rounded border">
<span className="text-sm font-medium">{s.name}</span>
<Select value={attendanceMap[s.id] || "present"} onValueChange={(v) => setAttendanceMap(m => ({ ...m, [s.id]: v }))}>
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="present">Present</SelectItem><SelectItem value="absent">Absent</SelectItem>
<SelectItem value="late">Late</SelectItem><SelectItem value="excused">Excused</SelectItem>
</SelectContent>
</Select>
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setMarkOpen(false)}>Cancel</Button>
<Button disabled={saveMut.isPending} onClick={() => {
const records = batchStudents.map(s => ({ student_id: s.id, status: attendanceMap[s.id] || "present" }));
saveMut.mutate({ course_id: batch?.course_id || 0, date: new Date().toISOString().split("T")[0], records });
}}>
{saveMut.isPending ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useCourses } from "@/hooks/queries";
import { Link } from "react-router-dom";
import { Plus, Users, BookOpen, Archive } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
export default function TeacherCourses() {
const { data: coursesData, isLoading } = useCourses();
const courses = coursesData?.items ?? [];
const teacherCourses = courses;
const { toast } = useToast();
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">My Courses</h1>
<p className="text-muted-foreground">Manage your courses and materials.</p>
</div>
<Button asChild><Link to="/teacher/courses/new"><Plus className="mr-2 h-4 w-4" />New Course</Link></Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{teacherCourses.map(c => (
<Card key={c.id} className="hover:shadow-md transition-shadow">
<div className="h-2 rounded-t-lg bg-primary" />
<CardContent className="pt-5 space-y-3">
<div className="flex items-center justify-between">
<Badge variant={c.status === "active" ? "default" : "secondary"} className="capitalize">{c.status}</Badge>
<span className="text-xs text-muted-foreground">{c.code}</span>
</div>
<h3 className="font-semibold">{c.title}</h3>
<p className="text-sm text-muted-foreground line-clamp-2">{c.description}</p>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{c.enrolled} students</span>
<span className="flex items-center gap-1"><BookOpen className="h-3 w-3" />{c.modules.length} modules</span>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" asChild><Link to={`/teacher/courses/${c.id}/edit`}>Edit</Link></Button>
<Button size="sm" variant="ghost" onClick={() => toast({ title: "Course archived" })}><Archive className="h-3 w-3" /></Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,107 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { BookOpen, Users, ClipboardList, TrendingUp, ArrowRight } from "lucide-react";
import { Link } from "react-router-dom";
import { useCourses, useAssignments } from "@/hooks/queries";
import AiInsightsPanel from "@/components/ai/AiInsightsPanel";
import AiTipBanner from "@/components/ai/AiTipBanner";
export default function TeacherDashboard() {
const { data: coursesData, isLoading: lc } = useCourses();
const { data: assignmentsData, isLoading: la } = useAssignments();
const courses = coursesData?.items ?? [];
const assignments = assignmentsData?.items ?? [];
const submissions: unknown[] = [];
const activityFeed: { id: string; action: string; user: string; target: string; time: string }[] = [];
if (lc || la) 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 teacherCourses = courses;
const pendingGrading = (submissions as { id: string; studentName: string; submittedAt: string; status: string }[]).filter(s => s.status === "pending");
const stats = [
{ label: "Active Courses", value: String(teacherCourses.filter(c => c.status === "active").length), icon: BookOpen, color: "text-primary" },
{ label: "Total Students", value: "55", icon: Users, color: "text-info" },
{ label: "Pending Grading", value: String(pendingGrading.length), icon: ClipboardList, color: "text-warning" },
{ label: "Avg. Pass Rate", value: "82%", icon: TrendingUp, color: "text-success" },
];
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Teacher Dashboard</h1>
<p className="text-muted-foreground">Overview of your teaching activities.</p>
</div>
<AiTipBanner context="teacher-dashboard" variant="recommendation" />
<AiInsightsPanel />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map(s => (
<Card key={s.label}>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div><p className="text-sm text-muted-foreground">{s.label}</p><p className="text-2xl font-bold">{s.value}</p></div>
<s.icon className={`h-8 w-8 ${s.color} opacity-80`} />
</div>
</CardContent>
</Card>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg">My Courses</CardTitle>
<Button variant="ghost" size="sm" asChild><Link to="/teacher/courses">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Course</TableHead><TableHead>Students</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
<TableBody>
{teacherCourses.map(c => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.title}</TableCell>
<TableCell>{c.enrolled}/{c.max_capacity}</TableCell>
<TableCell><Badge variant={c.status === "active" ? "default" : "secondary"} className="capitalize">{c.status}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="space-y-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg">Pending Grading</CardTitle>
<Button variant="ghost" size="sm" asChild><Link to="/teacher/assignments">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
</CardHeader>
<CardContent className="space-y-3">
{pendingGrading.slice(0, 4).map(s => (
<div key={s.id} className="flex items-center justify-between p-2 rounded border">
<div><p className="text-sm font-medium">{s.studentName}</p><p className="text-xs text-muted-foreground">Submitted {s.submittedAt}</p></div>
<Badge variant="secondary">Pending</Badge>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-lg">Recent Activity</CardTitle></CardHeader>
<CardContent className="space-y-2">
{activityFeed.slice(0, 4).map(a => (
<div key={a.id} className="text-sm">
<span className="font-medium">{a.user}</span> <span className="text-muted-foreground">{a.action}</span> <span>{a.target}</span>
<span className="text-xs text-muted-foreground ml-2">· {a.time}</span>
</div>
))}
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,198 @@
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 { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { MessageSquare, Pin, CheckCircle2, Loader2, ArrowLeft, Send } from "lucide-react";
import { useDiscussionBoards, usePosts, useCreatePost, usePinPost, useResolvePost } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { DiscussionPost } from "@/types/communication";
function PostThread({ post, boardId, onPin, onResolve }: { post: DiscussionPost; boardId: number; onPin: (id: number, pinned: boolean) => void; onResolve: (id: number) => void }) {
const [showReply, setShowReply] = useState(false);
const [replyContent, setReplyContent] = useState("");
const createPost = useCreatePost();
const { toast } = useToast();
const handleReply = () => {
createPost.mutate(
{ boardId, data: { board_id: boardId, parent_id: post.id, content: replyContent } },
{
onSuccess: () => { setShowReply(false); setReplyContent(""); },
onError: () => toast({ title: "Error", description: "Failed to post reply", variant: "destructive" }),
},
);
};
return (
<div className="space-y-2">
<div className={`p-4 rounded-lg border ${post.is_pinned ? "border-primary/30 bg-primary/5" : ""}`}>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
{post.title && <p className="font-medium">{post.title}</p>}
<p className="text-sm mt-1">{post.content}</p>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
<span>{post.author_name}</span>
<span>·</span>
<Badge variant="outline" className="text-xs">{post.author_role}</Badge>
<span>·</span>
<span>{new Date(post.created_at).toLocaleDateString()}</span>
{post.is_pinned && <Badge variant="secondary" className="text-xs"><Pin className="h-3 w-3 mr-1" />Pinned</Badge>}
{post.is_resolved && <Badge variant="default" className="text-xs"><CheckCircle2 className="h-3 w-3 mr-1" />Resolved</Badge>}
</div>
</div>
<div className="flex gap-1 shrink-0">
<Button variant="ghost" size="sm" onClick={() => onPin(post.id, !post.is_pinned)}>
<Pin className="h-3 w-3" />
</Button>
{!post.is_resolved && (
<Button variant="ghost" size="sm" onClick={() => onResolve(post.id)}>
<CheckCircle2 className="h-3 w-3" />
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => setShowReply(!showReply)}>Reply</Button>
</div>
</div>
{showReply && (
<div className="flex gap-2 mt-3 pt-3 border-t">
<Input placeholder="Write a reply..." value={replyContent} onChange={e => setReplyContent(e.target.value)} className="flex-1" />
<Button size="sm" onClick={handleReply} disabled={createPost.isPending || !replyContent}>
{createPost.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</div>
)}
</div>
{post.replies && post.replies.length > 0 && (
<div className="ml-6 space-y-2">
{post.replies.map(reply => (
<PostThread key={reply.id} post={reply} boardId={boardId} onPin={onPin} onResolve={onResolve} />
))}
</div>
)}
</div>
);
}
export default function TeacherDiscussionBoard() {
const { toast } = useToast();
const { data: boards = [], isLoading } = useDiscussionBoards();
const [selectedBoardId, setSelectedBoardId] = useState<number | null>(null);
const { data: postsData, isLoading: loadingPosts } = usePosts(selectedBoardId ?? 0);
const createPost = useCreatePost();
const pinPost = usePinPost();
const resolvePost = useResolvePost();
const [showNewPost, setShowNewPost] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [newContent, setNewContent] = useState("");
const posts = postsData?.results ?? [];
const handleCreatePost = () => {
if (!selectedBoardId) return;
createPost.mutate(
{ boardId: selectedBoardId, data: { board_id: selectedBoardId, title: newTitle, content: newContent } },
{
onSuccess: () => { toast({ title: "Post Created" }); setShowNewPost(false); setNewTitle(""); setNewContent(""); },
onError: () => toast({ title: "Error", description: "Failed to create post", variant: "destructive" }),
},
);
};
const handlePin = (id: number, pinned: boolean) => {
pinPost.mutate(
{ id, body: { pinned } },
{ onError: () => toast({ title: "Error", description: "Failed to pin post", variant: "destructive" }) },
);
};
const handleResolve = (id: number) => {
resolvePost.mutate(id, {
onError: () => toast({ title: "Error", description: "Failed to resolve post", 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>
<h1 className="text-2xl font-bold">Discussion Boards</h1>
<p className="text-muted-foreground">Manage and participate in course discussions.</p>
</div>
{!selectedBoardId ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{boards.map(board => (
<Card key={board.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedBoardId(board.id)}>
<CardHeader>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
<MessageSquare className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">{board.name}</CardTitle>
<p className="text-xs text-muted-foreground">{board.course_name}</p>
</div>
</div>
</CardHeader>
<CardContent>
<span className="text-sm text-muted-foreground">{board.post_count} posts</span>
</CardContent>
</Card>
))}
{boards.length === 0 && (
<div className="col-span-full text-center py-12 text-muted-foreground">
<MessageSquare className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>No discussion boards available.</p>
</div>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={() => setSelectedBoardId(null)}>
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Boards
</Button>
<Dialog open={showNewPost} onOpenChange={setShowNewPost}>
<Button onClick={() => setShowNewPost(true)}><MessageSquare className="mr-2 h-4 w-4" /> New Post</Button>
<DialogContent>
<DialogHeader><DialogTitle>Create Post</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Title</Label>
<Input placeholder="Post title" value={newTitle} onChange={e => setNewTitle(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Content</Label>
<Textarea placeholder="Write your post..." rows={4} value={newContent} onChange={e => setNewContent(e.target.value)} />
</div>
<Button className="w-full" onClick={handleCreatePost} disabled={createPost.isPending || !newContent}>
{createPost.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Post
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{loadingPosts ? (
<div className="flex items-center justify-center min-h-[200px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
) : (
<div className="space-y-3">
{posts.map(post => (
<PostThread key={post.id} post={post} boardId={selectedBoardId} onPin={handlePin} onResolve={handleResolve} />
))}
{posts.length === 0 && (
<Card><CardContent className="py-8 text-center text-muted-foreground">No posts yet. Start the conversation!</CardContent></Card>
)}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,49 @@
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";
export default function TeacherProfile() {
const { user } = useAuth();
const { toast } = useToast();
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?.avatar}</span>
</div>
<Button variant="outline" size="sm">Change Avatar</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"><Label>First Name</Label><Input defaultValue={user?.name.split(" ").slice(0, -1).join(" ")} /></div>
<div className="space-y-2"><Label>Last Name</Label><Input defaultValue={user?.name.split(" ").slice(-1)[0]} /></div>
</div>
<div className="space-y-2"><Label>Email</Label><Input defaultValue={user?.email} type="email" /></div>
<div className="space-y-2"><Label>Specialization</Label><Input defaultValue="IELTS / Academic Writing" /></div>
<Button onClick={() => toast({ title: "Profile Updated" })}>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" /></div>
<div className="space-y-2"><Label>New Password</Label><Input type="password" /></div>
<div className="space-y-2"><Label>Confirm Password</Label><Input type="password" /></div>
<Button onClick={() => toast({ title: "Password Updated" })}>Update Password</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } 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 { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useStudents } from "@/hooks/queries";
import { lmsStudentToUser } from "@/services/lms.service";
import type { User } from "@/types";
import { Search } from "lucide-react";
import AiRiskBadge from "@/components/ai/AiRiskBadge";
import AiGradeExplainer from "@/components/ai/AiGradeExplainer";
export default function TeacherStudents() {
const { data: studentsData, isLoading } = useStudents({ size: 500 });
const students = (studentsData?.items ?? []).map(lmsStudentToUser);
const [search, setSearch] = useState("");
const [selectedStudent, setSelectedStudent] = useState<User | null>(null);
const filtered = students.filter(s => s.name.toLowerCase().includes(search.toLowerCase()) || s.email.toLowerCase().includes(search.toLowerCase()));
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">My Students</h1><p className="text-muted-foreground">View and manage students across your courses.</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>Student</TableHead><TableHead>Batch</TableHead><TableHead>Attendance</TableHead><TableHead>Avg Grade</TableHead><TableHead>AI Risk</TableHead><TableHead>AI</TableHead><TableHead></TableHead></TableRow>
</TableHeader>
<TableBody>
{filtered.map(s => (
<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">{(s.avatar ?? s.name.slice(0, 1)).toUpperCase()}</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 className="text-sm">{s.entities?.[0]?.name ?? "—"}</TableCell>
<TableCell className="text-sm"></TableCell>
<TableCell className="text-sm"></TableCell>
<TableCell><AiRiskBadge /></TableCell>
<TableCell><AiGradeExplainer studentName={s.name} /></TableCell>
<TableCell><Button size="sm" variant="ghost" onClick={() => setSelectedStudent(s)}>View</Button></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={!!selectedStudent} onOpenChange={() => setSelectedStudent(null)}>
<DialogContent>
<DialogHeader><DialogTitle>{selectedStudent?.name}</DialogTitle></DialogHeader>
{selectedStudent && (
<div className="space-y-3">
<p className="text-sm"><span className="text-muted-foreground">Email:</span> {selectedStudent.email}</p>
<p className="text-sm"><span className="text-muted-foreground">Batch:</span> {selectedStudent.entities?.[0]?.name ?? "—"}</p>
<p className="text-sm"><span className="text-muted-foreground">Attendance:</span> </p>
<p className="text-sm"><span className="text-muted-foreground">Average Grade:</span> </p>
<p className="text-sm"><span className="text-muted-foreground">Joined:</span> </p>
<p className="text-sm"><span className="text-muted-foreground">AI Risk:</span> <AiRiskBadge /></p>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { Card, CardContent } from "@/components/ui/card";
import { useTimetable } from "@/hooks/queries";
import type { TimetableSession } from "@/types";
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%)";
function isStart(sessions: TimetableSession[], day: string, hour: string) {
return sessions.find(s => s.day === day && s.start_time === hour);
}
function getSessionAt(sessions: TimetableSession[], day: string, hour: string) {
return sessions.find(s => s.day === day && s.start_time <= hour && s.end_time > hour);
}
export default function TeacherTimetable() {
const { data: timetableSessions = [], isLoading } = useTimetable();
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 mySessions = timetableSessions;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">My Timetable</h1>
<p className="text-muted-foreground">Your weekly teaching schedule.</p>
</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(mySessions, day, hour);
const occupied = getSessionAt(mySessions, day, hour);
if (session) {
return (
<div key={day} className="rounded-md p-2 text-xs text-white" style={{ backgroundColor: SESSION_COLOR }}>
<p className="font-semibold">{session.course_name}</p>
<p className="opacity-80">{session.batch_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>
</div>
);
}