E2E QA surfaced a silent-drop bug in the exam-schedule endpoint: the frontend's student picker sends `op.student.id` values (that's what /api/students returns), but `encoach.exam.schedule.student_ids` is a Many2many → res.users. The controller was writing the op.student id straight through, so schedules linked to whichever res.users row happened to share that integer id (OdooBot, other staff, etc.) — the target student got 0 assignments and never saw the exam. - exam_schedules.py: add `_resolve_student_user_ids` and use it in the create and update endpoints. Falls back to treating unmatched ids as direct res.users ids for back-office callers. - AssignmentsPage.tsx: add a search input + larger list to the Individual Students picker; long lists were leaving Sarah unreachable inside the inner scroll container during QA. Made-with: Cursor
536 lines
23 KiB
TypeScript
536 lines
23 KiB
TypeScript
import { useState, useMemo } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import {
|
|
Search, Plus, Trash2, Loader2, Calendar, Users, Clock,
|
|
CheckCircle2, Archive, AlertTriangle, PlayCircle, Eye
|
|
} from "lucide-react";
|
|
import { assignmentsService } from "@/services/assignments.service";
|
|
import { api } from "@/lib/api-client";
|
|
import { useBatches, useStudents } from "@/hooks/queries";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import type { ExamSchedule, ScheduleState, ExamScheduleCreateRequest } from "@/types";
|
|
|
|
interface CustomExam {
|
|
id: number;
|
|
title: string;
|
|
status: string;
|
|
}
|
|
|
|
interface Entity {
|
|
id: number;
|
|
name: string;
|
|
}
|
|
|
|
const STATE_TABS: { key: ScheduleState | "all"; label: string; icon: React.ReactNode }[] = [
|
|
{ key: "all", label: "All", icon: <Eye className="h-3.5 w-3.5" /> },
|
|
{ key: "active", label: "Active", icon: <PlayCircle className="h-3.5 w-3.5" /> },
|
|
{ key: "planned", label: "Planned", icon: <Clock className="h-3.5 w-3.5" /> },
|
|
{ key: "past", label: "Past", icon: <CheckCircle2 className="h-3.5 w-3.5" /> },
|
|
{ key: "start_expired", label: "Start Expired", icon: <AlertTriangle className="h-3.5 w-3.5" /> },
|
|
{ key: "archived", label: "Archived", icon: <Archive className="h-3.5 w-3.5" /> },
|
|
];
|
|
|
|
const OPTION_FIELDS: { key: keyof ExamScheduleCreateRequest; label: string }[] = [
|
|
{ key: "full_length", label: "Full length exams" },
|
|
{ key: "generate_different", label: "Generate different exams" },
|
|
{ key: "auto_release_results", label: "Auto release results" },
|
|
{ key: "auto_start", label: "Auto start exam" },
|
|
{ key: "official_exam", label: "Official Exam" },
|
|
{ key: "hide_assignee_details", label: "Hide Assignees Details from Teachers" },
|
|
];
|
|
|
|
const stateBadgeVariant = (s: ScheduleState): "default" | "secondary" | "outline" | "destructive" => {
|
|
switch (s) {
|
|
case "active": return "default";
|
|
case "planned": return "secondary";
|
|
case "past": return "outline";
|
|
case "start_expired": return "destructive";
|
|
case "archived": return "outline";
|
|
default: return "secondary";
|
|
}
|
|
};
|
|
|
|
type FormState = {
|
|
name: string;
|
|
exam_id: string;
|
|
entity_id: string;
|
|
start_date: string;
|
|
start_time: string;
|
|
end_date: string;
|
|
end_time: string;
|
|
assign_mode: "entity" | "batch" | "individual";
|
|
batch_ids: Set<number>;
|
|
student_ids: Set<number>;
|
|
full_length: boolean;
|
|
generate_different: boolean;
|
|
auto_release_results: boolean;
|
|
auto_start: boolean;
|
|
official_exam: boolean;
|
|
hide_assignee_details: boolean;
|
|
};
|
|
|
|
const _iso = (d: Date) => {
|
|
const y = d.getFullYear();
|
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
const day = String(d.getDate()).padStart(2, "0");
|
|
return `${y}-${m}-${day}`;
|
|
};
|
|
|
|
const emptyForm = (): FormState => {
|
|
const today = new Date();
|
|
const inOneWeek = new Date();
|
|
inOneWeek.setDate(today.getDate() + 7);
|
|
return {
|
|
name: "",
|
|
exam_id: "",
|
|
entity_id: "",
|
|
start_date: _iso(today),
|
|
start_time: "09:00",
|
|
end_date: _iso(inOneWeek),
|
|
end_time: "17:00",
|
|
assign_mode: "batch",
|
|
batch_ids: new Set(),
|
|
student_ids: new Set(),
|
|
full_length: true,
|
|
generate_different: false,
|
|
auto_release_results: false,
|
|
auto_start: false,
|
|
official_exam: false,
|
|
hide_assignee_details: false,
|
|
};
|
|
};
|
|
|
|
export default function AssignmentsPage() {
|
|
const [search, setSearch] = useState("");
|
|
const [stateFilter, setStateFilter] = useState<ScheduleState | "all">("all");
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [form, setForm] = useState<FormState>(emptyForm());
|
|
const [studentQuery, setStudentQuery] = useState("");
|
|
const { toast } = useToast();
|
|
const qc = useQueryClient();
|
|
|
|
const schedulesQ = useQuery({
|
|
queryKey: ["exam-schedules", stateFilter],
|
|
queryFn: () => assignmentsService.listSchedules(
|
|
stateFilter === "all" ? {} : { state: stateFilter }
|
|
),
|
|
});
|
|
const schedules = schedulesQ.data?.items ?? [];
|
|
|
|
const customExamsQ = useQuery({
|
|
queryKey: ["custom-exams-for-assign"],
|
|
queryFn: () => api.get<{ items: CustomExam[]; total: number }>("/exam/custom/list?per_page=200"),
|
|
});
|
|
const publishedExams = (customExamsQ.data?.items ?? []).filter(
|
|
(e) => e.status === "published" || e.status === "draft"
|
|
);
|
|
|
|
const entitiesQ = useQuery({
|
|
queryKey: ["entities-for-assign"],
|
|
queryFn: () => api.get<{ items: Entity[] }>("/entities"),
|
|
});
|
|
const entities = entitiesQ.data?.items ?? [];
|
|
|
|
const batchesQ = useBatches({ size: 200 });
|
|
const batches = batchesQ.data?.items ?? [];
|
|
|
|
const studentsQ = useStudents({ size: 200 });
|
|
const students = studentsQ.data?.items ?? [];
|
|
const filteredStudents = useMemo(() => {
|
|
const q = studentQuery.trim().toLowerCase();
|
|
if (!q) return students;
|
|
return students.filter((s) =>
|
|
(s.name || "").toLowerCase().includes(q) ||
|
|
(s.email || "").toLowerCase().includes(q),
|
|
);
|
|
}, [students, studentQuery]);
|
|
|
|
const stateCounts = useMemo(() => {
|
|
const all = schedulesQ.data?.items ?? [];
|
|
const counts: Record<string, number> = { all: all.length };
|
|
for (const s of all) {
|
|
counts[s.state] = (counts[s.state] || 0) + 1;
|
|
}
|
|
return counts;
|
|
}, [schedulesQ.data]);
|
|
|
|
const allSchedulesQ = useQuery({
|
|
queryKey: ["exam-schedules", "all"],
|
|
queryFn: () => assignmentsService.listSchedules({}),
|
|
enabled: stateFilter !== "all",
|
|
});
|
|
|
|
const totalCounts = useMemo(() => {
|
|
const items = stateFilter === "all" ? schedules : (allSchedulesQ.data?.items ?? []);
|
|
const counts: Record<string, number> = { all: items.length };
|
|
for (const s of items) {
|
|
counts[s.state] = (counts[s.state] || 0) + 1;
|
|
}
|
|
return counts;
|
|
}, [stateFilter, schedules, allSchedulesQ.data]);
|
|
|
|
const createMut = useMutation({
|
|
mutationFn: (data: ExamScheduleCreateRequest) => assignmentsService.createSchedule(data),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
|
|
setCreateOpen(false);
|
|
setForm(emptyForm());
|
|
toast({ title: "Exam scheduled successfully" });
|
|
},
|
|
onError: (err: Error) => toast({ variant: "destructive", title: "Error", description: err.message }),
|
|
});
|
|
|
|
const deleteMut = useMutation({
|
|
mutationFn: (id: number) => assignmentsService.deleteSchedule(id),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
|
|
toast({ title: "Schedule deleted" });
|
|
},
|
|
onError: (err: Error) => toast({ variant: "destructive", title: "Error", description: err.message }),
|
|
});
|
|
|
|
const archiveMut = useMutation({
|
|
mutationFn: (id: number) => assignmentsService.archiveSchedule(id),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
|
|
toast({ title: "Schedule archived" });
|
|
},
|
|
});
|
|
|
|
const q = search.toLowerCase();
|
|
const filtered = schedules.filter(
|
|
(s) => s.name.toLowerCase().includes(q) || s.exam_title.toLowerCase().includes(q)
|
|
);
|
|
|
|
function handleCreate() {
|
|
const startDt = form.start_date && form.start_time ? `${form.start_date}T${form.start_time}:00` : "";
|
|
const endDt = form.end_date && form.end_time ? `${form.end_date}T${form.end_time}:00` : "";
|
|
|
|
if (!form.name || !form.exam_id || !startDt || !endDt) {
|
|
toast({ variant: "destructive", title: "Missing fields", description: "Please fill in name, exam, start date/time, and end date/time." });
|
|
return;
|
|
}
|
|
|
|
createMut.mutate({
|
|
name: form.name,
|
|
exam_id: Number(form.exam_id),
|
|
entity_id: form.entity_id ? Number(form.entity_id) : undefined,
|
|
start_date: startDt,
|
|
end_date: endDt,
|
|
assign_mode: form.assign_mode,
|
|
batch_ids: [...form.batch_ids],
|
|
student_ids: [...form.student_ids],
|
|
full_length: form.full_length,
|
|
generate_different: form.generate_different,
|
|
auto_release_results: form.auto_release_results,
|
|
auto_start: form.auto_start,
|
|
official_exam: form.official_exam,
|
|
hide_assignee_details: form.hide_assignee_details,
|
|
});
|
|
}
|
|
|
|
const updateF = (patch: Partial<FormState>) => setForm((p) => ({ ...p, ...patch }));
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
|
<Calendar className="h-6 w-6 text-primary" /> Assignments
|
|
</h1>
|
|
<p className="text-muted-foreground">Schedule exams and assign them to entities, classes, or students.</p>
|
|
</div>
|
|
<Button onClick={() => { setForm(emptyForm()); setCreateOpen(true); }}>
|
|
<Plus className="h-4 w-4 mr-1" /> Schedule Exam
|
|
</Button>
|
|
</div>
|
|
|
|
{/* State tabs */}
|
|
<div className="inline-flex items-center rounded-full border-2 border-primary bg-primary/5 p-1 gap-0">
|
|
{STATE_TABS.map((tab) => (
|
|
<button
|
|
key={tab.key}
|
|
className={`flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium transition-all ${
|
|
stateFilter === tab.key
|
|
? "bg-white text-primary shadow-sm"
|
|
: "text-primary/70 hover:text-primary"
|
|
}`}
|
|
onClick={() => setStateFilter(tab.key)}
|
|
>
|
|
{tab.icon}
|
|
{tab.label}
|
|
<span className={`text-xs ml-0.5 ${stateFilter === tab.key ? "text-primary" : "text-primary/50"}`}>
|
|
({totalCounts[tab.key] ?? 0})
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="relative max-w-sm">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input placeholder="Search schedules..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
|
</div>
|
|
|
|
{schedulesQ.isLoading ? (
|
|
<div className="flex justify-center py-12"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>
|
|
) : filtered.length === 0 ? (
|
|
<Card className="border-dashed">
|
|
<CardContent className="p-12 text-center text-muted-foreground">
|
|
<Calendar className="h-10 w-10 mx-auto mb-3 opacity-40" />
|
|
<p className="font-medium">No exam schedules found</p>
|
|
<p className="text-sm mt-1">Create a new schedule to assign exams to students.</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<Card className="border-0 shadow-sm">
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Schedule Name</TableHead>
|
|
<TableHead>Exam</TableHead>
|
|
<TableHead>Entity</TableHead>
|
|
<TableHead>Start</TableHead>
|
|
<TableHead>End</TableHead>
|
|
<TableHead>State</TableHead>
|
|
<TableHead>Assignees</TableHead>
|
|
<TableHead>Completed</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filtered.map((s) => (
|
|
<TableRow key={s.id}>
|
|
<TableCell className="font-medium">{s.name}</TableCell>
|
|
<TableCell>{s.exam_title}</TableCell>
|
|
<TableCell>{s.entity_name || "—"}</TableCell>
|
|
<TableCell className="text-xs">{s.start_date ? new Date(s.start_date).toLocaleString() : "—"}</TableCell>
|
|
<TableCell className="text-xs">{s.end_date ? new Date(s.end_date).toLocaleString() : "—"}</TableCell>
|
|
<TableCell>
|
|
<Badge variant={stateBadgeVariant(s.state)} className="capitalize">{s.state.replace("_", " ")}</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-1"><Users className="h-3.5 w-3.5 text-muted-foreground" />{s.assignee_count}</div>
|
|
</TableCell>
|
|
<TableCell>{s.completed_count}</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex justify-end gap-1">
|
|
{s.state !== "archived" && (
|
|
<Button variant="ghost" size="icon" className="h-7 w-7" title="Archive"
|
|
onClick={() => archiveMut.mutate(s.id)}>
|
|
<Archive className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" title="Delete"
|
|
onClick={() => { if (confirm(`Delete schedule "${s.name}"?`)) deleteMut.mutate(s.id); }}>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Create Schedule Dialog */}
|
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Schedule Exam</DialogTitle>
|
|
<DialogDescription>Select an exam, assign it to students, and set scheduling options.</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-5 py-2">
|
|
{/* Basic info */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-1">
|
|
<Label>Schedule Name <span className="text-destructive">*</span></Label>
|
|
<Input value={form.name} onChange={(e) => updateF({ name: e.target.value })} placeholder="e.g. Q2 IELTS Exam" />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Select Exam <span className="text-destructive">*</span></Label>
|
|
<Select value={form.exam_id} onValueChange={(v) => updateF({ exam_id: v })}>
|
|
<SelectTrigger><SelectValue placeholder="Choose exam..." /></SelectTrigger>
|
|
<SelectContent>
|
|
{publishedExams.map((e) => (
|
|
<SelectItem key={e.id} value={String(e.id)}>
|
|
{e.title} <span className="text-xs text-muted-foreground ml-1">({e.status})</span>
|
|
</SelectItem>
|
|
))}
|
|
{publishedExams.length === 0 && (
|
|
<SelectItem value="_none" disabled>No exams available</SelectItem>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Date/Time */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-semibold">Schedule Date & Time</Label>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Start Date <span className="text-destructive">*</span></Label>
|
|
<Input type="date" value={form.start_date} onChange={(e) => updateF({ start_date: e.target.value })} />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Start Time</Label>
|
|
<Input type="time" value={form.start_time} onChange={(e) => updateF({ start_time: e.target.value })} />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">End Date <span className="text-destructive">*</span></Label>
|
|
<Input type="date" value={form.end_date} onChange={(e) => updateF({ end_date: e.target.value })} />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">End Time</Label>
|
|
<Input type="time" value={form.end_time} onChange={(e) => updateF({ end_time: e.target.value })} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Options checkboxes */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-semibold">Exam Options</Label>
|
|
<div className="flex flex-wrap gap-x-6 gap-y-2">
|
|
{OPTION_FIELDS.map((opt) => (
|
|
<label key={opt.key} className="flex items-center gap-2 text-sm cursor-pointer">
|
|
<Checkbox
|
|
checked={form[opt.key] as boolean}
|
|
onCheckedChange={(checked) => updateF({ [opt.key]: !!checked } as Partial<FormState>)}
|
|
className="h-4 w-4"
|
|
/>
|
|
{opt.label}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Assignment target */}
|
|
<div className="space-y-3">
|
|
<Label className="text-sm font-semibold">Assign To</Label>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Assignment Mode</Label>
|
|
<Select value={form.assign_mode} onValueChange={(v) => updateF({ assign_mode: v as FormState["assign_mode"] })}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="entity">Entire Entity</SelectItem>
|
|
<SelectItem value="batch">Class / Batch</SelectItem>
|
|
<SelectItem value="individual">Individual Students</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Entity</Label>
|
|
<Select value={form.entity_id} onValueChange={(v) => updateF({ entity_id: v })}>
|
|
<SelectTrigger><SelectValue placeholder="Select entity..." /></SelectTrigger>
|
|
<SelectContent>
|
|
{entities.map((e) => (
|
|
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
|
|
))}
|
|
{entities.length === 0 && (
|
|
<SelectItem value="_none" disabled>No entities</SelectItem>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Batch selection */}
|
|
{form.assign_mode === "batch" && (
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Select Classes</Label>
|
|
<div className="max-h-36 overflow-y-auto border rounded-md p-2 space-y-1">
|
|
{batches.map((b) => (
|
|
<label key={b.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
|
<Checkbox
|
|
checked={form.batch_ids.has(b.id)}
|
|
onCheckedChange={(checked) => {
|
|
setForm((p) => {
|
|
const next = new Set(p.batch_ids);
|
|
checked ? next.add(b.id) : next.delete(b.id);
|
|
return { ...p, batch_ids: next };
|
|
});
|
|
}}
|
|
className="h-3.5 w-3.5"
|
|
/>
|
|
{b.name}
|
|
</label>
|
|
))}
|
|
{batches.length === 0 && <p className="text-xs text-muted-foreground italic text-center py-2">No classes available</p>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Individual student selection */}
|
|
{form.assign_mode === "individual" && (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<Label className="text-xs">Select Students</Label>
|
|
<span className="text-[11px] text-muted-foreground">
|
|
{form.student_ids.size} selected · {students.length} total
|
|
</span>
|
|
</div>
|
|
{/* Search box — needed because QA reported long student lists
|
|
made bottom entries unreachable inside the inner scroll
|
|
container. Filtering keeps the list short and predictable. */}
|
|
<Input
|
|
placeholder="Search students by name or email…"
|
|
value={studentQuery}
|
|
onChange={(e) => setStudentQuery(e.target.value)}
|
|
className="h-8 text-xs"
|
|
/>
|
|
<div className="max-h-64 overflow-y-auto border rounded-md p-2 space-y-1">
|
|
{filteredStudents.map((s) => (
|
|
<label key={s.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
|
<Checkbox
|
|
checked={form.student_ids.has(s.id)}
|
|
onCheckedChange={(checked) => {
|
|
setForm((p) => {
|
|
const next = new Set(p.student_ids);
|
|
checked ? next.add(s.id) : next.delete(s.id);
|
|
return { ...p, student_ids: next };
|
|
});
|
|
}}
|
|
className="h-3.5 w-3.5"
|
|
/>
|
|
{s.name}
|
|
{s.email && <span className="text-xs text-muted-foreground ml-auto">{s.email}</span>}
|
|
</label>
|
|
))}
|
|
{filteredStudents.length === 0 && students.length > 0 && (
|
|
<p className="text-xs text-muted-foreground italic text-center py-2">
|
|
No students match “{studentQuery}”.
|
|
</p>
|
|
)}
|
|
{students.length === 0 && <p className="text-xs text-muted-foreground italic text-center py-2">No students available</p>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
|
<Button disabled={createMut.isPending || !form.name || !form.exam_id || !form.start_date || !form.end_date} onClick={handleCreate}>
|
|
{createMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Schedule Exam"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|