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:
80
src/components/ai/AiAlertBanner.tsx
Normal file
80
src/components/ai/AiAlertBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
src/components/ai/AiAssistantDrawer.tsx
Normal file
139
src/components/ai/AiAssistantDrawer.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
101
src/components/ai/AiBatchOptimizer.tsx
Normal file
101
src/components/ai/AiBatchOptimizer.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
215
src/components/ai/AiCreationAssistant.tsx
Normal file
215
src/components/ai/AiCreationAssistant.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
207
src/components/ai/AiGeneratorModal.tsx
Normal file
207
src/components/ai/AiGeneratorModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
src/components/ai/AiGradeExplainer.tsx
Normal file
78
src/components/ai/AiGradeExplainer.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
100
src/components/ai/AiGradingAssistant.tsx
Normal file
100
src/components/ai/AiGradingAssistant.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
src/components/ai/AiInsightsPanel.tsx
Normal file
105
src/components/ai/AiInsightsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/components/ai/AiReportNarrative.tsx
Normal file
56
src/components/ai/AiReportNarrative.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/components/ai/AiRiskBadge.tsx
Normal file
45
src/components/ai/AiRiskBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
src/components/ai/AiSearchBar.tsx
Normal file
103
src/components/ai/AiSearchBar.tsx
Normal 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 "{query}". Try a different question or keyword.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/components/ai/AiStudyCoach.tsx
Normal file
102
src/components/ai/AiStudyCoach.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
src/components/ai/AiTipBanner.tsx
Normal file
81
src/components/ai/AiTipBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
src/components/ai/AiWritingHelper.tsx
Normal file
140
src/components/ai/AiWritingHelper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user