sanbon / frontend /src /components /admin /ClassStudentManager.jsx
Seth0330's picture
Update frontend/src/components/admin/ClassStudentManager.jsx
3ea9094 verified
// frontend/src/components/admin/ClassStudentManager.jsx
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";
/**
* Fetch enrollments for a specific class.
* FastAPI route: /api/admin/classes/{class_id}/enrollments
* Axios baseURL: /api
* → Frontend path: /admin/classes/{class_id}/enrollments
*/
const fetchEnrollments = async (classId) => {
const res = await client.get(`/admin/classes/${classId}/enrollments`);
return res.data || [];
};
/**
* Fetch all students so we can pick individual students.
* FastAPI route: /api/admin/students
* → Frontend path: /admin/students
*/
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) // Remove null/undefined
);
const enrolledEmails = new Set(
enrollments
.filter((e) => e.status !== "removed")
.map((e) => e.student_email)
);
// Filter out students that are already enrolled (by student_id or by email matching student name)
return students.filter((s) => {
// Check if student ID is enrolled
if (enrolledStudentIds.has(s.id)) return false;
// Check if student name matches any enrolled email (for backward compatibility)
// This is a fallback for old enrollments that might not have student_id
return !enrolledEmails.has(s.name);
});
}, [students, enrollments]);
// Filter students based on search query
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]);
// Close dropdown when clicking outside
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);
};
}, []);
// Handle keyboard navigation
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({
// POST /api/admin/classes/{class_id}/enroll
mutationFn: (payload) =>
client.post(`/admin/classes/${classId}/enroll`, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["class-enrollments", classId] });
setSelectedStudentId("");
setSearchQuery("");
setIsDropdownOpen(false);
},
});
const removeMutation = useMutation({
// DELETE /api/admin/classes/{class_id}/enrollments/{enrollment_id}
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>
);
}