Files
encoach_frontend_new_v2/src/pages/AssignmentsPage.tsx
2026-04-26 03:12:52 +04:00

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>
);
}