keeai / frontend /src /pages /AdminExams.jsx
Seth0330's picture
Update frontend/src/pages/AdminExams.jsx
35c01ba verified
// frontend/src/pages/AdminExams.jsx
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Plus,
Search,
Calendar,
DollarSign,
Users,
Clock,
Edit2,
Trash2,
ChevronDown,
ChevronRight,
Sparkles,
Brain,
UserPlus,
Send,
CheckCircle2,
AlertCircle,
X,
MapPin,
LayoutDashboard,
Users2,
FileText,
Trophy,
ShoppingBag,
Menu,
} from "lucide-react";
import { format } from "date-fns";
import api from "../api/client";
import UserMenu from "../components/UserMenu";
import dojoLogo from "../assets/dojo-logo.png";
export default function AdminExams() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState("");
const [expandedExams, setExpandedExams] = useState([]);
const [expandedBatches, setExpandedBatches] = useState([]);
const [selectedStudents, setSelectedStudents] = useState([]);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
// Forms
const [showExamForm, setShowExamForm] = useState(false);
const [showBatchForm, setShowBatchForm] = useState(false);
const [showStudentForm, setShowStudentForm] = useState(false);
const [showNotifyDialog, setShowNotifyDialog] = useState(false);
const [editingExam, setEditingExam] = useState(null);
const [editingBatch, setEditingBatch] = useState(null);
const [addingStudentTo, setAddingStudentTo] = useState(null);
const storedAdmin = JSON.parse(sessionStorage.getItem("admin") || "{}");
const adminName = storedAdmin.name || "Admin";
const adminEmail = storedAdmin.email || "admin@dojo.com";
function handleAdminLogout() {
sessionStorage.removeItem("admin");
navigate("/login");
}
// Data queries
const { data: exams = [], isLoading: examsLoading } = useQuery({
queryKey: ["exams"],
queryFn: async () => {
const res = await api.get("/admin/exams");
return Array.isArray(res.data) ? res.data : [];
},
initialData: [],
});
const { data: allStudents = [] } = useQuery({
queryKey: ["all-students"],
queryFn: async () => {
const res = await api.get("/admin/students");
return Array.isArray(res.data) ? res.data : [];
},
initialData: [],
});
// Mutations
const examMutation = useMutation({
mutationFn: async ({ id, data }) => {
if (id) {
const res = await api.put(`/admin/exams/${id}`, data);
return res.data;
} else {
const res = await api.post("/admin/exams", data);
return res.data;
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["exams"] });
setShowExamForm(false);
setEditingExam(null);
},
});
const deleteExamMutation = useMutation({
mutationFn: async (id) => {
await api.delete(`/admin/exams/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["exams"] });
},
});
const batchMutation = useMutation({
mutationFn: async ({ id, data }) => {
if (id) {
const res = await api.put(`/admin/exam-batches/${id}`, data);
return res.data;
} else {
const res = await api.post("/admin/exam-batches", data);
return res.data;
}
},
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ["exams"] });
setShowBatchForm(false);
// If it's a new batch (no id in variables), expand it
if (!variables.id && data?.id) {
setExpandedBatches((prev) => [...prev, data.id]);
}
setEditingBatch(null);
},
});
const deleteBatchMutation = useMutation({
mutationFn: async (id) => {
await api.delete(`/admin/exam-batches/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["exams"] });
},
});
const registrationMutation = useMutation({
mutationFn: async ({ id, data }) => {
if (id) {
const res = await api.put(`/admin/exam-registrations/${id}`, data);
return res.data;
} else {
const res = await api.post("/admin/exam-registrations", data);
return res.data;
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["exams"] });
setShowStudentForm(false);
setAddingStudentTo(null);
},
});
const deleteRegistrationMutation = useMutation({
mutationFn: async (id) => {
await api.delete(`/admin/exam-registrations/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["exams"] });
},
});
const notifyMutation = useMutation({
mutationFn: async (ids) => {
const res = await api.post("/admin/exam-registrations/notify", ids);
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["exams"] });
setSelectedStudents([]);
setShowNotifyDialog(false);
},
});
// Filter data
const filteredData = exams.map((exam) => {
const examBatches = (exam.batches || []).map((batch) => {
const batchRegistrations = batch.registrations || [];
const matchesSearch =
!searchQuery ||
batch.batch_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
batchRegistrations.some(
(r) =>
r.student_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.student_email?.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.current_level?.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.target_level?.toLowerCase().includes(searchQuery.toLowerCase())
);
return { ...batch, registrations: batchRegistrations, matchesSearch };
}).filter((b) => b.matchesSearch || !searchQuery);
const matchesSearch =
!searchQuery ||
exam.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
exam.location?.toLowerCase().includes(searchQuery.toLowerCase()) ||
examBatches.length > 0;
return { ...exam, batches: examBatches, matchesSearch };
}).filter((e) => e.matchesSearch);
const toggleExam = (examId) => {
setExpandedExams((prev) =>
prev.includes(examId) ? prev.filter((id) => id !== examId) : [...prev, examId]
);
};
const toggleBatch = (batchId) => {
setExpandedBatches((prev) =>
prev.includes(batchId) ? prev.filter((id) => id !== batchId) : [...prev, batchId]
);
};
const handleSelectStudent = (regId, checked) => {
setSelectedStudents((prev) =>
checked ? [...prev, regId] : prev.filter((id) => id !== regId)
);
};
const navItems = [
{ label: "Dashboard", to: "/admin", icon: LayoutDashboard },
{ label: "Classes", to: "/admin/classes", icon: Users2 },
{ label: "Exams", to: "/admin/exams", icon: FileText },
{ label: "Competitions", to: "/admin/competitions", icon: Trophy },
{ label: "Shop", to: "/admin/products", icon: ShoppingBag },
];
const handleNavClick = (to) => {
navigate(to);
setMobileNavOpen(false);
};
const isActivePath = (to) => {
return window.location.pathname === to;
};
return (
<div className="min-h-screen bg-stone-50 flex flex-col">
{/* TOP HEADER */}
<header className="border-b border-stone-100 bg-white flex items-center justify-between px-4 sm:px-6 lg:px-10 py-3">
<div className="flex items-center gap-3">
<button
type="button"
className="inline-flex md:hidden items-center justify-center h-9 w-9 rounded-full border border-stone-200 text-stone-700 hover:bg-stone-50"
onClick={() => setMobileNavOpen(true)}
aria-label="Open navigation"
>
<Menu className="w-4 h-4" />
</button>
<img
src={dojoLogo}
alt="Dojo logo"
className="h-9 w-9 rounded-full object-cover"
/>
<div className="hidden sm:block">
<div className="text-sm font-semibold text-stone-900">Arun Martial Arts</div>
<div className="text-xs text-stone-500">Admin Portal</div>
</div>
</div>
<UserMenu name={adminName} email={adminEmail} onLogout={handleAdminLogout} />
</header>
{/* BODY: sidebar + main content */}
<div className="flex flex-1">
{/* DESKTOP SIDEBAR */}
<aside className="hidden md:flex w-64 flex-col bg-white">
<nav className="flex-1 px-3 pt-4 pb-2 space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = isActivePath(item.to);
return (
<button
key={item.to}
type="button"
onClick={() => handleNavClick(item.to)}
className={`w-full flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-left ${
isActive
? "bg-rose-50 text-rose-700 font-semibold"
: "text-stone-600 hover:bg-stone-50"
}`}
>
<span
className={`inline-flex h-8 w-8 items-center justify-center rounded-xl border ${
isActive
? "border-rose-100 bg-rose-50 text-rose-600"
: "border-stone-200 bg-white text-stone-500"
}`}
>
<Icon className="w-4 h-4" />
</span>
<span>{item.label}</span>
</button>
);
})}
</nav>
<div className="px-4 pb-5">
<div className="rounded-2xl border border-rose-100 bg-rose-50 px-3 py-2">
<div className="text-[11px] font-semibold text-rose-700">Admin Mode</div>
<div className="text-[11px] text-rose-500">Full Access</div>
</div>
</div>
</aside>
{/* MAIN CONTENT */}
<div className="flex-1 border-l border-stone-100">
<main className="px-4 sm:px-6 lg:px-10 py-6 sm:py-8 pb-10">
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-stone-900">Exams Management</h1>
<p className="text-stone-600 mt-1">Manage exams, batches, and student registrations</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 bg-gradient-to-r from-purple-100 to-indigo-100 px-4 py-2 rounded-full">
<Brain className="w-5 h-5 text-purple-600" />
<span className="text-sm font-medium text-purple-700">AI Auto-Add</span>
<span className="inline-flex items-center rounded-full bg-green-500 text-white text-xs font-medium px-2 py-0.5">
Active
</span>
</div>
<button
type="button"
className="inline-flex items-center gap-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium px-4 py-2 shadow-sm"
onClick={() => {
if (showExamForm) {
setShowExamForm(false);
setEditingExam(null);
} else {
setShowExamForm(true);
setEditingExam(null);
}
}}
>
<Plus className="w-4 h-4" />
{showExamForm ? "Cancel" : "New Exam"}
</button>
</div>
</div>
{/* Search & Actions */}
<div className="mb-6 bg-white border border-stone-200 rounded-2xl shadow-sm">
<div className="px-4 sm:px-6 py-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-stone-400" />
<input
type="text"
placeholder="Search exams, batches, students, levels..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2 rounded-lg border border-stone-200 text-sm focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500"
/>
</div>
<button
type="button"
disabled={selectedStudents.length === 0}
onClick={() => setShowNotifyDialog(true)}
className="inline-flex items-center gap-2 rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Send className="w-4 h-4" />
Notify Selected ({selectedStudents.length})
</button>
</div>
</div>
</div>
{/* New Exam Form */}
{showExamForm && (
<div className="mb-6 bg-white border-2 border-red-200 rounded-2xl shadow-sm">
<div className="px-4 sm:px-6 py-4 border-b border-stone-100">
<h2 className="text-base font-semibold text-stone-900">
{editingExam?.id ? "Edit Exam" : "Create New Exam"}
</h2>
</div>
<div className="px-4 sm:px-6 py-4">
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.target);
examMutation.mutate({
id: editingExam?.id,
data: {
name: formData.get("name"),
exam_date: formData.get("exam_date"),
exam_fee: parseFloat(formData.get("exam_fee")),
registration_deadline: formData.get("registration_deadline") || null,
location: formData.get("location") || null,
description: formData.get("description") || null,
status: editingExam?.status || "upcoming",
},
});
}}
className="space-y-4"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-xs font-medium text-stone-700 mb-1">
Exam Name *
</label>
<input
type="text"
name="name"
defaultValue={editingExam?.name}
required
className="w-full rounded-lg border border-stone-200 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-700 mb-1">
Exam Date *
</label>
<input
type="date"
name="exam_date"
defaultValue={editingExam?.exam_date}
required
className="w-full rounded-lg border border-stone-200 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-700 mb-1">
Exam Fee *
</label>
<input
type="number"
name="exam_fee"
step="0.01"
defaultValue={editingExam?.exam_fee}
required
className="w-full rounded-lg border border-stone-200 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-700 mb-1">
Registration Deadline
</label>
<input
type="date"
name="registration_deadline"
defaultValue={editingExam?.registration_deadline}
className="w-full rounded-lg border border-stone-200 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-700 mb-1">
Location
</label>
<input
type="text"
name="location"
defaultValue={editingExam?.location}
className="w-full rounded-lg border border-stone-200 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500"
/>
</div>
<div className="md:col-span-2">
<label className="block text-xs font-medium text-stone-700 mb-1">
Description
</label>
<textarea
name="description"
defaultValue={editingExam?.description}
rows={3}
className="w-full rounded-lg border border-stone-200 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500"
/>
</div>
</div>
<div className="flex gap-2">
<button
type="submit"
className="inline-flex items-center justify-center rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium px-4 py-2"
>
Save Exam
</button>
<button
type="button"
onClick={() => {
setShowExamForm(false);
setEditingExam(null);
}}
className="inline-flex items-center justify-center rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50"
>
Cancel
</button>
</div>
</form>
</div>
</div>
)}
{/* Exams List */}
<div className="space-y-4">
{examsLoading ? (
<div className="bg-white border border-stone-200 rounded-2xl shadow-sm">
<div className="px-4 sm:px-6 py-12 text-center text-stone-500">
<p>Loading exams...</p>
</div>
</div>
) : filteredData.length === 0 ? (
<div className="bg-white border border-stone-200 rounded-2xl shadow-sm">
<div className="px-4 sm:px-6 py-12 text-center text-stone-500">
<Calendar className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>No exams found</p>
</div>
</div>
) : (
filteredData.map((exam) => {
const isExpanded = expandedExams.includes(exam.id);
const totalRegistrations = exam.batches.reduce(
(sum, b) => sum + (b.registrations?.length || 0),
0
);
const paidCount = exam.batches.reduce(
(sum, b) =>
sum +
(b.registrations?.filter((r) => r.payment_status === "paid").length || 0),
0
);
return (
<div key={exam.id} className="bg-white border border-stone-200 rounded-2xl shadow-sm overflow-hidden">
{/* Exam Header */}
<div
className="p-4 bg-stone-50 cursor-pointer hover:bg-stone-100 transition-colors"
onClick={() => toggleExam(exam.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<button
type="button"
className="h-8 w-8 flex items-center justify-center text-stone-600 hover:bg-stone-200 rounded"
onClick={(e) => {
e.stopPropagation();
toggleExam(exam.id);
}}
>
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-bold text-stone-900">{exam.name}</h3>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
exam.status === "upcoming"
? "bg-blue-100 text-blue-700"
: "bg-stone-100 text-stone-700"
}`}
>
{exam.status}
</span>
</div>
<div className="flex flex-wrap gap-4 text-sm text-stone-600">
<div className="flex items-center gap-1">
<Calendar className="w-4 h-4" />
{exam.exam_date ? format(new Date(exam.exam_date), "MMM d, yyyy") : "N/A"}
</div>
<div className="flex items-center gap-1">
<DollarSign className="w-4 h-4" />
${exam.exam_fee}
</div>
<div className="flex items-center gap-1">
<Users className="w-4 h-4" />
{exam.batches?.length || 0} batches • {totalRegistrations} students
</div>
<div
className={`flex items-center gap-1 ${
paidCount === totalRegistrations && totalRegistrations > 0
? "text-green-600 font-medium"
: ""
}`}
>
<CheckCircle2 className="w-4 h-4" />
{paidCount}/{totalRegistrations} paid
</div>
</div>
</div>
</div>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<button
type="button"
className="inline-flex items-center justify-center h-8 w-8 rounded-lg border border-stone-200 text-stone-700 hover:bg-stone-50"
onClick={() => {
setEditingExam(exam);
setShowExamForm(true);
}}
>
<Edit2 className="w-4 h-4" />
</button>
<button
type="button"
className="inline-flex items-center justify-center h-8 w-8 rounded-lg border border-stone-200 text-red-600 hover:bg-red-50"
onClick={() => {
if (confirm("Delete this exam and all its batches?")) {
deleteExamMutation.mutate(exam.id);
}
}}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Exam Content */}
{isExpanded && (
<div className="p-4 border-t">
{/* Exam Details */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 mb-4 text-sm">
{exam.location && (
<div className="flex items-center gap-2 text-stone-600">
<MapPin className="w-4 h-4" />
{exam.location}
</div>
)}
{exam.registration_deadline && (
<div className="text-stone-600">
Deadline: {format(new Date(exam.registration_deadline), "MMM d, yyyy")}
</div>
)}
</div>
{/* Add Batch Button */}
<button
type="button"
className="inline-flex items-center gap-2 rounded-lg border border-stone-200 px-3 py-1.5 text-sm font-medium text-stone-700 hover:bg-stone-50 mb-4"
onClick={() => {
if (showBatchForm && editingBatch?.exam_id === exam.id) {
setShowBatchForm(false);
setEditingBatch(null);
} else {
setEditingBatch({ exam_id: exam.id });
setShowBatchForm(true);
}
}}
>
<Plus className="w-4 h-4" />
{showBatchForm && editingBatch?.exam_id === exam.id ? "Cancel" : "Add Batch"}
</button>
{/* Batch Form */}
{showBatchForm && editingBatch?.exam_id === exam.id && (
<div className="mb-4 bg-blue-50 border-2 border-blue-200 rounded-xl p-4">
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.target);
batchMutation.mutate({
id: editingBatch?.id,
data: {
exam_id: editingBatch.exam_id,
batch_name: formData.get("batch_name"),
time_from: formData.get("time_from"),
time_to: formData.get("time_to"),
max_students: formData.get("max_students")
? parseInt(formData.get("max_students"))
: null,
},
});
}}
className="space-y-4"
>
<div>
<label className="block text-xs font-medium text-stone-700 mb-1">
Batch Name *
</label>
<input
type="text"
name="batch_name"
defaultValue={editingBatch?.batch_name}
required
placeholder="Batch A"
className="w-full rounded-lg border border-stone-200 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-stone-700 mb-1">
Time From *
</label>
<input
type="time"
name="time_from"
defaultValue={editingBatch?.time_from}
required
className="w-full rounded-lg border border-stone-200 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-700 mb-1">
Time To *
</label>
<input
type="time"
name="time_to"
defaultValue={editingBatch?.time_to}
required
className="w-full rounded-lg border border-stone-200 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-stone-700 mb-1">
Max Students
</label>
<input
type="number"
name="max_students"
defaultValue={editingBatch?.max_students}
placeholder="Optional"
className="w-full rounded-lg border border-stone-200 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500"
/>
</div>
<div className="flex gap-2">
<button
type="submit"
className="inline-flex items-center justify-center rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium px-4 py-2"
>
Save Batch
</button>
<button
type="button"
onClick={() => {
setShowBatchForm(false);
setEditingBatch(null);
}}
className="inline-flex items-center justify-center rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50"
>
Cancel
</button>
</div>
</form>
</div>
)}
{/* Batches */}
{!exam.batches || exam.batches.length === 0 ? (
<div className="text-center py-6 text-stone-500 text-sm">
No batches yet. Click "Add Batch" to create one.
</div>
) : (
<div className="space-y-3">
{exam.batches.map((batch) => {
const isBatchExpanded = expandedBatches.includes(batch.id);
const paidInBatch =
batch.registrations?.filter((r) => r.payment_status === "paid")
.length || 0;
return (
<div
key={batch.id}
className="border-l-4 border-l-blue-500 bg-white border border-stone-200 rounded-xl overflow-hidden"
>
{/* Batch Header */}
<div
className="p-3 cursor-pointer hover:bg-stone-50 transition-colors"
onClick={() => toggleBatch(batch.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<button
type="button"
className="h-6 w-6 flex items-center justify-center text-stone-600 hover:bg-stone-200 rounded"
onClick={(e) => {
e.stopPropagation();
toggleBatch(batch.id);
}}
>
{isBatchExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</button>
<div>
<div className="font-semibold text-stone-900">{batch.batch_name}</div>
<div className="flex items-center gap-3 text-xs text-stone-500">
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{batch.time_from} - {batch.time_to}
</div>
<div className="flex items-center gap-1">
<Users className="w-3 h-3" />
{batch.registrations?.length || 0}
{batch.max_students ? `/${batch.max_students}` : ""} students
</div>
<div
className={
paidInBatch === (batch.registrations?.length || 0) &&
(batch.registrations?.length || 0) > 0
? "text-green-600 font-medium"
: ""
}
>
{paidInBatch}/{batch.registrations?.length || 0} paid
</div>
</div>
</div>
</div>
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
<button
type="button"
className="inline-flex items-center justify-center h-7 w-7 rounded text-stone-600 hover:bg-stone-100"
onClick={() => {
if (
showStudentForm &&
addingStudentTo?.batch_id === batch.id
) {
setShowStudentForm(false);
setAddingStudentTo(null);
} else {
setAddingStudentTo({
exam_id: exam.id,
batch_id: batch.id,
});
setShowStudentForm(true);
}
}}
>
<UserPlus className="w-3 h-3" />
</button>
<button
type="button"
className="inline-flex items-center justify-center h-7 w-7 rounded text-stone-600 hover:bg-stone-100"
onClick={() => {
setEditingBatch(batch);
setShowBatchForm(true);
}}
>
<Edit2 className="w-3 h-3" />
</button>
<button
type="button"
className="inline-flex items-center justify-center h-7 w-7 rounded text-red-600 hover:bg-red-50"
onClick={() => {
if (confirm("Delete this batch?")) {
deleteBatchMutation.mutate(batch.id);
}
}}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
</div>
{/* Students in Batch */}
{isBatchExpanded && (
<div className="px-3 pb-3 border-t">
{/* Student Form */}
{showStudentForm && addingStudentTo?.batch_id === batch.id && (
<div className="mt-3 mb-3 bg-green-50 border-2 border-green-200 rounded-xl p-4">
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.target);
const studentId = formData.get("student_id");
const student = allStudents.find((s) => s.id === parseInt(studentId));
if (!student) {
alert("Please select a student");
return;
}
registrationMutation.mutate({
data: {
exam_id: addingStudentTo.exam_id,
batch_id: addingStudentTo.batch_id,
student_id: student.id,
student_name: `${student.first_name} ${student.last_name}`,
student_email: student.membership_email || "",
current_level: "Unknown", // TODO: Get from student data when belt_level is added
target_level: formData.get("target_level"),
registration_status: "invited",
added_by_ai: false,
},
});
}}
className="space-y-4"
>
<div>
<label className="block text-xs font-medium text-stone-700 mb-1">
Select Student *
</label>
<select
name="student_id"
required
className="w-full rounded-lg border border-stone-200 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500"
>
<option value="">Choose student...</option>
{allStudents.map((s) => (
<option key={s.id} value={s.id}>
{s.first_name} {s.last_name} - {s.belt_level || "No belt"}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-stone-700 mb-1">
Target Level *
</label>
<input
type="text"
name="target_level"
required
placeholder="e.g., Yellow Belt"
className="w-full rounded-lg border border-stone-200 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500"
/>
</div>
<div className="flex gap-2">
<button
type="submit"
className="inline-flex items-center justify-center rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium px-4 py-2"
>
Add Student
</button>
<button
type="button"
onClick={() => {
setShowStudentForm(false);
setAddingStudentTo(null);
}}
className="inline-flex items-center justify-center rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50"
>
Cancel
</button>
</div>
</form>
</div>
)}
{!batch.registrations || batch.registrations.length === 0 ? (
<div className="text-center py-4 text-stone-500 text-sm">
<Users className="w-8 h-8 mx-auto mb-2 opacity-30" />
<p>No students in this batch</p>
</div>
) : (
<div className="space-y-2 mt-3">
{batch.registrations.map((reg) => (
<div
key={reg.id}
className="flex items-center gap-3 p-2 bg-white rounded border border-stone-200 hover:border-stone-300 transition-colors"
>
<input
type="checkbox"
checked={selectedStudents.includes(reg.id)}
onChange={(e) =>
handleSelectStudent(reg.id, e.target.checked)
}
className="rounded border-stone-300"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm text-stone-900">{reg.student_name}</span>
{reg.added_by_ai && (
<span className="inline-flex items-center gap-1 rounded-full bg-purple-100 text-purple-700 text-xs px-2 py-0.5">
<Sparkles className="w-2 h-2" />
AI
</span>
)}
</div>
<div className="flex flex-wrap gap-2 text-xs text-stone-500">
<span>{reg.student_email}</span>
<span></span>
<span>
Current: <span className="font-medium">{reg.current_level || "Unknown"}</span>
</span>
<span></span>
<span>
Target:{" "}
<span className="font-medium text-blue-600">
{reg.target_level || "Unknown"}
</span>
</span>
</div>
</div>
<div className="flex items-center gap-2">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
reg.registration_status === "paid"
? "bg-green-100 text-green-700"
: reg.registration_status === "registered"
? "bg-blue-100 text-blue-700"
: "bg-amber-100 text-amber-700"
}`}
>
{reg.registration_status}
</span>
<button
type="button"
className="inline-flex items-center justify-center h-7 w-7 rounded text-red-600 hover:bg-red-50"
onClick={() => {
if (confirm("Remove student?")) {
deleteRegistrationMutation.mutate(reg.id);
}
}}
>
<X className="w-3 h-3" />
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
})
)}
</div>
</main>
</div>
</div>
{/* MOBILE DRAWER NAV */}
{mobileNavOpen && (
<>
<div
className="fixed inset-0 z-40 bg-black/20 md:hidden"
onClick={() => setMobileNavOpen(false)}
/>
<div className="fixed inset-y-0 left-0 z-50 w-72 max-w-full bg-white border-r border-stone-100 shadow-lg flex flex-col md:hidden">
<div className="px-5 pt-5 pb-4 flex items-center gap-3 border-b border-stone-100">
<img
src={dojoLogo}
alt="Dojo logo"
className="h-9 w-9 rounded-full object-cover"
/>
<div>
<div className="text-sm font-semibold text-stone-900">Arun Martial Arts</div>
<div className="text-xs text-stone-500">Admin Portal</div>
</div>
</div>
<nav className="flex-1 px-3 pt-4 pb-2 space-y-1 overflow-y-auto">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = isActivePath(item.to);
return (
<button
key={item.to}
type="button"
onClick={() => handleNavClick(item.to)}
className={`w-full flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-left ${
isActive
? "bg-rose-50 text-rose-700 font-semibold"
: "text-stone-600 hover:bg-stone-50"
}`}
>
<span
className={`inline-flex h-8 w-8 items-center justify-center rounded-xl border ${
isActive
? "border-rose-100 bg-rose-50 text-rose-600"
: "border-stone-200 bg-white text-stone-500"
}`}
>
<Icon className="w-4 h-4" />
</span>
<span>{item.label}</span>
</button>
);
})}
</nav>
<div className="px-4 pb-5">
<div className="rounded-2xl border border-rose-100 bg-rose-50 px-3 py-2">
<div className="text-[11px] font-semibold text-rose-700">Admin Mode</div>
<div className="text-[11px] text-rose-500">Full Access</div>
</div>
</div>
</div>
</>
)}
{/* Notify Dialog */}
{showNotifyDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="bg-white rounded-2xl shadow-xl border border-stone-200 p-6 max-w-md w-full mx-4">
<div className="mb-4">
<h2 className="text-lg font-semibold text-stone-900">Send Notifications</h2>
<p className="text-sm text-stone-600 mt-1">
Send payment reminder to {selectedStudents.length} student(s)
</p>
</div>
<div className="space-y-4">
<div className="p-4 bg-amber-50 border border-amber-200 rounded-lg">
<p className="text-sm text-amber-800">
Students will receive an email reminder to complete their exam registration and
payment.
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => notifyMutation.mutate(selectedStudents)}
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium px-4 py-2"
>
<Send className="w-4 h-4" />
Send Notifications
</button>
<button
type="button"
onClick={() => setShowNotifyDialog(false)}
className="inline-flex items-center justify-center rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50"
>
Cancel
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}