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:
198
frontend/src/pages/teacher/AdaptiveSettings.tsx
Normal file
198
frontend/src/pages/teacher/AdaptiveSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
201
frontend/src/pages/teacher/AiWorkbench.tsx
Normal file
201
frontend/src/pages/teacher/AiWorkbench.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
frontend/src/pages/teacher/ChapterDetail.tsx
Normal file
200
frontend/src/pages/teacher/ChapterDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
frontend/src/pages/teacher/CourseBuilder.tsx
Normal file
119
frontend/src/pages/teacher/CourseBuilder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
frontend/src/pages/teacher/CourseChapters.tsx
Normal file
163
frontend/src/pages/teacher/CourseChapters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
frontend/src/pages/teacher/CourseProgress.tsx
Normal file
200
frontend/src/pages/teacher/CourseProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
frontend/src/pages/teacher/TeacherAnnouncements.tsx
Normal file
155
frontend/src/pages/teacher/TeacherAnnouncements.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
frontend/src/pages/teacher/TeacherAssignmentDetail.tsx
Normal file
89
frontend/src/pages/teacher/TeacherAssignmentDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
frontend/src/pages/teacher/TeacherAssignments.tsx
Normal file
83
frontend/src/pages/teacher/TeacherAssignments.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
frontend/src/pages/teacher/TeacherAttendance.tsx
Normal file
126
frontend/src/pages/teacher/TeacherAttendance.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
frontend/src/pages/teacher/TeacherCourses.tsx
Normal file
52
frontend/src/pages/teacher/TeacherCourses.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
frontend/src/pages/teacher/TeacherDashboard.tsx
Normal file
107
frontend/src/pages/teacher/TeacherDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
198
frontend/src/pages/teacher/TeacherDiscussionBoard.tsx
Normal file
198
frontend/src/pages/teacher/TeacherDiscussionBoard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
frontend/src/pages/teacher/TeacherProfile.tsx
Normal file
49
frontend/src/pages/teacher/TeacherProfile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
frontend/src/pages/teacher/TeacherStudents.tsx
Normal file
76
frontend/src/pages/teacher/TeacherStudents.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
frontend/src/pages/teacher/TeacherTimetable.tsx
Normal file
63
frontend/src/pages/teacher/TeacherTimetable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user