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:
@@ -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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user