Seth0330 commited on
Commit
b2cfca9
·
verified ·
1 Parent(s): 1b3e319

Update frontend/src/components/admin/ClassStudentManager.jsx

Browse files
frontend/src/components/admin/ClassStudentManager.jsx CHANGED
@@ -1,209 +1,255 @@
1
- import React, { useEffect, useState, useCallback } from "react";
2
- import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
3
- import { Button } from "@/components/ui/button";
4
- import {
5
- Select,
6
- SelectTrigger,
7
- SelectContent,
8
- SelectItem,
9
- SelectValue,
10
- } from "@/components/ui/select";
11
- import { Badge } from "@/components/ui/badge";
12
- import { Skeleton } from "@/components/ui/skeleton";
13
- import { Users, X, Plus, Trash2 } from "lucide-react";
14
  import api from "../../api/client";
15
 
16
- export default function ClassStudentManager({ classData, onClose }) {
17
- const [memberships, setMemberships] = useState([]);
18
- const [enrollments, setEnrollments] = useState([]);
19
- const [selectedMembershipId, setSelectedMembershipId] = useState("");
20
- const [loading, setLoading] = useState(true);
21
- const [saving, setSaving] = useState(false);
22
-
23
- const loadData = useCallback(async () => {
24
- if (!classData?.id) return;
25
- setLoading(true);
26
- try {
27
- const [enrollRes, memberRes] = await Promise.all([
28
- api.get(`/api/admin/classes/${classData.id}/enrollments`),
29
- api.get("/api/admin/memberships"),
30
- ]);
31
-
32
- setEnrollments(enrollRes.data || []);
33
- setMemberships(memberRes.data?.items || []);
34
- } catch (err) {
35
- console.error("Failed to load class students", err);
36
- } finally {
37
- setLoading(false);
38
- }
39
- }, [classData?.id]);
40
 
41
- useEffect(() => {
42
- loadData();
43
- }, [loadData]);
 
 
 
 
 
 
44
 
45
- const enrolledEmails = new Set(
46
- enrollments
47
- .filter((e) => e.status !== "removed")
48
- .map((e) => e.student_email)
49
- );
 
 
 
 
 
 
 
50
 
51
- const availableMembers = memberships.filter(
52
- (m) =>
53
- (m.status === "active" || m.status === "pending") &&
54
- !enrolledEmails.has(m.user_email)
55
- );
 
 
 
 
 
 
 
56
 
57
- async function handleAddStudent() {
58
- if (!selectedMembershipId) return;
59
- setSaving(true);
60
- try {
61
- await api.post(
62
  `/api/admin/classes/${classData.id}/enroll-membership`,
63
- {
64
- membership_id: Number(selectedMembershipId),
65
- }
66
  );
67
- setSelectedMembershipId("");
68
- await loadData();
69
- } catch (err) {
70
- console.error("Failed to enroll student", err);
71
- alert("Could not add student to class. Please try again.");
72
- } finally {
73
- setSaving(false);
74
- }
75
- }
76
 
77
- async function handleRemove(enrollmentId) {
78
- if (!window.confirm("Remove this student from the class?")) return;
79
- setSaving(true);
80
- try {
81
  await api.delete(
82
  `/api/admin/classes/${classData.id}/enrollments/${enrollmentId}`
83
  );
84
- await loadData();
85
- } catch (err) {
86
- console.error("Failed to remove enrollment", err);
87
- alert("Could not remove student from class. Please try again.");
88
- } finally {
89
- setSaving(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  }
91
- }
 
 
 
 
 
92
 
93
  return (
94
  <Card>
95
- <CardHeader className="flex flex-row items-center justify-between">
96
- <div className="flex items-center gap-2">
97
- <Users className="w-5 h-5 text-stone-600" />
98
- <div>
99
- <CardTitle className="text-base sm:text-lg">
100
- Manage Students {classData?.name}
101
- </CardTitle>
102
- <p className="text-xs text-stone-500">
103
- Add existing members to this class. They will see it in their
104
- student login.
105
- </p>
106
- </div>
107
  </div>
108
- <Button
109
- type="button"
110
- variant="ghost"
111
- size="icon"
112
- onClick={onClose}
113
- className="text-stone-500"
114
- >
115
- <X className="w-4 h-4" />
116
  </Button>
117
  </CardHeader>
 
 
 
 
 
 
 
 
118
 
119
- <CardContent className="space-y-4">
120
- {/* Add student row */}
121
- <div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center">
122
- <Select
123
- value={selectedMembershipId}
124
- onValueChange={setSelectedMembershipId}
125
- >
126
- <SelectTrigger className="flex-1">
127
- <SelectValue placeholder="Select a student to add…" />
128
- </SelectTrigger>
129
- <SelectContent>
130
- {availableMembers.length === 0 && (
131
- <SelectItem value="__none" disabled>
132
- No available members
133
- </SelectItem>
134
- )}
135
- {availableMembers.map((m) => (
136
- <SelectItem key={m.id} value={String(m.id)}>
137
- {m.user_name || m.user_email} • {m.plan_name}
138
- </SelectItem>
139
- ))}
140
- </SelectContent>
141
- </Select>
142
-
143
- <Button
144
- type="button"
145
- onClick={handleAddStudent}
146
- disabled={!selectedMembershipId || saving}
147
- className="gap-2 bg-red-600 hover:bg-red-700"
148
- >
149
- <Plus className="w-4 h-4" />
150
- Add
151
- </Button>
152
- </div>
153
-
154
- {/* Students list */}
155
- {loading ? (
156
- <div className="space-y-2 mt-4">
157
- <Skeleton className="h-10 w-full" />
158
- <Skeleton className="h-10 w-full" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  </div>
160
- ) : enrollments.filter((e) => e.status !== "removed").length === 0 ? (
161
- <p className="text-sm text-stone-500 mt-4">
162
- No students enrolled in this class yet.
163
- </p>
164
- ) : (
165
- <div className="mt-4 space-y-2">
166
- {enrollments
167
- .filter((e) => e.status !== "removed")
168
- .map((enrollment) => (
169
- <div
170
- key={enrollment.id}
171
- className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 border rounded-md px-3 py-2"
172
- >
173
- <div>
174
- <div className="font-medium">
175
- {enrollment.student_name || enrollment.student_email}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  </div>
177
- <div className="text-xs text-stone-500">
178
- {enrollment.student_email}
 
 
 
 
 
 
 
 
 
 
 
179
  </div>
180
  </div>
181
- <div className="flex items-center gap-3">
182
- <Badge
183
- variant={
184
- enrollment.status === "invited"
185
- ? "secondary"
186
- : "default"
187
- }
188
- className="text-xs"
189
- >
190
- {enrollment.status}
191
- </Badge>
192
- <Button
193
- type="button"
194
- variant="ghost"
195
- size="icon"
196
- className="text-red-600"
197
- onClick={() => handleRemove(enrollment.id)}
198
- disabled={saving}
199
- >
200
- <Trash2 className="w-4 h-4" />
201
- </Button>
202
- </div>
203
- </div>
204
- ))}
205
  </div>
206
- )}
207
  </CardContent>
208
  </Card>
209
  );
 
1
+ // frontend/src/components/admin/ClassStudentManager.jsx
2
+ import React from "react";
3
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
 
 
 
 
 
 
 
 
 
 
4
  import api from "../../api/client";
5
 
6
+ import { Button } from "../ui/button";
7
+ import {
8
+ Card,
9
+ CardHeader,
10
+ CardTitle,
11
+ CardDescription,
12
+ CardContent,
13
+ } from "../ui/card";
14
+ import {
15
+ Table,
16
+ TableBody,
17
+ TableCell,
18
+ TableHead,
19
+ TableHeader,
20
+ TableRow,
21
+ } from "../ui/table";
22
+ import { Skeleton } from "../ui/skeleton";
23
+ import { Badge } from "../ui/badge";
24
+ import { X, UserPlus, Trash2 } from "lucide-react";
 
 
 
 
 
25
 
26
+ function useMemberships() {
27
+ return useQuery({
28
+ queryKey: ["admin-memberships"],
29
+ queryFn: async () => {
30
+ const res = await api.get("/api/admin/memberships");
31
+ return res.data?.items || [];
32
+ },
33
+ });
34
+ }
35
 
36
+ function useClassEnrollments(classId) {
37
+ return useQuery({
38
+ queryKey: ["class-enrollments", classId],
39
+ queryFn: async () => {
40
+ const res = await api.get(
41
+ `/api/admin/classes/${classId}/enrollments`
42
+ );
43
+ return res.data || [];
44
+ },
45
+ enabled: !!classId,
46
+ });
47
+ }
48
 
49
+ export default function ClassStudentManager({ classData, onClose }) {
50
+ const queryClient = useQueryClient();
51
+
52
+ const {
53
+ data: memberships = [],
54
+ isLoading: membershipsLoading,
55
+ } = useMemberships();
56
+
57
+ const {
58
+ data: enrollments = [],
59
+ isLoading: enrollmentsLoading,
60
+ } = useClassEnrollments(classData?.id);
61
 
62
+ const enrollMutation = useMutation({
63
+ mutationFn: async (membershipId) => {
64
+ const payload = { membership_id: membershipId };
65
+ const res = await api.post(
 
66
  `/api/admin/classes/${classData.id}/enroll-membership`,
67
+ payload
 
 
68
  );
69
+ return res.data;
70
+ },
71
+ onSuccess: () => {
72
+ queryClient.invalidateQueries({
73
+ queryKey: ["class-enrollments", classData.id],
74
+ });
75
+ },
76
+ });
 
77
 
78
+ const removeMutation = useMutation({
79
+ mutationFn: async (enrollmentId) => {
 
 
80
  await api.delete(
81
  `/api/admin/classes/${classData.id}/enrollments/${enrollmentId}`
82
  );
83
+ },
84
+ onSuccess: () => {
85
+ queryClient.invalidateQueries({
86
+ queryKey: ["class-enrollments", classData.id],
87
+ });
88
+ },
89
+ });
90
+
91
+ const activeEnrollments = enrollments.filter(
92
+ (e) => e.status !== "removed"
93
+ );
94
+
95
+ const enrolledEmails = new Set(
96
+ activeEnrollments.map((e) => e.student_email)
97
+ );
98
+
99
+ const handleEnroll = (membership) => {
100
+ if (enrolledEmails.has(membership.user_email)) {
101
+ return;
102
  }
103
+ enrollMutation.mutate(membership.id);
104
+ };
105
+
106
+ const handleRemove = (enrollmentId) => {
107
+ removeMutation.mutate(enrollmentId);
108
+ };
109
 
110
  return (
111
  <Card>
112
+ <CardHeader className="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
113
+ <div>
114
+ <CardTitle>Manage Students for {classData.name}</CardTitle>
115
+ <CardDescription>
116
+ Enroll existing members into this class. Students will see
117
+ their classes when they log in.
118
+ </CardDescription>
 
 
 
 
 
119
  </div>
120
+ <Button variant="ghost" size="icon" onClick={onClose}>
121
+ <X className="w-5 h-5" />
 
 
 
 
 
 
122
  </Button>
123
  </CardHeader>
124
+ <CardContent>
125
+ <div className="grid md:grid-cols-2 gap-6">
126
+ {/* Left: Membership list */}
127
+ <div>
128
+ <h3 className="font-semibold mb-2">All Members</h3>
129
+ <p className="text-xs text-stone-500 mb-3">
130
+ Click &quot;Enroll&quot; to add a member into this class.
131
+ </p>
132
 
133
+ {membershipsLoading ? (
134
+ <div className="space-y-2">
135
+ {[1, 2, 3].map((i) => (
136
+ <Skeleton key={i} className="h-10 w-full" />
137
+ ))}
138
+ </div>
139
+ ) : memberships.length === 0 ? (
140
+ <p className="text-sm text-stone-500">
141
+ No memberships found.
142
+ </p>
143
+ ) : (
144
+ <div className="border rounded-lg overflow-hidden">
145
+ <Table>
146
+ <TableHeader>
147
+ <TableRow>
148
+ <TableHead>Member</TableHead>
149
+ <TableHead>Plan</TableHead>
150
+ <TableHead className="text-right">
151
+ Action
152
+ </TableHead>
153
+ </TableRow>
154
+ </TableHeader>
155
+ <TableBody>
156
+ {memberships.map((m) => {
157
+ const alreadyEnrolled =
158
+ enrolledEmails.has(m.user_email);
159
+ return (
160
+ <TableRow key={m.id}>
161
+ <TableCell>
162
+ <div className="flex flex-col">
163
+ <span className="font-medium">
164
+ {m.user_name || "Unnamed"}
165
+ </span>
166
+ <span className="text-xs text-stone-500">
167
+ {m.user_email}
168
+ </span>
169
+ </div>
170
+ </TableCell>
171
+ <TableCell>
172
+ <span className="text-xs">
173
+ {m.plan_name}
174
+ </span>
175
+ </TableCell>
176
+ <TableCell className="text-right">
177
+ <Button
178
+ size="sm"
179
+ variant={alreadyEnrolled ? "outline" : "default"}
180
+ className="gap-1"
181
+ disabled={
182
+ alreadyEnrolled || enrollMutation.isPending
183
+ }
184
+ onClick={() => handleEnroll(m)}
185
+ >
186
+ <UserPlus className="w-4 h-4" />
187
+ {alreadyEnrolled ? "Enrolled" : "Enroll"}
188
+ </Button>
189
+ </TableCell>
190
+ </TableRow>
191
+ );
192
+ })}
193
+ </TableBody>
194
+ </Table>
195
+ </div>
196
+ )}
197
  </div>
198
+
199
+ {/* Right: Current enrollments */}
200
+ <div>
201
+ <h3 className="font-semibold mb-2">
202
+ Students in this class
203
+ </h3>
204
+ <p className="text-xs text-stone-500 mb-3">
205
+ These students are currently associated with this class.
206
+ </p>
207
+
208
+ {enrollmentsLoading ? (
209
+ <div className="space-y-2">
210
+ {[1, 2, 3].map((i) => (
211
+ <Skeleton key={i} className="h-10 w-full" />
212
+ ))}
213
+ </div>
214
+ ) : activeEnrollments.length === 0 ? (
215
+ <p className="text-sm text-stone-500">
216
+ No students enrolled yet.
217
+ </p>
218
+ ) : (
219
+ <div className="space-y-2">
220
+ {activeEnrollments.map((enroll) => (
221
+ <div
222
+ key={enroll.id}
223
+ className="flex items-center justify-between border rounded-lg px-3 py-2"
224
+ >
225
+ <div className="flex flex-col">
226
+ <span className="font-medium">
227
+ {enroll.student_name || "Student"}
228
+ </span>
229
+ <span className="text-xs text-stone-500">
230
+ {enroll.student_email}
231
+ </span>
232
  </div>
233
+ <div className="flex items-center gap-2">
234
+ <Badge variant="outline" className="text-xs">
235
+ {enroll.status}
236
+ </Badge>
237
+ <Button
238
+ size="icon"
239
+ variant="ghost"
240
+ className="text-red-600"
241
+ onClick={() => handleRemove(enroll.id)}
242
+ disabled={removeMutation.isPending}
243
+ >
244
+ <Trash2 className="w-4 h-4" />
245
+ </Button>
246
  </div>
247
  </div>
248
+ ))}
249
+ </div>
250
+ )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  </div>
252
+ </div>
253
  </CardContent>
254
  </Card>
255
  );