| | |
| | import React, { useState, useMemo, useRef, useEffect } from "react"; |
| | import { Link } from "react-router-dom"; |
| | import { Users2, X, ChevronDown, Search, User } from "lucide-react"; |
| | import client from "../../api/client"; |
| | import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | const fetchEnrollments = async (classId) => { |
| | const res = await client.get(`/admin/classes/${classId}/enrollments`); |
| | return res.data || []; |
| | }; |
| |
|
| | |
| | |
| | |
| | |
| | |
| | const fetchStudents = async () => { |
| | const res = await client.get("/admin/students"); |
| | return Array.isArray(res.data) ? res.data : []; |
| | }; |
| |
|
| | export default function ClassStudentManager({ classData, onClose }) { |
| | const queryClient = useQueryClient(); |
| | const classId = classData?.id; |
| |
|
| | const { data: enrollments = [], isLoading: isLoadingEnrollments } = useQuery({ |
| | queryKey: ["class-enrollments", classId], |
| | queryFn: () => fetchEnrollments(classId), |
| | enabled: !!classId, |
| | }); |
| |
|
| | const { data: students = [], isLoading: isLoadingStudents } = useQuery({ |
| | queryKey: ["students"], |
| | queryFn: fetchStudents, |
| | }); |
| |
|
| | const [selectedStudentId, setSelectedStudentId] = useState(""); |
| | const [searchQuery, setSearchQuery] = useState(""); |
| | const [isDropdownOpen, setIsDropdownOpen] = useState(false); |
| | const [highlightedIndex, setHighlightedIndex] = useState(-1); |
| | const dropdownRef = useRef(null); |
| | const inputRef = useRef(null); |
| |
|
| | const availableStudents = useMemo(() => { |
| | const enrolledStudentIds = new Set( |
| | enrollments |
| | .filter((e) => e.status !== "removed") |
| | .map((e) => e.student_id) |
| | .filter(Boolean) |
| | ); |
| | const enrolledEmails = new Set( |
| | enrollments |
| | .filter((e) => e.status !== "removed") |
| | .map((e) => e.student_email) |
| | ); |
| | |
| | return students.filter((s) => { |
| | |
| | if (enrolledStudentIds.has(s.id)) return false; |
| | |
| | |
| | return !enrolledEmails.has(s.name); |
| | }); |
| | }, [students, enrollments]); |
| |
|
| | |
| | const filteredStudents = useMemo(() => { |
| | if (!searchQuery.trim()) return availableStudents; |
| | const query = searchQuery.toLowerCase(); |
| | return availableStudents.filter((student) => { |
| | const fullName = `${student.first_name} ${student.last_name}`.toLowerCase(); |
| | const email = (student.membership_email || "").toLowerCase(); |
| | const planName = (student.membership_plan_name || "").toLowerCase(); |
| | return ( |
| | fullName.includes(query) || |
| | email.includes(query) || |
| | planName.includes(query) || |
| | (student.gender && student.gender.toLowerCase().includes(query)) |
| | ); |
| | }); |
| | }, [availableStudents, searchQuery]); |
| |
|
| | |
| | useEffect(() => { |
| | const handleClickOutside = (event) => { |
| | if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { |
| | setIsDropdownOpen(false); |
| | setHighlightedIndex(-1); |
| | } |
| | }; |
| |
|
| | document.addEventListener("mousedown", handleClickOutside); |
| | return () => { |
| | document.removeEventListener("mousedown", handleClickOutside); |
| | }; |
| | }, []); |
| |
|
| | |
| | const handleKeyDown = (e) => { |
| | if (!isDropdownOpen && (e.key === "ArrowDown" || e.key === "Enter")) { |
| | setIsDropdownOpen(true); |
| | return; |
| | } |
| |
|
| | if (e.key === "ArrowDown") { |
| | e.preventDefault(); |
| | setHighlightedIndex((prev) => |
| | prev < filteredStudents.length - 1 ? prev + 1 : prev |
| | ); |
| | } else if (e.key === "ArrowUp") { |
| | e.preventDefault(); |
| | setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : -1)); |
| | } else if (e.key === "Enter" && highlightedIndex >= 0) { |
| | e.preventDefault(); |
| | const student = filteredStudents[highlightedIndex]; |
| | if (student) { |
| | handleSelectStudent(student); |
| | } |
| | } else if (e.key === "Escape") { |
| | setIsDropdownOpen(false); |
| | setHighlightedIndex(-1); |
| | } |
| | }; |
| |
|
| | const handleSelectStudent = (student) => { |
| | setSelectedStudentId(student.id.toString()); |
| | setSearchQuery(`${student.first_name} ${student.last_name}`); |
| | setIsDropdownOpen(false); |
| | setHighlightedIndex(-1); |
| | }; |
| |
|
| | const handleInputChange = (e) => { |
| | setSearchQuery(e.target.value); |
| | setIsDropdownOpen(true); |
| | setHighlightedIndex(-1); |
| | if (!e.target.value) { |
| | setSelectedStudentId(""); |
| | } |
| | }; |
| |
|
| | const handleInputFocus = () => { |
| | setIsDropdownOpen(true); |
| | }; |
| |
|
| | const enrollMutation = useMutation({ |
| | |
| | mutationFn: (payload) => |
| | client.post(`/admin/classes/${classId}/enroll`, payload), |
| | onSuccess: () => { |
| | queryClient.invalidateQueries({ queryKey: ["class-enrollments", classId] }); |
| | setSelectedStudentId(""); |
| | setSearchQuery(""); |
| | setIsDropdownOpen(false); |
| | }, |
| | }); |
| |
|
| | const removeMutation = useMutation({ |
| | |
| | mutationFn: (enrollmentId) => |
| | client.delete( |
| | `/admin/classes/${classId}/enrollments/${enrollmentId}` |
| | ), |
| | onSuccess: () => { |
| | queryClient.invalidateQueries({ queryKey: ["class-enrollments", classId] }); |
| | }, |
| | }); |
| |
|
| | const handleAddStudent = () => { |
| | if (!selectedStudentId) return; |
| |
|
| | const student = students.find((s) => s.id === parseInt(selectedStudentId, 10)); |
| | if (!student) return; |
| |
|
| | enrollMutation.mutate({ |
| | student_id: student.id, |
| | student_email: student.membership_email || "", |
| | student_name: `${student.first_name} ${student.last_name}`, |
| | }); |
| | }; |
| |
|
| | const enrolledStudents = enrollments.filter((e) => e.status !== "removed"); |
| |
|
| | return ( |
| | <div className="mb-6 bg-white border border-stone-200 rounded-2xl shadow-sm"> |
| | <div className="px-4 sm:px-6 py-4 border-b border-stone-100 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4"> |
| | <div className="flex items-center gap-2"> |
| | <Users2 className="w-5 h-5 text-stone-700" /> |
| | <div> |
| | <h2 className="text-base font-semibold text-stone-900"> |
| | Manage Students – {classData?.name} |
| | </h2> |
| | <p className="text-sm text-stone-500"> |
| | Add individual students to this class (students can be from the same or different memberships) |
| | </p> |
| | </div> |
| | </div> |
| | {onClose && ( |
| | <button |
| | type="button" |
| | onClick={onClose} |
| | className="inline-flex items-center justify-center rounded-lg border border-stone-200 px-2.5 py-1.5 text-sm text-stone-600 hover:bg-stone-50" |
| | > |
| | <X className="w-4 h-4" /> |
| | </button> |
| | )} |
| | </div> |
| | |
| | <div className="px-4 sm:px-6 py-4 space-y-4"> |
| | {/* Add student row */} |
| | <div className="flex flex-wrap gap-3 items-center"> |
| | <div className="flex-1 min-w-[220px] relative" ref={dropdownRef}> |
| | <div className="relative"> |
| | <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-stone-400 pointer-events-none" /> |
| | <input |
| | ref={inputRef} |
| | type="text" |
| | value={searchQuery} |
| | onChange={handleInputChange} |
| | onFocus={handleInputFocus} |
| | onKeyDown={handleKeyDown} |
| | placeholder="Search and select a student to add..." |
| | disabled={isLoadingStudents || enrollMutation.isPending} |
| | className="w-full pl-9 pr-10 py-2 rounded-lg border border-stone-200 text-sm focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500 disabled:opacity-50 disabled:cursor-not-allowed" |
| | /> |
| | <ChevronDown |
| | className={`absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-stone-400 pointer-events-none transition-transform ${ |
| | isDropdownOpen ? "rotate-180" : "" |
| | }`} |
| | /> |
| | </div> |
| | |
| | {/* Dropdown list */} |
| | {isDropdownOpen && filteredStudents.length > 0 && ( |
| | <div className="absolute z-50 w-full mt-1 bg-white border border-stone-200 rounded-lg shadow-lg max-h-60 overflow-auto"> |
| | {filteredStudents.map((student, index) => { |
| | const fullName = `${student.first_name} ${student.last_name}`; |
| | const isHighlighted = index === highlightedIndex; |
| | return ( |
| | <button |
| | key={student.id} |
| | type="button" |
| | onClick={() => handleSelectStudent(student)} |
| | onMouseEnter={() => setHighlightedIndex(index)} |
| | className={`w-full text-left px-3 py-2 text-sm hover:bg-stone-50 transition-colors ${ |
| | isHighlighted ? "bg-stone-100" : "" |
| | }`} |
| | > |
| | <div className="font-medium text-stone-900">{fullName}</div> |
| | <div className="text-xs text-stone-500 mt-0.5"> |
| | {student.gender && `${student.gender} • `} |
| | {student.membership_plan_name && `${student.membership_plan_name} • `} |
| | {student.membership_email} |
| | </div> |
| | </button> |
| | ); |
| | })} |
| | </div> |
| | )} |
| | |
| | {/* No results message */} |
| | {isDropdownOpen && searchQuery && filteredStudents.length === 0 && ( |
| | <div className="absolute z-50 w-full mt-1 bg-white border border-stone-200 rounded-lg shadow-lg px-3 py-2 text-sm text-stone-500"> |
| | No students found matching "{searchQuery}" |
| | </div> |
| | )} |
| | |
| | {/* Empty state */} |
| | {isDropdownOpen && !searchQuery && availableStudents.length === 0 && ( |
| | <div className="absolute z-50 w-full mt-1 bg-white border border-stone-200 rounded-lg shadow-lg px-3 py-2 text-sm text-stone-500"> |
| | No available students to add |
| | </div> |
| | )} |
| | </div> |
| | <button |
| | type="button" |
| | onClick={handleAddStudent} |
| | disabled={!selectedStudentId || enrollMutation.isPending || isLoadingStudents} |
| | 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 disabled:opacity-50 disabled:cursor-not-allowed" |
| | > |
| | <span>+</span> |
| | {enrollMutation.isPending ? "Adding..." : "Add"} |
| | </button> |
| | </div> |
| | |
| | {/* Enrolled students list */} |
| | {isLoadingEnrollments ? ( |
| | <div className="text-center py-8 text-sm text-stone-500"> |
| | Loading students... |
| | </div> |
| | ) : enrolledStudents.length === 0 ? ( |
| | <div className="text-center py-8 text-sm text-stone-500"> |
| | No students enrolled in this class yet. |
| | </div> |
| | ) : ( |
| | <div className="border-t border-stone-100 pt-4"> |
| | <div className="space-y-2"> |
| | {enrolledStudents.map((enrollment) => { |
| | // Try to find the student by student_id or by matching email/name |
| | const student = enrollment.student_id |
| | ? students.find((s) => s.id === enrollment.student_id) |
| | : students.find( |
| | (s) => |
| | s.membership_email === enrollment.student_email || |
| | `${s.first_name} ${s.last_name}` === enrollment.student_name |
| | ); |
| | const studentId = student?.id || enrollment.student_id; |
| | |
| | return ( |
| | <div |
| | key={enrollment.id} |
| | className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-stone-50" |
| | > |
| | <div className="flex items-center gap-2 flex-1"> |
| | <div className="flex-1"> |
| | <div className="font-medium text-stone-900 text-sm"> |
| | {enrollment.student_name || enrollment.student_email || "Unknown Student"} |
| | </div> |
| | {enrollment.student_email && enrollment.student_name && ( |
| | <div className="text-xs text-stone-500"> |
| | {enrollment.student_email} |
| | </div> |
| | )} |
| | </div> |
| | {studentId && ( |
| | <Link |
| | to={`/admin/students/${studentId}`} |
| | className="p-1 text-stone-600 hover:text-stone-900" |
| | title="View Profile" |
| | onClick={(e) => e.stopPropagation()} |
| | > |
| | <User className="w-4 h-4" /> |
| | </Link> |
| | )} |
| | </div> |
| | <button |
| | type="button" |
| | onClick={() => removeMutation.mutate(enrollment.id)} |
| | disabled={removeMutation.isPending} |
| | className="text-xs font-medium text-red-600 hover:text-red-700 disabled:opacity-50 ml-2" |
| | > |
| | Remove |
| | </button> |
| | </div> |
| | ); |
| | })} |
| | </div> |
| | </div> |
| | )} |
| | </div> |
| | </div> |
| | ); |
| | } |
| |
|