- Restructure: move backend from new_project/ to backend/ - Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks) - Add docs/ with SRS specs, user stories, and workflow documentation - Update .gitignore for new directory layout Workflows implemented: WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration, WF4 General English Exam, WF5 Course Generation, WF6 Entity Student Onboarding, AI Course Generation, Adaptive Learning Engine UI, White-Label Branding, Score Release Made-with: Cursor
96 lines
6.8 KiB
TypeScript
96 lines
6.8 KiB
TypeScript
import { useState } from "react";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { useBatches, useCourses } from "@/hooks/queries";
|
|
import { lmsService } from "@/services/lms.service";
|
|
import { api } from "@/lib/api-client";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { Search, Plus, Trash2 } from "lucide-react";
|
|
import { Link } from "react-router-dom";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import AiBatchOptimizer from "@/components/ai/AiBatchOptimizer";
|
|
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
|
|
|
export default function AdminBatches() {
|
|
const [search, setSearch] = useState("");
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [form, setForm] = useState({ name: "", course_id: "", start_date: "", end_date: "", max_students: "30" });
|
|
const { toast } = useToast();
|
|
const qc = useQueryClient();
|
|
const { data: batchesData, isLoading } = useBatches();
|
|
const { data: coursesData } = useCourses({ size: 200 });
|
|
const batches = batchesData?.items ?? [];
|
|
const courses = coursesData?.items ?? [];
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (data: Record<string, unknown>) => lmsService.createBatch(data as never),
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "batches"] }); setCreateOpen(false); toast({ title: "Batch created" }); setForm({ name: "", course_id: "", start_date: "", end_date: "", max_students: "30" }); },
|
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
|
});
|
|
|
|
async function handleDelete(id: number, name: string) {
|
|
if (!window.confirm(`Delete batch "${name}"?`)) return;
|
|
try {
|
|
await api.delete(`/batches/${id}`);
|
|
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
|
toast({ title: "Batch deleted" });
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
toast({ title: "Delete failed", description: msg, variant: "destructive" });
|
|
}
|
|
}
|
|
|
|
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
|
|
|
const filtered = batches.filter(b => b.name.toLowerCase().includes(search.toLowerCase()));
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div><h1 className="text-2xl font-bold">Batches</h1><p className="text-muted-foreground">Manage student batches and groups.</p></div>
|
|
<div className="flex gap-2">
|
|
<AiBatchOptimizer batchId={batches[0]?.id} />
|
|
<AiCreationAssistant type="batch" />
|
|
<Button onClick={() => setCreateOpen(true)}><Plus className="mr-2 h-4 w-4" />Create Batch</Button>
|
|
</div>
|
|
</div>
|
|
<AiTipBanner context="admin-batches" 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 batches..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9" /></div>
|
|
<Card><CardContent className="pt-6"><Table><TableHeader><TableRow><TableHead>Batch</TableHead><TableHead>Course</TableHead><TableHead>Teacher</TableHead><TableHead>Students</TableHead><TableHead>Schedule</TableHead><TableHead>Status</TableHead><TableHead></TableHead></TableRow></TableHeader><TableBody>{filtered.map(b => (<TableRow key={b.id}><TableCell className="font-medium">{b.name}</TableCell><TableCell>{b.course_name}</TableCell><TableCell>{b.teacher_name}</TableCell><TableCell>{b.student_count}/{b.capacity}</TableCell><TableCell className="text-xs">{b.schedule}</TableCell><TableCell><Badge variant={b.status === "active" ? "default" : "secondary"} className="capitalize">{b.status}</Badge></TableCell><TableCell><div className="flex gap-1"><Button size="sm" variant="ghost" asChild><Link to={`/admin/batches/${b.id}`}>View</Link></Button><Button variant="ghost" size="icon" onClick={() => handleDelete(b.id, b.name)}><Trash2 className="h-4 w-4 text-destructive" /></Button></div></TableCell></TableRow>))}</TableBody></Table></CardContent></Card>
|
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
<DialogContent>
|
|
<DialogHeader><DialogTitle>Create Batch</DialogTitle></DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2"><Label>Batch Name *</Label><Input placeholder="e.g. IELTS Morning B" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} /></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 }))}>
|
|
<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="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2"><Label>Start Date *</Label><Input type="date" value={form.start_date} onChange={e => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
|
|
<div className="space-y-2"><Label>End Date *</Label><Input type="date" value={form.end_date} onChange={e => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
|
|
</div>
|
|
<div className="space-y-2"><Label>Capacity</Label><Input type="number" placeholder="30" value={form.max_students} onChange={e => setForm(f => ({ ...f, max_students: e.target.value }))} /></div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
|
<Button disabled={createMutation.isPending || !form.name || !form.course_id || !form.start_date || !form.end_date} onClick={() => createMutation.mutate({ name: form.name, course_id: Number(form.course_id), start_date: form.start_date, end_date: form.end_date, max_students: Number(form.max_students) || 30 })}>Create</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|