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:
@@ -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