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
448 lines
19 KiB
TypeScript
448 lines
19 KiB
TypeScript
import { useState } from "react";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Search, Plus, Trash2, Users, UserPlus, UserMinus, Pencil } from "lucide-react";
|
|
import { useBatches, useCourses, useStudents } from "@/hooks/queries";
|
|
import { lmsService } from "@/services/lms.service";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
|
|
export default function ClassroomsPage() {
|
|
const [search, setSearch] = useState("");
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [form, setForm] = useState({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" });
|
|
const [editOpen, setEditOpen] = useState(false);
|
|
const [editBatch, setEditBatch] = useState<{ id: number; name: string; course_id: string; start_date: string; end_date: string } | null>(null);
|
|
const [studentsOpen, setStudentsOpen] = useState(false);
|
|
const [studentsBatchId, setStudentsBatchId] = useState<number | null>(null);
|
|
const [addStudentsOpen, setAddStudentsOpen] = useState(false);
|
|
const [selectedAddIds, setSelectedAddIds] = useState<number[]>([]);
|
|
const { toast } = useToast();
|
|
const qc = useQueryClient();
|
|
|
|
const batchesQ = useBatches({ size: 200 });
|
|
const coursesQ = useCourses({ size: 200 });
|
|
const studentsQ = useStudents({ size: 200 });
|
|
const batches = batchesQ.data?.items ?? [];
|
|
const courses = coursesQ.data?.items ?? [];
|
|
const allStudents = studentsQ.data?.items ?? [];
|
|
|
|
const createMut = useMutation({
|
|
mutationFn: (data: Parameters<typeof lmsService.createBatch>[0]) => lmsService.createBatch(data),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
|
setCreateOpen(false);
|
|
setForm({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" });
|
|
toast({ title: "Classroom created" });
|
|
},
|
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
|
});
|
|
|
|
const updateMut = useMutation({
|
|
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) => lmsService.updateBatch(id, data as never),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
|
setEditOpen(false);
|
|
toast({ title: "Classroom updated" });
|
|
},
|
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
|
});
|
|
|
|
const deleteMut = useMutation({
|
|
mutationFn: (id: number) => lmsService.deleteBatch(id),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
|
toast({ title: "Deleted" });
|
|
},
|
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
|
});
|
|
|
|
const addStudentsMut = useMutation({
|
|
mutationFn: ({ batchId, studentIds }: { batchId: number; studentIds: number[] }) =>
|
|
lmsService.addStudentsToBatch(batchId, studentIds),
|
|
onSuccess: (res) => {
|
|
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
|
setAddStudentsOpen(false);
|
|
setSelectedAddIds([]);
|
|
toast({ title: `Added ${res.total} student(s)` });
|
|
},
|
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
|
});
|
|
|
|
const removeStudentMut = useMutation({
|
|
mutationFn: ({ batchId, studentIds }: { batchId: number; studentIds: number[] }) =>
|
|
lmsService.removeStudentsFromBatch(batchId, studentIds),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
|
toast({ title: "Student removed from classroom" });
|
|
},
|
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
|
});
|
|
|
|
const q = search.toLowerCase();
|
|
const filtered = batches.filter(
|
|
(b) => b.name.toLowerCase().includes(q) || b.course_name?.toLowerCase().includes(q),
|
|
);
|
|
const loading = batchesQ.isLoading;
|
|
|
|
const currentBatch = batches.find((b) => b.id === studentsBatchId);
|
|
const batchStudentIds = new Set(currentBatch?.student_ids ?? []);
|
|
const availableStudents = allStudents.filter((s) => !batchStudentIds.has(s.id));
|
|
|
|
function openStudents(batchId: number) {
|
|
setStudentsBatchId(batchId);
|
|
setStudentsOpen(true);
|
|
}
|
|
|
|
function openEdit(b: typeof batches[0]) {
|
|
setEditBatch({
|
|
id: b.id,
|
|
name: b.name,
|
|
course_id: String(b.course_id || ""),
|
|
start_date: b.start_date,
|
|
end_date: b.end_date,
|
|
});
|
|
setEditOpen(true);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight">Classrooms</h1>
|
|
<p className="text-muted-foreground">Create and manage classrooms with students.</p>
|
|
</div>
|
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
|
<Plus className="h-4 w-4 mr-1" /> Create Classroom
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex gap-3 items-center">
|
|
<div className="relative flex-1 max-w-sm">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input placeholder="Search classrooms..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
|
</div>
|
|
<Badge variant="secondary">{batches.length} total</Badge>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="flex items-center justify-center min-h-[300px]">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
|
</div>
|
|
) : (
|
|
<Card className="border-0 shadow-sm">
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Course</TableHead>
|
|
<TableHead>Students</TableHead>
|
|
<TableHead>Dates</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead className="w-[140px]" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filtered.length === 0 && (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
|
No classrooms found.
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{filtered.map((b) => (
|
|
<TableRow key={b.id}>
|
|
<TableCell className="font-medium">{b.name}</TableCell>
|
|
<TableCell>{b.course_name || "—"}</TableCell>
|
|
<TableCell>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="gap-1.5 h-7 px-2"
|
|
onClick={() => openStudents(b.id)}
|
|
>
|
|
<Users className="h-3.5 w-3.5" />
|
|
{b.student_count} students
|
|
</Button>
|
|
</TableCell>
|
|
<TableCell className="text-sm">
|
|
{b.start_date && b.end_date
|
|
? `${b.start_date} — ${b.end_date}`
|
|
: "—"}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge
|
|
variant={b.status === "active" ? "default" : "secondary"}
|
|
className="capitalize"
|
|
>
|
|
{b.status}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex gap-1">
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" title="Manage students" onClick={() => openStudents(b.id)}>
|
|
<UserPlus className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" title="Edit" onClick={() => openEdit(b)}>
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-destructive"
|
|
onClick={() => {
|
|
if (window.confirm(`Delete "${b.name}"?`)) deleteMut.mutate(b.id);
|
|
}}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Create Dialog */}
|
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
<DialogContent>
|
|
<DialogHeader><DialogTitle>Create Classroom</DialogTitle></DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>Name</Label>
|
|
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. IELTS Prep Group A" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Course</Label>
|
|
<Select value={form.course_id} onValueChange={(v) => setForm((f) => ({ ...f, course_id: v }))}>
|
|
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
|
|
<SelectContent>
|
|
{courses.map((c) => (
|
|
<SelectItem key={c.id} value={String(c.id)}>{c.title || c.code}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-2">
|
|
<Label>Start Date</Label>
|
|
<Input type="date" value={form.start_date} onChange={(e) => setForm((f) => ({ ...f, start_date: e.target.value }))} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>End Date</Label>
|
|
<Input type="date" value={form.end_date} onChange={(e) => setForm((f) => ({ ...f, end_date: e.target.value }))} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
|
<Button
|
|
disabled={createMut.isPending || !form.name}
|
|
onClick={() =>
|
|
createMut.mutate({
|
|
name: form.name,
|
|
course_id: Number(form.course_id) || undefined,
|
|
start_date: form.start_date || undefined,
|
|
end_date: form.end_date || undefined,
|
|
} as never)
|
|
}
|
|
>
|
|
{createMut.isPending ? "Creating..." : "Create"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Edit Dialog */}
|
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
|
<DialogContent>
|
|
<DialogHeader><DialogTitle>Edit Classroom</DialogTitle></DialogHeader>
|
|
{editBatch && (
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>Name</Label>
|
|
<Input
|
|
value={editBatch.name}
|
|
onChange={(e) => setEditBatch((prev) => prev ? { ...prev, name: e.target.value } : prev)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Course</Label>
|
|
<Select
|
|
value={editBatch.course_id}
|
|
onValueChange={(v) => setEditBatch((prev) => prev ? { ...prev, course_id: v } : prev)}
|
|
>
|
|
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
|
|
<SelectContent>
|
|
{courses.map((c) => (
|
|
<SelectItem key={c.id} value={String(c.id)}>{c.title || c.code}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-2">
|
|
<Label>Start Date</Label>
|
|
<Input
|
|
type="date"
|
|
value={editBatch.start_date}
|
|
onChange={(e) => setEditBatch((prev) => prev ? { ...prev, start_date: e.target.value } : prev)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>End Date</Label>
|
|
<Input
|
|
type="date"
|
|
value={editBatch.end_date}
|
|
onChange={(e) => setEditBatch((prev) => prev ? { ...prev, end_date: e.target.value } : prev)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
|
|
<Button
|
|
disabled={updateMut.isPending || !editBatch?.name}
|
|
onClick={() => {
|
|
if (!editBatch) return;
|
|
updateMut.mutate({
|
|
id: editBatch.id,
|
|
data: {
|
|
name: editBatch.name,
|
|
course_id: Number(editBatch.course_id) || undefined,
|
|
start_date: editBatch.start_date || undefined,
|
|
end_date: editBatch.end_date || undefined,
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
{updateMut.isPending ? "Saving..." : "Save"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Students Management Dialog */}
|
|
<Dialog open={studentsOpen} onOpenChange={setStudentsOpen}>
|
|
<DialogContent className="max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
Classroom Students — {currentBatch?.name}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{currentBatch?.course_name && `Course: ${currentBatch.course_name}`}
|
|
{currentBatch ? ` · ${currentBatch.student_count} student(s)` : ""}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="max-h-[320px] overflow-y-auto space-y-1">
|
|
{currentBatch?.student_ids?.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-6">
|
|
No students in this classroom yet.
|
|
</p>
|
|
) : (
|
|
allStudents
|
|
.filter((s) => batchStudentIds.has(s.id))
|
|
.map((s) => (
|
|
<div key={s.id} className="flex items-center justify-between p-2 rounded hover:bg-muted/50">
|
|
<div>
|
|
<p className="text-sm font-medium">{s.name}</p>
|
|
<p className="text-xs text-muted-foreground">{s.email}</p>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 text-destructive"
|
|
onClick={() => {
|
|
if (studentsBatchId && window.confirm(`Remove ${s.name} from this classroom?`))
|
|
removeStudentMut.mutate({ batchId: studentsBatchId, studentIds: [s.id] });
|
|
}}
|
|
>
|
|
<UserMinus className="h-3.5 w-3.5 mr-1" /> Remove
|
|
</Button>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setStudentsOpen(false)}>Close</Button>
|
|
<Button
|
|
onClick={() => {
|
|
setSelectedAddIds([]);
|
|
setAddStudentsOpen(true);
|
|
}}
|
|
>
|
|
<UserPlus className="h-4 w-4 mr-1" /> Add Students
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Add Students to Batch Dialog */}
|
|
<Dialog open={addStudentsOpen} onOpenChange={setAddStudentsOpen}>
|
|
<DialogContent className="max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>Add Students to {currentBatch?.name}</DialogTitle>
|
|
<DialogDescription>
|
|
Select students to add to this classroom.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="max-h-[320px] overflow-y-auto space-y-1">
|
|
{availableStudents.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-4">
|
|
All students are already in this classroom.
|
|
</p>
|
|
) : (
|
|
availableStudents.map((s) => (
|
|
<label key={s.id} className="flex items-center gap-3 p-2 rounded hover:bg-muted/50 cursor-pointer">
|
|
<Checkbox
|
|
checked={selectedAddIds.includes(s.id)}
|
|
onCheckedChange={() =>
|
|
setSelectedAddIds((prev) =>
|
|
prev.includes(s.id) ? prev.filter((x) => x !== s.id) : [...prev, s.id],
|
|
)
|
|
}
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium truncate">{s.name}</p>
|
|
<p className="text-xs text-muted-foreground">{s.email}</p>
|
|
</div>
|
|
</label>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setAddStudentsOpen(false)}>Cancel</Button>
|
|
<Button
|
|
disabled={addStudentsMut.isPending || selectedAddIds.length === 0}
|
|
onClick={() => {
|
|
if (studentsBatchId)
|
|
addStudentsMut.mutate({ batchId: studentsBatchId, studentIds: selectedAddIds });
|
|
}}
|
|
>
|
|
{addStudentsMut.isPending
|
|
? "Adding..."
|
|
: `Add ${selectedAddIds.length} Student(s)`}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|