feat: institutional + support + training admin sections (backend + frontend)

Ship three fully-wired admin areas end-to-end with APIs, seeds, tests and docs.

Backend (new `encoach_lms_api` addon + existing addons):
- Institutional: academic years/terms, departments, admission registers & admissions,
  courses/batches, lessons, fees (terms + student fees + invoicing with income-account
  auto-wiring), gradebook (assignments/grades), library, facilities (encoach.asset),
  student leave, result templates + marksheets (incl. delete-with-cascade).
- Support: `encoach.ticket` model + CRUD/assignee routes; payment records derived
  from `op.student.fees.details` and `account.move`; platform settings backed by
  `encoach.code` and `ir.config_parameter` (packages + grading config).
- Training: `encoach.vocab.item` + `encoach.grammar.rule` (plus progress models)
  with CRUD, pagination, search/level filters, and upsert-style progress endpoints.
  Odoo 19 compatibility: `_sql_constraints` replaced with `@api.constrains`;
  `ValidationError`/`UserError` mapped to HTTP 400.

Frontend:
- Rewire institutional admin pages (Academic Year Manager, Admissions, Courses,
  Lessons, Fees, Gradebook, Library, Facilities, Student Leave, Marksheets,
  Taxonomy, Resources) to real APIs with React Query invalidation and dialogs.
- New typed services: `payments.service.ts`, `platformSettings.service.ts`,
  `training.service.ts`. Updated `fees/gradebook/lms/courseware/taxonomy/
  resources/student-progress/generation` services + related types.
- Rewrite `VocabularyPage`, `GrammarPage`, `PaymentRecordPage`, `SettingsPage`,
  `TicketsPage` to consume live data with search/filter/progress/CRUD flows.
- New shared components: `TaxonomyCascade`, `MaterialViewer`, `teacher/TeacherLibrary`.
- Favicons/branding assets and misc. UX polish across teacher/student pages.

Tooling & QA:
- Seeders: `seed_demo.py`, `seed_demo_data.py`, `seed_institutional.py` (idempotent,
  covers institutional + support + training fixtures incl. income-account wiring).
- API write-flow test suites: `test_write_flows.py` (institutional),
  `test_support_flows.py` (support), `test_training_flows.py` (training),
  `test_ai_full.py`. All suites pass end-to-end.
- Docs: add `docs/PROJECT_SUMMARY.md` with per-section scope, artifacts and QA.
- `.gitignore`: ignore `pgdata_bak_*/`, `frontend/.vite/`, `frontend/dist/`,
  `frontend/node_modules/`.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-19 03:13:23 +04:00
parent b78124bb7b
commit 435930a827
71 changed files with 6677 additions and 1309 deletions

View File

@@ -78,24 +78,36 @@ type FormState = {
hide_assignee_details: boolean;
};
const emptyForm = (): FormState => ({
name: "",
exam_id: "",
entity_id: "",
start_date: "",
start_time: "09:00",
end_date: "",
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,
});
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() {
const [search, setSearch] = useState("");

View File

@@ -3,11 +3,12 @@ import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search, Plus, Trash2, Users } from "lucide-react";
import { useBatches, useCourses } from "@/hooks/queries";
import { Search, Plus, Trash2, Users, UserPlus, UserMinus, Pencil } from "lucide-react";
import { useBatches, useCourses, useStudents } from "@/hooks/queries";
import { lmsService } from "@/services/lms.service";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
@@ -17,49 +18,124 @@ export default function ClassroomsPage() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
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 qc = useQueryClient();
const batchesQ = useBatches({ size: 200 });
const coursesQ = useCourses({ size: 200 });
const studentsQ = useStudents({ size: 200 });
const batches = batchesQ.data?.items ?? [];
const courses = coursesQ.data?.items ?? [];
const allStudents = studentsQ.data?.items ?? [];
const createMut = useMutation({
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" }),
});
const deleteMut = useMutation({
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" }),
});
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 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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<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>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Create Classroom
</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search classrooms..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
<div className="flex gap-3 items-center">
<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 classrooms..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Badge variant="secondary">{batches.length} total</Badge>
</div>
{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">
<CardContent className="p-0">
@@ -69,29 +145,67 @@ export default function ClassroomsPage() {
<TableHead>Name</TableHead>
<TableHead>Course</TableHead>
<TableHead>Students</TableHead>
<TableHead>Capacity</TableHead>
<TableHead>Dates</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-10" />
<TableHead className="w-[140px]" />
</TableRow>
</TableHeader>
<TableBody>
{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) => (
<TableRow key={b.id}>
<TableCell className="font-medium">{b.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>{b.course_name || "—"}</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); }}>
<Trash2 className="h-4 w-4" />
<Button
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>
</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>
))}
</TableBody>
@@ -100,34 +214,234 @@ export default function ClassroomsPage() {
</Card>
)}
{/* Create Dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create Classroom</DialogTitle></DialogHeader>
<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">
<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>
<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>
</Select>
</div>
<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"><Label>End Date</Label><Input type="date" value={form.end_date} onChange={(e) => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
<div className="space-y-2">
<Label>Start Date</Label>
<Input type="date" value={form.start_date} onChange={(e) => setForm((f) => ({ ...f, start_date: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input type="date" value={form.end_date} onChange={(e) => setForm((f) => ({ ...f, end_date: e.target.value }))} />
</div>
</div>
<div className="space-y-2"><Label>Max Students</Label><Input type="number" value={form.max_students} onChange={(e) => setForm(f => ({ ...f, max_students: e.target.value }))} placeholder="30" /></div>
</div>
<DialogFooter>
<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"}
</Button>
</DialogFooter>
</DialogContent>
</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>
);
}

View File

@@ -5,91 +5,280 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Search, Plus, Building2, Trash2 } from "lucide-react";
import { useDepartments, useCreateDepartment, useDeleteDepartment } from "@/hooks/queries";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
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";
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() {
const [search, setSearch] = useState("");
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 qc = useQueryClient();
const deptsQ = useDepartments({ size: 200 });
const createMut = useCreateDepartment();
const deleteMut = useDeleteDepartment();
const entitiesQ = useQuery({
queryKey: ["entities"],
queryFn: () => entitiesService.list({ size: 200 }),
});
const depts = deptsQ.data?.items ?? deptsQ.data?.data ?? [];
const departments = Array.isArray(depts) ? depts : [];
const filtered = departments.filter(d => d.name?.toLowerCase().includes(search.toLowerCase()));
const loading = deptsQ.isLoading;
const createMut = useMutation({
mutationFn: (data: { name: string; code?: string; type?: string }) =>
entitiesService.create(data as Partial<import("@/types").Entity>),
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() {
createMut.mutate(
{ name: form.name, code: form.code || undefined },
{
onSuccess: () => { setCreateOpen(false); setForm({ name: "", code: "" }); toast({ title: "Department created" }); },
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
},
);
const updateMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) =>
entitiesService.update(id, data as Partial<import("@/types").Entity>),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["entities"] });
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Entities / Departments</h1>
<p className="text-muted-foreground">Manage organizational departments.</p>
<h1 className="text-2xl font-bold tracking-tight">Entities</h1>
<p className="text-muted-foreground">Manage organizations and entities on the platform.</p>
</div>
<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>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search departments..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
<div className="flex gap-4 items-center">
<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 entities..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Badge variant="secondary">{entities.length} total</Badge>
</div>
{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="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 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">
<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}>
<DialogContent>
<DialogHeader><DialogTitle>Create Department</DialogTitle></DialogHeader>
<DialogHeader><DialogTitle>Create Entity</DialogTitle></DialogHeader>
<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"><Label>Code</Label><Input value={form.code} onChange={(e) => setForm(f => ({ ...f, code: e.target.value }))} placeholder="DEPT01" /></div>
<div className="space-y-2">
<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>
<DialogFooter>
<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"}
</Button>
</DialogFooter>
</DialogContent>
</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>
);
}

View File

@@ -436,12 +436,60 @@ export default function GenerationPage() {
const currentState = activeModule ? getModuleState(activeModule) : null;
/**
* Build the rich "persona context" that the backend AI controller uses to
* build its system prompt. Every parameter the user has configured in the
* UI (exam mode, structure, entity, rubric, grading system, access type,
* passage category/type, module, subject) flows through here so the
* generated questions actually reflect those choices.
*/
const buildPersonaContext = useCallback(
(mod: ModuleKey): Record<string, unknown> => {
const st = getModuleState(mod);
const entityIdNum = st.entity && st.entity !== "" && st.entity !== "none"
? Number(st.entity)
: undefined;
const entityObj = entityIdNum
? entities.find((e) => e.id === entityIdNum)
: undefined;
let rubricIdNum: number | undefined;
if (st.rubricId && st.rubricId.startsWith("rubric-")) {
const parsed = Number(st.rubricId.split("-")[1]);
if (!Number.isNaN(parsed)) rubricIdNum = parsed;
}
const rubricObj = rubricIdNum
? rubrics.find((r) => r.id === rubricIdNum)
: undefined;
const structureObj = selectedStructure;
return {
module: mod,
exam_mode: examMode,
exam_title: title || undefined,
exam_label: examLabel || undefined,
structure_name: structureObj?.name,
entity_id: entityIdNum,
entity_name: entityObj?.name,
rubric_id: rubricIdNum,
rubric_name: rubricObj?.name,
grading_system: st.gradingSystem || undefined,
access_type: st.accessType || undefined,
};
},
[getModuleState, entities, rubrics, selectedStructure, examMode, title, examLabel],
);
const generatePassageMut = useMutation({
mutationFn: (params: { index: number; topic: string; difficulty: string; wordCount: number }) =>
mutationFn: (params: { index: number; topic: string; difficulty: string; wordCount: number; category: string; passageType: string }) =>
generationService.generatePassage({
...buildPersonaContext(activeModule ?? "reading"),
topic: params.topic,
difficulty: params.difficulty,
word_count: params.wordCount,
category: params.category,
passage_type: params.passageType,
}),
onSuccess: (res, vars) => {
if (!activeModule) return;
@@ -463,19 +511,36 @@ export default function GenerationPage() {
types: string[];
typeCounts: Record<string, number>;
typeInstructions: Record<string, string>;
typeDifficulties?: Record<string, string>;
passageText: string;
difficulty: string;
}) => {
const mod = params.module === "level" || params.module === "industry" ? "reading" : params.module;
const totalCount = Object.values(params.typeCounts).reduce((s, n) => s + n, 0);
const st = getModuleState(params.module);
let category: string | undefined;
let passageType: string | undefined;
if (params.module === "listening") {
const sec = st.listeningSections[params.passageIndex];
category = sec?.category;
passageType = sec?.type;
} else {
const p = st.passages[params.passageIndex];
category = p?.category;
passageType = p?.type;
}
return generationService.generateExercises(mod as "reading" | "listening" | "writing" | "speaking", {
...buildPersonaContext(params.module),
passage_index: params.passageIndex,
exercise_types: params.types,
count_per_type: totalCount,
passage_text: params.passageText,
type_counts: params.typeCounts,
type_instructions: params.typeInstructions,
type_difficulties: params.typeDifficulties,
difficulty: params.difficulty,
category,
passage_type: passageType,
} as Record<string, unknown> & { passage_index: number; exercise_types: string[] });
},
onSuccess: (res, vars) => {
@@ -536,8 +601,18 @@ export default function GenerationPage() {
});
const generateWritingMut = useMutation({
mutationFn: (params: { topic: string; difficulty: string; taskIndex: number }) =>
generationService.generateWritingInstructions({ topic: params.topic, difficulty: params.difficulty, task_type: "letter" }),
mutationFn: (params: { topic: string; difficulty: string; taskIndex: number }) => {
const st = getModuleState("writing");
const task = st.writingTasks[params.taskIndex];
return generationService.generateWritingInstructions({
...buildPersonaContext("writing"),
topic: params.topic,
difficulty: params.difficulty,
task_type: task?.type || "essay",
category: task?.category || undefined,
word_limit: task?.wordLimit,
});
},
onSuccess: (res, vars) => {
const st = getModuleState("writing");
const tasks = [...st.writingTasks];
@@ -551,8 +626,17 @@ export default function GenerationPage() {
});
const generateSpeakingMut = useMutation({
mutationFn: (params: { topics: string[]; difficulty: string; partIndex: number }) =>
generationService.generateSpeakingScript({ topics: params.topics.filter(Boolean), difficulty: params.difficulty, part: "speaking_1" }),
mutationFn: (params: { topics: string[]; difficulty: string; partIndex: number }) => {
const st = getModuleState("speaking");
const part = st.speakingParts[params.partIndex];
return generationService.generateSpeakingScript({
...buildPersonaContext("speaking"),
topics: params.topics.filter(Boolean),
difficulty: params.difficulty,
part: part?.type || "speaking_1",
category: part?.category || undefined,
});
},
onSuccess: (res, vars) => {
const r = res as Record<string, unknown>;
if (r.error) {
@@ -874,7 +958,14 @@ export default function GenerationPage() {
}} />
<Button size="sm" className="w-full text-xs" disabled={generatePassageMut.isPending}
onClick={() => {
generatePassageMut.mutate({ index: pi, topic: passage.category || "", difficulty: st.difficulty[0] || "B2", wordCount: Number(passage.divider) || 300 });
generatePassageMut.mutate({
index: pi,
topic: passage.category || "",
difficulty: st.difficulty[0] || "B2",
wordCount: Number(passage.divider) || 300,
category: passage.category || "",
passageType: passage.type || "academic",
});
}}>
{generatePassageMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Sparkles className="h-3 w-3 mr-1" />}
Generate
@@ -1126,7 +1217,13 @@ export default function GenerationPage() {
}} />
<div className="flex gap-2">
<Button size="sm" className="flex-1 text-xs" onClick={() => {
generationService.generateListeningContext({ topic: sec.category || "general conversation", section_type: sec.type })
generationService.generateListeningContext({
...buildPersonaContext("listening"),
topic: sec.category || "general conversation",
section_type: sec.type,
category: sec.category || undefined,
difficulty: st.difficulty[0] || "B1",
})
.then((res) => {
const r = res as Record<string, unknown>;
const sections = [...st.listeningSections];
@@ -2680,9 +2777,11 @@ export default function GenerationPage() {
const types = wizardEnabledTypes.map(([key]) => key);
const typeCounts: Record<string, number> = {};
const typeInstructions: Record<string, string> = {};
const typeDifficulties: Record<string, string> = {};
for (const [key, cfg] of wizardEnabledTypes) {
typeCounts[key] = cfg.count;
if (cfg.instructions.trim()) typeInstructions[key] = cfg.instructions.trim();
if (cfg.difficulty) typeDifficulties[key] = cfg.difficulty;
}
const difficulty = wizardEnabledTypes[0]?.[1]?.difficulty || st.difficulty[0] || "B2";
generateExercisesMut.mutate({
@@ -2691,6 +2790,7 @@ export default function GenerationPage() {
types,
typeCounts,
typeInstructions,
typeDifficulties,
passageText: text,
difficulty,
});

View File

@@ -1,31 +1,119 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
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 { useToast } from "@/hooks/use-toast";
import { trainingService, type CefrLevel } from "@/services/training.service";
const grammarItems = [
{ 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 },
];
const LEVELS: (CefrLevel | "all")[] = ["all", "A1", "A2", "B1", "B2", "C1", "C2"];
export default function GrammarPage() {
const [showCompleted, setShowCompleted] = useState(false);
const completed = grammarItems.filter(g => g.completed).length;
const filtered = showCompleted ? grammarItems : grammarItems.filter(g => !g.completed);
const { toast } = useToast();
const qc = useQueryClient();
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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Grammar Training</h1>
<p className="text-muted-foreground">Master grammar rules essential for IELTS.</p>
<div className="flex items-center justify-between">
<div>
<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>
<AiTipBanner context="grammar" variant="recommendation" />
@@ -36,31 +124,104 @@ export default function GrammarPage() {
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<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>
</CardHeader>
<CardContent>
<Progress value={(completed / grammarItems.length) * 100} className="h-3" />
<Progress value={summary?.completion_rate ?? 0} className="h-3" />
</CardContent>
</Card>
<div className="flex items-center gap-2">
<Switch id="show" checked={showCompleted} onCheckedChange={setShowCompleted} />
<Label htmlFor="show" className="text-sm">Show completed</Label>
<div className="flex flex-wrap gap-3 items-center">
<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 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 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) => (
<Card key={g.rule} className="border-0 shadow-sm">
<CardContent className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
{g.completed && <CheckCircle className="h-4 w-4 text-success shrink-0" />}
<div>
<p className="font-semibold text-sm">{g.rule}</p>
<p className="text-xs text-muted-foreground">{g.description}</p>
</div>
<Card key={g.id} className="border-0 shadow-sm">
<CardContent className="p-4 flex items-center justify-between gap-3">
<button
onClick={() =>
progressMut.mutate({ id: g.id, completed: !g.completed })
}
className="shrink-0 rounded-full p-0.5 hover:bg-muted transition"
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>
<Badge variant="outline">{g.level}</Badge>
</CardContent>
</Card>
))}
@@ -70,25 +231,133 @@ export default function GrammarPage() {
<div className="space-y-4">
<Card className="border-0 shadow-sm">
<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>
<CardContent>
<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"><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 sentence structures for C1 readiness</li>
<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 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">
<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>
</CardContent>
</Card>
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
<CardContent className="p-4">
<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>
<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>
</CardContent>
</Card>
{summary && (
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
<CardContent className="p-4">
<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>
<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>
<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>
);
}

View File

@@ -89,7 +89,7 @@ export default function Login() {
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
type="text"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}

View File

@@ -1,57 +1,104 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Plus, Download } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Download, Loader2 } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import AiReportNarrative from "@/components/ai/AiReportNarrative";
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 },
];
import { paymentsService } from "@/services/payments.service";
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<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 className="flex gap-2">
<Button variant="outline" size="sm"><Download className="h-4 w-4 mr-1" /> Export CSV</Button>
<Dialog>
<DialogTrigger asChild>
<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>
<Button variant="outline" size="sm" onClick={handleExport} disabled={!payments.length}>
<Download className="h-4 w-4 mr-1" /> Export CSV
</Button>
</div>
</div>
{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">
@@ -67,20 +114,51 @@ export default function PaymentRecordPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead><TableHead>Corporate</TableHead><TableHead>Manager</TableHead>
<TableHead>Amount</TableHead><TableHead>Type</TableHead><TableHead>Paid</TableHead><TableHead>Date</TableHead>
<TableHead>Ref</TableHead>
<TableHead>Student</TableHead>
<TableHead>Course</TableHead>
<TableHead>Product</TableHead>
<TableHead>Amount</TableHead>
<TableHead>State</TableHead>
<TableHead>Paid</TableHead>
<TableHead>Date</TableHead>
</TableRow>
</TableHeader>
<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) => (
<TableRow key={p.id}>
<TableCell className="font-mono text-xs">{p.id}</TableCell>
<TableCell>{p.corporate}</TableCell>
<TableCell>{p.manager}</TableCell>
<TableCell className="font-semibold">${p.amount.toLocaleString()}</TableCell>
<TableCell><Badge variant="outline">{p.type}</Badge></TableCell>
<TableCell><Badge variant={p.paid ? "default" : "destructive"}>{p.paid ? "Paid" : "Unpaid"}</Badge></TableCell>
<TableCell>{p.date}</TableCell>
<TableCell className="font-mono text-xs">{p.ref}</TableCell>
<TableCell>{p.student_name || "—"}</TableCell>
<TableCell>{p.course_name || "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{p.product_name || "—"}
</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>
))}
</TableBody>
@@ -91,26 +169,50 @@ export default function PaymentRecordPage() {
<TabsContent value="paymob" className="mt-4">
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Order ID</TableHead><TableHead>Status</TableHead><TableHead>User</TableHead>
<TableHead>Email</TableHead><TableHead>Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paymobOrders.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>
<CardContent className="p-6">
{(paymobData?.items?.length ?? 0) === 0 ? (
<div className="text-center text-muted-foreground py-8">
{paymobData?.message ??
"Paymob integration is not yet wired. Orders will appear here once configured."}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Order ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>User</TableHead>
<TableHead>Email</TableHead>
<TableHead>Amount</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{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>
</Card>
</TabsContent>

View File

@@ -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 { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Plus, Trash2, Copy } from "lucide-react";
import {
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";
const codes = [
{ code: "ENCOACH-2025-A1B2", type: "Single", used: false, created: "2025-01-10" },
{ code: "ENCOACH-2025-C3D4", type: "Single", used: true, created: "2025-01-12" },
{ code: "BATCH-2025-E5F6", type: "Batch", used: false, created: "2025-02-01" },
{ code: "BATCH-2025-G7H8", type: "Batch", used: false, created: "2025-02-01" },
];
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 },
];
import { useToast } from "@/hooks/use-toast";
import {
platformSettingsService,
type GradingConfig,
type PlatformPackage,
type RegistrationCode,
} from "@/services/platformSettings.service";
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 (
<div className="space-y-6">
<div>
<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>
<Tabs defaultValue="codes">
@@ -37,26 +146,107 @@ export default function SettingsPage() {
<TabsTrigger value="grading">Grading System</TabsTrigger>
</TabsList>
{/* CODES */}
<TabsContent value="codes" className="mt-4 space-y-4">
<AiTipBanner context="settings-codes" variant="insight" />
<div className="flex gap-2">
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Generate Single</Button>
<Button size="sm" variant="outline"><Copy className="h-4 w-4 mr-1" /> Generate Batch</Button>
<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>
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<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>
<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) => (
<TableRow key={c.code}>
<TableRow key={c.id}>
<TableCell className="font-mono text-xs">{c.code}</TableCell>
<TableCell><Badge variant="outline">{c.type}</Badge></TableCell>
<TableCell><Badge variant={c.used ? "secondary" : "default"}>{c.used ? "Used" : "Available"}</Badge></TableCell>
<TableCell>{c.created}</TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"><Trash2 className="h-4 w-4" /></Button></TableCell>
<TableCell>
<Badge variant="outline">{c.code_type}</Badge>
</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>
))}
</TableBody>
@@ -65,35 +255,186 @@ export default function SettingsPage() {
</Card>
</TabsContent>
{/* PACKAGES */}
<TabsContent value="packages" className="mt-4 space-y-4">
<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">
{pkgQ.isLoading && <Loader2 className="h-5 w-5 animate-spin" />}
{packages.map((p) => (
<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>
<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>
<CardContent className="space-y-2">
<p className="text-2xl font-bold">${p.price}</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>
</Card>
))}
</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>
{/* GRADING */}
<TabsContent value="grading" className="mt-4 space-y-4">
<AiTipBanner context="settings-grading" variant="tip" />
<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">
<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"><Label>Max Score</Label><Input type="number" defaultValue="9" /></div>
<div className="space-y-2">
<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 className="space-y-2"><Label>Score Increment</Label><Input type="number" defaultValue="0.5" step="0.5" /></div>
<Button>Save Grading Configuration</Button>
<div className="space-y-2">
<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>
</Card>
</TabsContent>

View File

@@ -33,7 +33,7 @@ export default function TicketsPage() {
}),
});
const tickets: Ticket[] = data?.data ?? [];
const tickets: Ticket[] = data?.items ?? data?.data ?? [];
const createMut = useMutation({
mutationFn: ticketsService.create,

View File

@@ -3,151 +3,153 @@ import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Search, Plus, Download, MoreHorizontal } from "lucide-react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { useStudents, useTeachers, useCreateStudent, useCreateTeacher } from "@/hooks/queries";
import { lmsService } from "@/services/lms.service";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Search, Plus, Download, Shield, Pencil, UserCog } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { useToast } from "@/hooks/use-toast";
import { ApiError } from "@/lib/api-client";
function messageFromCreateError(err: unknown): string {
if (err instanceof ApiError && err.data && typeof err.data === "object" && err.data !== null && "error" in err.data) {
const e = (err.data as { error: unknown }).error;
if (e != null && String(e).trim()) return String(e);
}
if (err instanceof Error) return err.message;
return "Something went wrong.";
interface PlatformUser {
id: number;
name: string;
email: string;
login: string;
user_type: string;
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" } {
const msg = messageFromCreateError(err).toLowerCase();
const isDuplicate =
msg.includes("already exists") ||
msg.includes("duplicate") ||
msg.includes("unique") ||
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" };
interface RoleInfo {
id: number;
name: string;
description: string;
assigned: boolean;
permission_count: number;
}
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() {
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
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 qc = useQueryClient();
const studentsQ = useStudents({ search: search || undefined, size: 200 });
const teachersQ = useTeachers({ search: search || undefined, size: 200 });
const createStudentMut = useCreateStudent();
const createTeacherMut = useCreateTeacher();
const students = studentsQ.data?.items ?? [];
const teachers = teachersQ.data?.items ?? [];
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 usersQ = useQuery({
queryKey: ["platform-users", search],
queryFn: async () => {
const params: Record<string, string | number> = { size: 200 };
if (search) params.search = search;
const res = await api.get<{ items?: PlatformUser[]; data?: PlatformUser[]; total: number }>("/users/list", params);
return (res.items ?? res.data ?? []) as PlatformUser[];
},
});
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: () => {
qc.invalidateQueries({ queryKey: ["lms"] });
toast({ title: "Done" });
qc.invalidateQueries({ queryKey: ["platform-users"] });
setCreateOpen(false);
setForm({ name: "", email: "", password: "", phone: "" });
toast({ title: "User created" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
function handleCreate() {
const email = form.email.trim();
const first_name = form.first_name.trim();
const last_name = form.last_name.trim();
const emailKey = email.toLowerCase();
const dupStudent = students.some((s) => (s.email || "").trim().toLowerCase() === emailKey);
const dupTeacher = teachers.some((t) => (t.email || "").trim().toLowerCase() === emailKey);
if (dupStudent || dupTeacher) {
toast({
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,
);
}
}
const updateMut = useMutation({
mutationFn: (data: { id: number; name?: string; email?: string; phone?: string }) =>
api.patch<{ data: PlatformUser }>("/users/update", data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["platform-users"] });
setEditUser(null);
toast({ title: "User updated" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
function resetForm() {
setForm({ first_name: "", last_name: "", email: "", role: "student", phone: "", gender: "" });
}
const toggleRoleMut = useMutation({
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() {
const rows = [["Name", "Email", "Role", "Status"]];
students.forEach(s => rows.push([s.name, s.email, "Student", s.status || "active"]));
teachers.forEach(t => rows.push([t.name, t.email, "Teacher", "active"]));
const csv = rows.map(r => r.join(",")).join("\n");
const rows = [["Name", "Email", "Type", "Roles", "Permissions"]];
users.forEach((u) =>
rows.push([
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 url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "users_export.csv";
a.download = "platform_users.csv";
a.click();
URL.revokeObjectURL(url);
}
const isPending = createStudentMut.isPending || createTeacherMut.isPending;
const loading = studentsQ.isLoading || teachersQ.isLoading;
const rolesData = rolesQ.data as { roles: RoleInfo[]; effective_permissions: unknown[] } | null;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<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 className="flex gap-2">
<Button variant="outline" size="sm" onClick={exportCsv}>
@@ -159,177 +161,254 @@ export default function UsersPage() {
</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">
<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>
<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>
{loading ? (
{usersQ.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>
) : (
<Tabs defaultValue="students">
<TabsList>
<TabsTrigger value="students">Students ({students.length})</TabsTrigger>
<TabsTrigger value="teachers">Teachers ({teachers.length})</TabsTrigger>
</TabsList>
<TabsContent value="students" 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>Batch</TableHead>
<TableHead>Status</TableHead>
<TableHead>Enrollment</TableHead>
<TableHead className="w-10" />
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Type</TableHead>
<TableHead>Roles</TableHead>
<TableHead>Permissions</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[120px]" />
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
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>
</TableHeader>
<TableBody>
{students.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No students found.</TableCell></TableRow>
)}
{students.map((s) => (
<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>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<Dialog open={createOpen} onOpenChange={(v) => { setCreateOpen(v); if (!v) resetForm(); }}>
{/* Create User Dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<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="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>First Name</Label>
<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 className="space-y-2">
<Label>Full Name</Label>
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. John Admin" />
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input type="email" value={form.email} onChange={(e) => setForm(f => ({ ...f, email: e.target.value }))} placeholder="john@email.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>
<Label>Email (login)</Label>
<Input type="email" value={form.email} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} placeholder="admin@encoach.com" />
</div>
<div className="space-y-2">
<Label>Phone</Label>
<Input value={form.phone} onChange={(e) => setForm(f => ({ ...f, phone: e.target.value }))} placeholder="+1234567890" />
<Label>Password</Label>
<Input type="password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} placeholder="Minimum 6 characters" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Role</Label>
<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 className="space-y-2">
<Label>Phone (optional)</Label>
<Input value={form.phone} onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))} placeholder="+1234567890" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setCreateOpen(false); resetForm(); }}>Cancel</Button>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button
disabled={isPending || !form.first_name.trim() || !form.last_name.trim() || !form.email.trim()}
onClick={handleCreate}
disabled={createMut.isPending || !form.name.trim() || !form.email.trim()}
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>
</DialogFooter>
</DialogContent>
</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>
);
}

View File

@@ -1,96 +1,389 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
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 { useToast } from "@/hooks/use-toast";
import {
trainingService,
type VocabItem,
type CefrLevel,
} from "@/services/training.service";
const vocabItems = [
{ 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 },
];
const LEVELS: (CefrLevel | "all")[] = ["all", "A1", "A2", "B1", "B2", "C1", "C2"];
export default function VocabularyPage() {
const [showCompleted, setShowCompleted] = useState(false);
const completed = vocabItems.filter(v => v.completed).length;
const filtered = showCompleted ? vocabItems : vocabItems.filter(v => !v.completed);
const { toast } = useToast();
const qc = useQueryClient();
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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Vocabulary Training</h1>
<p className="text-muted-foreground">Build your vocabulary for IELTS success.</p>
<div className="flex items-center justify-between">
<div>
<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>
<AiTipBanner context="vocabulary" variant="recommendation" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
{/* Summary */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<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>
</CardHeader>
<CardContent>
<Progress value={(completed / vocabItems.length) * 100} className="h-3" />
<Progress value={summary?.completion_rate ?? 0} className="h-3" />
</CardContent>
</Card>
<div className="flex items-center gap-2">
<Switch id="show" checked={showCompleted} onCheckedChange={setShowCompleted} />
<Label htmlFor="show" className="text-sm">Show completed</Label>
{/* Filters */}
<div className="flex flex-wrap gap-3 items-center">
<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>
{/* List */}
<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) => (
<Card key={v.word} className="border-0 shadow-sm">
<CardContent className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
{v.completed && <CheckCircle className="h-4 w-4 text-success shrink-0" />}
<div>
<p className="font-semibold text-sm">{v.word}</p>
<p className="text-xs text-muted-foreground">{v.meaning}</p>
</div>
<Card key={v.id} className="border-0 shadow-sm">
<CardContent className="p-4 flex items-center justify-between gap-3">
<button
onClick={() =>
progressMut.mutate({ id: v.id, completed: !v.completed })
}
className="shrink-0 rounded-full p-0.5 hover:bg-muted transition"
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>
<Badge variant="outline">{v.level}</Badge>
</CardContent>
</Card>
))}
</div>
</div>
{/* AI panel */}
<div className="space-y-4">
<Card className="border-0 shadow-sm">
<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>
<CardContent>
<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"><span className="text-primary font-bold"></span>Review C1-level vocabulary for IELTS Task 2</li>
<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>
<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>
<li className="text-sm text-muted-foreground flex items-start gap-2">
<span className="text-primary font-bold"></span>
Pair <b>coherent</b> + <b>concise</b> they share academic-writing
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>
</CardContent>
</Card>
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
<CardContent className="p-4">
<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>
<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>
</CardContent>
</Card>
{summary && (
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
<CardContent className="p-4">
<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>
<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>
{/* 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>
);
}

View File

@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
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 { Label } from "@/components/ui/label";
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>
</DialogTrigger>
<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-2">
<Label>Name</Label>

View File

@@ -256,13 +256,14 @@ export default function AdminActivities() {
Cancel
</Button>
<Button
disabled={createType.isPending}
disabled={createType.isPending || !typeName.trim()}
onClick={() =>
createType.mutate(
{ name: typeName },
{ name: typeName.trim() },
{
onSuccess: () => {
setTypeOpen(false);
setTypeName("");
toast({ title: "Created successfully" });
},
onError: (err: Error) =>

View File

@@ -1,50 +1,480 @@
import { useState } from "react";
import { useState, useEffect } 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 { 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { useCourses, useCreateCourse } from "@/hooks/queries";
import { lmsService } from "@/services";
import { useQueryClient } from "@tanstack/react-query";
import { Search, Plus, Trash2 } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCourses, useCreateCourse, useStudents, useBulkEnroll, useBatches } from "@/hooks/queries";
import { lmsService, taxonomyService, resourcesService } from "@/services";
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 AiTipBanner from "@/components/ai/AiTipBanner";
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() {
const [search, setSearch] = useState("");
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 createMut = useCreateCourse();
const { data: studentsData } = useStudents({ size: 200 });
const { data: batchesData } = useBatches({ size: 200 });
const enrollMut = useBulkEnroll();
const qc = useQueryClient();
const { toast } = useToast();
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() {
if (!form.title.trim()) return;
createMut.mutate(
{ 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 },
{
onSuccess: () => {
setCreateOpen(false);
setForm({ title: "", code: "", description: "", max_capacity: 30 });
toast({ title: "Course created" });
function openEnrollDialog(courseId: number) {
setEnrollCourseId(courseId);
setSelectedStudentIds([]);
setSelectedBatchId(null);
setEnrollTab("students");
setEnrollOpen(true);
}
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) {
if (!window.confirm(`Delete course "${title}"?`)) return;
try {
@@ -60,47 +490,268 @@ export default function AdminCourses() {
return (
<div className="space-y-6">
<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">
<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>
<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>
<Card><CardContent className="pt-6">
<Table>
<TableHeader><TableRow><TableHead>Course</TableHead><TableHead>Code</TableHead><TableHead>Level</TableHead><TableHead>Enrolled / Capacity</TableHead><TableHead>Status</TableHead><TableHead className="w-[60px]" /></TableRow></TableHeader>
<TableBody>
{filtered.map(c => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.title}</TableCell>
<TableCell>{c.code}</TableCell>
<TableCell><Badge variant="outline">{c.level}</Badge></TableCell>
<TableCell>{c.enrolled} / {c.max_capacity}</TableCell>
<TableCell><Badge variant={c.status === "active" ? "default" : "secondary"} className="capitalize">{c.status}</Badge></TableCell>
<TableCell>
<Button variant="ghost" size="icon" onClick={() => handleDelete(c.id, c.title)}><Trash2 className="h-4 w-4 text-destructive" /></Button>
</TableCell>
</TableRow>
))}
{filtered.length === 0 && <TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">No courses found.</TableCell></TableRow>}
</TableBody>
</Table>
</CardContent></Card>
<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>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Course</TableHead>
<TableHead>Subject / Tags</TableHead>
<TableHead>Difficulty</TableHead>
<TableHead>Chapters</TableHead>
<TableHead>Enrolled / Cap</TableHead>
<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>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button onClick={handleCreate} disabled={createMut.isPending}>{createMut.isPending ? "Creating..." : "Create Course"}</Button>
<Button variant="outline" onClick={() => setEnrollOpen(false)}>Cancel</Button>
<Button onClick={handleEnroll} disabled={enrollMut.isPending || !canEnroll}>
{enrollMut.isPending ? "Enrolling..." : enrollLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -5,14 +5,16 @@ 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 { useFacilities, useCreateFacility, useDeleteFacility, useAssets, useCreateAsset, useDeleteAsset } from "@/hooks/queries";
import { Search, Plus, Trash2, Building } from "lucide-react";
import { useFacilities, useCreateFacility, useUpdateFacility, useDeleteFacility, useAssets, useCreateAsset, useDeleteAsset } from "@/hooks/queries";
import { Search, Plus, Trash2, Edit, Building } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
export default function AdminFacilities() {
const [search, setSearch] = useState("");
const [section, setSection] = useState<"facilities" | "assets">("facilities");
const [facOpen, setFacOpen] = useState(false);
const [facEditOpen, setFacEditOpen] = useState(false);
const [facEditId, setFacEditId] = useState<number | null>(null);
const [assetOpen, setAssetOpen] = useState(false);
const [facForm, setFacForm] = useState({ name: "", code: "" });
const [assetForm, setAssetForm] = useState({ name: "", code: "" });
@@ -21,6 +23,7 @@ export default function AdminFacilities() {
const facQ = useFacilities();
const assetQ = useAssets();
const createFac = useCreateFacility();
const updateFac = useUpdateFacility();
const delFac = useDeleteFacility();
const createAsset = useCreateAsset();
const delAsset = useDeleteAsset();
@@ -104,21 +107,34 @@ export default function AdminFacilities() {
<TableCell className="font-medium">{f.name}</TableCell>
<TableCell>{f.code}</TableCell>
<TableCell>
<Button
size="sm"
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 className="flex gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => {
setFacEditId(f.id);
setFacForm({ name: f.name || "", code: f.code || "" });
setFacEditOpen(true);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
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>
</TableRow>
))}
@@ -179,13 +195,14 @@ export default function AdminFacilities() {
Cancel
</Button>
<Button
disabled={createFac.isPending}
disabled={createFac.isPending || !facForm.name.trim()}
onClick={() =>
createFac.mutate(
{ name: facForm.name, code: facForm.code || undefined },
{ name: facForm.name.trim(), code: facForm.code || undefined },
{
onSuccess: () => {
setFacOpen(false);
setFacForm({ name: "", code: "" });
toast({ title: "Created successfully" });
},
onError: (err: Error) =>
@@ -200,6 +217,50 @@ export default function AdminFacilities() {
</DialogContent>
</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}>
<DialogContent>
<DialogHeader>
@@ -226,13 +287,14 @@ export default function AdminFacilities() {
Cancel
</Button>
<Button
disabled={createAsset.isPending}
disabled={createAsset.isPending || !assetForm.name.trim()}
onClick={() =>
createAsset.mutate(
{ name: assetForm.name, code: assetForm.code || undefined },
{ name: assetForm.name.trim(), code: assetForm.code || undefined },
{
onSuccess: () => {
setAssetOpen(false);
setAssetForm({ name: "", code: "" });
toast({ title: "Created successfully" });
},
onError: (err: Error) =>

View File

@@ -1,40 +1,102 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useFeesPlans, useStudentFees } from "@/hooks/queries";
import { Label } from "@/components/ui/label";
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 { useToast } from "@/hooks/use-toast";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useFeesPlan } from "@/hooks/queries";
import { Search, DollarSign, CreditCard, Eye } from "lucide-react";
import { Search, DollarSign, CreditCard, Eye, FileText, Wallet, Loader2 } from "lucide-react";
import type { FeesPlan } from "@/types/fees";
function planStateBadge(state: string) {
const s = state?.toLowerCase();
if (s === "draft") return <Badge variant="secondary">{state}</Badge>;
if (s === "ongoing") return <Badge variant="default">{state}</Badge>;
if (s === "done")
return (
<Badge variant="outline" className="border-green-600 text-green-700">
{state}
</Badge>
);
return <Badge variant="outline">{state}</Badge>;
function stateBadge(state: string) {
const s = (state || "").toLowerCase();
if (s === "draft") return <Badge variant="secondary">Draft</Badge>;
if (s === "invoice")
return <Badge variant="outline" className="border-blue-500 text-blue-600">Invoiced</Badge>;
if (s === "cancel") return <Badge variant="destructive">Cancelled</Badge>;
return <Badge variant="outline" className="capitalize">{state || "—"}</Badge>;
}
function paymentBadge(state?: string) {
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() {
const [search, setSearch] = useState("");
const [section, setSection] = useState<"plans" | "student">("plans");
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 plansQ = useFeesPlans();
const feesQ = useStudentFees();
const qc = useQueryClient();
const plansQ = useFeesPlans({ q: search || undefined, size: 200 });
const feesQ = useStudentFees({ q: search || undefined, size: 200 });
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 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) {
return (
@@ -45,18 +107,27 @@ export default function AdminFees() {
}
const q = search.toLowerCase();
const filteredPlans = plans.filter(
const rows = section === "plans" ? plans : fees;
const filtered = rows.filter(
(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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Fees</h1>
<p className="text-muted-foreground">Fee plans and student fee lines.</p>
<h1 className="text-2xl font-bold flex items-center gap-2">
<DollarSign className="h-7 w-7" />
Fees &amp; 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 className="flex flex-wrap gap-2">
<Button variant={section === "plans" ? "default" : "outline"} onClick={() => setSection("plans")}>
<DollarSign className="mr-2 h-4 w-4" />
@@ -67,101 +138,174 @@ export default function AdminFees() {
Student fees
</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={section === "plans" ? "Search plans..." : "Search student fees..."}
placeholder="Search students, courses, invoice numbers..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
{section === "plans" ? (
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<Card>
<CardContent className="pt-6">
<Table>
<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>
<TableHead>#</TableHead>
<TableHead>Student</TableHead>
<TableHead>Course</TableHead>
<TableHead>Total</TableHead>
<TableHead>Paid</TableHead>
<TableHead>Remaining</TableHead>
<TableHead>State</TableHead>
<TableHead>Actions</TableHead>
<TableCell colSpan={10} className="text-center text-muted-foreground py-10">
No fees records match your search.
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{filteredPlans.map((p, i) => (
)}
{filtered.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}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{p.student_name}</TableCell>
<TableCell>{p.course_name}</TableCell>
<TableCell>{p.total_amount}</TableCell>
<TableCell>{p.paid_amount}</TableCell>
<TableCell>{p.remaining_amount}</TableCell>
<TableCell>{planStateBadge(p.state)}</TableCell>
<TableCell>
<Button size="sm" variant="ghost" onClick={() => setDetailId(p.id)}>
<TableCell className="font-medium">{p.student_name || "—"}</TableCell>
<TableCell className="text-muted-foreground">
{p.product_name || p.course_name || "—"}
</TableCell>
<TableCell className="text-xs">
{p.invoice_number ? p.invoice_number : <span className="text-muted-foreground"></span>}
</TableCell>
<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" />
</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>
</TableRow>
))}
</TableBody>
</Table>
</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>
)}
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={!!detailId} onOpenChange={(v) => { if (!v) setDetailId(null); }}>
<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 ? (
<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 ? (
<div className="space-y-2 text-sm">
<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">Total:</span> {detailQ.data.total_amount}</p>
<p><span className="font-medium">Paid:</span> {detailQ.data.paid_amount}</p>
<p><span className="font-medium">Remaining:</span> {detailQ.data.remaining_amount}</p>
<p><span className="font-medium">State:</span> {planStateBadge(detailQ.data.state)}</p>
<p><span className="font-medium">Student:</span> {detailQ.data.student_name || "—"}</p>
<p><span className="font-medium">Item / Course:</span> {detailQ.data.product_name || detailQ.data.course_name || "—"}</p>
<p><span className="font-medium">Invoice:</span> {detailQ.data.invoice_number || "Not created"}</p>
<p><span className="font-medium">Total:</span> {money(detailQ.data.total_amount, detailQ.data.currency)}</p>
<p><span className="font-medium">Paid:</span> {money(detailQ.data.paid_amount, detailQ.data.currency)}</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>
) : null}
</DialogContent>
</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>
);
}

View File

@@ -7,7 +7,7 @@ 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 { 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 { useToast } from "@/hooks/use-toast";
@@ -17,6 +17,7 @@ export default function AdminGradebook() {
const [createOpen, setCreateOpen] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [form, setForm] = useState({ name: "", course_id: "", subject_id: "", issued_date: "" });
const [drillId, setDrillId] = useState<number | null>(null);
const { toast } = useToast();
const booksQ = useGradebooks();
const assignQ = useGradingAssignments();
@@ -30,6 +31,9 @@ export default function AdminGradebook() {
const loading = section === "books" ? booksQ.isLoading : assignQ.isLoading;
const books = booksQ.data?.data ?? booksQ.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) {
return (
@@ -100,7 +104,7 @@ export default function AdminGradebook() {
</TableHeader>
<TableBody>
{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 className="font-medium">{b.student_name}</TableCell>
<TableCell>{b.course_name}</TableCell>
@@ -157,6 +161,49 @@ export default function AdminGradebook() {
</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); }}>
<DialogContent>
<DialogHeader>

View File

@@ -54,9 +54,9 @@ export default function AdminLessons() {
const openEdit = (l: Lesson) => {
setEditing(l);
setForm({
lesson_topic: l.name,
course_id: "",
batch_id: "",
lesson_topic: l.lesson_topic || l.name,
course_id: l.course_id ? String(l.course_id) : "",
batch_id: l.batch_id ? String(l.batch_id) : "",
subject_id: String(l.subject_id ?? ""),
});
setEditOpen(true);
@@ -220,6 +220,26 @@ export default function AdminLessons() {
<Label>Lesson Topic</Label>
<Input value={form.lesson_topic} onChange={(e) => setForm((f) => ({ ...f, lesson_topic: 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, 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">
<Label>Subject</Label>
<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: {
lesson_topic: 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,
},
},

View File

@@ -6,7 +6,8 @@ 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 { 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 { useToast } from "@/hooks/use-toast";
@@ -31,6 +32,8 @@ export default function AdminLibrary() {
const createMoveMut = useCreateMovement();
const returnMoveMut = useReturnMovement();
const createCardMut = useCreateCard();
const studentsQ = useStudents({ size: 500 });
const students = studentsQ.data?.items ?? [];
const loading =
tab === "media" ? mediaQ.isLoading : tab === "movements" ? movQ.isLoading : cardsQ.isLoading;
@@ -258,11 +261,11 @@ export default function AdminLibrary() {
Cancel
</Button>
<Button
disabled={createMediaMut.isPending}
disabled={createMediaMut.isPending || !mediaForm.name.trim()}
onClick={() =>
createMediaMut.mutate(
{
name: mediaForm.name,
name: mediaForm.name.trim(),
isbn: mediaForm.isbn || undefined,
author: mediaForm.author || undefined,
edition: mediaForm.edition || undefined,
@@ -270,6 +273,7 @@ export default function AdminLibrary() {
{
onSuccess: () => {
setMediaOpen(false);
setMediaForm({ name: "", isbn: "", author: "", edition: "" });
toast({ title: "Created successfully" });
},
onError: (err: Error) =>
@@ -291,17 +295,38 @@ export default function AdminLibrary() {
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Media ID</Label>
<Input type="number" value={moveForm.media_id} onChange={(e) => setMoveForm((f) => ({ ...f, media_id: e.target.value }))} />
<Label>Media</Label>
<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 className="space-y-2">
<Label>Student ID</Label>
<Input type="number" value={moveForm.student_id} onChange={(e) => setMoveForm((f) => ({ ...f, student_id: e.target.value }))} />
<Label>Student</Label>
<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>
<DialogFooter>
<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
</Button>
</DialogFooter>
@@ -312,8 +337,14 @@ export default function AdminLibrary() {
<DialogContent>
<DialogHeader><DialogTitle>Create library card</DialogTitle></DialogHeader>
<div className="space-y-2">
<Label>Student ID</Label>
<Input type="number" value={cardStudentId} onChange={(e) => setCardStudentId(e.target.value)} />
<Label>Student</Label>
<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>
<DialogFooter>
<Button variant="outline" onClick={() => setCardOpen(false)}>Cancel</Button>

View File

@@ -234,6 +234,7 @@ export default function AdminStudentLeave() {
{
onSuccess: () => {
setCreateOpen(false);
setForm({ student_id: "", leave_type: "", start_date: "", end_date: "", description: "" });
toast({ title: "Created successfully" });
},
onError: (err: Error) =>

View File

@@ -1,14 +1,27 @@
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useStudentProgressList } from "@/hooks/queries";
import { Search, TrendingUp } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
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() {
const [search, setSearch] = useState("");
const { data, isLoading } = useStudentProgressList();
const items = data?.data ?? data?.items ?? [];
const [drillId, setDrillId] = useState<number | null>(null);
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) {
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 (
<div className="space-y-6">
<div>
@@ -28,12 +38,14 @@ export default function AdminStudentProgress() {
<TrendingUp className="h-7 w-7" />
Student progress
</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 className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search students..."
placeholder="Search students or GR number..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
@@ -44,27 +56,159 @@ export default function AdminStudentProgress() {
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead className="w-12">#</TableHead>
<TableHead>Student</TableHead>
<TableHead>Total Attendance</TableHead>
<TableHead>Total Assignments</TableHead>
<TableHead>Total Marksheets</TableHead>
<TableHead>GR No.</TableHead>
<TableHead className="text-right">Attendance</TableHead>
<TableHead className="text-right">Assignments</TableHead>
<TableHead className="text-right">Marksheet lines</TableHead>
<TableHead className="text-right">Avg marks</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((r, i) => (
<TableRow key={r.id}>
{items.length === 0 && (
<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 className="font-medium">{r.student_name}</TableCell>
<TableCell>{r.total_attendance}</TableCell>
<TableCell>{r.total_assignment}</TableCell>
<TableCell>{r.total_marksheet_line}</TableCell>
<TableCell className="font-medium">{r.student_name || "—"}</TableCell>
<TableCell className="text-muted-foreground text-xs">{r.gr_no || "—"}</TableCell>
<TableCell className="text-right">
<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>
))}
</TableBody>
</Table>
</CardContent>
</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>
);
}

View File

@@ -50,7 +50,7 @@ export default function AdmissionRegisterPage() {
qc.invalidateQueries({ queryKey: ["admission-registers"] });
toast({ title: "Register created" });
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" }),
});

View File

@@ -194,11 +194,11 @@ export default function MarksheetManager() {
<TableCell className="text-muted-foreground">{tpl.result_date}</TableCell>
<TableCell>
<Badge variant={tpl.state === "result_generated" ? "default" : "outline"} className="capitalize">
{tpl.state.replace("_", " ")}
{(tpl.state ?? "draft").replace("_", " ")}
</Badge>
</TableCell>
<TableCell className="text-right">
{tpl.state === "draft" && (
{(tpl.state ?? "draft") === "draft" && (
<Button variant="ghost" size="sm" onClick={() => generateMutation.mutate(tpl.id)} disabled={generateMutation.isPending}>
<FileSpreadsheet className="mr-1 h-3 w-3" /> Generate Marksheets
</Button>
@@ -288,7 +288,7 @@ export default function MarksheetManager() {
<TableRow key={line.id}>
<TableCell className="font-medium">{line.student_name}</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>
<Badge variant={line.status === "pass" ? "default" : "destructive"} className="capitalize">{line.status}</Badge>

View File

@@ -1,17 +1,25 @@
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 { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
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 { 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 { resourcesService } from "@/services/resources.service";
import { coursewareService } from "@/services/courseware.service";
import { taxonomyService } from "@/services/taxonomy.service";
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 }> = {
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" />,
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" />,
};
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() {
const { toast } = useToast();
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = 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 [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({
queryKey: ["resources", "list", { search, resource_type: typeFilter === "all" ? undefined : typeFilter, review_status: statusFilter === "all" ? undefined : statusFilter }],
queryFn: () => resourcesService.list({ search: search || undefined, resource_type: typeFilter === "all" ? undefined : typeFilter, review_status: statusFilter === "all" ? undefined : statusFilter }),
const [showTagManager, setShowTagManager] = useState(false);
const [newTagName, setNewTagName] = useState("");
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 { 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({
mutationFn: (id: number) => resourcesService.delete(id),
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({
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" }),
});
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>) => {
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);
};
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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<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>
<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>
{/* 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>
<CardHeader>
<div className="flex items-center gap-4">
<div className="relative flex-1">
<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" />
<Input placeholder="Search all content..." 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-[140px]"><SelectValue placeholder="Type" /></SelectTrigger>
<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>
</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>
<SelectItem value="pdf">PDF</SelectItem><SelectItem value="video">Video</SelectItem><SelectItem value="link">Link</SelectItem>
<SelectItem value="article">Article</SelectItem><SelectItem value="document">Document</SelectItem><SelectItem value="audio">Audio</SelectItem>
</SelectContent>
</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>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Resource</TableHead>
<TableHead>Type</TableHead>
<TableHead>Topics</TableHead>
<TableHead>Author</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{resources.map((resource: Resource) => {
const sb = statusBadge[resource.review_status] ?? statusBadge.pending;
return (
<TableRow key={resource.id}>
<TableCell>
<div className="flex items-center gap-2">
{typeIcons[resource.resource_type] ?? <FileText className="h-4 w-4" />}
<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>
{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="resources">Library Resources ({resources.length})</TabsTrigger>
<TabsTrigger value="materials">Course Materials ({courseMaterials.length})</TabsTrigger>
</TabsList>
<TabsContent value="all">
<ContentTable resources={resources} materials={courseMaterials} showSource onDeleteResource={(id) => { if (window.confirm("Delete this resource?")) deleteMutation.mutate(id); }} onEditResource={setEditingResource} deletePending={deleteMutation.isPending} toast={toast} />
</TabsContent>
<TabsContent value="resources">
<ContentTable resources={resources} materials={[]} onDeleteResource={(id) => { if (window.confirm("Delete this resource?")) deleteMutation.mutate(id); }} onEditResource={setEditingResource} deletePending={deleteMutation.isPending} toast={toast} />
</TabsContent>
<TabsContent value="materials">
<ContentTable resources={[]} materials={courseMaterials} showCourseInfo onDeleteResource={() => {}} onEditResource={() => {}} deletePending={false} toast={toast} />
</TabsContent>
</Tabs>
)}
</CardContent>
</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>
);
}
// ── 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>
);
}

View File

@@ -1,5 +1,5 @@
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 { Input } from "@/components/ui/input";
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 { Label } from "@/components/ui/label";
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 { taxonomyService } from "@/services/taxonomy.service";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import { useNavigate } from "react-router-dom";
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() {
const { toast } = useToast();
const qc = useQueryClient();
const navigate = useNavigate();
const { data: rawSubjects, isLoading } = useSubjects();
const subjects = Array.isArray(rawSubjects) ? rawSubjects : [];
const [selectedSubjectId, setSelectedSubjectId] = useState<number | null>(null);
const { data: tree, isLoading: loadingTree } = useTaxonomyTree(selectedSubjectId ?? 0);
const [showAddSubject, setShowAddSubject] = useState(false);
const [newSubjectName, setNewSubjectName] = useState("");
const [newSubjectCode, setNewSubjectCode] = useState("");
const [showAddDomain, setShowAddDomain] = useState(false);
const [newDomainName, setNewDomainName] = useState("");
const [showAddTopic, setShowAddTopic] = useState<number | null>(null);
const [newTopicName, setNewTopicName] = useState("");
const [newTopicDifficulty, setNewTopicDifficulty] = useState("medium");
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({
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: () => {
qc.invalidateQueries({ queryKey: ["taxonomy", "subjects"] });
toast({ title: "Subject Created" });
setShowAddSubject(false);
setNewSubjectName("");
setNewSubjectCode("");
},
onSuccess: () => { invalidate(); toast({ title: "Subject Created" }); setShowAddSubject(false); setNewSubjectName(""); setNewSubjectCode(""); },
onError: () => toast({ title: "Error", description: "Failed to create subject", variant: "destructive" }),
});
const aiSuggestMutation = useMutation({
mutationFn: (domainId: number) => taxonomyService.aiSuggestTopics(domainId),
onSuccess: (data) => {
toast({ title: "AI Suggestions", description: `${data.suggestions.length} topics suggested. Review and add them.` });
},
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 updateSubjectMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<{ name: string; code: string }> }) => taxonomyService.updateSubject(id, data),
onSuccess: () => { invalidate(); toast({ title: "Subject updated" }); setEditingSubjectId(null); },
onError: () => toast({ title: "Error", description: "Failed to update subject", variant: "destructive" }),
});
const deleteSubjectMutation = useMutation({
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" }),
});
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>;
return (
@@ -115,25 +181,59 @@ export default function TaxonomyManager() {
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
{/* Subjects sidebar */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground px-1">Subjects</h3>
{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"}`}>
<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
key={s.id}
className={`w-full text-left p-3 rounded-lg border transition-colors ${selectedSubjectId === s.id ? "bg-primary/10 border-primary" : "hover:bg-accent"}`}
>
{editingSubjectId === s.id ? (
<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>
</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" />
</Button>
) : (
<>
<div className="flex items-start gap-2">
<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>
))}
{subjects.length === 0 && <p className="text-sm text-muted-foreground px-1">No subjects yet.</p>}
</div>
{/* Domain + Topic tree */}
<div className="md:col-span-3">
{!selectedSubjectId ? (
<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>
) : tree ? (
<div className="space-y-4">
<div className="flex justify-end">
<Button size="sm" variant="outline" onClick={() => setShowAddDomain(true)}>
<Plus className="h-3 w-3 mr-1" /> Add Domain
</Button>
</div>
{/* Relationship summary for selected subject */}
{(() => {
const sel = subjects.find(s => s.id === selectedSubjectId);
if (!sel) return null;
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 => (
<Collapsible key={domain.id} defaultOpen>
<Card>
<CardHeader className="pb-3">
<CollapsibleTrigger className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<ChevronRight className="h-4 w-4 transition-transform group-data-[state=open]:rotate-90" />
<Layers className="h-4 w-4 text-primary" />
<CardTitle className="text-base">{domain.name}</CardTitle>
<Badge variant="secondary">{(Array.isArray(domain.topics) ? domain.topics : []).length} topics</Badge>
</div>
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between w-full">
<CollapsibleTrigger asChild>
<div className="flex items-center gap-2 cursor-pointer select-none">
<ChevronRight className="h-4 w-4 transition-transform group-data-[state=open]:rotate-90" />
<Layers className="h-4 w-4 text-primary" />
{editingDomainId === domain.id ? (
<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}>
<Sparkles className="h-3 w-3 mr-1" /> AI Suggest
</Button>
<Button variant="ghost" size="sm" onClick={() => { setShowAddTopic(domain.id); setNewTopicName(""); }}>
<Plus className="h-3 w-3 mr-1" /> Topic
</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); }}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</CollapsibleTrigger>
</div>
</CardHeader>
<CollapsibleContent>
<CardContent className="pt-0">
<div className="space-y-2">
{showAddTopic === domain.id && (
<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}>
<SelectTrigger className="w-28 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
@@ -184,28 +341,62 @@ export default function TaxonomyManager() {
<SelectItem value="hard">Hard</SelectItem>
</SelectContent>
</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" variant="ghost" onClick={() => setShowAddTopic(null)}><X className="h-3 w-3" /></Button>
</div>
)}
{(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 className="flex items-center gap-2">
<FileText className="h-3 w-3 text-muted-foreground" />
<span className="text-sm">{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>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">~{topic.estimated_hours}h</span>
<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 key={topic.id} className="p-2 rounded border hover:bg-accent/50">
{editingTopicId === topic.id ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Input value={editTopicName} onChange={e => setEditTopicName(e.target.value)} className="h-7 text-sm flex-1" placeholder="Topic name" />
<Select value={editTopicDifficulty} onValueChange={setEditTopicDifficulty}>
<SelectTrigger className="w-28 h-7"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="easy">Easy</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="hard">Hard</SelectItem>
</SelectContent>
</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>
))}
{(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>
</CardContent>
</CollapsibleContent>
@@ -223,7 +414,7 @@ export default function TaxonomyManager() {
<DialogHeader><DialogTitle>Add Domain</DialogTitle></DialogHeader>
<div className="space-y-2">
<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 className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setShowAddDomain(false)}>Cancel</Button>

View File

@@ -5,33 +5,38 @@ import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { useAssignments } from "@/hooks/queries";
import { Upload } from "lucide-react";
import { useCourseAssignments } from "@/hooks/queries";
import { Upload, ClipboardList } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import AiWritingHelper from "@/components/ai/AiWritingHelper";
import AiTipBanner from "@/components/ai/AiTipBanner";
import type { CourseAssignment } from "@/types";
export default function StudentAssignments() {
const [submitOpen, setSubmitOpen] = useState(false);
const [selectedAssignment, setSelectedAssignment] = useState<string | null>(null);
const [selectedAssignment, setSelectedAssignment] = useState<number | null>(null);
const [draftText, setDraftText] = useState("");
const { toast } = useToast();
const { data: assignmentsData, isLoading } = useAssignments();
const assignments = assignmentsData?.items ?? [];
const { data: assignmentsData, isLoading } = useCourseAssignments();
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>;
const handleSubmit = () => {
setSubmitOpen(false);
setSelectedAssignment(null);
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 (
<div className="space-y-6">
<div>
<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>
<AiTipBanner context="student-assignments" variant="recommendation" />
@@ -39,26 +44,33 @@ export default function StudentAssignments() {
<Tabs defaultValue="all">
<TabsList>
<TabsTrigger value="all">All ({assignments.length})</TabsTrigger>
<TabsTrigger value="pending">Pending ({statusFilter("pending").length})</TabsTrigger>
<TabsTrigger value="submitted">Submitted ({statusFilter("submitted").length})</TabsTrigger>
<TabsTrigger value="graded">Graded ({statusFilter("graded").length})</TabsTrigger>
<TabsTrigger value="publish">Active ({stateFilter("publish").length})</TabsTrigger>
<TabsTrigger value="finish">Completed ({stateFilter("finish").length})</TabsTrigger>
</TabsList>
{["all", "pending", "submitted", "graded", "overdue"].map(tab => (
{["all", "publish", "finish"].map(tab => (
<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}>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="font-medium text-sm">{a.title}</p>
<p className="text-xs text-muted-foreground">{a.courseName} · Due: {a.dueDate}</p>
{a.grade !== undefined && <p className="text-xs font-medium text-primary mt-1">Grade: {a.grade}/{a.maxGrade}</p>}
{a.feedback && <p className="text-xs text-muted-foreground mt-0.5">"{a.feedback}"</p>}
<p className="font-medium text-sm">{a.name}</p>
<p className="text-xs text-muted-foreground">
{a.course_name} · Due: {a.submission_date}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Marks: {a.marks} · {a.assignment_type_name}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant={a.status === "graded" ? "default" : a.status === "overdue" ? "destructive" : "secondary"}>{a.status}</Badge>
{a.status === "pending" && (
<Badge variant={stateVariant(a.state)}>{stateLabel(a.state)}</Badge>
{a.state === "publish" && (
<Button size="sm" onClick={() => { setSelectedAssignment(a.id); setSubmitOpen(true); }}>Submit</Button>
)}
</div>

View File

@@ -1,13 +1,15 @@
import { useState } from "react";
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 { Badge } from "@/components/ui/badge";
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 { coursewareService } from "@/services/courseware.service";
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> = {
pdf: <FileText className="h-5 w-5" />,
@@ -29,6 +31,9 @@ export default function StudentChapterView() {
const completeChapter = useCompleteChapter();
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 handleDownload = async (materialId: number, name: string) => {
@@ -46,14 +51,16 @@ export default function StudentChapterView() {
};
const handleMarkComplete = () => {
completeChapter.mutate(chId, {
completeChapter.mutate({ chapterId: chId, courseId: Number(courseId) }, {
onSuccess: () => toast({ title: "Chapter Completed!" }),
onError: () => toast({ title: "Error", description: "Failed to mark complete", variant: "destructive" }),
});
};
const handleOpenMaterial = (materialId: number) => {
markViewed.mutate({ id: materialId, chapterId: chId });
const handleOpenMaterial = (material: ChapterMaterial) => {
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>;
@@ -95,7 +102,7 @@ export default function StudentChapterView() {
{materials
.sort((a, b) => a.sequence - b.sequence)
.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">
<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">
@@ -103,13 +110,23 @@ export default function StudentChapterView() {
</div>
<div className="flex-1 min-w-0">
<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>
{m.allow_download && (
<Button variant="ghost" size="icon" className="shrink-0" onClick={(e) => { e.stopPropagation(); handleDownload(m.id, m.name); }}>
<Download className="h-4 w-4" />
<div className="flex flex-col gap-1 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => { e.stopPropagation(); handleOpenMaterial(m); }}>
<Eye className="h-4 w-4" />
</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>
</CardContent>
</Card>
@@ -121,6 +138,13 @@ export default function StudentChapterView() {
</div>
)}
</div>
<MaterialViewer
material={activeMaterial}
open={viewerOpen}
onOpenChange={setViewerOpen}
onDownload={handleDownload}
/>
</div>
);
}

View File

@@ -4,30 +4,26 @@ import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { useCourses, useAssignments, useAttendance, useGrades } from "@/hooks/queries";
import { ArrowLeft, CheckCircle, Circle, FileText, Video, HelpCircle } from "lucide-react";
const iconMap = { video: Video, reading: FileText, quiz: HelpCircle };
import { useCourse, useChapters, useGrades, useCourseCompletion } from "@/hooks/queries";
import { ArrowLeft, BookOpen, Lock, ChevronRight, Play, CheckCircle2, Trophy } from "lucide-react";
export default function StudentCourseDetail() {
const { id } = useParams();
const { data: coursesData, isLoading: lc } = useCourses();
const { data: assignmentsData, isLoading: la } = useAssignments();
const { data: attendanceData, isLoading: lat } = useAttendance();
const { data: gradesData, isLoading: lg } = useGrades();
const courses = coursesData?.items ?? [];
const assignments = assignmentsData?.items ?? [];
const attendanceRecords = attendanceData ?? [];
const courseId = Number(id);
const { data: course, isLoading: lc } = useCourse(courseId);
const { data: chapters = [], isLoading: lch } = useChapters(courseId);
const { data: gradesData, isLoading: lg } = useGrades({ course_id: courseId });
const { data: completion } = useCourseCompletion(courseId);
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>;
const course = courses.find(c => c.id === id);
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>;
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 courseAttendance = attendanceRecords.filter(a => a.courseId === id);
const courseGrades = gradeRecords.filter(g => g.courseId === id);
const sortedChapters = [...chapters].sort((a, b) => a.sequence - b.sequence);
const completedChapters = completion?.chapters_completed ?? 0;
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 (
<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>
<div>
<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>
{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>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-2">
<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>
<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>
</Card>
<Tabs defaultValue="materials">
<Tabs defaultValue="chapters">
<TabsList>
<TabsTrigger value="materials">Materials</TabsTrigger>
<TabsTrigger value="assignments">Assignments ({courseAssignments.length})</TabsTrigger>
<TabsTrigger value="attendance">Attendance</TabsTrigger>
<TabsTrigger value="grades">Grades</TabsTrigger>
<TabsTrigger value="chapters">Chapters ({chapters.length})</TabsTrigger>
<TabsTrigger value="grades">Grades ({gradeRecords.length})</TabsTrigger>
<TabsTrigger value="about">About</TabsTrigger>
</TabsList>
<TabsContent value="materials" className="space-y-4 mt-4">
{course.modules.length === 0 ? (
<p className="text-muted-foreground text-sm py-8 text-center">No modules available yet.</p>
) : course.modules.map(mod => (
<Card key={mod.id}>
<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>
<TabsContent value="chapters" className="space-y-3 mt-4">
{sortedChapters.length === 0 ? (
<div className="text-center py-12">
<BookOpen className="h-10 w-10 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground text-sm">No chapters available yet.</p>
</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 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>
<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>
</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>
))}
</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>
</div>
);

View File

@@ -1,18 +1,17 @@
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
import { Link } from "react-router-dom";
import { useCourses } from "@/hooks/queries";
import { BookOpen, Users, Clock } from "lucide-react";
import { useMyEnrolledCourses } from "@/hooks/queries";
import { BookOpen, Users, Play } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
export default function StudentCourses() {
const { data: coursesData, isLoading } = useCourses();
const courses = coursesData?.items ?? [];
const { data: enrolledData, isLoading } = useMyEnrolledCourses();
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>;
const myCourses = courses.filter(c => ["c1", "c2", "c5", "c8"].includes(c.id));
return (
<div className="space-y-6">
<div>
@@ -22,37 +21,50 @@ export default function StudentCourses() {
<AiTipBanner context="student-courses" variant="recommendation" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{myCourses.map((c) => (
<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.level}</Badge>
<Badge variant="outline" className="text-xs">{c.code}</Badge>
{myCourses.length === 0 ? (
<div className="text-center py-12">
<BookOpen className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold">No Enrolled Courses</h3>
<p className="text-muted-foreground mt-1">You haven't been enrolled in any courses yet. Contact your administrator.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{myCourses.map((c) => (
<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>
<h3 className="font-semibold mt-2">{c.title}</h3>
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{c.description}</p>
</div>
<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 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.chapter_count} chapters</span>
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{c.student_count} students</span>
</div>
<Progress value={c.progress} className="h-2" />
</div>
<p className="text-xs text-muted-foreground flex items-center gap-1"><Clock className="h-3 w-3" /> {c.instructor}</p>
</CardContent>
</Card>
</Link>
))}
</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>
<Progress value={c.progress} className="h-2" />
</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>
);
}

View File

@@ -2,35 +2,39 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
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 { useCourses, useAssignments, useGrades } from "@/hooks/queries";
import { useMyEnrolledCourses, useGrades } from "@/hooks/queries";
import { useAuth } from "@/contexts/AuthContext";
import AiStudyCoach from "@/components/ai/AiStudyCoach";
import AiTipBanner from "@/components/ai/AiTipBanner";
export default function StudentDashboard() {
const { data: coursesData, isLoading: lc } = useCourses();
const { data: assignmentsData, isLoading: la } = useAssignments();
const { user } = useAuth();
const { data: enrolledData, isLoading: lc } = useMyEnrolledCourses();
const { data: gradesData, isLoading: lg } = useGrades();
const courses = coursesData?.items ?? [];
const assignments = assignmentsData?.items ?? [];
const myCourses = enrolledData?.items ?? [];
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 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 = [
{ 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: "Average Grade", value: "78%", icon: BarChart3, color: "text-success" },
{ label: "Attendance Rate", value: "94%", icon: Calendar, color: "text-info" },
{ label: "Overall Progress", value: `${avgProgress}%`, icon: ClipboardList, color: "text-warning" },
{ label: "Average Grade", value: gradeRecords.length > 0 ? `${avgGrade}%` : "N/A", icon: BarChart3, color: "text-success" },
{ label: "Total Chapters", value: String(myCourses.reduce((s, c) => s + c.chapter_count, 0)), icon: Calendar, color: "text-info" },
];
return (
<div className="space-y-6">
<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>
</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>
</CardHeader>
<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">
<div className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors">
<div className="min-w-0">
<p className="font-medium text-sm truncate">{c.title}</p>
<p className="text-xs text-muted-foreground">{c.instructor}</p>
<p className="font-medium text-sm truncate">{c.title || c.name}</p>
<p className="text-xs text-muted-foreground">{c.chapter_count} chapters · {c.total_materials} materials</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<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">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg">Upcoming Assignments</CardTitle>
<Button variant="ghost" size="sm" asChild><Link to="/student/assignments">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
<CardTitle className="text-lg">Quick Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{upcomingAssignments.map((a) => (
<div key={a.id} className="flex items-center justify-between p-2 rounded border">
<div><p className="text-sm font-medium">{a.title}</p><p className="text-xs text-muted-foreground">{a.courseName}</p></div>
<Badge variant="outline" className="text-xs">{a.dueDate}</Badge>
</div>
{myCourses.slice(0, 3).map(c => (
<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">
<Play className="h-4 w-4 text-primary shrink-0" />
<div className="flex-1 min-w-0">
<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>
</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>
</CardHeader>
<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><p className="text-sm font-medium">{g.assignmentTitle}</p><p className="text-xs text-muted-foreground">{g.courseName}</p></div>
<span className="text-sm font-bold text-primary">{g.grade}/{g.maxGrade}</span>
<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.max_grade}</span>
</div>
))}
</CardContent>

View File

@@ -8,11 +8,13 @@ import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Plus, Trash2, Loader2, FileText, Video, Image, Link2, Upload, Music } from "lucide-react";
import { useChapter, useChapterMaterials, useUploadMaterial, useDeleteMaterial } from "@/hooks/queries";
import { Plus, Trash2, Loader2, FileText, Video, Image, Link2, Upload, Music, Library, Search, CheckCircle2 } from "lucide-react";
import { useChapter, useChapterMaterials, useUploadMaterial, useDeleteMaterial, useCreateMaterialFromResource } from "@/hooks/queries";
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 type { MaterialType } from "@/types/courseware";
@@ -35,15 +37,32 @@ export default function ChapterDetail() {
const { data: materials = [], isLoading: loadingMaterials } = useChapterMaterials(chId);
const uploadMaterial = useUploadMaterial();
const deleteMaterial = useDeleteMaterial();
const fromResource = useCreateMaterialFromResource();
const fileRef = useRef<HTMLInputElement>(null);
const [showUpload, setShowUpload] = useState(false);
const [dialogTab, setDialogTab] = useState<string>("library");
const [matName, setMatName] = useState("");
const [matType, setMatType] = useState<MaterialType>("pdf");
const [matUrl, setMatUrl] = useState("");
const [matFile, setMatFile] = useState<File | null>(null);
const [allowDownload, setAllowDownload] = useState(true);
const [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({
mutationFn: ({ id, allow }: { id: number; allow: boolean }) =>
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" }),
});
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 formData = new FormData();
@@ -64,17 +87,28 @@ export default function ChapterDetail() {
uploadMaterial.mutate(
{ 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" }),
},
);
};
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) => {
deleteMaterial.mutate(
{ id, chapterId: chId },
{
onSuccess: () => toast({ title: "Material Deleted" }),
onSuccess: () => toast({ title: "Material deleted" }),
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">
<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>
<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>
<DialogContent>
<DialogHeader><DialogTitle>Upload Material</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="Material name" value={matName} onChange={e => setMatName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Type</Label>
<Select value={matType} onValueChange={v => setMatType(v as MaterialType)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="document">Document</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="audio">Audio</SelectItem>
<SelectItem value="image">Image</SelectItem>
<SelectItem value="link">Link</SelectItem>
<SelectItem value="article">Article</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>File</Label>
<Input ref={fileRef} type="file" onChange={e => setMatFile(e.target.files?.[0] ?? null)} />
</div>
<div className="space-y-2">
<Label>Or URL</Label>
<Input placeholder="https://..." value={matUrl} onChange={e => setMatUrl(e.target.value)} />
</div>
<div className="flex items-center gap-2">
<Checkbox id="allow-dl" checked={allowDownload} onCheckedChange={v => setAllowDownload(!!v)} />
<Label htmlFor="allow-dl">Allow Download</Label>
</div>
<Button className="w-full" onClick={handleUpload} disabled={uploadMaterial.isPending || !matName}>
{uploadMaterial.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Upload className="mr-2 h-4 w-4" /> Upload
</Button>
</div>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader><DialogTitle>Add Material</DialogTitle></DialogHeader>
<Tabs value={dialogTab} onValueChange={setDialogTab} className="pt-2">
<TabsList className="w-full">
<TabsTrigger value="library" className="flex-1">
<Library className="mr-2 h-4 w-4" /> From Library
</TabsTrigger>
<TabsTrigger value="upload" className="flex-1">
<Upload className="mr-2 h-4 w-4" /> Upload Manual
</TabsTrigger>
</TabsList>
{/* Tab: From Library */}
<TabsContent value="library" className="space-y-4 pt-2">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search resources…"
value={libSearch}
onChange={e => setLibSearch(e.target.value)}
className="pl-9"
/>
</div>
<Select value={libTypeFilter} onValueChange={setLibTypeFilter}>
<SelectTrigger className="w-[120px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="document">Document</SelectItem>
<SelectItem value="link">Link</SelectItem>
</SelectContent>
</Select>
</div>
<div className="max-h-[300px] overflow-y-auto border rounded-md">
{loadingResources ? (
<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>
</Dialog>
</div>
@@ -189,7 +313,7 @@ export default function ChapterDetail() {
))}
{materials.length === 0 && (
<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>
)}
</TableBody>

View File

@@ -1,23 +1,28 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useParams, useNavigate } from "react-router-dom";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Plus, GripVertical, Lock, Unlock, Trash2, Loader2, BookOpen } from "lucide-react";
import { useChapters, useCreateChapter, useDeleteChapter, useUnlockChapter, useLockChapter } from "@/hooks/queries";
import { Plus, GripVertical, Lock, Unlock, Trash2, Loader2, BookOpen, Target } from "lucide-react";
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 type { ChapterUnlockMode } from "@/types/courseware";
export default function CourseChapters() {
const { courseId } = useParams<{ courseId: string }>();
const cid = Number(courseId);
const navigate = useNavigate();
const { toast } = useToast();
const { data: chapters = [], isLoading } = useChapters(cid);
const { data: course } = useCourse(cid);
const createChapter = useCreateChapter();
const deleteChapter = useDeleteChapter();
const unlockChapter = useUnlockChapter();
@@ -28,12 +33,45 @@ export default function CourseChapters() {
const [description, setDescription] = useState("");
const [startDate, setStartDate] = useState("");
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 = () => {
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(); },
onError: () => toast({ title: "Error", description: "Failed to create chapter", variant: "destructive" }),
@@ -75,7 +113,7 @@ export default function CourseChapters() {
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Add Chapter</Button>
</DialogTrigger>
<DialogContent>
<DialogContent className="sm:max-w-[520px] max-h-[85vh] overflow-y-auto">
<DialogHeader><DialogTitle>Add New Chapter</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
@@ -86,21 +124,81 @@ export default function CourseChapters() {
<Label>Description</Label>
<Textarea placeholder="Brief description" value={description} onChange={e => setDescription(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Start Date</Label>
<Input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Unlock Mode</Label>
<Select value={unlockMode} onValueChange={v => setUnlockMode(v as ChapterUnlockMode)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="auto_date">Auto (Date)</SelectItem>
<SelectItem value="prerequisite">Prerequisite</SelectItem>
</SelectContent>
</Select>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Start Date</Label>
<Input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Unlock Mode</Label>
<Select value={unlockMode} onValueChange={v => setUnlockMode(v as ChapterUnlockMode)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="auto_date">Auto (Date)</SelectItem>
<SelectItem value="prerequisite">Prerequisite</SelectItem>
</SelectContent>
</Select>
</div>
</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}>
{createChapter.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Chapter
@@ -114,17 +212,29 @@ export default function CourseChapters() {
{chapters
.sort((a, b) => a.sequence - b.sequence)
.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">
<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 items-center gap-2">
<BookOpen className="h-4 w-4 text-primary shrink-0" />
<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 className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
{ch.start_date && <span>{new Date(ch.start_date).toLocaleDateString()}</span>}
<span>{ch.material_count} material{ch.material_count !== 1 ? "s" : ""}</span>
{(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>
<Badge variant={ch.is_unlocked ? "default" : "secondary"}>
@@ -133,7 +243,7 @@ export default function CourseChapters() {
<Button
variant="ghost"
size="icon"
onClick={() => handleToggleLock(ch.id, ch.is_unlocked)}
onClick={(e) => { e.stopPropagation(); handleToggleLock(ch.id, ch.is_unlocked); }}
disabled={unlockChapter.isPending || lockChapter.isPending}
>
{ch.is_unlocked ? <Lock className="h-4 w-4" /> : <Unlock className="h-4 w-4" />}
@@ -141,7 +251,7 @@ export default function CourseChapters() {
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(ch.id)}
onClick={(e) => { e.stopPropagation(); handleDelete(ch.id); }}
disabled={deleteChapter.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />

View File

@@ -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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { useAssignments } from "@/hooks/queries";
import { Link } from "react-router-dom";
import { useCourseAssignments } from "@/hooks/queries";
import { ClipboardList } from "lucide-react";
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
import AiGeneratorModal from "@/components/ai/AiGeneratorModal";
import AiTipBanner from "@/components/ai/AiTipBanner";
import type { CourseAssignment } from "@/types";
export default function TeacherAssignments() {
const { data: assignmentsData, isLoading } = useAssignments();
const assignments = assignmentsData?.items ?? [];
const submissions: unknown[] = [];
const { data: assignmentsData, isLoading } = useCourseAssignments();
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>;
const teacherAssignments = assignments;
const pendingSubs = (submissions as { id: string; studentName: string; submittedAt: string; status: string; feedback?: string; grade?: number }[]).filter(s => s.status === "pending");
const gradedSubs = (submissions as { id: string; studentName: string; submittedAt: string; status: string; feedback?: string; grade?: number }[]).filter(s => s.status === "graded");
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 (
<div className="space-y-6">
@@ -35,48 +35,40 @@ export default function TeacherAssignments() {
<Tabs defaultValue="all">
<TabsList>
<TabsTrigger value="all">All ({teacherAssignments.length})</TabsTrigger>
<TabsTrigger value="pending">Pending Review ({pendingSubs.length})</TabsTrigger>
<TabsTrigger value="graded">Graded ({gradedSubs.length})</TabsTrigger>
<TabsTrigger value="all">All ({assignments.length})</TabsTrigger>
<TabsTrigger value="publish">Active ({stateFilter("publish").length})</TabsTrigger>
<TabsTrigger value="finish">Completed ({stateFilter("finish").length})</TabsTrigger>
<TabsTrigger value="draft">Drafts ({stateFilter("draft").length})</TabsTrigger>
</TabsList>
<TabsContent value="all" className="mt-4 space-y-3">
{teacherAssignments.map(a => (
<Link to={`/teacher/assignments/${a.id}`} key={a.id}>
<Card className="hover:bg-muted/30 transition-colors">
{["all", "publish", "finish", "draft"].map(tab => (
<TabsContent key={tab} value={tab} className="mt-4 space-y-3">
{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} className="hover:bg-muted/30 transition-colors">
<CardContent className="pt-4 flex items-center justify-between">
<div><p className="font-medium text-sm">{a.title}</p><p className="text-xs text-muted-foreground">{a.entity_name} · Due: {a.end_date}</p></div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="capitalize text-xs">{a.state}</Badge>
<Badge variant={a.completed_count >= a.assignee_count && a.assignee_count > 0 ? "default" : "secondary"} className="capitalize">{a.state}</Badge>
<div className="min-w-0">
<p className="font-medium text-sm">{a.name}</p>
<p className="text-xs text-muted-foreground">
{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>
</CardContent>
</Card>
</Link>
))}
</TabsContent>
<TabsContent value="pending" className="mt-4 space-y-3">
{pendingSubs.map(s => (
<Card key={s.id}>
<CardContent className="pt-4 flex items-center justify-between">
<div><p className="font-medium text-sm">{s.studentName}</p><p className="text-xs text-muted-foreground">Submitted: {s.submittedAt}</p></div>
<Badge variant="secondary">Pending</Badge>
</CardContent>
</Card>
))}
</TabsContent>
<TabsContent value="graded" className="mt-4 space-y-3">
{gradedSubs.map(s => (
<Card key={s.id}>
<CardContent className="pt-4 flex items-center justify-between">
<div><p className="font-medium text-sm">{s.studentName}</p><p className="text-xs text-muted-foreground">{s.feedback}</p></div>
<span className="font-bold text-primary">{s.grade}</span>
</CardContent>
</Card>
))}
</TabsContent>
))}
</TabsContent>
))}
</Tabs>
</div>
);

View File

@@ -1,16 +1,95 @@
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useCourses } from "@/hooks/queries";
import { Link } from "react-router-dom";
import { Plus, Users, BookOpen, Archive } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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 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() {
const { data: coursesData, isLoading } = useCourses();
const courses = coursesData?.items ?? [];
const teacherCourses = courses;
const { toast } = useToast();
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
@@ -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>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{teacherCourses.map(c => (
<Card key={c.id} className="hover:shadow-md transition-shadow">
<div className="h-2 rounded-t-lg bg-primary" />
<CardContent className="pt-5 space-y-3">
<div className="flex items-center justify-between">
<Badge variant={c.status === "active" ? "default" : "secondary"} className="capitalize">{c.status}</Badge>
<span className="text-xs text-muted-foreground">{c.code}</span>
</div>
<h3 className="font-semibold">{c.title}</h3>
<p className="text-sm text-muted-foreground line-clamp-2">{c.description}</p>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{c.enrolled} students</span>
<span className="flex items-center gap-1"><BookOpen className="h-3 w-3" />{c.modules.length} modules</span>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" asChild><Link to={`/teacher/courses/${c.id}/edit`}>Edit</Link></Button>
<Button size="sm" variant="ghost" onClick={() => toast({ title: "Course archived" })}><Archive className="h-3 w-3" /></Button>
</div>
</CardContent>
</Card>
))}
</div>
{courses.length === 0 ? (
<div className="text-center py-16">
<BookOpen className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold">No Courses Yet</h3>
<p className="text-muted-foreground mt-1">Create your first course to get started.</p>
<Button className="mt-4" asChild><Link to="/teacher/courses/new"><Plus className="mr-2 h-4 w-4" /> Create Course</Link></Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map(c => <CourseCard key={c.id} course={c} />)}
</div>
)}
</div>
);
}

View 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>
);
}