| |
| 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); |
|
|
| |
| 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"); |
| } |
|
|
| |
| 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: [], |
| }); |
|
|
| |
| 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 (!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); |
| }, |
| }); |
|
|
| |
| 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> |
| </> |
| )} |
|
|
| {} |
| {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> |
| ); |
| } |
|
|
|
|