chore(release): sync frontend from monorepo v4
This commit is contained in:
@@ -1,91 +1,341 @@
|
||||
import { useState } from "react";
|
||||
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
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 } from "lucide-react";
|
||||
import { useAssignments, useCreateAssignment } from "@/hooks/queries";
|
||||
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({ title: "", entity_id: "", start_date: "", end_date: "" });
|
||||
const [form, setForm] = useState<FormState>(emptyForm());
|
||||
const [studentQuery, setStudentQuery] = useState("");
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const assignmentsQ = useAssignments({ size: 200 });
|
||||
const createMut = useCreateAssignment();
|
||||
const schedulesQ = useQuery({
|
||||
queryKey: ["exam-schedules", stateFilter],
|
||||
queryFn: () => assignmentsService.listSchedules(
|
||||
stateFilter === "all" ? {} : { state: stateFilter }
|
||||
),
|
||||
});
|
||||
const schedules = schedulesQ.data?.items ?? [];
|
||||
|
||||
const items = assignmentsQ.data?.items ?? assignmentsQ.data?.data ?? [];
|
||||
const assignments = Array.isArray(items) ? items : [];
|
||||
|
||||
const q = search.toLowerCase();
|
||||
const filtered = assignments.filter(a =>
|
||||
a.title?.toLowerCase().includes(q) || a.entity_name?.toLowerCase().includes(q),
|
||||
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 loading = assignmentsQ.isLoading;
|
||||
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() {
|
||||
createMut.mutate(
|
||||
{ title: form.title, entity_id: Number(form.entity_id) || 0, start_date: form.start_date, end_date: form.end_date },
|
||||
{
|
||||
onSuccess: () => { setCreateOpen(false); setForm({ title: "", entity_id: "", start_date: "", end_date: "" }); toast({ title: "Assignment created" }); },
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
},
|
||||
);
|
||||
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">Assignments</h1>
|
||||
<p className="text-muted-foreground">Create and manage assignments.</p>
|
||||
<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 size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Create Assignment
|
||||
<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 assignments..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<Input placeholder="Search schedules..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</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>
|
||||
{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>Title</TableHead>
|
||||
<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.length === 0 && (
|
||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No assignments found.</TableCell></TableRow>
|
||||
)}
|
||||
{filtered.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-medium">{a.title}</TableCell>
|
||||
<TableCell>{a.entity_name || "—"}</TableCell>
|
||||
<TableCell>{a.start_date || "—"}</TableCell>
|
||||
<TableCell>{a.end_date || "—"}</TableCell>
|
||||
<TableCell><Badge variant={a.state === "active" ? "default" : "secondary"} className="capitalize">{a.state}</Badge></TableCell>
|
||||
<TableCell>{a.assignee_count ?? 0}</TableCell>
|
||||
<TableCell>{a.completed_count ?? 0}</TableCell>
|
||||
{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>
|
||||
@@ -94,21 +344,188 @@ export default function AssignmentsPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create Schedule Dialog */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Create Assignment</DialogTitle></DialogHeader>
|
||||
<div className="space-y-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 Prep Q2" /></div>
|
||||
<div className="space-y-2"><Label>Entity ID</Label><Input type="number" value={form.entity_id} onChange={(e) => setForm(f => ({ ...f, entity_id: e.target.value }))} /></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>
|
||||
<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.title} onClick={handleCreate}>
|
||||
{createMut.isPending ? "Creating..." : "Create"}
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user