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