feat(v3): restructure project + add complete frontend

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

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

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

View File

@@ -0,0 +1,80 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, X, Sparkles, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { analyticsService } from "@/services/analytics.service";
export default function AiAlertBanner() {
const [dismissedIds, setDismissedIds] = useState<Set<string>>(() => new Set());
const [errorDismissed, setErrorDismissed] = useState(false);
const { data: alerts, isLoading, isError, error } = useQuery({
queryKey: ["ai", "alerts"],
queryFn: () => analyticsService.getAlerts(),
});
const visible = alerts?.filter((a) => !dismissedIds.has(a.id)) ?? [];
if (isLoading) {
return (
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-warning shrink-0" />
<p className="text-sm text-muted-foreground">Loading alerts</p>
</div>
);
}
if (isError && !errorDismissed) {
return (
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium flex items-center gap-1">
<Sparkles className="h-3 w-3 text-primary" /> Alerts unavailable
</p>
<p className="text-sm text-muted-foreground mt-1">{error instanceof Error ? error.message : "Could not load alerts."}</p>
</div>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setErrorDismissed(true)}>
<X className="h-4 w-4" />
</Button>
</div>
);
}
if (isError && errorDismissed) return null;
if (!alerts?.length) {
return (
<div className="rounded-lg border border-muted bg-muted/20 p-4 flex items-start gap-3">
<Sparkles className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
<p className="text-sm text-muted-foreground">No AI alerts right now.</p>
</div>
);
}
if (!visible.length) return null;
return (
<div className="space-y-3">
{visible.map((alert) => (
<div key={alert.id} className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium flex items-center gap-1">
<Sparkles className="h-3 w-3 text-primary" /> {alert.title}
</p>
<p className="text-sm text-muted-foreground mt-1">{alert.description}</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => setDismissedIds((prev) => new Set(prev).add(alert.id))}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,139 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useLocation } from "react-router-dom";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Sparkles, Send, Loader2 } from "lucide-react";
import { coachingService } from "@/services/coaching.service";
import { useToast } from "@/hooks/use-toast";
const quickActions = [
"Platform health summary",
"Show dropout risks",
"Compare course performance",
"Suggest batch improvements",
];
export default function AiAssistantDrawer() {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<{ role: "user" | "ai"; text: string }[]>([]);
const [input, setInput] = useState("");
const location = useLocation();
const { toast } = useToast();
const chatMutation = useMutation({
mutationFn: (message: string) =>
coachingService.chat({ message, context: { page: location.pathname } }),
onSuccess: (data) => {
setMessages((prev) => [...prev, { role: "ai", text: data.message }]);
},
onError: (err: Error) => {
toast({
variant: "destructive",
title: "Could not get a reply",
description: err.message || "Something went wrong. Try again.",
});
setMessages((prev) => [
...prev,
{
role: "ai",
text: "Sorry, I could not reach the assistant. Please try again in a moment.",
},
]);
},
});
const handleSend = (text: string) => {
if (!text.trim() || chatMutation.isPending) return;
const userMsg = text.trim();
setMessages((prev) => [...prev, { role: "user", text: userMsg }]);
setInput("");
chatMutation.mutate(userMsg);
};
return (
<>
<button
onClick={() => setOpen(true)}
className="fixed bottom-20 right-6 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
aria-label="AI Assistant"
>
<Sparkles className="h-5 w-5" />
</button>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent className="w-[400px] sm:w-[440px] flex flex-col">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
EnCoach AI Assistant
</SheetTitle>
</SheetHeader>
<div className="flex flex-wrap gap-2 mt-4">
{quickActions.map((action) => (
<Button
key={action}
variant="outline"
size="sm"
className="text-xs"
disabled={chatMutation.isPending}
onClick={() => handleSend(action)}
>
{action}
</Button>
))}
</div>
<div className="flex-1 overflow-y-auto mt-4 space-y-3 min-h-0">
{messages.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8">
<Sparkles className="h-8 w-8 mx-auto mb-3 text-primary/40" />
<p>Ask me anything about the platform,</p>
<p>or click a quick action above.</p>
</div>
)}
{messages.map((msg, i) => (
<div
key={i}
className={`rounded-lg p-3 text-sm ${
msg.role === "user"
? "bg-primary text-primary-foreground ml-8"
: "bg-muted mr-8"
}`}
>
{msg.role === "ai" && (
<Sparkles className="h-3 w-3 text-primary inline mr-1.5 -mt-0.5" />
)}
{msg.text}
</div>
))}
{chatMutation.isPending && (
<div className="bg-muted rounded-lg p-3 text-sm mr-8 flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-muted-foreground">Thinking...</span>
</div>
)}
</div>
<div className="flex gap-2 pt-3 border-t mt-auto">
<Input
placeholder="Ask anything..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend(input)}
/>
<Button
size="icon"
onClick={() => handleSend(input)}
disabled={!input.trim() || chatMutation.isPending}
>
<Send className="h-4 w-4" />
</Button>
</div>
</SheetContent>
</Sheet>
</>
);
}

View File

@@ -0,0 +1,101 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Sparkles, Loader2 } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { analyticsService } from "@/services/analytics.service";
interface Props {
batchId?: number;
}
export default function AiBatchOptimizer({ batchId }: Props) {
const [open, setOpen] = useState(false);
const { toast } = useToast();
const mutation = useMutation({
mutationFn: (id: number) => analyticsService.getBatchOptimization(id),
onError: (err: Error) => {
toast({
title: "Optimization failed",
description: err.message || "Could not analyze this batch.",
variant: "destructive",
});
},
});
const handleOpen = () => {
if (batchId == null) {
toast({
title: "No batch selected",
description: "Choose a batch to analyze, or open this from a batch detail page.",
variant: "destructive",
});
return;
}
mutation.reset();
setOpen(true);
mutation.mutate(batchId);
};
const handleApply = () => {
toast({ title: "Suggestion Applied", description: "Batch split recommendation has been saved successfully." });
setOpen(false);
};
const onOpenChange = (next: boolean) => {
setOpen(next);
if (!next) mutation.reset();
};
const suggestions = mutation.data ?? [];
const showResults = !mutation.isPending && !mutation.isError && suggestions.length > 0;
const showEmpty = !mutation.isPending && !mutation.isError && mutation.isSuccess && suggestions.length === 0;
return (
<>
<Button variant="outline" size="sm" onClick={handleOpen}>
<Sparkles className="h-3.5 w-3.5 mr-1 text-primary" /> AI Suggest Batch Split
</Button>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" /> AI Batch Optimization
</DialogTitle>
</DialogHeader>
{mutation.isPending ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-6 justify-center">
<Loader2 className="h-5 w-5 animate-spin text-primary" /> Analyzing batch data...
</div>
) : mutation.isError ? (
<p className="text-sm text-muted-foreground py-4 text-center">Something went wrong. Try again.</p>
) : showResults ? (
<div className="space-y-4">
<div className="space-y-3 max-h-[50vh] overflow-y-auto">
{suggestions.map((s, i) => (
<div key={i} className="rounded-lg bg-muted/30 p-4 border border-border/60">
<p className="text-xs font-semibold text-primary uppercase tracking-wide mb-1">{s.impact} impact</p>
<p className="text-sm font-medium">{s.suggestion}</p>
{s.details ? <p className="text-sm text-muted-foreground mt-2 leading-relaxed">{s.details}</p> : null}
</div>
))}
</div>
<div className="flex gap-2">
<Button className="flex-1" onClick={handleApply}>
Apply Suggestion
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Dismiss
</Button>
</div>
</div>
) : showEmpty ? (
<p className="text-sm text-muted-foreground py-4 text-center">No optimization suggestions for this batch.</p>
) : null}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,215 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
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 { Sparkles, Loader2 } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { lmsService } from "@/services/lms.service";
interface Props {
type: "course" | "assignment" | "batch" | "rubric" | "exam" | "student" | "teacher";
trigger?: React.ReactNode;
onGenerated?: (data: any) => void;
}
const typeLabels: Record<string, string> = {
course: "Course", assignment: "Assignment", batch: "Batch", rubric: "Rubric",
exam: "Exam", student: "Student Plan", teacher: "Teacher Assignment",
};
export default function AiCreationAssistant({ type, trigger, onGenerated }: Props) {
const [open, setOpen] = useState(false);
const [prompt, setPrompt] = useState("");
const [level, setLevel] = useState("b2");
const [subjectId, setSubjectId] = useState("");
const { toast } = useToast();
const generateCourseMutation = useMutation({
mutationFn: (payload: { title: string; subject_id?: number; level?: string }) =>
lmsService.aiGenerateCourse(payload),
onError: (err: Error) => {
toast({
variant: "destructive",
title: "Generation failed",
description: err.message || "Could not generate course outline.",
});
},
});
const handleGenerate = () => {
if (type !== "course") {
toast({
variant: "destructive",
title: "Not available",
description: "Live AI generation is wired for courses only. Use the course type to generate an outline.",
});
return;
}
const title = prompt.trim() || "Untitled course";
const sid = subjectId.trim() ? Number(subjectId) : undefined;
generateCourseMutation.mutate({
title,
...(sid !== undefined && !Number.isNaN(sid) ? { subject_id: sid } : {}),
level,
});
};
const handleApply = () => {
if (!generateCourseMutation.data) return;
onGenerated?.(generateCourseMutation.data);
toast({
title: `AI ${typeLabels[type]} Applied`,
description: `The AI-generated ${type} has been applied to your form.`,
});
setOpen(false);
generateCourseMutation.reset();
};
return (
<>
{trigger ? (
<div onClick={() => setOpen(true)}>{trigger}</div>
) : (
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<Sparkles className="h-3.5 w-3.5 mr-1 text-primary" /> AI Generate {typeLabels[type]}
</Button>
)}
<Dialog
open={open}
onOpenChange={(v) => {
setOpen(v);
if (!v) generateCourseMutation.reset();
}}
>
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" /> AI {typeLabels[type]} Generator
</DialogTitle>
</DialogHeader>
{type === "course" && generateCourseMutation.isSuccess ? (
<div className="space-y-4">
<div className="rounded-lg bg-muted/30 p-4 space-y-3">
<p className="text-xs font-semibold text-primary flex items-center gap-1">
<Sparkles className="h-3 w-3" /> AI Generated {typeLabels[type]}
</p>
<div>
<span className="text-xs text-muted-foreground">Outline</span>
<pre className="text-sm mt-1 whitespace-pre-wrap break-words rounded-md border bg-background p-3 max-h-[40vh] overflow-y-auto">
{typeof generateCourseMutation.data?.outline === "string"
? generateCourseMutation.data.outline
: JSON.stringify(generateCourseMutation.data?.outline, null, 2)}
</pre>
</div>
</div>
<div className="flex gap-2">
<Button className="flex-1" onClick={handleApply}>
Apply to Form
</Button>
<Button variant="outline" onClick={() => generateCourseMutation.reset()}>
Regenerate
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label>Describe what you need</Label>
<Textarea
placeholder={`Describe the ${type} you want to create... (e.g. "An intermediate IELTS writing course focusing on Task 2 essays")`}
rows={3}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
</div>
{type === "course" && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Level</Label>
<Select value={level} onValueChange={setLevel}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{["A1", "A2", "B1", "B2", "C1", "C2"].map((l) => (
<SelectItem key={l} value={l.toLowerCase()}>
{l}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Subject ID (optional)</Label>
<Input
inputMode="numeric"
placeholder="e.g. 12"
value={subjectId}
onChange={(e) => setSubjectId(e.target.value)}
/>
</div>
</div>
)}
{(type === "assignment" || type === "exam") && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Level</Label>
<Select defaultValue="b2">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{["A1", "A2", "B1", "B2", "C1", "C2"].map((l) => (
<SelectItem key={l} value={l.toLowerCase()}>
{l}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Focus Area</Label>
<Select defaultValue="general">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="general">General</SelectItem>
<SelectItem value="reading">Reading</SelectItem>
<SelectItem value="writing">Writing</SelectItem>
<SelectItem value="speaking">Speaking</SelectItem>
<SelectItem value="listening">Listening</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
<Button
className="w-full"
onClick={handleGenerate}
disabled={generateCourseMutation.isPending}
>
{generateCourseMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Generating...
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-2" /> Generate with AI
</>
)}
</Button>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,207 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Sparkles, Loader2, Trash2 } from "lucide-react";
import type { ExamModule } from "@/types";
import { generationService } from "@/services/generation.service";
import { useToast } from "@/hooks/use-toast";
type ExerciseRow = { title?: string; description?: string; marks?: number };
function exerciseLabel(ex: unknown, index: number): ExerciseRow {
if (ex && typeof ex === "object") {
const o = ex as Record<string, unknown>;
return {
title: typeof o.title === "string" ? o.title : `Exercise ${index + 1}`,
description: typeof o.description === "string" ? o.description : undefined,
marks: typeof o.marks === "number" ? o.marks : typeof o.marks === "string" ? Number(o.marks) : undefined,
};
}
return { title: String(ex) };
}
export default function AiGeneratorModal() {
const [open, setOpen] = useState(false);
const [moduleType, setModuleType] = useState<ExamModule>("reading");
const [difficulty, setDifficulty] = useState("b2");
const [count, setCount] = useState(5);
const [topic, setTopic] = useState("");
const [localExercises, setLocalExercises] = useState<unknown[] | null>(null);
const { toast } = useToast();
const generateMutation = useMutation({
mutationFn: () =>
generationService.generate(moduleType, {
title: topic.trim() || `${moduleType} practice set`,
difficulty,
count,
}),
onSuccess: (res) => {
setLocalExercises(Array.isArray(res.exercises) ? res.exercises : []);
},
onError: (err: Error) => {
toast({
variant: "destructive",
title: "Generation failed",
description: err.message || "Could not generate exercises.",
});
},
});
const handleGenerate = () => {
setLocalExercises(null);
generateMutation.mutate();
};
const generated = localExercises;
const handleRemove = (index: number) => {
setLocalExercises((prev) => (prev ? prev.filter((_, i) => i !== index) : null));
};
return (
<Dialog
open={open}
onOpenChange={(v) => {
setOpen(v);
if (!v) {
setLocalExercises(null);
generateMutation.reset();
}
}}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Sparkles className="h-3.5 w-3.5 mr-1 text-primary" /> Generate with AI
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" /> AI Assignment Generator
</DialogTitle>
</DialogHeader>
{!generated ? (
<div className="space-y-4">
<div className="space-y-2">
<Label>Module Type</Label>
<Select
value={moduleType}
onValueChange={(v) => setModuleType(v as ExamModule)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="reading">Reading</SelectItem>
<SelectItem value="listening">Listening</SelectItem>
<SelectItem value="writing">Writing</SelectItem>
<SelectItem value="speaking">Speaking</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Difficulty</Label>
<Select value={difficulty} onValueChange={setDifficulty}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{["A1", "A2", "B1", "B2", "C1", "C2"].map((l) => (
<SelectItem key={l} value={l.toLowerCase()}>
{l}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Count</Label>
<Input
type="number"
value={count}
min={1}
max={10}
onChange={(e) => setCount(Number(e.target.value) || 1)}
/>
</div>
</div>
<div className="space-y-2">
<Label>Topic / Focus Area</Label>
<Input
placeholder="e.g. Academic reading comprehension"
value={topic}
onChange={(e) => setTopic(e.target.value)}
/>
</div>
<Button
className="w-full"
onClick={handleGenerate}
disabled={generateMutation.isPending}
>
{generateMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Generating...
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-2" /> Generate Assignments
</>
)}
</Button>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
AI generated {generated.length} assignments. Edit or remove before saving.
</p>
{generated.map((item, i) => {
const row = exerciseLabel(item, i);
return (
<div key={i} className="rounded-lg border p-3 space-y-1">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium">{row.title}</p>
{row.description && (
<p className="text-xs text-muted-foreground mt-1">{row.description}</p>
)}
{row.marks !== undefined && !Number.isNaN(row.marks) && (
<p className="text-xs font-semibold mt-1">{row.marks} marks</p>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => handleRemove(i)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</div>
);
})}
<div className="flex gap-2">
<Button className="flex-1">Save All</Button>
<Button
variant="outline"
onClick={() => {
setLocalExercises(null);
generateMutation.reset();
}}
>
Regenerate
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,78 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Sparkles, Loader2 } from "lucide-react";
import { coachingService } from "@/services/coaching.service";
import { useToast } from "@/hooks/use-toast";
export default function AiGradeExplainer({
studentName,
scores,
}: {
studentName: string;
scores?: Record<string, number>;
}) {
const [open, setOpen] = useState(false);
const { toast } = useToast();
const explainMutation = useMutation({
mutationFn: () =>
coachingService.explain({
context: `IELTS / course grades for student: ${studentName}. Summarize what the scores mean and what to focus on next.`,
scores,
}),
onError: (err: Error) => {
toast({
variant: "destructive",
title: "Could not explain grades",
description: err.message || "Try again in a moment.",
});
},
});
const handleOpen = () => {
setOpen(true);
explainMutation.reset();
explainMutation.mutate();
};
return (
<>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleOpen} title="AI Explain Grade">
<Sparkles className="h-3.5 w-3.5 text-primary" />
</Button>
<Dialog
open={open}
onOpenChange={(v) => {
setOpen(v);
if (!v) explainMutation.reset();
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
AI Grade Explanation {studentName}
</DialogTitle>
</DialogHeader>
{explainMutation.isPending ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-6 justify-center">
<Loader2 className="h-5 w-5 animate-spin text-primary" /> Analyzing grades...
</div>
) : explainMutation.isError ? (
<p className="text-sm text-destructive text-center py-4">
Something went wrong. Close and try again.
</p>
) : (
<div className="rounded-lg bg-muted/30 p-4">
<p className="text-sm leading-relaxed">
{explainMutation.data?.explanation}
</p>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,100 @@
import { useEffect } from "react";
import { useMutation } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Sparkles, Loader2 } from "lucide-react";
import { analyticsService } from "@/services/analytics.service";
import { useToast } from "@/hooks/use-toast";
interface Props {
onAccept: (marks: number, feedback: string) => void;
submissionId?: number;
submissionText?: string;
rubricId?: number;
}
const DEFAULT_TEXT =
"Sample submission for AI grading suggestion. Replace by passing submissionText when integrating with real submissions.";
export default function AiGradingAssistant({
onAccept,
submissionId = 1,
submissionText = DEFAULT_TEXT,
rubricId,
}: Props) {
const { toast } = useToast();
const gradeMutation = useMutation({
mutationFn: () =>
analyticsService.getGradingSuggestion({
submission_id: submissionId,
text: submissionText,
...(rubricId !== undefined ? { rubric_id: rubricId } : {}),
}),
onError: (err: Error) => {
toast({
variant: "destructive",
title: "Could not load AI grade",
description: err.message || "Try again later.",
});
},
});
useEffect(() => {
gradeMutation.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps -- refetch when inputs change
}, [submissionId, submissionText, rubricId]);
const data = gradeMutation.data;
const marks = data ? Math.round(data.overall_score) : 0;
const feedbackBlock = data
? [
data.feedback,
data.suggestions?.length
? `Suggestions:\n${data.suggestions.map((s) => `${s}`).join("\n")}`
: "",
]
.filter(Boolean)
.join("\n\n")
: "";
return (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<p className="text-xs font-semibold text-primary flex items-center gap-1">
<Sparkles className="h-3 w-3" /> AI Suggested Grade
</p>
{gradeMutation.isPending ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" /> Analyzing submission...
</div>
) : gradeMutation.isError ? (
<p className="text-sm text-destructive">Could not load a suggestion. Check the console or try again.</p>
) : data ? (
<>
<div>
<p className="text-sm text-muted-foreground">Suggested marks</p>
<p className="text-2xl font-bold">{marks}/100</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1">Suggested feedback</p>
<p className="text-sm whitespace-pre-wrap">{feedbackBlock}</p>
</div>
{data.scores && Object.keys(data.scores).length > 0 && (
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Rubric scores</p>
<ul className="text-xs text-muted-foreground space-y-0.5">
{Object.entries(data.scores).map(([k, v]) => (
<li key={k}>
{k}: {v}
</li>
))}
</ul>
</div>
)}
<Button size="sm" onClick={() => onAccept(marks, feedbackBlock)}>
<Sparkles className="h-3.5 w-3.5 mr-1" /> Accept AI Grade
</Button>
</>
) : null}
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { useEffect, useMemo } from "react";
import { useMutation } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Sparkles, TrendingUp, AlertTriangle, Trophy, Loader2 } from "lucide-react";
import { analyticsService } from "@/services/analytics.service";
import type { AiInsight } from "@/types";
import { useToast } from "@/hooks/use-toast";
const EMPTY_PAYLOAD: Record<string, unknown> = {};
function insightIcon(type: AiInsight["type"]) {
switch (type) {
case "positive":
return Trophy;
case "warning":
return AlertTriangle;
default:
return TrendingUp;
}
}
function insightColor(type: AiInsight["type"]) {
switch (type) {
case "positive":
return "text-primary";
case "warning":
return "text-warning";
default:
return "text-success";
}
}
interface Props {
data?: Record<string, unknown>;
}
export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
const { toast } = useToast();
const payloadKey = useMemo(() => JSON.stringify(data), [data]);
const mutation = useMutation({
mutationFn: (payload: Record<string, unknown>) => analyticsService.getInsights(payload),
onError: (err: Error) => {
toast({
title: "Insights unavailable",
description: err.message || "Could not load AI insights.",
variant: "destructive",
});
},
});
useEffect(() => {
mutation.mutate(data);
// eslint-disable-next-line react-hooks/exhaustive-deps -- refetch when serialized payload changes
}, [payloadKey]);
const items = mutation.data ?? [];
return (
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
AI Platform Insights
</CardTitle>
</CardHeader>
<CardContent>
{mutation.isPending && (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-8 justify-center">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
Loading insights
</div>
)}
{mutation.isError && !mutation.isPending && (
<p className="text-sm text-muted-foreground py-4 text-center">Could not load insights.</p>
)}
{mutation.isSuccess && items.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">No insights available for this view.</p>
)}
{!mutation.isPending && items.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{items.map((item) => {
const Icon = insightIcon(item.type);
const color = insightColor(item.type);
return (
<div key={item.id} className="rounded-lg border bg-muted/30 p-4">
<div className="flex items-center gap-2 mb-2">
<Icon className={`h-4 w-4 ${color}`} />
<span className="text-sm font-semibold">{item.title}</span>
</div>
<p className="text-sm text-muted-foreground">{item.description}</p>
{item.metric != null && item.value != null && (
<p className="text-xs text-muted-foreground mt-2">
{item.metric}: {item.value}
</p>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,56 @@
import { useEffect, useMemo } from "react";
import { useMutation } from "@tanstack/react-query";
import { Sparkles, Loader2 } from "lucide-react";
import { analyticsService } from "@/services/analytics.service";
import { useToast } from "@/hooks/use-toast";
interface Props {
report_type: string;
data: Record<string, unknown>;
}
export default function AiReportNarrative({ report_type, data }: Props) {
const { toast } = useToast();
const dataKey = useMemo(() => JSON.stringify(data), [data]);
const mutation = useMutation({
mutationFn: (vars: { report_type: string; data: Record<string, unknown> }) =>
analyticsService.getReportNarrative(vars),
onError: (err: Error) => {
toast({
title: "Summary unavailable",
description: err.message || "Could not generate AI summary.",
variant: "destructive",
});
},
});
useEffect(() => {
mutation.mutate({ report_type, data });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [report_type, dataKey]);
return (
<div className="rounded-lg border bg-primary/5 p-4 mb-4 flex items-start gap-3">
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<span className="text-xs font-semibold text-primary">AI Summary</span>
{mutation.isPending && (
<p className="text-sm text-muted-foreground mt-1 flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
Generating summary
</p>
)}
{mutation.isError && !mutation.isPending && (
<p className="text-sm text-muted-foreground mt-1">Could not load summary.</p>
)}
{!mutation.isPending && mutation.data?.narrative && (
<p className="text-sm text-muted-foreground mt-1">{mutation.data.narrative}</p>
)}
{mutation.isSuccess && !mutation.data?.narrative?.trim() && (
<p className="text-sm text-muted-foreground mt-1">No summary returned.</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export type AiRiskLevel = "At Risk" | "Monitor" | "On Track";
interface Props {
riskLevel?: AiRiskLevel;
reasons?: string[];
}
export default function AiRiskBadge({ riskLevel, reasons }: Props) {
if (!riskLevel) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="text-xs cursor-help">
Unknown
</Badge>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs max-w-[200px]">Risk data not available from profile.</p>
</TooltipContent>
</Tooltip>
);
}
const variant: "destructive" | "secondary" | "default" =
riskLevel === "At Risk" ? "destructive" : riskLevel === "Monitor" ? "secondary" : "default";
const tooltipText =
reasons?.filter(Boolean).length ? reasons!.filter(Boolean).join(" · ") : "No additional details provided.";
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant={variant} className="text-xs cursor-help">
{riskLevel}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs max-w-[200px]">{tooltipText}</p>
</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,103 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Input } from "@/components/ui/input";
import { Sparkles, Search, Loader2, X } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { analyticsService } from "@/services/analytics.service";
import { useToast } from "@/hooks/use-toast";
export default function AiSearchBar() {
const [query, setQuery] = useState("");
const navigate = useNavigate();
const { toast } = useToast();
const searchMutation = useMutation({
mutationFn: (q: string) => analyticsService.search(q),
onError: (err: Error) => {
toast({
variant: "destructive",
title: "Search failed",
description: err.message || "Could not complete AI search.",
});
},
});
const handleSearch = () => {
if (!query.trim() || searchMutation.isPending) return;
searchMutation.mutate(query.trim());
};
const results = searchMutation.data;
return (
<div className="relative max-w-md w-full">
<div className="relative">
<Sparkles className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary" />
<Search className="absolute left-7 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Ask anything... e.g. 'Show students with low attendance'"
className="pl-12 pr-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1"
value={query}
onChange={(e) => {
setQuery(e.target.value);
searchMutation.reset();
}}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
{query && (
<button
onClick={() => {
setQuery("");
searchMutation.reset();
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{(searchMutation.isPending || results !== undefined) && (
<div className="absolute top-full mt-1 left-0 right-0 z-50 rounded-lg border bg-popover p-3 shadow-md">
{searchMutation.isPending ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
AI is searching...
</div>
) : results && results.length > 0 ? (
<div className="text-sm space-y-2 max-h-64 overflow-y-auto">
{results.map((r, i) => (
<div
key={`${r.title}-${i}`}
className="flex items-start gap-2 border-b border-border/60 pb-2 last:border-0 last:pb-0"
>
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<div className="min-w-0">
<p className="font-medium">{r.title}</p>
<p className="text-muted-foreground text-xs mt-0.5">{r.description}</p>
{r.url && (
<button
type="button"
className="text-xs text-primary mt-1 hover:underline"
onClick={() => navigate(r.url!)}
>
Go to {r.url}
</button>
)}
</div>
</div>
))}
</div>
) : (
<div className="text-sm flex items-start gap-2">
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<p>
No results for &quot;{query}&quot;. Try a different question or keyword.
</p>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { useEffect } from "react";
import { useMutation } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Sparkles, RefreshCw, Loader2, Lightbulb } from "lucide-react";
import { coachingService } from "@/services/coaching.service";
import { useToast } from "@/hooks/use-toast";
export default function AiStudyCoach() {
const { toast } = useToast();
const suggestMutation = useMutation({
mutationFn: () => coachingService.suggest(),
onError: (err: Error) => {
toast({
variant: "destructive",
title: "Could not load coach tips",
description: err.message || "Try refreshing in a moment.",
});
},
});
useEffect(() => {
suggestMutation.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount
}, []);
const refresh = () => {
suggestMutation.mutate();
};
const suggestions = suggestMutation.data?.suggestions ?? [];
const planTips = suggestMutation.data?.study_plan_tips ?? [];
return (
<Card className="border-0 shadow-sm bg-primary/5">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
Your AI Study Coach
</CardTitle>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={refresh}
disabled={suggestMutation.isPending}
>
<RefreshCw className={`h-4 w-4 ${suggestMutation.isPending ? "animate-spin" : ""}`} />
</Button>
</div>
</CardHeader>
<CardContent>
{suggestMutation.isPending && !suggestMutation.data ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4 justify-center">
<Loader2 className="h-5 w-5 animate-spin text-primary" /> Analyzing your performance...
</div>
) : (
<>
{suggestions.length > 0 && (
<div className="mb-4">
<p className="text-xs font-semibold text-primary mb-2 flex items-center gap-1">
<Lightbulb className="h-3.5 w-3.5" /> Suggestions
</p>
<ul className="text-sm text-muted-foreground space-y-2 list-disc list-inside">
{suggestions.map((s, i) => (
<li key={i}>{s}</li>
))}
</ul>
</div>
)}
{planTips.length > 0 && (
<div>
<p className="text-xs font-semibold text-primary mb-2 flex items-center gap-1">
<Sparkles className="h-3.5 w-3.5" /> Study plan tips
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{planTips.map((tip, i) => (
<div
key={i}
className="rounded-lg border bg-card p-3 hover:shadow-sm transition-shadow"
>
<p className="text-sm">{tip}</p>
</div>
))}
</div>
</div>
)}
{!suggestMutation.isPending &&
suggestions.length === 0 &&
planTips.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No suggestions yet. Try refreshing.
</p>
)}
</>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,81 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Sparkles, X, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { coachingService } from "@/services/coaching.service";
interface Props {
context?: string;
variant?: "tip" | "insight" | "recommendation";
dismissible?: boolean;
}
export default function AiTipBanner({ context = "dashboard", variant = "tip", dismissible = true }: Props) {
const [dismissed, setDismissed] = useState(false);
const { data, isLoading, isError, error } = useQuery({
queryKey: ["ai", "tip", context],
queryFn: () => coachingService.getTip(context),
});
if (dismissed) return null;
const bgClass = variant === "recommendation" ? "bg-accent/50 border-accent" : variant === "insight" ? "bg-primary/5 border-primary/20" : "bg-muted/50 border-muted-foreground/10";
if (isLoading) {
return (
<div className={`rounded-lg border ${bgClass} p-3 flex items-center gap-3`}>
<Loader2 className="h-4 w-4 animate-spin text-primary shrink-0" />
<span className="text-sm text-muted-foreground">Loading AI tip</span>
</div>
);
}
if (isError || !data) {
return (
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`}>
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<div className="flex-1">
<span className="text-xs font-semibold text-primary">AI Tip</span>
<p className="text-sm text-muted-foreground mt-0.5">
{isError ? (error instanceof Error ? error.message : "Could not load tip.") : "No tip available."}
</p>
</div>
{dismissible && (
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => setDismissed(true)}>
<X className="h-3 w-3" />
</Button>
)}
</div>
);
}
if (!data.content?.trim() && !data.title?.trim()) {
return (
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`}>
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<div className="flex-1">
<span className="text-xs font-semibold text-primary">AI {variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}</span>
<p className="text-sm text-muted-foreground mt-0.5">No tip for this context yet.</p>
</div>
</div>
);
}
return (
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3 animate-in fade-in slide-in-from-top-2 duration-300`}>
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<div className="flex-1">
<span className="text-xs font-semibold text-primary">
{data.title?.trim() || `AI ${variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}`}
</span>
<p className="text-sm text-muted-foreground mt-0.5">{data.content}</p>
</div>
{dismissible && (
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => setDismissed(true)}>
<X className="h-3 w-3" />
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,140 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Sparkles, Loader2, PenLine, CheckCircle, BarChart3, ChevronDown } from "lucide-react";
import { coachingService } from "@/services/coaching.service";
import { useToast } from "@/hooks/use-toast";
type Mode = "improve" | "grammar" | "band" | null;
interface Props {
text: string;
task_type?: string;
}
export default function AiWritingHelper({ text, task_type = "ielts_writing" }: Props) {
const [open, setOpen] = useState(false);
const [activeMode, setActiveMode] = useState<Mode>(null);
const [showResult, setShowResult] = useState(false);
const { toast } = useToast();
const mutation = useMutation({
mutationFn: (mode: NonNullable<Mode>) =>
coachingService.writingHelp({
text: text.trim(),
task_type: `${task_type}:${mode}`,
}),
onSuccess: () => setShowResult(true),
onError: (err: Error) => {
toast({
title: "Writing help failed",
description: err.message || "Could not analyze your writing. Try again.",
variant: "destructive",
});
},
});
const handleAction = (mode: Mode) => {
if (!mode) return;
if (!text.trim()) {
toast({
title: "Add some text first",
description: "Enter your draft in the text area so AI can analyze it.",
variant: "destructive",
});
return;
}
setActiveMode(mode);
setShowResult(false);
mutation.mutate(mode);
};
const loading = mutation.isPending;
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<Button variant="outline" size="sm" className="w-full justify-between mt-3">
<span className="flex items-center gap-2">
<Sparkles className="h-3.5 w-3.5 text-primary" />
AI Writing Helper
</span>
<ChevronDown className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-3">
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => handleAction("improve")} disabled={loading}>
<PenLine className="h-3.5 w-3.5 mr-1" /> Improve my draft
</Button>
<Button variant="outline" size="sm" onClick={() => handleAction("grammar")} disabled={loading}>
<CheckCircle className="h-3.5 w-3.5 mr-1" /> Check grammar
</Button>
<Button variant="outline" size="sm" onClick={() => handleAction("band")} disabled={loading}>
<BarChart3 className="h-3.5 w-3.5 mr-1" /> Estimate band score
</Button>
</div>
{loading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground rounded-lg bg-muted/50 p-3">
<Loader2 className="h-4 w-4 animate-spin text-primary" /> AI is analyzing your writing...
</div>
)}
{showResult && !loading && mutation.data && activeMode === "improve" && (
<div className="space-y-3">
{mutation.data.feedback && (
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
<Sparkles className="h-3 w-3" /> Feedback
</p>
<p className="text-sm text-muted-foreground">{mutation.data.feedback}</p>
</div>
)}
{mutation.data.improved && (
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
<Sparkles className="h-3 w-3" /> Improved Version
</p>
<p className="text-sm">{mutation.data.improved}</p>
</div>
)}
</div>
)}
{showResult && !loading && mutation.data && activeMode === "grammar" && (
<div className="rounded-lg border bg-muted/30 p-3 space-y-2">
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
<Sparkles className="h-3 w-3" /> Grammar notes
</p>
{(mutation.data.grammar_notes?.length ?? 0) > 0 ? (
mutation.data.grammar_notes!.map((note, i) => (
<div key={i} className="text-sm border-l-2 border-warning pl-2">
<p className="text-muted-foreground">{note}</p>
</div>
))
) : (
<p className="text-sm text-muted-foreground">No grammar issues flagged.</p>
)}
{mutation.data.feedback ? (
<p className="text-xs text-muted-foreground pt-2 border-t">{mutation.data.feedback}</p>
) : null}
</div>
)}
{showResult && !loading && mutation.data && activeMode === "band" && (
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
<Sparkles className="h-3 w-3" /> Estimated band / assessment
</p>
<p className="text-sm text-muted-foreground">{mutation.data.feedback}</p>
{mutation.data.improved ? (
<p className="text-sm mt-2 pt-2 border-t">{mutation.data.improved}</p>
) : null}
</div>
)}
</CollapsibleContent>
</Collapsible>
);
}