Compare commits
7 Commits
v3
...
435930a827
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
435930a827 | ||
|
|
b78124bb7b | ||
|
|
1d7ebea2af | ||
|
|
2f450a7f26 | ||
|
|
e066c595b9 | ||
|
|
74c2c9f2d2 | ||
|
|
5b2ccbfeec |
@@ -6,8 +6,14 @@
|
|||||||
<title>EnCoach — IELTS & English Learning Platform</title>
|
<title>EnCoach — IELTS & English Learning Platform</title>
|
||||||
<meta name="description" content="EnCoach is a comprehensive IELTS and English learning platform for students, teachers, and corporates." />
|
<meta name="description" content="EnCoach is a comprehensive IELTS and English learning platform for students, teachers, and corporates." />
|
||||||
<meta name="author" content="EnCoach" />
|
<meta name="author" content="EnCoach" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="192x192" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="theme-color" content="#4A4068" />
|
||||||
<meta property="og:title" content="EnCoach — IELTS & English Learning Platform" />
|
<meta property="og:title" content="EnCoach — IELTS & English Learning Platform" />
|
||||||
<meta property="og:description" content="Comprehensive IELTS preparation and English learning platform." />
|
<meta property="og:description" content="Comprehensive IELTS preparation and English learning platform." />
|
||||||
|
<meta property="og:image" content="/logo-icon.png" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|||||||
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/favicon-16x16.png
Normal file
BIN
public/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 532 B |
BIN
public/favicon-32x32.png
Normal file
BIN
public/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 888 B |
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 554 B |
BIN
public/logo-icon.png
Normal file
BIN
public/logo-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
13
src/App.tsx
13
src/App.tsx
@@ -61,6 +61,7 @@ import TeacherAttendance from "@/pages/teacher/TeacherAttendance";
|
|||||||
import TeacherStudents from "@/pages/teacher/TeacherStudents";
|
import TeacherStudents from "@/pages/teacher/TeacherStudents";
|
||||||
import TeacherTimetable from "@/pages/teacher/TeacherTimetable";
|
import TeacherTimetable from "@/pages/teacher/TeacherTimetable";
|
||||||
import TeacherProfile from "@/pages/teacher/TeacherProfile";
|
import TeacherProfile from "@/pages/teacher/TeacherProfile";
|
||||||
|
import TeacherLibrary from "@/pages/teacher/TeacherLibrary";
|
||||||
import AdaptiveSettings from "@/pages/teacher/AdaptiveSettings";
|
import AdaptiveSettings from "@/pages/teacher/AdaptiveSettings";
|
||||||
// Admin LMS pages
|
// Admin LMS pages
|
||||||
import AdminLmsDashboard from "@/pages/admin/AdminLmsDashboard";
|
import AdminLmsDashboard from "@/pages/admin/AdminLmsDashboard";
|
||||||
@@ -145,6 +146,7 @@ import FaqPage from "@/pages/FaqPage";
|
|||||||
import NotFound from "@/pages/NotFound";
|
import NotFound from "@/pages/NotFound";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||||
|
|
||||||
function StudentSubscriptionPlaceholder() {
|
function StudentSubscriptionPlaceholder() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -157,6 +159,7 @@ function StudentSubscriptionPlaceholder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const App = () => (
|
const App = () => (
|
||||||
|
<ErrorBoundary>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
@@ -220,10 +223,11 @@ const App = () => (
|
|||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Teacher routes */}
|
{/* Teacher routes */}
|
||||||
<Route element={<ProtectedRoute allowedRoles={["teacher"]} />}>
|
<Route element={<ProtectedRoute allowedRoles={["teacher", "admin", "developer"]} />}>
|
||||||
<Route element={<TeacherLayout />}>
|
<Route element={<TeacherLayout />}>
|
||||||
<Route path="/teacher/dashboard" element={<TeacherDashboard />} />
|
<Route path="/teacher/dashboard" element={<TeacherDashboard />} />
|
||||||
<Route path="/teacher/courses" element={<TeacherCourses />} />
|
<Route path="/teacher/courses" element={<TeacherCourses />} />
|
||||||
|
<Route path="/teacher/library" element={<TeacherLibrary />} />
|
||||||
<Route path="/teacher/courses/new" element={<CourseBuilder />} />
|
<Route path="/teacher/courses/new" element={<CourseBuilder />} />
|
||||||
<Route path="/teacher/courses/:id/edit" element={<CourseBuilder />} />
|
<Route path="/teacher/courses/:id/edit" element={<CourseBuilder />} />
|
||||||
<Route path="/teacher/assignments" element={<TeacherAssignments />} />
|
<Route path="/teacher/assignments" element={<TeacherAssignments />} />
|
||||||
@@ -231,9 +235,9 @@ const App = () => (
|
|||||||
<Route path="/teacher/attendance" element={<TeacherAttendance />} />
|
<Route path="/teacher/attendance" element={<TeacherAttendance />} />
|
||||||
<Route path="/teacher/students" element={<TeacherStudents />} />
|
<Route path="/teacher/students" element={<TeacherStudents />} />
|
||||||
<Route path="/teacher/timetable" element={<TeacherTimetable />} />
|
<Route path="/teacher/timetable" element={<TeacherTimetable />} />
|
||||||
<Route path="/teacher/courses/:id/chapters" element={<CourseChapters />} />
|
<Route path="/teacher/courses/:courseId/chapters" element={<CourseChapters />} />
|
||||||
<Route path="/teacher/courses/:id/chapters/:chapterId" element={<ChapterDetail />} />
|
<Route path="/teacher/courses/:courseId/chapters/:chapterId" element={<ChapterDetail />} />
|
||||||
<Route path="/teacher/courses/:id/workbench" element={<AiWorkbench />} />
|
<Route path="/teacher/courses/:courseId/workbench" element={<AiWorkbench />} />
|
||||||
<Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} />
|
<Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} />
|
||||||
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
|
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
|
||||||
<Route path="/teacher/profile" element={<TeacherProfile />} />
|
<Route path="/teacher/profile" element={<TeacherProfile />} />
|
||||||
@@ -340,6 +344,7 @@ const App = () => (
|
|||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
62
src/components/ErrorBoundary.tsx
Normal file
62
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error("ErrorBoundary caught:", error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) return this.props.fallback;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background p-6">
|
||||||
|
<div className="max-w-md w-full text-center space-y-4">
|
||||||
|
<div className="mx-auto h-16 w-16 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||||
|
<svg className="h-8 w-8 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold">Something went wrong</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
An unexpected error occurred. Please try refreshing the page.
|
||||||
|
</p>
|
||||||
|
{this.state.error && (
|
||||||
|
<details className="text-left text-xs text-muted-foreground bg-muted rounded-lg p-3">
|
||||||
|
<summary className="cursor-pointer font-medium">Error details</summary>
|
||||||
|
<pre className="mt-2 whitespace-pre-wrap break-words">{this.state.error.message}</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Refresh Page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
235
src/components/MaterialViewer.tsx
Normal file
235
src/components/MaterialViewer.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ExternalLink, Download, FileText, Video, Music, Image, Link2 } from "lucide-react";
|
||||||
|
import { API_BASE_URL } from "@/lib/api-client";
|
||||||
|
import type { ChapterMaterial, MaterialType } from "@/types/courseware";
|
||||||
|
|
||||||
|
interface MaterialViewerProps {
|
||||||
|
material: ChapterMaterial | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onDownload?: (materialId: number, name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabels: Record<MaterialType, string> = {
|
||||||
|
pdf: "PDF Document",
|
||||||
|
document: "Document",
|
||||||
|
video: "Video",
|
||||||
|
audio: "Audio",
|
||||||
|
image: "Image",
|
||||||
|
link: "External Link",
|
||||||
|
article: "Article",
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeIcons: Record<MaterialType, React.ReactNode> = {
|
||||||
|
pdf: <FileText className="h-4 w-4" />,
|
||||||
|
document: <FileText className="h-4 w-4" />,
|
||||||
|
video: <Video className="h-4 w-4" />,
|
||||||
|
audio: <Music className="h-4 w-4" />,
|
||||||
|
image: <Image className="h-4 w-4" />,
|
||||||
|
link: <Link2 className="h-4 w-4" />,
|
||||||
|
article: <FileText className="h-4 w-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getContentUrl(materialId: number): string {
|
||||||
|
const token = localStorage.getItem("encoach_token") ?? "";
|
||||||
|
return `${API_BASE_URL}/materials/${materialId}/content?token=${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isYouTubeUrl(url: string): boolean {
|
||||||
|
return /(?:youtube\.com\/(?:watch|embed)|youtu\.be\/)/.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getYouTubeEmbedUrl(url: string): string {
|
||||||
|
const match = url.match(
|
||||||
|
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([\w-]+)/
|
||||||
|
);
|
||||||
|
return match ? `https://www.youtube.com/embed/${match[1]}` : url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PdfViewer({ material }: { material: ChapterMaterial }) {
|
||||||
|
if (material.file_url) {
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src={getContentUrl(material.id)}
|
||||||
|
className="w-full h-full min-h-[600px] rounded-lg border"
|
||||||
|
title={material.name}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (material.url) {
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src={material.url}
|
||||||
|
className="w-full h-full min-h-[600px] rounded-lg border"
|
||||||
|
title={material.name}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <EmptyState message="No PDF file available." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoViewer({ material }: { material: ChapterMaterial }) {
|
||||||
|
if (material.url && isYouTubeUrl(material.url)) {
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src={getYouTubeEmbedUrl(material.url)}
|
||||||
|
className="w-full aspect-video rounded-lg border"
|
||||||
|
title={material.name}
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (material.url) {
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
src={material.url}
|
||||||
|
controls
|
||||||
|
className="w-full rounded-lg border"
|
||||||
|
title={material.name}
|
||||||
|
>
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (material.file_url) {
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
src={getContentUrl(material.id)}
|
||||||
|
controls
|
||||||
|
className="w-full rounded-lg border"
|
||||||
|
title={material.name}
|
||||||
|
>
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <EmptyState message="No video source available." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AudioViewer({ material }: { material: ChapterMaterial }) {
|
||||||
|
const src = material.url || (material.file_url ? getContentUrl(material.id) : null);
|
||||||
|
if (!src) return <EmptyState message="No audio source available." />;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<audio src={src} controls className="w-full max-w-lg">
|
||||||
|
Your browser does not support the audio element.
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageViewer({ material }: { material: ChapterMaterial }) {
|
||||||
|
const src = material.url || (material.file_url ? getContentUrl(material.id) : null);
|
||||||
|
if (!src) return <EmptyState message="No image available." />;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={material.name}
|
||||||
|
className="max-w-full max-h-[70vh] rounded-lg border object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArticleViewer({ material }: { material: ChapterMaterial }) {
|
||||||
|
if (!material.description) return <EmptyState message="No article content available." />;
|
||||||
|
return (
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none p-4 rounded-lg border bg-card">
|
||||||
|
<div className="whitespace-pre-wrap">{material.description}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkViewer({ material }: { material: ChapterMaterial }) {
|
||||||
|
if (!material.url) return <EmptyState message="No URL available." />;
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 p-4 rounded-lg border bg-muted/50">
|
||||||
|
<Link2 className="h-5 w-5 text-primary shrink-0" />
|
||||||
|
<a
|
||||||
|
href={material.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary underline break-all flex-1"
|
||||||
|
>
|
||||||
|
{material.url}
|
||||||
|
</a>
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={material.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="h-4 w-4 mr-1" /> Open
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
src={material.url}
|
||||||
|
className="w-full min-h-[500px] rounded-lg border"
|
||||||
|
title={material.name}
|
||||||
|
sandbox="allow-scripts allow-same-origin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||||
|
<FileText className="h-12 w-12 mb-3 opacity-40" />
|
||||||
|
<p>{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewers: Record<MaterialType, React.FC<{ material: ChapterMaterial }>> = {
|
||||||
|
pdf: PdfViewer,
|
||||||
|
document: PdfViewer,
|
||||||
|
video: VideoViewer,
|
||||||
|
audio: AudioViewer,
|
||||||
|
image: ImageViewer,
|
||||||
|
link: LinkViewer,
|
||||||
|
article: ArticleViewer,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MaterialViewer({ material, open, onOpenChange, onDownload }: MaterialViewerProps) {
|
||||||
|
if (!material) return null;
|
||||||
|
|
||||||
|
const Viewer = viewers[material.type] ?? ArticleViewer;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent side="right" className="w-full sm:max-w-2xl lg:max-w-4xl overflow-y-auto">
|
||||||
|
<SheetHeader className="space-y-3 pb-4 border-b">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{typeIcons[material.type]}
|
||||||
|
<Badge variant="outline">{typeLabels[material.type]}</Badge>
|
||||||
|
</div>
|
||||||
|
<SheetTitle className="text-lg">{material.name}</SheetTitle>
|
||||||
|
{material.description && material.type !== "article" && (
|
||||||
|
<p className="text-sm text-muted-foreground">{material.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{material.allow_download && material.file_url && onDownload && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onDownload(material.id, material.name)}>
|
||||||
|
<Download className="h-4 w-4 mr-1" /> Download
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{material.url && (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={material.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="h-4 w-4 mr-1" /> Open Link
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<Viewer material={material} />
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import RoleLayout, { NavGroup } from "./RoleLayout";
|
import RoleLayout, { NavGroup } from "./RoleLayout";
|
||||||
|
import ExamPopup from "./student/ExamPopup";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
|
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
|
||||||
CalendarCheck, Calendar, User, Target, GraduationCap, ListChecks,
|
CalendarCheck, Calendar, User, Target, GraduationCap, ListChecks,
|
||||||
@@ -47,5 +48,10 @@ const navGroups: NavGroup[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function StudentLayout() {
|
export default function StudentLayout() {
|
||||||
return <RoleLayout navGroups={navGroups} role="student" />;
|
return (
|
||||||
|
<>
|
||||||
|
<RoleLayout navGroups={navGroups} role="student" />
|
||||||
|
<ExamPopup />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
143
src/components/TaxonomyCascade.tsx
Normal file
143
src/components/TaxonomyCascade.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { taxonomyService } from "@/services/taxonomy.service";
|
||||||
|
import { lmsService } from "@/services/lms.service";
|
||||||
|
|
||||||
|
interface TaxonomyCascadeProps {
|
||||||
|
subjectId: string;
|
||||||
|
onSubjectChange: (id: string) => void;
|
||||||
|
domainId: string;
|
||||||
|
onDomainChange: (id: string) => void;
|
||||||
|
topicIds: number[];
|
||||||
|
onTopicIdsChange: (ids: number[]) => void;
|
||||||
|
objectiveIds: number[];
|
||||||
|
onObjectiveIdsChange: (ids: number[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaxonomyCascade({
|
||||||
|
subjectId, onSubjectChange,
|
||||||
|
domainId, onDomainChange,
|
||||||
|
topicIds, onTopicIdsChange,
|
||||||
|
objectiveIds, onObjectiveIdsChange,
|
||||||
|
}: TaxonomyCascadeProps) {
|
||||||
|
const { data: subjects } = useQuery({
|
||||||
|
queryKey: ["taxonomy", "subjects"],
|
||||||
|
queryFn: () => taxonomyService.listSubjects(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const numericSubjectId = subjectId !== "none" ? Number(subjectId) : undefined;
|
||||||
|
const numericDomainId = domainId !== "none" ? Number(domainId) : undefined;
|
||||||
|
|
||||||
|
const { data: domains } = useQuery({
|
||||||
|
queryKey: ["taxonomy", "domains", numericSubjectId],
|
||||||
|
queryFn: () => taxonomyService.listDomains({ subject_id: numericSubjectId }),
|
||||||
|
enabled: !!numericSubjectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: topics } = useQuery({
|
||||||
|
queryKey: ["taxonomy", "topics", numericDomainId],
|
||||||
|
queryFn: () => taxonomyService.listTopics({ domain_id: numericDomainId }),
|
||||||
|
enabled: !!numericDomainId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: objectivesData } = useQuery({
|
||||||
|
queryKey: ["learning-objectives", topicIds],
|
||||||
|
queryFn: () => lmsService.listLearningObjectives({ topic_ids: topicIds.join(",") }),
|
||||||
|
enabled: topicIds.length > 0,
|
||||||
|
});
|
||||||
|
const objectives = objectivesData?.items ?? [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (subjectId === "none") {
|
||||||
|
if (domainId !== "none") onDomainChange("none");
|
||||||
|
if (topicIds.length > 0) onTopicIdsChange([]);
|
||||||
|
if (objectiveIds.length > 0) onObjectiveIdsChange([]);
|
||||||
|
}
|
||||||
|
}, [subjectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (domainId === "none") {
|
||||||
|
if (topicIds.length > 0) onTopicIdsChange([]);
|
||||||
|
if (objectiveIds.length > 0) onObjectiveIdsChange([]);
|
||||||
|
}
|
||||||
|
}, [domainId]);
|
||||||
|
|
||||||
|
const toggleTopic = (id: number) => {
|
||||||
|
const next = topicIds.includes(id) ? topicIds.filter(t => t !== id) : [...topicIds, id];
|
||||||
|
onTopicIdsChange(next);
|
||||||
|
if (next.length === 0 && objectiveIds.length > 0) onObjectiveIdsChange([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleObjective = (id: number) => {
|
||||||
|
onObjectiveIdsChange(
|
||||||
|
objectiveIds.includes(id) ? objectiveIds.filter(o => o !== id) : [...objectiveIds, id],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Subject</Label>
|
||||||
|
<Select value={subjectId} onValueChange={(v) => { onSubjectChange(v); if (v === "none") { onDomainChange("none"); } }}>
|
||||||
|
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="Select subject" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
{(subjects ?? []).map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Domain</Label>
|
||||||
|
<Select value={domainId} onValueChange={onDomainChange} disabled={!numericSubjectId}>
|
||||||
|
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder={numericSubjectId ? "Select domain" : "Select subject first"} /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
{(domains ?? []).map(d => <SelectItem key={d.id} value={String(d.id)}>{d.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{numericDomainId && (topics ?? []).length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Topics</Label>
|
||||||
|
<div className="flex flex-wrap gap-1.5 p-2 rounded border bg-muted/30 max-h-[100px] overflow-y-auto">
|
||||||
|
{(topics ?? []).map(t => (
|
||||||
|
<Badge
|
||||||
|
key={t.id}
|
||||||
|
variant={topicIds.includes(t.id) ? "default" : "outline"}
|
||||||
|
className="cursor-pointer select-none text-xs"
|
||||||
|
onClick={() => toggleTopic(t.id)}
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{topicIds.length > 0 && objectives.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Learning Objectives</Label>
|
||||||
|
<div className="space-y-1 p-2 rounded border bg-muted/30 max-h-[100px] overflow-y-auto">
|
||||||
|
{objectives.map(o => (
|
||||||
|
<label key={o.id} className="flex items-center gap-2 text-xs cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={objectiveIds.includes(o.id)}
|
||||||
|
onCheckedChange={() => toggleObjective(o.id)}
|
||||||
|
/>
|
||||||
|
<span>{o.name}</span>
|
||||||
|
{o.bloom_level && <Badge variant="outline" className="text-[10px] h-4">{o.bloom_level}</Badge>}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import RoleLayout, { NavGroup } from "./RoleLayout";
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard, BookOpen, ClipboardList, FileText,
|
LayoutDashboard, BookOpen, ClipboardList, FileText,
|
||||||
CalendarCheck, Users, Calendar, User, MessageSquare,
|
CalendarCheck, Users, Calendar, User, MessageSquare,
|
||||||
Megaphone, Wand2,
|
Megaphone, Wand2, Library,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const navGroups: NavGroup[] = [
|
const navGroups: NavGroup[] = [
|
||||||
@@ -11,6 +11,7 @@ const navGroups: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ title: "Dashboard", url: "/teacher/dashboard", icon: LayoutDashboard },
|
{ title: "Dashboard", url: "/teacher/dashboard", icon: LayoutDashboard },
|
||||||
{ title: "Courses", url: "/teacher/courses", icon: BookOpen },
|
{ title: "Courses", url: "/teacher/courses", icon: BookOpen },
|
||||||
|
{ title: "Resource Library", url: "/teacher/library", icon: Library },
|
||||||
{ title: "Assignments", url: "/teacher/assignments", icon: ClipboardList },
|
{ title: "Assignments", url: "/teacher/assignments", icon: ClipboardList },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ export default function AiAlertBanner() {
|
|||||||
const [dismissedIds, setDismissedIds] = useState<Set<string>>(() => new Set());
|
const [dismissedIds, setDismissedIds] = useState<Set<string>>(() => new Set());
|
||||||
const [errorDismissed, setErrorDismissed] = useState(false);
|
const [errorDismissed, setErrorDismissed] = useState(false);
|
||||||
|
|
||||||
const { data: alerts, isLoading, isError, error } = useQuery({
|
const { data: resp, isLoading, isError, error } = useQuery({
|
||||||
queryKey: ["ai", "alerts"],
|
queryKey: ["ai", "alerts"],
|
||||||
queryFn: () => analyticsService.getAlerts(),
|
queryFn: () => analyticsService.getAlerts(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const visible = alerts?.filter((a) => !dismissedIds.has(a.id)) ?? [];
|
const alerts = resp?.alerts ?? [];
|
||||||
|
const visible = alerts.filter((a, i) => !dismissedIds.has(String(i)));
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -43,7 +44,7 @@ export default function AiAlertBanner() {
|
|||||||
|
|
||||||
if (isError && errorDismissed) return null;
|
if (isError && errorDismissed) return null;
|
||||||
|
|
||||||
if (!alerts?.length) {
|
if (!alerts.length) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-muted bg-muted/20 p-4 flex items-start gap-3">
|
<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" />
|
<Sparkles className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
|
||||||
@@ -56,8 +57,8 @@ export default function AiAlertBanner() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{visible.map((alert) => (
|
{visible.map((alert, idx) => (
|
||||||
<div key={alert.id} className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3">
|
<div key={idx} 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" />
|
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium flex items-center gap-1">
|
<p className="text-sm font-medium flex items-center gap-1">
|
||||||
@@ -69,7 +70,7 @@ export default function AiAlertBanner() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 shrink-0"
|
className="h-7 w-7 shrink-0"
|
||||||
onClick={() => setDismissedIds((prev) => new Set(prev).add(alert.id))}
|
onClick={() => setDismissedIds((prev) => new Set(prev).add(String(idx)))}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function AiAssistantDrawer() {
|
|||||||
mutationFn: (message: string) =>
|
mutationFn: (message: string) =>
|
||||||
coachingService.chat({ message, context: { page: location.pathname } }),
|
coachingService.chat({ message, context: { page: location.pathname } }),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setMessages((prev) => [...prev, { role: "ai", text: data.message }]);
|
setMessages((prev) => [...prev, { role: "ai", text: data.reply }]);
|
||||||
},
|
},
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type OptResult = Awaited<ReturnType<typeof analyticsService.getBatchOptimization>>;
|
||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
if (batchId == null) {
|
if (batchId == null) {
|
||||||
toast({
|
toast({
|
||||||
@@ -39,9 +41,23 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
|||||||
mutation.mutate(batchId);
|
mutation.mutate(batchId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyMutation = useMutation({
|
||||||
|
mutationFn: () => analyticsService.applyBatchOptimization(batchId!, mutation.data?.optimized ?? []),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
toast({ title: "Suggestion Applied", description: `${res.applied} optimization(s) saved successfully.` });
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Apply failed",
|
||||||
|
description: err.message || "Could not apply batch optimization.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
toast({ title: "Suggestion Applied", description: "Batch split recommendation has been saved successfully." });
|
applyMutation.mutate();
|
||||||
setOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onOpenChange = (next: boolean) => {
|
const onOpenChange = (next: boolean) => {
|
||||||
@@ -49,9 +65,10 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
|||||||
if (!next) mutation.reset();
|
if (!next) mutation.reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
const suggestions = mutation.data ?? [];
|
const optData = mutation.data as OptResult | undefined;
|
||||||
const showResults = !mutation.isPending && !mutation.isError && suggestions.length > 0;
|
const hasSuggestions = !!optData?.summary;
|
||||||
const showEmpty = !mutation.isPending && !mutation.isError && mutation.isSuccess && suggestions.length === 0;
|
const showResults = !mutation.isPending && !mutation.isError && hasSuggestions;
|
||||||
|
const showEmpty = !mutation.isPending && !mutation.isError && mutation.isSuccess && !hasSuggestions;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -71,20 +88,28 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
) : mutation.isError ? (
|
) : mutation.isError ? (
|
||||||
<p className="text-sm text-muted-foreground py-4 text-center">Something went wrong. Try again.</p>
|
<p className="text-sm text-muted-foreground py-4 text-center">Something went wrong. Try again.</p>
|
||||||
) : showResults ? (
|
) : showResults && optData ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-3 max-h-[50vh] overflow-y-auto">
|
<div className="rounded-lg bg-muted/30 p-4 border border-border/60">
|
||||||
{suggestions.map((s, i) => (
|
<p className="text-xs font-semibold text-primary uppercase tracking-wide mb-1">{optData.impact} impact</p>
|
||||||
<div key={i} className="rounded-lg bg-muted/30 p-4 border border-border/60">
|
<p className="text-sm font-medium">{optData.summary}</p>
|
||||||
<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>
|
||||||
|
{Array.isArray(optData.optimized) && optData.optimized.length > 0 && (
|
||||||
|
<div className="space-y-2 max-h-[40vh] overflow-y-auto">
|
||||||
|
{optData.optimized.map((item, i) => (
|
||||||
|
<div key={i} className="rounded-lg bg-muted/20 p-3 border text-sm">
|
||||||
|
{typeof item === "object" && item !== null ? JSON.stringify(item) : String(item)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button className="flex-1" onClick={handleApply}>
|
<Button className="flex-1" onClick={handleApply} disabled={applyMutation.isPending}>
|
||||||
Apply Suggestion
|
{applyMutation.isPending ? (
|
||||||
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Applying...</>
|
||||||
|
) : (
|
||||||
|
"Apply Suggestion"
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
Dismiss
|
Dismiss
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ export default function AiGeneratorModal() {
|
|||||||
difficulty,
|
difficulty,
|
||||||
count,
|
count,
|
||||||
}),
|
}),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res: Record<string, unknown>) => {
|
||||||
setLocalExercises(Array.isArray(res.exercises) ? res.exercises : []);
|
const items = Array.isArray(res.questions) ? res.questions : Array.isArray(res.exercises) ? res.exercises : [];
|
||||||
|
setLocalExercises(items);
|
||||||
},
|
},
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
toast({
|
toast({
|
||||||
@@ -57,6 +58,21 @@ export default function AiGeneratorModal() {
|
|||||||
generateMutation.mutate();
|
generateMutation.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: () => generationService.saveGenerated(moduleType, localExercises ?? []),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
toast({ title: "Saved", description: `${res.saved} assignments saved successfully.` });
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Save failed",
|
||||||
|
description: err.message || "Could not save generated assignments.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const generated = localExercises;
|
const generated = localExercises;
|
||||||
|
|
||||||
const handleRemove = (index: number) => {
|
const handleRemove = (index: number) => {
|
||||||
@@ -188,7 +204,17 @@ export default function AiGeneratorModal() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button className="flex-1">Save All</Button>
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
disabled={saveMutation.isPending || !generated?.length}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? (
|
||||||
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Saving...</>
|
||||||
|
) : (
|
||||||
|
"Save All"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export default function AiGradeExplainer({
|
|||||||
const explainMutation = useMutation({
|
const explainMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
coachingService.explain({
|
coachingService.explain({
|
||||||
context: `IELTS / course grades for student: ${studentName}. Summarize what the scores mean and what to focus on next.`,
|
score_data: scores ?? {},
|
||||||
scores,
|
student_context: `IELTS / course grades for student: ${studentName}. Summarize what the scores mean and what to focus on next.`,
|
||||||
}),
|
}),
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -26,9 +26,8 @@ export default function AiGradingAssistant({
|
|||||||
const gradeMutation = useMutation({
|
const gradeMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
analyticsService.getGradingSuggestion({
|
analyticsService.getGradingSuggestion({
|
||||||
submission_id: submissionId,
|
submission_text: submissionText,
|
||||||
text: submissionText,
|
skill: "writing",
|
||||||
...(rubricId !== undefined ? { rubric_id: rubricId } : {}),
|
|
||||||
}),
|
}),
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
toast({
|
toast({
|
||||||
@@ -45,7 +44,7 @@ export default function AiGradingAssistant({
|
|||||||
}, [submissionId, submissionText, rubricId]);
|
}, [submissionId, submissionText, rubricId]);
|
||||||
|
|
||||||
const data = gradeMutation.data;
|
const data = gradeMutation.data;
|
||||||
const marks = data ? Math.round(data.overall_score) : 0;
|
const marks = data ? Math.round(data.overall_band * 100 / 9) : 0;
|
||||||
const feedbackBlock = data
|
const feedbackBlock = data
|
||||||
? [
|
? [
|
||||||
data.feedback,
|
data.feedback,
|
||||||
|
|||||||
@@ -1,32 +1,31 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Sparkles, TrendingUp, AlertTriangle, Trophy, Loader2 } from "lucide-react";
|
import { Sparkles, TrendingUp, AlertTriangle, Info, Loader2 } from "lucide-react";
|
||||||
import { analyticsService } from "@/services/analytics.service";
|
import { analyticsService, type AiInsightItem } from "@/services/analytics.service";
|
||||||
import type { AiInsight } from "@/types";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
const EMPTY_PAYLOAD: Record<string, unknown> = {};
|
const EMPTY_PAYLOAD: Record<string, unknown> = {};
|
||||||
|
|
||||||
function insightIcon(type: AiInsight["type"]) {
|
function insightIcon(severity: AiInsightItem["severity"]) {
|
||||||
switch (type) {
|
switch (severity) {
|
||||||
case "positive":
|
case "critical":
|
||||||
return Trophy;
|
|
||||||
case "warning":
|
|
||||||
return AlertTriangle;
|
return AlertTriangle;
|
||||||
default:
|
case "warning":
|
||||||
return TrendingUp;
|
return TrendingUp;
|
||||||
|
default:
|
||||||
|
return Info;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function insightColor(type: AiInsight["type"]) {
|
function insightColor(severity: AiInsightItem["severity"]) {
|
||||||
switch (type) {
|
switch (severity) {
|
||||||
case "positive":
|
case "critical":
|
||||||
return "text-primary";
|
return "text-destructive";
|
||||||
case "warning":
|
case "warning":
|
||||||
return "text-warning";
|
return "text-warning";
|
||||||
default:
|
default:
|
||||||
return "text-success";
|
return "text-primary";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,10 +50,10 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mutation.mutate(data);
|
mutation.mutate(data);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- refetch when serialized payload changes
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [payloadKey]);
|
}, [payloadKey]);
|
||||||
|
|
||||||
const items = mutation.data ?? [];
|
const items = mutation.data?.insights ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
@@ -79,19 +78,19 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
|
|||||||
)}
|
)}
|
||||||
{!mutation.isPending && items.length > 0 && (
|
{!mutation.isPending && items.length > 0 && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{items.map((item) => {
|
{items.map((item, idx) => {
|
||||||
const Icon = insightIcon(item.type);
|
const Icon = insightIcon(item.severity);
|
||||||
const color = insightColor(item.type);
|
const color = insightColor(item.severity);
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="rounded-lg border bg-muted/30 p-4">
|
<div key={idx} className="rounded-lg border bg-muted/30 p-4">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Icon className={`h-4 w-4 ${color}`} />
|
<Icon className={`h-4 w-4 ${color}`} />
|
||||||
<span className="text-sm font-semibold">{item.title}</span>
|
<span className="text-sm font-semibold">{item.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{item.description}</p>
|
<p className="text-sm text-muted-foreground">{item.description}</p>
|
||||||
{item.metric != null && item.value != null && (
|
{item.recommendation && (
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<p className="text-xs text-muted-foreground mt-2 italic">
|
||||||
{item.metric}: {item.value}
|
{item.recommendation}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function AiSearchBar() {
|
|||||||
searchMutation.mutate(query.trim());
|
searchMutation.mutate(query.trim());
|
||||||
};
|
};
|
||||||
|
|
||||||
const results = searchMutation.data;
|
const result = searchMutation.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative max-w-md w-full">
|
<div className="relative max-w-md w-full">
|
||||||
@@ -57,35 +57,43 @@ export default function AiSearchBar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(searchMutation.isPending || results !== undefined) && (
|
{(searchMutation.isPending || result !== undefined) && (
|
||||||
<div className="absolute top-full mt-1 left-0 right-0 z-50 rounded-lg border bg-popover p-3 shadow-md">
|
<div className="absolute top-full mt-1 left-0 right-0 z-50 rounded-lg border bg-popover p-3 shadow-md">
|
||||||
{searchMutation.isPending ? (
|
{searchMutation.isPending ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||||
AI is searching...
|
AI is searching...
|
||||||
</div>
|
</div>
|
||||||
) : results && results.length > 0 ? (
|
) : result?.answer ? (
|
||||||
<div className="text-sm space-y-2 max-h-64 overflow-y-auto">
|
<div className="text-sm space-y-2 max-h-64 overflow-y-auto">
|
||||||
{results.map((r, i) => (
|
<div className="flex items-start gap-2 pb-2">
|
||||||
<div
|
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||||
key={`${r.title}-${i}`}
|
<p className="text-muted-foreground">{result.answer}</p>
|
||||||
className="flex items-start gap-2 border-b border-border/60 pb-2 last:border-0 last:pb-0"
|
</div>
|
||||||
>
|
{result.suggestions?.length > 0 && (
|
||||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
<div className="border-t pt-2 space-y-1">
|
||||||
<div className="min-w-0">
|
<p className="text-xs font-semibold text-primary">Related queries</p>
|
||||||
<p className="font-medium">{r.title}</p>
|
{result.suggestions.map((s, i) => (
|
||||||
<p className="text-muted-foreground text-xs mt-0.5">{r.description}</p>
|
<button
|
||||||
{r.url && (
|
key={i}
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className="block text-xs text-primary hover:underline"
|
||||||
className="text-xs text-primary mt-1 hover:underline"
|
onClick={() => { setQuery(s); searchMutation.mutate(s); }}
|
||||||
onClick={() => navigate(r.url!)}
|
>
|
||||||
>
|
{s}
|
||||||
Go to {r.url}
|
</button>
|
||||||
</button>
|
))}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{result.related_actions?.map((a, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-primary hover:underline"
|
||||||
|
onClick={() => navigate(a.action)}
|
||||||
|
>
|
||||||
|
{a.label}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -29,8 +29,11 @@ export default function AiStudyCoach() {
|
|||||||
suggestMutation.mutate();
|
suggestMutation.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
const suggestions = suggestMutation.data?.suggestions ?? [];
|
const d = suggestMutation.data;
|
||||||
const planTips = suggestMutation.data?.study_plan_tips ?? [];
|
const suggestions = d ? [d.suggestion, ...(d.focus_areas ?? []).map((a: string) => `Focus area: ${a}`)].filter(Boolean) : [];
|
||||||
|
const planTips = d?.daily_plan?.length
|
||||||
|
? d.daily_plan.map((p: { activity: string; duration_min: number; skill: string }) => `${p.activity} (${p.duration_min}min — ${p.skill})`)
|
||||||
|
: d?.motivation ? [d.motivation] : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-0 shadow-sm bg-primary/5">
|
<Card className="border-0 shadow-sm bg-primary/5">
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function AiTipBanner({ context = "dashboard", variant = "tip", di
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.content?.trim() && !data.title?.trim()) {
|
if (!data.tip?.trim()) {
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`}>
|
<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" />
|
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||||
@@ -62,14 +62,16 @@ export default function AiTipBanner({ context = "dashboard", variant = "tip", di
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const label = data.category && data.category !== "general"
|
||||||
|
? `AI ${data.category.charAt(0).toUpperCase() + data.category.slice(1)} Tip`
|
||||||
|
: `AI ${variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}`;
|
||||||
|
|
||||||
return (
|
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`}>
|
<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" />
|
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="text-xs font-semibold text-primary">
|
<span className="text-xs font-semibold text-primary">{label}</span>
|
||||||
{data.title?.trim() || `AI ${variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}`}
|
<p className="text-sm text-muted-foreground mt-0.5">{data.tip}</p>
|
||||||
</span>
|
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">{data.content}</p>
|
|
||||||
</div>
|
</div>
|
||||||
{dismissible && (
|
{dismissible && (
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => setDismissed(true)}>
|
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => setDismissed(true)}>
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (mode: NonNullable<Mode>) =>
|
mutationFn: (mode: NonNullable<Mode>) =>
|
||||||
coachingService.writingHelp({
|
coachingService.writingHelp({
|
||||||
text: text.trim(),
|
task: task_type,
|
||||||
task_type: `${task_type}:${mode}`,
|
draft: text.trim(),
|
||||||
|
help_type: mode,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => setShowResult(true),
|
onSuccess: () => setShowResult(true),
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
@@ -84,20 +85,20 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
|
|||||||
|
|
||||||
{showResult && !loading && mutation.data && activeMode === "improve" && (
|
{showResult && !loading && mutation.data && activeMode === "improve" && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{mutation.data.feedback && (
|
{mutation.data.tips?.length > 0 && (
|
||||||
<div className="rounded-lg border bg-muted/30 p-3">
|
<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">
|
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||||
<Sparkles className="h-3 w-3" /> Feedback
|
<Sparkles className="h-3 w-3" /> Feedback
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">{mutation.data.feedback}</p>
|
<p className="text-sm text-muted-foreground">{mutation.data.tips.join(" ")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{mutation.data.improved && (
|
{mutation.data.improved_text && (
|
||||||
<div className="rounded-lg border bg-muted/30 p-3">
|
<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">
|
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||||
<Sparkles className="h-3 w-3" /> Improved Version
|
<Sparkles className="h-3 w-3" /> Improved Version
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm">{mutation.data.improved}</p>
|
<p className="text-sm">{mutation.data.improved_text}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -108,17 +109,17 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
|
|||||||
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||||
<Sparkles className="h-3 w-3" /> Grammar notes
|
<Sparkles className="h-3 w-3" /> Grammar notes
|
||||||
</p>
|
</p>
|
||||||
{(mutation.data.grammar_notes?.length ?? 0) > 0 ? (
|
{(mutation.data.changes?.length ?? 0) > 0 ? (
|
||||||
mutation.data.grammar_notes!.map((note, i) => (
|
mutation.data.changes.map((c, i) => (
|
||||||
<div key={i} className="text-sm border-l-2 border-warning pl-2">
|
<div key={i} className="text-sm border-l-2 border-warning pl-2">
|
||||||
<p className="text-muted-foreground">{note}</p>
|
<p className="text-muted-foreground"><strong>{c.original}</strong> → {c.revised} — {c.reason}</p>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">No grammar issues flagged.</p>
|
<p className="text-sm text-muted-foreground">No grammar issues flagged.</p>
|
||||||
)}
|
)}
|
||||||
{mutation.data.feedback ? (
|
{mutation.data.tips?.length > 0 ? (
|
||||||
<p className="text-xs text-muted-foreground pt-2 border-t">{mutation.data.feedback}</p>
|
<p className="text-xs text-muted-foreground pt-2 border-t">{mutation.data.tips.join("; ")}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -128,9 +129,9 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
|
|||||||
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||||
<Sparkles className="h-3 w-3" /> Estimated band / assessment
|
<Sparkles className="h-3 w-3" /> Estimated band / assessment
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">{mutation.data.feedback}</p>
|
<p className="text-sm text-muted-foreground">{mutation.data.tips?.join(" ") ?? ""}</p>
|
||||||
{mutation.data.improved ? (
|
{mutation.data.improved_text ? (
|
||||||
<p className="text-sm mt-2 pt-2 border-t">{mutation.data.improved}</p>
|
<p className="text-sm mt-2 pt-2 border-t">{mutation.data.improved_text}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
119
src/components/student/ExamPopup.tsx
Normal file
119
src/components/student/ExamPopup.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Clock, Calendar, PlayCircle, AlertCircle } from "lucide-react";
|
||||||
|
import { assignmentsService } from "@/services/assignments.service";
|
||||||
|
import type { StudentExamAssignment } from "@/types";
|
||||||
|
|
||||||
|
export default function ExamPopup() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [dismissed, setDismissed] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["student-my-exams"],
|
||||||
|
queryFn: () => assignmentsService.getStudentExams(),
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const exams = (data?.items ?? []) as StudentExamAssignment[];
|
||||||
|
const pendingExams = exams.filter((e) => !dismissed.has(e.id));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingExams.length > 0 && !open) {
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
}, [pendingExams.length]);
|
||||||
|
|
||||||
|
if (pendingExams.length === 0) return null;
|
||||||
|
|
||||||
|
const formatDate = (iso: string | null) => {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) +
|
||||||
|
" " + d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStart = (exam: StudentExamAssignment) => {
|
||||||
|
setOpen(false);
|
||||||
|
navigate(`/student/exam/${exam.exam_id}/session`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismiss = (id: number) => {
|
||||||
|
setDismissed((prev) => new Set([...prev, id]));
|
||||||
|
const remaining = pendingExams.filter((e) => e.id !== id);
|
||||||
|
if (remaining.length === 0) setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-5 w-5 text-primary" />
|
||||||
|
Upcoming Exams ({pendingExams.length})
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
|
||||||
|
{pendingExams.map((exam) => (
|
||||||
|
<div key={exam.id} className="border rounded-lg p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-sm">{exam.exam_title || exam.schedule_name}</h3>
|
||||||
|
<Badge
|
||||||
|
variant={exam.schedule_state === "active" ? "default" : "secondary"}
|
||||||
|
className="capitalize text-xs"
|
||||||
|
>
|
||||||
|
{exam.schedule_state === "active" ? "Active Now" : exam.schedule_state}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{exam.schedule_name && exam.exam_title && (
|
||||||
|
<p className="text-xs text-muted-foreground">{exam.schedule_name}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
From: {formatDate(exam.start_date)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
To: {formatDate(exam.end_date)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!exam.can_start}
|
||||||
|
onClick={() => handleStart(exam)}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<PlayCircle className="h-3.5 w-3.5" />
|
||||||
|
{exam.can_start ? "Start Exam" : "Not Available Yet"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleDismiss(exam.id)}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
{!exam.can_start && (
|
||||||
|
<span className="text-[11px] text-muted-foreground italic ml-auto">
|
||||||
|
{exam.schedule_state === "planned"
|
||||||
|
? "Exam will be available once it becomes active"
|
||||||
|
: "Exam is not currently active"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ const DialogContent = React.forwardRef<
|
|||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
aria-describedby={props["aria-describedby"] ?? undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export const queryKeys = {
|
|||||||
lms: {
|
lms: {
|
||||||
courses: (params?: Record<string, unknown>) => ["lms", "courses", params] as const,
|
courses: (params?: Record<string, unknown>) => ["lms", "courses", params] as const,
|
||||||
course: (id: number) => ["lms", "courses", id] as const,
|
course: (id: number) => ["lms", "courses", id] as const,
|
||||||
|
myCourses: ["lms", "my-courses"] as const,
|
||||||
students: (params?: Record<string, unknown>) => ["lms", "students", params] as const,
|
students: (params?: Record<string, unknown>) => ["lms", "students", params] as const,
|
||||||
teachers: (params?: Record<string, unknown>) => ["lms", "teachers", params] as const,
|
teachers: (params?: Record<string, unknown>) => ["lms", "teachers", params] as const,
|
||||||
batches: (params?: Record<string, unknown>) => ["lms", "batches", params] as const,
|
batches: (params?: Record<string, unknown>) => ["lms", "batches", params] as const,
|
||||||
@@ -155,6 +156,7 @@ export const queryKeys = {
|
|||||||
list: (params?: Record<string, unknown>) => ["subject-registrations", "list", params] as const,
|
list: (params?: Record<string, unknown>) => ["subject-registrations", "list", params] as const,
|
||||||
available: (params?: Record<string, unknown>) => ["subject-registrations", "available", params] as const,
|
available: (params?: Record<string, unknown>) => ["subject-registrations", "available", params] as const,
|
||||||
},
|
},
|
||||||
|
courseCompletion: (courseId: number) => ["course-completion", courseId] as const,
|
||||||
chapters: (courseId: number) => ["chapters", "list", courseId] as const,
|
chapters: (courseId: number) => ["chapters", "list", courseId] as const,
|
||||||
chapter: (id: number) => ["chapters", "detail", id] as const,
|
chapter: (id: number) => ["chapters", "detail", id] as const,
|
||||||
chapterMaterials: (chapterId: number) => ["chapter-materials", chapterId] as const,
|
chapterMaterials: (chapterId: number) => ["chapter-materials", chapterId] as const,
|
||||||
@@ -211,6 +213,7 @@ export const queryKeys = {
|
|||||||
studentProgress: {
|
studentProgress: {
|
||||||
all: ["student-progress"] as const,
|
all: ["student-progress"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["student-progress", "list", params] as const,
|
list: (params?: Record<string, unknown>) => ["student-progress", "list", params] as const,
|
||||||
|
detail: (id: number) => ["student-progress", "detail", id] as const,
|
||||||
},
|
},
|
||||||
libraryMedia: {
|
libraryMedia: {
|
||||||
all: ["library-media"] as const,
|
all: ["library-media"] as const,
|
||||||
|
|||||||
@@ -66,7 +66,10 @@ export function useGenerateTerms() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (yearId: number) => academicService.generateTerms(yearId),
|
mutationFn: (yearId: number) => academicService.generateTerms(yearId),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.academicTerms.all }),
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.academicTerms.all });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.academicYears.all });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { queryKeys } from "./keys";
|
import { queryKeys } from "./keys";
|
||||||
import { aiCourseService } from "@/services/ai-course.service";
|
import {
|
||||||
import type { ExaminerReview } from "@/types";
|
aiCourseService,
|
||||||
|
type AiCourseCreateEnglishRequest,
|
||||||
|
type AiCourseCreateIeltsRequest,
|
||||||
|
} from "@/services/ai-course.service";
|
||||||
|
|
||||||
export function useAiCourse(courseId: number | undefined) {
|
export function useAiCourse(courseId: number | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -22,7 +25,7 @@ export function useAiCourseTracks(courseId: number | undefined) {
|
|||||||
export function useCreateEnglishCourse() {
|
export function useCreateEnglishCourse() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: { current_level: string; target_level: string; learning_style: string[] }) =>
|
mutationFn: (data: AiCourseCreateEnglishRequest) =>
|
||||||
aiCourseService.createEnglish(data),
|
aiCourseService.createEnglish(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
||||||
@@ -33,7 +36,7 @@ export function useCreateEnglishCourse() {
|
|||||||
export function useCreateIeltsCourse() {
|
export function useCreateIeltsCourse() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: { exam_type: string; target_band: number; skills: string[] }) =>
|
mutationFn: (data: AiCourseCreateIeltsRequest) =>
|
||||||
aiCourseService.createIelts(data),
|
aiCourseService.createIelts(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
||||||
@@ -63,8 +66,8 @@ export function useApproveQuality() {
|
|||||||
export function useRejectQuality() {
|
export function useRejectQuality() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ courseId, notes }: { courseId: number; notes: string }) =>
|
mutationFn: ({ courseId, reason }: { courseId: number; reason: string }) =>
|
||||||
aiCourseService.rejectQuality(courseId, notes),
|
aiCourseService.rejectQuality(courseId, reason),
|
||||||
onSuccess: (_d, { courseId }) => {
|
onSuccess: (_d, { courseId }) => {
|
||||||
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.quality(courseId) });
|
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.quality(courseId) });
|
||||||
},
|
},
|
||||||
@@ -89,7 +92,8 @@ export function useIeltsValidation(courseId: number | undefined) {
|
|||||||
export function useSubmitExaminerReview() {
|
export function useSubmitExaminerReview() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: ExaminerReview) => aiCourseService.submitExaminerReview(data),
|
mutationFn: (data: { logId: number; action: string; examiner_notes?: string }) =>
|
||||||
|
aiCourseService.submitExaminerReview(data.logId, { action: data.action, examiner_notes: data.examiner_notes }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -120,6 +120,19 @@ export function useUploadMaterial() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCreateMaterialFromResource() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ chapterId, resourceId, name }: { chapterId: number; resourceId: number; name?: string }) =>
|
||||||
|
coursewareService.createMaterialFromResource(chapterId, resourceId, name),
|
||||||
|
onSuccess: (_, { chapterId }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.chapterMaterials(chapterId) });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.chapterProgress(chapterId) });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.chapter(chapterId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useDeleteMaterial() {
|
export function useDeleteMaterial() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -145,13 +158,24 @@ export function useMarkMaterialViewed() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCourseCompletion(courseId: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.courseCompletion(courseId),
|
||||||
|
queryFn: () => coursewareService.getCourseCompletion(courseId),
|
||||||
|
enabled: !!courseId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useCompleteChapter() {
|
export function useCompleteChapter() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (chapterId: number) => coursewareService.markChapterComplete(chapterId),
|
mutationFn: ({ chapterId, courseId }: { chapterId: number; courseId: number }) =>
|
||||||
onSuccess: (_, chapterId) => {
|
coursewareService.markChapterComplete(chapterId),
|
||||||
|
onSuccess: (_, { chapterId, courseId }) => {
|
||||||
qc.invalidateQueries({ queryKey: queryKeys.chapterProgress(chapterId) });
|
qc.invalidateQueries({ queryKey: queryKeys.chapterProgress(chapterId) });
|
||||||
qc.invalidateQueries({ queryKey: queryKeys.chapter(chapterId) });
|
qc.invalidateQueries({ queryKey: queryKeys.chapter(chapterId) });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.courseCompletion(courseId) });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.lms.myCourses });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function useExamAutoSave() {
|
|||||||
|
|
||||||
export function useExamSubmit() {
|
export function useExamSubmit() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (examId: number) => examSessionService.submit(examId),
|
mutationFn: (data: { examId: number; attempt_id: number; answers: { question_id: number; answer: unknown }[] }) =>
|
||||||
|
examSessionService.submit(data.examId, { attempt_id: data.attempt_id, answers: data.answers }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { queryKeys } from "./keys";
|
import { queryKeys } from "./keys";
|
||||||
import { feesService } from "@/services/fees.service";
|
import { feesService, type FeesPlanFilters } from "@/services/fees.service";
|
||||||
import type { PaginationParams } from "@/types";
|
import type { PaginationParams } from "@/types";
|
||||||
|
|
||||||
export function useFeesPlans(params?: PaginationParams) {
|
export function useFeesPlans(params?: FeesPlanFilters) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.feesPlans.list(params),
|
queryKey: queryKeys.feesPlans.list(params),
|
||||||
queryFn: () => feesService.listFeesPlans(params),
|
queryFn: () => feesService.listFeesPlans(params),
|
||||||
@@ -18,7 +18,7 @@ export function useFeesPlan(id: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStudentFees(params?: PaginationParams) {
|
export function useStudentFees(params?: FeesPlanFilters) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.studentFees.list(params),
|
queryKey: queryKeys.studentFees.list(params),
|
||||||
queryFn: () => feesService.listStudentFees(params),
|
queryFn: () => feesService.listStudentFees(params),
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ export function useGradebooks(params?: PaginationParams) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGradebookLines(params?: PaginationParams) {
|
export function useGradebookLines(params?: PaginationParams & { gradebook_id?: number }) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.gradebookLines.list(params),
|
queryKey: queryKeys.gradebookLines.list(params),
|
||||||
queryFn: () => gradebookService.listGradebookLines(params),
|
queryFn: () => gradebookService.listGradebookLines(params),
|
||||||
|
enabled: params?.gradebook_id ? !!params.gradebook_id : true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { queryKeys } from "./keys";
|
import { queryKeys } from "./keys";
|
||||||
import { lmsService } from "@/services";
|
import { lmsService } from "@/services";
|
||||||
import type { PaginationParams, CourseCreateRequest, LmsStudentCreateRequest, LmsTeacherCreateRequest } from "@/types";
|
import type { PaginationParams, CourseCreateRequest, LmsStudentCreateRequest, LmsTeacherCreateRequest, EnrollStudentRequest, BulkEnrollRequest } from "@/types";
|
||||||
|
|
||||||
export function useCourses(params?: PaginationParams & { status?: string }) {
|
export function useCourses(params?: PaginationParams & { status?: string }) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -26,6 +26,38 @@ export function useCreateCourse() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useMyEnrolledCourses() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.lms.myCourses,
|
||||||
|
queryFn: () => lmsService.getMyEnrolledCourses(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEnrollStudent() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ studentId, data }: { studentId: number; data: EnrollStudentRequest }) =>
|
||||||
|
lmsService.enrollStudent(studentId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms", "students"] });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.lms.myCourses });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBulkEnroll() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ courseId, data }: { courseId: number; data: BulkEnrollRequest }) =>
|
||||||
|
lmsService.bulkEnrollInCourse(courseId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms", "students"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.lms.myCourses });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useStudents(params?: PaginationParams & { search?: string; batch_id?: number }) {
|
export function useStudents(params?: PaginationParams & { search?: string; batch_id?: number }) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.lms.students(params),
|
queryKey: queryKeys.lms.students(params),
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { queryKeys } from "./keys";
|
import { queryKeys } from "./keys";
|
||||||
import { studentProgressService } from "@/services/student-progress.service";
|
import { studentProgressService, type StudentProgressFilters } from "@/services/student-progress.service";
|
||||||
import type { PaginationParams } from "@/types";
|
|
||||||
|
|
||||||
export function useStudentProgressList(params?: PaginationParams) {
|
export function useStudentProgressList(params?: StudentProgressFilters) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.studentProgress.list(params),
|
queryKey: queryKeys.studentProgress.list(params),
|
||||||
queryFn: () => studentProgressService.listProgress(params),
|
queryFn: () => studentProgressService.listProgress(params),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useStudentProgressDetail(id: number | null | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.studentProgress.detail(id ?? 0),
|
||||||
|
queryFn: () => studentProgressService.getProgress(id as number),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,119 +1,556 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Plus, CheckCircle, XCircle, Clock } from "lucide-react";
|
import {
|
||||||
import AiGradingAssistant from "@/components/ai/AiGradingAssistant";
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
Loader2,
|
||||||
|
Trash2,
|
||||||
|
ChevronRight,
|
||||||
|
ShieldCheck,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
|
||||||
const workflows = [
|
interface WorkflowStep {
|
||||||
{
|
id: number;
|
||||||
id: 1, name: "Exam Content Review", status: "In Progress",
|
sequence: number;
|
||||||
steps: [
|
approver_id: number | null;
|
||||||
{ name: "Initial Review", assignee: "Dr. Smith", status: "Approved" },
|
approver_name: string;
|
||||||
{ name: "Quality Check", assignee: "Prof. Lee", status: "Pending" },
|
status: string;
|
||||||
{ name: "Final Approval", assignee: "Admin", status: "Waiting" },
|
comment: string;
|
||||||
],
|
auto_escalate: boolean;
|
||||||
},
|
max_days: number;
|
||||||
{
|
acted_at: string | null;
|
||||||
id: 2, name: "Rubric Approval", status: "Completed",
|
}
|
||||||
steps: [
|
|
||||||
{ name: "Draft Review", assignee: "Mr. Kim", status: "Approved" },
|
interface Workflow {
|
||||||
{ name: "Academic Board", assignee: "Dr. Smith", status: "Approved" },
|
id: number;
|
||||||
],
|
name: string;
|
||||||
},
|
type: string;
|
||||||
{
|
entity_id: number | null;
|
||||||
id: 3, name: "New Exam Structure", status: "Rejected",
|
entity_name: string | null;
|
||||||
steps: [
|
allow_bypass: boolean;
|
||||||
{ name: "Content Review", assignee: "Prof. Lee", status: "Approved" },
|
active: boolean;
|
||||||
{ name: "Standards Check", assignee: "Dr. Smith", status: "Rejected" },
|
steps: WorkflowStep[];
|
||||||
],
|
created: string;
|
||||||
},
|
}
|
||||||
];
|
|
||||||
|
interface ApprovalRequest {
|
||||||
|
id: number;
|
||||||
|
workflow_id: number | null;
|
||||||
|
workflow_name: string;
|
||||||
|
res_model: string;
|
||||||
|
res_id: number;
|
||||||
|
state: string;
|
||||||
|
requester_id: number | null;
|
||||||
|
requester_name: string;
|
||||||
|
current_stage_id: number | null;
|
||||||
|
current_stage_sequence: number | null;
|
||||||
|
bypass_reason: string;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
login: string;
|
||||||
|
}
|
||||||
|
|
||||||
const statusIcon = (s: string) => {
|
const statusIcon = (s: string) => {
|
||||||
if (s === "Approved") return <CheckCircle className="h-4 w-4 text-success" />;
|
if (s === "approved") return <CheckCircle className="h-4 w-4 text-green-600" />;
|
||||||
if (s === "Rejected") return <XCircle className="h-4 w-4 text-destructive" />;
|
if (s === "rejected") return <XCircle className="h-4 w-4 text-destructive" />;
|
||||||
return <Clock className="h-4 w-4 text-warning" />;
|
return <Clock className="h-4 w-4 text-amber-500" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateBadge = (state: string) => {
|
||||||
|
const map: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||||
|
approved: "default",
|
||||||
|
in_progress: "secondary",
|
||||||
|
rejected: "destructive",
|
||||||
|
draft: "outline",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Badge variant={map[state] || "outline"} className="capitalize">
|
||||||
|
{state.replace(/_/g, " ")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ApprovalWorkflowsPage() {
|
export default function ApprovalWorkflowsPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [formName, setFormName] = useState("");
|
||||||
|
const [formType, setFormType] = useState("custom");
|
||||||
|
const [formSteps, setFormSteps] = useState<{ approver_id: string }[]>([
|
||||||
|
{ approver_id: "" },
|
||||||
|
{ approver_id: "" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [rejectDialog, setRejectDialog] = useState<number | null>(null);
|
||||||
|
const [rejectComment, setRejectComment] = useState("");
|
||||||
|
|
||||||
|
const workflowsQ = useQuery({
|
||||||
|
queryKey: ["approval-workflows"],
|
||||||
|
queryFn: () => api.get<{ items: Workflow[]; total: number }>("/approval-workflows"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestsQ = useQuery({
|
||||||
|
queryKey: ["approval-requests"],
|
||||||
|
queryFn: () => api.get<{ items: ApprovalRequest[]; total: number }>("/approval-requests"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const usersQ = useQuery({
|
||||||
|
queryKey: ["approval-users"],
|
||||||
|
queryFn: () => api.get<{ items: UserItem[] }>("/approval-users"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflows = workflowsQ.data?.items ?? [];
|
||||||
|
const requests = requestsQ.data?.items ?? [];
|
||||||
|
const users = usersQ.data?.items ?? [];
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => api.post("/approval-workflows", data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
resetForm();
|
||||||
|
toast({ title: "Workflow created" });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast({ variant: "destructive", title: "Create failed", description: e.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMut = useMutation({
|
||||||
|
mutationFn: (id: number) => api.delete(`/approval-workflows/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
|
||||||
|
toast({ title: "Workflow deleted" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const approveMut = useMutation({
|
||||||
|
mutationFn: (id: number) => api.post(`/approval-requests/${id}/approve`, {}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["approval-requests"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
|
||||||
|
toast({ title: "Request approved" });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast({ variant: "destructive", title: "Approve failed", description: e.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rejectMut = useMutation({
|
||||||
|
mutationFn: ({ id, comment }: { id: number; comment: string }) =>
|
||||||
|
api.post(`/approval-requests/${id}/reject`, { comment }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["approval-requests"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
|
||||||
|
setRejectDialog(null);
|
||||||
|
setRejectComment("");
|
||||||
|
toast({ title: "Request rejected" });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast({ variant: "destructive", title: "Reject failed", description: e.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormName("");
|
||||||
|
setFormType("custom");
|
||||||
|
setFormSteps([{ approver_id: "" }, { approver_id: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
createMut.mutate({
|
||||||
|
name: formName.trim(),
|
||||||
|
type: formType,
|
||||||
|
steps: formSteps
|
||||||
|
.filter((s) => s.approver_id)
|
||||||
|
.map((s) => ({ approver_id: Number(s.approver_id) })),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingRequests = requests.filter((r) => r.state === "in_progress");
|
||||||
|
const completedRequests = requests.filter((r) => r.state !== "in_progress");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Approval Workflows</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Approval Workflows</h1>
|
||||||
<p className="text-muted-foreground">Manage multi-step approval processes for exam content.</p>
|
<p className="text-muted-foreground">
|
||||||
|
Manage multi-step approval processes for exam content.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog>
|
<Dialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setCreateOpen(open);
|
||||||
|
if (!open) resetForm();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Workflow</Button>
|
<Button size="sm">
|
||||||
|
<Plus className="h-4 w-4 mr-1" /> Create Workflow
|
||||||
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader><DialogTitle>Create Workflow</DialogTitle></DialogHeader>
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Workflow</DialogTitle>
|
||||||
|
<DialogDescription>Define a multi-step approval process.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2"><Label>Workflow Name</Label><Input placeholder="e.g. Exam Content Review" /></div>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Step 1 — Assignee</Label>
|
<Label>
|
||||||
<Select><SelectTrigger><SelectValue placeholder="Select assignee" /></SelectTrigger>
|
Workflow Name <span className="text-destructive">*</span>
|
||||||
<SelectContent><SelectItem value="smith">Dr. Smith</SelectItem><SelectItem value="lee">Prof. Lee</SelectItem><SelectItem value="kim">Mr. Kim</SelectItem></SelectContent>
|
</Label>
|
||||||
</Select>
|
<Input
|
||||||
|
value={formName}
|
||||||
|
onChange={(e) => setFormName(e.target.value)}
|
||||||
|
placeholder="e.g. Exam Content Review"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Step 2 — Assignee</Label>
|
<Label>Type</Label>
|
||||||
<Select><SelectTrigger><SelectValue placeholder="Select assignee" /></SelectTrigger>
|
<Select value={formType} onValueChange={setFormType}>
|
||||||
<SelectContent><SelectItem value="smith">Dr. Smith</SelectItem><SelectItem value="lee">Prof. Lee</SelectItem><SelectItem value="admin">Admin</SelectItem></SelectContent>
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="custom">Custom</SelectItem>
|
||||||
|
<SelectItem value="exam_publish">Exam Publication</SelectItem>
|
||||||
|
<SelectItem value="assignment_publish">Assignment Publication</SelectItem>
|
||||||
|
<SelectItem value="content_publish">Content Publication</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Button className="w-full">Create</Button>
|
|
||||||
|
{formSteps.map((step, i) => (
|
||||||
|
<div key={i} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Step {i + 1} — Approver</Label>
|
||||||
|
{formSteps.length > 1 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() =>
|
||||||
|
setFormSteps((s) => s.filter((_, idx) => idx !== i))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={step.approver_id}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setFormSteps((s) =>
|
||||||
|
s.map((st, idx) => (idx === i ? { ...st, approver_id: v } : st))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select approver" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{users.map((u) => (
|
||||||
|
<SelectItem key={u.id} value={String(u.id)}>
|
||||||
|
{u.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setFormSteps((s) => [...s, { approver_id: "" }])}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" /> Add Step
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!formName.trim() || createMut.isPending}
|
||||||
|
onClick={handleCreate}
|
||||||
|
>
|
||||||
|
{createMut.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Create"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* Loading */}
|
||||||
|
{(workflowsQ.isLoading || requestsQ.isLoading) && (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pending Requests */}
|
||||||
|
{pendingRequests.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Clock className="h-5 w-5 text-amber-500" />
|
||||||
|
Pending Approval ({pendingRequests.length})
|
||||||
|
</h2>
|
||||||
|
{pendingRequests.map((req) => {
|
||||||
|
const wf = workflows.find((w) => w.id === req.workflow_id);
|
||||||
|
return (
|
||||||
|
<Card key={req.id} className="border-amber-200 bg-amber-50/30">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{req.workflow_name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Requested by {req.requester_name} ·{" "}
|
||||||
|
{req.res_model.replace("encoach.", "")} #{req.res_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{stateBadge(req.state)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{wf && (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mb-3">
|
||||||
|
{wf.steps.map((step, i) => {
|
||||||
|
const isActive = step.id === req.current_stage_id;
|
||||||
|
return (
|
||||||
|
<div key={step.id} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 rounded-lg border p-3 min-w-[160px] transition-all ${
|
||||||
|
isActive
|
||||||
|
? "border-amber-400 bg-amber-50 ring-2 ring-amber-200"
|
||||||
|
: step.status === "approved"
|
||||||
|
? "border-green-200 bg-green-50/50"
|
||||||
|
: step.status === "rejected"
|
||||||
|
? "border-red-200 bg-red-50/50"
|
||||||
|
: "bg-muted/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{statusIcon(step.status)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Step {i + 1}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{step.approver_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{i < wf.steps.length - 1 && (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={approveMut.isPending}
|
||||||
|
onClick={() => approveMut.mutate(req.id)}
|
||||||
|
>
|
||||||
|
{approveMut.isPending ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="h-3.5 w-3.5 mr-1" />
|
||||||
|
)}
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setRejectDialog(req.id)}
|
||||||
|
>
|
||||||
|
<XCircle className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Workflow Templates */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-5 w-5" />
|
||||||
|
Workflow Templates ({workflows.length})
|
||||||
|
</h2>
|
||||||
|
{workflows.length === 0 && !workflowsQ.isLoading && (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="p-8 text-center text-muted-foreground">
|
||||||
|
No workflows defined yet. Create one to get started.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
{workflows.map((wf) => (
|
{workflows.map((wf) => (
|
||||||
<Card key={wf.id} className="border-0 shadow-sm">
|
<Card key={wf.id} className="border-0 shadow-sm">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-base font-semibold">{wf.name}</CardTitle>
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant={wf.status === "Completed" ? "default" : wf.status === "Rejected" ? "destructive" : "secondary"}>{wf.status}</Badge>
|
<CardTitle className="text-base font-semibold">{wf.name}</CardTitle>
|
||||||
|
<Badge variant="outline" className="text-xs capitalize">
|
||||||
|
{wf.type.replace(/_/g, " ")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete workflow "${wf.name}"?`)) deleteMut.mutate(wf.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{wf.steps.map((step, i) => (
|
{wf.steps.map((step, i) => (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={step.id} className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2 rounded-lg border p-3 bg-muted/30 min-w-[160px]">
|
<div className="flex items-center gap-2 rounded-lg border p-3 bg-muted/30 min-w-[160px]">
|
||||||
{statusIcon(step.status)}
|
<div className="h-6 w-6 rounded-full bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary">
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">{step.name}</p>
|
<p className="text-sm font-medium">{step.approver_name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{step.assignee}</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{step.max_days}d limit
|
||||||
|
{step.auto_escalate ? " · auto-escalate" : ""}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{i < wf.steps.length - 1 && <div className="w-8 h-0.5 bg-border" />}
|
{i < wf.steps.length - 1 && (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{wf.status === "In Progress" && (
|
|
||||||
<div className="space-y-4 mt-4">
|
|
||||||
<AiGradingAssistant onAccept={(marks, feedback) => {
|
|
||||||
toast({ title: "AI Grade Accepted", description: `Marks: ${marks}/100 applied with AI feedback.` });
|
|
||||||
}} />
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button size="sm" variant="default">Approve</Button>
|
|
||||||
<Button size="sm" variant="outline">Reject</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Completed Requests */}
|
||||||
|
{completedRequests.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5" />
|
||||||
|
Recent Decisions ({completedRequests.length})
|
||||||
|
</h2>
|
||||||
|
{completedRequests.map((req) => (
|
||||||
|
<Card key={req.id} className="border-0 shadow-sm">
|
||||||
|
<CardContent className="p-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{req.workflow_name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{req.requester_name} · {req.res_model.replace("encoach.", "")} #
|
||||||
|
{req.res_id}
|
||||||
|
{req.created_at && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
· {new Date(req.created_at).toLocaleDateString()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{stateBadge(req.state)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reject Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={rejectDialog !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setRejectDialog(null);
|
||||||
|
setRejectComment("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Reject Request</DialogTitle>
|
||||||
|
<DialogDescription>Provide a reason for rejection.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Comment</Label>
|
||||||
|
<Textarea
|
||||||
|
value={rejectComment}
|
||||||
|
onChange={(e) => setRejectComment(e.target.value)}
|
||||||
|
placeholder="Reason for rejection..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setRejectDialog(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={rejectMut.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (rejectDialog !== null) {
|
||||||
|
rejectMut.mutate({ id: rejectDialog, comment: rejectComment });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rejectMut.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
) : null}
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +1,332 @@
|
|||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Search, Plus, Trash2 } from "lucide-react";
|
import {
|
||||||
import { useAssignments, useCreateAssignment } from "@/hooks/queries";
|
Search, Plus, Trash2, Loader2, Calendar, Users, Clock,
|
||||||
|
CheckCircle2, Archive, AlertTriangle, PlayCircle, Eye
|
||||||
|
} from "lucide-react";
|
||||||
|
import { assignmentsService } from "@/services/assignments.service";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import { useBatches, useStudents } from "@/hooks/queries";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import type { ExamSchedule, ScheduleState, ExamScheduleCreateRequest } from "@/types";
|
||||||
|
|
||||||
|
interface CustomExam {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Entity {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE_TABS: { key: ScheduleState | "all"; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ key: "all", label: "All", icon: <Eye className="h-3.5 w-3.5" /> },
|
||||||
|
{ key: "active", label: "Active", icon: <PlayCircle className="h-3.5 w-3.5" /> },
|
||||||
|
{ key: "planned", label: "Planned", icon: <Clock className="h-3.5 w-3.5" /> },
|
||||||
|
{ key: "past", label: "Past", icon: <CheckCircle2 className="h-3.5 w-3.5" /> },
|
||||||
|
{ key: "start_expired", label: "Start Expired", icon: <AlertTriangle className="h-3.5 w-3.5" /> },
|
||||||
|
{ key: "archived", label: "Archived", icon: <Archive className="h-3.5 w-3.5" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const OPTION_FIELDS: { key: keyof ExamScheduleCreateRequest; label: string }[] = [
|
||||||
|
{ key: "full_length", label: "Full length exams" },
|
||||||
|
{ key: "generate_different", label: "Generate different exams" },
|
||||||
|
{ key: "auto_release_results", label: "Auto release results" },
|
||||||
|
{ key: "auto_start", label: "Auto start exam" },
|
||||||
|
{ key: "official_exam", label: "Official Exam" },
|
||||||
|
{ key: "hide_assignee_details", label: "Hide Assignees Details from Teachers" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const stateBadgeVariant = (s: ScheduleState): "default" | "secondary" | "outline" | "destructive" => {
|
||||||
|
switch (s) {
|
||||||
|
case "active": return "default";
|
||||||
|
case "planned": return "secondary";
|
||||||
|
case "past": return "outline";
|
||||||
|
case "start_expired": return "destructive";
|
||||||
|
case "archived": return "outline";
|
||||||
|
default: return "secondary";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
name: string;
|
||||||
|
exam_id: string;
|
||||||
|
entity_id: string;
|
||||||
|
start_date: string;
|
||||||
|
start_time: string;
|
||||||
|
end_date: string;
|
||||||
|
end_time: string;
|
||||||
|
assign_mode: "entity" | "batch" | "individual";
|
||||||
|
batch_ids: Set<number>;
|
||||||
|
student_ids: Set<number>;
|
||||||
|
full_length: boolean;
|
||||||
|
generate_different: boolean;
|
||||||
|
auto_release_results: boolean;
|
||||||
|
auto_start: boolean;
|
||||||
|
official_exam: boolean;
|
||||||
|
hide_assignee_details: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const _iso = (d: Date) => {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyForm = (): FormState => {
|
||||||
|
const today = new Date();
|
||||||
|
const inOneWeek = new Date();
|
||||||
|
inOneWeek.setDate(today.getDate() + 7);
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
exam_id: "",
|
||||||
|
entity_id: "",
|
||||||
|
start_date: _iso(today),
|
||||||
|
start_time: "09:00",
|
||||||
|
end_date: _iso(inOneWeek),
|
||||||
|
end_time: "17:00",
|
||||||
|
assign_mode: "batch",
|
||||||
|
batch_ids: new Set(),
|
||||||
|
student_ids: new Set(),
|
||||||
|
full_length: true,
|
||||||
|
generate_different: false,
|
||||||
|
auto_release_results: false,
|
||||||
|
auto_start: false,
|
||||||
|
official_exam: false,
|
||||||
|
hide_assignee_details: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default function AssignmentsPage() {
|
export default function AssignmentsPage() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [stateFilter, setStateFilter] = useState<ScheduleState | "all">("all");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [form, setForm] = useState({ title: "", entity_id: "", start_date: "", end_date: "" });
|
const [form, setForm] = useState<FormState>(emptyForm());
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const assignmentsQ = useAssignments({ size: 200 });
|
const schedulesQ = useQuery({
|
||||||
const createMut = useCreateAssignment();
|
queryKey: ["exam-schedules", stateFilter],
|
||||||
|
queryFn: () => assignmentsService.listSchedules(
|
||||||
|
stateFilter === "all" ? {} : { state: stateFilter }
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const schedules = schedulesQ.data?.items ?? [];
|
||||||
|
|
||||||
const items = assignmentsQ.data?.items ?? assignmentsQ.data?.data ?? [];
|
const customExamsQ = useQuery({
|
||||||
const assignments = Array.isArray(items) ? items : [];
|
queryKey: ["custom-exams-for-assign"],
|
||||||
|
queryFn: () => api.get<{ items: CustomExam[]; total: number }>("/exam/custom/list?per_page=200"),
|
||||||
const q = search.toLowerCase();
|
});
|
||||||
const filtered = assignments.filter(a =>
|
const publishedExams = (customExamsQ.data?.items ?? []).filter(
|
||||||
a.title?.toLowerCase().includes(q) || a.entity_name?.toLowerCase().includes(q),
|
(e) => e.status === "published" || e.status === "draft"
|
||||||
);
|
);
|
||||||
|
|
||||||
const loading = assignmentsQ.isLoading;
|
const entitiesQ = useQuery({
|
||||||
|
queryKey: ["entities-for-assign"],
|
||||||
|
queryFn: () => api.get<{ items: Entity[] }>("/entities"),
|
||||||
|
});
|
||||||
|
const entities = entitiesQ.data?.items ?? [];
|
||||||
|
|
||||||
|
const batchesQ = useBatches({ size: 200 });
|
||||||
|
const batches = batchesQ.data?.items ?? [];
|
||||||
|
|
||||||
|
const studentsQ = useStudents({ size: 200 });
|
||||||
|
const students = studentsQ.data?.items ?? [];
|
||||||
|
|
||||||
|
const stateCounts = useMemo(() => {
|
||||||
|
const all = schedulesQ.data?.items ?? [];
|
||||||
|
const counts: Record<string, number> = { all: all.length };
|
||||||
|
for (const s of all) {
|
||||||
|
counts[s.state] = (counts[s.state] || 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [schedulesQ.data]);
|
||||||
|
|
||||||
|
const allSchedulesQ = useQuery({
|
||||||
|
queryKey: ["exam-schedules", "all"],
|
||||||
|
queryFn: () => assignmentsService.listSchedules({}),
|
||||||
|
enabled: stateFilter !== "all",
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCounts = useMemo(() => {
|
||||||
|
const items = stateFilter === "all" ? schedules : (allSchedulesQ.data?.items ?? []);
|
||||||
|
const counts: Record<string, number> = { all: items.length };
|
||||||
|
for (const s of items) {
|
||||||
|
counts[s.state] = (counts[s.state] || 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [stateFilter, schedules, allSchedulesQ.data]);
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: (data: ExamScheduleCreateRequest) => assignmentsService.createSchedule(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
setForm(emptyForm());
|
||||||
|
toast({ title: "Exam scheduled successfully" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Error", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMut = useMutation({
|
||||||
|
mutationFn: (id: number) => assignmentsService.deleteSchedule(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
|
||||||
|
toast({ title: "Schedule deleted" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Error", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const archiveMut = useMutation({
|
||||||
|
mutationFn: (id: number) => assignmentsService.archiveSchedule(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
|
||||||
|
toast({ title: "Schedule archived" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
const filtered = schedules.filter(
|
||||||
|
(s) => s.name.toLowerCase().includes(q) || s.exam_title.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
|
||||||
function handleCreate() {
|
function handleCreate() {
|
||||||
createMut.mutate(
|
const startDt = form.start_date && form.start_time ? `${form.start_date}T${form.start_time}:00` : "";
|
||||||
{ title: form.title, entity_id: Number(form.entity_id) || 0, start_date: form.start_date, end_date: form.end_date },
|
const endDt = form.end_date && form.end_time ? `${form.end_date}T${form.end_time}:00` : "";
|
||||||
{
|
|
||||||
onSuccess: () => { setCreateOpen(false); setForm({ title: "", entity_id: "", start_date: "", end_date: "" }); toast({ title: "Assignment created" }); },
|
if (!form.name || !form.exam_id || !startDt || !endDt) {
|
||||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
toast({ variant: "destructive", title: "Missing fields", description: "Please fill in name, exam, start date/time, and end date/time." });
|
||||||
},
|
return;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
createMut.mutate({
|
||||||
|
name: form.name,
|
||||||
|
exam_id: Number(form.exam_id),
|
||||||
|
entity_id: form.entity_id ? Number(form.entity_id) : undefined,
|
||||||
|
start_date: startDt,
|
||||||
|
end_date: endDt,
|
||||||
|
assign_mode: form.assign_mode,
|
||||||
|
batch_ids: [...form.batch_ids],
|
||||||
|
student_ids: [...form.student_ids],
|
||||||
|
full_length: form.full_length,
|
||||||
|
generate_different: form.generate_different,
|
||||||
|
auto_release_results: form.auto_release_results,
|
||||||
|
auto_start: form.auto_start,
|
||||||
|
official_exam: form.official_exam,
|
||||||
|
hide_assignee_details: form.hide_assignee_details,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateF = (patch: Partial<FormState>) => setForm((p) => ({ ...p, ...patch }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Assignments</h1>
|
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||||
<p className="text-muted-foreground">Create and manage assignments.</p>
|
<Calendar className="h-6 w-6 text-primary" /> Assignments
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">Schedule exams and assign them to entities, classes, or students.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<Button onClick={() => { setForm(emptyForm()); setCreateOpen(true); }}>
|
||||||
<Plus className="h-4 w-4 mr-1" /> Create Assignment
|
<Plus className="h-4 w-4 mr-1" /> Schedule Exam
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* State tabs */}
|
||||||
|
<div className="inline-flex items-center rounded-full border-2 border-primary bg-primary/5 p-1 gap-0">
|
||||||
|
{STATE_TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
className={`flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium transition-all ${
|
||||||
|
stateFilter === tab.key
|
||||||
|
? "bg-white text-primary shadow-sm"
|
||||||
|
: "text-primary/70 hover:text-primary"
|
||||||
|
}`}
|
||||||
|
onClick={() => setStateFilter(tab.key)}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
{tab.label}
|
||||||
|
<span className={`text-xs ml-0.5 ${stateFilter === tab.key ? "text-primary" : "text-primary/50"}`}>
|
||||||
|
({totalCounts[tab.key] ?? 0})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="relative max-w-sm">
|
<div className="relative max-w-sm">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input placeholder="Search assignments..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
<Input placeholder="Search schedules..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{schedulesQ.isLoading ? (
|
||||||
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
<div className="flex justify-center py-12"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="p-12 text-center text-muted-foreground">
|
||||||
|
<Calendar className="h-10 w-10 mx-auto mb-3 opacity-40" />
|
||||||
|
<p className="font-medium">No exam schedules found</p>
|
||||||
|
<p className="text-sm mt-1">Create a new schedule to assign exams to students.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Title</TableHead>
|
<TableHead>Schedule Name</TableHead>
|
||||||
|
<TableHead>Exam</TableHead>
|
||||||
<TableHead>Entity</TableHead>
|
<TableHead>Entity</TableHead>
|
||||||
<TableHead>Start</TableHead>
|
<TableHead>Start</TableHead>
|
||||||
<TableHead>End</TableHead>
|
<TableHead>End</TableHead>
|
||||||
<TableHead>State</TableHead>
|
<TableHead>State</TableHead>
|
||||||
<TableHead>Assignees</TableHead>
|
<TableHead>Assignees</TableHead>
|
||||||
<TableHead>Completed</TableHead>
|
<TableHead>Completed</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filtered.length === 0 && (
|
{filtered.map((s) => (
|
||||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No assignments found.</TableCell></TableRow>
|
<TableRow key={s.id}>
|
||||||
)}
|
<TableCell className="font-medium">{s.name}</TableCell>
|
||||||
{filtered.map((a) => (
|
<TableCell>{s.exam_title}</TableCell>
|
||||||
<TableRow key={a.id}>
|
<TableCell>{s.entity_name || "—"}</TableCell>
|
||||||
<TableCell className="font-medium">{a.title}</TableCell>
|
<TableCell className="text-xs">{s.start_date ? new Date(s.start_date).toLocaleString() : "—"}</TableCell>
|
||||||
<TableCell>{a.entity_name || "—"}</TableCell>
|
<TableCell className="text-xs">{s.end_date ? new Date(s.end_date).toLocaleString() : "—"}</TableCell>
|
||||||
<TableCell>{a.start_date || "—"}</TableCell>
|
<TableCell>
|
||||||
<TableCell>{a.end_date || "—"}</TableCell>
|
<Badge variant={stateBadgeVariant(s.state)} className="capitalize">{s.state.replace("_", " ")}</Badge>
|
||||||
<TableCell><Badge variant={a.state === "active" ? "default" : "secondary"} className="capitalize">{a.state}</Badge></TableCell>
|
</TableCell>
|
||||||
<TableCell>{a.assignee_count ?? 0}</TableCell>
|
<TableCell>
|
||||||
<TableCell>{a.completed_count ?? 0}</TableCell>
|
<div className="flex items-center gap-1"><Users className="h-3.5 w-3.5 text-muted-foreground" />{s.assignee_count}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{s.completed_count}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{s.state !== "archived" && (
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" title="Archive"
|
||||||
|
onClick={() => archiveMut.mutate(s.id)}>
|
||||||
|
<Archive className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" title="Delete"
|
||||||
|
onClick={() => { if (confirm(`Delete schedule "${s.name}"?`)) deleteMut.mutate(s.id); }}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -94,21 +335,169 @@ export default function AssignmentsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Create Schedule Dialog */}
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<DialogContent>
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader><DialogTitle>Create Assignment</DialogTitle></DialogHeader>
|
<DialogHeader>
|
||||||
<div className="space-y-4">
|
<DialogTitle>Schedule Exam</DialogTitle>
|
||||||
<div className="space-y-2"><Label>Title</Label><Input value={form.title} onChange={(e) => setForm(f => ({ ...f, title: e.target.value }))} placeholder="e.g. IELTS Prep Q2" /></div>
|
<DialogDescription>Select an exam, assign it to students, and set scheduling options.</DialogDescription>
|
||||||
<div className="space-y-2"><Label>Entity ID</Label><Input type="number" value={form.entity_id} onChange={(e) => setForm(f => ({ ...f, entity_id: e.target.value }))} /></div>
|
</DialogHeader>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-2"><Label>Start Date</Label><Input type="date" value={form.start_date} onChange={(e) => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
|
<div className="space-y-5 py-2">
|
||||||
<div className="space-y-2"><Label>End Date</Label><Input type="date" value={form.end_date} onChange={(e) => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
|
{/* Basic info */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Schedule Name <span className="text-destructive">*</span></Label>
|
||||||
|
<Input value={form.name} onChange={(e) => updateF({ name: e.target.value })} placeholder="e.g. Q2 IELTS Exam" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Select Exam <span className="text-destructive">*</span></Label>
|
||||||
|
<Select value={form.exam_id} onValueChange={(v) => updateF({ exam_id: v })}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Choose exam..." /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{publishedExams.map((e) => (
|
||||||
|
<SelectItem key={e.id} value={String(e.id)}>
|
||||||
|
{e.title} <span className="text-xs text-muted-foreground ml-1">({e.status})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{publishedExams.length === 0 && (
|
||||||
|
<SelectItem value="_none" disabled>No exams available</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date/Time */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">Schedule Date & Time</Label>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Start Date <span className="text-destructive">*</span></Label>
|
||||||
|
<Input type="date" value={form.start_date} onChange={(e) => updateF({ start_date: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Start Time</Label>
|
||||||
|
<Input type="time" value={form.start_time} onChange={(e) => updateF({ start_time: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">End Date <span className="text-destructive">*</span></Label>
|
||||||
|
<Input type="date" value={form.end_date} onChange={(e) => updateF({ end_date: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">End Time</Label>
|
||||||
|
<Input type="time" value={form.end_time} onChange={(e) => updateF({ end_time: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options checkboxes */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">Exam Options</Label>
|
||||||
|
<div className="flex flex-wrap gap-x-6 gap-y-2">
|
||||||
|
{OPTION_FIELDS.map((opt) => (
|
||||||
|
<label key={opt.key} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={form[opt.key] as boolean}
|
||||||
|
onCheckedChange={(checked) => updateF({ [opt.key]: !!checked } as Partial<FormState>)}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
{opt.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assignment target */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-semibold">Assign To</Label>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Assignment Mode</Label>
|
||||||
|
<Select value={form.assign_mode} onValueChange={(v) => updateF({ assign_mode: v as FormState["assign_mode"] })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="entity">Entire Entity</SelectItem>
|
||||||
|
<SelectItem value="batch">Class / Batch</SelectItem>
|
||||||
|
<SelectItem value="individual">Individual Students</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Entity</Label>
|
||||||
|
<Select value={form.entity_id} onValueChange={(v) => updateF({ entity_id: v })}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select entity..." /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{entities.map((e) => (
|
||||||
|
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
{entities.length === 0 && (
|
||||||
|
<SelectItem value="_none" disabled>No entities</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Batch selection */}
|
||||||
|
{form.assign_mode === "batch" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Select Classes</Label>
|
||||||
|
<div className="max-h-36 overflow-y-auto border rounded-md p-2 space-y-1">
|
||||||
|
{batches.map((b) => (
|
||||||
|
<label key={b.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
||||||
|
<Checkbox
|
||||||
|
checked={form.batch_ids.has(b.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setForm((p) => {
|
||||||
|
const next = new Set(p.batch_ids);
|
||||||
|
checked ? next.add(b.id) : next.delete(b.id);
|
||||||
|
return { ...p, batch_ids: next };
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
{b.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{batches.length === 0 && <p className="text-xs text-muted-foreground italic text-center py-2">No classes available</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Individual student selection */}
|
||||||
|
{form.assign_mode === "individual" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Select Students</Label>
|
||||||
|
<div className="max-h-48 overflow-y-auto border rounded-md p-2 space-y-1">
|
||||||
|
{students.map((s) => (
|
||||||
|
<label key={s.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
||||||
|
<Checkbox
|
||||||
|
checked={form.student_ids.has(s.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setForm((p) => {
|
||||||
|
const next = new Set(p.student_ids);
|
||||||
|
checked ? next.add(s.id) : next.delete(s.id);
|
||||||
|
return { ...p, student_ids: next };
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
{s.name}
|
||||||
|
{s.email && <span className="text-xs text-muted-foreground ml-auto">{s.email}</span>}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{students.length === 0 && <p className="text-xs text-muted-foreground italic text-center py-2">No students available</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||||
<Button disabled={createMut.isPending || !form.title} onClick={handleCreate}>
|
<Button disabled={createMut.isPending || !form.name || !form.exam_id || !form.start_date || !form.end_date} onClick={handleCreate}>
|
||||||
{createMut.isPending ? "Creating..." : "Create"}
|
{createMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Schedule Exam"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Search, Plus, Trash2, Users } from "lucide-react";
|
import { Search, Plus, Trash2, Users, UserPlus, UserMinus, Pencil } from "lucide-react";
|
||||||
import { useBatches, useCourses } from "@/hooks/queries";
|
import { useBatches, useCourses, useStudents } from "@/hooks/queries";
|
||||||
import { lmsService } from "@/services/lms.service";
|
import { lmsService } from "@/services/lms.service";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
@@ -17,49 +18,124 @@ export default function ClassroomsPage() {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [form, setForm] = useState({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" });
|
const [form, setForm] = useState({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" });
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [editBatch, setEditBatch] = useState<{ id: number; name: string; course_id: string; start_date: string; end_date: string } | null>(null);
|
||||||
|
const [studentsOpen, setStudentsOpen] = useState(false);
|
||||||
|
const [studentsBatchId, setStudentsBatchId] = useState<number | null>(null);
|
||||||
|
const [addStudentsOpen, setAddStudentsOpen] = useState(false);
|
||||||
|
const [selectedAddIds, setSelectedAddIds] = useState<number[]>([]);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const batchesQ = useBatches({ size: 200 });
|
const batchesQ = useBatches({ size: 200 });
|
||||||
const coursesQ = useCourses({ size: 200 });
|
const coursesQ = useCourses({ size: 200 });
|
||||||
|
const studentsQ = useStudents({ size: 200 });
|
||||||
const batches = batchesQ.data?.items ?? [];
|
const batches = batchesQ.data?.items ?? [];
|
||||||
const courses = coursesQ.data?.items ?? [];
|
const courses = coursesQ.data?.items ?? [];
|
||||||
|
const allStudents = studentsQ.data?.items ?? [];
|
||||||
|
|
||||||
const createMut = useMutation({
|
const createMut = useMutation({
|
||||||
mutationFn: (data: Parameters<typeof lmsService.createBatch>[0]) => lmsService.createBatch(data),
|
mutationFn: (data: Parameters<typeof lmsService.createBatch>[0]) => lmsService.createBatch(data),
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "batches"] }); setCreateOpen(false); setForm({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" }); toast({ title: "Classroom created" }); },
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
setForm({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" });
|
||||||
|
toast({ title: "Classroom created" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMut = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) => lmsService.updateBatch(id, data as never),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
||||||
|
setEditOpen(false);
|
||||||
|
toast({ title: "Classroom updated" });
|
||||||
|
},
|
||||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMut = useMutation({
|
const deleteMut = useMutation({
|
||||||
mutationFn: (id: number) => lmsService.deleteBatch(id),
|
mutationFn: (id: number) => lmsService.deleteBatch(id),
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "batches"] }); toast({ title: "Deleted" }); },
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
||||||
|
toast({ title: "Deleted" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addStudentsMut = useMutation({
|
||||||
|
mutationFn: ({ batchId, studentIds }: { batchId: number; studentIds: number[] }) =>
|
||||||
|
lmsService.addStudentsToBatch(batchId, studentIds),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
||||||
|
setAddStudentsOpen(false);
|
||||||
|
setSelectedAddIds([]);
|
||||||
|
toast({ title: `Added ${res.total} student(s)` });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeStudentMut = useMutation({
|
||||||
|
mutationFn: ({ batchId, studentIds }: { batchId: number; studentIds: number[] }) =>
|
||||||
|
lmsService.removeStudentsFromBatch(batchId, studentIds),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
||||||
|
toast({ title: "Student removed from classroom" });
|
||||||
|
},
|
||||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
const filtered = batches.filter(b => b.name.toLowerCase().includes(q) || b.course_name?.toLowerCase().includes(q));
|
const filtered = batches.filter(
|
||||||
|
(b) => b.name.toLowerCase().includes(q) || b.course_name?.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
const loading = batchesQ.isLoading;
|
const loading = batchesQ.isLoading;
|
||||||
|
|
||||||
|
const currentBatch = batches.find((b) => b.id === studentsBatchId);
|
||||||
|
const batchStudentIds = new Set(currentBatch?.student_ids ?? []);
|
||||||
|
const availableStudents = allStudents.filter((s) => !batchStudentIds.has(s.id));
|
||||||
|
|
||||||
|
function openStudents(batchId: number) {
|
||||||
|
setStudentsBatchId(batchId);
|
||||||
|
setStudentsOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(b: typeof batches[0]) {
|
||||||
|
setEditBatch({
|
||||||
|
id: b.id,
|
||||||
|
name: b.name,
|
||||||
|
course_id: String(b.course_id || ""),
|
||||||
|
start_date: b.start_date,
|
||||||
|
end_date: b.end_date,
|
||||||
|
});
|
||||||
|
setEditOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Classrooms</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Classrooms</h1>
|
||||||
<p className="text-muted-foreground">Create and manage batches (classrooms).</p>
|
<p className="text-muted-foreground">Create and manage classrooms with students.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
<Plus className="h-4 w-4 mr-1" /> Create Classroom
|
<Plus className="h-4 w-4 mr-1" /> Create Classroom
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative max-w-sm">
|
<div className="flex gap-3 items-center">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<div className="relative flex-1 max-w-sm">
|
||||||
<Input placeholder="Search classrooms..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input placeholder="Search classrooms..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">{batches.length} total</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
<div className="flex items-center justify-center min-h-[300px]">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
@@ -69,29 +145,67 @@ export default function ClassroomsPage() {
|
|||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Course</TableHead>
|
<TableHead>Course</TableHead>
|
||||||
<TableHead>Students</TableHead>
|
<TableHead>Students</TableHead>
|
||||||
<TableHead>Capacity</TableHead>
|
|
||||||
<TableHead>Dates</TableHead>
|
<TableHead>Dates</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead className="w-10" />
|
<TableHead className="w-[140px]" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No classrooms found.</TableCell></TableRow>
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||||
|
No classrooms found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{filtered.map((b) => (
|
{filtered.map((b) => (
|
||||||
<TableRow key={b.id}>
|
<TableRow key={b.id}>
|
||||||
<TableCell className="font-medium">{b.name}</TableCell>
|
<TableCell className="font-medium">{b.name}</TableCell>
|
||||||
<TableCell>{b.course_name}</TableCell>
|
<TableCell>{b.course_name || "—"}</TableCell>
|
||||||
<TableCell><Badge variant="secondary"><Users className="h-3 w-3 mr-1" />{b.student_count}</Badge></TableCell>
|
|
||||||
<TableCell>{b.capacity}</TableCell>
|
|
||||||
<TableCell className="text-sm">{b.start_date} — {b.end_date}</TableCell>
|
|
||||||
<TableCell><Badge variant={b.status === "active" ? "default" : "secondary"} className="capitalize">{b.status}</Badge></TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => { if (window.confirm(`Delete "${b.name}"?`)) deleteMut.mutate(b.id); }}>
|
<Button
|
||||||
<Trash2 className="h-4 w-4" />
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5 h-7 px-2"
|
||||||
|
onClick={() => openStudents(b.id)}
|
||||||
|
>
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
{b.student_count} students
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{b.start_date && b.end_date
|
||||||
|
? `${b.start_date} — ${b.end_date}`
|
||||||
|
: "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={b.status === "active" ? "default" : "secondary"}
|
||||||
|
className="capitalize"
|
||||||
|
>
|
||||||
|
{b.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" title="Manage students" onClick={() => openStudents(b.id)}>
|
||||||
|
<UserPlus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" title="Edit" onClick={() => openEdit(b)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Delete "${b.name}"?`)) deleteMut.mutate(b.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -100,34 +214,234 @@ export default function ClassroomsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Create Dialog */}
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader><DialogTitle>Create Classroom</DialogTitle></DialogHeader>
|
<DialogHeader><DialogTitle>Create Classroom</DialogTitle></DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2"><Label>Name</Label><Input value={form.name} onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))} placeholder="e.g. IELTS Prep Group A" /></div>
|
<div className="space-y-2">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. IELTS Prep Group A" />
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Course</Label>
|
<Label>Course</Label>
|
||||||
<Select value={form.course_id} onValueChange={(v) => setForm(f => ({ ...f, course_id: v }))}>
|
<Select value={form.course_id} onValueChange={(v) => setForm((f) => ({ ...f, course_id: v }))}>
|
||||||
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
|
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{courses.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.title || c.code}</SelectItem>)}
|
{courses.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={String(c.id)}>{c.title || c.code}</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-2"><Label>Start Date</Label><Input type="date" value={form.start_date} onChange={(e) => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
|
<div className="space-y-2">
|
||||||
<div className="space-y-2"><Label>End Date</Label><Input type="date" value={form.end_date} onChange={(e) => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
|
<Label>Start Date</Label>
|
||||||
|
<Input type="date" value={form.start_date} onChange={(e) => setForm((f) => ({ ...f, start_date: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>End Date</Label>
|
||||||
|
<Input type="date" value={form.end_date} onChange={(e) => setForm((f) => ({ ...f, end_date: e.target.value }))} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2"><Label>Max Students</Label><Input type="number" value={form.max_students} onChange={(e) => setForm(f => ({ ...f, max_students: e.target.value }))} placeholder="30" /></div>
|
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||||
<Button disabled={createMut.isPending || !form.name} onClick={() => createMut.mutate({ name: form.name, course_id: Number(form.course_id) || undefined, start_date: form.start_date || undefined, end_date: form.end_date || undefined, capacity: Number(form.max_students) || undefined } as never)}>
|
<Button
|
||||||
|
disabled={createMut.isPending || !form.name}
|
||||||
|
onClick={() =>
|
||||||
|
createMut.mutate({
|
||||||
|
name: form.name,
|
||||||
|
course_id: Number(form.course_id) || undefined,
|
||||||
|
start_date: form.start_date || undefined,
|
||||||
|
end_date: form.end_date || undefined,
|
||||||
|
} as never)
|
||||||
|
}
|
||||||
|
>
|
||||||
{createMut.isPending ? "Creating..." : "Create"}
|
{createMut.isPending ? "Creating..." : "Create"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit Dialog */}
|
||||||
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Edit Classroom</DialogTitle></DialogHeader>
|
||||||
|
{editBatch && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input
|
||||||
|
value={editBatch.name}
|
||||||
|
onChange={(e) => setEditBatch((prev) => prev ? { ...prev, name: e.target.value } : prev)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Course</Label>
|
||||||
|
<Select
|
||||||
|
value={editBatch.course_id}
|
||||||
|
onValueChange={(v) => setEditBatch((prev) => prev ? { ...prev, course_id: v } : prev)}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{courses.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={String(c.id)}>{c.title || c.code}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Start Date</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={editBatch.start_date}
|
||||||
|
onChange={(e) => setEditBatch((prev) => prev ? { ...prev, start_date: e.target.value } : prev)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>End Date</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={editBatch.end_date}
|
||||||
|
onChange={(e) => setEditBatch((prev) => prev ? { ...prev, end_date: e.target.value } : prev)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
disabled={updateMut.isPending || !editBatch?.name}
|
||||||
|
onClick={() => {
|
||||||
|
if (!editBatch) return;
|
||||||
|
updateMut.mutate({
|
||||||
|
id: editBatch.id,
|
||||||
|
data: {
|
||||||
|
name: editBatch.name,
|
||||||
|
course_id: Number(editBatch.course_id) || undefined,
|
||||||
|
start_date: editBatch.start_date || undefined,
|
||||||
|
end_date: editBatch.end_date || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{updateMut.isPending ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Students Management Dialog */}
|
||||||
|
<Dialog open={studentsOpen} onOpenChange={setStudentsOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Classroom Students — {currentBatch?.name}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{currentBatch?.course_name && `Course: ${currentBatch.course_name}`}
|
||||||
|
{currentBatch ? ` · ${currentBatch.student_count} student(s)` : ""}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="max-h-[320px] overflow-y-auto space-y-1">
|
||||||
|
{currentBatch?.student_ids?.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-6">
|
||||||
|
No students in this classroom yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
allStudents
|
||||||
|
.filter((s) => batchStudentIds.has(s.id))
|
||||||
|
.map((s) => (
|
||||||
|
<div key={s.id} className="flex items-center justify-between p-2 rounded hover:bg-muted/50">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{s.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{s.email}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (studentsBatchId && window.confirm(`Remove ${s.name} from this classroom?`))
|
||||||
|
removeStudentMut.mutate({ batchId: studentsBatchId, studentIds: [s.id] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserMinus className="h-3.5 w-3.5 mr-1" /> Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setStudentsOpen(false)}>Close</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedAddIds([]);
|
||||||
|
setAddStudentsOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserPlus className="h-4 w-4 mr-1" /> Add Students
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Add Students to Batch Dialog */}
|
||||||
|
<Dialog open={addStudentsOpen} onOpenChange={setAddStudentsOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Students to {currentBatch?.name}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Select students to add to this classroom.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="max-h-[320px] overflow-y-auto space-y-1">
|
||||||
|
{availableStudents.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
All students are already in this classroom.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
availableStudents.map((s) => (
|
||||||
|
<label key={s.id} className="flex items-center gap-3 p-2 rounded hover:bg-muted/50 cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedAddIds.includes(s.id)}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setSelectedAddIds((prev) =>
|
||||||
|
prev.includes(s.id) ? prev.filter((x) => x !== s.id) : [...prev, s.id],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{s.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{s.email}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setAddStudentsOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
disabled={addStudentsMut.isPending || selectedAddIds.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
if (studentsBatchId)
|
||||||
|
addStudentsMut.mutate({ batchId: studentsBatchId, studentIds: selectedAddIds });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{addStudentsMut.isPending
|
||||||
|
? "Adding..."
|
||||||
|
: `Add ${selectedAddIds.length} Student(s)`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,91 +5,280 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Search, Plus, Building2, Trash2 } from "lucide-react";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { useDepartments, useCreateDepartment, useDeleteDepartment } from "@/hooks/queries";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Search, Plus, Building2, Trash2, Pencil, Users, Shield } from "lucide-react";
|
||||||
|
import { entitiesService } from "@/services/entities.service";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
const ENTITY_TYPES = [
|
||||||
|
{ value: "corporate", label: "Corporate" },
|
||||||
|
{ value: "university", label: "University" },
|
||||||
|
{ value: "school", label: "School" },
|
||||||
|
{ value: "government", label: "Government" },
|
||||||
|
{ value: "freelance", label: "Freelance" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function EntitiesPage() {
|
export default function EntitiesPage() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [form, setForm] = useState({ name: "", code: "" });
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [editEntity, setEditEntity] = useState<{ id: number; name: string; code: string; type: string } | null>(null);
|
||||||
|
const [form, setForm] = useState({ name: "", code: "", type: "" });
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const deptsQ = useDepartments({ size: 200 });
|
const entitiesQ = useQuery({
|
||||||
const createMut = useCreateDepartment();
|
queryKey: ["entities"],
|
||||||
const deleteMut = useDeleteDepartment();
|
queryFn: () => entitiesService.list({ size: 200 }),
|
||||||
|
});
|
||||||
|
|
||||||
const depts = deptsQ.data?.items ?? deptsQ.data?.data ?? [];
|
const createMut = useMutation({
|
||||||
const departments = Array.isArray(depts) ? depts : [];
|
mutationFn: (data: { name: string; code?: string; type?: string }) =>
|
||||||
const filtered = departments.filter(d => d.name?.toLowerCase().includes(search.toLowerCase()));
|
entitiesService.create(data as Partial<import("@/types").Entity>),
|
||||||
const loading = deptsQ.isLoading;
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["entities"] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
setForm({ name: "", code: "", type: "" });
|
||||||
|
toast({ title: "Entity created" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
function handleCreate() {
|
const updateMut = useMutation({
|
||||||
createMut.mutate(
|
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) =>
|
||||||
{ name: form.name, code: form.code || undefined },
|
entitiesService.update(id, data as Partial<import("@/types").Entity>),
|
||||||
{
|
onSuccess: () => {
|
||||||
onSuccess: () => { setCreateOpen(false); setForm({ name: "", code: "" }); toast({ title: "Department created" }); },
|
qc.invalidateQueries({ queryKey: ["entities"] });
|
||||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
setEditOpen(false);
|
||||||
},
|
toast({ title: "Entity updated" });
|
||||||
);
|
},
|
||||||
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMut = useMutation({
|
||||||
|
mutationFn: (id: number) => entitiesService.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["entities"] });
|
||||||
|
toast({ title: "Deleted" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const raw = entitiesQ.data;
|
||||||
|
const entities: { id: number; name: string; code: string; type: string; user_count: number; role_count: number; active: boolean }[] = (() => {
|
||||||
|
if (!raw) return [];
|
||||||
|
if (Array.isArray(raw)) return raw as never[];
|
||||||
|
const r = raw as Record<string, unknown>;
|
||||||
|
const arr = (r.items ?? r.data ?? []) as never[];
|
||||||
|
return Array.isArray(arr) ? arr : [];
|
||||||
|
})();
|
||||||
|
|
||||||
|
const filtered = entities.filter(
|
||||||
|
(e) =>
|
||||||
|
e.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
e.code?.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
);
|
||||||
|
const loading = entitiesQ.isLoading;
|
||||||
|
|
||||||
|
function openEdit(e: typeof entities[0]) {
|
||||||
|
setEditEntity({ id: e.id, name: e.name, code: e.code, type: e.type });
|
||||||
|
setEditOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Entities / Departments</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Entities</h1>
|
||||||
<p className="text-muted-foreground">Manage organizational departments.</p>
|
<p className="text-muted-foreground">Manage organizations and entities on the platform.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
<Plus className="h-4 w-4 mr-1" /> Create Department
|
<Plus className="h-4 w-4 mr-1" /> Create Entity
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative max-w-sm">
|
<div className="flex gap-4 items-center">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<div className="relative flex-1 max-w-sm">
|
||||||
<Input placeholder="Search departments..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input placeholder="Search entities..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">{entities.length} total</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
<div className="flex items-center justify-center min-h-[300px]">
|
||||||
) : (
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{filtered.length === 0 && <p className="col-span-full text-center text-muted-foreground py-8">No departments found.</p>}
|
|
||||||
{filtered.map((d) => (
|
|
||||||
<Card key={d.id} className="border-0 shadow-sm">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
|
||||||
<Building2 className="h-4 w-4 text-primary" />
|
|
||||||
{d.name}
|
|
||||||
</CardTitle>
|
|
||||||
<Button size="sm" variant="ghost" className="text-destructive" onClick={() => { if (window.confirm(`Delete "${d.name}"?`)) deleteMut.mutate(d.id, { onSuccess: () => toast({ title: "Deleted" }), onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }) }); }}>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">{d.code || "No code"}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Code</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Users</TableHead>
|
||||||
|
<TableHead>Roles</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="w-24" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
|
||||||
|
No entities found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{filtered.map((e) => (
|
||||||
|
<TableRow key={e.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="h-4 w-4 text-primary" />
|
||||||
|
{e.name}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell><code className="text-xs bg-muted px-1.5 py-0.5 rounded">{e.code}</code></TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="capitalize">{e.type || "—"}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary"><Users className="h-3 w-3 mr-1" />{e.user_count ?? 0}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary"><Shield className="h-3 w-3 mr-1" />{e.role_count ?? 0}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={e.active !== false ? "default" : "secondary"}>
|
||||||
|
{e.active !== false ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(e)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Delete entity "${e.name}"?`))
|
||||||
|
deleteMut.mutate(e.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Create Dialog */}
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader><DialogTitle>Create Department</DialogTitle></DialogHeader>
|
<DialogHeader><DialogTitle>Create Entity</DialogTitle></DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2"><Label>Name</Label><Input value={form.name} onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))} placeholder="Department name" /></div>
|
<div className="space-y-2">
|
||||||
<div className="space-y-2"><Label>Code</Label><Input value={form.code} onChange={(e) => setForm(f => ({ ...f, code: e.target.value }))} placeholder="DEPT01" /></div>
|
<Label>Name</Label>
|
||||||
|
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Organization name" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Code</Label>
|
||||||
|
<Input value={form.code} onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))} placeholder="ORG_CODE (auto-generated if empty)" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Select value={form.type} onValueChange={(v) => setForm((f) => ({ ...f, type: v }))}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ENTITY_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||||
<Button disabled={createMut.isPending || !form.name} onClick={handleCreate}>
|
<Button
|
||||||
|
disabled={createMut.isPending || !form.name.trim()}
|
||||||
|
onClick={() =>
|
||||||
|
createMut.mutate({
|
||||||
|
name: form.name.trim(),
|
||||||
|
code: form.code.trim() || undefined,
|
||||||
|
type: form.type || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
{createMut.isPending ? "Creating..." : "Create"}
|
{createMut.isPending ? "Creating..." : "Create"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit Dialog */}
|
||||||
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Edit Entity</DialogTitle></DialogHeader>
|
||||||
|
{editEntity && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input
|
||||||
|
value={editEntity.name}
|
||||||
|
onChange={(e) => setEditEntity((prev) => prev ? { ...prev, name: e.target.value } : prev)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Code</Label>
|
||||||
|
<Input
|
||||||
|
value={editEntity.code}
|
||||||
|
onChange={(e) => setEditEntity((prev) => prev ? { ...prev, code: e.target.value } : prev)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Select
|
||||||
|
value={editEntity.type}
|
||||||
|
onValueChange={(v) => setEditEntity((prev) => prev ? { ...prev, type: v } : prev)}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ENTITY_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
disabled={updateMut.isPending || !editEntity?.name.trim()}
|
||||||
|
onClick={() => {
|
||||||
|
if (!editEntity) return;
|
||||||
|
updateMut.mutate({
|
||||||
|
id: editEntity.id,
|
||||||
|
data: { name: editEntity.name, code: editEntity.code, type: editEntity.type },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{updateMut.isPending ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import AiTipBanner from "@/components/ai/AiTipBanner";
|
|||||||
export default function ExamPage() {
|
export default function ExamPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-[70vh] gap-4 max-w-md mx-auto">
|
<div className="flex flex-col items-center justify-center min-h-[70vh] gap-4 max-w-md mx-auto">
|
||||||
<AiTipBanner tip="Based on your practice history, focus on Reading Part 3 (sentence completion) — your accuracy there is 58% vs 82% average. Budget 20 min for the writing section." variant="recommendation" />
|
<AiTipBanner context="exam" variant="recommendation" />
|
||||||
|
|
||||||
<Card className="border-0 shadow-sm w-full">
|
<Card className="border-0 shadow-sm w-full">
|
||||||
<CardContent className="p-8 text-center space-y-6">
|
<CardContent className="p-8 text-center space-y-6">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,48 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Search } from "lucide-react";
|
import { Search, Plus, FileText, Clock } from "lucide-react";
|
||||||
import { useInstitutionalExamSessions } from "@/hooks/queries";
|
import { useInstitutionalExamSessions } from "@/hooks/queries";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
interface CustomExam {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
total_time_min: number;
|
||||||
|
sections: { id: number; title: string; skill: string }[];
|
||||||
|
teacher_id: number | null;
|
||||||
|
template_id: number | null;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
|
||||||
|
published: "default",
|
||||||
|
draft: "secondary",
|
||||||
|
archived: "outline",
|
||||||
|
};
|
||||||
|
|
||||||
export default function ExamsListPage() {
|
export default function ExamsListPage() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const sessionsQ = useInstitutionalExamSessions();
|
const [tab, setTab] = useState<"custom" | "sessions">("custom");
|
||||||
const items = sessionsQ.data?.data ?? sessionsQ.data?.items ?? [];
|
|
||||||
const sessions = Array.isArray(items) ? items : [];
|
|
||||||
|
|
||||||
|
const customQ = useQuery({
|
||||||
|
queryKey: ["custom-exams", search],
|
||||||
|
queryFn: () => api.get<{ items: CustomExam[]; total: number }>(`/exam/custom/list?search=${encodeURIComponent(search)}&per_page=50`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionsQ = useInstitutionalExamSessions();
|
||||||
|
const sessionItems = sessionsQ.data?.data ?? sessionsQ.data?.items ?? [];
|
||||||
|
const sessions = Array.isArray(sessionItems) ? sessionItems : [];
|
||||||
|
|
||||||
|
const customExams = customQ.data?.items ?? [];
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
const filtered = sessions.filter(s =>
|
const filteredSessions = sessions.filter((s: Record<string, string>) =>
|
||||||
s.name?.toLowerCase().includes(q) || s.course_name?.toLowerCase().includes(q),
|
s.name?.toLowerCase().includes(q) || s.course_name?.toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -23,8 +51,20 @@ export default function ExamsListPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Exams List</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Exams List</h1>
|
||||||
<p className="text-muted-foreground">Browse and manage all exam sessions.</p>
|
<p className="text-muted-foreground">Browse and manage all exams and exam sessions.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Link to="/admin/exam/create">
|
||||||
|
<Button><Plus className="h-4 w-4 mr-2" /> Create Exam</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant={tab === "custom" ? "default" : "outline"} size="sm" onClick={() => setTab("custom")}>
|
||||||
|
<FileText className="h-4 w-4 mr-1" /> Custom Exams ({customExams.length})
|
||||||
|
</Button>
|
||||||
|
<Button variant={tab === "sessions" ? "default" : "outline"} size="sm" onClick={() => setTab("sessions")}>
|
||||||
|
<Clock className="h-4 w-4 mr-1" /> Exam Sessions ({filteredSessions.length})
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative max-w-sm">
|
<div className="relative max-w-sm">
|
||||||
@@ -32,44 +72,92 @@ export default function ExamsListPage() {
|
|||||||
<Input placeholder="Search exams..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
<Input placeholder="Search exams..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sessionsQ.isLoading ? (
|
{tab === "custom" && (
|
||||||
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
customQ.isLoading ? (
|
||||||
) : (
|
<div className="flex items-center justify-center min-h-[200px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
||||||
<Card className="border-0 shadow-sm">
|
) : (
|
||||||
<CardContent className="p-0">
|
<Card className="border-0 shadow-sm">
|
||||||
<Table>
|
<CardContent className="p-0">
|
||||||
<TableHeader>
|
<Table>
|
||||||
<TableRow>
|
<TableHeader>
|
||||||
<TableHead>#</TableHead>
|
<TableRow>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead className="w-12">#</TableHead>
|
||||||
<TableHead>Course</TableHead>
|
<TableHead>Title</TableHead>
|
||||||
<TableHead>Batch</TableHead>
|
<TableHead>Modules</TableHead>
|
||||||
<TableHead>Start</TableHead>
|
<TableHead>Duration</TableHead>
|
||||||
<TableHead>End</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead>State</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No exam sessions found.</TableCell></TableRow>
|
|
||||||
)}
|
|
||||||
{filtered.map((s, i) => (
|
|
||||||
<TableRow key={s.id}>
|
|
||||||
<TableCell>{i + 1}</TableCell>
|
|
||||||
<TableCell className="font-medium">{s.name}</TableCell>
|
|
||||||
<TableCell>{s.course_name || "—"}</TableCell>
|
|
||||||
<TableCell>{s.batch_name || "—"}</TableCell>
|
|
||||||
<TableCell>{s.start_date || "—"}</TableCell>
|
|
||||||
<TableCell>{s.end_date || "—"}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={s.state === "done" ? "default" : s.state === "schedule" ? "secondary" : "outline"} className="capitalize">{s.state}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{customExams.length === 0 && (
|
||||||
</CardContent>
|
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">No custom exams found. Submit one from the Generation page.</TableCell></TableRow>
|
||||||
</Card>
|
)}
|
||||||
|
{customExams.map((exam, i) => (
|
||||||
|
<TableRow key={exam.id}>
|
||||||
|
<TableCell className="text-muted-foreground">{exam.id}</TableCell>
|
||||||
|
<TableCell className="font-medium">{exam.title}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{exam.sections.length > 0
|
||||||
|
? exam.sections.map((s) => (
|
||||||
|
<Badge key={s.id} variant="outline" className="text-xs capitalize">{s.skill || s.title}</Badge>
|
||||||
|
))
|
||||||
|
: <span className="text-muted-foreground text-xs">—</span>}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{exam.total_time_min ? `${exam.total_time_min} min` : "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusColors[exam.status] ?? "outline"} className="capitalize">{exam.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "sessions" && (
|
||||||
|
sessionsQ.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center min-h-[200px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
||||||
|
) : (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>#</TableHead>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Course</TableHead>
|
||||||
|
<TableHead>Batch</TableHead>
|
||||||
|
<TableHead>Start</TableHead>
|
||||||
|
<TableHead>End</TableHead>
|
||||||
|
<TableHead>State</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredSessions.length === 0 && (
|
||||||
|
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No exam sessions found.</TableCell></TableRow>
|
||||||
|
)}
|
||||||
|
{filteredSessions.map((s: Record<string, string>, i: number) => (
|
||||||
|
<TableRow key={s.id}>
|
||||||
|
<TableCell>{i + 1}</TableCell>
|
||||||
|
<TableCell className="font-medium">{s.name}</TableCell>
|
||||||
|
<TableCell>{s.course_name || "—"}</TableCell>
|
||||||
|
<TableCell>{s.batch_name || "—"}</TableCell>
|
||||||
|
<TableCell>{s.start_date || "—"}</TableCell>
|
||||||
|
<TableCell>{s.end_date || "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={s.state === "done" ? "default" : s.state === "schedule" ? "secondary" : "outline"} className="capitalize">{s.state}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,122 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { CheckCircle, PenTool, Sparkles } from "lucide-react";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
Sparkles,
|
||||||
|
Circle,
|
||||||
|
} from "lucide-react";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { trainingService, type CefrLevel } from "@/services/training.service";
|
||||||
|
|
||||||
const grammarItems = [
|
const LEVELS: (CefrLevel | "all")[] = ["all", "A1", "A2", "B1", "B2", "C1", "C2"];
|
||||||
{ rule: "Conditional Sentences (Type 2 & 3)", description: "If + past simple / past perfect for hypothetical situations", level: "B2", completed: true },
|
|
||||||
{ rule: "Passive Voice in Academic Writing", description: "Using passive constructions in formal reports", level: "B2", completed: false },
|
|
||||||
{ rule: "Relative Clauses", description: "Defining vs non-defining relative clauses", level: "B1", completed: true },
|
|
||||||
{ rule: "Subject-Verb Agreement", description: "Complex subjects with prepositional phrases", level: "B1", completed: false },
|
|
||||||
{ rule: "Modal Verbs for Speculation", description: "Must have, could have, might have + past participle", level: "C1", completed: false },
|
|
||||||
{ rule: "Reported Speech", description: "Tense changes and time references in indirect speech", level: "B2", completed: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function GrammarPage() {
|
export default function GrammarPage() {
|
||||||
const [showCompleted, setShowCompleted] = useState(false);
|
const { toast } = useToast();
|
||||||
const completed = grammarItems.filter(g => g.completed).length;
|
const qc = useQueryClient();
|
||||||
const filtered = showCompleted ? grammarItems : grammarItems.filter(g => !g.completed);
|
const [showCompleted, setShowCompleted] = useState(true);
|
||||||
|
const [levelFilter, setLevelFilter] = useState<string>("all");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
example: "",
|
||||||
|
level: "B1" as CefrLevel,
|
||||||
|
category: "general",
|
||||||
|
});
|
||||||
|
|
||||||
|
const listQ = useQuery({
|
||||||
|
queryKey: ["training-grammar", levelFilter, search],
|
||||||
|
queryFn: () =>
|
||||||
|
trainingService.listGrammar({
|
||||||
|
...(levelFilter !== "all" ? { level: levelFilter } : {}),
|
||||||
|
...(search ? { search } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = listQ.data?.items ?? [];
|
||||||
|
const summary = listQ.data?.summary;
|
||||||
|
const filtered = showCompleted ? items : items.filter((g) => !g.completed);
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: trainingService.createGrammar,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["training-grammar"] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
setForm({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
example: "",
|
||||||
|
level: "B1",
|
||||||
|
category: "general",
|
||||||
|
});
|
||||||
|
toast({ title: "Rule added" });
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const msg =
|
||||||
|
err && typeof err === "object" && "message" in err
|
||||||
|
? String((err as { message: unknown }).message)
|
||||||
|
: "Failed to add rule";
|
||||||
|
toast({ title: msg, variant: "destructive" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const delMut = useMutation({
|
||||||
|
mutationFn: trainingService.deleteGrammar,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["training-grammar"] });
|
||||||
|
toast({ title: "Rule deleted" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const progressMut = useMutation({
|
||||||
|
mutationFn: ({ id, completed }: { id: number; completed: boolean }) =>
|
||||||
|
trainingService.setGrammarProgress(id, { completed }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["training-grammar"] }),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Grammar Training</h1>
|
<div>
|
||||||
<p className="text-muted-foreground">Master grammar rules essential for IELTS.</p>
|
<h1 className="text-2xl font-bold tracking-tight">Grammar Training</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage the grammar-rule library and track mastery by CEFR level.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" /> Add Rule
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AiTipBanner tip="You've completed 50% of grammar topics. Focus on Passive Voice next — it appears in 73% of IELTS Writing Task 1 questions and will boost your band score." variant="recommendation" />
|
<AiTipBanner context="grammar" variant="recommendation" />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="lg:col-span-2 space-y-4">
|
<div className="lg:col-span-2 space-y-4">
|
||||||
@@ -36,31 +124,104 @@ export default function GrammarPage() {
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-base">Progress</CardTitle>
|
<CardTitle className="text-base">Progress</CardTitle>
|
||||||
<span className="text-sm text-muted-foreground">{completed}/{grammarItems.length} completed</span>
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{summary
|
||||||
|
? `${summary.completed}/${summary.total} completed (${summary.completion_rate}%)`
|
||||||
|
: "—"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Progress value={(completed / grammarItems.length) * 100} className="h-3" />
|
<Progress value={summary?.completion_rate ?? 0} className="h-3" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
<Switch id="show" checked={showCompleted} onCheckedChange={setShowCompleted} />
|
<div className="relative flex-1 max-w-sm">
|
||||||
<Label htmlFor="show" className="text-sm">Show completed</Label>
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search rules…"
|
||||||
|
className="pl-9"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={levelFilter} onValueChange={setLevelFilter}>
|
||||||
|
<SelectTrigger className="w-[110px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{LEVELS.map((l) => (
|
||||||
|
<SelectItem key={l} value={l}>
|
||||||
|
{l === "all" ? "All levels" : l}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="show-g"
|
||||||
|
checked={showCompleted}
|
||||||
|
onCheckedChange={setShowCompleted}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="show-g" className="text-sm">
|
||||||
|
Show completed
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
{listQ.isLoading && (
|
||||||
|
<div className="py-8 text-center text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!listQ.isLoading && filtered.length === 0 && (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="text-center text-muted-foreground py-8">
|
||||||
|
No grammar rules match.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
{filtered.map((g) => (
|
{filtered.map((g) => (
|
||||||
<Card key={g.rule} className="border-0 shadow-sm">
|
<Card key={g.id} className="border-0 shadow-sm">
|
||||||
<CardContent className="p-4 flex items-center justify-between">
|
<CardContent className="p-4 flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<button
|
||||||
{g.completed && <CheckCircle className="h-4 w-4 text-success shrink-0" />}
|
onClick={() =>
|
||||||
<div>
|
progressMut.mutate({ id: g.id, completed: !g.completed })
|
||||||
<p className="font-semibold text-sm">{g.rule}</p>
|
}
|
||||||
<p className="text-xs text-muted-foreground">{g.description}</p>
|
className="shrink-0 rounded-full p-0.5 hover:bg-muted transition"
|
||||||
</div>
|
aria-label={g.completed ? "Mark incomplete" : "Mark complete"}
|
||||||
|
>
|
||||||
|
{g.completed ? (
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Circle className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-sm">{g.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{g.description}</p>
|
||||||
|
{g.example && (
|
||||||
|
<p className="text-xs italic text-muted-foreground mt-0.5">
|
||||||
|
e.g. {g.example}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Badge variant="outline">{g.level}</Badge>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{g.category}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
onClick={() => delMut.mutate(g.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">{g.level}</Badge>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -70,25 +231,133 @@ export default function GrammarPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-base flex items-center gap-2"><Sparkles className="h-4 w-4 text-primary" /> AI Recommendations</CardTitle>
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-primary" /> AI Recommendations
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Practice passive voice transformations — high exam frequency</li>
|
<li className="text-sm text-muted-foreground flex items-start gap-2">
|
||||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Review modal verbs for IELTS Writing Task 1</li>
|
<span className="text-primary font-bold">•</span>
|
||||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Focus on complex sentence structures for C1 readiness</li>
|
Practice passive-voice transformations — high exam frequency.
|
||||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Your conditional sentences are strong — try advanced mixed conditionals</li>
|
</li>
|
||||||
|
<li className="text-sm text-muted-foreground flex items-start gap-2">
|
||||||
|
<span className="text-primary font-bold">•</span>
|
||||||
|
Review modal verbs for IELTS Writing Task 1.
|
||||||
|
</li>
|
||||||
|
<li className="text-sm text-muted-foreground flex items-start gap-2">
|
||||||
|
<span className="text-primary font-bold">•</span>
|
||||||
|
Focus on complex sentences for C1 readiness.
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
|
{summary && (
|
||||||
<CardContent className="p-4">
|
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
|
||||||
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-2"><Sparkles className="h-3 w-3" /> AI Study Plan</p>
|
<CardContent className="p-4">
|
||||||
<p className="text-sm text-muted-foreground">Based on your progress, complete Passive Voice and Subject-Verb Agreement this week. At your current pace, you'll finish all B2 grammar by April 15.</p>
|
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-2">
|
||||||
</CardContent>
|
<Sparkles className="h-3 w-3" /> AI Study Plan
|
||||||
</Card>
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{summary.remaining > 0
|
||||||
|
? `Finish the remaining ${summary.remaining} rule(s) at one per day to stay on track.`
|
||||||
|
: "You've mastered every active rule in the library."}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Grammar Rule</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Rule name</Label>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
placeholder="e.g. Present Perfect vs Past Simple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, description: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="When and how this rule applies"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Example (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={form.example}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, example: e.target.value }))}
|
||||||
|
placeholder="A sample sentence"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>CEFR level</Label>
|
||||||
|
<Select
|
||||||
|
value={form.level}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setForm((f) => ({ ...f, level: v as CefrLevel }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(LEVELS.filter((l) => l !== "all") as CefrLevel[]).map(
|
||||||
|
(l) => (
|
||||||
|
<SelectItem key={l} value={l}>
|
||||||
|
{l}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Category</Label>
|
||||||
|
<Input
|
||||||
|
value={form.category}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, category: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
!form.name.trim() ||
|
||||||
|
!form.description.trim() ||
|
||||||
|
createMut.isPending
|
||||||
|
}
|
||||||
|
onClick={() => createMut.mutate(form)}
|
||||||
|
>
|
||||||
|
{createMut.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> Adding…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Add"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default function Login() {
|
|||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="text"
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
|||||||
@@ -1,58 +1,105 @@
|
|||||||
import { useState } from "react";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import {
|
||||||
import { Label } from "@/components/ui/label";
|
Table,
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
TableBody,
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
TableCell,
|
||||||
import { Plus, Download } from "lucide-react";
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Download, Loader2 } from "lucide-react";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
import AiReportNarrative from "@/components/ai/AiReportNarrative";
|
import AiReportNarrative from "@/components/ai/AiReportNarrative";
|
||||||
|
import { paymentsService } from "@/services/payments.service";
|
||||||
const payments = [
|
|
||||||
{ id: "PAY-001", corporate: "Acme Corp", manager: "John Admin", amount: 5000, currency: "USD", paid: true, date: "2025-01-15", type: "Corporate" },
|
|
||||||
{ id: "PAY-002", corporate: "Global Ltd", manager: "HR Global", amount: 8500, currency: "USD", paid: true, date: "2025-02-01", type: "Corporate" },
|
|
||||||
{ id: "PAY-003", corporate: "Tech Co", manager: "Tech Admin", amount: 2000, currency: "USD", paid: false, date: "2025-03-01", type: "Commission" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const paymobOrders = [
|
|
||||||
{ id: "PMB-1001", status: "Paid", user: "Sarah Johnson", email: "sarah.j@email.com", amount: 199.00 },
|
|
||||||
{ id: "PMB-1002", status: "Pending", user: "Ahmed Hassan", email: "ahmed.h@email.com", amount: 149.00 },
|
|
||||||
{ id: "PMB-1003", status: "Paid", user: "Li Wei", email: "li.w@email.com", amount: 199.00 },
|
|
||||||
{ id: "PMB-1004", status: "Failed", user: "Emma Brown", email: "emma.b@email.com", amount: 99.00 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function PaymentRecordPage() {
|
export default function PaymentRecordPage() {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["payment-records"],
|
||||||
|
queryFn: () => paymentsService.list(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: paymobData } = useQuery({
|
||||||
|
queryKey: ["paymob-orders"],
|
||||||
|
queryFn: () => paymentsService.paymobOrders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payments = data?.items ?? [];
|
||||||
|
const totals = data?.totals;
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (!payments.length) return;
|
||||||
|
const header = ["Ref", "Student", "Course", "Product", "Amount", "Currency", "State", "Paid", "Date"];
|
||||||
|
const rows = payments.map((p) => [
|
||||||
|
p.ref,
|
||||||
|
p.student_name,
|
||||||
|
p.course_name,
|
||||||
|
p.product_name,
|
||||||
|
p.amount.toFixed(2),
|
||||||
|
p.currency,
|
||||||
|
p.state,
|
||||||
|
p.paid ? "yes" : "no",
|
||||||
|
p.date,
|
||||||
|
]);
|
||||||
|
const csv = [header, ...rows].map((r) => r.join(",")).join("\n");
|
||||||
|
const blob = new Blob([csv], { type: "text/csv" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `payment-records-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Payment Record</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Payment Record</h1>
|
||||||
<p className="text-muted-foreground">Manage payments, commissions, and Paymob orders.</p>
|
<p className="text-muted-foreground">
|
||||||
|
Live student fees, invoices, and Paymob orders.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm"><Download className="h-4 w-4 mr-1" /> Export CSV</Button>
|
<Button variant="outline" size="sm" onClick={handleExport} disabled={!payments.length}>
|
||||||
<Dialog>
|
<Download className="h-4 w-4 mr-1" /> Export CSV
|
||||||
<DialogTrigger asChild>
|
</Button>
|
||||||
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> New Payment</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader><DialogTitle>Create Payment Record</DialogTitle></DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2"><Label>Corporate</Label><Select><SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger><SelectContent><SelectItem value="acme">Acme Corp</SelectItem></SelectContent></Select></div>
|
|
||||||
<div className="space-y-2"><Label>Amount (USD)</Label><Input type="number" placeholder="5000" /></div>
|
|
||||||
<div className="space-y-2"><Label>Type</Label><Select><SelectTrigger><SelectValue placeholder="Type" /></SelectTrigger><SelectContent><SelectItem value="corporate">Corporate</SelectItem><SelectItem value="commission">Commission</SelectItem></SelectContent></Select></div>
|
|
||||||
<Button className="w-full">Create</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AiTipBanner tip="PAY-003 (Tech Co) is unpaid and overdue. PMB-1004 failed — AI recommends sending an automated retry notification to Emma Brown." variant="recommendation" />
|
{totals && (
|
||||||
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-xs text-muted-foreground">Total records</p>
|
||||||
|
<p className="text-2xl font-bold">{totals.count}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-xs text-muted-foreground">Paid</p>
|
||||||
|
<p className="text-2xl font-bold text-green-600">{totals.paid}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-xs text-muted-foreground">Unpaid</p>
|
||||||
|
<p className="text-2xl font-bold text-red-600">{totals.unpaid}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-xs text-muted-foreground">Total amount</p>
|
||||||
|
<p className="text-2xl font-bold">${totals.amount.toLocaleString()}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AiTipBanner context="payment-record" variant="recommendation" />
|
||||||
|
|
||||||
<Tabs defaultValue="payments">
|
<Tabs defaultValue="payments">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
@@ -61,26 +108,57 @@ export default function PaymentRecordPage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="payments" className="mt-4 space-y-4">
|
<TabsContent value="payments" className="mt-4 space-y-4">
|
||||||
<AiReportNarrative narrative="Total revenue collected: $13,500 from 2 corporate payments. One commission of $2,000 remains unpaid. Collection rate: 67%. Trend: Q1 payments are on track but Tech Co requires follow-up." />
|
<AiReportNarrative report_type="payments" data={{ payments }} />
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>ID</TableHead><TableHead>Corporate</TableHead><TableHead>Manager</TableHead>
|
<TableHead>Ref</TableHead>
|
||||||
<TableHead>Amount</TableHead><TableHead>Type</TableHead><TableHead>Paid</TableHead><TableHead>Date</TableHead>
|
<TableHead>Student</TableHead>
|
||||||
|
<TableHead>Course</TableHead>
|
||||||
|
<TableHead>Product</TableHead>
|
||||||
|
<TableHead>Amount</TableHead>
|
||||||
|
<TableHead>State</TableHead>
|
||||||
|
<TableHead>Paid</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
{isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{!isLoading && payments.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center text-muted-foreground py-8">
|
||||||
|
No payment records yet.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
{payments.map((p) => (
|
{payments.map((p) => (
|
||||||
<TableRow key={p.id}>
|
<TableRow key={p.id}>
|
||||||
<TableCell className="font-mono text-xs">{p.id}</TableCell>
|
<TableCell className="font-mono text-xs">{p.ref}</TableCell>
|
||||||
<TableCell>{p.corporate}</TableCell>
|
<TableCell>{p.student_name || "—"}</TableCell>
|
||||||
<TableCell>{p.manager}</TableCell>
|
<TableCell>{p.course_name || "—"}</TableCell>
|
||||||
<TableCell className="font-semibold">${p.amount.toLocaleString()}</TableCell>
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
<TableCell><Badge variant="outline">{p.type}</Badge></TableCell>
|
{p.product_name || "—"}
|
||||||
<TableCell><Badge variant={p.paid ? "default" : "destructive"}>{p.paid ? "Paid" : "Unpaid"}</Badge></TableCell>
|
</TableCell>
|
||||||
<TableCell>{p.date}</TableCell>
|
<TableCell className="font-semibold">
|
||||||
|
${p.amount.toLocaleString()} {p.currency}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{p.state}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={p.paid ? "default" : "destructive"}>
|
||||||
|
{p.paid ? "Paid" : "Unpaid"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{p.date || "—"}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -91,26 +169,50 @@ export default function PaymentRecordPage() {
|
|||||||
|
|
||||||
<TabsContent value="paymob" className="mt-4">
|
<TabsContent value="paymob" className="mt-4">
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-6">
|
||||||
<Table>
|
{(paymobData?.items?.length ?? 0) === 0 ? (
|
||||||
<TableHeader>
|
<div className="text-center text-muted-foreground py-8">
|
||||||
<TableRow>
|
{paymobData?.message ??
|
||||||
<TableHead>Order ID</TableHead><TableHead>Status</TableHead><TableHead>User</TableHead>
|
"Paymob integration is not yet wired. Orders will appear here once configured."}
|
||||||
<TableHead>Email</TableHead><TableHead>Amount</TableHead>
|
</div>
|
||||||
</TableRow>
|
) : (
|
||||||
</TableHeader>
|
<Table>
|
||||||
<TableBody>
|
<TableHeader>
|
||||||
{paymobOrders.map((o) => (
|
<TableRow>
|
||||||
<TableRow key={o.id}>
|
<TableHead>Order ID</TableHead>
|
||||||
<TableCell className="font-mono text-xs">{o.id}</TableCell>
|
<TableHead>Status</TableHead>
|
||||||
<TableCell><Badge variant={o.status === "Paid" ? "default" : o.status === "Pending" ? "secondary" : "destructive"}>{o.status}</Badge></TableCell>
|
<TableHead>User</TableHead>
|
||||||
<TableCell>{o.user}</TableCell>
|
<TableHead>Email</TableHead>
|
||||||
<TableCell>{o.email}</TableCell>
|
<TableHead>Amount</TableHead>
|
||||||
<TableCell className="font-semibold">${o.amount.toFixed(2)}</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{paymobData!.items.map((o) => (
|
||||||
|
<TableRow key={o.id}>
|
||||||
|
<TableCell className="font-mono text-xs">{o.id}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
o.status === "Paid"
|
||||||
|
? "default"
|
||||||
|
: o.status === "Pending"
|
||||||
|
? "secondary"
|
||||||
|
: "destructive"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{o.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{o.user}</TableCell>
|
||||||
|
<TableCell>{o.email}</TableCell>
|
||||||
|
<TableCell className="font-semibold">
|
||||||
|
${o.amount.toFixed(2)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ export default function RecordPage() {
|
|||||||
<p className="text-muted-foreground">Browse assignment and exam attempt history.</p>
|
<p className="text-muted-foreground">Browse assignment and exam attempt history.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AiTipBanner tip="The student's scores show an upward trend from 5.5 → 6.0 → 7.5 over the last 3 completed exams. Listening remains the weakest module — recommend targeted practice." variant="insight" />
|
<AiTipBanner context="record" variant="insight" />
|
||||||
|
|
||||||
<AiReportNarrative narrative="3 of 4 attempts completed with an average score of 6.3. Time management is good — all exams finished within allocated time. The Full Mock Exam is still in progress (67% time used). Strongest area: Reading (7.5), weakest: Listening (5.5)." />
|
<AiReportNarrative report_type="record" data={{ records }} />
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="Entity" /></SelectTrigger>
|
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="Entity" /></SelectTrigger>
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { GraduationCap, Eye, EyeOff, Loader2 } from "lucide-react";
|
import { GraduationCap, Eye, EyeOff, Loader2, ShieldCheck } from "lucide-react";
|
||||||
import { useRegister, useCheckEmail } from "@/hooks/queries/useSignup";
|
import { useRegister, useCheckEmail } from "@/hooks/queries/useSignup";
|
||||||
import { ApiError } from "@/lib/api-client";
|
import { ApiError, api } from "@/lib/api-client";
|
||||||
import type { RegisterRequest } from "@/types";
|
import type { RegisterRequest } from "@/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -47,6 +48,41 @@ export default function Register() {
|
|||||||
const checkEmail = useCheckEmail();
|
const checkEmail = useCheckEmail();
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const [captchaToken, setCaptchaToken] = useState<string>("");
|
||||||
|
const captchaContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { data: captchaConfig } = useQuery({
|
||||||
|
queryKey: ["captcha-config"],
|
||||||
|
queryFn: () => api.get<{ provider: string; site_key: string }>("/config/captcha"),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadCaptchaScript = useCallback((provider: string, siteKey: string) => {
|
||||||
|
if (!siteKey || !provider) return;
|
||||||
|
const existingScript = document.querySelector(`script[data-captcha-provider="${provider}"]`);
|
||||||
|
if (existingScript) return;
|
||||||
|
|
||||||
|
const scriptUrls: Record<string, string> = {
|
||||||
|
recaptcha: `https://www.google.com/recaptcha/api.js?render=${siteKey}`,
|
||||||
|
hcaptcha: "https://js.hcaptcha.com/1/api.js",
|
||||||
|
turnstile: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
||||||
|
};
|
||||||
|
const url = scriptUrls[provider];
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = url;
|
||||||
|
script.async = true;
|
||||||
|
script.defer = true;
|
||||||
|
script.setAttribute("data-captcha-provider", provider);
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (captchaConfig?.site_key && captchaConfig?.provider) {
|
||||||
|
loadCaptchaScript(captchaConfig.provider, captchaConfig.site_key);
|
||||||
|
}
|
||||||
|
}, [captchaConfig, loadCaptchaScript]);
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
@@ -69,7 +105,7 @@ export default function Register() {
|
|||||||
email: values.email.trim(),
|
email: values.email.trim(),
|
||||||
password: values.password,
|
password: values.password,
|
||||||
role: values.role,
|
role: values.role,
|
||||||
captcha_token: "demo-placeholder",
|
captcha_token: captchaToken || undefined,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await register.mutateAsync(payload);
|
await register.mutateAsync(payload);
|
||||||
@@ -251,10 +287,26 @@ export default function Register() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="rounded-lg border border-dashed bg-muted/30 p-4 space-y-2">
|
<div className="rounded-lg border border-dashed bg-muted/30 p-4 space-y-2">
|
||||||
<p className="text-sm font-medium">CAPTCHA</p>
|
<p className="text-sm font-medium flex items-center gap-2">
|
||||||
<div className="h-16 rounded-md bg-muted flex items-center justify-center text-xs text-muted-foreground">
|
<ShieldCheck className="h-4 w-4" /> Verification
|
||||||
Verification widget placeholder
|
</p>
|
||||||
|
<div ref={captchaContainerRef} className="min-h-[65px] flex items-center justify-center">
|
||||||
|
{captchaConfig?.site_key ? (
|
||||||
|
<div
|
||||||
|
className={captchaConfig.provider === "hcaptcha" ? "h-captcha" :
|
||||||
|
captchaConfig.provider === "turnstile" ? "cf-turnstile" : "g-recaptcha"}
|
||||||
|
data-sitekey={captchaConfig.site_key}
|
||||||
|
data-callback="onCaptchaSuccess"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">CAPTCHA not configured — registration allowed</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{captchaToken && (
|
||||||
|
<p className="text-xs text-green-600 flex items-center gap-1">
|
||||||
|
<ShieldCheck className="h-3 w-3" /> Verified
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{form.formState.errors.root && (
|
{form.formState.errors.root && (
|
||||||
|
|||||||
@@ -1,31 +1,528 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import {
|
||||||
import { Search, Plus } from "lucide-react";
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import { Search, Plus, Loader2, Pencil, Trash2, X, Sparkles, ChevronDown } from "lucide-react";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
||||||
|
import { examsService } from "@/services/exams.service";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
const rubrics = [
|
interface RubricItem {
|
||||||
{ id: 1, name: "IELTS Writing Task 2", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 4, created: "2025-01-05" },
|
id: number;
|
||||||
{ id: 2, name: "Speaking Fluency", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 3, created: "2025-01-10" },
|
name: string;
|
||||||
{ id: 3, name: "Reading Comprehension", levels: ["A1","A2","B1","B2","C1"], criteria: 5, created: "2025-02-01" },
|
skill: string;
|
||||||
|
exam_type: string;
|
||||||
|
criteria: number;
|
||||||
|
criteria_text: string;
|
||||||
|
levels: string[];
|
||||||
|
created: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CriterionEntry {
|
||||||
|
name: string;
|
||||||
|
weight: number;
|
||||||
|
descriptors: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKILL_OPTIONS = [
|
||||||
|
{ value: "writing", label: "Writing" },
|
||||||
|
{ value: "speaking", label: "Speaking" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const rubricGroups = [
|
const EXAM_TYPE_OPTIONS = [
|
||||||
{ id: 1, name: "Academic IELTS Full", rubrics: ["IELTS Writing Task 2", "Speaking Fluency", "Reading Comprehension"], created: "2025-02-15" },
|
{ value: "academic", label: "Academic" },
|
||||||
{ id: 2, name: "Business English", rubrics: ["Speaking Fluency"], created: "2025-03-01" },
|
{ value: "general_training", label: "General Training" },
|
||||||
|
{ value: "general_english", label: "General English" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ALL_CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"];
|
||||||
|
|
||||||
|
interface RubricFormState {
|
||||||
|
name: string;
|
||||||
|
skill: string;
|
||||||
|
exam_type: string;
|
||||||
|
criteria: CriterionEntry[];
|
||||||
|
levels: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyCriterion = (levels: Set<string>): CriterionEntry => ({
|
||||||
|
name: "",
|
||||||
|
weight: 0,
|
||||||
|
descriptors: Object.fromEntries([...levels].map((l) => [l, ""])),
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyForm = (): RubricFormState => ({
|
||||||
|
name: "",
|
||||||
|
skill: "writing",
|
||||||
|
exam_type: "academic",
|
||||||
|
criteria: [],
|
||||||
|
levels: new Set(ALL_CEFR_LEVELS),
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseCriteriaText(text: string, levels: string[]): CriterionEntry[] {
|
||||||
|
if (!text) return [];
|
||||||
|
|
||||||
|
const mapStructured = (arr: unknown[]): CriterionEntry[] =>
|
||||||
|
arr.map((c) => {
|
||||||
|
const obj = c as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
name: String(obj.name || ""),
|
||||||
|
weight: Number(obj.weight || 0),
|
||||||
|
descriptors:
|
||||||
|
typeof obj.descriptors === "object" && obj.descriptors !== null
|
||||||
|
? Object.fromEntries(levels.map((l) => [l, String((obj.descriptors as Record<string, unknown>)[l] || "")]))
|
||||||
|
: Object.fromEntries(levels.map((l) => [l, ""])),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
// Handle {"criteria": [...]} wrapper (from AI or previous saves)
|
||||||
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && Array.isArray(parsed.criteria)) {
|
||||||
|
return mapStructured(parsed.criteria);
|
||||||
|
}
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
if (parsed.length > 0 && typeof parsed[0] === "object" && parsed[0] !== null && "name" in parsed[0]) {
|
||||||
|
return mapStructured(parsed);
|
||||||
|
}
|
||||||
|
return parsed.filter(Boolean).map((name) => ({
|
||||||
|
name: String(name),
|
||||||
|
weight: Math.round(100 / parsed.length),
|
||||||
|
descriptors: Object.fromEntries(levels.map((l) => [l, ""])),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch { /* not JSON */ }
|
||||||
|
|
||||||
|
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||||
|
if (lines.length === 0) return [];
|
||||||
|
return lines.map((name) => ({
|
||||||
|
name,
|
||||||
|
weight: Math.round(100 / lines.length),
|
||||||
|
descriptors: Object.fromEntries(levels.map((l) => [l, ""])),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupFormState {
|
||||||
|
name: string;
|
||||||
|
rubric_ids: Set<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyGroupForm = (): GroupFormState => ({ name: "", rubric_ids: new Set() });
|
||||||
|
|
||||||
export default function RubricsPage() {
|
export default function RubricsPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createForm, setCreateForm] = useState<RubricFormState>(emptyForm());
|
||||||
|
|
||||||
|
const [editTarget, setEditTarget] = useState<RubricItem | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState<RubricFormState>(emptyForm());
|
||||||
|
|
||||||
|
const [groupCreateOpen, setGroupCreateOpen] = useState(false);
|
||||||
|
const [groupCreateForm, setGroupCreateForm] = useState<GroupFormState>(emptyGroupForm());
|
||||||
|
const [groupEditTarget, setGroupEditTarget] = useState<{ id: number; name: string; rubric_ids: number[] } | null>(null);
|
||||||
|
const [groupEditForm, setGroupEditForm] = useState<GroupFormState>(emptyGroupForm());
|
||||||
|
|
||||||
|
const rubricsQ = useQuery({
|
||||||
|
queryKey: ["rubrics"],
|
||||||
|
queryFn: () => examsService.listRubrics({}),
|
||||||
|
});
|
||||||
|
const rubrics = (rubricsQ.data?.items ?? []) as RubricItem[];
|
||||||
|
|
||||||
|
const rubricGroupsQ = useQuery({
|
||||||
|
queryKey: ["rubric-groups"],
|
||||||
|
queryFn: () => examsService.listRubricGroups({}),
|
||||||
|
});
|
||||||
|
const rubricGroups = rubricGroupsQ.data?.items ?? [];
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => examsService.createRubric(data as never),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["rubrics"] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
setCreateForm(emptyForm());
|
||||||
|
toast({ title: "Rubric created" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Create failed", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMut = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) =>
|
||||||
|
examsService.updateRubric(id, data as never),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["rubrics"] });
|
||||||
|
setEditTarget(null);
|
||||||
|
toast({ title: "Rubric updated" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Update failed", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMut = useMutation({
|
||||||
|
mutationFn: (id: number) => examsService.deleteRubric(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["rubrics"] });
|
||||||
|
toast({ title: "Rubric deleted" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Delete failed", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createGroupMut = useMutation({
|
||||||
|
mutationFn: (data: { name: string; rubric_ids: number[] }) => examsService.createRubricGroup(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["rubric-groups"] });
|
||||||
|
setGroupCreateOpen(false);
|
||||||
|
setGroupCreateForm(emptyGroupForm());
|
||||||
|
toast({ title: "Rubric group created" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Create group failed", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateGroupMut = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: { name?: string; rubric_ids?: number[] } }) =>
|
||||||
|
examsService.updateRubricGroup(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["rubric-groups"] });
|
||||||
|
setGroupEditTarget(null);
|
||||||
|
toast({ title: "Rubric group updated" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Update group failed", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteGroupMut = useMutation({
|
||||||
|
mutationFn: (id: number) => examsService.deleteRubricGroup(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["rubric-groups"] });
|
||||||
|
toast({ title: "Rubric group deleted" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Delete group failed", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [suggesting, setSuggesting] = useState(false);
|
||||||
|
|
||||||
|
const suggestCriteria = async (
|
||||||
|
form: RubricFormState,
|
||||||
|
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
|
||||||
|
) => {
|
||||||
|
setSuggesting(true);
|
||||||
|
try {
|
||||||
|
const result = await examsService.suggestRubricCriteria({
|
||||||
|
name: form.name,
|
||||||
|
skill: form.skill,
|
||||||
|
exam_type: form.exam_type,
|
||||||
|
levels: [...form.levels],
|
||||||
|
});
|
||||||
|
if (result.criteria && Array.isArray(result.criteria) && result.criteria.length > 0) {
|
||||||
|
const levelsArr = [...form.levels];
|
||||||
|
const entries: CriterionEntry[] = result.criteria.map((c: unknown) => {
|
||||||
|
if (typeof c === "object" && c !== null) {
|
||||||
|
const obj = c as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
name: String(obj.name || ""),
|
||||||
|
weight: Number(obj.weight || 0),
|
||||||
|
descriptors: typeof obj.descriptors === "object" && obj.descriptors !== null
|
||||||
|
? Object.fromEntries(
|
||||||
|
levelsArr.map((l) => [l, String((obj.descriptors as Record<string, unknown>)[l] || "")])
|
||||||
|
)
|
||||||
|
: Object.fromEntries(levelsArr.map((l) => [l, ""])),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { name: String(c), weight: 0, descriptors: Object.fromEntries(levelsArr.map((l) => [l, ""])) };
|
||||||
|
});
|
||||||
|
setForm((f) => ({ ...f, criteria: entries }));
|
||||||
|
toast({ title: "Criteria generated", description: `${entries.length} criteria with band descriptors created by AI.` });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast({ variant: "destructive", title: "AI suggestion failed", description: (err as Error).message });
|
||||||
|
} finally {
|
||||||
|
setSuggesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (r: RubricItem) => {
|
||||||
|
const levelsArr = Array.isArray(r.levels) && r.levels.length > 0 ? r.levels : ALL_CEFR_LEVELS;
|
||||||
|
setEditTarget(r);
|
||||||
|
setEditForm({
|
||||||
|
name: r.name,
|
||||||
|
skill: r.skill || "writing",
|
||||||
|
exam_type: r.exam_type || "academic",
|
||||||
|
criteria: parseCriteriaText(r.criteria_text, levelsArr),
|
||||||
|
levels: new Set(levelsArr),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formToPayload = (form: RubricFormState) => ({
|
||||||
|
name: form.name.trim(),
|
||||||
|
skill: form.skill,
|
||||||
|
exam_type: form.exam_type,
|
||||||
|
criteria: JSON.stringify(form.criteria.filter((c) => c.name.trim())),
|
||||||
|
levels: [...form.levels],
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = rubrics.filter((r) => {
|
||||||
|
if (!search) return true;
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return r.name?.toLowerCase().includes(q) || r.skill?.toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCriterion = (
|
||||||
|
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
|
||||||
|
index: number,
|
||||||
|
patch: Partial<CriterionEntry>,
|
||||||
|
) => {
|
||||||
|
setForm((f) => {
|
||||||
|
const criteria = [...f.criteria];
|
||||||
|
criteria[index] = { ...criteria[index], ...patch };
|
||||||
|
return { ...f, criteria };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDescriptor = (
|
||||||
|
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
|
||||||
|
cIndex: number,
|
||||||
|
level: string,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
setForm((f) => {
|
||||||
|
const criteria = [...f.criteria];
|
||||||
|
criteria[cIndex] = {
|
||||||
|
...criteria[cIndex],
|
||||||
|
descriptors: { ...criteria[cIndex].descriptors, [level]: value },
|
||||||
|
};
|
||||||
|
return { ...f, criteria };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderForm = (
|
||||||
|
form: RubricFormState,
|
||||||
|
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
|
||||||
|
) => {
|
||||||
|
const levelsArr = [...form.levels].sort(
|
||||||
|
(a, b) => ALL_CEFR_LEVELS.indexOf(a) - ALL_CEFR_LEVELS.indexOf(b)
|
||||||
|
);
|
||||||
|
const totalWeight = form.criteria.reduce((s, c) => s + (c.weight || 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Rubric Name <span className="text-destructive">*</span></Label>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
placeholder="e.g. IELTS Writing Task 1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Skill</Label>
|
||||||
|
<Select value={form.skill} onValueChange={(v) => setForm((f) => ({ ...f, skill: v }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SKILL_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Exam Type</Label>
|
||||||
|
<Select value={form.exam_type} onValueChange={(v) => setForm((f) => ({ ...f, exam_type: v }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{EXAM_TYPE_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>CEFR Levels</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ALL_CEFR_LEVELS.map((level) => {
|
||||||
|
const checked = form.levels.has(level);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={level}
|
||||||
|
className={`flex items-center gap-1.5 rounded-md border px-3 py-1.5 cursor-pointer transition-all text-sm font-medium ${
|
||||||
|
checked ? "bg-primary/10 border-primary text-primary" : "hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(val) => {
|
||||||
|
setForm((f) => {
|
||||||
|
const next = new Set(f.levels);
|
||||||
|
if (val) next.add(level); else next.delete(level);
|
||||||
|
return { ...f, levels: next };
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{level}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>Criteria</Label>
|
||||||
|
{form.criteria.length > 0 && (
|
||||||
|
<span className={`ml-2 text-xs ${totalWeight === 100 ? "text-green-600" : "text-amber-600"}`}>
|
||||||
|
(Total weight: {totalWeight}%)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 bg-gradient-to-r from-violet-500 to-indigo-500 text-white hover:from-violet-600 hover:to-indigo-600"
|
||||||
|
disabled={suggesting}
|
||||||
|
onClick={() => suggestCriteria(form, setForm)}
|
||||||
|
>
|
||||||
|
{suggesting
|
||||||
|
? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> Generating...</>
|
||||||
|
: <><Sparkles className="h-3.5 w-3.5 mr-1.5" /> Generate with AI</>
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{suggesting ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 gap-2 text-muted-foreground rounded-lg border border-dashed border-violet-300 bg-violet-50/50">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-violet-500" />
|
||||||
|
<p className="text-sm font-medium">AI is generating criteria with band descriptors...</p>
|
||||||
|
<p className="text-xs">Based on {form.skill} / {form.exam_type.replace(/_/g, " ")}</p>
|
||||||
|
</div>
|
||||||
|
) : form.criteria.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 gap-2 text-muted-foreground rounded-lg border border-dashed">
|
||||||
|
<Sparkles className="h-5 w-5 text-violet-400" />
|
||||||
|
<p className="text-sm">No criteria yet.</p>
|
||||||
|
<p className="text-xs">Click <strong>Generate with AI</strong> to create criteria automatically, or add manually.</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-1"
|
||||||
|
onClick={() => setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" /> Add Manually
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{form.criteria.map((c, ci) => (
|
||||||
|
<Collapsible key={ci} defaultOpen={form.criteria.length <= 3}>
|
||||||
|
<div className="rounded-lg border bg-card">
|
||||||
|
<div className="flex items-center gap-2 p-3">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<Input
|
||||||
|
value={c.name}
|
||||||
|
onChange={(e) => updateCriterion(setForm, ci, { name: e.target.value })}
|
||||||
|
placeholder="Criterion name"
|
||||||
|
className="flex-1 h-8 font-medium"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={c.weight}
|
||||||
|
onChange={(e) => updateCriterion(setForm, ci, { weight: Number(e.target.value) })}
|
||||||
|
className="w-16 h-8 text-center text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">%</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => setForm((f) => ({ ...f, criteria: f.criteria.filter((_, i) => i !== ci) }))}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="px-3 pb-3 pt-0 border-t">
|
||||||
|
<p className="text-xs text-muted-foreground my-2">Band level descriptors:</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{levelsArr.map((level) => (
|
||||||
|
<div key={level} className="flex gap-2 items-start">
|
||||||
|
<Badge variant="outline" className="mt-1.5 shrink-0 w-9 justify-center text-xs">{level}</Badge>
|
||||||
|
<Textarea
|
||||||
|
value={c.descriptors[level] || ""}
|
||||||
|
onChange={(e) => updateDescriptor(setForm, ci, level, e.target.value)}
|
||||||
|
placeholder={`Performance descriptor for ${level}...`}
|
||||||
|
className="min-h-[40px] h-10 text-sm resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" /> Add Criterion
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -35,25 +532,25 @@ export default function RubricsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<AiCreationAssistant type="rubric" />
|
<AiCreationAssistant type="rubric" />
|
||||||
<Dialog>
|
<Dialog open={createOpen} onOpenChange={(open) => { setCreateOpen(open); if (!open) setCreateForm(emptyForm()); }}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Rubric</Button>
|
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Rubric</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||||
<DialogHeader><DialogTitle>Create Rubric</DialogTitle></DialogHeader>
|
<DialogHeader>
|
||||||
<div className="space-y-4">
|
<DialogTitle>Create Rubric</DialogTitle>
|
||||||
<div className="space-y-2"><Label>Rubric Name</Label><Input placeholder="e.g. IELTS Writing Task 1" /></div>
|
<DialogDescription>Define a scoring rubric. Use AI to generate criteria or add them manually.</DialogDescription>
|
||||||
<div className="space-y-3">
|
</DialogHeader>
|
||||||
<Label>Level Descriptors</Label>
|
{renderForm(createForm, setCreateForm)}
|
||||||
{["A1","A2","B1","B2","C1","C2"].map(level => (
|
<DialogFooter>
|
||||||
<div key={level} className="space-y-1">
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||||
<Label className="text-xs text-muted-foreground">{level}</Label>
|
<Button
|
||||||
<Textarea placeholder={`Descriptor for level ${level}...`} className="h-16" />
|
disabled={!createForm.name.trim() || createForm.criteria.filter((c) => c.name.trim()).length === 0 || createMut.isPending}
|
||||||
</div>
|
onClick={() => createMut.mutate(formToPayload(createForm))}
|
||||||
))}
|
>
|
||||||
</div>
|
{createMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Create"}
|
||||||
<Button className="w-full">Create Rubric</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,51 +569,248 @@ export default function RubricsPage() {
|
|||||||
<TabsTrigger value="groups">Rubric Groups</TabsTrigger>
|
<TabsTrigger value="groups">Rubric Groups</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="rubrics" className="mt-4">
|
<TabsContent value="rubrics" className="mt-4">
|
||||||
<Card className="border-0 shadow-sm">
|
{rubricsQ.isLoading && (
|
||||||
<CardContent className="p-0">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Table>
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
<TableHeader>
|
</div>
|
||||||
<TableRow>
|
)}
|
||||||
<TableHead>Name</TableHead><TableHead>Levels</TableHead><TableHead>Criteria</TableHead><TableHead>Created</TableHead>
|
{rubricsQ.error && (
|
||||||
</TableRow>
|
<Card className="border-destructive">
|
||||||
</TableHeader>
|
<CardContent className="p-4 text-sm text-destructive">
|
||||||
<TableBody>
|
Failed to load rubrics. The backend endpoint may not be available yet.
|
||||||
{rubrics.map((r) => (
|
</CardContent>
|
||||||
<TableRow key={r.id}>
|
</Card>
|
||||||
<TableCell className="font-medium">{r.name}</TableCell>
|
)}
|
||||||
<TableCell><div className="flex gap-1">{r.levels.map(l => <Badge key={l} variant="outline" className="text-xs">{l}</Badge>)}</div></TableCell>
|
{!rubricsQ.isLoading && !rubricsQ.error && filtered.length === 0 && (
|
||||||
<TableCell>{r.criteria}</TableCell>
|
<Card className="border-dashed">
|
||||||
<TableCell>{r.created}</TableCell>
|
<CardContent className="p-8 text-center text-muted-foreground">
|
||||||
|
No rubrics found. Create one to get started.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{!rubricsQ.isLoading && filtered.length > 0 && (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Skill</TableHead>
|
||||||
|
<TableHead>Exam Type</TableHead>
|
||||||
|
<TableHead>Levels</TableHead>
|
||||||
|
<TableHead>Criteria</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-[90px]">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{filtered.map((r) => (
|
||||||
</CardContent>
|
<TableRow key={r.id} className="cursor-pointer hover:bg-muted/50" onClick={() => openEdit(r)}>
|
||||||
</Card>
|
<TableCell className="font-medium">{r.name}</TableCell>
|
||||||
|
<TableCell><Badge variant="secondary" className="capitalize">{r.skill || "—"}</Badge></TableCell>
|
||||||
|
<TableCell className="capitalize text-sm">{r.exam_type?.replace(/_/g, " ") || "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(r.levels || ALL_CEFR_LEVELS).map((l) => (
|
||||||
|
<Badge key={l} variant="outline" className="text-xs">{l}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{r.criteria}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{r.created}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8"
|
||||||
|
onClick={(e) => { e.stopPropagation(); openEdit(r); }}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"
|
||||||
|
onClick={(e) => { e.stopPropagation(); deleteMut.mutate(r.id); }}
|
||||||
|
disabled={deleteMut.isPending}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="groups" className="mt-4">
|
<TabsContent value="groups" className="mt-4">
|
||||||
<Card className="border-0 shadow-sm">
|
<div className="flex justify-end mb-3">
|
||||||
<CardContent className="p-0">
|
<Button size="sm" onClick={() => { setGroupCreateForm(emptyGroupForm()); setGroupCreateOpen(true); }}>
|
||||||
<Table>
|
<Plus className="h-4 w-4 mr-1" /> Create Group
|
||||||
<TableHeader>
|
</Button>
|
||||||
<TableRow>
|
</div>
|
||||||
<TableHead>Group Name</TableHead><TableHead>Rubrics</TableHead><TableHead>Created</TableHead>
|
{rubricGroupsQ.isLoading && (
|
||||||
</TableRow>
|
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
|
||||||
</TableHeader>
|
)}
|
||||||
<TableBody>
|
{!rubricGroupsQ.isLoading && rubricGroups.length === 0 && (
|
||||||
{rubricGroups.map((g) => (
|
<Card className="border-dashed"><CardContent className="p-8 text-center text-muted-foreground text-sm">No rubric groups yet. Create one to bundle rubrics together.</CardContent></Card>
|
||||||
<TableRow key={g.id}>
|
)}
|
||||||
<TableCell className="font-medium">{g.name}</TableCell>
|
{!rubricGroupsQ.isLoading && rubricGroups.length > 0 && (
|
||||||
<TableCell><div className="flex gap-1 flex-wrap">{g.rubrics.map(r => <Badge key={r} variant="secondary" className="text-xs">{r}</Badge>)}</div></TableCell>
|
<Card className="border-0 shadow-sm">
|
||||||
<TableCell>{g.created}</TableCell>
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Group Name</TableHead>
|
||||||
|
<TableHead>Rubrics</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-24 text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{rubricGroups.map((g) => (
|
||||||
</CardContent>
|
<TableRow key={g.id}>
|
||||||
</Card>
|
<TableCell className="font-medium">{g.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{(g.rubric_names ?? []).map((rb) => (
|
||||||
|
<Badge key={rb} variant="secondary" className="text-xs">{rb}</Badge>
|
||||||
|
))}
|
||||||
|
{(!g.rubric_names || g.rubric_names.length === 0) && (
|
||||||
|
<span className="text-xs text-muted-foreground italic">No rubrics</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{g.created}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => {
|
||||||
|
setGroupEditTarget({ id: g.id, name: g.name, rubric_ids: g.rubric_ids });
|
||||||
|
setGroupEditForm({ name: g.name, rubric_ids: new Set(g.rubric_ids) });
|
||||||
|
}}>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => { if (confirm(`Delete group "${g.name}"?`)) deleteGroupMut.mutate(g.id); }}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Group Dialog */}
|
||||||
|
<Dialog open={groupCreateOpen} onOpenChange={setGroupCreateOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Rubric Group</DialogTitle>
|
||||||
|
<DialogDescription>Bundle multiple rubrics into a group for easy selection.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Group Name <span className="text-destructive">*</span></Label>
|
||||||
|
<Input value={groupCreateForm.name} onChange={(e) => setGroupCreateForm((p) => ({ ...p, name: e.target.value }))} placeholder="e.g. Academic IELTS Full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Select Rubrics</Label>
|
||||||
|
<div className="max-h-48 overflow-y-auto border rounded-md p-2 space-y-1">
|
||||||
|
{rubrics.map((r) => (
|
||||||
|
<label key={r.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
||||||
|
<Checkbox checked={groupCreateForm.rubric_ids.has(r.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setGroupCreateForm((p) => {
|
||||||
|
const next = new Set(p.rubric_ids);
|
||||||
|
checked ? next.add(r.id) : next.delete(r.id);
|
||||||
|
return { ...p, rubric_ids: next };
|
||||||
|
});
|
||||||
|
}} className="h-3.5 w-3.5" />
|
||||||
|
{r.name}
|
||||||
|
<Badge variant="outline" className="text-[10px] ml-auto">{r.skill}</Badge>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{rubrics.length === 0 && <p className="text-xs text-muted-foreground italic py-2 text-center">No rubrics available</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setGroupCreateOpen(false)}>Cancel</Button>
|
||||||
|
<Button disabled={!groupCreateForm.name.trim() || createGroupMut.isPending}
|
||||||
|
onClick={() => createGroupMut.mutate({ name: groupCreateForm.name.trim(), rubric_ids: [...groupCreateForm.rubric_ids] })}>
|
||||||
|
{createGroupMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Create Group"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit Group Dialog */}
|
||||||
|
<Dialog open={!!groupEditTarget} onOpenChange={(open) => { if (!open) setGroupEditTarget(null); }}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Rubric Group</DialogTitle>
|
||||||
|
<DialogDescription>Update the group name and selected rubrics.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Group Name <span className="text-destructive">*</span></Label>
|
||||||
|
<Input value={groupEditForm.name} onChange={(e) => setGroupEditForm((p) => ({ ...p, name: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Select Rubrics</Label>
|
||||||
|
<div className="max-h-48 overflow-y-auto border rounded-md p-2 space-y-1">
|
||||||
|
{rubrics.map((r) => (
|
||||||
|
<label key={r.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
||||||
|
<Checkbox checked={groupEditForm.rubric_ids.has(r.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setGroupEditForm((p) => {
|
||||||
|
const next = new Set(p.rubric_ids);
|
||||||
|
checked ? next.add(r.id) : next.delete(r.id);
|
||||||
|
return { ...p, rubric_ids: next };
|
||||||
|
});
|
||||||
|
}} className="h-3.5 w-3.5" />
|
||||||
|
{r.name}
|
||||||
|
<Badge variant="outline" className="text-[10px] ml-auto">{r.skill}</Badge>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setGroupEditTarget(null)}>Cancel</Button>
|
||||||
|
<Button disabled={!groupEditForm.name.trim() || updateGroupMut.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!groupEditTarget) return;
|
||||||
|
updateGroupMut.mutate({ id: groupEditTarget.id, data: { name: groupEditForm.name.trim(), rubric_ids: [...groupEditForm.rubric_ids] } });
|
||||||
|
}}>
|
||||||
|
{updateGroupMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Saving...</> : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
<Dialog open={!!editTarget} onOpenChange={(open) => { if (!open) setEditTarget(null); }}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Rubric</DialogTitle>
|
||||||
|
<DialogDescription>Update the rubric details below. Use AI to regenerate criteria.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{renderForm(editForm, setEditForm)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditTarget(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!editForm.name.trim() || editForm.criteria.filter((c) => c.name.trim()).length === 0 || updateMut.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!editTarget) return;
|
||||||
|
updateMut.mutate({ id: editTarget.id, data: formToPayload(editForm) });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{updateMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Saving...</> : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,142 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import {
|
||||||
import { Plus, Trash2, Copy } from "lucide-react";
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Plus, Trash2, Copy, Loader2 } from "lucide-react";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
const codes = [
|
import {
|
||||||
{ code: "ENCOACH-2025-A1B2", type: "Single", used: false, created: "2025-01-10" },
|
platformSettingsService,
|
||||||
{ code: "ENCOACH-2025-C3D4", type: "Single", used: true, created: "2025-01-12" },
|
type GradingConfig,
|
||||||
{ code: "BATCH-2025-E5F6", type: "Batch", used: false, created: "2025-02-01" },
|
type PlatformPackage,
|
||||||
{ code: "BATCH-2025-G7H8", type: "Batch", used: false, created: "2025-02-01" },
|
type RegistrationCode,
|
||||||
];
|
} from "@/services/platformSettings.service";
|
||||||
|
|
||||||
const packages = [
|
|
||||||
{ id: 1, name: "IELTS Starter", price: 99, duration: "1 month", discount: 0 },
|
|
||||||
{ id: 2, name: "IELTS Pro", price: 249, duration: "3 months", discount: 15 },
|
|
||||||
{ id: 3, name: "Corporate Bundle", price: 1999, duration: "12 months", discount: 25 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
// ── Codes ──────────────────────────────────────────────────────────
|
||||||
|
const codesQ = useQuery({
|
||||||
|
queryKey: ["codes"],
|
||||||
|
queryFn: () => platformSettingsService.listCodes(),
|
||||||
|
});
|
||||||
|
const codes: RegistrationCode[] = codesQ.data?.items ?? [];
|
||||||
|
|
||||||
|
const genMut = useMutation({
|
||||||
|
mutationFn: platformSettingsService.generateCodes,
|
||||||
|
onSuccess: (r) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["codes"] });
|
||||||
|
toast({ title: `Generated ${r.count} code(s)` });
|
||||||
|
},
|
||||||
|
onError: () =>
|
||||||
|
toast({ title: "Failed to generate codes", variant: "destructive" }),
|
||||||
|
});
|
||||||
|
const delCodeMut = useMutation({
|
||||||
|
mutationFn: platformSettingsService.deleteCode,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["codes"] });
|
||||||
|
toast({ title: "Code deleted" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Packages ───────────────────────────────────────────────────────
|
||||||
|
const pkgQ = useQuery({
|
||||||
|
queryKey: ["packages"],
|
||||||
|
queryFn: () => platformSettingsService.listPackages(),
|
||||||
|
});
|
||||||
|
const packages: PlatformPackage[] = pkgQ.data?.items ?? [];
|
||||||
|
const [pkgOpen, setPkgOpen] = useState(false);
|
||||||
|
const [pkgForm, setPkgForm] = useState<Omit<PlatformPackage, "id">>({
|
||||||
|
name: "",
|
||||||
|
price: 0,
|
||||||
|
duration: "1 month",
|
||||||
|
discount: 0,
|
||||||
|
});
|
||||||
|
const createPkgMut = useMutation({
|
||||||
|
mutationFn: platformSettingsService.createPackage,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["packages"] });
|
||||||
|
setPkgOpen(false);
|
||||||
|
setPkgForm({ name: "", price: 0, duration: "1 month", discount: 0 });
|
||||||
|
toast({ title: "Package created" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const delPkgMut = useMutation({
|
||||||
|
mutationFn: platformSettingsService.deletePackage,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["packages"] });
|
||||||
|
toast({ title: "Package deleted" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Grading config ─────────────────────────────────────────────────
|
||||||
|
const gradingQ = useQuery({
|
||||||
|
queryKey: ["grading-config"],
|
||||||
|
queryFn: () => platformSettingsService.getGrading(),
|
||||||
|
});
|
||||||
|
const [grading, setGrading] = useState<GradingConfig>({
|
||||||
|
min_score: 0,
|
||||||
|
max_score: 9,
|
||||||
|
increment: 0.5,
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (gradingQ.data) {
|
||||||
|
setGrading({
|
||||||
|
min_score: gradingQ.data.min_score,
|
||||||
|
max_score: gradingQ.data.max_score,
|
||||||
|
increment: gradingQ.data.increment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [gradingQ.data]);
|
||||||
|
const saveGradingMut = useMutation({
|
||||||
|
mutationFn: platformSettingsService.setGrading,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["grading-config"] });
|
||||||
|
toast({ title: "Grading configuration saved" });
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const msg =
|
||||||
|
err && typeof err === "object" && "message" in err
|
||||||
|
? String((err as { message: unknown }).message)
|
||||||
|
: "Failed to save";
|
||||||
|
toast({ title: msg, variant: "destructive" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
||||||
<p className="text-muted-foreground">Manage codes, packages, discounts, and grading system.</p>
|
<p className="text-muted-foreground">
|
||||||
|
Manage registration codes, packages, and the grading scale.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="codes">
|
<Tabs defaultValue="codes">
|
||||||
@@ -37,26 +146,107 @@ export default function SettingsPage() {
|
|||||||
<TabsTrigger value="grading">Grading System</TabsTrigger>
|
<TabsTrigger value="grading">Grading System</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
{/* CODES */}
|
||||||
<TabsContent value="codes" className="mt-4 space-y-4">
|
<TabsContent value="codes" className="mt-4 space-y-4">
|
||||||
<AiTipBanner tip="2 batch codes have been unused for over 30 days. Consider sending reminder emails to the assigned entities or recycling unused codes." variant="insight" />
|
<AiTipBanner context="settings-codes" variant="insight" />
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Generate Single</Button>
|
<Button
|
||||||
<Button size="sm" variant="outline"><Copy className="h-4 w-4 mr-1" /> Generate Batch</Button>
|
size="sm"
|
||||||
|
disabled={genMut.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
genMut.mutate({
|
||||||
|
count: 1,
|
||||||
|
code_type: "individual",
|
||||||
|
user_type: "student",
|
||||||
|
max_uses: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{genMut.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
)}{" "}
|
||||||
|
Generate Single
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={genMut.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
genMut.mutate({
|
||||||
|
count: 10,
|
||||||
|
code_type: "corporate",
|
||||||
|
user_type: "corporate",
|
||||||
|
max_uses: 50,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4 mr-1" /> Generate Batch (10)
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow><TableHead>Code</TableHead><TableHead>Type</TableHead><TableHead>Used</TableHead><TableHead>Created</TableHead><TableHead className="w-10"></TableHead></TableRow>
|
<TableRow>
|
||||||
|
<TableHead>Code</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Uses</TableHead>
|
||||||
|
<TableHead>State</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-10"></TableHead>
|
||||||
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
{codesQ.isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{!codesQ.isLoading && codes.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={7}
|
||||||
|
className="text-center text-muted-foreground py-8"
|
||||||
|
>
|
||||||
|
No codes yet. Click Generate Single to create one.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
{codes.map((c) => (
|
{codes.map((c) => (
|
||||||
<TableRow key={c.code}>
|
<TableRow key={c.id}>
|
||||||
<TableCell className="font-mono text-xs">{c.code}</TableCell>
|
<TableCell className="font-mono text-xs">{c.code}</TableCell>
|
||||||
<TableCell><Badge variant="outline">{c.type}</Badge></TableCell>
|
<TableCell>
|
||||||
<TableCell><Badge variant={c.used ? "secondary" : "default"}>{c.used ? "Used" : "Available"}</Badge></TableCell>
|
<Badge variant="outline">{c.code_type}</Badge>
|
||||||
<TableCell>{c.created}</TableCell>
|
</TableCell>
|
||||||
<TableCell><Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"><Trash2 className="h-4 w-4" /></Button></TableCell>
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{c.user_type}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{c.uses}/{c.max_uses}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={c.used ? "secondary" : "default"}>
|
||||||
|
{c.used ? "Used" : "Available"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{c.created ? new Date(c.created).toLocaleDateString() : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive"
|
||||||
|
onClick={() => delCodeMut.mutate(c.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -65,35 +255,186 @@ export default function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* PACKAGES */}
|
||||||
<TabsContent value="packages" className="mt-4 space-y-4">
|
<TabsContent value="packages" className="mt-4 space-y-4">
|
||||||
<AiTipBanner tip="Based on conversion data, the IELTS Pro package has the highest ROI. Consider increasing the Corporate Bundle discount to 30% to boost enterprise sign-ups." variant="recommendation" />
|
<AiTipBanner context="settings-packages" variant="recommendation" />
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" onClick={() => setPkgOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" /> Add Package
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{pkgQ.isLoading && <Loader2 className="h-5 w-5 animate-spin" />}
|
||||||
{packages.map((p) => (
|
{packages.map((p) => (
|
||||||
<Card key={p.id} className="border-0 shadow-sm">
|
<Card key={p.id} className="border-0 shadow-sm">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3 flex-row items-start justify-between">
|
||||||
<CardTitle className="text-base">{p.name}</CardTitle>
|
<CardTitle className="text-base">{p.name}</CardTitle>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
onClick={() => delPkgMut.mutate(p.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
<p className="text-2xl font-bold">${p.price}</p>
|
<p className="text-2xl font-bold">${p.price}</p>
|
||||||
<p className="text-sm text-muted-foreground">{p.duration}</p>
|
<p className="text-sm text-muted-foreground">{p.duration}</p>
|
||||||
{p.discount > 0 && <Badge variant="default">{p.discount}% OFF</Badge>}
|
{p.discount > 0 && (
|
||||||
|
<Badge variant="default">{p.discount}% OFF</Badge>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={pkgOpen} onOpenChange={setPkgOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New Package</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input
|
||||||
|
value={pkgForm.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPkgForm((f) => ({ ...f, name: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Price (USD)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={pkgForm.price}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPkgForm((f) => ({
|
||||||
|
...f,
|
||||||
|
price: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Discount %</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={pkgForm.discount}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPkgForm((f) => ({
|
||||||
|
...f,
|
||||||
|
discount: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Duration</Label>
|
||||||
|
<Select
|
||||||
|
value={pkgForm.duration}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setPkgForm((f) => ({ ...f, duration: v }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1 month">1 month</SelectItem>
|
||||||
|
<SelectItem value="3 months">3 months</SelectItem>
|
||||||
|
<SelectItem value="6 months">6 months</SelectItem>
|
||||||
|
<SelectItem value="12 months">12 months</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setPkgOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!pkgForm.name || createPkgMut.isPending}
|
||||||
|
onClick={() => createPkgMut.mutate(pkgForm)}
|
||||||
|
>
|
||||||
|
{createPkgMut.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> Creating…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Create"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* GRADING */}
|
||||||
<TabsContent value="grading" className="mt-4 space-y-4">
|
<TabsContent value="grading" className="mt-4 space-y-4">
|
||||||
<AiTipBanner tip="Current 0.5 increment scoring aligns with official IELTS band scoring. AI recommends keeping this configuration for standardised assessment." variant="tip" />
|
<AiTipBanner context="settings-grading" variant="tip" />
|
||||||
<Card className="border-0 shadow-sm max-w-lg">
|
<Card className="border-0 shadow-sm max-w-lg">
|
||||||
<CardHeader><CardTitle className="text-base">Scoring Scale</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Scoring Scale</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-2"><Label>Min Score</Label><Input type="number" defaultValue="0" /></div>
|
<div className="space-y-2">
|
||||||
<div className="space-y-2"><Label>Max Score</Label><Input type="number" defaultValue="9" /></div>
|
<Label>Min Score</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={grading.min_score}
|
||||||
|
onChange={(e) =>
|
||||||
|
setGrading((g) => ({
|
||||||
|
...g,
|
||||||
|
min_score: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Max Score</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={grading.max_score}
|
||||||
|
onChange={(e) =>
|
||||||
|
setGrading((g) => ({
|
||||||
|
...g,
|
||||||
|
max_score: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2"><Label>Score Increment</Label><Input type="number" defaultValue="0.5" step="0.5" /></div>
|
<div className="space-y-2">
|
||||||
<Button>Save Grading Configuration</Button>
|
<Label>Score Increment</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
value={grading.increment}
|
||||||
|
onChange={(e) =>
|
||||||
|
setGrading((g) => ({
|
||||||
|
...g,
|
||||||
|
increment: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
disabled={saveGradingMut.isPending}
|
||||||
|
onClick={() => saveGradingMut.mutate(grading)}
|
||||||
|
>
|
||||||
|
{saveGradingMut.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> Saving…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save Grading Configuration"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -6,13 +6,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, PieChart, Pie, Cell } from "recharts";
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, PieChart, Pie, Cell } from "recharts";
|
||||||
import AiReportNarrative from "@/components/ai/AiReportNarrative";
|
import AiReportNarrative from "@/components/ai/AiReportNarrative";
|
||||||
|
|
||||||
const tabNarratives: Record<string, string> = {
|
|
||||||
overview: "Writing scores (61%) are significantly lower than other modules. Consider allocating more teaching resources to writing workshops. Reading leads at 72%, suggesting current materials are effective.",
|
|
||||||
trends: "Scores have shown a consistent upward trend of +14 points over 6 months. The plateau in April correlates with mid-term exam stress. June's 72% is the highest recorded average this year.",
|
|
||||||
distribution: "B1 is the largest cohort at 30%, indicating most students are at intermediate level. Only 3% reach C2 — consider creating more advanced pathways to support progression from C1.",
|
|
||||||
comparison: "Attendance dropped 8% in the second week of March, correlating with the mid-term assignment deadline. Consider spacing deadlines more evenly across the term.",
|
|
||||||
};
|
|
||||||
|
|
||||||
const thresholds = ["0%", "50%", "70%", "90%"];
|
const thresholds = ["0%", "50%", "70%", "90%"];
|
||||||
|
|
||||||
const barData = [
|
const barData = [
|
||||||
@@ -69,7 +62,7 @@ export default function StatsCorporatePage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="overview" className="mt-4">
|
<TabsContent value="overview" className="mt-4">
|
||||||
<AiReportNarrative narrative={tabNarratives.overview} />
|
<AiReportNarrative report_type="corporate-overview" data={{ modules: barData }} />
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader><CardTitle className="text-base">Average Score by Module</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-base">Average Score by Module</CardTitle></CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -87,7 +80,7 @@ export default function StatsCorporatePage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="trends" className="mt-4">
|
<TabsContent value="trends" className="mt-4">
|
||||||
<AiReportNarrative narrative={tabNarratives.trends} />
|
<AiReportNarrative report_type="corporate-trends" data={{ trends: trendData }} />
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader><CardTitle className="text-base">Score Trend Over Time</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-base">Score Trend Over Time</CardTitle></CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -105,7 +98,7 @@ export default function StatsCorporatePage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="distribution" className="mt-4">
|
<TabsContent value="distribution" className="mt-4">
|
||||||
<AiReportNarrative narrative={tabNarratives.distribution} />
|
<AiReportNarrative report_type="corporate-distribution" data={{ distribution: distData }} />
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader><CardTitle className="text-base">Level Distribution</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-base">Level Distribution</CardTitle></CardHeader>
|
||||||
<CardContent className="flex justify-center">
|
<CardContent className="flex justify-center">
|
||||||
@@ -122,7 +115,7 @@ export default function StatsCorporatePage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="comparison" className="mt-4">
|
<TabsContent value="comparison" className="mt-4">
|
||||||
<AiReportNarrative narrative={tabNarratives.comparison} />
|
<AiReportNarrative report_type="corporate-comparison" data={{ threshold }} />
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardContent className="p-8 text-center text-muted-foreground">Entity comparison charts will appear here based on selected filters.</CardContent>
|
<CardContent className="p-8 text-center text-muted-foreground">Entity comparison charts will appear here based on selected filters.</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default function TicketsPage() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const tickets: Ticket[] = data?.data ?? [];
|
const tickets: Ticket[] = data?.items ?? data?.data ?? [];
|
||||||
|
|
||||||
const createMut = useMutation({
|
const createMut = useMutation({
|
||||||
mutationFn: ticketsService.create,
|
mutationFn: ticketsService.create,
|
||||||
|
|||||||
@@ -3,151 +3,153 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Search, Plus, Download, MoreHorizontal } from "lucide-react";
|
import { Search, Plus, Download, Shield, Pencil, UserCog } from "lucide-react";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useStudents, useTeachers, useCreateStudent, useCreateTeacher } from "@/hooks/queries";
|
import { api } from "@/lib/api-client";
|
||||||
import { lmsService } from "@/services/lms.service";
|
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { ApiError } from "@/lib/api-client";
|
|
||||||
|
|
||||||
function messageFromCreateError(err: unknown): string {
|
interface PlatformUser {
|
||||||
if (err instanceof ApiError && err.data && typeof err.data === "object" && err.data !== null && "error" in err.data) {
|
id: number;
|
||||||
const e = (err.data as { error: unknown }).error;
|
name: string;
|
||||||
if (e != null && String(e).trim()) return String(e);
|
email: string;
|
||||||
}
|
login: string;
|
||||||
if (err instanceof Error) return err.message;
|
user_type: string;
|
||||||
return "Something went wrong.";
|
active: boolean;
|
||||||
|
phone: string;
|
||||||
|
role_ids: number[];
|
||||||
|
roles: { id: number; name: string }[];
|
||||||
|
effective_permission_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toastForCreateUserError(err: unknown): { title: string; description: string; variant?: "default" | "destructive" } {
|
interface RoleInfo {
|
||||||
const msg = messageFromCreateError(err).toLowerCase();
|
id: number;
|
||||||
const isDuplicate =
|
name: string;
|
||||||
msg.includes("already exists") ||
|
description: string;
|
||||||
msg.includes("duplicate") ||
|
assigned: boolean;
|
||||||
msg.includes("unique") ||
|
permission_count: number;
|
||||||
msg.includes("already registered");
|
|
||||||
if (isDuplicate) {
|
|
||||||
return {
|
|
||||||
title: "Email already in use",
|
|
||||||
description:
|
|
||||||
messageFromCreateError(err) +
|
|
||||||
" Try another address, or find the existing user in the Students or Teachers tab.",
|
|
||||||
variant: "destructive",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { title: "Could not create user", description: messageFromCreateError(err), variant: "destructive" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
|
admin: "bg-red-100 text-red-800 border-red-200",
|
||||||
|
teacher: "bg-blue-100 text-blue-800 border-blue-200",
|
||||||
|
student: "bg-green-100 text-green-800 border-green-200",
|
||||||
|
user: "bg-gray-100 text-gray-800 border-gray-200",
|
||||||
|
};
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [form, setForm] = useState({ first_name: "", last_name: "", email: "", role: "student", phone: "", gender: "" });
|
const [editUser, setEditUser] = useState<PlatformUser | null>(null);
|
||||||
|
const [rolesUser, setRolesUser] = useState<PlatformUser | null>(null);
|
||||||
|
const [form, setForm] = useState({ name: "", email: "", password: "", phone: "" });
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const studentsQ = useStudents({ search: search || undefined, size: 200 });
|
const usersQ = useQuery({
|
||||||
const teachersQ = useTeachers({ search: search || undefined, size: 200 });
|
queryKey: ["platform-users", search],
|
||||||
const createStudentMut = useCreateStudent();
|
queryFn: async () => {
|
||||||
const createTeacherMut = useCreateTeacher();
|
const params: Record<string, string | number> = { size: 200 };
|
||||||
|
if (search) params.search = search;
|
||||||
const students = studentsQ.data?.items ?? [];
|
const res = await api.get<{ items?: PlatformUser[]; data?: PlatformUser[]; total: number }>("/users/list", params);
|
||||||
const teachers = teachersQ.data?.items ?? [];
|
return (res.items ?? res.data ?? []) as PlatformUser[];
|
||||||
|
|
||||||
const deleteMut = useMutation({
|
|
||||||
mutationFn: async ({ type, id }: { type: "student" | "teacher"; id: number }) => {
|
|
||||||
if (type === "student") return lmsService.deleteStudent?.(id) ?? lmsService.updateStudent(id, { status: "inactive" });
|
|
||||||
return lmsService.updateTeacher?.(id, {}) ?? Promise.resolve();
|
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rolesQ = useQuery({
|
||||||
|
queryKey: ["user-roles-detail", rolesUser?.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!rolesUser) return null;
|
||||||
|
return api.get<{ roles: RoleInfo[]; effective_permissions: unknown[] }>(`/users/${rolesUser.id}/roles`);
|
||||||
|
},
|
||||||
|
enabled: !!rolesUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: (data: { name: string; email: string; password: string; phone?: string }) =>
|
||||||
|
api.post<{ data: PlatformUser }>("/users/create", data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["lms"] });
|
qc.invalidateQueries({ queryKey: ["platform-users"] });
|
||||||
toast({ title: "Done" });
|
setCreateOpen(false);
|
||||||
|
setForm({ name: "", email: "", password: "", phone: "" });
|
||||||
|
toast({ title: "User created" });
|
||||||
},
|
},
|
||||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleCreate() {
|
const updateMut = useMutation({
|
||||||
const email = form.email.trim();
|
mutationFn: (data: { id: number; name?: string; email?: string; phone?: string }) =>
|
||||||
const first_name = form.first_name.trim();
|
api.patch<{ data: PlatformUser }>("/users/update", data),
|
||||||
const last_name = form.last_name.trim();
|
onSuccess: () => {
|
||||||
const emailKey = email.toLowerCase();
|
qc.invalidateQueries({ queryKey: ["platform-users"] });
|
||||||
const dupStudent = students.some((s) => (s.email || "").trim().toLowerCase() === emailKey);
|
setEditUser(null);
|
||||||
const dupTeacher = teachers.some((t) => (t.email || "").trim().toLowerCase() === emailKey);
|
toast({ title: "User updated" });
|
||||||
if (dupStudent || dupTeacher) {
|
},
|
||||||
toast({
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
title: "Email already in use",
|
});
|
||||||
description:
|
|
||||||
"That address matches someone already listed on this page (search may hide them). Use another email or clear search to find the user.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cb = {
|
|
||||||
onSuccess: () => {
|
|
||||||
setCreateOpen(false);
|
|
||||||
resetForm();
|
|
||||||
toast({ title: "User created successfully" });
|
|
||||||
},
|
|
||||||
onError: (err: unknown) => toast(toastForCreateUserError(err)),
|
|
||||||
};
|
|
||||||
if (form.role === "teacher") {
|
|
||||||
createTeacherMut.mutate(
|
|
||||||
{
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
email,
|
|
||||||
phone: form.phone?.trim() || undefined,
|
|
||||||
gender: (form.gender as "male" | "female") || undefined,
|
|
||||||
},
|
|
||||||
cb,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
createStudentMut.mutate(
|
|
||||||
{
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
email,
|
|
||||||
phone: form.phone?.trim() || undefined,
|
|
||||||
gender: form.gender || undefined,
|
|
||||||
create_portal_user: true,
|
|
||||||
},
|
|
||||||
cb,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetForm() {
|
const toggleRoleMut = useMutation({
|
||||||
setForm({ first_name: "", last_name: "", email: "", role: "student", phone: "", gender: "" });
|
mutationFn: ({ userId, roleId }: { userId: number; roleId: number }) =>
|
||||||
}
|
api.post<{ assigned: boolean }>(`/users/${userId}/roles/toggle`, { role_id: roleId }),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["platform-users"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["user-roles-detail"] });
|
||||||
|
const verb = (res as { assigned: boolean }).assigned ? "assigned" : "removed";
|
||||||
|
toast({ title: `Role ${verb}` });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const users = usersQ.data ?? [];
|
||||||
|
const filtered = users.filter((u) => {
|
||||||
|
if (typeFilter !== "all" && u.user_type !== typeFilter) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeCounts = {
|
||||||
|
all: users.length,
|
||||||
|
admin: users.filter((u) => u.user_type === "admin").length,
|
||||||
|
teacher: users.filter((u) => u.user_type === "teacher").length,
|
||||||
|
student: users.filter((u) => u.user_type === "student").length,
|
||||||
|
};
|
||||||
|
|
||||||
function exportCsv() {
|
function exportCsv() {
|
||||||
const rows = [["Name", "Email", "Role", "Status"]];
|
const rows = [["Name", "Email", "Type", "Roles", "Permissions"]];
|
||||||
students.forEach(s => rows.push([s.name, s.email, "Student", s.status || "active"]));
|
users.forEach((u) =>
|
||||||
teachers.forEach(t => rows.push([t.name, t.email, "Teacher", "active"]));
|
rows.push([
|
||||||
const csv = rows.map(r => r.join(",")).join("\n");
|
u.name,
|
||||||
|
u.email,
|
||||||
|
u.user_type,
|
||||||
|
u.roles.map((r) => r.name).join("; "),
|
||||||
|
String(u.effective_permission_count),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const csv = rows.map((r) => r.map((c) => `"${c}"`).join(",")).join("\n");
|
||||||
const blob = new Blob([csv], { type: "text/csv" });
|
const blob = new Blob([csv], { type: "text/csv" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = "users_export.csv";
|
a.download = "platform_users.csv";
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPending = createStudentMut.isPending || createTeacherMut.isPending;
|
const rolesData = rolesQ.data as { roles: RoleInfo[]; effective_permissions: unknown[] } | null;
|
||||||
const loading = studentsQ.isLoading || teachersQ.isLoading;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Users</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Users</h1>
|
||||||
<p className="text-muted-foreground">Manage platform users across all roles.</p>
|
<p className="text-muted-foreground">
|
||||||
|
Manage all platform accounts — admins, teachers, and students.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={exportCsv}>
|
<Button variant="outline" size="sm" onClick={exportCsv}>
|
||||||
@@ -159,177 +161,254 @@ export default function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 items-center">
|
<div className="flex gap-3 items-center flex-wrap">
|
||||||
<div className="relative flex-1 max-w-sm">
|
<div className="relative flex-1 max-w-sm">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input placeholder="Search users..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
<Input placeholder="Search by name..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
<Tabs value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="all">All ({typeCounts.all})</TabsTrigger>
|
||||||
|
<TabsTrigger value="admin">Admins ({typeCounts.admin})</TabsTrigger>
|
||||||
|
<TabsTrigger value="teacher">Teachers ({typeCounts.teacher})</TabsTrigger>
|
||||||
|
<TabsTrigger value="student">Students ({typeCounts.student})</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{usersQ.isLoading ? (
|
||||||
<div className="flex items-center justify-center min-h-[300px]">
|
<div className="flex items-center justify-center min-h-[300px]">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Tabs defaultValue="students">
|
<Card className="border-0 shadow-sm">
|
||||||
<TabsList>
|
<CardContent className="p-0">
|
||||||
<TabsTrigger value="students">Students ({students.length})</TabsTrigger>
|
<Table>
|
||||||
<TabsTrigger value="teachers">Teachers ({teachers.length})</TabsTrigger>
|
<TableHeader>
|
||||||
</TabsList>
|
<TableRow>
|
||||||
<TabsContent value="students" className="mt-4">
|
<TableHead>User</TableHead>
|
||||||
<Card className="border-0 shadow-sm">
|
<TableHead>Type</TableHead>
|
||||||
<CardContent className="p-0">
|
<TableHead>Roles</TableHead>
|
||||||
<Table>
|
<TableHead>Permissions</TableHead>
|
||||||
<TableHeader>
|
<TableHead>Status</TableHead>
|
||||||
<TableRow>
|
<TableHead className="w-[120px]" />
|
||||||
<TableHead>Name</TableHead>
|
</TableRow>
|
||||||
<TableHead>Email</TableHead>
|
</TableHeader>
|
||||||
<TableHead>Phone</TableHead>
|
<TableBody>
|
||||||
<TableHead>Batch</TableHead>
|
{filtered.length === 0 && (
|
||||||
<TableHead>Status</TableHead>
|
<TableRow>
|
||||||
<TableHead>Enrollment</TableHead>
|
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||||
<TableHead className="w-10" />
|
No users found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{filtered.map((u) => {
|
||||||
|
const initials = u.name
|
||||||
|
.split(" ")
|
||||||
|
.map((w) => w[0])
|
||||||
|
.join("")
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase();
|
||||||
|
return (
|
||||||
|
<TableRow key={u.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{u.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{u.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className={`capitalize text-xs ${TYPE_COLORS[u.user_type] || TYPE_COLORS.user}`}>
|
||||||
|
{u.user_type}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{u.roles.length > 0
|
||||||
|
? u.roles.map((r) => (
|
||||||
|
<Badge key={r.id} variant="secondary" className="text-xs">
|
||||||
|
{r.name}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
: <span className="text-xs text-muted-foreground">No roles</span>}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm">{u.effective_permission_count}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={u.active ? "default" : "secondary"}>
|
||||||
|
{u.active ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" title="Edit user" onClick={() => setEditUser(u)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" title="Manage roles" onClick={() => setRolesUser(u)}>
|
||||||
|
<UserCog className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
);
|
||||||
<TableBody>
|
})}
|
||||||
{students.length === 0 && (
|
</TableBody>
|
||||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No students found.</TableCell></TableRow>
|
</Table>
|
||||||
)}
|
</CardContent>
|
||||||
{students.map((s) => (
|
</Card>
|
||||||
<TableRow key={s.id}>
|
|
||||||
<TableCell className="font-medium">{s.name}</TableCell>
|
|
||||||
<TableCell>{s.email}</TableCell>
|
|
||||||
<TableCell>{s.phone || "—"}</TableCell>
|
|
||||||
<TableCell>{s.batch_name || "—"}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={s.status === "active" ? "default" : "secondary"} className="capitalize">{s.status || "active"}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{s.enrollment_date || "—"}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem className="text-destructive" onClick={() => { if (window.confirm(`Deactivate student "${s.name}"?`)) deleteMut.mutate({ type: "student", id: s.id }); }}>
|
|
||||||
Deactivate
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="teachers" className="mt-4">
|
|
||||||
<Card className="border-0 shadow-sm">
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Name</TableHead>
|
|
||||||
<TableHead>Email</TableHead>
|
|
||||||
<TableHead>Phone</TableHead>
|
|
||||||
<TableHead>Department</TableHead>
|
|
||||||
<TableHead>Specialization</TableHead>
|
|
||||||
<TableHead className="w-10" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{teachers.length === 0 && (
|
|
||||||
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">No teachers found.</TableCell></TableRow>
|
|
||||||
)}
|
|
||||||
{teachers.map((t) => (
|
|
||||||
<TableRow key={t.id}>
|
|
||||||
<TableCell className="font-medium">{t.name}</TableCell>
|
|
||||||
<TableCell>{t.email}</TableCell>
|
|
||||||
<TableCell>{t.phone || "—"}</TableCell>
|
|
||||||
<TableCell>{t.department_name || "—"}</TableCell>
|
|
||||||
<TableCell>{t.specialization || "—"}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem className="text-destructive" onClick={() => { if (window.confirm(`Remove teacher "${t.name}"?`)) deleteMut.mutate({ type: "teacher", id: t.id }); }}>
|
|
||||||
Remove
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={createOpen} onOpenChange={(v) => { setCreateOpen(v); if (!v) resetForm(); }}>
|
{/* Create User Dialog */}
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader><DialogTitle>Create New User</DialogTitle></DialogHeader>
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Platform User</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Creates an internal Odoo user (res.users) with login access.
|
||||||
|
For students/teachers, use the LMS pages instead.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<Label>Full Name</Label>
|
||||||
<Label>First Name</Label>
|
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. John Admin" />
|
||||||
<Input value={form.first_name} onChange={(e) => setForm(f => ({ ...f, first_name: e.target.value }))} placeholder="John" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Last Name</Label>
|
|
||||||
<Input value={form.last_name} onChange={(e) => setForm(f => ({ ...f, last_name: e.target.value }))} placeholder="Doe" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Email</Label>
|
<Label>Email (login)</Label>
|
||||||
<Input type="email" value={form.email} onChange={(e) => setForm(f => ({ ...f, email: e.target.value }))} placeholder="john@email.com" />
|
<Input type="email" value={form.email} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} placeholder="admin@encoach.com" />
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Must be unique in Odoo for students and teachers. If create fails, the address may already exist outside the current list.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Phone</Label>
|
<Label>Password</Label>
|
||||||
<Input value={form.phone} onChange={(e) => setForm(f => ({ ...f, phone: e.target.value }))} placeholder="+1234567890" />
|
<Input type="password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} placeholder="Minimum 6 characters" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<Label>Phone (optional)</Label>
|
||||||
<Label>Role</Label>
|
<Input value={form.phone} onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))} placeholder="+1234567890" />
|
||||||
<Select value={form.role} onValueChange={(v) => setForm(f => ({ ...f, role: v }))}>
|
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="student">Student</SelectItem>
|
|
||||||
<SelectItem value="teacher">Teacher</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Gender</Label>
|
|
||||||
<Select value={form.gender} onValueChange={(v) => setForm(f => ({ ...f, gender: v }))}>
|
|
||||||
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="male">Male</SelectItem>
|
|
||||||
<SelectItem value="female">Female</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => { setCreateOpen(false); resetForm(); }}>Cancel</Button>
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={isPending || !form.first_name.trim() || !form.last_name.trim() || !form.email.trim()}
|
disabled={createMut.isPending || !form.name.trim() || !form.email.trim()}
|
||||||
onClick={handleCreate}
|
onClick={() => createMut.mutate({ name: form.name.trim(), email: form.email.trim(), password: form.password || "admin123", phone: form.phone.trim() || undefined })}
|
||||||
>
|
>
|
||||||
{isPending ? "Creating..." : "Create"}
|
{createMut.isPending ? "Creating..." : "Create User"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit User Dialog */}
|
||||||
|
<Dialog open={!!editUser} onOpenChange={(v) => { if (!v) setEditUser(null); }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit User</DialogTitle>
|
||||||
|
<DialogDescription>Update profile for {editUser?.name}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{editUser && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input
|
||||||
|
value={editUser.name}
|
||||||
|
onChange={(e) => setEditUser((u) => u ? { ...u, name: e.target.value } : null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Email</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={editUser.email}
|
||||||
|
onChange={(e) => setEditUser((u) => u ? { ...u, email: e.target.value } : null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Phone</Label>
|
||||||
|
<Input
|
||||||
|
value={editUser.phone || ""}
|
||||||
|
onChange={(e) => setEditUser((u) => u ? { ...u, phone: e.target.value } : null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditUser(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
disabled={updateMut.isPending || !editUser?.name.trim()}
|
||||||
|
onClick={() => {
|
||||||
|
if (!editUser) return;
|
||||||
|
updateMut.mutate({
|
||||||
|
id: editUser.id,
|
||||||
|
name: editUser.name.trim(),
|
||||||
|
email: editUser.email.trim(),
|
||||||
|
phone: editUser.phone?.trim() || undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{updateMut.isPending ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Manage Roles Dialog */}
|
||||||
|
<Dialog open={!!rolesUser} onOpenChange={(v) => { if (!v) setRolesUser(null); }}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="h-5 w-5" />
|
||||||
|
Manage Roles — {rolesUser?.name}
|
||||||
|
</div>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>Toggle roles on/off for this user.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{rolesQ.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
) : rolesData?.roles ? (
|
||||||
|
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||||
|
{rolesData.roles.map((r) => (
|
||||||
|
<label
|
||||||
|
key={r.id}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||||
|
r.assigned ? "border-primary bg-primary/5" : "border-border hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={r.assigned}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
if (rolesUser) toggleRoleMut.mutate({ userId: rolesUser.id, roleId: r.id });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{r.name}</p>
|
||||||
|
{r.description && <p className="text-xs text-muted-foreground">{r.description}</p>}
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-xs shrink-0">
|
||||||
|
{r.permission_count} perms
|
||||||
|
</Badge>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">No roles available.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setRolesUser(null)}>Close</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,96 +1,389 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { CheckCircle, BookA, Sparkles } from "lucide-react";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
Sparkles,
|
||||||
|
Circle,
|
||||||
|
} from "lucide-react";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import {
|
||||||
|
trainingService,
|
||||||
|
type VocabItem,
|
||||||
|
type CefrLevel,
|
||||||
|
} from "@/services/training.service";
|
||||||
|
|
||||||
const vocabItems = [
|
const LEVELS: (CefrLevel | "all")[] = ["all", "A1", "A2", "B1", "B2", "C1", "C2"];
|
||||||
{ word: "Ubiquitous", meaning: "Present, appearing, or found everywhere", level: "C1", completed: true },
|
|
||||||
{ word: "Pragmatic", meaning: "Dealing with things sensibly and realistically", level: "B2", completed: true },
|
|
||||||
{ word: "Eloquent", meaning: "Fluent or persuasive in speaking or writing", level: "C1", completed: false },
|
|
||||||
{ word: "Meticulous", meaning: "Showing great attention to detail", level: "B2", completed: false },
|
|
||||||
{ word: "Ambiguous", meaning: "Open to more than one interpretation", level: "B2", completed: false },
|
|
||||||
{ word: "Coherent", meaning: "Logical and consistent", level: "B1", completed: false },
|
|
||||||
{ word: "Versatile", meaning: "Able to adapt to many different functions", level: "B2", completed: true },
|
|
||||||
{ word: "Concise", meaning: "Giving a lot of information in few words", level: "B1", completed: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function VocabularyPage() {
|
export default function VocabularyPage() {
|
||||||
const [showCompleted, setShowCompleted] = useState(false);
|
const { toast } = useToast();
|
||||||
const completed = vocabItems.filter(v => v.completed).length;
|
const qc = useQueryClient();
|
||||||
const filtered = showCompleted ? vocabItems : vocabItems.filter(v => !v.completed);
|
const [showCompleted, setShowCompleted] = useState(true);
|
||||||
|
const [levelFilter, setLevelFilter] = useState<string>("all");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
word: "",
|
||||||
|
meaning: "",
|
||||||
|
example_sentence: "",
|
||||||
|
level: "B1" as CefrLevel,
|
||||||
|
part_of_speech: "noun" as VocabItem["part_of_speech"],
|
||||||
|
category: "general",
|
||||||
|
});
|
||||||
|
|
||||||
|
const listQ = useQuery({
|
||||||
|
queryKey: ["training-vocab", levelFilter, search],
|
||||||
|
queryFn: () =>
|
||||||
|
trainingService.listVocab({
|
||||||
|
...(levelFilter !== "all" ? { level: levelFilter } : {}),
|
||||||
|
...(search ? { search } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = listQ.data?.items ?? [];
|
||||||
|
const summary = listQ.data?.summary;
|
||||||
|
const filtered = showCompleted ? items : items.filter((v) => !v.completed);
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: trainingService.createVocab,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["training-vocab"] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
setForm({
|
||||||
|
word: "",
|
||||||
|
meaning: "",
|
||||||
|
example_sentence: "",
|
||||||
|
level: "B1",
|
||||||
|
part_of_speech: "noun",
|
||||||
|
category: "general",
|
||||||
|
});
|
||||||
|
toast({ title: "Word added" });
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const msg =
|
||||||
|
err && typeof err === "object" && "message" in err
|
||||||
|
? String((err as { message: unknown }).message)
|
||||||
|
: "Failed to add word";
|
||||||
|
toast({ title: msg, variant: "destructive" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const delMut = useMutation({
|
||||||
|
mutationFn: trainingService.deleteVocab,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["training-vocab"] });
|
||||||
|
toast({ title: "Word deleted" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const progressMut = useMutation({
|
||||||
|
mutationFn: ({ id, completed }: { id: number; completed: boolean }) =>
|
||||||
|
trainingService.setVocabProgress(id, { completed }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["training-vocab"] }),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Vocabulary Training</h1>
|
<div>
|
||||||
<p className="text-muted-foreground">Build your vocabulary for IELTS success.</p>
|
<h1 className="text-2xl font-bold tracking-tight">Vocabulary Training</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Build and manage the vocabulary library — track completion by CEFR level.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" /> Add Word
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AiTipBanner context="vocabulary" variant="recommendation" />
|
<AiTipBanner context="vocabulary" variant="recommendation" />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="lg:col-span-2 space-y-4">
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
{/* Summary */}
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-base">Progress</CardTitle>
|
<CardTitle className="text-base">Progress</CardTitle>
|
||||||
<span className="text-sm text-muted-foreground">{completed}/{vocabItems.length} completed</span>
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{summary
|
||||||
|
? `${summary.completed}/${summary.total} completed (${summary.completion_rate}%)`
|
||||||
|
: "—"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Progress value={(completed / vocabItems.length) * 100} className="h-3" />
|
<Progress value={summary?.completion_rate ?? 0} className="h-3" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
{/* Filters */}
|
||||||
<Switch id="show" checked={showCompleted} onCheckedChange={setShowCompleted} />
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
<Label htmlFor="show" className="text-sm">Show completed</Label>
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search word or meaning…"
|
||||||
|
className="pl-9"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={levelFilter} onValueChange={setLevelFilter}>
|
||||||
|
<SelectTrigger className="w-[110px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{LEVELS.map((l) => (
|
||||||
|
<SelectItem key={l} value={l}>
|
||||||
|
{l === "all" ? "All levels" : l}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch id="show" checked={showCompleted} onCheckedChange={setShowCompleted} />
|
||||||
|
<Label htmlFor="show" className="text-sm">
|
||||||
|
Show completed
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
{listQ.isLoading && (
|
||||||
|
<div className="py-8 text-center text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!listQ.isLoading && filtered.length === 0 && (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="text-center text-muted-foreground py-8">
|
||||||
|
No vocabulary items match.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
{filtered.map((v) => (
|
{filtered.map((v) => (
|
||||||
<Card key={v.word} className="border-0 shadow-sm">
|
<Card key={v.id} className="border-0 shadow-sm">
|
||||||
<CardContent className="p-4 flex items-center justify-between">
|
<CardContent className="p-4 flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<button
|
||||||
{v.completed && <CheckCircle className="h-4 w-4 text-success shrink-0" />}
|
onClick={() =>
|
||||||
<div>
|
progressMut.mutate({ id: v.id, completed: !v.completed })
|
||||||
<p className="font-semibold text-sm">{v.word}</p>
|
}
|
||||||
<p className="text-xs text-muted-foreground">{v.meaning}</p>
|
className="shrink-0 rounded-full p-0.5 hover:bg-muted transition"
|
||||||
</div>
|
aria-label={v.completed ? "Mark incomplete" : "Mark complete"}
|
||||||
|
>
|
||||||
|
{v.completed ? (
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Circle className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-sm">{v.word}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{v.meaning}</p>
|
||||||
|
{v.example_sentence && (
|
||||||
|
<p className="text-xs italic text-muted-foreground mt-0.5 truncate">
|
||||||
|
“{v.example_sentence}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Badge variant="outline">{v.level}</Badge>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{v.part_of_speech}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
onClick={() => delMut.mutate(v.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">{v.level}</Badge>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* AI panel */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-base flex items-center gap-2"><Sparkles className="h-4 w-4 text-primary" /> AI Recommendations</CardTitle>
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-primary" /> AI Recommendations
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Learn "coherent" + "concise" together — they share academic writing context</li>
|
<li className="text-sm text-muted-foreground flex items-start gap-2">
|
||||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Review C1-level vocabulary for IELTS Task 2</li>
|
<span className="text-primary font-bold">•</span>
|
||||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Focus on collocations with 'make' and 'do'</li>
|
Pair <b>coherent</b> + <b>concise</b> — they share academic-writing
|
||||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Try using "meticulous" in your next writing practice</li>
|
contexts.
|
||||||
|
</li>
|
||||||
|
<li className="text-sm text-muted-foreground flex items-start gap-2">
|
||||||
|
<span className="text-primary font-bold">•</span>
|
||||||
|
Review C1 words for IELTS Writing Task 2.
|
||||||
|
</li>
|
||||||
|
<li className="text-sm text-muted-foreground flex items-start gap-2">
|
||||||
|
<span className="text-primary font-bold">•</span>
|
||||||
|
Try using <b>meticulous</b> in your next essay.
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
|
{summary && (
|
||||||
<CardContent className="p-4">
|
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
|
||||||
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-2"><Sparkles className="h-3 w-3" /> AI Vocabulary Goal</p>
|
<CardContent className="p-4">
|
||||||
<p className="text-sm text-muted-foreground">At 2 words/day, you'll complete this set by March 15. AI recommends adding 10 more domain-specific words from your upcoming exam topics.</p>
|
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-2">
|
||||||
</CardContent>
|
<Sparkles className="h-3 w-3" /> AI Vocabulary Goal
|
||||||
</Card>
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{summary.remaining > 0
|
||||||
|
? `At 2 words/day, you'll complete the remaining ${summary.remaining} items in about ${Math.ceil(summary.remaining / 2)} days.`
|
||||||
|
: "You've completed every active word — try adding more to keep challenging yourself."}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Create dialog */}
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Vocabulary Word</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Word</Label>
|
||||||
|
<Input
|
||||||
|
value={form.word}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, word: e.target.value }))}
|
||||||
|
placeholder="e.g. Ubiquitous"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Meaning</Label>
|
||||||
|
<Textarea
|
||||||
|
value={form.meaning}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, meaning: e.target.value }))}
|
||||||
|
placeholder="Plain-English definition"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Example sentence (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={form.example_sentence}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, example_sentence: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Use the word in context"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>CEFR level</Label>
|
||||||
|
<Select
|
||||||
|
value={form.level}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setForm((f) => ({ ...f, level: v as CefrLevel }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(LEVELS.filter((l) => l !== "all") as CefrLevel[]).map(
|
||||||
|
(l) => (
|
||||||
|
<SelectItem key={l} value={l}>
|
||||||
|
{l}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Part of speech</Label>
|
||||||
|
<Select
|
||||||
|
value={form.part_of_speech}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
part_of_speech: v as VocabItem["part_of_speech"],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="noun">noun</SelectItem>
|
||||||
|
<SelectItem value="verb">verb</SelectItem>
|
||||||
|
<SelectItem value="adjective">adjective</SelectItem>
|
||||||
|
<SelectItem value="adverb">adverb</SelectItem>
|
||||||
|
<SelectItem value="phrase">phrase</SelectItem>
|
||||||
|
<SelectItem value="other">other</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Category</Label>
|
||||||
|
<Input
|
||||||
|
value={form.category}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, category: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!form.word.trim() || !form.meaning.trim() || createMut.isPending}
|
||||||
|
onClick={() => createMut.mutate(form)}
|
||||||
|
>
|
||||||
|
{createMut.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> Adding…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Add"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
@@ -87,7 +87,10 @@ export default function AcademicYearManager() {
|
|||||||
<Button><Plus className="mr-2 h-4 w-4" /> Create Academic Year</Button>
|
<Button><Plus className="mr-2 h-4 w-4" /> Create Academic Year</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader><DialogTitle>Create Academic Year</DialogTitle></DialogHeader>
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Academic Year</DialogTitle>
|
||||||
|
<DialogDescription>Define the academic year range and term structure.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
<div className="space-y-4 pt-4">
|
<div className="space-y-4 pt-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Name</Label>
|
<Label>Name</Label>
|
||||||
|
|||||||
@@ -256,13 +256,14 @@ export default function AdminActivities() {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={createType.isPending}
|
disabled={createType.isPending || !typeName.trim()}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
createType.mutate(
|
createType.mutate(
|
||||||
{ name: typeName },
|
{ name: typeName.trim() },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setTypeOpen(false);
|
setTypeOpen(false);
|
||||||
|
setTypeName("");
|
||||||
toast({ title: "Created successfully" });
|
toast({ title: "Created successfully" });
|
||||||
},
|
},
|
||||||
onError: (err: Error) =>
|
onError: (err: Error) =>
|
||||||
|
|||||||
@@ -1,50 +1,480 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||||
import { useCourses, useCreateCourse } from "@/hooks/queries";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { lmsService } from "@/services";
|
import { useCourses, useCreateCourse, useStudents, useBulkEnroll, useBatches } from "@/hooks/queries";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { lmsService, taxonomyService, resourcesService } from "@/services";
|
||||||
import { Search, Plus, Trash2 } from "lucide-react";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Search, Plus, Trash2, UserPlus, FileEdit, Pencil, BookOpen, Target, Loader2, Users, GraduationCap } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import type { Course, CourseCreateRequest } from "@/types";
|
||||||
|
import type { ResourceTag } from "@/types/adaptive";
|
||||||
|
|
||||||
|
const DIFFICULTY_OPTIONS = [
|
||||||
|
{ value: "beginner", label: "Beginner" },
|
||||||
|
{ value: "intermediate", label: "Intermediate" },
|
||||||
|
{ value: "advanced", label: "Advanced" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CEFR_OPTIONS = [
|
||||||
|
{ value: "pre_a1", label: "Pre-A1" },
|
||||||
|
{ value: "a1", label: "A1" },
|
||||||
|
{ value: "a2", label: "A2" },
|
||||||
|
{ value: "b1", label: "B1" },
|
||||||
|
{ value: "b2", label: "B2" },
|
||||||
|
{ value: "c1", label: "C1" },
|
||||||
|
{ value: "c2", label: "C2" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CourseFormData {
|
||||||
|
title: string;
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
max_capacity: number;
|
||||||
|
encoach_subject_id: number | null;
|
||||||
|
topic_ids: number[];
|
||||||
|
learning_objective_ids: number[];
|
||||||
|
tag_ids: number[];
|
||||||
|
difficulty_level: string;
|
||||||
|
cefr_level: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: CourseFormData = {
|
||||||
|
title: "",
|
||||||
|
code: "",
|
||||||
|
description: "",
|
||||||
|
max_capacity: 30,
|
||||||
|
encoach_subject_id: null,
|
||||||
|
topic_ids: [],
|
||||||
|
learning_objective_ids: [],
|
||||||
|
tag_ids: [],
|
||||||
|
difficulty_level: "",
|
||||||
|
cefr_level: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
function CourseFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
initialData,
|
||||||
|
courseId,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
initialData?: CourseFormData;
|
||||||
|
courseId?: number;
|
||||||
|
}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const createMut = useCreateCourse();
|
||||||
|
const [form, setForm] = useState<CourseFormData>(initialData ?? emptyForm);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setForm(initialData ?? emptyForm);
|
||||||
|
}
|
||||||
|
}, [open, initialData]);
|
||||||
|
|
||||||
|
const { data: subjects = [] } = useQuery({
|
||||||
|
queryKey: ["taxonomy", "subjects"],
|
||||||
|
queryFn: () => taxonomyService.listSubjects(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: topicsData } = useQuery({
|
||||||
|
queryKey: ["taxonomy", "topics", form.encoach_subject_id],
|
||||||
|
queryFn: () =>
|
||||||
|
form.encoach_subject_id
|
||||||
|
? taxonomyService.listTopics({ subject_id: form.encoach_subject_id })
|
||||||
|
: Promise.resolve([]),
|
||||||
|
enabled: !!form.encoach_subject_id,
|
||||||
|
});
|
||||||
|
const topics = topicsData ?? [];
|
||||||
|
|
||||||
|
const { data: objectivesData } = useQuery({
|
||||||
|
queryKey: ["lms", "objectives", form.topic_ids.join(",")],
|
||||||
|
queryFn: () =>
|
||||||
|
form.topic_ids.length > 0
|
||||||
|
? lmsService.listLearningObjectives({ topic_ids: form.topic_ids.join(",") })
|
||||||
|
: Promise.resolve({ items: [], total: 0 }),
|
||||||
|
enabled: form.topic_ids.length > 0,
|
||||||
|
});
|
||||||
|
const objectives = objectivesData?.items ?? [];
|
||||||
|
|
||||||
|
const { data: allTags = [] } = useQuery({
|
||||||
|
queryKey: ["resource-tags"],
|
||||||
|
queryFn: () => resourcesService.listTags(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubjectChange(val: string) {
|
||||||
|
const sid = val === "none" ? null : Number(val);
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
encoach_subject_id: sid,
|
||||||
|
topic_ids: [],
|
||||||
|
learning_objective_ids: [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTopic(id: number) {
|
||||||
|
setForm((f) => {
|
||||||
|
const next = f.topic_ids.includes(id)
|
||||||
|
? f.topic_ids.filter((t) => t !== id)
|
||||||
|
: [...f.topic_ids, id];
|
||||||
|
const validObjectiveTopics = new Set(
|
||||||
|
topics.filter((t) => next.includes(t.id)).flatMap((t) => [t.id])
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
topic_ids: next,
|
||||||
|
learning_objective_ids: f.learning_objective_ids.filter((oid) =>
|
||||||
|
objectives.some(
|
||||||
|
(o) => o.id === oid && validObjectiveTopics.has(o.topic_id)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleObjective(id: number) {
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
learning_objective_ids: f.learning_objective_ids.includes(id)
|
||||||
|
? f.learning_objective_ids.filter((o) => o !== id)
|
||||||
|
: [...f.learning_objective_ids, id],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTag(id: number) {
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
tag_ids: f.tag_ids.includes(id)
|
||||||
|
? f.tag_ids.filter((t) => t !== id)
|
||||||
|
: [...f.tag_ids, id],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!form.title.trim()) return;
|
||||||
|
const payload: Partial<CourseCreateRequest> = {
|
||||||
|
title: form.title.trim(),
|
||||||
|
code:
|
||||||
|
form.code.trim() ||
|
||||||
|
form.title.trim().toUpperCase().replace(/\s+/g, "-").slice(0, 16),
|
||||||
|
description: form.description,
|
||||||
|
max_capacity: form.max_capacity,
|
||||||
|
encoach_subject_id: form.encoach_subject_id ?? undefined,
|
||||||
|
topic_ids: form.topic_ids,
|
||||||
|
learning_objective_ids: form.learning_objective_ids,
|
||||||
|
tag_ids: form.tag_ids,
|
||||||
|
difficulty_level: (form.difficulty_level as CourseCreateRequest["difficulty_level"]) || undefined,
|
||||||
|
cefr_level: form.cefr_level || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (courseId) {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await lmsService.updateCourse(courseId, payload);
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
|
||||||
|
toast({ title: "Course updated" });
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast({ title: "Error", description: String(e instanceof Error ? e.message : e), variant: "destructive" });
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
} else {
|
||||||
|
createMut.mutate(payload as CourseCreateRequest, {
|
||||||
|
onSuccess: () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
toast({ title: "Course created" });
|
||||||
|
},
|
||||||
|
onError: (e) =>
|
||||||
|
toast({ title: "Error", description: String(e.message || e), variant: "destructive" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPending = saving || createMut.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{courseId ? "Edit Course" : "Create New Course"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Title *</Label>
|
||||||
|
<Input
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
||||||
|
placeholder="e.g. IELTS Academic Preparation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Code</Label>
|
||||||
|
<Input
|
||||||
|
value={form.code}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))}
|
||||||
|
placeholder="Auto-generated if empty"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Max Capacity</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={form.max_capacity}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, max_capacity: Number(e.target.value) || 30 }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Difficulty</Label>
|
||||||
|
<Select
|
||||||
|
value={form.difficulty_level || "none"}
|
||||||
|
onValueChange={(v) => setForm((f) => ({ ...f, difficulty_level: v === "none" ? "" : v }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select..." /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
{DIFFICULTY_OPTIONS.map((d) => (
|
||||||
|
<SelectItem key={d.value} value={d.value}>{d.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>CEFR Level</Label>
|
||||||
|
<Select
|
||||||
|
value={form.cefr_level || "none"}
|
||||||
|
onValueChange={(v) => setForm((f) => ({ ...f, cefr_level: v === "none" ? "" : v }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select..." /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
{CEFR_OPTIONS.map((c) => (
|
||||||
|
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Subject</Label>
|
||||||
|
<Select
|
||||||
|
value={form.encoach_subject_id ? String(form.encoach_subject_id) : "none"}
|
||||||
|
onValueChange={handleSubjectChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select subject..." /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
{subjects.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.encoach_subject_id && topics.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Topics ({form.topic_ids.length} selected)</Label>
|
||||||
|
<div className="border rounded-md p-3 max-h-[140px] overflow-y-auto space-y-1">
|
||||||
|
{topics.map((t) => (
|
||||||
|
<label key={t.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 p-1 rounded">
|
||||||
|
<Checkbox
|
||||||
|
checked={form.topic_ids.includes(t.id)}
|
||||||
|
onCheckedChange={() => toggleTopic(t.id)}
|
||||||
|
/>
|
||||||
|
<span>{t.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-auto">{t.domain_name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.topic_ids.length > 0 && objectives.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Learning Objectives ({form.learning_objective_ids.length} selected)</Label>
|
||||||
|
<div className="border rounded-md p-3 max-h-[140px] overflow-y-auto space-y-1">
|
||||||
|
{objectives.map((o) => (
|
||||||
|
<label key={o.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 p-1 rounded">
|
||||||
|
<Checkbox
|
||||||
|
checked={form.learning_objective_ids.includes(o.id)}
|
||||||
|
onCheckedChange={() => toggleObjective(o.id)}
|
||||||
|
/>
|
||||||
|
<span>{o.name}</span>
|
||||||
|
{o.bloom_level && (
|
||||||
|
<Badge variant="outline" className="text-[10px] ml-auto capitalize">
|
||||||
|
{o.bloom_level}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{allTags.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Tags</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{allTags.map((t: ResourceTag) => (
|
||||||
|
<Badge
|
||||||
|
key={t.id}
|
||||||
|
variant={form.tag_ids.includes(t.id) ? "default" : "outline"}
|
||||||
|
className="cursor-pointer select-none transition-colors"
|
||||||
|
style={
|
||||||
|
form.tag_ids.includes(t.id)
|
||||||
|
? { backgroundColor: t.color, borderColor: t.color, color: "#fff" }
|
||||||
|
: { borderColor: t.color, color: t.color }
|
||||||
|
}
|
||||||
|
onClick={() => toggleTag(t.id)}
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleSave} disabled={isPending || !form.title.trim()}>
|
||||||
|
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{courseId ? "Save Changes" : "Create Course"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function courseToFormData(c: Course): CourseFormData {
|
||||||
|
return {
|
||||||
|
title: c.title,
|
||||||
|
code: c.code,
|
||||||
|
description: c.description,
|
||||||
|
max_capacity: c.max_capacity,
|
||||||
|
encoach_subject_id: c.encoach_subject_id ?? null,
|
||||||
|
topic_ids: c.topic_ids ?? [],
|
||||||
|
learning_objective_ids: c.learning_objective_ids ?? [],
|
||||||
|
tag_ids: c.tag_ids ?? [],
|
||||||
|
difficulty_level: c.difficulty_level ?? "",
|
||||||
|
cefr_level: c.cefr_level ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminCourses() {
|
export default function AdminCourses() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [form, setForm] = useState({ title: "", code: "", description: "", max_capacity: 30 });
|
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
|
||||||
|
const [enrollOpen, setEnrollOpen] = useState(false);
|
||||||
|
const [enrollCourseId, setEnrollCourseId] = useState<number | null>(null);
|
||||||
|
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]);
|
||||||
|
const [enrollTab, setEnrollTab] = useState<"students" | "classroom">("students");
|
||||||
|
const [selectedBatchId, setSelectedBatchId] = useState<number | null>(null);
|
||||||
const { data: coursesData, isLoading } = useCourses();
|
const { data: coursesData, isLoading } = useCourses();
|
||||||
const createMut = useCreateCourse();
|
const { data: studentsData } = useStudents({ size: 200 });
|
||||||
|
const { data: batchesData } = useBatches({ size: 200 });
|
||||||
|
const enrollMut = useBulkEnroll();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const courses = coursesData?.items ?? [];
|
const courses = coursesData?.items ?? [];
|
||||||
|
const allStudents = studentsData?.items ?? [];
|
||||||
|
const allBatches = batchesData?.items ?? [];
|
||||||
|
|
||||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const filtered = courses.filter(c => c.title.toLowerCase().includes(search.toLowerCase()));
|
const filtered = courses.filter((c) =>
|
||||||
|
c.title.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
function handleCreate() {
|
function openEnrollDialog(courseId: number) {
|
||||||
if (!form.title.trim()) return;
|
setEnrollCourseId(courseId);
|
||||||
createMut.mutate(
|
setSelectedStudentIds([]);
|
||||||
{ title: form.title.trim(), code: form.code.trim() || form.title.trim().toUpperCase().replace(/\s+/g, "-").slice(0, 16), description: form.description, max_capacity: form.max_capacity },
|
setSelectedBatchId(null);
|
||||||
{
|
setEnrollTab("students");
|
||||||
onSuccess: () => {
|
setEnrollOpen(true);
|
||||||
setCreateOpen(false);
|
}
|
||||||
setForm({ title: "", code: "", description: "", max_capacity: 30 });
|
|
||||||
toast({ title: "Course created" });
|
function handleEnroll() {
|
||||||
|
if (!enrollCourseId) return;
|
||||||
|
|
||||||
|
if (enrollTab === "classroom" && selectedBatchId) {
|
||||||
|
enrollMut.mutate(
|
||||||
|
{ courseId: enrollCourseId, data: { batch_id: selectedBatchId } },
|
||||||
|
{
|
||||||
|
onSuccess: (res) => {
|
||||||
|
toast({ title: `Enrolled ${res.total_enrolled} student(s) from classroom` });
|
||||||
|
setEnrollOpen(false);
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
||||||
|
},
|
||||||
|
onError: (e) =>
|
||||||
|
toast({ title: "Enrollment failed", description: String(e.message || e), variant: "destructive" }),
|
||||||
},
|
},
|
||||||
onError: (e) => toast({ title: "Error", description: String(e.message || e), variant: "destructive" }),
|
);
|
||||||
}
|
} else if (enrollTab === "students" && selectedStudentIds.length > 0) {
|
||||||
|
enrollMut.mutate(
|
||||||
|
{ courseId: enrollCourseId, data: { student_ids: selectedStudentIds } },
|
||||||
|
{
|
||||||
|
onSuccess: (res) => {
|
||||||
|
toast({ title: `Enrolled ${res.total_enrolled} student(s)` });
|
||||||
|
setEnrollOpen(false);
|
||||||
|
},
|
||||||
|
onError: (e) =>
|
||||||
|
toast({ title: "Enrollment failed", description: String(e.message || e), variant: "destructive" }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleStudentSelection(sid: number) {
|
||||||
|
setSelectedStudentIds((prev) =>
|
||||||
|
prev.includes(sid) ? prev.filter((x) => x !== sid) : [...prev, sid]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canEnroll =
|
||||||
|
(enrollTab === "students" && selectedStudentIds.length > 0) ||
|
||||||
|
(enrollTab === "classroom" && selectedBatchId !== null);
|
||||||
|
const enrollLabel =
|
||||||
|
enrollTab === "classroom"
|
||||||
|
? `Enroll Classroom${selectedBatchId ? ` (${allBatches.find((b) => b.id === selectedBatchId)?.student_count ?? 0} students)` : ""}`
|
||||||
|
: `Enroll ${selectedStudentIds.length} Student(s)`;
|
||||||
|
|
||||||
async function handleDelete(id: number, title: string) {
|
async function handleDelete(id: number, title: string) {
|
||||||
if (!window.confirm(`Delete course "${title}"?`)) return;
|
if (!window.confirm(`Delete course "${title}"?`)) return;
|
||||||
try {
|
try {
|
||||||
@@ -60,47 +490,268 @@ export default function AdminCourses() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div><h1 className="text-2xl font-bold">Courses</h1><p className="text-muted-foreground">Manage all platform courses.</p></div>
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Courses</h1>
|
||||||
|
<p className="text-muted-foreground">Manage all platform courses.</p>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<AiCreationAssistant type="course" />
|
<AiCreationAssistant type="course" />
|
||||||
<Button onClick={() => setCreateOpen(true)}><Plus className="mr-2 h-4 w-4" />New Course</Button>
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New Course
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AiTipBanner context="admin-courses" variant="recommendation" />
|
<AiTipBanner context="admin-courses" variant="recommendation" />
|
||||||
<div className="relative max-w-sm"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="Search courses..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9" /></div>
|
<div className="relative max-w-sm">
|
||||||
<Card><CardContent className="pt-6">
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Table>
|
<Input
|
||||||
<TableHeader><TableRow><TableHead>Course</TableHead><TableHead>Code</TableHead><TableHead>Level</TableHead><TableHead>Enrolled / Capacity</TableHead><TableHead>Status</TableHead><TableHead className="w-[60px]" /></TableRow></TableHeader>
|
placeholder="Search courses..."
|
||||||
<TableBody>
|
value={search}
|
||||||
{filtered.map(c => (
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
<TableRow key={c.id}>
|
className="pl-9"
|
||||||
<TableCell className="font-medium">{c.title}</TableCell>
|
/>
|
||||||
<TableCell>{c.code}</TableCell>
|
</div>
|
||||||
<TableCell><Badge variant="outline">{c.level}</Badge></TableCell>
|
|
||||||
<TableCell>{c.enrolled} / {c.max_capacity}</TableCell>
|
<Card>
|
||||||
<TableCell><Badge variant={c.status === "active" ? "default" : "secondary"} className="capitalize">{c.status}</Badge></TableCell>
|
<CardContent className="pt-6">
|
||||||
<TableCell>
|
<Table>
|
||||||
<Button variant="ghost" size="icon" onClick={() => handleDelete(c.id, c.title)}><Trash2 className="h-4 w-4 text-destructive" /></Button>
|
<TableHeader>
|
||||||
</TableCell>
|
<TableRow>
|
||||||
</TableRow>
|
<TableHead>Course</TableHead>
|
||||||
))}
|
<TableHead>Subject / Tags</TableHead>
|
||||||
{filtered.length === 0 && <TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">No courses found.</TableCell></TableRow>}
|
<TableHead>Difficulty</TableHead>
|
||||||
</TableBody>
|
<TableHead>Chapters</TableHead>
|
||||||
</Table>
|
<TableHead>Enrolled / Cap</TableHead>
|
||||||
</CardContent></Card>
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="w-[150px]" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.map((c) => (
|
||||||
|
<TableRow key={c.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{c.title}</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">{c.code}</span>
|
||||||
|
</div>
|
||||||
|
{c.topic_names && c.topic_names.length > 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{c.topic_names.slice(0, 3).join(", ")}
|
||||||
|
{c.topic_names.length > 3 && ` +${c.topic_names.length - 3}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{c.encoach_subject_name && (
|
||||||
|
<div className="flex items-center gap-1 text-xs">
|
||||||
|
<BookOpen className="h-3 w-3" />
|
||||||
|
{c.encoach_subject_name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(c.tags ?? []).slice(0, 3).map((t) => (
|
||||||
|
<Badge
|
||||||
|
key={t.id}
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] px-1.5 py-0"
|
||||||
|
style={{ borderColor: t.color, color: t.color }}
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{c.difficulty_level && (
|
||||||
|
<Badge variant="outline" className="capitalize text-xs">
|
||||||
|
{c.difficulty_level}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{c.cefr_level && (
|
||||||
|
<Badge variant="secondary" className="ml-1 text-xs uppercase">
|
||||||
|
{c.cefr_level}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1 text-sm">
|
||||||
|
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{c.chapter_count ?? 0}
|
||||||
|
{(c.objective_count ?? 0) > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-1">
|
||||||
|
<Target className="inline h-3 w-3 mr-0.5" />
|
||||||
|
{c.objective_count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{c.enrolled} / {c.max_capacity}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={c.status === "active" ? "default" : "secondary"}
|
||||||
|
className="capitalize"
|
||||||
|
>
|
||||||
|
{c.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Edit course"
|
||||||
|
onClick={() => setEditingCourse(c)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Enroll students"
|
||||||
|
onClick={() => openEnrollDialog(c.id)}
|
||||||
|
>
|
||||||
|
<UserPlus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" title="Assign exam" asChild>
|
||||||
|
<Link to={`/admin/generation?course_id=${c.id}`}>
|
||||||
|
<FileEdit className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(c.id, c.title)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
|
||||||
|
No courses found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<CourseFormDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{editingCourse && (
|
||||||
|
<CourseFormDialog
|
||||||
|
open={!!editingCourse}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setEditingCourse(null);
|
||||||
|
}}
|
||||||
|
initialData={courseToFormData(editingCourse)}
|
||||||
|
courseId={editingCourse.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={enrollOpen} onOpenChange={setEnrollOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Enroll Students</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enroll in: <strong>{courses.find((c) => c.id === enrollCourseId)?.title}</strong>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Tabs value={enrollTab} onValueChange={(v) => setEnrollTab(v as "students" | "classroom")}>
|
||||||
|
<TabsList className="w-full">
|
||||||
|
<TabsTrigger value="students" className="flex-1 gap-1.5">
|
||||||
|
<UserPlus className="h-3.5 w-3.5" /> Individual Students
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="classroom" className="flex-1 gap-1.5">
|
||||||
|
<GraduationCap className="h-3.5 w-3.5" /> By Classroom
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="students" className="mt-3">
|
||||||
|
<div className="max-h-[320px] overflow-y-auto space-y-1">
|
||||||
|
{allStudents.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">No students found.</p>
|
||||||
|
) : (
|
||||||
|
allStudents.map((s) => (
|
||||||
|
<label key={s.id} className="flex items-center gap-3 p-2 rounded hover:bg-muted/50 cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedStudentIds.includes(s.id)}
|
||||||
|
onCheckedChange={() => toggleStudentSelection(s.id)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{s.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{s.email}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="classroom" className="mt-3">
|
||||||
|
<div className="max-h-[320px] overflow-y-auto space-y-2">
|
||||||
|
{allBatches.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
No classrooms found. Create one on the Classrooms page first.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
allBatches.map((b) => (
|
||||||
|
<label
|
||||||
|
key={b.id}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||||
|
selectedBatchId === b.id
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedBatchId(selectedBatchId === b.id ? null : b.id)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="batch"
|
||||||
|
checked={selectedBatchId === b.id}
|
||||||
|
onChange={() => setSelectedBatchId(b.id)}
|
||||||
|
className="accent-primary"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium">{b.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{b.course_name && <span>{b.course_name} · </span>}
|
||||||
|
{b.start_date && <span>{b.start_date} — {b.end_date}</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="shrink-0">
|
||||||
|
<Users className="h-3 w-3 mr-1" />{b.student_count} students
|
||||||
|
</Badge>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedBatchId && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
All students in this classroom will be enrolled in the course with the classroom linked.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader><DialogTitle>Create New Course</DialogTitle></DialogHeader>
|
|
||||||
<div className="space-y-4 py-2">
|
|
||||||
<div><Label>Title *</Label><Input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} placeholder="e.g. IELTS Academic Preparation" /></div>
|
|
||||||
<div><Label>Code</Label><Input value={form.code} onChange={e => setForm(f => ({ ...f, code: e.target.value }))} placeholder="Auto-generated if empty" /></div>
|
|
||||||
<div><Label>Description</Label><Textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} rows={3} /></div>
|
|
||||||
<div><Label>Max Capacity</Label><Input type="number" value={form.max_capacity} onChange={e => setForm(f => ({ ...f, max_capacity: Number(e.target.value) || 30 }))} /></div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setEnrollOpen(false)}>Cancel</Button>
|
||||||
<Button onClick={handleCreate} disabled={createMut.isPending}>{createMut.isPending ? "Creating..." : "Create Course"}</Button>
|
<Button onClick={handleEnroll} disabled={enrollMut.isPending || !canEnroll}>
|
||||||
|
{enrollMut.isPending ? "Enrolling..." : enrollLabel}
|
||||||
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { useFacilities, useCreateFacility, useDeleteFacility, useAssets, useCreateAsset, useDeleteAsset } from "@/hooks/queries";
|
import { useFacilities, useCreateFacility, useUpdateFacility, useDeleteFacility, useAssets, useCreateAsset, useDeleteAsset } from "@/hooks/queries";
|
||||||
import { Search, Plus, Trash2, Building } from "lucide-react";
|
import { Search, Plus, Trash2, Edit, Building } from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
export default function AdminFacilities() {
|
export default function AdminFacilities() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [section, setSection] = useState<"facilities" | "assets">("facilities");
|
const [section, setSection] = useState<"facilities" | "assets">("facilities");
|
||||||
const [facOpen, setFacOpen] = useState(false);
|
const [facOpen, setFacOpen] = useState(false);
|
||||||
|
const [facEditOpen, setFacEditOpen] = useState(false);
|
||||||
|
const [facEditId, setFacEditId] = useState<number | null>(null);
|
||||||
const [assetOpen, setAssetOpen] = useState(false);
|
const [assetOpen, setAssetOpen] = useState(false);
|
||||||
const [facForm, setFacForm] = useState({ name: "", code: "" });
|
const [facForm, setFacForm] = useState({ name: "", code: "" });
|
||||||
const [assetForm, setAssetForm] = useState({ name: "", code: "" });
|
const [assetForm, setAssetForm] = useState({ name: "", code: "" });
|
||||||
@@ -21,6 +23,7 @@ export default function AdminFacilities() {
|
|||||||
const facQ = useFacilities();
|
const facQ = useFacilities();
|
||||||
const assetQ = useAssets();
|
const assetQ = useAssets();
|
||||||
const createFac = useCreateFacility();
|
const createFac = useCreateFacility();
|
||||||
|
const updateFac = useUpdateFacility();
|
||||||
const delFac = useDeleteFacility();
|
const delFac = useDeleteFacility();
|
||||||
const createAsset = useCreateAsset();
|
const createAsset = useCreateAsset();
|
||||||
const delAsset = useDeleteAsset();
|
const delAsset = useDeleteAsset();
|
||||||
@@ -104,21 +107,34 @@ export default function AdminFacilities() {
|
|||||||
<TableCell className="font-medium">{f.name}</TableCell>
|
<TableCell className="font-medium">{f.name}</TableCell>
|
||||||
<TableCell>{f.code}</TableCell>
|
<TableCell>{f.code}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button
|
<div className="flex gap-1">
|
||||||
size="sm"
|
<Button
|
||||||
variant="ghost"
|
size="sm"
|
||||||
className="text-destructive"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!window.confirm("Delete this facility?")) return;
|
setFacEditId(f.id);
|
||||||
delFac.mutate(f.id, {
|
setFacForm({ name: f.name || "", code: f.code || "" });
|
||||||
onSuccess: () => toast({ title: "Deleted" }),
|
setFacEditOpen(true);
|
||||||
onError: (err: Error) =>
|
}}
|
||||||
toast({ title: "Error", description: err.message, variant: "destructive" }),
|
>
|
||||||
});
|
<Edit className="h-4 w-4" />
|
||||||
}}
|
</Button>
|
||||||
>
|
<Button
|
||||||
<Trash2 className="h-4 w-4" />
|
size="sm"
|
||||||
</Button>
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (!window.confirm("Delete this facility?")) return;
|
||||||
|
delFac.mutate(f.id, {
|
||||||
|
onSuccess: () => toast({ title: "Deleted" }),
|
||||||
|
onError: (err: Error) =>
|
||||||
|
toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
@@ -179,13 +195,14 @@ export default function AdminFacilities() {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={createFac.isPending}
|
disabled={createFac.isPending || !facForm.name.trim()}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
createFac.mutate(
|
createFac.mutate(
|
||||||
{ name: facForm.name, code: facForm.code || undefined },
|
{ name: facForm.name.trim(), code: facForm.code || undefined },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setFacOpen(false);
|
setFacOpen(false);
|
||||||
|
setFacForm({ name: "", code: "" });
|
||||||
toast({ title: "Created successfully" });
|
toast({ title: "Created successfully" });
|
||||||
},
|
},
|
||||||
onError: (err: Error) =>
|
onError: (err: Error) =>
|
||||||
@@ -200,6 +217,50 @@ export default function AdminFacilities() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={facEditOpen} onOpenChange={setFacEditOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit facility</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input value={facForm.name} onChange={(e) => setFacForm((f) => ({ ...f, name: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Code</Label>
|
||||||
|
<Input value={facForm.code} onChange={(e) => setFacForm((f) => ({ ...f, code: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setFacEditOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={updateFac.isPending || !facForm.name.trim() || !facEditId}
|
||||||
|
onClick={() => {
|
||||||
|
if (!facEditId) return;
|
||||||
|
updateFac.mutate(
|
||||||
|
{ id: facEditId, data: { name: facForm.name.trim(), code: facForm.code || undefined } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setFacEditOpen(false);
|
||||||
|
setFacEditId(null);
|
||||||
|
setFacForm({ name: "", code: "" });
|
||||||
|
toast({ title: "Updated successfully" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) =>
|
||||||
|
toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={assetOpen} onOpenChange={setAssetOpen}>
|
<Dialog open={assetOpen} onOpenChange={setAssetOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -226,13 +287,14 @@ export default function AdminFacilities() {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={createAsset.isPending}
|
disabled={createAsset.isPending || !assetForm.name.trim()}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
createAsset.mutate(
|
createAsset.mutate(
|
||||||
{ name: assetForm.name, code: assetForm.code || undefined },
|
{ name: assetForm.name.trim(), code: assetForm.code || undefined },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setAssetOpen(false);
|
setAssetOpen(false);
|
||||||
|
setAssetForm({ name: "", code: "" });
|
||||||
toast({ title: "Created successfully" });
|
toast({ title: "Created successfully" });
|
||||||
},
|
},
|
||||||
onError: (err: Error) =>
|
onError: (err: Error) =>
|
||||||
|
|||||||
@@ -1,40 +1,102 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useFeesPlans, useStudentFees } from "@/hooks/queries";
|
import {
|
||||||
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogFooter,
|
||||||
|
DialogHeader, DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { useFeesPlans, useStudentFees, useFeesPlan } from "@/hooks/queries";
|
||||||
import { feesService } from "@/services/fees.service";
|
import { feesService } from "@/services/fees.service";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Search, DollarSign, CreditCard, Eye, FileText, Wallet, Loader2 } from "lucide-react";
|
||||||
import { useFeesPlan } from "@/hooks/queries";
|
import type { FeesPlan } from "@/types/fees";
|
||||||
import { Search, DollarSign, CreditCard, Eye } from "lucide-react";
|
|
||||||
|
|
||||||
function planStateBadge(state: string) {
|
function stateBadge(state: string) {
|
||||||
const s = state?.toLowerCase();
|
const s = (state || "").toLowerCase();
|
||||||
if (s === "draft") return <Badge variant="secondary">{state}</Badge>;
|
if (s === "draft") return <Badge variant="secondary">Draft</Badge>;
|
||||||
if (s === "ongoing") return <Badge variant="default">{state}</Badge>;
|
if (s === "invoice")
|
||||||
if (s === "done")
|
return <Badge variant="outline" className="border-blue-500 text-blue-600">Invoiced</Badge>;
|
||||||
return (
|
if (s === "cancel") return <Badge variant="destructive">Cancelled</Badge>;
|
||||||
<Badge variant="outline" className="border-green-600 text-green-700">
|
return <Badge variant="outline" className="capitalize">{state || "—"}</Badge>;
|
||||||
{state}
|
}
|
||||||
</Badge>
|
|
||||||
);
|
function paymentBadge(state?: string) {
|
||||||
return <Badge variant="outline">{state}</Badge>;
|
const s = (state || "").toLowerCase();
|
||||||
|
if (s === "paid")
|
||||||
|
return <Badge className="bg-emerald-500 hover:bg-emerald-500/90 text-white">Paid</Badge>;
|
||||||
|
if (s === "in_payment")
|
||||||
|
return <Badge className="bg-amber-500 hover:bg-amber-500/90 text-white">In payment</Badge>;
|
||||||
|
if (s === "partial")
|
||||||
|
return <Badge className="bg-sky-500 hover:bg-sky-500/90 text-white">Partial</Badge>;
|
||||||
|
if (s === "reversed") return <Badge variant="destructive">Reversed</Badge>;
|
||||||
|
if (s === "not_paid")
|
||||||
|
return <Badge variant="outline" className="border-rose-500 text-rose-600">Not paid</Badge>;
|
||||||
|
return <Badge variant="outline" className="capitalize">{state || "—"}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(n: number | undefined, currency?: string) {
|
||||||
|
const v = Number(n ?? 0);
|
||||||
|
return `${v.toFixed(2)}${currency ? ` ${currency}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminFees() {
|
export default function AdminFees() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [section, setSection] = useState<"plans" | "student">("plans");
|
const [section, setSection] = useState<"plans" | "student">("plans");
|
||||||
const [detailId, setDetailId] = useState<number | null>(null);
|
const [detailId, setDetailId] = useState<number | null>(null);
|
||||||
|
const [paymentFor, setPaymentFor] = useState<FeesPlan | null>(null);
|
||||||
|
const [paymentAmount, setPaymentAmount] = useState("");
|
||||||
|
const [paymentMemo, setPaymentMemo] = useState("");
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const plansQ = useFeesPlans();
|
const qc = useQueryClient();
|
||||||
const feesQ = useStudentFees();
|
|
||||||
|
const plansQ = useFeesPlans({ q: search || undefined, size: 200 });
|
||||||
|
const feesQ = useStudentFees({ q: search || undefined, size: 200 });
|
||||||
const detailQ = useFeesPlan(detailId ?? 0);
|
const detailQ = useFeesPlan(detailId ?? 0);
|
||||||
|
|
||||||
|
const plans = (plansQ.data?.data ?? plansQ.data?.items ?? []) as FeesPlan[];
|
||||||
|
const fees = (feesQ.data?.data ?? feesQ.data?.items ?? []) as FeesPlan[];
|
||||||
const loading = section === "plans" ? plansQ.isLoading : feesQ.isLoading;
|
const loading = section === "plans" ? plansQ.isLoading : feesQ.isLoading;
|
||||||
const plans = plansQ.data?.data ?? plansQ.data?.items ?? [];
|
|
||||||
const fees = feesQ.data?.data ?? feesQ.data?.items ?? [];
|
const invalidateAll = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["fees-plans"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["student-fees"] });
|
||||||
|
if (detailId) qc.invalidateQueries({ queryKey: ["fees-plan", detailId] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const invoiceMut = useMutation({
|
||||||
|
mutationFn: (id: number) => feesService.createInvoice(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Invoice created", description: "Accounting entry has been generated." });
|
||||||
|
invalidateAll();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast({ title: "Invoice failed", description: e.message, variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const paymentMut = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!paymentFor) throw new Error("No plan selected");
|
||||||
|
const amt = Number(paymentAmount);
|
||||||
|
return feesService.registerPayment(paymentFor.id, {
|
||||||
|
amount: Number.isFinite(amt) && amt > 0 ? amt : undefined,
|
||||||
|
memo: paymentMemo || undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Payment registered", description: "The invoice balance has been updated." });
|
||||||
|
setPaymentFor(null);
|
||||||
|
setPaymentAmount("");
|
||||||
|
setPaymentMemo("");
|
||||||
|
invalidateAll();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast({ title: "Payment failed", description: e.message, variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -45,18 +107,27 @@ export default function AdminFees() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
const filteredPlans = plans.filter(
|
const rows = section === "plans" ? plans : fees;
|
||||||
|
const filtered = rows.filter(
|
||||||
(p) =>
|
(p) =>
|
||||||
p.student_name?.toLowerCase().includes(q) || p.course_name?.toLowerCase().includes(q),
|
(p.student_name || "").toLowerCase().includes(q) ||
|
||||||
|
(p.course_name || "").toLowerCase().includes(q) ||
|
||||||
|
(p.product_name || "").toLowerCase().includes(q) ||
|
||||||
|
(p.invoice_number || "").toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
const filteredFees = fees.filter((f) => f.student_name?.toLowerCase().includes(q));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Fees</h1>
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
<p className="text-muted-foreground">Fee plans and student fee lines.</p>
|
<DollarSign className="h-7 w-7" />
|
||||||
|
Fees & Payments
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Fee plans, linked invoices and payment status. Paid and remaining balances are pulled live from accounting.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant={section === "plans" ? "default" : "outline"} onClick={() => setSection("plans")}>
|
<Button variant={section === "plans" ? "default" : "outline"} onClick={() => setSection("plans")}>
|
||||||
<DollarSign className="mr-2 h-4 w-4" />
|
<DollarSign className="mr-2 h-4 w-4" />
|
||||||
@@ -67,101 +138,174 @@ export default function AdminFees() {
|
|||||||
Student fees
|
Student fees
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative max-w-sm">
|
<div className="relative max-w-sm">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder={section === "plans" ? "Search plans..." : "Search student fees..."}
|
placeholder="Search students, courses, invoice numbers..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{section === "plans" ? (
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-12">#</TableHead>
|
||||||
|
<TableHead>Student</TableHead>
|
||||||
|
<TableHead>Item / Course</TableHead>
|
||||||
|
<TableHead>Invoice</TableHead>
|
||||||
|
<TableHead className="text-right">Total</TableHead>
|
||||||
|
<TableHead className="text-right">Paid</TableHead>
|
||||||
|
<TableHead className="text-right">Remaining</TableHead>
|
||||||
|
<TableHead>Plan state</TableHead>
|
||||||
|
<TableHead>Payment</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>#</TableHead>
|
<TableCell colSpan={10} className="text-center text-muted-foreground py-10">
|
||||||
<TableHead>Student</TableHead>
|
No fees records match your search.
|
||||||
<TableHead>Course</TableHead>
|
</TableCell>
|
||||||
<TableHead>Total</TableHead>
|
|
||||||
<TableHead>Paid</TableHead>
|
|
||||||
<TableHead>Remaining</TableHead>
|
|
||||||
<TableHead>State</TableHead>
|
|
||||||
<TableHead>Actions</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
)}
|
||||||
<TableBody>
|
{filtered.map((p, i) => {
|
||||||
{filteredPlans.map((p, i) => (
|
const hasInvoice = !!p.invoice_id;
|
||||||
|
const isPaid = (p.payment_state || "").toLowerCase() === "paid";
|
||||||
|
const canInvoice = !hasInvoice && p.state !== "cancel";
|
||||||
|
const canPay = hasInvoice && !isPaid;
|
||||||
|
return (
|
||||||
<TableRow key={p.id}>
|
<TableRow key={p.id}>
|
||||||
<TableCell>{i + 1}</TableCell>
|
<TableCell>{i + 1}</TableCell>
|
||||||
<TableCell className="font-medium">{p.student_name}</TableCell>
|
<TableCell className="font-medium">{p.student_name || "—"}</TableCell>
|
||||||
<TableCell>{p.course_name}</TableCell>
|
<TableCell className="text-muted-foreground">
|
||||||
<TableCell>{p.total_amount}</TableCell>
|
{p.product_name || p.course_name || "—"}
|
||||||
<TableCell>{p.paid_amount}</TableCell>
|
</TableCell>
|
||||||
<TableCell>{p.remaining_amount}</TableCell>
|
<TableCell className="text-xs">
|
||||||
<TableCell>{planStateBadge(p.state)}</TableCell>
|
{p.invoice_number ? p.invoice_number : <span className="text-muted-foreground">—</span>}
|
||||||
<TableCell>
|
</TableCell>
|
||||||
<Button size="sm" variant="ghost" onClick={() => setDetailId(p.id)}>
|
<TableCell className="text-right tabular-nums">{money(p.total_amount, p.currency)}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{money(p.paid_amount, p.currency)}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{money(p.remaining_amount, p.currency)}</TableCell>
|
||||||
|
<TableCell>{stateBadge(p.state)}</TableCell>
|
||||||
|
<TableCell>{paymentBadge(p.payment_state)}</TableCell>
|
||||||
|
<TableCell className="text-right space-x-1">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setDetailId(p.id)} title="View">
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!canInvoice || invoiceMut.isPending}
|
||||||
|
onClick={() => invoiceMut.mutate(p.id)}
|
||||||
|
title="Create invoice"
|
||||||
|
>
|
||||||
|
{invoiceMut.isPending && invoiceMut.variables === p.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
disabled={!canPay}
|
||||||
|
onClick={() => {
|
||||||
|
setPaymentFor(p);
|
||||||
|
setPaymentAmount(String(p.remaining_amount ?? ""));
|
||||||
|
setPaymentMemo(`Payment for ${p.product_name || p.course_name || "fees"}`);
|
||||||
|
}}
|
||||||
|
title="Register payment"
|
||||||
|
>
|
||||||
|
<Wallet className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
);
|
||||||
</TableBody>
|
})}
|
||||||
</Table>
|
</TableBody>
|
||||||
</CardContent>
|
</Table>
|
||||||
</Card>
|
</CardContent>
|
||||||
) : (
|
</Card>
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>#</TableHead>
|
|
||||||
<TableHead>Student</TableHead>
|
|
||||||
<TableHead>Amount</TableHead>
|
|
||||||
<TableHead>Date</TableHead>
|
|
||||||
<TableHead>State</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredFees.map((f, i) => (
|
|
||||||
<TableRow key={f.id}>
|
|
||||||
<TableCell>{i + 1}</TableCell>
|
|
||||||
<TableCell className="font-medium">{f.student_name}</TableCell>
|
|
||||||
<TableCell>{f.amount}</TableCell>
|
|
||||||
<TableCell>{f.date}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline" className="capitalize">
|
|
||||||
{f.state}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
<Dialog open={!!detailId} onOpenChange={(v) => { if (!v) setDetailId(null); }}>
|
<Dialog open={!!detailId} onOpenChange={(v) => { if (!v) setDetailId(null); }}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader><DialogTitle>Fee Plan Details</DialogTitle></DialogHeader>
|
<DialogHeader>
|
||||||
|
<DialogTitle>Fee Plan Details</DialogTitle>
|
||||||
|
<DialogDescription>Accounting-backed balance for this fee line.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
{detailQ.isLoading ? (
|
{detailQ.isLoading ? (
|
||||||
<div className="flex justify-center py-4"><div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" /></div>
|
<div className="flex justify-center py-4">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
) : detailQ.data ? (
|
) : detailQ.data ? (
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<p><span className="font-medium">Student:</span> {detailQ.data.student_name}</p>
|
<p><span className="font-medium">Student:</span> {detailQ.data.student_name || "—"}</p>
|
||||||
<p><span className="font-medium">Course:</span> {detailQ.data.course_name}</p>
|
<p><span className="font-medium">Item / Course:</span> {detailQ.data.product_name || detailQ.data.course_name || "—"}</p>
|
||||||
<p><span className="font-medium">Total:</span> {detailQ.data.total_amount}</p>
|
<p><span className="font-medium">Invoice:</span> {detailQ.data.invoice_number || "Not created"}</p>
|
||||||
<p><span className="font-medium">Paid:</span> {detailQ.data.paid_amount}</p>
|
<p><span className="font-medium">Total:</span> {money(detailQ.data.total_amount, detailQ.data.currency)}</p>
|
||||||
<p><span className="font-medium">Remaining:</span> {detailQ.data.remaining_amount}</p>
|
<p><span className="font-medium">Paid:</span> {money(detailQ.data.paid_amount, detailQ.data.currency)}</p>
|
||||||
<p><span className="font-medium">State:</span> {planStateBadge(detailQ.data.state)}</p>
|
<p><span className="font-medium">Remaining:</span> {money(detailQ.data.remaining_amount, detailQ.data.currency)}</p>
|
||||||
|
<p className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">State:</span> {stateBadge(detailQ.data.state)}
|
||||||
|
<span>·</span>
|
||||||
|
{paymentBadge(detailQ.data.payment_state)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={!!paymentFor} onOpenChange={(v) => { if (!v) { setPaymentFor(null); setPaymentAmount(""); setPaymentMemo(""); } }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Register payment</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{paymentFor
|
||||||
|
? `Invoice ${paymentFor.invoice_number || ""} for ${paymentFor.student_name} · remaining ${money(paymentFor.remaining_amount, paymentFor.currency)}`
|
||||||
|
: ""}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Amount</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={paymentAmount}
|
||||||
|
onChange={(e) => setPaymentAmount(e.target.value)}
|
||||||
|
placeholder="Leave empty to settle the full remaining balance"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Memo</Label>
|
||||||
|
<Input
|
||||||
|
value={paymentMemo}
|
||||||
|
onChange={(e) => setPaymentMemo(e.target.value)}
|
||||||
|
placeholder="Payment communication"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setPaymentFor(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
disabled={paymentMut.isPending}
|
||||||
|
onClick={() => paymentMut.mutate()}
|
||||||
|
>
|
||||||
|
{paymentMut.isPending ? (
|
||||||
|
<><Loader2 className="h-4 w-4 animate-spin mr-2" />Registering...</>
|
||||||
|
) : (
|
||||||
|
"Register payment"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { useGradebooks, useGradingAssignments, useCreateGradingAssignment, useUpdateGradingAssignment, useDeleteGradingAssignment, useCourses, useSubjects } from "@/hooks/queries";
|
import { useGradebooks, useGradebookLines, useGradingAssignments, useCreateGradingAssignment, useUpdateGradingAssignment, useDeleteGradingAssignment, useCourses, useSubjects } from "@/hooks/queries";
|
||||||
import { Search, Plus, BookOpen, Pencil, Trash2 } from "lucide-react";
|
import { Search, Plus, BookOpen, Pencil, Trash2 } from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ export default function AdminGradebook() {
|
|||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editId, setEditId] = useState<number | null>(null);
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
const [form, setForm] = useState({ name: "", course_id: "", subject_id: "", issued_date: "" });
|
const [form, setForm] = useState({ name: "", course_id: "", subject_id: "", issued_date: "" });
|
||||||
|
const [drillId, setDrillId] = useState<number | null>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const booksQ = useGradebooks();
|
const booksQ = useGradebooks();
|
||||||
const assignQ = useGradingAssignments();
|
const assignQ = useGradingAssignments();
|
||||||
@@ -30,6 +31,9 @@ export default function AdminGradebook() {
|
|||||||
const loading = section === "books" ? booksQ.isLoading : assignQ.isLoading;
|
const loading = section === "books" ? booksQ.isLoading : assignQ.isLoading;
|
||||||
const books = booksQ.data?.data ?? booksQ.data?.items ?? [];
|
const books = booksQ.data?.data ?? booksQ.data?.items ?? [];
|
||||||
const assignments = assignQ.data?.data ?? assignQ.data?.items ?? [];
|
const assignments = assignQ.data?.data ?? assignQ.data?.items ?? [];
|
||||||
|
const drillQ = useGradebookLines(drillId ? { gradebook_id: drillId, size: 200 } : undefined);
|
||||||
|
const drillLines = drillQ.data?.data ?? drillQ.data?.items ?? [];
|
||||||
|
const drillBook = drillId ? books.find((b) => b.id === drillId) : null;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -100,7 +104,7 @@ export default function AdminGradebook() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredBooks.map((b, i) => (
|
{filteredBooks.map((b, i) => (
|
||||||
<TableRow key={b.id}>
|
<TableRow key={b.id} className="cursor-pointer hover:bg-accent/40" onClick={() => setDrillId(b.id)}>
|
||||||
<TableCell>{i + 1}</TableCell>
|
<TableCell>{i + 1}</TableCell>
|
||||||
<TableCell className="font-medium">{b.student_name}</TableCell>
|
<TableCell className="font-medium">{b.student_name}</TableCell>
|
||||||
<TableCell>{b.course_name}</TableCell>
|
<TableCell>{b.course_name}</TableCell>
|
||||||
@@ -157,6 +161,49 @@ export default function AdminGradebook() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Dialog open={!!drillId} onOpenChange={(v) => { if (!v) setDrillId(null); }}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Gradebook{drillBook ? ` — ${drillBook.student_name} · ${drillBook.course_name}` : ""}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{drillQ.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-10">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
) : drillLines.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-4">No gradebook lines recorded yet.</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>#</TableHead>
|
||||||
|
<TableHead>Assignment</TableHead>
|
||||||
|
<TableHead>Marks</TableHead>
|
||||||
|
<TableHead>%</TableHead>
|
||||||
|
<TableHead>State</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{drillLines.map((l, i) => (
|
||||||
|
<TableRow key={l.id}>
|
||||||
|
<TableCell>{i + 1}</TableCell>
|
||||||
|
<TableCell className="font-medium">{l.assignment_name || "—"}</TableCell>
|
||||||
|
<TableCell>{typeof l.marks === "number" ? l.marks : "—"}</TableCell>
|
||||||
|
<TableCell>{typeof l.percentage === "number" ? l.percentage.toFixed(1) : "—"}</TableCell>
|
||||||
|
<TableCell><Badge variant="outline" className="capitalize">{l.state || "draft"}</Badge></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDrillId(null)}>Close</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={createOpen} onOpenChange={(v) => { setCreateOpen(v); if (!v) setEditId(null); }}>
|
<Dialog open={createOpen} onOpenChange={(v) => { setCreateOpen(v); if (!v) setEditId(null); }}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ export default function AdminLessons() {
|
|||||||
const openEdit = (l: Lesson) => {
|
const openEdit = (l: Lesson) => {
|
||||||
setEditing(l);
|
setEditing(l);
|
||||||
setForm({
|
setForm({
|
||||||
lesson_topic: l.name,
|
lesson_topic: l.lesson_topic || l.name,
|
||||||
course_id: "",
|
course_id: l.course_id ? String(l.course_id) : "",
|
||||||
batch_id: "",
|
batch_id: l.batch_id ? String(l.batch_id) : "",
|
||||||
subject_id: String(l.subject_id ?? ""),
|
subject_id: String(l.subject_id ?? ""),
|
||||||
});
|
});
|
||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
@@ -220,6 +220,26 @@ export default function AdminLessons() {
|
|||||||
<Label>Lesson Topic</Label>
|
<Label>Lesson Topic</Label>
|
||||||
<Input value={form.lesson_topic} onChange={(e) => setForm((f) => ({ ...f, lesson_topic: e.target.value }))} />
|
<Input value={form.lesson_topic} onChange={(e) => setForm((f) => ({ ...f, lesson_topic: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Course</Label>
|
||||||
|
<Select value={form.course_id || "__none__"} onValueChange={(v) => setForm((f) => ({ ...f, course_id: v === "__none__" ? "" : v, batch_id: "" }))}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">— Select —</SelectItem>
|
||||||
|
{courses.map((c) => <SelectItem key={c.id} value={String(c.id)}>{c.title}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Batch</Label>
|
||||||
|
<Select value={form.batch_id || "__none__"} onValueChange={(v) => setForm((f) => ({ ...f, batch_id: v === "__none__" ? "" : v }))} disabled={!form.course_id}>
|
||||||
|
<SelectTrigger><SelectValue placeholder={form.course_id ? "Select batch" : "Pick course first"} /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">— Select —</SelectItem>
|
||||||
|
{batchesForCourse.map((b) => <SelectItem key={b.id} value={String(b.id)}>{b.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Subject</Label>
|
<Label>Subject</Label>
|
||||||
<Select value={form.subject_id || "__none__"} onValueChange={(v) => setForm((f) => ({ ...f, subject_id: v === "__none__" ? "" : v }))}>
|
<Select value={form.subject_id || "__none__"} onValueChange={(v) => setForm((f) => ({ ...f, subject_id: v === "__none__" ? "" : v }))}>
|
||||||
@@ -245,6 +265,8 @@ export default function AdminLessons() {
|
|||||||
data: {
|
data: {
|
||||||
lesson_topic: form.lesson_topic,
|
lesson_topic: form.lesson_topic,
|
||||||
name: form.lesson_topic,
|
name: form.lesson_topic,
|
||||||
|
course_id: form.course_id ? Number(form.course_id) : undefined,
|
||||||
|
batch_id: form.batch_id ? Number(form.batch_id) : undefined,
|
||||||
subject_id: form.subject_id ? Number(form.subject_id) : undefined,
|
subject_id: form.subject_id ? Number(form.subject_id) : undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { useLibraryMedia, useCreateMedia, useDeleteMedia, useLibraryMovements, useCreateMovement, useReturnMovement, useLibraryCards, useCreateCard } from "@/hooks/queries";
|
import { useLibraryMedia, useCreateMedia, useDeleteMedia, useLibraryMovements, useCreateMovement, useReturnMovement, useLibraryCards, useCreateCard, useStudents } from "@/hooks/queries";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Search, Plus, Book, ArrowUpDown, Trash2, RotateCcw } from "lucide-react";
|
import { Search, Plus, Book, ArrowUpDown, Trash2, RotateCcw } from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
@@ -31,6 +32,8 @@ export default function AdminLibrary() {
|
|||||||
const createMoveMut = useCreateMovement();
|
const createMoveMut = useCreateMovement();
|
||||||
const returnMoveMut = useReturnMovement();
|
const returnMoveMut = useReturnMovement();
|
||||||
const createCardMut = useCreateCard();
|
const createCardMut = useCreateCard();
|
||||||
|
const studentsQ = useStudents({ size: 500 });
|
||||||
|
const students = studentsQ.data?.items ?? [];
|
||||||
|
|
||||||
const loading =
|
const loading =
|
||||||
tab === "media" ? mediaQ.isLoading : tab === "movements" ? movQ.isLoading : cardsQ.isLoading;
|
tab === "media" ? mediaQ.isLoading : tab === "movements" ? movQ.isLoading : cardsQ.isLoading;
|
||||||
@@ -258,11 +261,11 @@ export default function AdminLibrary() {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={createMediaMut.isPending}
|
disabled={createMediaMut.isPending || !mediaForm.name.trim()}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
createMediaMut.mutate(
|
createMediaMut.mutate(
|
||||||
{
|
{
|
||||||
name: mediaForm.name,
|
name: mediaForm.name.trim(),
|
||||||
isbn: mediaForm.isbn || undefined,
|
isbn: mediaForm.isbn || undefined,
|
||||||
author: mediaForm.author || undefined,
|
author: mediaForm.author || undefined,
|
||||||
edition: mediaForm.edition || undefined,
|
edition: mediaForm.edition || undefined,
|
||||||
@@ -270,6 +273,7 @@ export default function AdminLibrary() {
|
|||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setMediaOpen(false);
|
setMediaOpen(false);
|
||||||
|
setMediaForm({ name: "", isbn: "", author: "", edition: "" });
|
||||||
toast({ title: "Created successfully" });
|
toast({ title: "Created successfully" });
|
||||||
},
|
},
|
||||||
onError: (err: Error) =>
|
onError: (err: Error) =>
|
||||||
@@ -291,17 +295,38 @@ export default function AdminLibrary() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Media ID</Label>
|
<Label>Media</Label>
|
||||||
<Input type="number" value={moveForm.media_id} onChange={(e) => setMoveForm((f) => ({ ...f, media_id: e.target.value }))} />
|
<Select value={moveForm.media_id || "__none__"} onValueChange={(v) => setMoveForm((f) => ({ ...f, media_id: v === "__none__" ? "" : v }))}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select media" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">— Select —</SelectItem>
|
||||||
|
{media.map((m) => (<SelectItem key={m.id} value={String(m.id)}>{m.name}</SelectItem>))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Student ID</Label>
|
<Label>Student</Label>
|
||||||
<Input type="number" value={moveForm.student_id} onChange={(e) => setMoveForm((f) => ({ ...f, student_id: e.target.value }))} />
|
<Select value={moveForm.student_id || "__none__"} onValueChange={(v) => setMoveForm((f) => ({ ...f, student_id: v === "__none__" ? "" : v }))}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select student" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">— Select —</SelectItem>
|
||||||
|
{students.map((s) => (<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setMoveOpen(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setMoveOpen(false)}>Cancel</Button>
|
||||||
<Button disabled={createMoveMut.isPending} onClick={() => createMoveMut.mutate({ media_id: Number(moveForm.media_id), student_id: Number(moveForm.student_id) }, { onSuccess: () => { setMoveOpen(false); toast({ title: "Issued" }); }, onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }) })}>
|
<Button
|
||||||
|
disabled={createMoveMut.isPending || !moveForm.media_id || !moveForm.student_id}
|
||||||
|
onClick={() => createMoveMut.mutate(
|
||||||
|
{ media_id: Number(moveForm.media_id), student_id: Number(moveForm.student_id) },
|
||||||
|
{
|
||||||
|
onSuccess: () => { setMoveOpen(false); setMoveForm({ media_id: "", student_id: "" }); toast({ title: "Issued" }); },
|
||||||
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
Issue
|
Issue
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -312,8 +337,14 @@ export default function AdminLibrary() {
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader><DialogTitle>Create library card</DialogTitle></DialogHeader>
|
<DialogHeader><DialogTitle>Create library card</DialogTitle></DialogHeader>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Student ID</Label>
|
<Label>Student</Label>
|
||||||
<Input type="number" value={cardStudentId} onChange={(e) => setCardStudentId(e.target.value)} />
|
<Select value={cardStudentId || "__none__"} onValueChange={(v) => setCardStudentId(v === "__none__" ? "" : v)}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select student" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">— Select —</SelectItem>
|
||||||
|
{students.map((s) => (<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setCardOpen(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setCardOpen(false)}>Cancel</Button>
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ export default function AdminStudentLeave() {
|
|||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
|
setForm({ student_id: "", leave_type: "", start_date: "", end_date: "", description: "" });
|
||||||
toast({ title: "Created successfully" });
|
toast({ title: "Created successfully" });
|
||||||
},
|
},
|
||||||
onError: (err: Error) =>
|
onError: (err: Error) =>
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { useStudentProgressList } from "@/hooks/queries";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Search, TrendingUp } from "lucide-react";
|
import { useStudentProgressList, useStudentProgressDetail } from "@/hooks/queries";
|
||||||
|
import { Search, TrendingUp, Loader2 } from "lucide-react";
|
||||||
|
import type { StudentProgression } from "@/types/student-progress";
|
||||||
|
|
||||||
|
function rateColor(rate: number | null | undefined): string {
|
||||||
|
if (rate == null) return "bg-muted text-muted-foreground";
|
||||||
|
if (rate >= 85) return "bg-emerald-100 text-emerald-700 border-emerald-200";
|
||||||
|
if (rate >= 60) return "bg-amber-100 text-amber-700 border-amber-200";
|
||||||
|
return "bg-red-100 text-red-700 border-red-200";
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminStudentProgress() {
|
export default function AdminStudentProgress() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const { data, isLoading } = useStudentProgressList();
|
const [drillId, setDrillId] = useState<number | null>(null);
|
||||||
const items = data?.data ?? data?.items ?? [];
|
const { data, isLoading } = useStudentProgressList({ q: search || undefined, size: 200 });
|
||||||
|
const items = (data?.data ?? data?.items ?? []) as StudentProgression[];
|
||||||
|
|
||||||
|
const { data: detail, isLoading: loadingDetail } = useStudentProgressDetail(drillId);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -18,9 +31,6 @@ export default function AdminStudentProgress() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const q = search.toLowerCase();
|
|
||||||
const filtered = items.filter((r) => r.student_name?.toLowerCase().includes(q));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -28,12 +38,14 @@ export default function AdminStudentProgress() {
|
|||||||
<TrendingUp className="h-7 w-7" />
|
<TrendingUp className="h-7 w-7" />
|
||||||
Student progress
|
Student progress
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">Attendance, assignments, and marksheets per student.</p>
|
<p className="text-muted-foreground">
|
||||||
|
Per-student attendance, assignments and marksheet activity across the institution. Click a row for the breakdown.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative max-w-sm">
|
<div className="relative max-w-sm">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search students..."
|
placeholder="Search students or GR number..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
@@ -44,27 +56,159 @@ export default function AdminStudentProgress() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>#</TableHead>
|
<TableHead className="w-12">#</TableHead>
|
||||||
<TableHead>Student</TableHead>
|
<TableHead>Student</TableHead>
|
||||||
<TableHead>Total Attendance</TableHead>
|
<TableHead>GR No.</TableHead>
|
||||||
<TableHead>Total Assignments</TableHead>
|
<TableHead className="text-right">Attendance</TableHead>
|
||||||
<TableHead>Total Marksheets</TableHead>
|
<TableHead className="text-right">Assignments</TableHead>
|
||||||
|
<TableHead className="text-right">Marksheet lines</TableHead>
|
||||||
|
<TableHead className="text-right">Avg marks</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filtered.map((r, i) => (
|
{items.length === 0 && (
|
||||||
<TableRow key={r.id}>
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center text-muted-foreground py-10">
|
||||||
|
No students match your search.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{items.map((r, i) => (
|
||||||
|
<TableRow
|
||||||
|
key={r.id}
|
||||||
|
className="cursor-pointer hover:bg-accent/50"
|
||||||
|
onClick={() => setDrillId(r.id)}
|
||||||
|
>
|
||||||
<TableCell>{i + 1}</TableCell>
|
<TableCell>{i + 1}</TableCell>
|
||||||
<TableCell className="font-medium">{r.student_name}</TableCell>
|
<TableCell className="font-medium">{r.student_name || "—"}</TableCell>
|
||||||
<TableCell>{r.total_attendance}</TableCell>
|
<TableCell className="text-muted-foreground text-xs">{r.gr_no || "—"}</TableCell>
|
||||||
<TableCell>{r.total_assignment}</TableCell>
|
<TableCell className="text-right">
|
||||||
<TableCell>{r.total_marksheet_line}</TableCell>
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{r.total_attendance_present ?? 0}/{r.total_attendance ?? 0}
|
||||||
|
</span>
|
||||||
|
{r.attendance_rate != null && (
|
||||||
|
<Badge variant="outline" className={rateColor(r.attendance_rate)}>
|
||||||
|
{r.attendance_rate}%
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{r.total_assignment ?? 0}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{r.total_marksheet_line ?? 0}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{r.avg_marks != null ? r.avg_marks : "—"}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={!!drillId} onOpenChange={(open) => !open && setDrillId(null)}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{detail?.student_name || "Student breakdown"}
|
||||||
|
{detail?.gr_no ? (
|
||||||
|
<span className="text-sm text-muted-foreground ml-2">GR {detail.gr_no}</span>
|
||||||
|
) : null}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{loadingDetail && (
|
||||||
|
<div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
|
||||||
|
)}
|
||||||
|
{detail && !loadingDetail && (
|
||||||
|
<div className="space-y-6 max-h-[70vh] overflow-y-auto">
|
||||||
|
<section>
|
||||||
|
<h3 className="text-sm font-medium mb-2">Recent attendance ({detail.attendance.length})</h3>
|
||||||
|
<div className="rounded border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Sheet</TableHead>
|
||||||
|
<TableHead className="text-right">Present</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{detail.attendance.length === 0 && (
|
||||||
|
<TableRow><TableCell colSpan={2} className="text-center text-muted-foreground py-4">No attendance records.</TableCell></TableRow>
|
||||||
|
)}
|
||||||
|
{detail.attendance.map((a) => (
|
||||||
|
<TableRow key={a.id}>
|
||||||
|
<TableCell className="text-sm">{a.sheet || "—"}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{a.present == null ? "—" : a.present ? (
|
||||||
|
<Badge variant="outline" className="bg-emerald-50 text-emerald-700 border-emerald-200">Present</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200">Absent</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="text-sm font-medium mb-2">Recent assignments ({detail.assignments.length})</h3>
|
||||||
|
<div className="rounded border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Assignment</TableHead>
|
||||||
|
<TableHead className="text-right">Marks</TableHead>
|
||||||
|
<TableHead className="text-right">State</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{detail.assignments.length === 0 && (
|
||||||
|
<TableRow><TableCell colSpan={3} className="text-center text-muted-foreground py-4">No assignment submissions.</TableCell></TableRow>
|
||||||
|
)}
|
||||||
|
{detail.assignments.map((a) => (
|
||||||
|
<TableRow key={a.id}>
|
||||||
|
<TableCell className="text-sm">{a.assignment || "—"}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{a.marks ?? 0}</TableCell>
|
||||||
|
<TableCell className="text-right"><Badge variant="outline" className="capitalize">{a.state || "—"}</Badge></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="text-sm font-medium mb-2">Marksheet lines ({detail.marksheets.length})</h3>
|
||||||
|
<div className="rounded border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Marksheet</TableHead>
|
||||||
|
<TableHead className="text-right">Marks</TableHead>
|
||||||
|
<TableHead className="text-right">Grade</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{detail.marksheets.length === 0 && (
|
||||||
|
<TableRow><TableCell colSpan={3} className="text-center text-muted-foreground py-4">No marksheet lines.</TableCell></TableRow>
|
||||||
|
)}
|
||||||
|
{detail.marksheets.map((m) => (
|
||||||
|
<TableRow key={m.id}>
|
||||||
|
<TableCell className="text-sm">{m.marksheet || "—"}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{m.marks ?? 0}</TableCell>
|
||||||
|
<TableCell className="text-right">{m.grade || "—"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function AdmissionRegisterPage() {
|
|||||||
qc.invalidateQueries({ queryKey: ["admission-registers"] });
|
qc.invalidateQueries({ queryKey: ["admission-registers"] });
|
||||||
toast({ title: "Register created" });
|
toast({ title: "Register created" });
|
||||||
setShowCreate(false);
|
setShowCreate(false);
|
||||||
setForm({ name: "", course_id: 0, start_date: "", end_date: "" });
|
setForm({ name: "", course_id: 0, start_date: "", end_date: "", min_count: undefined, max_count: undefined });
|
||||||
},
|
},
|
||||||
onError: () => toast({ title: "Error", description: "Failed to create register", variant: "destructive" }),
|
onError: () => toast({ title: "Error", description: "Failed to create register", variant: "destructive" }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export default function AiEnglishQuality() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
reject.mutate(
|
reject.mutate(
|
||||||
{ courseId, notes },
|
{ courseId, reason: notes },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Rejected; regeneration requested.");
|
toast.success("Rejected; regeneration requested.");
|
||||||
|
|||||||
@@ -38,13 +38,11 @@ export default function AiIeltsValidation() {
|
|||||||
|
|
||||||
const send = (approved: boolean) => {
|
const send = (approved: boolean) => {
|
||||||
if (!preview) return;
|
if (!preview) return;
|
||||||
const payload: Parameters<typeof submitReview.mutate>[0] = {
|
submitReview.mutate({
|
||||||
item_id: preview.id,
|
logId: preview.id,
|
||||||
approved,
|
action: approved ? "approve" : "reject",
|
||||||
notes: approved ? undefined : notes,
|
examiner_notes: approved ? undefined : notes,
|
||||||
checklist,
|
}, {
|
||||||
};
|
|
||||||
submitReview.mutate(payload, {
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(approved ? "Approved." : "Rejected with notes.");
|
toast.success(approved ? "Approved." : "Rejected with notes.");
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
|
|||||||
@@ -583,10 +583,27 @@ function NewQuestionInline({ sectionIndex, form }: { sectionIndex: number; form:
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
disabled={!stem.trim()}
|
||||||
const cur = form.getValues(`sections.${sectionIndex}.question_ids`) ?? [];
|
onClick={async () => {
|
||||||
const nextId = Math.floor(Math.random() * 1_000_000_000);
|
if (!stem.trim()) return;
|
||||||
form.setValue(`sections.${sectionIndex}.question_ids`, [...cur, nextId]);
|
try {
|
||||||
|
const res = await fetch("/api/exam/questions/quick-create", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("encoach_token")}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ stem: stem.trim(), question_type: "short_answer", skill: form.getValues(`sections.${sectionIndex}.skill`) || "reading" }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
const qId = data?.question_id;
|
||||||
|
if (qId) {
|
||||||
|
const cur = form.getValues(`sections.${sectionIndex}.question_ids`) ?? [];
|
||||||
|
form.setValue(`sections.${sectionIndex}.question_ids`, [...cur, qId]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fallback: still add with a placeholder notice
|
||||||
|
}
|
||||||
setStem("");
|
setStem("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -194,11 +194,11 @@ export default function MarksheetManager() {
|
|||||||
<TableCell className="text-muted-foreground">{tpl.result_date}</TableCell>
|
<TableCell className="text-muted-foreground">{tpl.result_date}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={tpl.state === "result_generated" ? "default" : "outline"} className="capitalize">
|
<Badge variant={tpl.state === "result_generated" ? "default" : "outline"} className="capitalize">
|
||||||
{tpl.state.replace("_", " ")}
|
{(tpl.state ?? "draft").replace("_", " ")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{tpl.state === "draft" && (
|
{(tpl.state ?? "draft") === "draft" && (
|
||||||
<Button variant="ghost" size="sm" onClick={() => generateMutation.mutate(tpl.id)} disabled={generateMutation.isPending}>
|
<Button variant="ghost" size="sm" onClick={() => generateMutation.mutate(tpl.id)} disabled={generateMutation.isPending}>
|
||||||
<FileSpreadsheet className="mr-1 h-3 w-3" /> Generate Marksheets
|
<FileSpreadsheet className="mr-1 h-3 w-3" /> Generate Marksheets
|
||||||
</Button>
|
</Button>
|
||||||
@@ -288,7 +288,7 @@ export default function MarksheetManager() {
|
|||||||
<TableRow key={line.id}>
|
<TableRow key={line.id}>
|
||||||
<TableCell className="font-medium">{line.student_name}</TableCell>
|
<TableCell className="font-medium">{line.student_name}</TableCell>
|
||||||
<TableCell>{line.total_marks}</TableCell>
|
<TableCell>{line.total_marks}</TableCell>
|
||||||
<TableCell>{line.percentage.toFixed(1)}%</TableCell>
|
<TableCell>{typeof line.percentage === "number" ? line.percentage.toFixed(1) : "0.0"}%</TableCell>
|
||||||
<TableCell>{line.grade ?? "—"}</TableCell>
|
<TableCell>{line.grade ?? "—"}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={line.status === "pass" ? "default" : "destructive"} className="capitalize">{line.status}</Badge>
|
<Badge variant={line.status === "pass" ? "default" : "destructive"} className="capitalize">{line.status}</Badge>
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Upload, Search, FileText, Video, Link2, Download, Trash2, CheckCircle2, Clock, XCircle, Loader2 } from "lucide-react";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Upload, Search, FileText, Video, Link2, Download, Trash2,
|
||||||
|
CheckCircle2, Clock, XCircle, Loader2, BookOpen, Music, Image,
|
||||||
|
Tag, Plus, Pencil, X, CalendarDays,
|
||||||
|
} from "lucide-react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { resourcesService } from "@/services/resources.service";
|
import { resourcesService } from "@/services/resources.service";
|
||||||
|
import { coursewareService } from "@/services/courseware.service";
|
||||||
|
import { taxonomyService } from "@/services/taxonomy.service";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type { Resource } from "@/types";
|
import { TaxonomyCascade } from "@/components/TaxonomyCascade";
|
||||||
|
import type { Resource, ResourceTag } from "@/types";
|
||||||
|
|
||||||
const statusBadge: Record<string, { variant: "default" | "secondary" | "destructive"; icon: React.ReactNode }> = {
|
const statusBadge: Record<string, { variant: "default" | "secondary" | "destructive"; icon: React.ReactNode }> = {
|
||||||
approved: { variant: "default", icon: <CheckCircle2 className="h-3 w-3 mr-1" /> },
|
approved: { variant: "default", icon: <CheckCircle2 className="h-3 w-3 mr-1" /> },
|
||||||
@@ -25,163 +33,597 @@ const typeIcons: Record<string, React.ReactNode> = {
|
|||||||
link: <Link2 className="h-4 w-4 text-green-500" />,
|
link: <Link2 className="h-4 w-4 text-green-500" />,
|
||||||
document: <FileText className="h-4 w-4 text-orange-500" />,
|
document: <FileText className="h-4 w-4 text-orange-500" />,
|
||||||
interactive: <FileText className="h-4 w-4 text-purple-500" />,
|
interactive: <FileText className="h-4 w-4 text-purple-500" />,
|
||||||
|
article: <FileText className="h-4 w-4 text-indigo-500" />,
|
||||||
|
audio: <Music className="h-4 w-4 text-yellow-500" />,
|
||||||
|
image: <Image className="h-4 w-4 text-pink-500" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TAG_COLORS = [
|
||||||
|
"#3b82f6", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6",
|
||||||
|
"#ec4899", "#06b6d4", "#f97316", "#6366f1", "#14b8a6",
|
||||||
|
];
|
||||||
|
|
||||||
|
function pickColor() {
|
||||||
|
return TAG_COLORS[Math.floor(Math.random() * TAG_COLORS.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reusable Tag Picker with inline create ──────────────────────────
|
||||||
|
function TagPicker({
|
||||||
|
allTags, selectedIds, onToggle, onTagCreated,
|
||||||
|
}: {
|
||||||
|
allTags: ResourceTag[];
|
||||||
|
selectedIds: number[];
|
||||||
|
onToggle: (id: number) => void;
|
||||||
|
onTagCreated: (tag: ResourceTag) => void;
|
||||||
|
}) {
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const tag = await resourcesService.createTag({ name, color: pickColor() });
|
||||||
|
onTagCreated(tag);
|
||||||
|
onToggle(tag.id);
|
||||||
|
setNewName("");
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setCreating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Tags</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{allTags.map((t) => (
|
||||||
|
<Badge
|
||||||
|
key={t.id}
|
||||||
|
variant={selectedIds.includes(t.id) ? "default" : "outline"}
|
||||||
|
className="cursor-pointer select-none transition-colors"
|
||||||
|
style={selectedIds.includes(t.id) ? { backgroundColor: t.color, borderColor: t.color, color: "#fff" } : { borderColor: t.color, color: t.color }}
|
||||||
|
onClick={() => onToggle(t.id)}
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Input
|
||||||
|
placeholder="New tag…"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleCreate(); } }}
|
||||||
|
className="h-8 text-sm flex-1"
|
||||||
|
/>
|
||||||
|
<Button type="button" size="sm" variant="outline" disabled={!newName.trim() || creating} onClick={handleCreate}>
|
||||||
|
{creating ? <Loader2 className="h-3 w-3 animate-spin" /> : <><Plus className="h-3 w-3 mr-1" />Add</>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit Resource Dialog ────────────────────────────────────────────
|
||||||
|
function EditResourceDialog({
|
||||||
|
resource, open, onOpenChange, allTags, onTagCreated,
|
||||||
|
}: {
|
||||||
|
resource: Resource;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
allTags: ResourceTag[];
|
||||||
|
onTagCreated: (tag: ResourceTag) => void;
|
||||||
|
}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [name, setName] = useState(resource.name);
|
||||||
|
const [type, setType] = useState(resource.type || resource.resource_type || "document");
|
||||||
|
const [status, setStatus] = useState(resource.review_status || "approved");
|
||||||
|
const [tagIds, setTagIds] = useState<number[]>(resource.tag_ids ?? resource.tags?.map((t) => t.id) ?? []);
|
||||||
|
|
||||||
|
const [editSubjectId, setEditSubjectId] = useState<string>(resource.subject_id ? String(resource.subject_id) : "none");
|
||||||
|
const [editDomainId, setEditDomainId] = useState<string>(resource.domain_id ? String(resource.domain_id) : "none");
|
||||||
|
const [editTopicIds, setEditTopicIds] = useState<number[]>(resource.topic_ids ?? []);
|
||||||
|
const [editObjectiveIds, setEditObjectiveIds] = useState<number[]>(resource.learning_objective_ids ?? []);
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => resourcesService.update(resource.id, data as Partial<Resource>),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["resources"] });
|
||||||
|
toast({ title: "Resource updated" });
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: () => toast({ title: "Error", description: "Failed to update", variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
updateMutation.mutate({
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
subject_id: editSubjectId !== "none" ? Number(editSubjectId) : null,
|
||||||
|
domain_id: editDomainId !== "none" ? Number(editDomainId) : null,
|
||||||
|
topic_ids: editTopicIds,
|
||||||
|
learning_objective_ids: editObjectiveIds,
|
||||||
|
review_status: status,
|
||||||
|
tag_ids: tagIds,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTag = (id: number) => setTagIds((prev) => prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[540px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader><DialogTitle>Edit Resource</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-4 pt-2">
|
||||||
|
<div className="space-y-2"><Label>Name</Label><Input value={name} onChange={(e) => setName(e.target.value)} /></div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Select value={type} onValueChange={setType}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="pdf">PDF</SelectItem>
|
||||||
|
<SelectItem value="video">Video</SelectItem>
|
||||||
|
<SelectItem value="link">Link</SelectItem>
|
||||||
|
<SelectItem value="document">Document</SelectItem>
|
||||||
|
<SelectItem value="interactive">Interactive</SelectItem>
|
||||||
|
<SelectItem value="audio">Audio</SelectItem>
|
||||||
|
<SelectItem value="image">Image</SelectItem>
|
||||||
|
<SelectItem value="article">Article</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Status</Label>
|
||||||
|
<Select value={status} onValueChange={setStatus}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="pending">Pending</SelectItem>
|
||||||
|
<SelectItem value="approved">Approved</SelectItem>
|
||||||
|
<SelectItem value="rejected">Rejected</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TaxonomyCascade
|
||||||
|
subjectId={editSubjectId} onSubjectChange={setEditSubjectId}
|
||||||
|
domainId={editDomainId} onDomainChange={setEditDomainId}
|
||||||
|
topicIds={editTopicIds} onTopicIdsChange={setEditTopicIds}
|
||||||
|
objectiveIds={editObjectiveIds} onObjectiveIdsChange={setEditObjectiveIds}
|
||||||
|
/>
|
||||||
|
<TagPicker allTags={allTags} selectedIds={tagIds} onToggle={toggleTag} onTagCreated={onTagCreated} />
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleSave} disabled={updateMutation.isPending || !name.trim()}>
|
||||||
|
{updateMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Component ──────────────────────────────────────────────────
|
||||||
export default function ResourceManager() {
|
export default function ResourceManager() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
|
const [subjectFilter, setSubjectFilter] = useState<string>("all");
|
||||||
|
const [tagFilter, setTagFilter] = useState<string>("all");
|
||||||
|
const [dateFrom, setDateFrom] = useState("");
|
||||||
|
const [dateTo, setDateTo] = useState("");
|
||||||
|
const [activeTab, setActiveTab] = useState<string>("all");
|
||||||
|
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
const [uploadType, setUploadType] = useState<string>("pdf");
|
const [uploadType, setUploadType] = useState<string>("pdf");
|
||||||
|
const [uploadSubjectId, setUploadSubjectId] = useState<string>("none");
|
||||||
|
const [uploadDomainId, setUploadDomainId] = useState<string>("none");
|
||||||
|
const [uploadTopicIds, setUploadTopicIds] = useState<number[]>([]);
|
||||||
|
const [uploadObjectiveIds, setUploadObjectiveIds] = useState<number[]>([]);
|
||||||
|
const [uploadTagIds, setUploadTagIds] = useState<number[]>([]);
|
||||||
|
|
||||||
const { data: resourcesData, isLoading } = useQuery({
|
const [showTagManager, setShowTagManager] = useState(false);
|
||||||
queryKey: ["resources", "list", { search, resource_type: typeFilter === "all" ? undefined : typeFilter, review_status: statusFilter === "all" ? undefined : statusFilter }],
|
const [newTagName, setNewTagName] = useState("");
|
||||||
queryFn: () => resourcesService.list({ search: search || undefined, resource_type: typeFilter === "all" ? undefined : typeFilter, review_status: statusFilter === "all" ? undefined : statusFilter }),
|
const [newTagColor, setNewTagColor] = useState(TAG_COLORS[0]);
|
||||||
|
const [editingTag, setEditingTag] = useState<ResourceTag | null>(null);
|
||||||
|
|
||||||
|
const [editingResource, setEditingResource] = useState<Resource | null>(null);
|
||||||
|
|
||||||
|
const { data: subjects } = useQuery({
|
||||||
|
queryKey: ["taxonomy", "subjects"],
|
||||||
|
queryFn: () => taxonomyService.listSubjects(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tags } = useQuery({
|
||||||
|
queryKey: ["resource-tags"],
|
||||||
|
queryFn: () => resourcesService.listTags(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: resourcesData, isLoading: loadingResources } = useQuery({
|
||||||
|
queryKey: ["resources", "list", { search, resource_type: typeFilter === "all" ? undefined : typeFilter, review_status: statusFilter === "all" ? undefined : statusFilter, subject_id: subjectFilter === "all" ? undefined : subjectFilter, tag_id: tagFilter === "all" ? undefined : tagFilter, date_from: dateFrom || undefined, date_to: dateTo || undefined }],
|
||||||
|
queryFn: () => resourcesService.list({
|
||||||
|
search: search || undefined,
|
||||||
|
resource_type: typeFilter === "all" ? undefined : typeFilter,
|
||||||
|
review_status: statusFilter === "all" ? undefined : statusFilter,
|
||||||
|
subject_id: subjectFilter === "all" ? undefined : Number(subjectFilter),
|
||||||
|
tag_id: tagFilter === "all" ? undefined : Number(tagFilter),
|
||||||
|
date_from: dateFrom || undefined,
|
||||||
|
date_to: dateTo || undefined,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const resources = resourcesData?.items ?? [];
|
const resources = resourcesData?.items ?? [];
|
||||||
|
|
||||||
|
const { data: materialsData, isLoading: loadingMaterials } = useQuery({
|
||||||
|
queryKey: ["materials", "search", { search, type: typeFilter }],
|
||||||
|
queryFn: () => coursewareService.searchMaterials({ search: search || undefined, type: typeFilter === "all" ? undefined : typeFilter }),
|
||||||
|
});
|
||||||
|
const courseMaterials = materialsData?.items ?? [];
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: number) => resourcesService.delete(id),
|
mutationFn: (id: number) => resourcesService.delete(id),
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Resource deleted" }); },
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Resource deleted" }); },
|
||||||
onError: () => toast({ title: "Error", description: "Failed to delete resource", variant: "destructive" }),
|
onError: () => toast({ title: "Error", description: "Failed to delete", variant: "destructive" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const uploadMutation = useMutation({
|
const uploadMutation = useMutation({
|
||||||
mutationFn: (formData: FormData) => resourcesService.upload(formData),
|
mutationFn: (formData: FormData) => resourcesService.upload(formData),
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Resource uploaded" }); setShowUpload(false); },
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Resource uploaded" }); setShowUpload(false); setUploadTagIds([]); setUploadSubjectId("none"); setUploadDomainId("none"); setUploadTopicIds([]); setUploadObjectiveIds([]); },
|
||||||
onError: () => toast({ title: "Error", description: "Upload failed", variant: "destructive" }),
|
onError: () => toast({ title: "Error", description: "Upload failed", variant: "destructive" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createTagMutation = useMutation({
|
||||||
|
mutationFn: (data: { name: string; color: string }) => resourcesService.createTag(data),
|
||||||
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["resource-tags"] }); setNewTagName(""); toast({ title: "Tag created" }); },
|
||||||
|
onError: () => toast({ title: "Error", description: "Failed to create tag", variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTagMutation = useMutation({
|
||||||
|
mutationFn: ({ id, ...data }: { id: number; name: string; color: string }) => resourcesService.updateTag(id, data),
|
||||||
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["resource-tags"] }); qc.invalidateQueries({ queryKey: ["resources"] }); setEditingTag(null); toast({ title: "Tag updated" }); },
|
||||||
|
onError: () => toast({ title: "Error", description: "Failed to update tag", variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteTagMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => resourcesService.deleteTag(id),
|
||||||
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["resource-tags"] }); qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Tag deleted" }); },
|
||||||
|
onError: () => toast({ title: "Error", description: "Failed to delete tag", variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
const handleUpload = (e: React.FormEvent<HTMLFormElement>) => {
|
const handleUpload = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
|
if (uploadTagIds.length > 0) formData.set("tag_ids", uploadTagIds.join(","));
|
||||||
|
if (uploadDomainId !== "none") formData.set("domain_id", uploadDomainId);
|
||||||
|
if (uploadTopicIds.length > 0) formData.set("topic_ids", uploadTopicIds.join(","));
|
||||||
|
if (uploadObjectiveIds.length > 0) formData.set("learning_objective_ids", uploadObjectiveIds.join(","));
|
||||||
uploadMutation.mutate(formData);
|
uploadMutation.mutate(formData);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
const toggleUploadTag = (id: number) => setUploadTagIds((prev) => prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]);
|
||||||
|
|
||||||
|
const handleInlineTagCreated = () => qc.invalidateQueries({ queryKey: ["resource-tags"] });
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearch(""); setTypeFilter("all"); setStatusFilter("all");
|
||||||
|
setSubjectFilter("all"); setTagFilter("all"); setDateFrom(""); setDateTo("");
|
||||||
|
};
|
||||||
|
const hasActiveFilters = search || typeFilter !== "all" || statusFilter !== "all" || subjectFilter !== "all" || tagFilter !== "all" || dateFrom || dateTo;
|
||||||
|
const isLoading = loadingResources || loadingMaterials;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Resource Manager</h1>
|
<h1 className="text-2xl font-bold">Resource Manager</h1>
|
||||||
<p className="text-muted-foreground">Upload, manage, and review learning resources.</p>
|
<p className="text-muted-foreground">All learning content — uploaded resources and course materials — available for AI generation.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setShowTagManager(true)}><Tag className="mr-2 h-4 w-4" /> Manage Tags</Button>
|
||||||
|
<Dialog open={showUpload} onOpenChange={(o) => { setShowUpload(o); if (!o) { setUploadTagIds([]); setUploadSubjectId("none"); setUploadDomainId("none"); setUploadTopicIds([]); setUploadObjectiveIds([]); } }}>
|
||||||
|
<DialogTrigger asChild><Button><Upload className="mr-2 h-4 w-4" /> Upload Resource</Button></DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader><DialogTitle>Upload New Resource</DialogTitle></DialogHeader>
|
||||||
|
<form onSubmit={handleUpload} className="space-y-4 pt-4">
|
||||||
|
<input type="hidden" name="resource_type" value={uploadType} />
|
||||||
|
{uploadSubjectId !== "none" && <input type="hidden" name="subject_id" value={uploadSubjectId} />}
|
||||||
|
<div className="space-y-2"><Label>Name</Label><Input name="name" placeholder="Resource title" required /></div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Select value={uploadType} onValueChange={setUploadType}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="pdf">PDF</SelectItem>
|
||||||
|
<SelectItem value="video">Video</SelectItem>
|
||||||
|
<SelectItem value="link">Link</SelectItem>
|
||||||
|
<SelectItem value="document">Document</SelectItem>
|
||||||
|
<SelectItem value="interactive">Interactive</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<TaxonomyCascade
|
||||||
|
subjectId={uploadSubjectId} onSubjectChange={setUploadSubjectId}
|
||||||
|
domainId={uploadDomainId} onDomainChange={setUploadDomainId}
|
||||||
|
topicIds={uploadTopicIds} onTopicIdsChange={setUploadTopicIds}
|
||||||
|
objectiveIds={uploadObjectiveIds} onObjectiveIdsChange={setUploadObjectiveIds}
|
||||||
|
/>
|
||||||
|
<TagPicker
|
||||||
|
allTags={tags ?? []}
|
||||||
|
selectedIds={uploadTagIds}
|
||||||
|
onToggle={toggleUploadTag}
|
||||||
|
onTagCreated={(tag) => { handleInlineTagCreated(); toggleUploadTag(tag.id); }}
|
||||||
|
/>
|
||||||
|
<div className="space-y-2"><Label>File</Label><Input name="file" type="file" /></div>
|
||||||
|
<Button type="submit" className="w-full" disabled={uploadMutation.isPending}>
|
||||||
|
{uploadMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} Upload
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={showUpload} onOpenChange={setShowUpload}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button><Upload className="mr-2 h-4 w-4" /> Upload Resource</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader><DialogTitle>Upload New Resource</DialogTitle></DialogHeader>
|
|
||||||
<form onSubmit={handleUpload} className="space-y-4 pt-4">
|
|
||||||
<input type="hidden" name="resource_type" value={uploadType} />
|
|
||||||
<div className="space-y-2"><Label>Name</Label><Input name="name" placeholder="Resource title" required /></div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Type</Label>
|
|
||||||
<Select value={uploadType} onValueChange={setUploadType}>
|
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="pdf">PDF</SelectItem>
|
|
||||||
<SelectItem value="video">Video</SelectItem>
|
|
||||||
<SelectItem value="link">Link</SelectItem>
|
|
||||||
<SelectItem value="document">Document</SelectItem>
|
|
||||||
<SelectItem value="interactive">Interactive</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2"><Label>File or URL</Label><Input name="file" type="file" /></div>
|
|
||||||
<Button type="submit" className="w-full" disabled={uploadMutation.isPending}>
|
|
||||||
{uploadMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
Upload
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setActiveTab("all")}>
|
||||||
|
<CardContent className="pt-6 text-center"><p className="text-2xl font-bold">{resources.length + courseMaterials.length}</p><p className="text-sm text-muted-foreground">Total Content Items</p></CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setActiveTab("resources")}>
|
||||||
|
<CardContent className="pt-6 text-center"><p className="text-2xl font-bold">{resources.length}</p><p className="text-sm text-muted-foreground">Library Resources</p></CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setActiveTab("materials")}>
|
||||||
|
<CardContent className="pt-6 text-center"><p className="text-2xl font-bold">{courseMaterials.length}</p><p className="text-sm text-muted-foreground">Course Materials</p></CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters + Table */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="space-y-3">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input placeholder="Search resources..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9" />
|
<Input placeholder="Search all content..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9" />
|
||||||
</div>
|
</div>
|
||||||
|
<Select value={subjectFilter} onValueChange={setSubjectFilter}>
|
||||||
|
<SelectTrigger className="w-[150px]"><SelectValue placeholder="Subject" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Subjects</SelectItem>
|
||||||
|
{(subjects ?? []).map((s) => (<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={tagFilter} onValueChange={setTagFilter}>
|
||||||
|
<SelectTrigger className="w-[140px]"><SelectValue placeholder="Tag" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Tags</SelectItem>
|
||||||
|
{(tags ?? []).map((t) => (
|
||||||
|
<SelectItem key={t.id} value={String(t.id)}>
|
||||||
|
<span className="flex items-center gap-2"><span className="h-2 w-2 rounded-full inline-block" style={{ backgroundColor: t.color }} />{t.name}</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
<SelectTrigger className="w-[140px]"><SelectValue placeholder="Type" /></SelectTrigger>
|
<SelectTrigger className="w-[130px]"><SelectValue placeholder="Type" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Types</SelectItem>
|
<SelectItem value="all">All Types</SelectItem>
|
||||||
<SelectItem value="pdf">PDF</SelectItem>
|
<SelectItem value="pdf">PDF</SelectItem><SelectItem value="video">Video</SelectItem><SelectItem value="link">Link</SelectItem>
|
||||||
<SelectItem value="video">Video</SelectItem>
|
<SelectItem value="article">Article</SelectItem><SelectItem value="document">Document</SelectItem><SelectItem value="audio">Audio</SelectItem>
|
||||||
<SelectItem value="link">Link</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
||||||
<SelectTrigger className="w-[140px]"><SelectValue placeholder="Status" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Status</SelectItem>
|
|
||||||
<SelectItem value="pending">Pending</SelectItem>
|
|
||||||
<SelectItem value="approved">Approved</SelectItem>
|
|
||||||
<SelectItem value="rejected">Rejected</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{activeTab !== "materials" && (
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-[130px]"><SelectValue placeholder="Status" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Status</SelectItem>
|
||||||
|
<SelectItem value="pending">Pending</SelectItem><SelectItem value="approved">Approved</SelectItem><SelectItem value="rejected">Rejected</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<CalendarDays className="h-4 w-4" /><span>From</span>
|
||||||
|
<Input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)} className="w-[150px] h-8 text-xs" />
|
||||||
|
<span>To</span>
|
||||||
|
<Input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)} className="w-[150px] h-8 text-xs" />
|
||||||
|
</div>
|
||||||
|
{hasActiveFilters && (<Button variant="ghost" size="sm" onClick={clearFilters} className="text-muted-foreground"><X className="h-3 w-3 mr-1" /> Clear filters</Button>)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
{isLoading ? (
|
||||||
<TableHeader>
|
<div className="flex justify-center py-12"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>
|
||||||
<TableRow>
|
) : (
|
||||||
<TableHead>Resource</TableHead>
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TableHead>Type</TableHead>
|
<TabsList className="mb-4">
|
||||||
<TableHead>Topics</TableHead>
|
<TabsTrigger value="all">All ({resources.length + courseMaterials.length})</TabsTrigger>
|
||||||
<TableHead>Author</TableHead>
|
<TabsTrigger value="resources">Library Resources ({resources.length})</TabsTrigger>
|
||||||
<TableHead>Status</TableHead>
|
<TabsTrigger value="materials">Course Materials ({courseMaterials.length})</TabsTrigger>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
</TabsList>
|
||||||
</TableRow>
|
<TabsContent value="all">
|
||||||
</TableHeader>
|
<ContentTable resources={resources} materials={courseMaterials} showSource onDeleteResource={(id) => { if (window.confirm("Delete this resource?")) deleteMutation.mutate(id); }} onEditResource={setEditingResource} deletePending={deleteMutation.isPending} toast={toast} />
|
||||||
<TableBody>
|
</TabsContent>
|
||||||
{resources.map((resource: Resource) => {
|
<TabsContent value="resources">
|
||||||
const sb = statusBadge[resource.review_status] ?? statusBadge.pending;
|
<ContentTable resources={resources} materials={[]} onDeleteResource={(id) => { if (window.confirm("Delete this resource?")) deleteMutation.mutate(id); }} onEditResource={setEditingResource} deletePending={deleteMutation.isPending} toast={toast} />
|
||||||
return (
|
</TabsContent>
|
||||||
<TableRow key={resource.id}>
|
<TabsContent value="materials">
|
||||||
<TableCell>
|
<ContentTable resources={[]} materials={courseMaterials} showCourseInfo onDeleteResource={() => {}} onEditResource={() => {}} deletePending={false} toast={toast} />
|
||||||
<div className="flex items-center gap-2">
|
</TabsContent>
|
||||||
{typeIcons[resource.resource_type] ?? <FileText className="h-4 w-4" />}
|
</Tabs>
|
||||||
<span className="font-medium">{resource.name}</span>
|
)}
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell><Badge variant="outline" className="capitalize">{resource.resource_type}</Badge></TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{resource.topic_names.slice(0, 2).map((t, i) => <Badge key={i} variant="secondary" className="text-xs">{t}</Badge>)}
|
|
||||||
{resource.topic_names.length > 2 && <Badge variant="secondary" className="text-xs">+{resource.topic_names.length - 2}</Badge>}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm">{resource.author_name}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={sb.variant} className="flex items-center w-fit">{sb.icon}{resource.review_status}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
<Button variant="ghost" size="icon" onClick={async () => { try { const blob = await resourcesService.download(resource.id); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = resource.name || "resource"; a.click(); URL.revokeObjectURL(url); } catch { toast({ title: "Download failed", variant: "destructive" }); } }}><Download className="h-4 w-4" /></Button>
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => { if (!window.confirm(`Delete "${resource.name}"?`)) return; deleteMutation.mutate(resource.id); }} disabled={deleteMutation.isPending}>
|
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{resources.length === 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">No resources found.</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Tag Manager Dialog */}
|
||||||
|
<Dialog open={showTagManager} onOpenChange={(o) => { setShowTagManager(o); if (!o) setEditingTag(null); }}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader><DialogTitle className="flex items-center gap-2"><Tag className="h-5 w-5" /> Manage Resource Tags</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-4 pt-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input placeholder="New tag name…" value={newTagName} onChange={(e) => setNewTagName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && newTagName.trim()) createTagMutation.mutate({ name: newTagName.trim(), color: newTagColor }); }} className="flex-1" />
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{TAG_COLORS.slice(0, 5).map((c) => (
|
||||||
|
<button key={c} className="h-8 w-8 rounded-full border-2 transition-transform" style={{ backgroundColor: c, borderColor: newTagColor === c ? "#000" : "transparent", transform: newTagColor === c ? "scale(1.2)" : "scale(1)" }} onClick={() => setNewTagColor(c)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button size="sm" disabled={!newTagName.trim() || createTagMutation.isPending} onClick={() => createTagMutation.mutate({ name: newTagName.trim(), color: newTagColor })}>
|
||||||
|
{createTagMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||||
|
{(tags ?? []).length === 0 && <p className="text-sm text-muted-foreground text-center py-4">No tags yet. Create one above.</p>}
|
||||||
|
{(tags ?? []).map((t) => (
|
||||||
|
<div key={t.id} className="flex items-center justify-between p-2 rounded-lg border hover:bg-accent/50">
|
||||||
|
{editingTag?.id === t.id ? (
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<Input value={editingTag.name} onChange={(e) => setEditingTag({ ...editingTag, name: e.target.value })} className="h-8 text-sm flex-1" />
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{TAG_COLORS.slice(0, 5).map((c) => (
|
||||||
|
<button key={c} className="h-6 w-6 rounded-full border-2" style={{ backgroundColor: c, borderColor: editingTag.color === c ? "#000" : "transparent" }} onClick={() => setEditingTag({ ...editingTag, color: c })} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => updateTagMutation.mutate({ id: editingTag.id, name: editingTag.name, color: editingTag.color })} disabled={updateTagMutation.isPending}><CheckCircle2 className="h-4 w-4 text-green-600" /></Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setEditingTag(null)}><X className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: t.color }} />
|
||||||
|
<span className="text-sm font-medium">{t.name}</span>
|
||||||
|
<Badge variant="outline" className="text-xs">{t.resource_count ?? 0}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setEditingTag(t)}><Pencil className="h-3 w-3" /></Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => { if (window.confirm(`Delete tag "${t.name}"?`)) deleteTagMutation.mutate(t.id); }} disabled={deleteTagMutation.isPending}><Trash2 className="h-3 w-3 text-destructive" /></Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter><Button variant="outline" onClick={() => setShowTagManager(false)}>Done</Button></DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit Resource Dialog */}
|
||||||
|
{editingResource && (
|
||||||
|
<EditResourceDialog
|
||||||
|
resource={editingResource}
|
||||||
|
open={!!editingResource}
|
||||||
|
onOpenChange={(o) => { if (!o) setEditingResource(null); }}
|
||||||
|
allTags={tags ?? []}
|
||||||
|
onTagCreated={handleInlineTagCreated}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Content Table ───────────────────────────────────────────────────
|
||||||
|
interface CourseMaterialItem {
|
||||||
|
id: number; name: string; type: string; course_name: string;
|
||||||
|
chapter_name: string; source: string; file_url: string | null;
|
||||||
|
url: string | null; description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContentTable({
|
||||||
|
resources, materials, showSource, showCourseInfo, onDeleteResource, onEditResource, deletePending, toast,
|
||||||
|
}: {
|
||||||
|
resources: Resource[]; materials: CourseMaterialItem[];
|
||||||
|
showSource?: boolean; showCourseInfo?: boolean;
|
||||||
|
onDeleteResource: (id: number) => void;
|
||||||
|
onEditResource: (r: Resource) => void;
|
||||||
|
deletePending: boolean;
|
||||||
|
toast: ReturnType<typeof useToast>["toast"];
|
||||||
|
}) {
|
||||||
|
type UnifiedRow = {
|
||||||
|
key: string; name: string; type: string; source: "resource" | "material";
|
||||||
|
context: string; tags?: { id: number; name: string; color: string }[];
|
||||||
|
status?: string; createdAt?: string; resourceId?: number; raw?: Resource;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows: UnifiedRow[] = [
|
||||||
|
...resources.map((r): UnifiedRow => ({
|
||||||
|
key: `r-${r.id}`, name: r.name, type: r.resource_type, source: "resource",
|
||||||
|
context: [r.subject_name, ...(r.topic_names?.slice(0, 2) ?? [])].filter(Boolean).join(" › ") || "",
|
||||||
|
tags: r.tags ?? [], status: r.review_status, createdAt: r.created_at, resourceId: r.id, raw: r,
|
||||||
|
})),
|
||||||
|
...materials.map((m): UnifiedRow => ({
|
||||||
|
key: `m-${m.id}`, name: m.name, type: m.type, source: "material",
|
||||||
|
context: showCourseInfo || showSource ? `${m.course_name} › ${m.chapter_name}` : m.chapter_name || "",
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const formatDate = (iso?: string) => {
|
||||||
|
if (!iso) return "";
|
||||||
|
try { return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); } catch { return ""; }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
{showSource && <TableHead>Source</TableHead>}
|
||||||
|
<TableHead>Subject / Tags</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Uploaded</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((row) => {
|
||||||
|
const sb = row.status ? (statusBadge[row.status] ?? statusBadge.pending) : null;
|
||||||
|
return (
|
||||||
|
<TableRow key={row.key}>
|
||||||
|
<TableCell><div className="flex items-center gap-2">{typeIcons[row.type] ?? <FileText className="h-4 w-4" />}<span className="font-medium">{row.name}</span></div></TableCell>
|
||||||
|
<TableCell><Badge variant="outline" className="capitalize">{row.type}</Badge></TableCell>
|
||||||
|
{showSource && (
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={row.source === "resource" ? "secondary" : "default"} className="text-xs">
|
||||||
|
{row.source === "resource" ? "Library" : <><BookOpen className="h-3 w-3 mr-1 inline" />Course</>}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{row.context && <span className="text-xs text-muted-foreground">{row.context}</span>}
|
||||||
|
{row.tags && row.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{row.tags.map((t) => (<span key={t.id} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium text-white" style={{ backgroundColor: t.color }}>{t.name}</span>))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!row.context && (!row.tags || row.tags.length === 0) && <span className="text-muted-foreground">—</span>}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{sb ? (<Badge variant={sb.variant} className="flex items-center w-fit">{sb.icon}{row.status}</Badge>) : (<Badge variant="default" className="flex items-center w-fit"><CheckCircle2 className="h-3 w-3 mr-1" />indexed</Badge>)}</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">{formatDate(row.createdAt)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{row.source === "resource" && row.resourceId && (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" size="icon" title="Edit" onClick={() => row.raw && onEditResource(row.raw)}><Pencil className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" title="Download" onClick={async () => {
|
||||||
|
try { const blob = await resourcesService.download(row.resourceId!); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = row.name || "resource"; a.click(); URL.revokeObjectURL(url); } catch { toast({ title: "Download failed", variant: "destructive" }); }
|
||||||
|
}}><Download className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" title="Delete" onClick={() => onDeleteResource(row.resourceId!)} disabled={deletePending}><Trash2 className="h-4 w-4 text-destructive" /></Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{rows.length === 0 && (<TableRow><TableCell colSpan={showSource ? 8 : 7} className="text-center py-8 text-muted-foreground">No content found.</TableCell></TableRow>)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
@@ -22,6 +23,7 @@ import type { PendingScore } from "@/types";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export default function ScoreApprovalQueue() {
|
export default function ScoreApprovalQueue() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { data, isLoading, refetch } = usePendingScores();
|
const { data, isLoading, refetch } = usePendingScores();
|
||||||
const release = useReleaseScore();
|
const release = useReleaseScore();
|
||||||
const reject = useRejectScore();
|
const reject = useRejectScore();
|
||||||
@@ -156,7 +158,7 @@ export default function ScoreApprovalQueue() {
|
|||||||
<Button size="sm" variant="destructive" onClick={() => openReject(r)}>
|
<Button size="sm" variant="destructive" onClick={() => openReject(r)}>
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline">
|
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/exam/${r.exam_id ?? r.attempt_id}/grading`)}>
|
||||||
View Details
|
View Details
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -7,80 +7,146 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Plus, ChevronRight, BookOpen, Layers, FileText, Sparkles, Loader2, Trash2, X } from "lucide-react";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Plus, ChevronRight, BookOpen, Layers, FileText, Sparkles,
|
||||||
|
Loader2, Trash2, X, Pencil, Check, Library, GraduationCap, Link2,
|
||||||
|
} from "lucide-react";
|
||||||
import { useSubjects, useTaxonomyTree } from "@/hooks/queries";
|
import { useSubjects, useTaxonomyTree } from "@/hooks/queries";
|
||||||
import { taxonomyService } from "@/services/taxonomy.service";
|
import { taxonomyService } from "@/services/taxonomy.service";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import type { Domain, Topic } from "@/types";
|
import type { Domain, Topic } from "@/types";
|
||||||
|
|
||||||
|
function RelBadge({ count, label, icon: Icon, onClick }: { count: number; label: string; icon: React.ElementType; onClick?: () => void }) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={count > 0 ? "default" : "outline"}
|
||||||
|
className={`text-xs gap-1 ${onClick ? "cursor-pointer hover:bg-accent" : ""} ${count === 0 ? "text-muted-foreground" : ""}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
|
||||||
|
>
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
{count} {label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function TaxonomyManager() {
|
export default function TaxonomyManager() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { data: rawSubjects, isLoading } = useSubjects();
|
const { data: rawSubjects, isLoading } = useSubjects();
|
||||||
const subjects = Array.isArray(rawSubjects) ? rawSubjects : [];
|
const subjects = Array.isArray(rawSubjects) ? rawSubjects : [];
|
||||||
const [selectedSubjectId, setSelectedSubjectId] = useState<number | null>(null);
|
const [selectedSubjectId, setSelectedSubjectId] = useState<number | null>(null);
|
||||||
const { data: tree, isLoading: loadingTree } = useTaxonomyTree(selectedSubjectId ?? 0);
|
const { data: tree, isLoading: loadingTree } = useTaxonomyTree(selectedSubjectId ?? 0);
|
||||||
|
|
||||||
const [showAddSubject, setShowAddSubject] = useState(false);
|
const [showAddSubject, setShowAddSubject] = useState(false);
|
||||||
const [newSubjectName, setNewSubjectName] = useState("");
|
const [newSubjectName, setNewSubjectName] = useState("");
|
||||||
const [newSubjectCode, setNewSubjectCode] = useState("");
|
const [newSubjectCode, setNewSubjectCode] = useState("");
|
||||||
|
|
||||||
const [showAddDomain, setShowAddDomain] = useState(false);
|
const [showAddDomain, setShowAddDomain] = useState(false);
|
||||||
const [newDomainName, setNewDomainName] = useState("");
|
const [newDomainName, setNewDomainName] = useState("");
|
||||||
|
|
||||||
const [showAddTopic, setShowAddTopic] = useState<number | null>(null);
|
const [showAddTopic, setShowAddTopic] = useState<number | null>(null);
|
||||||
const [newTopicName, setNewTopicName] = useState("");
|
const [newTopicName, setNewTopicName] = useState("");
|
||||||
const [newTopicDifficulty, setNewTopicDifficulty] = useState("medium");
|
const [newTopicDifficulty, setNewTopicDifficulty] = useState("medium");
|
||||||
const [newTopicHours, setNewTopicHours] = useState("1");
|
const [newTopicHours, setNewTopicHours] = useState("1");
|
||||||
|
|
||||||
|
const [editingSubjectId, setEditingSubjectId] = useState<number | null>(null);
|
||||||
|
const [editSubjectName, setEditSubjectName] = useState("");
|
||||||
|
const [editSubjectCode, setEditSubjectCode] = useState("");
|
||||||
|
|
||||||
|
const [editingDomainId, setEditingDomainId] = useState<number | null>(null);
|
||||||
|
const [editDomainName, setEditDomainName] = useState("");
|
||||||
|
|
||||||
|
const [editingTopicId, setEditingTopicId] = useState<number | null>(null);
|
||||||
|
const [editTopicName, setEditTopicName] = useState("");
|
||||||
|
const [editTopicDifficulty, setEditTopicDifficulty] = useState("medium");
|
||||||
|
const [editTopicHours, setEditTopicHours] = useState("1");
|
||||||
|
const [editTopicDesc, setEditTopicDesc] = useState("");
|
||||||
|
|
||||||
|
const invalidate = () => qc.invalidateQueries({ queryKey: ["taxonomy"] });
|
||||||
|
|
||||||
const createSubjectMutation = useMutation({
|
const createSubjectMutation = useMutation({
|
||||||
mutationFn: () => taxonomyService.createSubject({ name: newSubjectName, code: newSubjectCode, is_active: true, mastery_threshold: 80, grading_scale: "percentage", diagnostic_config: { questions_per_domain: 5, total_question_cap: 30, time_limit_minutes: 45, starting_difficulty: "medium", mastery_per_correct: 10, mastery_per_incorrect: -5 } }),
|
mutationFn: () => taxonomyService.createSubject({ name: newSubjectName, code: newSubjectCode, is_active: true, mastery_threshold: 80, grading_scale: "percentage", diagnostic_config: { questions_per_domain: 5, total_question_cap: 30, time_limit_minutes: 45, starting_difficulty: "medium", mastery_per_correct: 10, mastery_per_incorrect: -5 } }),
|
||||||
onSuccess: () => {
|
onSuccess: () => { invalidate(); toast({ title: "Subject Created" }); setShowAddSubject(false); setNewSubjectName(""); setNewSubjectCode(""); },
|
||||||
qc.invalidateQueries({ queryKey: ["taxonomy", "subjects"] });
|
|
||||||
toast({ title: "Subject Created" });
|
|
||||||
setShowAddSubject(false);
|
|
||||||
setNewSubjectName("");
|
|
||||||
setNewSubjectCode("");
|
|
||||||
},
|
|
||||||
onError: () => toast({ title: "Error", description: "Failed to create subject", variant: "destructive" }),
|
onError: () => toast({ title: "Error", description: "Failed to create subject", variant: "destructive" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const aiSuggestMutation = useMutation({
|
const updateSubjectMutation = useMutation({
|
||||||
mutationFn: (domainId: number) => taxonomyService.aiSuggestTopics(domainId),
|
mutationFn: ({ id, data }: { id: number; data: Partial<{ name: string; code: string }> }) => taxonomyService.updateSubject(id, data),
|
||||||
onSuccess: (data) => {
|
onSuccess: () => { invalidate(); toast({ title: "Subject updated" }); setEditingSubjectId(null); },
|
||||||
toast({ title: "AI Suggestions", description: `${data.suggestions.length} topics suggested. Review and add them.` });
|
onError: () => toast({ title: "Error", description: "Failed to update subject", variant: "destructive" }),
|
||||||
},
|
|
||||||
onError: () => toast({ title: "Error", description: "AI suggestion failed", variant: "destructive" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const createDomainMutation = useMutation({
|
|
||||||
mutationFn: (data: Partial<Domain>) => taxonomyService.createDomain(data),
|
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["taxonomy"] }); setShowAddDomain(false); setNewDomainName(""); toast({ title: "Domain created" }); },
|
|
||||||
onError: () => toast({ title: "Error", description: "Failed to create domain", variant: "destructive" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteDomainMutation = useMutation({
|
|
||||||
mutationFn: (id: number) => taxonomyService.deleteDomain(id),
|
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["taxonomy"] }); toast({ title: "Domain deleted" }); },
|
|
||||||
onError: () => toast({ title: "Error", description: "Failed to delete domain", variant: "destructive" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const createTopicMutation = useMutation({
|
|
||||||
mutationFn: (data: Partial<Topic>) => taxonomyService.createTopic(data),
|
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["taxonomy"] }); setShowAddTopic(null); setNewTopicName(""); toast({ title: "Topic created" }); },
|
|
||||||
onError: () => toast({ title: "Error", description: "Failed to create topic", variant: "destructive" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteTopicMutation = useMutation({
|
|
||||||
mutationFn: (id: number) => taxonomyService.deleteTopic(id),
|
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["taxonomy"] }); toast({ title: "Topic deleted" }); },
|
|
||||||
onError: () => toast({ title: "Error", description: "Failed to delete topic", variant: "destructive" }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteSubjectMutation = useMutation({
|
const deleteSubjectMutation = useMutation({
|
||||||
mutationFn: (id: number) => taxonomyService.deleteSubject(id),
|
mutationFn: (id: number) => taxonomyService.deleteSubject(id),
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["taxonomy"] }); if (selectedSubjectId === id) setSelectedSubjectId(null); toast({ title: "Subject deleted" }); },
|
onSuccess: (_d, id) => { invalidate(); if (selectedSubjectId === id) setSelectedSubjectId(null); toast({ title: "Subject deleted" }); },
|
||||||
onError: () => toast({ title: "Error", description: "Failed to delete subject", variant: "destructive" }),
|
onError: () => toast({ title: "Error", description: "Failed to delete subject", variant: "destructive" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createDomainMutation = useMutation({
|
||||||
|
mutationFn: (data: Partial<Domain>) => taxonomyService.createDomain(data),
|
||||||
|
onSuccess: () => { invalidate(); setShowAddDomain(false); setNewDomainName(""); toast({ title: "Domain created" }); },
|
||||||
|
onError: () => toast({ title: "Error", description: "Failed to create domain", variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateDomainMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: Partial<Domain> }) => taxonomyService.updateDomain(id, data),
|
||||||
|
onSuccess: () => { invalidate(); toast({ title: "Domain updated" }); setEditingDomainId(null); },
|
||||||
|
onError: () => toast({ title: "Error", description: "Failed to update domain", variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteDomainMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => taxonomyService.deleteDomain(id),
|
||||||
|
onSuccess: () => { invalidate(); toast({ title: "Domain deleted" }); },
|
||||||
|
onError: () => toast({ title: "Error", description: "Failed to delete domain", variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const aiSuggestMutation = useMutation({
|
||||||
|
mutationFn: (domainId: number) => taxonomyService.aiSuggestTopics(domainId),
|
||||||
|
onSuccess: (data) => toast({ title: "AI Suggestions", description: `${data.suggestions.length} topics suggested.` }),
|
||||||
|
onError: () => toast({ title: "Error", description: "AI suggestion failed", variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTopicMutation = useMutation({
|
||||||
|
mutationFn: (data: Partial<Topic>) => taxonomyService.createTopic(data),
|
||||||
|
onSuccess: () => { invalidate(); setShowAddTopic(null); setNewTopicName(""); toast({ title: "Topic created" }); },
|
||||||
|
onError: () => toast({ title: "Error", description: "Failed to create topic", variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTopicMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: Partial<Topic> }) => taxonomyService.updateTopic(id, data),
|
||||||
|
onSuccess: () => { invalidate(); toast({ title: "Topic updated" }); setEditingTopicId(null); },
|
||||||
|
onError: () => toast({ title: "Error", description: "Failed to update topic", variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteTopicMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => taxonomyService.deleteTopic(id),
|
||||||
|
onSuccess: () => { invalidate(); toast({ title: "Topic deleted" }); },
|
||||||
|
onError: () => toast({ title: "Error", description: "Failed to delete topic", variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const startEditSubject = (s: { id: number; name: string; code: string }) => {
|
||||||
|
setEditingSubjectId(s.id);
|
||||||
|
setEditSubjectName(s.name);
|
||||||
|
setEditSubjectCode(s.code);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditDomain = (d: { id: number; name: string }) => {
|
||||||
|
setEditingDomainId(d.id);
|
||||||
|
setEditDomainName(d.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditTopic = (t: { id: number; name: string; difficulty_level?: string; estimated_hours?: number; description?: string }) => {
|
||||||
|
setEditingTopicId(t.id);
|
||||||
|
setEditTopicName(t.name);
|
||||||
|
setEditTopicDifficulty(t.difficulty_level ?? "medium");
|
||||||
|
setEditTopicHours(String(t.estimated_hours ?? 1));
|
||||||
|
setEditTopicDesc(t.description ?? "");
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -115,25 +181,59 @@ export default function TaxonomyManager() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
|
{/* Subjects sidebar */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground px-1">Subjects</h3>
|
<h3 className="text-sm font-medium text-muted-foreground px-1">Subjects</h3>
|
||||||
{subjects.map(s => (
|
{subjects.map(s => (
|
||||||
<div key={s.id} className={`w-full text-left p-3 rounded-lg border transition-colors flex items-center gap-2 ${selectedSubjectId === s.id ? "bg-primary/10 border-primary" : "hover:bg-accent"}`}>
|
<div
|
||||||
<button onClick={() => setSelectedSubjectId(s.id)} className="flex items-center gap-2 flex-1 min-w-0 text-left">
|
key={s.id}
|
||||||
<BookOpen className="h-4 w-4 shrink-0" />
|
className={`w-full text-left p-3 rounded-lg border transition-colors ${selectedSubjectId === s.id ? "bg-primary/10 border-primary" : "hover:bg-accent"}`}
|
||||||
<div className="min-w-0">
|
>
|
||||||
<p className="text-sm font-medium truncate">{s.name}</p>
|
{editingSubjectId === s.id ? (
|
||||||
<p className="text-xs text-muted-foreground">{s.code} · {s.domain_count ?? 0} domains · {s.topic_count ?? 0} topics</p>
|
<div className="space-y-2">
|
||||||
|
<Input value={editSubjectName} onChange={e => setEditSubjectName(e.target.value)} className="h-7 text-sm" placeholder="Name" />
|
||||||
|
<Input value={editSubjectCode} onChange={e => setEditSubjectCode(e.target.value)} className="h-7 text-sm" placeholder="Code" />
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" className="h-6" disabled={updateSubjectMutation.isPending || !editSubjectName}
|
||||||
|
onClick={() => updateSubjectMutation.mutate({ id: s.id, data: { name: editSubjectName, code: editSubjectCode } })}>
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="h-6" onClick={() => setEditingSubjectId(null)}>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
) : (
|
||||||
<Button size="sm" variant="ghost" className="text-destructive shrink-0" onClick={() => { if (!window.confirm(`Delete subject "${s.name}"?`)) return; deleteSubjectMutation.mutate(s.id); }}>
|
<>
|
||||||
<Trash2 className="h-3 w-3" />
|
<div className="flex items-start gap-2">
|
||||||
</Button>
|
<button onClick={() => setSelectedSubjectId(s.id)} className="flex items-center gap-2 flex-1 min-w-0 text-left">
|
||||||
|
<BookOpen className="h-4 w-4 shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{s.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{s.code} · {s.domain_count ?? 0} domains · {s.topic_count ?? 0} topics</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-col gap-0.5 shrink-0">
|
||||||
|
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => startEditSubject(s)}>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="h-6 w-6 p-0 text-destructive" onClick={() => { if (!window.confirm(`Delete subject "${s.name}"?`)) return; deleteSubjectMutation.mutate(s.id); }}>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||||
|
<RelBadge count={s.course_count ?? 0} label="courses" icon={GraduationCap} onClick={() => navigate("/admin/courses")} />
|
||||||
|
<RelBadge count={s.resource_count ?? 0} label="resources" icon={Library} onClick={() => navigate("/admin/resources")} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{subjects.length === 0 && <p className="text-sm text-muted-foreground px-1">No subjects yet.</p>}
|
{subjects.length === 0 && <p className="text-sm text-muted-foreground px-1">No subjects yet.</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Domain + Topic tree */}
|
||||||
<div className="md:col-span-3">
|
<div className="md:col-span-3">
|
||||||
{!selectedSubjectId ? (
|
{!selectedSubjectId ? (
|
||||||
<Card><CardContent className="py-12 text-center text-muted-foreground">Select a subject to view its taxonomy tree.</CardContent></Card>
|
<Card><CardContent className="py-12 text-center text-muted-foreground">Select a subject to view its taxonomy tree.</CardContent></Card>
|
||||||
@@ -141,41 +241,98 @@ export default function TaxonomyManager() {
|
|||||||
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
||||||
) : tree ? (
|
) : tree ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-end">
|
{/* Relationship summary for selected subject */}
|
||||||
<Button size="sm" variant="outline" onClick={() => setShowAddDomain(true)}>
|
{(() => {
|
||||||
<Plus className="h-3 w-3 mr-1" /> Add Domain
|
const sel = subjects.find(s => s.id === selectedSubjectId);
|
||||||
</Button>
|
if (!sel) return null;
|
||||||
</div>
|
return (
|
||||||
|
<Card className="bg-muted/30 border-dashed">
|
||||||
|
<CardContent className="py-3 px-4">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="font-medium text-muted-foreground">Relationships:</span>
|
||||||
|
<button onClick={() => navigate("/admin/courses")} className="flex items-center gap-1.5 hover:underline">
|
||||||
|
<GraduationCap className="h-4 w-4 text-primary" />
|
||||||
|
<span className="font-semibold">{sel.course_count ?? 0}</span>
|
||||||
|
<span className="text-muted-foreground">Courses</span>
|
||||||
|
</button>
|
||||||
|
<span className="text-muted-foreground">·</span>
|
||||||
|
<button onClick={() => navigate("/admin/resources")} className="flex items-center gap-1.5 hover:underline">
|
||||||
|
<Library className="h-4 w-4 text-primary" />
|
||||||
|
<span className="font-semibold">{sel.resource_count ?? 0}</span>
|
||||||
|
<span className="text-muted-foreground">Resources</span>
|
||||||
|
</button>
|
||||||
|
<span className="text-muted-foreground">·</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Layers className="h-4 w-4 text-primary" />
|
||||||
|
<span className="font-semibold">{sel.domain_count ?? 0}</span>
|
||||||
|
<span className="text-muted-foreground">Domains</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground">·</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<FileText className="h-4 w-4 text-primary" />
|
||||||
|
<span className="font-semibold">{sel.topic_count ?? 0}</span>
|
||||||
|
<span className="text-muted-foreground">Topics</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setShowAddDomain(true)}>
|
||||||
|
<Plus className="h-3 w-3 mr-1" /> Add Domain
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{(Array.isArray(tree.domains) ? tree.domains : []).map(domain => (
|
{(Array.isArray(tree.domains) ? tree.domains : []).map(domain => (
|
||||||
<Collapsible key={domain.id} defaultOpen>
|
<Collapsible key={domain.id} defaultOpen>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CollapsibleTrigger className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
<div className="flex items-center gap-2">
|
<CollapsibleTrigger asChild>
|
||||||
<ChevronRight className="h-4 w-4 transition-transform group-data-[state=open]:rotate-90" />
|
<div className="flex items-center gap-2 cursor-pointer select-none">
|
||||||
<Layers className="h-4 w-4 text-primary" />
|
<ChevronRight className="h-4 w-4 transition-transform group-data-[state=open]:rotate-90" />
|
||||||
<CardTitle className="text-base">{domain.name}</CardTitle>
|
<Layers className="h-4 w-4 text-primary" />
|
||||||
<Badge variant="secondary">{(Array.isArray(domain.topics) ? domain.topics : []).length} topics</Badge>
|
{editingDomainId === domain.id ? (
|
||||||
</div>
|
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
|
||||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
<Input value={editDomainName} onChange={e => setEditDomainName(e.target.value)} className="h-7 text-sm w-48" />
|
||||||
|
<Button size="sm" className="h-7" disabled={updateDomainMutation.isPending || !editDomainName}
|
||||||
|
onClick={() => updateDomainMutation.mutate({ id: domain.id, data: { name: editDomainName } })}>
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="h-7" onClick={() => setEditingDomainId(null)}>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CardTitle className="text-base">{domain.name}</CardTitle>
|
||||||
|
)}
|
||||||
|
<Badge variant="secondary">{(Array.isArray(domain.topics) ? domain.topics : []).length} topics</Badge>
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<Button variant="ghost" size="sm" onClick={() => aiSuggestMutation.mutate(domain.id)} disabled={aiSuggestMutation.isPending}>
|
<Button variant="ghost" size="sm" onClick={() => aiSuggestMutation.mutate(domain.id)} disabled={aiSuggestMutation.isPending}>
|
||||||
<Sparkles className="h-3 w-3 mr-1" /> AI Suggest
|
<Sparkles className="h-3 w-3 mr-1" /> AI Suggest
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={() => { setShowAddTopic(domain.id); setNewTopicName(""); }}>
|
<Button variant="ghost" size="sm" onClick={() => { setShowAddTopic(domain.id); setNewTopicName(""); }}>
|
||||||
<Plus className="h-3 w-3 mr-1" /> Topic
|
<Plus className="h-3 w-3 mr-1" /> Topic
|
||||||
</Button>
|
</Button>
|
||||||
|
{editingDomainId !== domain.id && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => startEditDomain(domain)}>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="ghost" size="sm" className="text-destructive" onClick={() => { if (!window.confirm(`Delete domain "${domain.name}"?`)) return; deleteDomainMutation.mutate(domain.id); }}>
|
<Button variant="ghost" size="sm" className="text-destructive" onClick={() => { if (!window.confirm(`Delete domain "${domain.name}"?`)) return; deleteDomainMutation.mutate(domain.id); }}>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleTrigger>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{showAddTopic === domain.id && (
|
{showAddTopic === domain.id && (
|
||||||
<div className="flex items-center gap-2 p-2 rounded border bg-muted/50">
|
<div className="flex items-center gap-2 p-2 rounded border bg-muted/50">
|
||||||
<Input placeholder="Topic name" value={newTopicName} onChange={(e) => setNewTopicName(e.target.value)} className="h-8 text-sm" />
|
<Input placeholder="Topic name" value={newTopicName} onChange={e => setNewTopicName(e.target.value)} className="h-8 text-sm" />
|
||||||
<Select value={newTopicDifficulty} onValueChange={setNewTopicDifficulty}>
|
<Select value={newTopicDifficulty} onValueChange={setNewTopicDifficulty}>
|
||||||
<SelectTrigger className="w-28 h-8"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="w-28 h-8"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -184,28 +341,62 @@ export default function TaxonomyManager() {
|
|||||||
<SelectItem value="hard">Hard</SelectItem>
|
<SelectItem value="hard">Hard</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input placeholder="Hrs" type="number" value={newTopicHours} onChange={(e) => setNewTopicHours(e.target.value)} className="w-16 h-8 text-sm" />
|
<Input placeholder="Hrs" type="number" value={newTopicHours} onChange={e => setNewTopicHours(e.target.value)} className="w-16 h-8 text-sm" />
|
||||||
<Button size="sm" disabled={createTopicMutation.isPending || !newTopicName} onClick={() => createTopicMutation.mutate({ name: newTopicName, domain_id: domain.id, difficulty_level: newTopicDifficulty, estimated_hours: Number(newTopicHours) || 1 })}>Add</Button>
|
<Button size="sm" disabled={createTopicMutation.isPending || !newTopicName} onClick={() => createTopicMutation.mutate({ name: newTopicName, domain_id: domain.id, difficulty_level: newTopicDifficulty, estimated_hours: Number(newTopicHours) || 1 })}>Add</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={() => setShowAddTopic(null)}><X className="h-3 w-3" /></Button>
|
<Button size="sm" variant="ghost" onClick={() => setShowAddTopic(null)}><X className="h-3 w-3" /></Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(Array.isArray(domain.topics) ? domain.topics : []).map(topic => (
|
{(Array.isArray(domain.topics) ? domain.topics : []).map(topic => (
|
||||||
<div key={topic.id} className="flex items-center justify-between p-2 rounded border hover:bg-accent/50">
|
<div key={topic.id} className="p-2 rounded border hover:bg-accent/50">
|
||||||
<div className="flex items-center gap-2">
|
{editingTopicId === topic.id ? (
|
||||||
<FileText className="h-3 w-3 text-muted-foreground" />
|
<div className="space-y-2">
|
||||||
<span className="text-sm">{topic.name}</span>
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className="text-xs">{topic.difficulty_level}</Badge>
|
<Input value={editTopicName} onChange={e => setEditTopicName(e.target.value)} className="h-7 text-sm flex-1" placeholder="Topic name" />
|
||||||
<span className="text-xs text-muted-foreground">{(Array.isArray(topic.objectives) ? topic.objectives : []).length} objectives</span>
|
<Select value={editTopicDifficulty} onValueChange={setEditTopicDifficulty}>
|
||||||
</div>
|
<SelectTrigger className="w-28 h-7"><SelectValue /></SelectTrigger>
|
||||||
<div className="flex items-center gap-2">
|
<SelectContent>
|
||||||
<span className="text-xs text-muted-foreground">~{topic.estimated_hours}h</span>
|
<SelectItem value="easy">Easy</SelectItem>
|
||||||
<Button size="sm" variant="ghost" className="text-destructive h-6 w-6 p-0" onClick={() => { if (!window.confirm(`Delete topic "${topic.name}"?`)) return; deleteTopicMutation.mutate(topic.id); }}>
|
<SelectItem value="medium">Medium</SelectItem>
|
||||||
<Trash2 className="h-3 w-3" />
|
<SelectItem value="hard">Hard</SelectItem>
|
||||||
</Button>
|
</SelectContent>
|
||||||
</div>
|
</Select>
|
||||||
|
<Input type="number" value={editTopicHours} onChange={e => setEditTopicHours(e.target.value)} className="w-16 h-7 text-sm" placeholder="Hrs" />
|
||||||
|
</div>
|
||||||
|
<Textarea value={editTopicDesc} onChange={e => setEditTopicDesc(e.target.value)} className="text-sm min-h-[60px]" placeholder="Description (optional)" />
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" className="h-6" disabled={updateTopicMutation.isPending || !editTopicName}
|
||||||
|
onClick={() => updateTopicMutation.mutate({ id: topic.id, data: { name: editTopicName, description: editTopicDesc } })}>
|
||||||
|
<Check className="h-3 w-3 mr-1" /> Save
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="h-6" onClick={() => setEditingTopicId(null)}>
|
||||||
|
<X className="h-3 w-3" /> Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<FileText className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||||
|
<span className="text-sm font-medium">{topic.name}</span>
|
||||||
|
<Badge variant="outline" className="text-xs">{topic.difficulty_level}</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">{(Array.isArray(topic.objectives) ? topic.objectives : []).length} objectives</span>
|
||||||
|
<RelBadge count={(topic as unknown as { resource_count?: number }).resource_count ?? 0} label="resources" icon={Library} onClick={() => navigate("/admin/resources")} />
|
||||||
|
<RelBadge count={(topic as unknown as { chapter_count?: number }).chapter_count ?? 0} label="chapters" icon={Link2} onClick={() => navigate("/admin/courses")} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<span className="text-xs text-muted-foreground">~{topic.estimated_hours}h</span>
|
||||||
|
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => startEditTopic(topic)}>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="text-destructive h-6 w-6 p-0" onClick={() => { if (!window.confirm(`Delete topic "${topic.name}"?`)) return; deleteTopicMutation.mutate(topic.id); }}>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{(Array.isArray(domain.topics) ? domain.topics : []).length === 0 && !showAddTopic && <p className="text-sm text-muted-foreground py-2">No topics in this domain yet.</p>}
|
{(Array.isArray(domain.topics) ? domain.topics : []).length === 0 && showAddTopic !== domain.id && <p className="text-sm text-muted-foreground py-2">No topics in this domain yet.</p>}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
@@ -223,7 +414,7 @@ export default function TaxonomyManager() {
|
|||||||
<DialogHeader><DialogTitle>Add Domain</DialogTitle></DialogHeader>
|
<DialogHeader><DialogTitle>Add Domain</DialogTitle></DialogHeader>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Domain Name</Label>
|
<Label>Domain Name</Label>
|
||||||
<Input placeholder="e.g. Algebra" value={newDomainName} onChange={(e) => setNewDomainName(e.target.value)} />
|
<Input placeholder="e.g. Algebra" value={newDomainName} onChange={e => setNewDomainName(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button variant="outline" onClick={() => setShowAddDomain(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setShowAddDomain(false)}>Cancel</Button>
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export default function AiEnglishCourse() {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: course?.learning_style ?? ["visual"];
|
: course?.learning_style ?? ["visual"];
|
||||||
createEnglish.mutate(
|
createEnglish.mutate(
|
||||||
{ current_level: course?.current_level ?? "B1", target_level: tgt, learning_style: styles },
|
{ cefr_level: tgt || course?.current_level || "B1" },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.course(courseId) });
|
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.course(courseId) });
|
||||||
|
|||||||
@@ -166,9 +166,8 @@ export default function AiIeltsCourse() {
|
|||||||
const band = Number(targetBand || course?.target_level || 7);
|
const band = Number(targetBand || course?.target_level || 7);
|
||||||
createIelts.mutate(
|
createIelts.mutate(
|
||||||
{
|
{
|
||||||
exam_type: course?.exam_type ?? "academic",
|
skill: skillsRanked[0]?.skill ?? "writing",
|
||||||
target_band: Number.isFinite(band) ? band : 7,
|
target_band: Number.isFinite(band) ? band : 7,
|
||||||
skills: skillsRanked.map((s) => s.skill),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||||
@@ -19,58 +20,173 @@ import {
|
|||||||
PolarRadiusAxis,
|
PolarRadiusAxis,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { Award, BookOpen, Download, RefreshCw } from "lucide-react";
|
import { Award, BookOpen, Download, RefreshCw, Loader2 } from "lucide-react";
|
||||||
|
import { examSessionService } from "@/services/exam-session.service";
|
||||||
|
import { reportService } from "@/services/report.service";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
const SKILLS = [
|
interface ScoreEntry {
|
||||||
{ skill: "Listening", band: 7.5, cefr: "C1", gap: 0.5, target: 8 },
|
skill: string;
|
||||||
{ skill: "Reading", band: 8, cefr: "C1", gap: 0, target: 8 },
|
band_score: number;
|
||||||
{ skill: "Writing", band: 6.5, cefr: "B2", gap: 1.5, target: 8 },
|
raw_score: number;
|
||||||
{ skill: "Speaking", band: 7, cefr: "C1", gap: 1, target: 8 },
|
max_score: number;
|
||||||
];
|
cefr_level: string;
|
||||||
|
}
|
||||||
|
|
||||||
const RADAR_DATA = SKILLS.map((s) => ({ skill: s.skill, band: s.band }));
|
interface FeedbackEntry {
|
||||||
|
question_id: number | null;
|
||||||
|
feedback_text: string;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnswerEntry {
|
||||||
|
question_id: number;
|
||||||
|
answer: string;
|
||||||
|
score: number;
|
||||||
|
is_correct: boolean;
|
||||||
|
feedback: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExamResultsData {
|
||||||
|
attempt_id: number;
|
||||||
|
exam_id: number | null;
|
||||||
|
exam_title?: string;
|
||||||
|
status: string;
|
||||||
|
completed_at?: string;
|
||||||
|
released_at?: string;
|
||||||
|
started_at?: string | null;
|
||||||
|
finished_at?: string | null;
|
||||||
|
total_score?: number;
|
||||||
|
max_score?: number;
|
||||||
|
percentage?: number;
|
||||||
|
listening_band?: number;
|
||||||
|
reading_band?: number;
|
||||||
|
writing_band?: number;
|
||||||
|
speaking_band?: number;
|
||||||
|
overall_band?: number;
|
||||||
|
cefr_level?: string;
|
||||||
|
scores?: ScoreEntry[];
|
||||||
|
feedback?: FeedbackEntry[];
|
||||||
|
answers?: AnswerEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
export default function ExamResults() {
|
export default function ExamResults() {
|
||||||
const { examId } = useParams();
|
const { examId } = useParams();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const practice = searchParams.get("mode") === "practice";
|
const practice = searchParams.get("mode") === "practice";
|
||||||
const overall = 7.5;
|
const [downloading, setDownloading] = useState(false);
|
||||||
const cefr = "C1";
|
|
||||||
const passed = overall >= 7;
|
const { data: results, isLoading, isError } = useQuery<ExamResultsData>({
|
||||||
|
queryKey: ["exam-results", examId],
|
||||||
|
queryFn: () => examSessionService.getResults(Number(examId)),
|
||||||
|
enabled: !!examId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDownloadPdf = async () => {
|
||||||
|
if (!results?.attempt_id) return;
|
||||||
|
setDownloading(true);
|
||||||
|
try {
|
||||||
|
await reportService.downloadPdf(results.attempt_id);
|
||||||
|
} catch {
|
||||||
|
// error handled silently
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !results) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl p-6 text-center">
|
||||||
|
<p className="text-lg font-medium text-destructive">Results not yet available</p>
|
||||||
|
<p className="text-muted-foreground mt-2">Your results may still be pending approval or grading.</p>
|
||||||
|
<Button variant="outline" className="mt-4" asChild>
|
||||||
|
<Link to={`/student/exam/${examId}/status`}>Check Status</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasDetailedScores = Array.isArray(results.scores) && results.scores.length > 0;
|
||||||
|
const overall = results.overall_band ?? 0;
|
||||||
|
const pct = results.percentage ?? (results.max_score ? Math.round((results.total_score ?? 0) / results.max_score * 100) : 0);
|
||||||
|
const cefr = results.cefr_level?.toUpperCase() || "N/A";
|
||||||
|
const passed = hasDetailedScores ? overall >= 7 : pct >= 70;
|
||||||
|
|
||||||
|
const skillScores = hasDetailedScores ? results.scores!.filter((s) => s.skill !== "overall") : [];
|
||||||
|
const SKILLS = skillScores.map((s) => ({
|
||||||
|
skill: s.skill.charAt(0).toUpperCase() + s.skill.slice(1),
|
||||||
|
band: s.band_score,
|
||||||
|
cefr: s.cefr_level?.toUpperCase() || "N/A",
|
||||||
|
gap: Math.max(0, 8 - s.band_score),
|
||||||
|
target: 8,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const RADAR_DATA = SKILLS.map((s) => ({ skill: s.skill, band: s.band }));
|
||||||
|
|
||||||
|
const feedbackList = results.feedback ?? [];
|
||||||
|
const feedbackBySkill: Record<string, FeedbackEntry[]> = {};
|
||||||
|
for (const fb of feedbackList) {
|
||||||
|
const key = "General";
|
||||||
|
if (!feedbackBySkill[key]) feedbackBySkill[key] = [];
|
||||||
|
feedbackBySkill[key].push(fb);
|
||||||
|
}
|
||||||
|
|
||||||
|
const answerEntries = results.answers ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl space-y-8 p-6">
|
<div className="mx-auto max-w-5xl space-y-8 p-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
{results.exam_title && (
|
||||||
|
<h1 className="text-xl font-semibold mb-1">{results.exam_title}</h1>
|
||||||
|
)}
|
||||||
<p className="text-sm text-muted-foreground">Your overall result</p>
|
<p className="text-sm text-muted-foreground">Your overall result</p>
|
||||||
<div className="mt-2 flex items-center justify-center gap-3">
|
{hasDetailedScores ? (
|
||||||
<Award className="h-12 w-12 text-primary" />
|
<div className="mt-2 flex items-center justify-center gap-3">
|
||||||
<span className="text-5xl font-bold tabular-nums">{overall}</span>
|
<Award className="h-12 w-12 text-primary" />
|
||||||
<Badge variant="secondary" className="text-lg">
|
<span className="text-5xl font-bold tabular-nums">{overall}</span>
|
||||||
Band
|
<Badge variant="secondary" className="text-lg">Band</Badge>
|
||||||
</Badge>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
<p className="mt-2 text-lg text-muted-foreground">CEFR equivalent: {cefr}</p>
|
<div className="mt-2 flex items-center justify-center gap-3">
|
||||||
|
<Award className="h-12 w-12 text-primary" />
|
||||||
|
<span className="text-5xl font-bold tabular-nums">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{results.total_score ?? 0} / {results.max_score ?? 0} marks
|
||||||
|
</p>
|
||||||
|
{hasDetailedScores && <p className="mt-1 text-lg text-muted-foreground">CEFR equivalent: {cefr}</p>}
|
||||||
{practice ? <Badge className="mt-2">Practice mode</Badge> : null}
|
{practice ? <Badge className="mt-2">Practice mode</Badge> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
{RADAR_DATA.length > 0 && (
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle>Skill profile</CardTitle>
|
<CardHeader>
|
||||||
<CardDescription>Per-skill performance vs maximum scale</CardDescription>
|
<CardTitle>Skill profile</CardTitle>
|
||||||
</CardHeader>
|
<CardDescription>Per-skill performance vs maximum scale</CardDescription>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<div className="h-72 w-full">
|
<CardContent>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<div className="h-72 w-full">
|
||||||
<RadarChart data={RADAR_DATA} cx="50%" cy="50%" outerRadius="80%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<PolarGrid />
|
<RadarChart data={RADAR_DATA} cx="50%" cy="50%" outerRadius="80%">
|
||||||
<PolarAngleAxis dataKey="skill" />
|
<PolarGrid />
|
||||||
<PolarRadiusAxis angle={30} domain={[0, 9]} tickCount={6} />
|
<PolarAngleAxis dataKey="skill" />
|
||||||
<Radar name="Band" dataKey="band" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.35} />
|
<PolarRadiusAxis angle={30} domain={[0, 9]} tickCount={6} />
|
||||||
</RadarChart>
|
<Radar name="Band" dataKey="band" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.35} />
|
||||||
</ResponsiveContainer>
|
</RadarChart>
|
||||||
</div>
|
</ResponsiveContainer>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -100,19 +216,61 @@ export default function ExamResults() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div>
|
{answerEntries.length > 0 && (
|
||||||
<h2 className="mb-3 text-lg font-semibold">Section feedback</h2>
|
<Card>
|
||||||
<Accordion type="multiple" className="w-full">
|
<CardHeader>
|
||||||
{["Listening", "Reading", "Writing", "Speaking"].map((name) => (
|
<CardTitle>Your Answers</CardTitle>
|
||||||
<AccordionItem key={name} value={name}>
|
<CardDescription>
|
||||||
<AccordionTrigger>{name}</AccordionTrigger>
|
{answerEntries.filter((a) => a.is_correct).length} correct out of {answerEntries.length}
|
||||||
<AccordionContent className="text-muted-foreground">
|
</CardDescription>
|
||||||
Detailed feedback for {name} will appear here once released by your instructor.
|
</CardHeader>
|
||||||
</AccordionContent>
|
<CardContent>
|
||||||
</AccordionItem>
|
<Table>
|
||||||
))}
|
<TableHeader>
|
||||||
</Accordion>
|
<TableRow>
|
||||||
</div>
|
<TableHead>#</TableHead>
|
||||||
|
<TableHead>Your Answer</TableHead>
|
||||||
|
<TableHead>Score</TableHead>
|
||||||
|
<TableHead>Result</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{answerEntries.map((a, i) => (
|
||||||
|
<TableRow key={a.question_id}>
|
||||||
|
<TableCell>{i + 1}</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate">{a.answer || "—"}</TableCell>
|
||||||
|
<TableCell>{a.score}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={a.is_correct ? "default" : "destructive"}>
|
||||||
|
{a.is_correct ? "Correct" : "Incorrect"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{feedbackList.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 text-lg font-semibold">Feedback</h2>
|
||||||
|
<Accordion type="multiple" className="w-full">
|
||||||
|
{feedbackList.map((fb, i) => (
|
||||||
|
<AccordionItem key={i} value={`fb-${i}`}>
|
||||||
|
<AccordionTrigger>
|
||||||
|
{fb.source === "ai" ? "AI Feedback" : fb.source === "teacher" ? "Teacher Feedback" : "Feedback"}{" "}
|
||||||
|
{fb.question_id ? `(Q${fb.question_id})` : ""}
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="text-muted-foreground">
|
||||||
|
{fb.feedback_text || "No detailed feedback available."}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -121,16 +279,21 @@ export default function ExamResults() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ul className="list-inside list-disc space-y-2 text-sm">
|
<ul className="list-inside list-disc space-y-2 text-sm">
|
||||||
<li>Strengthen task response structure in Writing Task 2.</li>
|
{SKILLS.filter((s) => s.gap > 0)
|
||||||
<li>Extend range of cohesive devices in argumentative essays.</li>
|
.sort((a, b) => b.gap - a.gap)
|
||||||
<li>Maintain fluency while reducing hesitation in Speaking Part 2.</li>
|
.map((s) => (
|
||||||
|
<li key={s.skill}>
|
||||||
|
Focus on <strong>{s.skill}</strong> — current band {s.band}, target {s.target} (gap: {s.gap}).
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{SKILLS.every((s) => s.gap === 0) && <li>Excellent performance across all skills!</li>}
|
||||||
</ul>
|
</ul>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Button type="button" variant="outline">
|
<Button type="button" variant="outline" onClick={handleDownloadPdf} disabled={downloading}>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
{downloading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
|
||||||
Download PDF Report
|
Download PDF Report
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" asChild>
|
<Button type="button" asChild>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useExamSession, useExamAutoSave, useExamSubmit } from "@/hooks/queries/useExamSession";
|
import { useExamSession, useExamAutoSave, useExamSubmit } from "@/hooks/queries/useExamSession";
|
||||||
import type { ExamAnswer, ExamQuestion, ExamSessionSection } from "@/types";
|
import type { ExamAnswer, ExamOptionItem, ExamQuestion, ExamSessionSection } from "@/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
@@ -19,11 +19,17 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Flag, ChevronLeft, ChevronRight, Pause, Play } from "lucide-react";
|
import { Flag, ChevronLeft, ChevronRight, Pause, Play, Mic, Square } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { mediaService } from "@/services/media.service";
|
||||||
|
|
||||||
function normalizeType(t: string) {
|
function normalizeType(t: string | null | undefined) {
|
||||||
return t.toLowerCase().replace(/\s+/g, "_");
|
return (t ?? "").toLowerCase().replace(/\s+/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOption(o: ExamOptionItem): { label: string; text: string } {
|
||||||
|
if (typeof o === "string") return { label: o, text: o };
|
||||||
|
return { label: o.label ?? o.text ?? "", text: o.text ?? o.label ?? "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
function countWords(s: string) {
|
function countWords(s: string) {
|
||||||
@@ -49,10 +55,26 @@ export default function ExamSession() {
|
|||||||
const { examId: examIdParam } = useParams();
|
const { examId: examIdParam } = useParams();
|
||||||
const examId = Number(examIdParam);
|
const examId = Number(examIdParam);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: session, isLoading, isError } = useExamSession(examId);
|
const { data: rawSession, isLoading, isError } = useExamSession(examId);
|
||||||
const autoSave = useExamAutoSave();
|
const autoSave = useExamAutoSave();
|
||||||
const submitMut = useExamSubmit();
|
const submitMut = useExamSubmit();
|
||||||
|
|
||||||
|
const session = useMemo(() => {
|
||||||
|
if (!rawSession) return rawSession;
|
||||||
|
const raw = rawSession as any;
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
title: raw.title || raw.exam_title || "",
|
||||||
|
sections: (raw.sections || []).map((s: any) => ({
|
||||||
|
...s,
|
||||||
|
questions: (s.questions || []).map((q: any) => ({
|
||||||
|
...q,
|
||||||
|
type: q.type || q.question_type || q.skill || "",
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
} as typeof rawSession;
|
||||||
|
}, [rawSession]);
|
||||||
|
|
||||||
const [sectionIdx, setSectionIdx] = useState(0);
|
const [sectionIdx, setSectionIdx] = useState(0);
|
||||||
const [questionIdx, setQuestionIdx] = useState(0);
|
const [questionIdx, setQuestionIdx] = useState(0);
|
||||||
const [answers, setAnswers] = useState<Map<number, ExamAnswer>>(new Map());
|
const [answers, setAnswers] = useState<Map<number, ExamAnswer>>(new Map());
|
||||||
@@ -121,10 +143,11 @@ export default function ExamSession() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session || !section) return;
|
if (!session || !section) return;
|
||||||
|
const attemptId = (session as any)?.attempt_id;
|
||||||
const id = window.setInterval(() => {
|
const id = window.setInterval(() => {
|
||||||
autoSave.mutate({
|
autoSave.mutate({
|
||||||
examId,
|
examId,
|
||||||
payload: { section_id: section.id, answers: currentSectionAnswers() },
|
payload: { attempt_id: attemptId, section_id: section.id, answers: currentSectionAnswers() },
|
||||||
});
|
});
|
||||||
}, 10000);
|
}, 10000);
|
||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
@@ -204,26 +227,19 @@ export default function ExamSession() {
|
|||||||
const a = answers.get(q.id);
|
const a = answers.get(q.id);
|
||||||
if (!a) return null;
|
if (!a) return null;
|
||||||
const nt = normalizeType(q.type);
|
const nt = normalizeType(q.type);
|
||||||
|
const opts = (q.options ?? []).map(normalizeOption);
|
||||||
|
|
||||||
if (nt.includes("listen") || q.audio_url) {
|
if (nt.includes("listen") || q.audio_url) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3 rounded-lg border bg-muted/40 p-4">
|
<ListeningPlayer audioUrl={q.audio_url} audioBase64={q.audio_base64} />
|
||||||
<Button type="button" variant="outline" size="icon" onClick={() => setPlaying((p) => !p)}>
|
{opts.length ? (
|
||||||
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
|
||||||
</Button>
|
|
||||||
<div className="h-2 flex-1 rounded-full bg-muted">
|
|
||||||
<div className="h-2 w-1/3 rounded-full bg-primary" />
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-muted-foreground">0:00 / 3:42</span>
|
|
||||||
</div>
|
|
||||||
{q.options?.length ? (
|
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={typeof a.answer === "string" ? a.answer : ""}
|
value={typeof a.answer === "string" ? a.answer : ""}
|
||||||
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
|
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
|
||||||
className="space-y-2"
|
className="space-y-2"
|
||||||
>
|
>
|
||||||
{q.options.map((o) => (
|
{opts.map((o) => (
|
||||||
<div key={o.label} className="flex items-center space-x-2">
|
<div key={o.label} className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
|
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
|
||||||
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
||||||
@@ -239,7 +255,7 @@ export default function ExamSession() {
|
|||||||
const selected = Array.isArray(a.answer) ? a.answer : [];
|
const selected = Array.isArray(a.answer) ? a.answer : [];
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{q.options?.map((o) => (
|
{opts.map((o) => (
|
||||||
<div key={o.label} className="flex items-center space-x-2">
|
<div key={o.label} className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`${q.id}-${o.label}`}
|
id={`${q.id}-${o.label}`}
|
||||||
@@ -257,7 +273,7 @@ export default function ExamSession() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nt.includes("gap")) {
|
if (nt.includes("gap") || nt.includes("fill") || nt.includes("short_answer") || nt.includes("summary")) {
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
value={typeof a.answer === "string" ? a.answer : ""}
|
value={typeof a.answer === "string" ? a.answer : ""}
|
||||||
@@ -269,12 +285,12 @@ export default function ExamSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (nt.includes("true") || nt.includes("tfng") || nt === "yes_no_not_given") {
|
if (nt.includes("true") || nt.includes("tfng") || nt === "yes_no_not_given") {
|
||||||
const opts = q.options?.length
|
const tfOpts = opts.length
|
||||||
? q.options
|
? opts
|
||||||
: [
|
: [
|
||||||
{ label: "T", text: "True" },
|
{ label: "TRUE", text: "TRUE" },
|
||||||
{ label: "F", text: "False" },
|
{ label: "FALSE", text: "FALSE" },
|
||||||
{ label: "NG", text: "Not Given" },
|
{ label: "NOT GIVEN", text: "NOT GIVEN" },
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
@@ -282,7 +298,7 @@ export default function ExamSession() {
|
|||||||
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
|
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
|
||||||
className="space-y-2"
|
className="space-y-2"
|
||||||
>
|
>
|
||||||
{opts.map((o) => (
|
{tfOpts.map((o) => (
|
||||||
<div key={o.label} className="flex items-center space-x-2">
|
<div key={o.label} className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
|
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
|
||||||
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
||||||
@@ -312,9 +328,13 @@ export default function ExamSession() {
|
|||||||
|
|
||||||
if (nt.includes("speak") || nt.includes("record") || nt.includes("audio")) {
|
if (nt.includes("speak") || nt.includes("record") || nt.includes("audio")) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
|
<SpeakingRecorder
|
||||||
Recording interface will appear here.
|
questionId={q.id}
|
||||||
</div>
|
onRecorded={(blob) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
updateAnswer(q.id, { answer: url });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +344,7 @@ export default function ExamSession() {
|
|||||||
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
|
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
|
||||||
className="space-y-2"
|
className="space-y-2"
|
||||||
>
|
>
|
||||||
{q.options?.map((o) => (
|
{opts.map((o) => (
|
||||||
<div key={o.label} className="flex items-center space-x-2">
|
<div key={o.label} className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
|
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
|
||||||
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
||||||
@@ -375,13 +395,39 @@ export default function ExamSession() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-1 min-h-0 flex-col md:flex-row">
|
<div className="flex flex-1 min-h-0 flex-col md:flex-row">
|
||||||
<ScrollArea className="flex-1 p-6">
|
{section?.passage_text ? (
|
||||||
|
<ScrollArea className="w-full md:w-1/2 border-r p-6">
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||||
|
<h3 className="text-base font-semibold mb-2">{section.title}</h3>
|
||||||
|
{section.difficulty && (
|
||||||
|
<span className="inline-block text-xs font-medium bg-primary/10 text-primary px-2 py-0.5 rounded mb-3">
|
||||||
|
Level: {section.difficulty}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="rounded-lg bg-muted/50 p-4 text-sm leading-relaxed whitespace-pre-line">
|
||||||
|
{section.passage_text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
) : null}
|
||||||
|
<ScrollArea className={cn("flex-1 p-6", section?.passage_text && "md:w-1/2")}>
|
||||||
{question ? (
|
{question ? (
|
||||||
<Card className="border-none shadow-none">
|
<Card className="border-none shadow-none">
|
||||||
<CardContent className="space-y-6 p-0">
|
<CardContent className="space-y-6 p-0">
|
||||||
{question.passage_text ? (
|
{section?.instructions_text ? (
|
||||||
<div className="rounded-lg bg-muted/50 p-4 text-sm leading-relaxed">{question.passage_text}</div>
|
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 text-sm leading-relaxed border border-blue-200 dark:border-blue-800">
|
||||||
|
<p className="font-medium text-blue-800 dark:text-blue-200 mb-1">Instructions</p>
|
||||||
|
{section.instructions_text}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="inline-flex items-center justify-center h-7 w-7 rounded-full bg-primary text-primary-foreground text-xs font-bold">
|
||||||
|
{questionIdx + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
of {section?.questions.length ?? 0} · {question.marks} mark{question.marks !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||||
<p className="font-medium">{question.stem}</p>
|
<p className="font-medium">{question.stem}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -439,12 +485,20 @@ export default function ExamSession() {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
submitMut.mutate(examId, {
|
const attemptId = (session as any)?.attempt_id;
|
||||||
onSuccess: () => {
|
const allAnswers = Array.from(answers.entries()).map(([qId, a]) => ({
|
||||||
setReviewOpen(false);
|
question_id: qId,
|
||||||
navigate(`/student/exam/${examId}/status`);
|
answer: a.answer ?? "",
|
||||||
|
}));
|
||||||
|
submitMut.mutate(
|
||||||
|
{ examId, attempt_id: attemptId, answers: allAnswers },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setReviewOpen(false);
|
||||||
|
navigate(`/student/exam/${examId}/status`);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
}}
|
}}
|
||||||
disabled={submitMut.isPending}
|
disabled={submitMut.isPending}
|
||||||
>
|
>
|
||||||
@@ -456,3 +510,131 @@ export default function ExamSession() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ListeningPlayer({ audioUrl, audioBase64 }: { audioUrl?: string; audioBase64?: string }) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
|
||||||
|
const src = audioUrl || (audioBase64 ? `data:audio/mpeg;base64,${audioBase64}` : "");
|
||||||
|
|
||||||
|
const formatTime = (sec: number) => {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = Math.floor(sec % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{src ? (
|
||||||
|
<>
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={src}
|
||||||
|
onTimeUpdate={() => setCurrentTime(audioRef.current?.currentTime ?? 0)}
|
||||||
|
onLoadedMetadata={() => setDuration(audioRef.current?.duration ?? 0)}
|
||||||
|
onEnded={() => setIsPlaying(false)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border bg-muted/40 p-4">
|
||||||
|
<Button
|
||||||
|
type="button" variant="outline" size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
if (isPlaying) { audioRef.current.pause(); setIsPlaying(false); }
|
||||||
|
else { audioRef.current.play(); setIsPlaying(true); }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||||
|
</Button>
|
||||||
|
<div className="h-2 flex-1 rounded-full bg-muted cursor-pointer" onClick={(e) => {
|
||||||
|
if (!audioRef.current || !duration) return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const pct = (e.clientX - rect.left) / rect.width;
|
||||||
|
audioRef.current.currentTime = pct * duration;
|
||||||
|
}}>
|
||||||
|
<div className="h-2 rounded-full bg-primary transition-all" style={{ width: duration ? `${(currentTime / duration) * 100}%` : "0%" }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground tabular-nums">{formatTime(currentTime)} / {formatTime(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border bg-muted/40 p-4 text-muted-foreground text-sm">
|
||||||
|
Audio will be played by the examiner or is not yet available.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpeakingRecorder({ questionId, onRecorded }: { questionId: number; onRecorded: (blob: Blob) => void }) {
|
||||||
|
const [recording, setRecording] = useState(false);
|
||||||
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
const timerRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const startRecording = async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const mr = new MediaRecorder(stream, { mimeType: "audio/webm" });
|
||||||
|
chunksRef.current = [];
|
||||||
|
mr.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
|
||||||
|
mr.onstop = () => {
|
||||||
|
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
setAudioUrl(url);
|
||||||
|
onRecorded(blob);
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
};
|
||||||
|
mediaRecorderRef.current = mr;
|
||||||
|
mr.start();
|
||||||
|
setRecording(true);
|
||||||
|
setElapsed(0);
|
||||||
|
timerRef.current = window.setInterval(() => setElapsed((t) => t + 1), 1000);
|
||||||
|
} catch {
|
||||||
|
// microphone permission denied
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopRecording = () => {
|
||||||
|
mediaRecorderRef.current?.stop();
|
||||||
|
setRecording(false);
|
||||||
|
window.clearInterval(timerRef.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mm = Math.floor(elapsed / 60);
|
||||||
|
const ss = elapsed % 60;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-center gap-4">
|
||||||
|
{!recording && !audioUrl && (
|
||||||
|
<Button type="button" size="lg" onClick={startRecording} className="gap-2">
|
||||||
|
<Mic className="h-5 w-5" /> Start Recording
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{recording && (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-red-500 animate-pulse" />
|
||||||
|
<span className="font-mono text-lg tabular-nums">{String(mm).padStart(2, "0")}:{String(ss).padStart(2, "0")}</span>
|
||||||
|
<Button type="button" variant="destructive" size="lg" onClick={stopRecording} className="gap-2">
|
||||||
|
<Square className="h-4 w-4" /> Stop
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{audioUrl && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<audio controls src={audioUrl} className="w-full" />
|
||||||
|
<div className="flex gap-2 justify-center">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => { setAudioUrl(null); setElapsed(0); }}>
|
||||||
|
Re-record
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate, useSearchParams, useLocation } from "react-router-dom";
|
import { useNavigate, useSearchParams, useLocation } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -7,7 +7,8 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Mic, Loader2 } from "lucide-react";
|
import { Mic, Loader2, Square } from "lucide-react";
|
||||||
|
import { placementService } from "@/services/placement.service";
|
||||||
import { usePlacementAnswer, usePlacementAutoSave } from "@/hooks/queries/usePlacement";
|
import { usePlacementAnswer, usePlacementAutoSave } from "@/hooks/queries/usePlacement";
|
||||||
import type { CATQuestion, CATSection } from "@/types";
|
import type { CATQuestion, CATSection } from "@/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -324,15 +325,11 @@ export default function PlacementTest() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{question.type === "audio_recording" && (
|
{question.type === "audio_recording" && (
|
||||||
<div className="flex flex-col items-center gap-4 py-8 rounded-xl border border-dashed bg-muted/30">
|
<PlacementSpeakingRecorder
|
||||||
<Mic className="h-12 w-12 text-muted-foreground" />
|
sessionId={sessionId}
|
||||||
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
promptId={question.id}
|
||||||
Recording will be available in a future update. Use the button below to proceed for now.
|
onUploaded={() => setSingleAnswer("audio_submitted")}
|
||||||
</p>
|
/>
|
||||||
<Button type="button" variant="secondary" size="lg" disabled>
|
|
||||||
Record (placeholder)
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end pt-4">
|
<div className="flex justify-end pt-4">
|
||||||
@@ -362,3 +359,93 @@ export default function PlacementTest() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlacementSpeakingRecorder({ sessionId, promptId, onUploaded }: {
|
||||||
|
sessionId: string; promptId: number; onUploaded: () => void;
|
||||||
|
}) {
|
||||||
|
const [recording, setRecording] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
const timerRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const startRecording = async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const mr = new MediaRecorder(stream, { mimeType: "audio/webm" });
|
||||||
|
chunksRef.current = [];
|
||||||
|
mr.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
|
||||||
|
mr.onstop = () => {
|
||||||
|
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
|
||||||
|
setAudioUrl(URL.createObjectURL(blob));
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
uploadAudio(blob);
|
||||||
|
};
|
||||||
|
mediaRecorderRef.current = mr;
|
||||||
|
mr.start();
|
||||||
|
setRecording(true);
|
||||||
|
setElapsed(0);
|
||||||
|
timerRef.current = window.setInterval(() => setElapsed((t) => t + 1), 1000);
|
||||||
|
} catch {
|
||||||
|
// microphone permission denied
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopRecording = () => {
|
||||||
|
mediaRecorderRef.current?.stop();
|
||||||
|
setRecording(false);
|
||||||
|
window.clearInterval(timerRef.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadAudio = async (blob: Blob) => {
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const file = new File([blob], "speaking.webm", { type: "audio/webm" });
|
||||||
|
await placementService.uploadSpeaking(sessionId, promptId, file);
|
||||||
|
onUploaded();
|
||||||
|
} catch {
|
||||||
|
// upload error handled silently
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mm = Math.floor(elapsed / 60);
|
||||||
|
const ss = elapsed % 60;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-8 rounded-xl border border-dashed bg-muted/30">
|
||||||
|
{!recording && !audioUrl && (
|
||||||
|
<>
|
||||||
|
<Mic className="h-12 w-12 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
||||||
|
Click the button below to start recording your speaking response.
|
||||||
|
</p>
|
||||||
|
<Button type="button" variant="secondary" size="lg" onClick={startRecording} className="gap-2">
|
||||||
|
<Mic className="h-5 w-5" /> Start Recording
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{recording && (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-red-500 animate-pulse" />
|
||||||
|
<span className="font-mono text-lg tabular-nums">{String(mm).padStart(2, "0")}:{String(ss).padStart(2, "0")}</span>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="destructive" size="lg" onClick={stopRecording} className="gap-2">
|
||||||
|
<Square className="h-4 w-4" /> Stop Recording
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{audioUrl && (
|
||||||
|
<div className="space-y-3 w-full max-w-md">
|
||||||
|
<audio controls src={audioUrl} className="w-full" />
|
||||||
|
{uploading && <p className="text-sm text-center text-muted-foreground flex items-center justify-center gap-2"><Loader2 className="h-4 w-4 animate-spin" /> Uploading...</p>}
|
||||||
|
{!uploading && <p className="text-sm text-center text-green-600">Recording uploaded successfully</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,33 +5,38 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useAssignments } from "@/hooks/queries";
|
import { useCourseAssignments } from "@/hooks/queries";
|
||||||
import { Upload } from "lucide-react";
|
import { Upload, ClipboardList } from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import AiWritingHelper from "@/components/ai/AiWritingHelper";
|
import AiWritingHelper from "@/components/ai/AiWritingHelper";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
|
import type { CourseAssignment } from "@/types";
|
||||||
|
|
||||||
export default function StudentAssignments() {
|
export default function StudentAssignments() {
|
||||||
const [submitOpen, setSubmitOpen] = useState(false);
|
const [submitOpen, setSubmitOpen] = useState(false);
|
||||||
const [selectedAssignment, setSelectedAssignment] = useState<string | null>(null);
|
const [selectedAssignment, setSelectedAssignment] = useState<number | null>(null);
|
||||||
const [draftText, setDraftText] = useState("");
|
const [draftText, setDraftText] = useState("");
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { data: assignmentsData, isLoading } = useAssignments();
|
const { data: assignmentsData, isLoading } = useCourseAssignments();
|
||||||
const assignments = assignmentsData?.items ?? [];
|
const assignments: CourseAssignment[] = assignmentsData?.items ?? [];
|
||||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
setSubmitOpen(false);
|
setSubmitOpen(false);
|
||||||
|
setSelectedAssignment(null);
|
||||||
toast({ title: "Assignment Submitted", description: "Your work has been submitted successfully." });
|
toast({ title: "Assignment Submitted", description: "Your work has been submitted successfully." });
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusFilter = (status: string) => assignments.filter(a => status === "all" ? true : a.status === status);
|
const stateVariant = (s: string) => s === "finish" ? "default" : s === "cancel" ? "destructive" : "secondary";
|
||||||
|
const stateLabel = (s: string) => s === "publish" ? "Active" : s === "finish" ? "Completed" : s === "cancel" ? "Cancelled" : "Draft";
|
||||||
|
|
||||||
|
const stateFilter = (state: string) => assignments.filter(a => state === "all" ? true : a.state === state);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Assignments</h1>
|
<h1 className="text-2xl font-bold">Assignments</h1>
|
||||||
<p className="text-muted-foreground">View and submit your assignments.</p>
|
<p className="text-muted-foreground">View and submit your course assignments.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AiTipBanner context="student-assignments" variant="recommendation" />
|
<AiTipBanner context="student-assignments" variant="recommendation" />
|
||||||
@@ -39,26 +44,33 @@ export default function StudentAssignments() {
|
|||||||
<Tabs defaultValue="all">
|
<Tabs defaultValue="all">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="all">All ({assignments.length})</TabsTrigger>
|
<TabsTrigger value="all">All ({assignments.length})</TabsTrigger>
|
||||||
<TabsTrigger value="pending">Pending ({statusFilter("pending").length})</TabsTrigger>
|
<TabsTrigger value="publish">Active ({stateFilter("publish").length})</TabsTrigger>
|
||||||
<TabsTrigger value="submitted">Submitted ({statusFilter("submitted").length})</TabsTrigger>
|
<TabsTrigger value="finish">Completed ({stateFilter("finish").length})</TabsTrigger>
|
||||||
<TabsTrigger value="graded">Graded ({statusFilter("graded").length})</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{["all", "pending", "submitted", "graded", "overdue"].map(tab => (
|
{["all", "publish", "finish"].map(tab => (
|
||||||
<TabsContent key={tab} value={tab} className="mt-4 space-y-3">
|
<TabsContent key={tab} value={tab} className="mt-4 space-y-3">
|
||||||
{statusFilter(tab).map(a => (
|
{stateFilter(tab).length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<ClipboardList className="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
||||||
|
<p className="text-muted-foreground text-sm">No assignments found.</p>
|
||||||
|
</div>
|
||||||
|
) : stateFilter(tab).map(a => (
|
||||||
<Card key={a.id}>
|
<Card key={a.id}>
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium text-sm">{a.title}</p>
|
<p className="font-medium text-sm">{a.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{a.courseName} · Due: {a.dueDate}</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
{a.grade !== undefined && <p className="text-xs font-medium text-primary mt-1">Grade: {a.grade}/{a.maxGrade}</p>}
|
{a.course_name} · Due: {a.submission_date}
|
||||||
{a.feedback && <p className="text-xs text-muted-foreground mt-0.5">"{a.feedback}"</p>}
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Marks: {a.marks} · {a.assignment_type_name}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Badge variant={a.status === "graded" ? "default" : a.status === "overdue" ? "destructive" : "secondary"}>{a.status}</Badge>
|
<Badge variant={stateVariant(a.state)}>{stateLabel(a.state)}</Badge>
|
||||||
{a.status === "pending" && (
|
{a.state === "publish" && (
|
||||||
<Button size="sm" onClick={() => { setSelectedAssignment(a.id); setSubmitOpen(true); }}>Submit</Button>
|
<Button size="sm" onClick={() => { setSelectedAssignment(a.id); setSubmitOpen(true); }}>Submit</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { FileText, Video, Image, Link2, Music, Download, CheckCircle2, Loader2 } from "lucide-react";
|
import { FileText, Video, Image, Link2, Music, Download, CheckCircle2, Loader2, Eye } from "lucide-react";
|
||||||
import { useChapter, useChapterMaterials, useChapterProgress, useCompleteChapter, useMarkMaterialViewed } from "@/hooks/queries";
|
import { useChapter, useChapterMaterials, useChapterProgress, useCompleteChapter, useMarkMaterialViewed } from "@/hooks/queries";
|
||||||
import { coursewareService } from "@/services/courseware.service";
|
import { coursewareService } from "@/services/courseware.service";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type { MaterialType } from "@/types/courseware";
|
import MaterialViewer from "@/components/MaterialViewer";
|
||||||
|
import type { ChapterMaterial, MaterialType } from "@/types/courseware";
|
||||||
|
|
||||||
const typeIcons: Record<MaterialType, React.ReactNode> = {
|
const typeIcons: Record<MaterialType, React.ReactNode> = {
|
||||||
pdf: <FileText className="h-5 w-5" />,
|
pdf: <FileText className="h-5 w-5" />,
|
||||||
@@ -29,6 +31,9 @@ export default function StudentChapterView() {
|
|||||||
const completeChapter = useCompleteChapter();
|
const completeChapter = useCompleteChapter();
|
||||||
const markViewed = useMarkMaterialViewed();
|
const markViewed = useMarkMaterialViewed();
|
||||||
|
|
||||||
|
const [activeMaterial, setActiveMaterial] = useState<ChapterMaterial | null>(null);
|
||||||
|
const [viewerOpen, setViewerOpen] = useState(false);
|
||||||
|
|
||||||
const completionPct = progress ? Math.round((progress.materials_completed / Math.max(progress.materials_total, 1)) * 100) : 0;
|
const completionPct = progress ? Math.round((progress.materials_completed / Math.max(progress.materials_total, 1)) * 100) : 0;
|
||||||
|
|
||||||
const handleDownload = async (materialId: number, name: string) => {
|
const handleDownload = async (materialId: number, name: string) => {
|
||||||
@@ -46,14 +51,16 @@ export default function StudentChapterView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkComplete = () => {
|
const handleMarkComplete = () => {
|
||||||
completeChapter.mutate(chId, {
|
completeChapter.mutate({ chapterId: chId, courseId: Number(courseId) }, {
|
||||||
onSuccess: () => toast({ title: "Chapter Completed!" }),
|
onSuccess: () => toast({ title: "Chapter Completed!" }),
|
||||||
onError: () => toast({ title: "Error", description: "Failed to mark complete", variant: "destructive" }),
|
onError: () => toast({ title: "Error", description: "Failed to mark complete", variant: "destructive" }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenMaterial = (materialId: number) => {
|
const handleOpenMaterial = (material: ChapterMaterial) => {
|
||||||
markViewed.mutate({ id: materialId, chapterId: chId });
|
markViewed.mutate({ id: material.id, chapterId: chId });
|
||||||
|
setActiveMaterial(material);
|
||||||
|
setViewerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (lc || lm || lp) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
if (lc || lm || lp) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||||
@@ -95,7 +102,7 @@ export default function StudentChapterView() {
|
|||||||
{materials
|
{materials
|
||||||
.sort((a, b) => a.sequence - b.sequence)
|
.sort((a, b) => a.sequence - b.sequence)
|
||||||
.map(m => (
|
.map(m => (
|
||||||
<Card key={m.id} className="hover:shadow-md transition-shadow cursor-pointer" onClick={() => handleOpenMaterial(m.id)}>
|
<Card key={m.id} className="hover:shadow-md transition-shadow cursor-pointer" onClick={() => handleOpenMaterial(m)}>
|
||||||
<CardContent className="py-4">
|
<CardContent className="py-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 text-primary">
|
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 text-primary">
|
||||||
@@ -103,13 +110,23 @@ export default function StudentChapterView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium text-sm truncate">{m.name}</p>
|
<p className="font-medium text-sm truncate">{m.name}</p>
|
||||||
<Badge variant="outline" className="mt-1 text-xs">{m.type}</Badge>
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Badge variant="outline" className="text-xs">{m.type}</Badge>
|
||||||
|
</div>
|
||||||
|
{m.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{m.description}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{m.allow_download && (
|
<div className="flex flex-col gap-1 shrink-0">
|
||||||
<Button variant="ghost" size="icon" className="shrink-0" onClick={(e) => { e.stopPropagation(); handleDownload(m.id, m.name); }}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => { e.stopPropagation(); handleOpenMaterial(m); }}>
|
||||||
<Download className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
{m.allow_download && (
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => { e.stopPropagation(); handleDownload(m.id, m.name); }}>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -121,6 +138,13 @@ export default function StudentChapterView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MaterialViewer
|
||||||
|
material={activeMaterial}
|
||||||
|
open={viewerOpen}
|
||||||
|
onOpenChange={setViewerOpen}
|
||||||
|
onDownload={handleDownload}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,30 +4,26 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useCourses, useAssignments, useAttendance, useGrades } from "@/hooks/queries";
|
import { useCourse, useChapters, useGrades, useCourseCompletion } from "@/hooks/queries";
|
||||||
import { ArrowLeft, CheckCircle, Circle, FileText, Video, HelpCircle } from "lucide-react";
|
import { ArrowLeft, BookOpen, Lock, ChevronRight, Play, CheckCircle2, Trophy } from "lucide-react";
|
||||||
|
|
||||||
const iconMap = { video: Video, reading: FileText, quiz: HelpCircle };
|
|
||||||
|
|
||||||
export default function StudentCourseDetail() {
|
export default function StudentCourseDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { data: coursesData, isLoading: lc } = useCourses();
|
const courseId = Number(id);
|
||||||
const { data: assignmentsData, isLoading: la } = useAssignments();
|
const { data: course, isLoading: lc } = useCourse(courseId);
|
||||||
const { data: attendanceData, isLoading: lat } = useAttendance();
|
const { data: chapters = [], isLoading: lch } = useChapters(courseId);
|
||||||
const { data: gradesData, isLoading: lg } = useGrades();
|
const { data: gradesData, isLoading: lg } = useGrades({ course_id: courseId });
|
||||||
const courses = coursesData?.items ?? [];
|
const { data: completion } = useCourseCompletion(courseId);
|
||||||
const assignments = assignmentsData?.items ?? [];
|
|
||||||
const attendanceRecords = attendanceData ?? [];
|
|
||||||
const gradeRecords = gradesData ?? [];
|
const gradeRecords = gradesData ?? [];
|
||||||
if (lc || la || lat || lg) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
if (lc || lch || lg) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||||
|
|
||||||
const course = courses.find(c => c.id === id);
|
|
||||||
|
|
||||||
if (!course) return <div className="p-8 text-center text-muted-foreground">Course not found.</div>;
|
if (!course) return <div className="p-8 text-center text-muted-foreground">Course not found.</div>;
|
||||||
|
|
||||||
const courseAssignments = assignments.filter(a => a.courseId === id);
|
const sortedChapters = [...chapters].sort((a, b) => a.sequence - b.sequence);
|
||||||
const courseAttendance = attendanceRecords.filter(a => a.courseId === id);
|
const completedChapters = completion?.chapters_completed ?? 0;
|
||||||
const courseGrades = gradeRecords.filter(g => g.courseId === id);
|
const progress = completion?.progress_percent ?? (chapters.length > 0 ? Math.round((completedChapters / chapters.length) * 100) : 0);
|
||||||
|
const isCompleted = completion?.status === "completed";
|
||||||
|
const postTestAvailable = completion?.post_test_available ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -35,91 +31,116 @@ export default function StudentCourseDetail() {
|
|||||||
<Button variant="ghost" size="icon" asChild><Link to="/student/courses"><ArrowLeft className="h-4 w-4" /></Link></Button>
|
<Button variant="ghost" size="icon" asChild><Link to="/student/courses"><ArrowLeft className="h-4 w-4" /></Link></Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">{course.title}</h1>
|
<h1 className="text-2xl font-bold">{course.title}</h1>
|
||||||
<p className="text-muted-foreground">{course.instructor} · {course.code}</p>
|
<p className="text-muted-foreground">{course.code} · {chapters.length} chapters</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isCompleted && (
|
||||||
|
<Card className="border-green-200 bg-green-50 dark:bg-green-950/20 dark:border-green-800">
|
||||||
|
<CardContent className="pt-6 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Trophy className="h-8 w-8 text-green-600" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-green-800 dark:text-green-200">Course Completed!</p>
|
||||||
|
<p className="text-sm text-green-600 dark:text-green-400">You've finished all {chapters.length} chapters.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{postTestAvailable && (
|
||||||
|
<Button asChild>
|
||||||
|
<Link to={`/student/my-exams?course_id=${courseId}`}>Take Post-Test</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-sm text-muted-foreground">Overall Progress</span>
|
<span className="text-sm text-muted-foreground">Overall Progress</span>
|
||||||
<span className="text-sm font-bold">{course.progress}%</span>
|
<span className="text-sm font-bold">{progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={course.progress} className="h-3" />
|
<Progress value={progress} className="h-3" />
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">{completedChapters} of {chapters.length} chapters completed</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Tabs defaultValue="materials">
|
<Tabs defaultValue="chapters">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="materials">Materials</TabsTrigger>
|
<TabsTrigger value="chapters">Chapters ({chapters.length})</TabsTrigger>
|
||||||
<TabsTrigger value="assignments">Assignments ({courseAssignments.length})</TabsTrigger>
|
<TabsTrigger value="grades">Grades ({gradeRecords.length})</TabsTrigger>
|
||||||
<TabsTrigger value="attendance">Attendance</TabsTrigger>
|
<TabsTrigger value="about">About</TabsTrigger>
|
||||||
<TabsTrigger value="grades">Grades</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="materials" className="space-y-4 mt-4">
|
<TabsContent value="chapters" className="space-y-3 mt-4">
|
||||||
{course.modules.length === 0 ? (
|
{sortedChapters.length === 0 ? (
|
||||||
<p className="text-muted-foreground text-sm py-8 text-center">No modules available yet.</p>
|
<div className="text-center py-12">
|
||||||
) : course.modules.map(mod => (
|
<BookOpen className="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
||||||
<Card key={mod.id}>
|
<p className="text-muted-foreground text-sm">No chapters available yet.</p>
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-base">{mod.title}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
{mod.lessons.map(lesson => {
|
|
||||||
const Icon = iconMap[lesson.type];
|
|
||||||
return (
|
|
||||||
<div key={lesson.id} className="flex items-center gap-3 p-2 rounded hover:bg-muted/50">
|
|
||||||
{lesson.completed ? <CheckCircle className="h-4 w-4 text-success shrink-0" /> : <Circle className="h-4 w-4 text-muted-foreground shrink-0" />}
|
|
||||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
||||||
<span className="text-sm flex-1">{lesson.title}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">{lesson.duration}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="assignments" className="mt-4 space-y-3">
|
|
||||||
{courseAssignments.map(a => (
|
|
||||||
<Card key={a.id}>
|
|
||||||
<CardContent className="pt-4 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-sm">{a.title}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Due: {a.dueDate}</p>
|
|
||||||
</div>
|
|
||||||
<Badge variant={a.status === "graded" ? "default" : a.status === "overdue" ? "destructive" : "secondary"}>
|
|
||||||
{a.status}
|
|
||||||
</Badge>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="attendance" className="mt-4 space-y-3">
|
|
||||||
{courseAttendance.map(a => (
|
|
||||||
<div key={a.id} className="flex items-center justify-between p-3 rounded border">
|
|
||||||
<span className="text-sm">{a.date}</span>
|
|
||||||
<Badge variant={a.status === "present" ? "default" : a.status === "absent" ? "destructive" : "secondary"}>
|
|
||||||
{a.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : sortedChapters.map((ch, idx) => (
|
||||||
|
<Link
|
||||||
|
key={ch.id}
|
||||||
|
to={ch.is_unlocked ? `/student/courses/${courseId}/chapters/${ch.id}` : "#"}
|
||||||
|
className={ch.is_unlocked ? "" : "pointer-events-none"}
|
||||||
|
>
|
||||||
|
<Card className={`transition-shadow ${ch.is_unlocked ? "hover:shadow-md cursor-pointer" : "opacity-60"}`}>
|
||||||
|
<CardContent className="flex items-center gap-4 py-4">
|
||||||
|
<div className="flex items-center justify-center h-10 w-10 rounded-full bg-primary/10 text-primary font-bold text-sm shrink-0">
|
||||||
|
{idx + 1}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium text-sm truncate">{ch.name}</p>
|
||||||
|
{!ch.is_unlocked && <Lock className="h-3 w-3 text-muted-foreground shrink-0" />}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{ch.material_count} materials
|
||||||
|
{ch.description ? ` · ${ch.description.slice(0, 60)}${ch.description.length > 60 ? "..." : ""}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{ch.is_unlocked ? (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
<Play className="h-3 w-3 mr-1" /> Open
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-xs">Locked</Badge>
|
||||||
|
)}
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="grades" className="mt-4 space-y-3">
|
<TabsContent value="grades" className="mt-4 space-y-3">
|
||||||
{courseGrades.map(g => (
|
{gradeRecords.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm text-center py-8">No grades yet for this course.</p>
|
||||||
|
) : gradeRecords.map(g => (
|
||||||
<div key={g.id} className="flex items-center justify-between p-3 rounded border">
|
<div key={g.id} className="flex items-center justify-between p-3 rounded border">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">{g.assignmentTitle}</p>
|
<p className="text-sm font-medium">{g.assignment_title}</p>
|
||||||
<p className="text-xs text-muted-foreground">{g.date}</p>
|
<p className="text-xs text-muted-foreground">{g.date}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-primary">{g.grade}/{g.maxGrade}</span>
|
<span className="font-bold text-primary">{g.grade}/{g.max_grade}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="about" className="mt-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle className="text-base">About this Course</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">{course.description || "No description available."}</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div><span className="text-muted-foreground">Enrolled Students:</span> <span className="font-medium">{course.enrolled}</span></div>
|
||||||
|
<div><span className="text-muted-foreground">Max Capacity:</span> <span className="font-medium">{course.max_capacity}</span></div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useCourses } from "@/hooks/queries";
|
import { useMyEnrolledCourses } from "@/hooks/queries";
|
||||||
import { BookOpen, Users, Clock } from "lucide-react";
|
import { BookOpen, Users, Play } from "lucide-react";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
|
|
||||||
export default function StudentCourses() {
|
export default function StudentCourses() {
|
||||||
const { data: coursesData, isLoading } = useCourses();
|
const { data: enrolledData, isLoading } = useMyEnrolledCourses();
|
||||||
const courses = coursesData?.items ?? [];
|
const myCourses = enrolledData?.items ?? [];
|
||||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||||
|
|
||||||
const myCourses = courses.filter(c => ["c1", "c2", "c5", "c8"].includes(c.id));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -22,37 +21,50 @@ export default function StudentCourses() {
|
|||||||
|
|
||||||
<AiTipBanner context="student-courses" variant="recommendation" />
|
<AiTipBanner context="student-courses" variant="recommendation" />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
{myCourses.length === 0 ? (
|
||||||
{myCourses.map((c) => (
|
<div className="text-center py-12">
|
||||||
<Link to={`/student/courses/${c.id}`} key={c.id}>
|
<BookOpen className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
<Card className="hover:shadow-md transition-shadow h-full">
|
<h3 className="text-lg font-semibold">No Enrolled Courses</h3>
|
||||||
<div className="h-2 rounded-t-lg bg-primary" />
|
<p className="text-muted-foreground mt-1">You haven't been enrolled in any courses yet. Contact your administrator.</p>
|
||||||
<CardContent className="pt-5 space-y-4">
|
</div>
|
||||||
<div>
|
) : (
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<Badge variant="secondary" className="text-xs">{c.level}</Badge>
|
{myCourses.map((c) => (
|
||||||
<Badge variant="outline" className="text-xs">{c.code}</Badge>
|
<Link to={`/student/courses/${c.id}`} key={c.id}>
|
||||||
|
<Card className="hover:shadow-md transition-shadow h-full">
|
||||||
|
<div className="h-2 rounded-t-lg bg-primary" />
|
||||||
|
<CardContent className="pt-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{c.progress === 100 ? "Completed" : c.progress > 0 ? "In Progress" : "Not Started"}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs">{c.code}</Badge>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold mt-2">{c.title || c.name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{c.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-semibold mt-2">{c.title}</h3>
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{c.description}</p>
|
<span className="flex items-center gap-1"><BookOpen className="h-3 w-3" />{c.chapter_count} chapters</span>
|
||||||
</div>
|
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{c.student_count} students</span>
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1"><BookOpen className="h-3 w-3" />{c.modules.length} modules</span>
|
|
||||||
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{c.enrolled} students</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-xs mb-1">
|
|
||||||
<span className="text-muted-foreground">Progress</span>
|
|
||||||
<span className="font-medium">{c.progress}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Progress value={c.progress} className="h-2" />
|
<div>
|
||||||
</div>
|
<div className="flex justify-between text-xs mb-1">
|
||||||
<p className="text-xs text-muted-foreground flex items-center gap-1"><Clock className="h-3 w-3" /> {c.instructor}</p>
|
<span className="text-muted-foreground">Progress</span>
|
||||||
</CardContent>
|
<span className="font-medium">{c.progress}%</span>
|
||||||
</Card>
|
</div>
|
||||||
</Link>
|
<Progress value={c.progress} className="h-2" />
|
||||||
))}
|
</div>
|
||||||
</div>
|
<Button variant="outline" className="w-full" size="sm">
|
||||||
|
<Play className="mr-2 h-3 w-3" />
|
||||||
|
{c.progress > 0 ? "Continue Learning" : "Start Learning"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,35 +2,39 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { BookOpen, ClipboardList, BarChart3, Calendar, ArrowRight } from "lucide-react";
|
import { BookOpen, ClipboardList, BarChart3, Calendar, ArrowRight, Play } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useCourses, useAssignments, useGrades } from "@/hooks/queries";
|
import { useMyEnrolledCourses, useGrades } from "@/hooks/queries";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import AiStudyCoach from "@/components/ai/AiStudyCoach";
|
import AiStudyCoach from "@/components/ai/AiStudyCoach";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
|
|
||||||
export default function StudentDashboard() {
|
export default function StudentDashboard() {
|
||||||
const { data: coursesData, isLoading: lc } = useCourses();
|
const { user } = useAuth();
|
||||||
const { data: assignmentsData, isLoading: la } = useAssignments();
|
const { data: enrolledData, isLoading: lc } = useMyEnrolledCourses();
|
||||||
const { data: gradesData, isLoading: lg } = useGrades();
|
const { data: gradesData, isLoading: lg } = useGrades();
|
||||||
const courses = coursesData?.items ?? [];
|
const myCourses = enrolledData?.items ?? [];
|
||||||
const assignments = assignmentsData?.items ?? [];
|
|
||||||
const gradeRecords = gradesData ?? [];
|
const gradeRecords = gradesData ?? [];
|
||||||
if (lc || la || lg) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
if (lc || lg) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||||
|
|
||||||
const myCourses = courses.filter(c => ["c1", "c2", "c5", "c8"].includes(c.id));
|
|
||||||
const upcomingAssignments = assignments.filter(a => a.status === "pending").slice(0, 3);
|
|
||||||
const recentGrades = gradeRecords.slice(0, 3);
|
const recentGrades = gradeRecords.slice(0, 3);
|
||||||
|
const avgProgress = myCourses.length > 0 ? Math.round(myCourses.reduce((s, c) => s + c.progress, 0) / myCourses.length) : 0;
|
||||||
|
const avgGrade = gradeRecords.length > 0
|
||||||
|
? Math.round(gradeRecords.reduce((s, g) => s + (g.grade / g.max_grade) * 100, 0) / gradeRecords.length)
|
||||||
|
: 0;
|
||||||
|
const firstName = user?.name?.split(" ")[0] || "Student";
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: "Enrolled Courses", value: String(myCourses.length), icon: BookOpen, color: "text-primary" },
|
{ label: "Enrolled Courses", value: String(myCourses.length), icon: BookOpen, color: "text-primary" },
|
||||||
{ label: "Pending Assignments", value: String(assignments.filter(a => a.status === "pending").length), icon: ClipboardList, color: "text-warning" },
|
{ label: "Overall Progress", value: `${avgProgress}%`, icon: ClipboardList, color: "text-warning" },
|
||||||
{ label: "Average Grade", value: "78%", icon: BarChart3, color: "text-success" },
|
{ label: "Average Grade", value: gradeRecords.length > 0 ? `${avgGrade}%` : "N/A", icon: BarChart3, color: "text-success" },
|
||||||
{ label: "Attendance Rate", value: "94%", icon: Calendar, color: "text-info" },
|
{ label: "Total Chapters", value: String(myCourses.reduce((s, c) => s + c.chapter_count, 0)), icon: Calendar, color: "text-info" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Welcome back, Sarah!</h1>
|
<h1 className="text-2xl font-bold">Welcome back, {firstName}!</h1>
|
||||||
<p className="text-muted-foreground">Here's an overview of your learning progress.</p>
|
<p className="text-muted-foreground">Here's an overview of your learning progress.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,12 +65,14 @@ export default function StudentDashboard() {
|
|||||||
<Button variant="ghost" size="sm" asChild><Link to="/student/courses">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
|
<Button variant="ghost" size="sm" asChild><Link to="/student/courses">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{myCourses.map((c) => (
|
{myCourses.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">No enrolled courses yet.</p>
|
||||||
|
) : myCourses.map((c) => (
|
||||||
<Link to={`/student/courses/${c.id}`} key={c.id} className="block">
|
<Link to={`/student/courses/${c.id}`} key={c.id} className="block">
|
||||||
<div className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors">
|
<div className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium text-sm truncate">{c.title}</p>
|
<p className="font-medium text-sm truncate">{c.title || c.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{c.instructor}</p>
|
<p className="text-xs text-muted-foreground">{c.chapter_count} chapters · {c.total_materials} materials</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
<div className="w-24"><Progress value={c.progress} className="h-2" /></div>
|
<div className="w-24"><Progress value={c.progress} className="h-2" /></div>
|
||||||
@@ -81,16 +87,20 @@ export default function StudentDashboard() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="text-lg">Upcoming Assignments</CardTitle>
|
<CardTitle className="text-lg">Quick Actions</CardTitle>
|
||||||
<Button variant="ghost" size="sm" asChild><Link to="/student/assignments">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{upcomingAssignments.map((a) => (
|
{myCourses.slice(0, 3).map(c => (
|
||||||
<div key={a.id} className="flex items-center justify-between p-2 rounded border">
|
<Link key={c.id} to={`/student/courses/${c.id}`} className="flex items-center gap-3 p-3 rounded-lg border hover:bg-muted/50 transition-colors">
|
||||||
<div><p className="text-sm font-medium">{a.title}</p><p className="text-xs text-muted-foreground">{a.courseName}</p></div>
|
<Play className="h-4 w-4 text-primary shrink-0" />
|
||||||
<Badge variant="outline" className="text-xs">{a.dueDate}</Badge>
|
<div className="flex-1 min-w-0">
|
||||||
</div>
|
<p className="text-sm font-medium truncate">{c.progress > 0 ? "Continue" : "Start"} {c.title || c.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{c.completed_chapters}/{c.chapter_count} chapters done</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">{c.progress}%</Badge>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
{myCourses.length === 0 && <p className="text-sm text-muted-foreground text-center py-4">Enroll in a course to get started.</p>}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -100,10 +110,12 @@ export default function StudentDashboard() {
|
|||||||
<Button variant="ghost" size="sm" asChild><Link to="/student/grades">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
|
<Button variant="ghost" size="sm" asChild><Link to="/student/grades">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{recentGrades.map((g) => (
|
{recentGrades.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">No grades yet.</p>
|
||||||
|
) : recentGrades.map((g) => (
|
||||||
<div key={g.id} className="flex items-center justify-between p-2 rounded border">
|
<div key={g.id} className="flex items-center justify-between p-2 rounded border">
|
||||||
<div><p className="text-sm font-medium">{g.assignmentTitle}</p><p className="text-xs text-muted-foreground">{g.courseName}</p></div>
|
<div><p className="text-sm font-medium">{g.assignment_title}</p><p className="text-xs text-muted-foreground">{g.course_name}</p></div>
|
||||||
<span className="text-sm font-bold text-primary">{g.grade}/{g.maxGrade}</span>
|
<span className="text-sm font-bold text-primary">{g.grade}/{g.max_grade}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default function StudentGrades() {
|
|||||||
<p className="text-muted-foreground">Track your academic performance.</p>
|
<p className="text-muted-foreground">Track your academic performance.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AiReportNarrative narrative={`Your average grade is ${avgGrade}%. Your strongest area is essay writing with consistent scores above 80%. Focus on improving speaking scores — your last mock test scored 72%, which is below your average. AI recommends practicing with the IELTS Speaking Masterclass materials.`} />
|
<AiReportNarrative report_type="grades" data={{ avgGrade, highest, count: gradeRecords.length }} />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
<Card><CardContent className="pt-6 text-center"><p className="text-sm text-muted-foreground">Average</p><p className="text-3xl font-bold text-primary">{avgGrade}%</p></CardContent></Card>
|
<Card><CardContent className="pt-6 text-center"><p className="text-sm text-muted-foreground">Average</p><p className="text-3xl font-bold text-primary">{avgGrade}%</p></CardContent></Card>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -12,6 +12,23 @@ import { useGenerateOutline, useGenerateChapterContent, usePublishWorkbench } fr
|
|||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type { WorkbenchGeneratedOutline, WorkbenchGeneratedChapter } from "@/types/courseware";
|
import type { WorkbenchGeneratedOutline, WorkbenchGeneratedChapter } from "@/types/courseware";
|
||||||
|
|
||||||
|
function sanitizeHtml(dirty: string): string {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = "";
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(dirty, "text/html");
|
||||||
|
const scripts = doc.querySelectorAll("script, iframe, object, embed, link[rel=import]");
|
||||||
|
scripts.forEach((el) => el.remove());
|
||||||
|
doc.querySelectorAll("*").forEach((el) => {
|
||||||
|
for (const attr of Array.from(el.attributes)) {
|
||||||
|
if (attr.name.startsWith("on") || attr.value.trim().toLowerCase().startsWith("javascript:")) {
|
||||||
|
el.removeAttribute(attr.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return doc.body.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
type Complexity = "beginner" | "intermediate" | "advanced";
|
type Complexity = "beginner" | "intermediate" | "advanced";
|
||||||
|
|
||||||
export default function AiWorkbench() {
|
export default function AiWorkbench() {
|
||||||
@@ -160,7 +177,7 @@ export default function AiWorkbench() {
|
|||||||
<CardDescription>Review the detailed content before publishing.</CardDescription>
|
<CardDescription>Review the detailed content before publishing.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="prose prose-sm max-w-none p-4 rounded-lg border bg-muted/30" dangerouslySetInnerHTML={{ __html: generatedContent.content }} />
|
<div className="prose prose-sm max-w-none p-4 rounded-lg border bg-muted/30" dangerouslySetInnerHTML={{ __html: sanitizeHtml(generatedContent.content) }} />
|
||||||
{generatedContent.exercises.length > 0 && (
|
{generatedContent.exercises.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="font-medium">Exercises ({generatedContent.exercises.length})</h3>
|
<h3 className="font-medium">Exercises ({generatedContent.exercises.length})</h3>
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Plus, Trash2, Loader2, FileText, Video, Image, Link2, Upload, Music } from "lucide-react";
|
import { Plus, Trash2, Loader2, FileText, Video, Image, Link2, Upload, Music, Library, Search, CheckCircle2 } from "lucide-react";
|
||||||
import { useChapter, useChapterMaterials, useUploadMaterial, useDeleteMaterial } from "@/hooks/queries";
|
import { useChapter, useChapterMaterials, useUploadMaterial, useDeleteMaterial, useCreateMaterialFromResource } from "@/hooks/queries";
|
||||||
import { coursewareService } from "@/services/courseware.service";
|
import { coursewareService } from "@/services/courseware.service";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { resourcesService } from "@/services/resources.service";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type { MaterialType } from "@/types/courseware";
|
import type { MaterialType } from "@/types/courseware";
|
||||||
|
|
||||||
@@ -35,15 +37,32 @@ export default function ChapterDetail() {
|
|||||||
const { data: materials = [], isLoading: loadingMaterials } = useChapterMaterials(chId);
|
const { data: materials = [], isLoading: loadingMaterials } = useChapterMaterials(chId);
|
||||||
const uploadMaterial = useUploadMaterial();
|
const uploadMaterial = useUploadMaterial();
|
||||||
const deleteMaterial = useDeleteMaterial();
|
const deleteMaterial = useDeleteMaterial();
|
||||||
|
const fromResource = useCreateMaterialFromResource();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
const [dialogTab, setDialogTab] = useState<string>("library");
|
||||||
const [matName, setMatName] = useState("");
|
const [matName, setMatName] = useState("");
|
||||||
const [matType, setMatType] = useState<MaterialType>("pdf");
|
const [matType, setMatType] = useState<MaterialType>("pdf");
|
||||||
const [matUrl, setMatUrl] = useState("");
|
const [matUrl, setMatUrl] = useState("");
|
||||||
const [matFile, setMatFile] = useState<File | null>(null);
|
const [matFile, setMatFile] = useState<File | null>(null);
|
||||||
const [allowDownload, setAllowDownload] = useState(true);
|
const [allowDownload, setAllowDownload] = useState(true);
|
||||||
|
|
||||||
|
const [libSearch, setLibSearch] = useState("");
|
||||||
|
const [libTypeFilter, setLibTypeFilter] = useState("all");
|
||||||
|
const [selectedResourceId, setSelectedResourceId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data: resourcesData, isLoading: loadingResources } = useQuery({
|
||||||
|
queryKey: ["resources", "list", { search: libSearch, resource_type: libTypeFilter === "all" ? undefined : libTypeFilter }],
|
||||||
|
queryFn: () => resourcesService.list({
|
||||||
|
search: libSearch || undefined,
|
||||||
|
resource_type: libTypeFilter === "all" ? undefined : libTypeFilter,
|
||||||
|
limit: 50,
|
||||||
|
}),
|
||||||
|
enabled: showUpload,
|
||||||
|
});
|
||||||
|
const libraryResources = resourcesData?.items ?? [];
|
||||||
|
|
||||||
const toggleDownload = useMutation({
|
const toggleDownload = useMutation({
|
||||||
mutationFn: ({ id, allow }: { id: number; allow: boolean }) =>
|
mutationFn: ({ id, allow }: { id: number; allow: boolean }) =>
|
||||||
coursewareService.updateMaterial(id, { allow_download: allow }),
|
coursewareService.updateMaterial(id, { allow_download: allow }),
|
||||||
@@ -51,7 +70,11 @@ export default function ChapterDetail() {
|
|||||||
onError: () => toast({ title: "Error", description: "Failed to update material", variant: "destructive" }),
|
onError: () => toast({ title: "Error", description: "Failed to update material", variant: "destructive" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetForm = () => { setMatName(""); setMatType("pdf"); setMatUrl(""); setMatFile(null); setAllowDownload(true); };
|
const resetForm = () => {
|
||||||
|
setMatName(""); setMatType("pdf"); setMatUrl(""); setMatFile(null);
|
||||||
|
setAllowDownload(true); setSelectedResourceId(null); setLibSearch("");
|
||||||
|
setLibTypeFilter("all"); setDialogTab("library");
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpload = () => {
|
const handleUpload = () => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -64,17 +87,28 @@ export default function ChapterDetail() {
|
|||||||
uploadMaterial.mutate(
|
uploadMaterial.mutate(
|
||||||
{ chapterId: chId, formData },
|
{ chapterId: chId, formData },
|
||||||
{
|
{
|
||||||
onSuccess: () => { toast({ title: "Material Uploaded" }); setShowUpload(false); resetForm(); },
|
onSuccess: () => { toast({ title: "Material uploaded" }); setShowUpload(false); resetForm(); },
|
||||||
onError: () => toast({ title: "Error", description: "Failed to upload material", variant: "destructive" }),
|
onError: () => toast({ title: "Error", description: "Failed to upload material", variant: "destructive" }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddFromLibrary = () => {
|
||||||
|
if (!selectedResourceId) return;
|
||||||
|
fromResource.mutate(
|
||||||
|
{ chapterId: chId, resourceId: selectedResourceId },
|
||||||
|
{
|
||||||
|
onSuccess: () => { toast({ title: "Material added from library" }); setShowUpload(false); resetForm(); },
|
||||||
|
onError: () => toast({ title: "Error", description: "Failed to add material from library", variant: "destructive" }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = (id: number) => {
|
const handleDelete = (id: number) => {
|
||||||
deleteMaterial.mutate(
|
deleteMaterial.mutate(
|
||||||
{ id, chapterId: chId },
|
{ id, chapterId: chId },
|
||||||
{
|
{
|
||||||
onSuccess: () => toast({ title: "Material Deleted" }),
|
onSuccess: () => toast({ title: "Material deleted" }),
|
||||||
onError: () => toast({ title: "Error", description: "Failed to delete material", variant: "destructive" }),
|
onError: () => toast({ title: "Error", description: "Failed to delete material", variant: "destructive" }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -105,49 +139,139 @@ export default function ChapterDetail() {
|
|||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold">Materials</h2>
|
<h2 className="text-lg font-semibold">Materials</h2>
|
||||||
<Dialog open={showUpload} onOpenChange={setShowUpload}>
|
<Dialog open={showUpload} onOpenChange={(open) => { setShowUpload(open); if (!open) resetForm(); }}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button size="sm"><Plus className="mr-2 h-4 w-4" /> Upload Material</Button>
|
<Button size="sm"><Plus className="mr-2 h-4 w-4" /> Add Material</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
<DialogHeader><DialogTitle>Upload Material</DialogTitle></DialogHeader>
|
<DialogHeader><DialogTitle>Add Material</DialogTitle></DialogHeader>
|
||||||
<div className="space-y-4 pt-4">
|
<Tabs value={dialogTab} onValueChange={setDialogTab} className="pt-2">
|
||||||
<div className="space-y-2">
|
<TabsList className="w-full">
|
||||||
<Label>Name</Label>
|
<TabsTrigger value="library" className="flex-1">
|
||||||
<Input placeholder="Material name" value={matName} onChange={e => setMatName(e.target.value)} />
|
<Library className="mr-2 h-4 w-4" /> From Library
|
||||||
</div>
|
</TabsTrigger>
|
||||||
<div className="space-y-2">
|
<TabsTrigger value="upload" className="flex-1">
|
||||||
<Label>Type</Label>
|
<Upload className="mr-2 h-4 w-4" /> Upload Manual
|
||||||
<Select value={matType} onValueChange={v => setMatType(v as MaterialType)}>
|
</TabsTrigger>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
</TabsList>
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="pdf">PDF</SelectItem>
|
{/* Tab: From Library */}
|
||||||
<SelectItem value="document">Document</SelectItem>
|
<TabsContent value="library" className="space-y-4 pt-2">
|
||||||
<SelectItem value="video">Video</SelectItem>
|
<div className="flex gap-2">
|
||||||
<SelectItem value="audio">Audio</SelectItem>
|
<div className="relative flex-1">
|
||||||
<SelectItem value="image">Image</SelectItem>
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<SelectItem value="link">Link</SelectItem>
|
<Input
|
||||||
<SelectItem value="article">Article</SelectItem>
|
placeholder="Search resources…"
|
||||||
</SelectContent>
|
value={libSearch}
|
||||||
</Select>
|
onChange={e => setLibSearch(e.target.value)}
|
||||||
</div>
|
className="pl-9"
|
||||||
<div className="space-y-2">
|
/>
|
||||||
<Label>File</Label>
|
</div>
|
||||||
<Input ref={fileRef} type="file" onChange={e => setMatFile(e.target.files?.[0] ?? null)} />
|
<Select value={libTypeFilter} onValueChange={setLibTypeFilter}>
|
||||||
</div>
|
<SelectTrigger className="w-[120px]"><SelectValue /></SelectTrigger>
|
||||||
<div className="space-y-2">
|
<SelectContent>
|
||||||
<Label>Or URL</Label>
|
<SelectItem value="all">All Types</SelectItem>
|
||||||
<Input placeholder="https://..." value={matUrl} onChange={e => setMatUrl(e.target.value)} />
|
<SelectItem value="pdf">PDF</SelectItem>
|
||||||
</div>
|
<SelectItem value="video">Video</SelectItem>
|
||||||
<div className="flex items-center gap-2">
|
<SelectItem value="document">Document</SelectItem>
|
||||||
<Checkbox id="allow-dl" checked={allowDownload} onCheckedChange={v => setAllowDownload(!!v)} />
|
<SelectItem value="link">Link</SelectItem>
|
||||||
<Label htmlFor="allow-dl">Allow Download</Label>
|
</SelectContent>
|
||||||
</div>
|
</Select>
|
||||||
<Button className="w-full" onClick={handleUpload} disabled={uploadMaterial.isPending || !matName}>
|
</div>
|
||||||
{uploadMaterial.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
<Upload className="mr-2 h-4 w-4" /> Upload
|
<div className="max-h-[300px] overflow-y-auto border rounded-md">
|
||||||
</Button>
|
{loadingResources ? (
|
||||||
</div>
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : libraryResources.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||||
|
No resources found. Try a different search or upload manually.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{libraryResources.map(r => {
|
||||||
|
const selected = selectedResourceId === r.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={r.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedResourceId(selected ? null : r.id)}
|
||||||
|
className={`w-full text-left px-4 py-3 flex items-center gap-3 transition-colors hover:bg-muted/50 ${
|
||||||
|
selected ? "bg-primary/10 ring-1 ring-primary/30" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="shrink-0">
|
||||||
|
{selected ? (
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-primary" />
|
||||||
|
) : (
|
||||||
|
typeIcons[r.resource_type as MaterialType] ?? <FileText className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-sm truncate">{r.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{r.resource_type} {r.topic_names?.length ? `· ${r.topic_names.slice(0, 2).join(", ")}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="capitalize shrink-0">{r.resource_type}</Badge>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleAddFromLibrary}
|
||||||
|
disabled={!selectedResourceId || fromResource.isPending}
|
||||||
|
>
|
||||||
|
{fromResource.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
<Library className="mr-2 h-4 w-4" />
|
||||||
|
Add Selected Resource
|
||||||
|
</Button>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Tab: Manual Upload */}
|
||||||
|
<TabsContent value="upload" className="space-y-4 pt-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input placeholder="Material name" value={matName} onChange={e => setMatName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Select value={matType} onValueChange={v => setMatType(v as MaterialType)}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="pdf">PDF</SelectItem>
|
||||||
|
<SelectItem value="document">Document</SelectItem>
|
||||||
|
<SelectItem value="video">Video</SelectItem>
|
||||||
|
<SelectItem value="audio">Audio</SelectItem>
|
||||||
|
<SelectItem value="image">Image</SelectItem>
|
||||||
|
<SelectItem value="link">Link</SelectItem>
|
||||||
|
<SelectItem value="article">Article</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>File</Label>
|
||||||
|
<Input ref={fileRef} type="file" onChange={e => setMatFile(e.target.files?.[0] ?? null)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Or URL</Label>
|
||||||
|
<Input placeholder="https://..." value={matUrl} onChange={e => setMatUrl(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox id="allow-dl" checked={allowDownload} onCheckedChange={v => setAllowDownload(!!v)} />
|
||||||
|
<Label htmlFor="allow-dl">Allow Download</Label>
|
||||||
|
</div>
|
||||||
|
<Button className="w-full" onClick={handleUpload} disabled={uploadMaterial.isPending || !matName}>
|
||||||
|
{uploadMaterial.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
<Upload className="mr-2 h-4 w-4" /> Upload
|
||||||
|
</Button>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
@@ -189,7 +313,7 @@ export default function ChapterDetail() {
|
|||||||
))}
|
))}
|
||||||
{materials.length === 0 && (
|
{materials.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">No materials uploaded yet.</TableCell>
|
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">No materials added yet.</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { Plus, GripVertical, Lock, Unlock, Trash2, Loader2, BookOpen } from "lucide-react";
|
import { Plus, GripVertical, Lock, Unlock, Trash2, Loader2, BookOpen, Target } from "lucide-react";
|
||||||
import { useChapters, useCreateChapter, useDeleteChapter, useUnlockChapter, useLockChapter } from "@/hooks/queries";
|
import { useChapters, useCreateChapter, useDeleteChapter, useUnlockChapter, useLockChapter, useCourse } from "@/hooks/queries";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { taxonomyService, lmsService } from "@/services";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type { ChapterUnlockMode } from "@/types/courseware";
|
import type { ChapterUnlockMode } from "@/types/courseware";
|
||||||
|
|
||||||
export default function CourseChapters() {
|
export default function CourseChapters() {
|
||||||
const { courseId } = useParams<{ courseId: string }>();
|
const { courseId } = useParams<{ courseId: string }>();
|
||||||
const cid = Number(courseId);
|
const cid = Number(courseId);
|
||||||
|
const navigate = useNavigate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { data: chapters = [], isLoading } = useChapters(cid);
|
const { data: chapters = [], isLoading } = useChapters(cid);
|
||||||
|
const { data: course } = useCourse(cid);
|
||||||
const createChapter = useCreateChapter();
|
const createChapter = useCreateChapter();
|
||||||
const deleteChapter = useDeleteChapter();
|
const deleteChapter = useDeleteChapter();
|
||||||
const unlockChapter = useUnlockChapter();
|
const unlockChapter = useUnlockChapter();
|
||||||
@@ -28,12 +33,45 @@ export default function CourseChapters() {
|
|||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [startDate, setStartDate] = useState("");
|
const [startDate, setStartDate] = useState("");
|
||||||
const [unlockMode, setUnlockMode] = useState<ChapterUnlockMode>("manual");
|
const [unlockMode, setUnlockMode] = useState<ChapterUnlockMode>("manual");
|
||||||
|
const [topicId, setTopicId] = useState<number | null>(null);
|
||||||
|
const [selectedObjectiveIds, setSelectedObjectiveIds] = useState<number[]>([]);
|
||||||
|
|
||||||
const resetForm = () => { setName(""); setDescription(""); setStartDate(""); setUnlockMode("manual"); };
|
const subjectId = course?.encoach_subject_id;
|
||||||
|
|
||||||
|
const { data: topics = [] } = useQuery({
|
||||||
|
queryKey: ["taxonomy", "topics", subjectId],
|
||||||
|
queryFn: () => subjectId ? taxonomyService.listTopics({ subject_id: subjectId }) : Promise.resolve([]),
|
||||||
|
enabled: !!subjectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: objectivesData } = useQuery({
|
||||||
|
queryKey: ["lms", "objectives", topicId],
|
||||||
|
queryFn: () => topicId ? lmsService.listLearningObjectives({ topic_id: topicId }) : Promise.resolve({ items: [], total: 0 }),
|
||||||
|
enabled: !!topicId,
|
||||||
|
});
|
||||||
|
const objectives = objectivesData?.items ?? [];
|
||||||
|
|
||||||
|
const selectedTopic = topics.find((t) => t.id === topicId);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setName(""); setDescription(""); setStartDate(""); setUnlockMode("manual");
|
||||||
|
setTopicId(null); setSelectedObjectiveIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
createChapter.mutate(
|
createChapter.mutate(
|
||||||
{ courseId: cid, data: { name, course_id: cid, description, start_date: startDate || undefined, unlock_mode: unlockMode } },
|
{
|
||||||
|
courseId: cid,
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
course_id: cid,
|
||||||
|
description,
|
||||||
|
start_date: startDate || undefined,
|
||||||
|
unlock_mode: unlockMode,
|
||||||
|
topic_id: topicId ?? undefined,
|
||||||
|
learning_objective_ids: selectedObjectiveIds.length > 0 ? selectedObjectiveIds : undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => { toast({ title: "Chapter Created" }); setShowAdd(false); resetForm(); },
|
onSuccess: () => { toast({ title: "Chapter Created" }); setShowAdd(false); resetForm(); },
|
||||||
onError: () => toast({ title: "Error", description: "Failed to create chapter", variant: "destructive" }),
|
onError: () => toast({ title: "Error", description: "Failed to create chapter", variant: "destructive" }),
|
||||||
@@ -75,7 +113,7 @@ export default function CourseChapters() {
|
|||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button><Plus className="mr-2 h-4 w-4" /> Add Chapter</Button>
|
<Button><Plus className="mr-2 h-4 w-4" /> Add Chapter</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent className="sm:max-w-[520px] max-h-[85vh] overflow-y-auto">
|
||||||
<DialogHeader><DialogTitle>Add New Chapter</DialogTitle></DialogHeader>
|
<DialogHeader><DialogTitle>Add New Chapter</DialogTitle></DialogHeader>
|
||||||
<div className="space-y-4 pt-4">
|
<div className="space-y-4 pt-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -86,21 +124,81 @@ export default function CourseChapters() {
|
|||||||
<Label>Description</Label>
|
<Label>Description</Label>
|
||||||
<Textarea placeholder="Brief description" value={description} onChange={e => setDescription(e.target.value)} />
|
<Textarea placeholder="Brief description" value={description} onChange={e => setDescription(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Label>Start Date</Label>
|
<div className="space-y-2">
|
||||||
<Input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
|
<Label>Start Date</Label>
|
||||||
</div>
|
<Input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
<Label>Unlock Mode</Label>
|
<div className="space-y-2">
|
||||||
<Select value={unlockMode} onValueChange={v => setUnlockMode(v as ChapterUnlockMode)}>
|
<Label>Unlock Mode</Label>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<Select value={unlockMode} onValueChange={v => setUnlockMode(v as ChapterUnlockMode)}>
|
||||||
<SelectContent>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectItem value="manual">Manual</SelectItem>
|
<SelectContent>
|
||||||
<SelectItem value="auto_date">Auto (Date)</SelectItem>
|
<SelectItem value="manual">Manual</SelectItem>
|
||||||
<SelectItem value="prerequisite">Prerequisite</SelectItem>
|
<SelectItem value="auto_date">Auto (Date)</SelectItem>
|
||||||
</SelectContent>
|
<SelectItem value="prerequisite">Prerequisite</SelectItem>
|
||||||
</Select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{topics.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Topic</Label>
|
||||||
|
<Select
|
||||||
|
value={topicId ? String(topicId) : "none"}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setTopicId(v === "none" ? null : Number(v));
|
||||||
|
setSelectedObjectiveIds([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select topic..." /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
{topics.map((t) => (
|
||||||
|
<SelectItem key={t.id} value={String(t.id)}>{t.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{selectedTopic && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Domain: <strong>{selectedTopic.domain_name}</strong>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{topicId && objectives.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
<Target className="inline h-3.5 w-3.5 mr-1" />
|
||||||
|
Learning Objectives ({selectedObjectiveIds.length} selected)
|
||||||
|
</Label>
|
||||||
|
<div className="border rounded-md p-3 max-h-[140px] overflow-y-auto space-y-1">
|
||||||
|
{objectives.map((o) => (
|
||||||
|
<label key={o.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 p-1 rounded">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedObjectiveIds.includes(o.id)}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setSelectedObjectiveIds((prev) =>
|
||||||
|
prev.includes(o.id)
|
||||||
|
? prev.filter((x) => x !== o.id)
|
||||||
|
: [...prev, o.id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">{o.name}</span>
|
||||||
|
{o.bloom_level && (
|
||||||
|
<Badge variant="outline" className="text-[10px] capitalize">
|
||||||
|
{o.bloom_level}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button className="w-full" onClick={handleCreate} disabled={createChapter.isPending || !name}>
|
<Button className="w-full" onClick={handleCreate} disabled={createChapter.isPending || !name}>
|
||||||
{createChapter.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{createChapter.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
Create Chapter
|
Create Chapter
|
||||||
@@ -114,17 +212,29 @@ export default function CourseChapters() {
|
|||||||
{chapters
|
{chapters
|
||||||
.sort((a, b) => a.sequence - b.sequence)
|
.sort((a, b) => a.sequence - b.sequence)
|
||||||
.map(ch => (
|
.map(ch => (
|
||||||
<Card key={ch.id} className="hover:shadow-sm transition-shadow">
|
<Card key={ch.id} className="hover:shadow-sm transition-shadow cursor-pointer" onClick={() => navigate(`/teacher/courses/${cid}/chapters/${ch.id}`)}>
|
||||||
<CardContent className="flex items-center gap-4 py-4">
|
<CardContent className="flex items-center gap-4 py-4">
|
||||||
<GripVertical className="h-5 w-5 text-muted-foreground shrink-0 cursor-grab" />
|
<GripVertical className="h-5 w-5 text-muted-foreground shrink-0 cursor-grab" onClick={e => e.stopPropagation()} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BookOpen className="h-4 w-4 text-primary shrink-0" />
|
<BookOpen className="h-4 w-4 text-primary shrink-0" />
|
||||||
<span className="font-medium truncate">{ch.name}</span>
|
<span className="font-medium truncate">{ch.name}</span>
|
||||||
|
{ch.topic_name && (
|
||||||
|
<Badge variant="outline" className="text-[10px] shrink-0">{ch.topic_name}</Badge>
|
||||||
|
)}
|
||||||
|
{ch.domain_name && (
|
||||||
|
<span className="text-[10px] text-muted-foreground shrink-0">({ch.domain_name})</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
||||||
{ch.start_date && <span>{new Date(ch.start_date).toLocaleDateString()}</span>}
|
{ch.start_date && <span>{new Date(ch.start_date).toLocaleDateString()}</span>}
|
||||||
<span>{ch.material_count} material{ch.material_count !== 1 ? "s" : ""}</span>
|
<span>{ch.material_count} material{ch.material_count !== 1 ? "s" : ""}</span>
|
||||||
|
{(ch.learning_objective_ids?.length ?? 0) > 0 && (
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<Target className="h-3 w-3" />
|
||||||
|
{ch.learning_objective_ids?.length} objective{(ch.learning_objective_ids?.length ?? 0) !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={ch.is_unlocked ? "default" : "secondary"}>
|
<Badge variant={ch.is_unlocked ? "default" : "secondary"}>
|
||||||
@@ -133,7 +243,7 @@ export default function CourseChapters() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleToggleLock(ch.id, ch.is_unlocked)}
|
onClick={(e) => { e.stopPropagation(); handleToggleLock(ch.id, ch.is_unlocked); }}
|
||||||
disabled={unlockChapter.isPending || lockChapter.isPending}
|
disabled={unlockChapter.isPending || lockChapter.isPending}
|
||||||
>
|
>
|
||||||
{ch.is_unlocked ? <Lock className="h-4 w-4" /> : <Unlock className="h-4 w-4" />}
|
{ch.is_unlocked ? <Lock className="h-4 w-4" /> : <Unlock className="h-4 w-4" />}
|
||||||
@@ -141,7 +251,7 @@ export default function CourseChapters() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleDelete(ch.id)}
|
onClick={(e) => { e.stopPropagation(); handleDelete(ch.id); }}
|
||||||
disabled={deleteChapter.isPending}
|
disabled={deleteChapter.isPending}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { useCourseAssignments } from "@/hooks/queries";
|
||||||
import { useAssignments } from "@/hooks/queries";
|
import { ClipboardList } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
||||||
import AiGeneratorModal from "@/components/ai/AiGeneratorModal";
|
import AiGeneratorModal from "@/components/ai/AiGeneratorModal";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
|
import type { CourseAssignment } from "@/types";
|
||||||
|
|
||||||
export default function TeacherAssignments() {
|
export default function TeacherAssignments() {
|
||||||
const { data: assignmentsData, isLoading } = useAssignments();
|
const { data: assignmentsData, isLoading } = useCourseAssignments();
|
||||||
const assignments = assignmentsData?.items ?? [];
|
const assignments: CourseAssignment[] = assignmentsData?.items ?? [];
|
||||||
const submissions: unknown[] = [];
|
|
||||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||||
|
|
||||||
const teacherAssignments = assignments;
|
const stateVariant = (s: string) => s === "finish" ? "default" : s === "cancel" ? "destructive" : "secondary";
|
||||||
const pendingSubs = (submissions as { id: string; studentName: string; submittedAt: string; status: string; feedback?: string; grade?: number }[]).filter(s => s.status === "pending");
|
const stateLabel = (s: string) => s === "publish" ? "Active" : s === "finish" ? "Completed" : s === "cancel" ? "Cancelled" : "Draft";
|
||||||
const gradedSubs = (submissions as { id: string; studentName: string; submittedAt: string; status: string; feedback?: string; grade?: number }[]).filter(s => s.status === "graded");
|
|
||||||
|
const stateFilter = (state: string) => assignments.filter(a => state === "all" ? true : a.state === state);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -35,48 +35,40 @@ export default function TeacherAssignments() {
|
|||||||
|
|
||||||
<Tabs defaultValue="all">
|
<Tabs defaultValue="all">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="all">All ({teacherAssignments.length})</TabsTrigger>
|
<TabsTrigger value="all">All ({assignments.length})</TabsTrigger>
|
||||||
<TabsTrigger value="pending">Pending Review ({pendingSubs.length})</TabsTrigger>
|
<TabsTrigger value="publish">Active ({stateFilter("publish").length})</TabsTrigger>
|
||||||
<TabsTrigger value="graded">Graded ({gradedSubs.length})</TabsTrigger>
|
<TabsTrigger value="finish">Completed ({stateFilter("finish").length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="draft">Drafts ({stateFilter("draft").length})</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="all" className="mt-4 space-y-3">
|
{["all", "publish", "finish", "draft"].map(tab => (
|
||||||
{teacherAssignments.map(a => (
|
<TabsContent key={tab} value={tab} className="mt-4 space-y-3">
|
||||||
<Link to={`/teacher/assignments/${a.id}`} key={a.id}>
|
{stateFilter(tab).length === 0 ? (
|
||||||
<Card className="hover:bg-muted/30 transition-colors">
|
<div className="text-center py-8">
|
||||||
|
<ClipboardList className="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
||||||
|
<p className="text-muted-foreground text-sm">No assignments found.</p>
|
||||||
|
</div>
|
||||||
|
) : stateFilter(tab).map(a => (
|
||||||
|
<Card key={a.id} className="hover:bg-muted/30 transition-colors">
|
||||||
<CardContent className="pt-4 flex items-center justify-between">
|
<CardContent className="pt-4 flex items-center justify-between">
|
||||||
<div><p className="font-medium text-sm">{a.title}</p><p className="text-xs text-muted-foreground">{a.entity_name} · Due: {a.end_date}</p></div>
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<p className="font-medium text-sm">{a.name}</p>
|
||||||
<Badge variant="outline" className="capitalize text-xs">{a.state}</Badge>
|
<p className="text-xs text-muted-foreground">
|
||||||
<Badge variant={a.completed_count >= a.assignee_count && a.assignee_count > 0 ? "default" : "secondary"} className="capitalize">{a.state}</Badge>
|
{a.course_name} · {a.batch_name} · Due: {a.submission_date}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{a.submission_count}/{a.allocation_count} submissions · Marks: {a.marks}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Badge variant="outline" className="text-xs">{a.assignment_type_name}</Badge>
|
||||||
|
<Badge variant={stateVariant(a.state)}>{stateLabel(a.state)}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
))}
|
||||||
))}
|
</TabsContent>
|
||||||
</TabsContent>
|
))}
|
||||||
|
|
||||||
<TabsContent value="pending" className="mt-4 space-y-3">
|
|
||||||
{pendingSubs.map(s => (
|
|
||||||
<Card key={s.id}>
|
|
||||||
<CardContent className="pt-4 flex items-center justify-between">
|
|
||||||
<div><p className="font-medium text-sm">{s.studentName}</p><p className="text-xs text-muted-foreground">Submitted: {s.submittedAt}</p></div>
|
|
||||||
<Badge variant="secondary">Pending</Badge>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="graded" className="mt-4 space-y-3">
|
|
||||||
{gradedSubs.map(s => (
|
|
||||||
<Card key={s.id}>
|
|
||||||
<CardContent className="pt-4 flex items-center justify-between">
|
|
||||||
<div><p className="font-medium text-sm">{s.studentName}</p><p className="text-xs text-muted-foreground">{s.feedback}</p></div>
|
|
||||||
<span className="font-bold text-primary">{s.grade}</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,95 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useCourses } from "@/hooks/queries";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Link } from "react-router-dom";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Plus, Users, BookOpen, Archive } from "lucide-react";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { useCourses, useChapters } from "@/hooks/queries";
|
||||||
|
import { lmsService } from "@/services";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { Plus, Users, BookOpen, Pencil, FolderOpen, Wand2 } from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import type { Course } from "@/types";
|
||||||
|
|
||||||
|
function CourseCard({ course }: { course: Course }) {
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState({ title: course.title, code: course.code, description: course.description, max_capacity: course.max_capacity });
|
||||||
|
const { data: chapters = [] } = useChapters(course.id);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await lmsService.updateCourse(course.id, form);
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
|
||||||
|
toast({ title: "Course updated" });
|
||||||
|
setEditOpen(false);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast({ title: "Error", description: e instanceof Error ? e.message : String(e), variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMaterials = chapters.reduce((s, ch) => s + (ch.material_count || 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="hover:shadow-md transition-shadow">
|
||||||
|
<div className="h-2 rounded-t-lg bg-primary" />
|
||||||
|
<CardContent className="pt-5 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge variant={course.status === "active" ? "default" : "secondary"} className="capitalize">{course.status}</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">{course.code}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold">{course.title}</h3>
|
||||||
|
{course.description && <p className="text-sm text-muted-foreground line-clamp-2">{course.description}</p>}
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{course.enrolled} students</span>
|
||||||
|
<span className="flex items-center gap-1"><BookOpen className="h-3 w-3" />{chapters.length} chapters · {totalMaterials} materials</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => { setForm({ title: course.title, code: course.code, description: course.description, max_capacity: course.max_capacity }); setEditOpen(true); }}>
|
||||||
|
<Pencil className="mr-1.5 h-3 w-3" /> Edit
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="default" onClick={() => navigate(`/teacher/courses/${course.id}/chapters`)}>
|
||||||
|
<FolderOpen className="mr-1.5 h-3 w-3" /> Chapters & Materials
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => navigate(`/teacher/courses/${course.id}/workbench`)}>
|
||||||
|
<Wand2 className="mr-1.5 h-3 w-3" /> AI Workbench
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Edit Course</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div><Label>Title *</Label><Input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} /></div>
|
||||||
|
<div><Label>Code</Label><Input value={form.code} onChange={e => setForm(f => ({ ...f, code: e.target.value }))} /></div>
|
||||||
|
<div><Label>Description</Label><Textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} rows={3} /></div>
|
||||||
|
<div><Label>Max Capacity</Label><Input type="number" value={form.max_capacity} onChange={e => setForm(f => ({ ...f, max_capacity: Number(e.target.value) || 30 }))} /></div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>{saving ? "Saving..." : "Save Changes"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function TeacherCourses() {
|
export default function TeacherCourses() {
|
||||||
const { data: coursesData, isLoading } = useCourses();
|
const { data: coursesData, isLoading } = useCourses();
|
||||||
const courses = coursesData?.items ?? [];
|
const courses = coursesData?.items ?? [];
|
||||||
const teacherCourses = courses;
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||||
|
|
||||||
@@ -24,29 +103,18 @@ export default function TeacherCourses() {
|
|||||||
<Button asChild><Link to="/teacher/courses/new"><Plus className="mr-2 h-4 w-4" />New Course</Link></Button>
|
<Button asChild><Link to="/teacher/courses/new"><Plus className="mr-2 h-4 w-4" />New Course</Link></Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
{courses.length === 0 ? (
|
||||||
{teacherCourses.map(c => (
|
<div className="text-center py-16">
|
||||||
<Card key={c.id} className="hover:shadow-md transition-shadow">
|
<BookOpen className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
<div className="h-2 rounded-t-lg bg-primary" />
|
<h3 className="text-lg font-semibold">No Courses Yet</h3>
|
||||||
<CardContent className="pt-5 space-y-3">
|
<p className="text-muted-foreground mt-1">Create your first course to get started.</p>
|
||||||
<div className="flex items-center justify-between">
|
<Button className="mt-4" asChild><Link to="/teacher/courses/new"><Plus className="mr-2 h-4 w-4" /> Create Course</Link></Button>
|
||||||
<Badge variant={c.status === "active" ? "default" : "secondary"} className="capitalize">{c.status}</Badge>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">{c.code}</span>
|
) : (
|
||||||
</div>
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<h3 className="font-semibold">{c.title}</h3>
|
{courses.map(c => <CourseCard key={c.id} course={c} />)}
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2">{c.description}</p>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
)}
|
||||||
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{c.enrolled} students</span>
|
|
||||||
<span className="flex items-center gap-1"><BookOpen className="h-3 w-3" />{c.modules.length} modules</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button size="sm" variant="outline" asChild><Link to={`/teacher/courses/${c.id}/edit`}>Edit</Link></Button>
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => toast({ title: "Course archived" })}><Archive className="h-3 w-3" /></Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
515
src/pages/teacher/TeacherLibrary.tsx
Normal file
515
src/pages/teacher/TeacherLibrary.tsx
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Search, Upload, FileText, Video, Link2, Download, Trash2,
|
||||||
|
CheckCircle2, Clock, XCircle, Loader2, BookOpen, Music, Image,
|
||||||
|
Library, Plus, CalendarDays, X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { resourcesService } from "@/services/resources.service";
|
||||||
|
import { coursewareService } from "@/services/courseware.service";
|
||||||
|
import { taxonomyService } from "@/services/taxonomy.service";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { TaxonomyCascade } from "@/components/TaxonomyCascade";
|
||||||
|
import type { Resource, ResourceTag } from "@/types";
|
||||||
|
|
||||||
|
const statusBadge: Record<string, { variant: "default" | "secondary" | "destructive"; icon: React.ReactNode }> = {
|
||||||
|
approved: { variant: "default", icon: <CheckCircle2 className="h-3 w-3 mr-1" /> },
|
||||||
|
pending: { variant: "secondary", icon: <Clock className="h-3 w-3 mr-1" /> },
|
||||||
|
rejected: { variant: "destructive", icon: <XCircle className="h-3 w-3 mr-1" /> },
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeIcons: Record<string, React.ReactNode> = {
|
||||||
|
pdf: <FileText className="h-4 w-4 text-red-500" />,
|
||||||
|
video: <Video className="h-4 w-4 text-blue-500" />,
|
||||||
|
link: <Link2 className="h-4 w-4 text-green-500" />,
|
||||||
|
document: <FileText className="h-4 w-4 text-orange-500" />,
|
||||||
|
interactive: <FileText className="h-4 w-4 text-purple-500" />,
|
||||||
|
article: <FileText className="h-4 w-4 text-indigo-500" />,
|
||||||
|
audio: <Music className="h-4 w-4 text-yellow-500" />,
|
||||||
|
image: <Image className="h-4 w-4 text-pink-500" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CourseMaterialItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
course_name: string;
|
||||||
|
chapter_name: string;
|
||||||
|
source: string;
|
||||||
|
file_url: string | null;
|
||||||
|
url: string | null;
|
||||||
|
description: string | null;
|
||||||
|
course_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TeacherLibrary() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
|
const [subjectFilter, setSubjectFilter] = useState<string>("all");
|
||||||
|
const [tagFilter, setTagFilter] = useState<string>("all");
|
||||||
|
const [dateFrom, setDateFrom] = useState("");
|
||||||
|
const [dateTo, setDateTo] = useState("");
|
||||||
|
const [activeTab, setActiveTab] = useState("all");
|
||||||
|
|
||||||
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
const [uploadType, setUploadType] = useState("pdf");
|
||||||
|
const [uploadSubjectId, setUploadSubjectId] = useState<string>("none");
|
||||||
|
const [uploadDomainId, setUploadDomainId] = useState<string>("none");
|
||||||
|
const [uploadTopicIds, setUploadTopicIds] = useState<number[]>([]);
|
||||||
|
const [uploadObjectiveIds, setUploadObjectiveIds] = useState<number[]>([]);
|
||||||
|
const [uploadTagIds, setUploadTagIds] = useState<number[]>([]);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { data: subjects } = useQuery({
|
||||||
|
queryKey: ["taxonomy", "subjects"],
|
||||||
|
queryFn: () => taxonomyService.listSubjects(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tags } = useQuery({
|
||||||
|
queryKey: ["resource-tags"],
|
||||||
|
queryFn: () => resourcesService.listTags(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: resourcesData, isLoading: loadingResources } = useQuery({
|
||||||
|
queryKey: ["resources", "list", { search, resource_type: typeFilter === "all" ? undefined : typeFilter, subject_id: subjectFilter === "all" ? undefined : subjectFilter, tag_id: tagFilter === "all" ? undefined : tagFilter, date_from: dateFrom || undefined, date_to: dateTo || undefined }],
|
||||||
|
queryFn: () => resourcesService.list({
|
||||||
|
search: search || undefined,
|
||||||
|
resource_type: typeFilter === "all" ? undefined : typeFilter,
|
||||||
|
subject_id: subjectFilter === "all" ? undefined : Number(subjectFilter),
|
||||||
|
tag_id: tagFilter === "all" ? undefined : Number(tagFilter),
|
||||||
|
date_from: dateFrom || undefined,
|
||||||
|
date_to: dateTo || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const resources = resourcesData?.items ?? [];
|
||||||
|
|
||||||
|
const { data: materialsData, isLoading: loadingMaterials } = useQuery({
|
||||||
|
queryKey: ["materials", "search", { search, type: typeFilter }],
|
||||||
|
queryFn: () => coursewareService.searchMaterials({
|
||||||
|
search: search || undefined,
|
||||||
|
type: typeFilter === "all" ? undefined : typeFilter,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const courseMaterials = materialsData?.items ?? [];
|
||||||
|
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
mutationFn: (formData: FormData) => resourcesService.upload(formData),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["resources"] });
|
||||||
|
toast({ title: "Resource uploaded successfully" });
|
||||||
|
setShowUpload(false);
|
||||||
|
setUploadTagIds([]);
|
||||||
|
setUploadSubjectId("none");
|
||||||
|
setUploadDomainId("none");
|
||||||
|
setUploadTopicIds([]);
|
||||||
|
setUploadObjectiveIds([]);
|
||||||
|
},
|
||||||
|
onError: () => toast({ title: "Error", description: "Upload failed", variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => resourcesService.delete(id),
|
||||||
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Resource deleted" }); },
|
||||||
|
onError: () => toast({ title: "Error", description: "Delete failed", variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUpload = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
if (uploadTagIds.length > 0) formData.set("tag_ids", uploadTagIds.join(","));
|
||||||
|
if (uploadDomainId !== "none") formData.set("domain_id", uploadDomainId);
|
||||||
|
if (uploadTopicIds.length > 0) formData.set("topic_ids", uploadTopicIds.join(","));
|
||||||
|
if (uploadObjectiveIds.length > 0) formData.set("learning_objective_ids", uploadObjectiveIds.join(","));
|
||||||
|
uploadMutation.mutate(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleUploadTag = (id: number) => {
|
||||||
|
setUploadTagIds((prev) => prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearch(""); setTypeFilter("all"); setSubjectFilter("all");
|
||||||
|
setTagFilter("all"); setDateFrom(""); setDateTo("");
|
||||||
|
};
|
||||||
|
const hasActiveFilters = search || typeFilter !== "all" || subjectFilter !== "all" || tagFilter !== "all" || dateFrom || dateTo;
|
||||||
|
|
||||||
|
const isLoading = loadingResources || loadingMaterials;
|
||||||
|
|
||||||
|
const formatDate = (iso?: string) => {
|
||||||
|
if (!iso) return "";
|
||||||
|
try { return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); } catch { return ""; }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<Library className="h-6 w-6" /> Resource Library
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">Browse shared resources and your course materials. Upload new content or pick existing resources when building courses.</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowUpload(true)}>
|
||||||
|
<Upload className="mr-2 h-4 w-4" /> Upload Resource
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setActiveTab("all")}>
|
||||||
|
<CardContent className="pt-6 flex items-center gap-4">
|
||||||
|
<div className="p-3 rounded-full bg-primary/10"><Library className="h-5 w-5 text-primary" /></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{resources.length + courseMaterials.length}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Total Resources</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setActiveTab("library")}>
|
||||||
|
<CardContent className="pt-6 flex items-center gap-4">
|
||||||
|
<div className="p-3 rounded-full bg-blue-500/10"><FileText className="h-5 w-5 text-blue-500" /></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{resources.length}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Shared Library</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setActiveTab("materials")}>
|
||||||
|
<CardContent className="pt-6 flex items-center gap-4">
|
||||||
|
<div className="p-3 rounded-full bg-green-500/10"><BookOpen className="h-5 w-5 text-green-500" /></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{courseMaterials.length}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Course Materials</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters + Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input placeholder="Search resources…" value={search} onChange={e => setSearch(e.target.value)} className="pl-9" />
|
||||||
|
</div>
|
||||||
|
<Select value={subjectFilter} onValueChange={setSubjectFilter}>
|
||||||
|
<SelectTrigger className="w-[150px]"><SelectValue placeholder="Subject" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Subjects</SelectItem>
|
||||||
|
{(subjects ?? []).map((s) => (
|
||||||
|
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={tagFilter} onValueChange={setTagFilter}>
|
||||||
|
<SelectTrigger className="w-[140px]"><SelectValue placeholder="Tag" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Tags</SelectItem>
|
||||||
|
{(tags ?? []).map((t) => (
|
||||||
|
<SelectItem key={t.id} value={String(t.id)}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full inline-block" style={{ backgroundColor: t.color }} />
|
||||||
|
{t.name}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
|
<SelectTrigger className="w-[130px]"><SelectValue placeholder="Type" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Types</SelectItem>
|
||||||
|
<SelectItem value="pdf">PDF</SelectItem>
|
||||||
|
<SelectItem value="video">Video</SelectItem>
|
||||||
|
<SelectItem value="link">Link</SelectItem>
|
||||||
|
<SelectItem value="document">Document</SelectItem>
|
||||||
|
<SelectItem value="article">Article</SelectItem>
|
||||||
|
<SelectItem value="audio">Audio</SelectItem>
|
||||||
|
<SelectItem value="image">Image</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<CalendarDays className="h-4 w-4" />
|
||||||
|
<span>From</span>
|
||||||
|
<Input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)} className="w-[150px] h-8 text-xs" />
|
||||||
|
<span>To</span>
|
||||||
|
<Input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)} className="w-[150px] h-8 text-xs" />
|
||||||
|
</div>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearFilters} className="text-muted-foreground">
|
||||||
|
<X className="h-3 w-3 mr-1" /> Clear filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-12"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>
|
||||||
|
) : (
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList className="mb-4">
|
||||||
|
<TabsTrigger value="all">All ({resources.length + courseMaterials.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="library">Shared Library ({resources.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="materials">Course Materials ({courseMaterials.length})</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="all">
|
||||||
|
<ResourceTable resources={resources} materials={courseMaterials} showSource onDelete={(id) => { if (window.confirm("Delete?")) deleteMutation.mutate(id); }} deletePending={deleteMutation.isPending} toast={toast} formatDate={formatDate} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="library">
|
||||||
|
<ResourceTable resources={resources} materials={[]} onDelete={(id) => { if (window.confirm("Delete?")) deleteMutation.mutate(id); }} deletePending={deleteMutation.isPending} toast={toast} formatDate={formatDate} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="materials">
|
||||||
|
<ResourceTable resources={[]} materials={courseMaterials} showCourseInfo onDelete={() => {}} deletePending={false} toast={toast} formatDate={formatDate} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Upload Dialog */}
|
||||||
|
<Dialog open={showUpload} onOpenChange={(o) => { setShowUpload(o); if (!o) { setUploadTagIds([]); setUploadSubjectId("none"); setUploadDomainId("none"); setUploadTopicIds([]); setUploadObjectiveIds([]); } }}>
|
||||||
|
<DialogContent className="sm:max-w-[540px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2"><Plus className="h-5 w-5" /> Upload New Resource</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleUpload} className="space-y-4 pt-2">
|
||||||
|
<input type="hidden" name="resource_type" value={uploadType} />
|
||||||
|
{uploadSubjectId !== "none" && <input type="hidden" name="subject_id" value={uploadSubjectId} />}
|
||||||
|
<div className="space-y-2"><Label>Name *</Label><Input name="name" placeholder="Resource title" required /></div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Select value={uploadType} onValueChange={setUploadType}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="pdf">PDF</SelectItem>
|
||||||
|
<SelectItem value="video">Video</SelectItem>
|
||||||
|
<SelectItem value="document">Document</SelectItem>
|
||||||
|
<SelectItem value="link">Link / URL</SelectItem>
|
||||||
|
<SelectItem value="audio">Audio</SelectItem>
|
||||||
|
<SelectItem value="image">Image</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<TaxonomyCascade
|
||||||
|
subjectId={uploadSubjectId} onSubjectChange={setUploadSubjectId}
|
||||||
|
domainId={uploadDomainId} onDomainChange={setUploadDomainId}
|
||||||
|
topicIds={uploadTopicIds} onTopicIdsChange={setUploadTopicIds}
|
||||||
|
objectiveIds={uploadObjectiveIds} onObjectiveIdsChange={setUploadObjectiveIds}
|
||||||
|
/>
|
||||||
|
<InlineTagPicker
|
||||||
|
allTags={tags ?? []}
|
||||||
|
selectedIds={uploadTagIds}
|
||||||
|
onToggle={toggleUploadTag}
|
||||||
|
onTagCreated={() => qc.invalidateQueries({ queryKey: ["resource-tags"] })}
|
||||||
|
/>
|
||||||
|
<div className="space-y-2"><Label>File</Label><Input name="file" type="file" ref={fileRef} /></div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="ghost" onClick={() => setShowUpload(false)}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={uploadMutation.isPending}>
|
||||||
|
{uploadMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResourceTable({
|
||||||
|
resources, materials, showSource, showCourseInfo, onDelete, deletePending, toast, formatDate,
|
||||||
|
}: {
|
||||||
|
resources: Resource[];
|
||||||
|
materials: CourseMaterialItem[];
|
||||||
|
showSource?: boolean;
|
||||||
|
showCourseInfo?: boolean;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
deletePending: boolean;
|
||||||
|
toast: ReturnType<typeof useToast>["toast"];
|
||||||
|
formatDate: (iso?: string) => string;
|
||||||
|
}) {
|
||||||
|
type Row = {
|
||||||
|
key: string; name: string; type: string; source: "library" | "course";
|
||||||
|
context: string; tags?: { id: number; name: string; color: string }[];
|
||||||
|
status?: string; createdAt?: string; resourceId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows: Row[] = [
|
||||||
|
...resources.map((r): Row => ({
|
||||||
|
key: `r-${r.id}`, name: r.name, type: r.resource_type, source: "library",
|
||||||
|
context: [r.subject_name, ...(r.topic_names?.slice(0, 2) ?? [])].filter(Boolean).join(" › ") || "",
|
||||||
|
tags: r.tags ?? [], status: r.review_status, createdAt: r.created_at, resourceId: r.id,
|
||||||
|
})),
|
||||||
|
...materials.map((m): Row => ({
|
||||||
|
key: `m-${m.id}`, name: m.name, type: m.type, source: "course",
|
||||||
|
context: showCourseInfo || showSource ? `${m.course_name} › ${m.chapter_name}` : m.chapter_name || "",
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<Library className="h-12 w-12 mx-auto mb-3 opacity-40" />
|
||||||
|
<p className="font-medium">No resources found</p>
|
||||||
|
<p className="text-sm">Upload a resource or add materials to your courses.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
{showSource && <TableHead>Source</TableHead>}
|
||||||
|
<TableHead>Subject / Tags</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Uploaded</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((row) => {
|
||||||
|
const sb = row.status ? (statusBadge[row.status] ?? statusBadge.pending) : null;
|
||||||
|
return (
|
||||||
|
<TableRow key={row.key}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{typeIcons[row.type] ?? <FileText className="h-4 w-4" />}
|
||||||
|
<span className="font-medium">{row.name}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell><Badge variant="outline" className="capitalize">{row.type}</Badge></TableCell>
|
||||||
|
{showSource && (
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={row.source === "library" ? "secondary" : "default"} className="text-xs">
|
||||||
|
{row.source === "library" ? "Library" : <><BookOpen className="h-3 w-3 mr-1 inline" />Course</>}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{row.context && <span className="text-xs text-muted-foreground">{row.context}</span>}
|
||||||
|
{row.tags && row.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{row.tags.map((t) => (
|
||||||
|
<span key={t.id} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium text-white" style={{ backgroundColor: t.color }}>{t.name}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!row.context && (!row.tags || row.tags.length === 0) && <span className="text-muted-foreground">—</span>}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{sb ? (
|
||||||
|
<Badge variant={sb.variant} className="flex items-center w-fit">{sb.icon}{row.status}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="default" className="flex items-center w-fit"><CheckCircle2 className="h-3 w-3 mr-1" />indexed</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">{formatDate(row.createdAt)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{row.source === "library" && row.resourceId && (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" size="icon" title="Download" onClick={async () => {
|
||||||
|
try {
|
||||||
|
const blob = await resourcesService.download(row.resourceId!);
|
||||||
|
const url = URL.createObjectURL(blob); const a = document.createElement("a");
|
||||||
|
a.href = url; a.download = row.name || "resource"; a.click(); URL.revokeObjectURL(url);
|
||||||
|
} catch { toast({ title: "Download failed", variant: "destructive" }); }
|
||||||
|
}}>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" title="Delete" onClick={() => onDelete(row.resourceId!)} disabled={deletePending}>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const INLINE_TAG_COLORS = ["#3b82f6", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6", "#ec4899", "#06b6d4"];
|
||||||
|
|
||||||
|
function InlineTagPicker({
|
||||||
|
allTags, selectedIds, onToggle, onTagCreated,
|
||||||
|
}: {
|
||||||
|
allTags: ResourceTag[];
|
||||||
|
selectedIds: number[];
|
||||||
|
onToggle: (id: number) => void;
|
||||||
|
onTagCreated: (tag: ResourceTag) => void;
|
||||||
|
}) {
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const color = INLINE_TAG_COLORS[Math.floor(Math.random() * INLINE_TAG_COLORS.length)];
|
||||||
|
const tag = await resourcesService.createTag({ name, color });
|
||||||
|
onTagCreated(tag);
|
||||||
|
onToggle(tag.id);
|
||||||
|
setNewName("");
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setCreating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Tags</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{allTags.map((t) => (
|
||||||
|
<Badge
|
||||||
|
key={t.id}
|
||||||
|
variant={selectedIds.includes(t.id) ? "default" : "outline"}
|
||||||
|
className="cursor-pointer select-none transition-colors"
|
||||||
|
style={selectedIds.includes(t.id) ? { backgroundColor: t.color, borderColor: t.color, color: "#fff" } : { borderColor: t.color, color: t.color }}
|
||||||
|
onClick={() => onToggle(t.id)}
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Input
|
||||||
|
placeholder="New tag…"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleCreate(); } }}
|
||||||
|
className="h-8 text-sm flex-1"
|
||||||
|
/>
|
||||||
|
<Button type="button" size="sm" variant="outline" disabled={!newName.trim() || creating} onClick={handleCreate}>
|
||||||
|
{creating ? <Loader2 className="h-3 w-3 animate-spin" /> : <><Plus className="h-3 w-3 mr-1" />Add</>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,22 +9,22 @@ import type {
|
|||||||
import type { ApiSuccessResponse } from "@/types";
|
import type { ApiSuccessResponse } from "@/types";
|
||||||
|
|
||||||
export const adaptiveEngineService = {
|
export const adaptiveEngineService = {
|
||||||
getDashboard: () => api.get<AdaptiveDashboardMetrics>("/adaptive-engine/dashboard"),
|
getDashboard: () => api.get<AdaptiveDashboardMetrics>("/adaptive/dashboard"),
|
||||||
|
|
||||||
getStudents: (params?: { page?: number; limit?: number }) =>
|
getStudents: (params?: { page?: number; limit?: number }) =>
|
||||||
api.get<{ data: AdaptiveEngineStudentRow[]; pagination?: { total: number; page: number } }>(
|
api.get<{ data: AdaptiveEngineStudentRow[]; pagination?: { total: number; page: number } }>(
|
||||||
"/adaptive-engine/students",
|
"/adaptive/students",
|
||||||
params as Record<string, string | number | boolean | undefined>,
|
params as Record<string, string | number | boolean | undefined>,
|
||||||
),
|
),
|
||||||
|
|
||||||
getStudentSignals: (studentId: number) =>
|
getStudentSignals: (studentId: number) =>
|
||||||
api.get<StudentAdaptiveSignal[]>(`/adaptive-engine/students/${studentId}/signals`),
|
api.get<StudentAdaptiveSignal[]>(`/adaptive/student/${studentId}/signals`),
|
||||||
|
|
||||||
getStudentAbility: (studentId: number) =>
|
getStudentAbility: (studentId: number) =>
|
||||||
api.get<StudentAbilityModel>(`/adaptive-engine/students/${studentId}/ability`),
|
api.get<StudentAbilityModel>(`/adaptive/student/${studentId}/ability`),
|
||||||
|
|
||||||
getSettings: () => api.get<AdaptiveThresholdSettings>("/adaptive-engine/settings"),
|
getSettings: () => api.get<AdaptiveThresholdSettings>("/adaptive/settings"),
|
||||||
|
|
||||||
updateSettings: (data: AdaptiveThresholdSettings) =>
|
updateSettings: (data: AdaptiveThresholdSettings) =>
|
||||||
api.put<ApiSuccessResponse>("/adaptive-engine/settings", data),
|
api.put<ApiSuccessResponse>("/adaptive/settings", data),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,41 +1,71 @@
|
|||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import type {
|
|
||||||
AICourseConfig,
|
export interface AiCourseCreateEnglishRequest {
|
||||||
QualityGateResult,
|
cefr_level: string;
|
||||||
IELTSValidationResult,
|
gap_profile_id?: number;
|
||||||
ExaminerReview,
|
}
|
||||||
AICourseTrack,
|
|
||||||
} from "@/types";
|
export interface AiCourseCreateIeltsRequest {
|
||||||
import type { ApiSuccessResponse } from "@/types";
|
skill: "listening" | "reading" | "writing" | "speaking";
|
||||||
|
target_band: number;
|
||||||
|
brief?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiCourseCreateResponse {
|
||||||
|
log_id: number;
|
||||||
|
status: string;
|
||||||
|
brief?: Record<string, unknown>;
|
||||||
|
skill?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QualityGateResult {
|
||||||
|
status: string;
|
||||||
|
readability_score: number;
|
||||||
|
cefr_alignment: boolean;
|
||||||
|
grammar_issues: string[];
|
||||||
|
attempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IELTSValidationResult {
|
||||||
|
type: string;
|
||||||
|
validation_results: Record<string, unknown>;
|
||||||
|
overall_passed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const aiCourseService = {
|
export const aiCourseService = {
|
||||||
createEnglish: (data: { current_level: string; target_level: string; learning_style: string[] }) =>
|
createEnglish: (data: AiCourseCreateEnglishRequest) =>
|
||||||
api.post<{ course_id: number }>("/ai-course/english/create", data),
|
api.post<AiCourseCreateResponse>("/ai-course/english/create", data),
|
||||||
|
|
||||||
createIelts: (data: { exam_type: string; target_band: number; skills: string[] }) =>
|
createIelts: (data: AiCourseCreateIeltsRequest) =>
|
||||||
api.post<{ course_id: number }>("/ai-course/ielts/create", data),
|
api.post<AiCourseCreateResponse>("/ai-course/ielts/create", data),
|
||||||
|
|
||||||
getCourse: (courseId: number) =>
|
getCourse: (courseId: number) =>
|
||||||
api.get<AICourseConfig>(`/ai-course/${courseId}`),
|
api.get<Record<string, unknown>>(`/ai-course/${courseId}`),
|
||||||
|
|
||||||
getTracks: (courseId: number) =>
|
getTracks: (courseId: number) =>
|
||||||
api.get<AICourseTrack[]>(`/ai-course/${courseId}/tracks`),
|
api.get<unknown[]>(`/ai-course/${courseId}/tracks`),
|
||||||
|
|
||||||
getQualityGate: (courseId: number) =>
|
getQualityGate: (courseId: number) =>
|
||||||
api.get<QualityGateResult>(`/ai-course/${courseId}/quality`),
|
api.get<QualityGateResult>(`/ai-course/${courseId}/quality`),
|
||||||
|
|
||||||
approveQuality: (courseId: number) =>
|
approveQuality: (courseId: number) =>
|
||||||
api.post<ApiSuccessResponse>(`/ai-course/${courseId}/quality/approve`),
|
api.post<{ approved: boolean }>(`/ai-course/${courseId}/approve`),
|
||||||
|
|
||||||
rejectQuality: (courseId: number, notes: string) =>
|
rejectQuality: (courseId: number, reason: string) =>
|
||||||
api.post<ApiSuccessResponse>(`/ai-course/${courseId}/quality/reject`, { notes }),
|
api.post<{ rejected: boolean; can_retry: boolean }>(`/ai-course/${courseId}/reject`, { reason }),
|
||||||
|
|
||||||
getIeltsValidation: (courseId: number) =>
|
getIeltsValidation: (courseId: number) =>
|
||||||
api.get<IELTSValidationResult>(`/ai-course/${courseId}/validation`),
|
api.get<IELTSValidationResult>(`/ai-course/${courseId}/validation`),
|
||||||
|
|
||||||
submitExaminerReview: (data: ExaminerReview) =>
|
submitExaminerReview: (logId: number, data: { action: string; examiner_notes?: string }) =>
|
||||||
api.post<ApiSuccessResponse>(`/ai-course/examiner-review`, data),
|
api.post<{ status: string; log_id: number }>(`/ai-course/ielts-review/${logId}`, data),
|
||||||
|
|
||||||
getEnglishTaxonomy: () =>
|
getEnglishTaxonomy: () =>
|
||||||
api.get<Record<string, unknown>>("/ai-course/english/taxonomy"),
|
api.get<Record<string, unknown>>("/ai-course/english/taxonomy"),
|
||||||
|
|
||||||
|
getReviewQueue: (page = 1, size = 20) =>
|
||||||
|
api.get<{ total: number; page: number; size: number; items: unknown[] }>("/ai-course/review-queue", { page, size }),
|
||||||
|
|
||||||
|
getIeltsReviewQueue: (page = 1, size = 20) =>
|
||||||
|
api.get<{ total: number; page: number; size: number; items: unknown[] }>("/ai-course/ielts-review-queue", { page, size }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,37 @@
|
|||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import type { AiInsight, AiAlert, AiSearchResult, AiBatchOptimization, AiGradingResult } from "@/types";
|
|
||||||
|
export interface AiSearchResponse {
|
||||||
|
answer: string;
|
||||||
|
suggestions: string[];
|
||||||
|
related_actions?: { label: string; action: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiInsightItem {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
severity: "info" | "warning" | "critical";
|
||||||
|
recommendation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiAlertItem {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
severity: string;
|
||||||
|
recommendation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchOptimizeResponse {
|
||||||
|
optimized: unknown[];
|
||||||
|
summary: string;
|
||||||
|
impact: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiGradingResult {
|
||||||
|
scores: Record<string, number>;
|
||||||
|
overall_band: number;
|
||||||
|
feedback: string;
|
||||||
|
suggestions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export const analyticsService = {
|
export const analyticsService = {
|
||||||
async getStudentAnalytics(params?: Record<string, string | number | boolean | undefined>): Promise<unknown> {
|
async getStudentAnalytics(params?: Record<string, string | number | boolean | undefined>): Promise<unknown> {
|
||||||
@@ -18,27 +50,44 @@ export const analyticsService = {
|
|||||||
return api.get("/analytics/content-gaps", params as Record<string, string | number | boolean | undefined>);
|
return api.get("/analytics/content-gaps", params as Record<string, string | number | boolean | undefined>);
|
||||||
},
|
},
|
||||||
|
|
||||||
async search(query: string): Promise<AiSearchResult[]> {
|
async search(query: string): Promise<AiSearchResponse> {
|
||||||
return api.post<AiSearchResult[]>("/ai/search", { query });
|
return api.post<AiSearchResponse>("/ai/search", { query });
|
||||||
},
|
},
|
||||||
|
|
||||||
async getInsights(data: Record<string, unknown>): Promise<AiInsight[]> {
|
async getInsights(data: Record<string, unknown>): Promise<{ insights: AiInsightItem[] }> {
|
||||||
return api.post<AiInsight[]>("/ai/insights", data);
|
return api.post<{ insights: AiInsightItem[] }>("/ai/insights", { data, type: "general" });
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAlerts(): Promise<AiAlert[]> {
|
async getAlerts(): Promise<{ alerts: AiAlertItem[] }> {
|
||||||
return api.get<AiAlert[]>("/ai/alerts");
|
return api.get<{ alerts: AiAlertItem[] }>("/ai/alerts");
|
||||||
},
|
},
|
||||||
|
|
||||||
async getReportNarrative(data: { report_type: string; data: Record<string, unknown> }): Promise<{ narrative: string }> {
|
async getReportNarrative(data: { report_type: string; data: Record<string, unknown> }): Promise<{ narrative: string }> {
|
||||||
return api.post("/ai/report-narrative", data);
|
return api.post("/ai/report-narrative", data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getBatchOptimization(batchId: number): Promise<AiBatchOptimization[]> {
|
async getBatchOptimization(batchId: number, items: unknown[] = [], type = "schedule"): Promise<BatchOptimizeResponse> {
|
||||||
return api.post<AiBatchOptimization[]>("/ai/batch-optimize", { batch_id: batchId });
|
return api.post<BatchOptimizeResponse>("/ai/batch-optimize", { items, type });
|
||||||
},
|
},
|
||||||
|
|
||||||
async getGradingSuggestion(data: { submission_id: number; text: string; rubric_id?: number }): Promise<AiGradingResult> {
|
async getGradingSuggestion(data: {
|
||||||
|
submission_text: string;
|
||||||
|
skill?: string;
|
||||||
|
rubric?: string;
|
||||||
|
task?: string;
|
||||||
|
}): Promise<AiGradingResult> {
|
||||||
return api.post<AiGradingResult>("/ai/grade-suggest", data);
|
return api.post<AiGradingResult>("/ai/grade-suggest", data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async applyBatchOptimization(batchId: number, optimized: unknown[]): Promise<{ applied: number }> {
|
||||||
|
return api.post("/ai/batch-optimize/apply", { batch_id: batchId, optimized });
|
||||||
|
},
|
||||||
|
|
||||||
|
async vectorSearch(query: string, options?: { content_type?: string; limit?: number }): Promise<{
|
||||||
|
results: { content_type: string; content_id: number; text: string; metadata: Record<string, unknown>; similarity: number }[];
|
||||||
|
query: string;
|
||||||
|
count: number;
|
||||||
|
}> {
|
||||||
|
return api.get("/ai/vector-search", { q: query, ...options } as Record<string, string | number | boolean | undefined>);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import type { Assignment, AssignmentState, AssignmentCreateRequest, PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types";
|
import type {
|
||||||
|
Assignment,
|
||||||
|
AssignmentState,
|
||||||
|
AssignmentCreateRequest,
|
||||||
|
ExamSchedule,
|
||||||
|
ExamScheduleCreateRequest,
|
||||||
|
ScheduleState,
|
||||||
|
StudentExamAssignment,
|
||||||
|
PaginatedResponse,
|
||||||
|
PaginationParams,
|
||||||
|
ApiSuccessResponse,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
export interface AssignmentListParams extends PaginationParams {
|
export interface AssignmentListParams extends PaginationParams {
|
||||||
state?: AssignmentState;
|
state?: AssignmentState;
|
||||||
entity_id?: number;
|
entity_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScheduleListParams extends PaginationParams {
|
||||||
|
state?: ScheduleState;
|
||||||
|
}
|
||||||
|
|
||||||
export const assignmentsService = {
|
export const assignmentsService = {
|
||||||
async list(params?: AssignmentListParams): Promise<PaginatedResponse<Assignment>> {
|
async list(params?: AssignmentListParams): Promise<PaginatedResponse<Assignment>> {
|
||||||
return api.get<PaginatedResponse<Assignment>>("/assignments", params as Record<string, string | number | boolean | undefined>);
|
return api.get<PaginatedResponse<Assignment>>("/assignments", params as Record<string, string | number | boolean | undefined>);
|
||||||
@@ -34,4 +49,28 @@ export const assignmentsService = {
|
|||||||
async start(id: number): Promise<Assignment> {
|
async start(id: number): Promise<Assignment> {
|
||||||
return api.post<Assignment>(`/assignments/${id}/start`);
|
return api.post<Assignment>(`/assignments/${id}/start`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listSchedules(params?: ScheduleListParams): Promise<PaginatedResponse<ExamSchedule>> {
|
||||||
|
return api.get<PaginatedResponse<ExamSchedule>>("/exam-schedules", params as Record<string, string | number | boolean | undefined>);
|
||||||
|
},
|
||||||
|
|
||||||
|
async createSchedule(data: ExamScheduleCreateRequest): Promise<ExamSchedule> {
|
||||||
|
return api.post<ExamSchedule>("/exam-schedules", data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSchedule(id: number, data: Partial<ExamScheduleCreateRequest & { state: ScheduleState }>): Promise<ExamSchedule> {
|
||||||
|
return api.put<ExamSchedule>(`/exam-schedules/${id}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteSchedule(id: number): Promise<ApiSuccessResponse> {
|
||||||
|
return api.delete<ApiSuccessResponse>(`/exam-schedules/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async archiveSchedule(id: number): Promise<ExamSchedule> {
|
||||||
|
return api.post<ExamSchedule>(`/exam-schedules/${id}/archive`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getStudentExams(): Promise<{ items: StudentExamAssignment[] }> {
|
||||||
|
return api.get<{ items: StudentExamAssignment[] }>("/student/my-exams");
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,28 +1,55 @@
|
|||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import type { AiChatRequest, AiChatResponse, AiTip } from "@/types";
|
|
||||||
|
interface CoachChatRequest {
|
||||||
|
message: string;
|
||||||
|
history?: { role: string; content: string }[];
|
||||||
|
context?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CoachChatResponse {
|
||||||
|
reply: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CoachTipResponse {
|
||||||
|
tip: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CoachSuggestResponse {
|
||||||
|
suggestion: string;
|
||||||
|
focus_areas: string[];
|
||||||
|
daily_plan: { activity: string; duration_min: number; skill: string }[];
|
||||||
|
motivation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CoachWritingResponse {
|
||||||
|
improved_text: string;
|
||||||
|
changes: { original: string; revised: string; reason: string }[];
|
||||||
|
tips: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export const coachingService = {
|
export const coachingService = {
|
||||||
async chat(data: AiChatRequest): Promise<AiChatResponse> {
|
async chat(data: CoachChatRequest): Promise<CoachChatResponse> {
|
||||||
return api.post<AiChatResponse>("/coach/chat", data);
|
return api.post<CoachChatResponse>("/coach/chat", data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getHint(data: { topic_id: number; question_id: string }): Promise<{ hint: string }> {
|
async getHint(data: { topic_id: number; question_id: string }): Promise<{ hint: string; strategy: string }> {
|
||||||
return api.post("/coach/hint", data);
|
return api.post("/coach/hint", data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async explain(data: { context: string; scores?: Record<string, number> }): Promise<{ explanation: string }> {
|
async explain(data: { score_data: Record<string, unknown>; student_context?: string }): Promise<{ explanation: string }> {
|
||||||
return api.post("/coach/explain", data);
|
return api.post("/coach/explain", data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async suggest(data?: { subject_id?: number }): Promise<{ suggestions: string[]; study_plan_tips: string[] }> {
|
async suggest(data?: Record<string, unknown>): Promise<CoachSuggestResponse> {
|
||||||
return api.post("/coach/suggest", data);
|
return api.post("/coach/suggest", data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async writingHelp(data: { text: string; task_type: string }): Promise<{ feedback: string; improved: string; grammar_notes: string[] }> {
|
async writingHelp(data: { task: string; draft: string; help_type: string }): Promise<CoachWritingResponse> {
|
||||||
return api.post("/coach/writing-help", data);
|
return api.post("/coach/writing-help", data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getTip(context: string): Promise<AiTip> {
|
async getTip(context: string): Promise<CoachTipResponse> {
|
||||||
return api.get<AiTip>("/coach/tip", { context });
|
return api.get<CoachTipResponse>("/coach/tip", { context });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,6 +51,18 @@ export const coursewareService = {
|
|||||||
return api.get<ChapterProgress>(`/chapters/${id}/progress`);
|
return api.get<ChapterProgress>(`/chapters/${id}/progress`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getCourseCompletion(courseId: number): Promise<{
|
||||||
|
course_id: number;
|
||||||
|
status: string;
|
||||||
|
chapters_total: number;
|
||||||
|
chapters_completed: number;
|
||||||
|
progress_percent: number;
|
||||||
|
completed_at?: string | null;
|
||||||
|
post_test_available: boolean;
|
||||||
|
}> {
|
||||||
|
return api.get(`/courses/${courseId}/completion`);
|
||||||
|
},
|
||||||
|
|
||||||
async markChapterComplete(id: number): Promise<ApiSuccessResponse> {
|
async markChapterComplete(id: number): Promise<ApiSuccessResponse> {
|
||||||
return api.post<ApiSuccessResponse>(`/chapters/${id}/progress/complete`);
|
return api.post<ApiSuccessResponse>(`/chapters/${id}/progress/complete`);
|
||||||
},
|
},
|
||||||
@@ -67,6 +79,13 @@ export const coursewareService = {
|
|||||||
return api.patch<ChapterMaterial>(`/materials/${id}`, data);
|
return api.patch<ChapterMaterial>(`/materials/${id}`, data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async createMaterialFromResource(chapterId: number, resourceId: number, name?: string): Promise<ChapterMaterial> {
|
||||||
|
return api.post<ChapterMaterial>(`/chapters/${chapterId}/materials/from-resource`, {
|
||||||
|
resource_id: resourceId,
|
||||||
|
...(name ? { name } : {}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async deleteMaterial(id: number): Promise<ApiSuccessResponse> {
|
async deleteMaterial(id: number): Promise<ApiSuccessResponse> {
|
||||||
return api.delete<ApiSuccessResponse>(`/materials/${id}`);
|
return api.delete<ApiSuccessResponse>(`/materials/${id}`);
|
||||||
},
|
},
|
||||||
@@ -87,6 +106,27 @@ export const coursewareService = {
|
|||||||
return api.patch<ApiSuccessResponse>(`/chapters/${chapterId}/materials/reorder`, body);
|
return api.patch<ApiSuccessResponse>(`/chapters/${chapterId}/materials/reorder`, body);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async searchMaterials(params?: { search?: string; type?: string; course_id?: number; limit?: number; offset?: number }): Promise<{
|
||||||
|
items: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
chapter_id: number;
|
||||||
|
type: string;
|
||||||
|
file_url: string | null;
|
||||||
|
url: string | null;
|
||||||
|
description: string | null;
|
||||||
|
sequence: number;
|
||||||
|
allow_download: boolean;
|
||||||
|
course_id: number;
|
||||||
|
course_name: string;
|
||||||
|
chapter_name: string;
|
||||||
|
source: string;
|
||||||
|
}>;
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
return api.get("/materials/search", params as Record<string, string | number | boolean | undefined>);
|
||||||
|
},
|
||||||
|
|
||||||
async generateOutline(data: WorkbenchGenerateRequest): Promise<WorkbenchGeneratedOutline> {
|
async generateOutline(data: WorkbenchGenerateRequest): Promise<WorkbenchGeneratedOutline> {
|
||||||
return api.post<WorkbenchGeneratedOutline>("/workbench/generate-outline", data);
|
return api.post<WorkbenchGeneratedOutline>("/workbench/generate-outline", data);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ export const entityOnboardingService = {
|
|||||||
validateCsv: (file: File) => {
|
validateCsv: (file: File) => {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("file", file);
|
fd.append("file", file);
|
||||||
return api.upload<CSVValidationReport>("/entity/students/csv/validate", fd);
|
return api.upload<CSVValidationReport>("/entity/students/validate-csv", fd);
|
||||||
},
|
},
|
||||||
|
|
||||||
bulkCreate: (payload: { validate_session_id?: string }) =>
|
bulkCreate: (payload: { validate_session_id?: string }) =>
|
||||||
api.post<BulkCreateResult>("/entity/students/bulk-create", payload),
|
api.post<BulkCreateResult>("/entity/students/bulk-create", payload),
|
||||||
|
|
||||||
sendCredentials: (studentIds: number[]) =>
|
sendCredentials: (studentIds: number[]) =>
|
||||||
api.post<ApiSuccessResponse>("/entity/students/send-credentials", { student_ids: studentIds }),
|
api.post<ApiSuccessResponse>("/entity/students/send-credentials", { user_ids: studentIds }),
|
||||||
|
|
||||||
getCredentialStatuses: (
|
getCredentialStatuses: (
|
||||||
filters?: CredentialFilters & { page?: number; limit?: number },
|
filters?: CredentialFilters & { page?: number; limit?: number },
|
||||||
@@ -29,8 +29,8 @@ export const entityOnboardingService = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
resendCredential: (studentId: number) =>
|
resendCredential: (studentId: number) =>
|
||||||
api.post<ApiSuccessResponse>(`/entity/students/${studentId}/resend-credential`),
|
api.post<ApiSuccessResponse>(`/entity/students/${studentId}/resend-credentials`),
|
||||||
|
|
||||||
resendAllPending: () =>
|
resendAllPending: () =>
|
||||||
api.post<ApiSuccessResponse>("/entity/students/resend-credentials/pending"),
|
api.post<ApiSuccessResponse>("/entity/students/resend-all-pending"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ export const examSessionService = {
|
|||||||
autoSave: (examId: number, data: ExamAutoSave) =>
|
autoSave: (examId: number, data: ExamAutoSave) =>
|
||||||
api.post<ApiSuccessResponse>(`/exam/${examId}/autosave`, data),
|
api.post<ApiSuccessResponse>(`/exam/${examId}/autosave`, data),
|
||||||
|
|
||||||
submit: (examId: number) =>
|
submit: (examId: number, data?: { attempt_id: number; answers: { question_id: number; answer: unknown }[] }) =>
|
||||||
api.post<ExamSubmitResponse>(`/exam/${examId}/submit`),
|
api.post<ExamSubmitResponse>(`/exam/${examId}/submit`, data),
|
||||||
|
|
||||||
getStatus: (examId: number) =>
|
getStatus: (examId: number) =>
|
||||||
api.get<{ status: string; scores_available: boolean }>(`/exam/${examId}/status`),
|
api.get<{ status: string; scores_available: boolean }>(`/exam/${examId}/status`),
|
||||||
|
|
||||||
|
getResults: (examId: number) =>
|
||||||
|
api.get(`/exam/${examId}/results`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,10 +40,30 @@ export const examsService = {
|
|||||||
return api.post<Rubric>("/rubrics", data);
|
return api.post<Rubric>("/rubrics", data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateRubric(id: number, data: Partial<Rubric>): Promise<Rubric> {
|
||||||
|
return api.put<Rubric>(`/rubrics/${id}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteRubric(id: number): Promise<ApiSuccessResponse> {
|
||||||
|
return api.delete<ApiSuccessResponse>(`/rubrics/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
async listRubricGroups(params?: PaginationParams): Promise<PaginatedResponse<RubricGroup>> {
|
async listRubricGroups(params?: PaginationParams): Promise<PaginatedResponse<RubricGroup>> {
|
||||||
return api.get<PaginatedResponse<RubricGroup>>("/rubric-groups", params as Record<string, string | number | boolean | undefined>);
|
return api.get<PaginatedResponse<RubricGroup>>("/rubric-groups", params as Record<string, string | number | boolean | undefined>);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async createRubricGroup(data: { name: string; rubric_ids: number[] }): Promise<RubricGroup> {
|
||||||
|
return api.post<RubricGroup>("/rubric-groups", data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateRubricGroup(id: number, data: { name?: string; rubric_ids?: number[] }): Promise<RubricGroup> {
|
||||||
|
return api.put<RubricGroup>(`/rubric-groups/${id}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteRubricGroup(id: number): Promise<ApiSuccessResponse> {
|
||||||
|
return api.delete<ApiSuccessResponse>(`/rubric-groups/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
async listStructures(params?: PaginationParams & { entity_id?: number }): Promise<PaginatedResponse<ExamStructure>> {
|
async listStructures(params?: PaginationParams & { entity_id?: number }): Promise<PaginatedResponse<ExamStructure>> {
|
||||||
return api.get<PaginatedResponse<ExamStructure>>("/exam-structures", params as Record<string, string | number | boolean | undefined>);
|
return api.get<PaginatedResponse<ExamStructure>>("/exam-structures", params as Record<string, string | number | boolean | undefined>);
|
||||||
},
|
},
|
||||||
@@ -52,10 +72,23 @@ export const examsService = {
|
|||||||
return api.post<ExamStructure>("/exam-structures", data);
|
return api.post<ExamStructure>("/exam-structures", data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateStructure(id: number, data: Partial<ExamStructure>): Promise<ExamStructure> {
|
||||||
|
return api.put<ExamStructure>(`/exam-structures/${id}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
async deleteStructure(id: number): Promise<ApiSuccessResponse> {
|
async deleteStructure(id: number): Promise<ApiSuccessResponse> {
|
||||||
return api.delete<ApiSuccessResponse>(`/exam-structures/${id}`);
|
return api.delete<ApiSuccessResponse>(`/exam-structures/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async suggestRubricCriteria(data: {
|
||||||
|
name?: string;
|
||||||
|
skill: string;
|
||||||
|
exam_type: string;
|
||||||
|
levels: string[];
|
||||||
|
}): Promise<{ criteria: string[]; suggested_levels?: string[] }> {
|
||||||
|
return api.post("/ai/suggest-rubric-criteria", data);
|
||||||
|
},
|
||||||
|
|
||||||
async getAvatars(): Promise<{ id: number; name: string; thumbnail: string }[]> {
|
async getAvatars(): Promise<{ id: number; name: string; thumbnail: string }[]> {
|
||||||
return api.get("/exam/avatars");
|
return api.get("/exam/avatars");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,20 +2,51 @@ import { api } from "@/lib/api-client";
|
|||||||
import type { FeesPlan, StudentFeesDetail, FeesTerms } from "@/types/fees";
|
import type { FeesPlan, StudentFeesDetail, FeesTerms } from "@/types/fees";
|
||||||
import type { PaginatedResponse, PaginationParams } from "@/types";
|
import type { PaginatedResponse, PaginationParams } from "@/types";
|
||||||
|
|
||||||
|
export interface FeesPlanFilters extends PaginationParams {
|
||||||
|
q?: string;
|
||||||
|
state?: string;
|
||||||
|
payment_state?: string;
|
||||||
|
student_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterPaymentBody {
|
||||||
|
amount?: number;
|
||||||
|
journal_id?: number;
|
||||||
|
payment_date?: string;
|
||||||
|
memo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const feesService = {
|
export const feesService = {
|
||||||
async listFeesPlans(params?: PaginationParams): Promise<PaginatedResponse<FeesPlan>> {
|
async listFeesPlans(params?: FeesPlanFilters): Promise<PaginatedResponse<FeesPlan>> {
|
||||||
return api.get<PaginatedResponse<FeesPlan>>("/fees-plans", params as Record<string, string | number | boolean | undefined>);
|
return api.get<PaginatedResponse<FeesPlan>>(
|
||||||
|
"/fees-plans",
|
||||||
|
params as Record<string, string | number | boolean | undefined>,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getFeesPlan(id: number): Promise<FeesPlan> {
|
async getFeesPlan(id: number): Promise<FeesPlan> {
|
||||||
return api.get<FeesPlan>(`/fees-plans/${id}`);
|
return api.get<FeesPlan>(`/fees-plans/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
async listStudentFees(params?: PaginationParams): Promise<PaginatedResponse<StudentFeesDetail>> {
|
async createInvoice(id: number): Promise<FeesPlan> {
|
||||||
return api.get<PaginatedResponse<StudentFeesDetail>>("/student-fees", params as Record<string, string | number | boolean | undefined>);
|
return api.post<FeesPlan>(`/fees-plans/${id}/create-invoice`, {});
|
||||||
|
},
|
||||||
|
|
||||||
|
async registerPayment(id: number, body: RegisterPaymentBody = {}): Promise<FeesPlan> {
|
||||||
|
return api.post<FeesPlan>(`/fees-plans/${id}/register-payment`, body);
|
||||||
|
},
|
||||||
|
|
||||||
|
async listStudentFees(params?: FeesPlanFilters): Promise<PaginatedResponse<StudentFeesDetail>> {
|
||||||
|
return api.get<PaginatedResponse<StudentFeesDetail>>(
|
||||||
|
"/student-fees",
|
||||||
|
params as Record<string, string | number | boolean | undefined>,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
async listFeesTerms(params?: PaginationParams): Promise<PaginatedResponse<FeesTerms>> {
|
async listFeesTerms(params?: PaginationParams): Promise<PaginatedResponse<FeesTerms>> {
|
||||||
return api.get<PaginatedResponse<FeesTerms>>("/fees-terms", params as Record<string, string | number | boolean | undefined>);
|
return api.get<PaginatedResponse<FeesTerms>>(
|
||||||
|
"/fees-terms",
|
||||||
|
params as Record<string, string | number | boolean | undefined>,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,21 +2,182 @@ import { api } from "@/lib/api-client";
|
|||||||
import type { ExamModule } from "@/types";
|
import type { ExamModule } from "@/types";
|
||||||
|
|
||||||
export interface GenerationParams {
|
export interface GenerationParams {
|
||||||
title: string;
|
title?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
entity_id?: number;
|
entity_id?: number;
|
||||||
subject_id?: number;
|
subject_id?: number;
|
||||||
topic_id?: number;
|
topic_id?: number;
|
||||||
difficulty?: string;
|
difficulty?: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
|
topic?: string;
|
||||||
|
passage_length?: string;
|
||||||
|
task_type?: string;
|
||||||
|
part?: string;
|
||||||
|
question_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonaContext {
|
||||||
|
exam_mode?: "official" | "practice" | string;
|
||||||
|
exam_title?: string;
|
||||||
|
exam_label?: string;
|
||||||
|
structure_name?: string;
|
||||||
|
passage_type?: string;
|
||||||
|
task_type?: string;
|
||||||
|
section_type?: string;
|
||||||
|
part?: string;
|
||||||
|
category?: string;
|
||||||
|
subject_name?: string;
|
||||||
|
subject_id?: number;
|
||||||
|
entity_id?: number;
|
||||||
|
entity_name?: string;
|
||||||
|
rubric_name?: string;
|
||||||
|
rubric_id?: number | string;
|
||||||
|
grading_system?: string;
|
||||||
|
access_type?: string;
|
||||||
|
course_id?: number;
|
||||||
|
module?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PassageGenerationParams extends PersonaContext {
|
||||||
|
topic?: string;
|
||||||
|
difficulty?: string;
|
||||||
|
word_count?: number;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExerciseConfig extends PersonaContext {
|
||||||
|
passage_index: number;
|
||||||
|
exercise_types: string[];
|
||||||
|
count_per_type?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleConfig {
|
||||||
|
module: ExamModule;
|
||||||
|
timer_minutes: number;
|
||||||
|
difficulty: string[];
|
||||||
|
access_type: string;
|
||||||
|
entities?: number[];
|
||||||
|
approval_workflow?: string;
|
||||||
|
rubric_criteria_group?: string;
|
||||||
|
rubric_criteria?: string;
|
||||||
|
grading_system?: string;
|
||||||
|
shuffling_enabled: boolean;
|
||||||
|
passages?: PassageConfig[];
|
||||||
|
sections?: SectionConfig[];
|
||||||
|
tasks?: TaskConfig[];
|
||||||
|
speaking_parts?: SpeakingPartConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PassageConfig {
|
||||||
|
index: number;
|
||||||
|
category?: string;
|
||||||
|
type?: string;
|
||||||
|
divider?: string;
|
||||||
|
text?: string;
|
||||||
|
exercise_types: string[];
|
||||||
|
exercises?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectionConfig {
|
||||||
|
type: string;
|
||||||
|
category?: string;
|
||||||
|
divider?: string;
|
||||||
|
audio_context?: string;
|
||||||
|
audio_url?: string;
|
||||||
|
exercise_types: string[];
|
||||||
|
exercises?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskConfig {
|
||||||
|
index: number;
|
||||||
|
category?: string;
|
||||||
|
type?: string;
|
||||||
|
divider?: string;
|
||||||
|
instructions?: string;
|
||||||
|
word_limit: number;
|
||||||
|
marks: number;
|
||||||
|
images?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeakingPartConfig {
|
||||||
|
type: string;
|
||||||
|
category?: string;
|
||||||
|
divider?: string;
|
||||||
|
script?: string;
|
||||||
|
video_url?: string;
|
||||||
|
avatar_id?: string;
|
||||||
|
marks: number;
|
||||||
|
topics?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const generationService = {
|
export const generationService = {
|
||||||
async generate(module: ExamModule, params: GenerationParams): Promise<{ exam_id: number; exercises: unknown[] }> {
|
generate(module: ExamModule, params: GenerationParams): Promise<{ questions: unknown[] }> {
|
||||||
return api.post(`/exam/${module}/generate`, params);
|
return api.post(`/exam/${module}/generate`, params);
|
||||||
},
|
},
|
||||||
|
|
||||||
async generateFromScratch(module: ExamModule, params: GenerationParams): Promise<{ exam_id: number; exercises: unknown[] }> {
|
saveGenerated(module: ExamModule, data: unknown): Promise<{ saved: number; module: string }> {
|
||||||
return api.post(`/exam/${module}/generate/scratch`, params);
|
const payload = Array.isArray(data) ? { questions: data } : data;
|
||||||
|
return api.post(`/exam/${module}/generate/save`, payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
generatePassage(params: PassageGenerationParams): Promise<{ passage: string; title?: string }> {
|
||||||
|
return api.post("/exam/reading/generate", {
|
||||||
|
...params,
|
||||||
|
generate_passage: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
generateExercises(
|
||||||
|
module: ExamModule,
|
||||||
|
config: ExerciseConfig & {
|
||||||
|
passage_text?: string;
|
||||||
|
type_counts?: Record<string, number>;
|
||||||
|
type_instructions?: Record<string, string>;
|
||||||
|
type_difficulties?: Record<string, string>;
|
||||||
|
difficulty?: string;
|
||||||
|
},
|
||||||
|
): Promise<{ questions: unknown[] }> {
|
||||||
|
return api.post(`/exam/${module}/generate`, {
|
||||||
|
...config,
|
||||||
|
generate_exercises: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
generateWritingInstructions(
|
||||||
|
params: PersonaContext & { topic?: string; difficulty?: string; task_type?: string; word_limit?: number },
|
||||||
|
): Promise<{ instructions: string }> {
|
||||||
|
return api.post("/exam/writing/generate", {
|
||||||
|
...params,
|
||||||
|
generate_instructions: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
generateSpeakingScript(
|
||||||
|
params: PersonaContext & { topics?: string[]; difficulty?: string; part?: string },
|
||||||
|
): Promise<{ script: string }> {
|
||||||
|
return api.post("/exam/speaking/generate", {
|
||||||
|
...params,
|
||||||
|
generate_script: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
generateListeningContext(
|
||||||
|
params: PersonaContext & { topic?: string; section_type?: string; difficulty?: string },
|
||||||
|
): Promise<{ context: string }> {
|
||||||
|
return api.post("/exam/listening/generate", {
|
||||||
|
...params,
|
||||||
|
generate_context: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
submitExam(data: {
|
||||||
|
title: string;
|
||||||
|
label: string;
|
||||||
|
exam_mode?: string;
|
||||||
|
structure_id?: number;
|
||||||
|
modules: Record<string, unknown>;
|
||||||
|
skip_approval?: boolean;
|
||||||
|
}): Promise<{ exam_id: number; status: string; template_id?: number; total_questions?: number }> {
|
||||||
|
return api.post("/exam/generation/submit", data);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const gradebookService = {
|
|||||||
return api.get<PaginatedResponse<Gradebook>>("/gradebooks", params as Record<string, string | number | boolean | undefined>);
|
return api.get<PaginatedResponse<Gradebook>>("/gradebooks", params as Record<string, string | number | boolean | undefined>);
|
||||||
},
|
},
|
||||||
|
|
||||||
async listGradebookLines(params?: PaginationParams): Promise<PaginatedResponse<GradebookLine>> {
|
async listGradebookLines(params?: PaginationParams & { gradebook_id?: number }): Promise<PaginatedResponse<GradebookLine>> {
|
||||||
return api.get<PaginatedResponse<GradebookLine>>("/gradebook-lines", params as Record<string, string | number | boolean | undefined>);
|
return api.get<PaginatedResponse<GradebookLine>>("/gradebook-lines", params as Record<string, string | number | boolean | undefined>);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user