feat(classroom): implement enrollment system (Phase 4)
- Add EnrollChildFlow component for parents to enroll children - Add enrollment hooks (useEnrolledStudents, useCreateEnrollmentRequest, etc.) - Update StudentManagerTab with enrolled students and pending requests - Add "Enroll in another classroom" option for teachers who are parents - Fix critical auth bug: convert guestId to user.id in all classroom API routes The API routes were incorrectly using viewerId (guestId from cookie) when they should have been using users.id. This caused 403 errors for all classroom operations. Fixed by adding getOrCreateUser() to convert guestId to proper user.id before calling business logic functions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -254,7 +254,13 @@
|
||||
"Bash(mcp__sqlite__describe_table:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git show:*)",
|
||||
"Bash(npx tsx:*)"
|
||||
"Bash(npx tsx:*)",
|
||||
"Bash(xargs ls:*)",
|
||||
"Bash(mcp__sqlite__list_tables)",
|
||||
"WebFetch(domain:developer.chrome.com)",
|
||||
"Bash(claude mcp add:*)",
|
||||
"Bash(claude mcp:*)",
|
||||
"Bash(git rev-parse:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { approveEnrollmentRequest, getTeacherClassroom } from '@/lib/classroom'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* Get or create user record for a viewerId (guestId)
|
||||
*/
|
||||
async function getOrCreateUser(viewerId: string) {
|
||||
let user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
|
||||
user = newUser
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ classroomId: string; requestId: string }>
|
||||
}
|
||||
@@ -16,14 +34,15 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { classroomId, requestId } = await params
|
||||
const viewerId = await getViewerId()
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
|
||||
// Verify user is the teacher of this classroom
|
||||
const classroom = await getTeacherClassroom(viewerId)
|
||||
const classroom = await getTeacherClassroom(user.id)
|
||||
if (!classroom || classroom.id !== classroomId) {
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const result = await approveEnrollmentRequest(requestId, viewerId, 'teacher')
|
||||
const result = await approveEnrollmentRequest(requestId, user.id, 'teacher')
|
||||
|
||||
return NextResponse.json({
|
||||
request: result.request,
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { denyEnrollmentRequest, getTeacherClassroom } from '@/lib/classroom'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* Get or create user record for a viewerId (guestId)
|
||||
*/
|
||||
async function getOrCreateUser(viewerId: string) {
|
||||
let user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
|
||||
user = newUser
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ classroomId: string; requestId: string }>
|
||||
}
|
||||
@@ -16,14 +34,15 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { classroomId, requestId } = await params
|
||||
const viewerId = await getViewerId()
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
|
||||
// Verify user is the teacher of this classroom
|
||||
const classroom = await getTeacherClassroom(viewerId)
|
||||
const classroom = await getTeacherClassroom(user.id)
|
||||
if (!classroom || classroom.id !== classroomId) {
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const request = await denyEnrollmentRequest(requestId, viewerId, 'teacher')
|
||||
const request = await denyEnrollmentRequest(requestId, user.id, 'teacher')
|
||||
|
||||
return NextResponse.json({ request })
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import {
|
||||
createEnrollmentRequest,
|
||||
getPendingRequestsForClassroom,
|
||||
@@ -7,6 +9,22 @@ import {
|
||||
} from '@/lib/classroom'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* Get or create user record for a viewerId (guestId)
|
||||
*/
|
||||
async function getOrCreateUser(viewerId: string) {
|
||||
let user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
|
||||
user = newUser
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ classroomId: string }>
|
||||
}
|
||||
@@ -21,9 +39,10 @@ export async function GET(req: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { classroomId } = await params
|
||||
const viewerId = await getViewerId()
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
|
||||
// Verify user is the teacher of this classroom
|
||||
const classroom = await getTeacherClassroom(viewerId)
|
||||
const classroom = await getTeacherClassroom(user.id)
|
||||
if (!classroom || classroom.id !== classroomId) {
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
|
||||
}
|
||||
@@ -48,6 +67,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { classroomId } = await params
|
||||
const viewerId = await getViewerId()
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
const body = await req.json()
|
||||
|
||||
if (!body.playerId) {
|
||||
@@ -55,9 +75,9 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
// Determine role: is user the teacher or a parent?
|
||||
const classroom = await getTeacherClassroom(viewerId)
|
||||
const classroom = await getTeacherClassroom(user.id)
|
||||
const isTeacher = classroom?.id === classroomId
|
||||
const parentCheck = await isParent(viewerId, body.playerId)
|
||||
const parentCheck = await isParent(user.id, body.playerId)
|
||||
|
||||
if (!isTeacher && !parentCheck) {
|
||||
return NextResponse.json(
|
||||
@@ -71,7 +91,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
|
||||
const request = await createEnrollmentRequest({
|
||||
classroomId,
|
||||
playerId: body.playerId,
|
||||
requestedBy: viewerId,
|
||||
requestedBy: user.id,
|
||||
requestedByRole,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { unenrollStudent, getTeacherClassroom, isParent } from '@/lib/classroom'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* Get or create user record for a viewerId (guestId)
|
||||
*/
|
||||
async function getOrCreateUser(viewerId: string) {
|
||||
let user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
|
||||
user = newUser
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ classroomId: string; playerId: string }>
|
||||
}
|
||||
@@ -16,11 +34,12 @@ export async function DELETE(req: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { classroomId, playerId } = await params
|
||||
const viewerId = await getViewerId()
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
|
||||
// Check authorization: must be teacher of classroom OR parent of student
|
||||
const classroom = await getTeacherClassroom(viewerId)
|
||||
const classroom = await getTeacherClassroom(user.id)
|
||||
const isTeacher = classroom?.id === classroomId
|
||||
const parentCheck = await isParent(viewerId, playerId)
|
||||
const parentCheck = await isParent(user.id, playerId)
|
||||
|
||||
if (!isTeacher && !parentCheck) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { getEnrolledStudents, getTeacherClassroom } from '@/lib/classroom'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* Get or create user record for a viewerId (guestId)
|
||||
*/
|
||||
async function getOrCreateUser(viewerId: string) {
|
||||
let user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
|
||||
user = newUser
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ classroomId: string }>
|
||||
}
|
||||
@@ -16,9 +34,10 @@ export async function GET(req: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { classroomId } = await params
|
||||
const viewerId = await getViewerId()
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
|
||||
// Verify user is the teacher of this classroom
|
||||
const classroom = await getTeacherClassroom(viewerId)
|
||||
const classroom = await getTeacherClassroom(user.id)
|
||||
if (!classroom || classroom.id !== classroomId) {
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { leaveSpecificClassroom, getTeacherClassroom, isParent } from '@/lib/classroom'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* Get or create user record for a viewerId (guestId)
|
||||
*/
|
||||
async function getOrCreateUser(viewerId: string) {
|
||||
let user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
|
||||
user = newUser
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ classroomId: string; playerId: string }>
|
||||
}
|
||||
@@ -16,11 +34,12 @@ export async function DELETE(req: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { classroomId, playerId } = await params
|
||||
const viewerId = await getViewerId()
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
|
||||
// Check authorization: must be teacher of classroom OR parent of student
|
||||
const classroom = await getTeacherClassroom(viewerId)
|
||||
const classroom = await getTeacherClassroom(user.id)
|
||||
const isTeacher = classroom?.id === classroomId
|
||||
const parentCheck = await isParent(viewerId, playerId)
|
||||
const parentCheck = await isParent(user.id, playerId)
|
||||
|
||||
if (!isTeacher && !parentCheck) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import {
|
||||
enterClassroom,
|
||||
getClassroomPresence,
|
||||
@@ -7,6 +9,22 @@ import {
|
||||
} from '@/lib/classroom'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* Get or create user record for a viewerId (guestId)
|
||||
*/
|
||||
async function getOrCreateUser(viewerId: string) {
|
||||
let user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
|
||||
user = newUser
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ classroomId: string }>
|
||||
}
|
||||
@@ -21,9 +39,10 @@ export async function GET(req: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { classroomId } = await params
|
||||
const viewerId = await getViewerId()
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
|
||||
// Verify user is the teacher of this classroom
|
||||
const classroom = await getTeacherClassroom(viewerId)
|
||||
const classroom = await getTeacherClassroom(user.id)
|
||||
if (!classroom || classroom.id !== classroomId) {
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
|
||||
}
|
||||
@@ -55,6 +74,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { classroomId } = await params
|
||||
const viewerId = await getViewerId()
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
const body = await req.json()
|
||||
|
||||
if (!body.playerId) {
|
||||
@@ -62,9 +82,9 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
// Check authorization: must be teacher of classroom OR parent of student
|
||||
const classroom = await getTeacherClassroom(viewerId)
|
||||
const classroom = await getTeacherClassroom(user.id)
|
||||
const isTeacher = classroom?.id === classroomId
|
||||
const parentCheck = await isParent(viewerId, body.playerId)
|
||||
const parentCheck = await isParent(user.id, body.playerId)
|
||||
|
||||
if (!isTeacher && !parentCheck) {
|
||||
return NextResponse.json(
|
||||
@@ -76,7 +96,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
|
||||
const result = await enterClassroom({
|
||||
playerId: body.playerId,
|
||||
classroomId,
|
||||
enteredBy: viewerId,
|
||||
enteredBy: user.id,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import {
|
||||
deleteClassroom,
|
||||
getClassroom,
|
||||
@@ -7,6 +9,22 @@ import {
|
||||
} from '@/lib/classroom'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* Get or create user record for a viewerId (guestId)
|
||||
*/
|
||||
async function getOrCreateUser(viewerId: string) {
|
||||
let user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning()
|
||||
user = newUser
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ classroomId: string }>
|
||||
}
|
||||
@@ -45,11 +63,12 @@ export async function PATCH(req: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { classroomId } = await params
|
||||
const viewerId = await getViewerId()
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
const body = await req.json()
|
||||
|
||||
// Handle code regeneration separately
|
||||
if (body.regenerateCode) {
|
||||
const newCode = await regenerateClassroomCode(classroomId, viewerId)
|
||||
const newCode = await regenerateClassroomCode(classroomId, user.id)
|
||||
if (!newCode) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Not authorized or classroom not found' },
|
||||
@@ -69,7 +88,7 @@ export async function PATCH(req: NextRequest, { params }: RouteParams) {
|
||||
return NextResponse.json({ error: 'No valid updates provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
const classroom = await updateClassroom(classroomId, viewerId, updates)
|
||||
const classroom = await updateClassroom(classroomId, user.id, updates)
|
||||
|
||||
if (!classroom) {
|
||||
return NextResponse.json({ error: 'Not authorized or classroom not found' }, { status: 403 })
|
||||
@@ -92,8 +111,9 @@ export async function DELETE(req: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { classroomId } = await params
|
||||
const viewerId = await getViewerId()
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
|
||||
const success = await deleteClassroom(classroomId, viewerId)
|
||||
const success = await deleteClassroom(classroomId, user.id)
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json({ error: 'Not authorized or classroom not found' }, { status: 403 })
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Z_INDEX } from '@/constants/zIndex'
|
||||
import { ClassroomDashboard, CreateClassroomForm } from '@/components/classroom'
|
||||
import { ClassroomDashboard, CreateClassroomForm, EnrollChildFlow } from '@/components/classroom'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { StudentFilterBar } from '@/components/practice/StudentFilterBar'
|
||||
import { StudentSelector, type StudentWithProgress } from '@/components/practice'
|
||||
@@ -34,6 +34,7 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
// Classroom state - check if user is a teacher
|
||||
const { data: classroom, isLoading: isLoadingClassroom } = useMyClassroom()
|
||||
const [showCreateClassroom, setShowCreateClassroom] = useState(false)
|
||||
const [showEnrollChild, setShowEnrollChild] = useState(false)
|
||||
|
||||
// Filter state
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
@@ -190,6 +191,15 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
setShowCreateClassroom(false)
|
||||
}, [])
|
||||
|
||||
// Handle enrollment flow
|
||||
const handleEnrollChild = useCallback(() => {
|
||||
setShowEnrollChild(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseEnrollChild = useCallback(() => {
|
||||
setShowEnrollChild(false)
|
||||
}, [])
|
||||
|
||||
// If user is a teacher, show the classroom dashboard
|
||||
if (classroom) {
|
||||
return (
|
||||
@@ -235,6 +245,31 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Show enroll child flow if requested
|
||||
if (showEnrollChild) {
|
||||
return (
|
||||
<PageWithNav>
|
||||
<main
|
||||
data-component="practice-page"
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
backgroundColor: isDark ? 'gray.900' : 'gray.50',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '24px',
|
||||
})}
|
||||
>
|
||||
<EnrollChildFlow
|
||||
children={players}
|
||||
onSuccess={handleCloseEnrollChild}
|
||||
onCancel={handleCloseEnrollChild}
|
||||
/>
|
||||
</main>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
// Parent view - show student list with filter bar
|
||||
return (
|
||||
<PageWithNav>
|
||||
@@ -295,31 +330,67 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
Build your soroban skills one step at a time
|
||||
</p>
|
||||
|
||||
{/* Become a Teacher option */}
|
||||
{/* Parent/Teacher options */}
|
||||
{!isLoadingClassroom && !classroom && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBecomeTeacher}
|
||||
data-action="become-teacher"
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
marginTop: '16px',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: 'transparent',
|
||||
color: isDark ? 'blue.400' : 'blue.600',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'blue.700' : 'blue.300',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.875rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'blue.900/30' : 'blue.50',
|
||||
borderColor: isDark ? 'blue.500' : 'blue.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
🏫 Are you a teacher? Create a classroom
|
||||
</button>
|
||||
{/* Enroll in Classroom option - for parents with a teacher */}
|
||||
{players.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEnrollChild}
|
||||
data-action="enroll-child"
|
||||
className={css({
|
||||
padding: '8px 16px',
|
||||
backgroundColor: isDark ? 'green.900/30' : 'green.50',
|
||||
color: isDark ? 'green.400' : 'green.700',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'green.700' : 'green.300',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.875rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'green.900/50' : 'green.100',
|
||||
borderColor: isDark ? 'green.500' : 'green.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
📚 Have a classroom code? Enroll your child
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Become a Teacher option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBecomeTeacher}
|
||||
data-action="become-teacher"
|
||||
className={css({
|
||||
padding: '8px 16px',
|
||||
backgroundColor: 'transparent',
|
||||
color: isDark ? 'blue.400' : 'blue.600',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'blue.700' : 'blue.300',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.875rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'blue.900/30' : 'blue.50',
|
||||
borderColor: isDark ? 'blue.500' : 'blue.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
🏫 Are you a teacher? Create a classroom
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { ClassroomCodeShare } from './ClassroomCodeShare'
|
||||
import { ClassroomTab } from './ClassroomTab'
|
||||
import { EnrollChildFlow } from './EnrollChildFlow'
|
||||
import { StudentManagerTab } from './StudentManagerTab'
|
||||
|
||||
type TabId = 'classroom' | 'students'
|
||||
@@ -29,6 +30,7 @@ export function ClassroomDashboard({ classroom, ownChildren = [] }: ClassroomDas
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const [activeTab, setActiveTab] = useState<TabId>('classroom')
|
||||
const [showEnrollChild, setShowEnrollChild] = useState(false)
|
||||
|
||||
const tabs: { id: TabId; label: string; icon: string }[] = [
|
||||
{ id: 'classroom', label: 'Classroom', icon: '🏫' },
|
||||
@@ -95,16 +97,45 @@ export function ClassroomDashboard({ classroom, ownChildren = [] }: ClassroomDas
|
||||
borderColor: isDark ? 'green.800' : 'green.200',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.9375rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'green.300' : 'green.700',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '12px',
|
||||
})}
|
||||
>
|
||||
Your Children
|
||||
</h2>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '0.9375rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'green.300' : 'green.700',
|
||||
})}
|
||||
>
|
||||
Your Children
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEnrollChild(true)}
|
||||
data-action="enroll-child-in-other-classroom"
|
||||
className={css({
|
||||
padding: '6px 12px',
|
||||
backgroundColor: 'transparent',
|
||||
color: isDark ? 'green.400' : 'green.700',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'green.700' : 'green.400',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.8125rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'green.900/50' : 'green.100',
|
||||
},
|
||||
})}
|
||||
>
|
||||
📚 Enroll in another classroom
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
@@ -155,6 +186,34 @@ export function ClassroomDashboard({ classroom, ownChildren = [] }: ClassroomDas
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Enroll child modal */}
|
||||
{showEnrollChild && (
|
||||
<div
|
||||
data-component="enroll-child-modal"
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '24px',
|
||||
zIndex: 1000,
|
||||
})}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowEnrollChild(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<EnrollChildFlow
|
||||
children={ownChildren}
|
||||
onSuccess={() => setShowEnrollChild(false)}
|
||||
onCancel={() => setShowEnrollChild(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab navigation */}
|
||||
<nav
|
||||
className={css({
|
||||
|
||||
441
apps/web/src/components/classroom/EnrollChildFlow.tsx
Normal file
441
apps/web/src/components/classroom/EnrollChildFlow.tsx
Normal file
@@ -0,0 +1,441 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { Player } from '@/db/schema'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { useClassroomByCode, useCreateEnrollmentRequest } from '@/hooks/useClassroom'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
interface EnrollChildFlowProps {
|
||||
/** Children available to enroll (linked to current user) */
|
||||
children: Player[]
|
||||
/** Called when enrollment request is successfully created */
|
||||
onSuccess?: () => void
|
||||
/** Called when user cancels */
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* EnrollChildFlow - Parent enrollment flow
|
||||
*
|
||||
* Allows parents to:
|
||||
* 1. Enter a classroom code
|
||||
* 2. See classroom/teacher info
|
||||
* 3. Select which child to enroll
|
||||
* 4. Submit enrollment request
|
||||
*/
|
||||
export function EnrollChildFlow({ children, onSuccess, onCancel }: EnrollChildFlowProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
// Step state
|
||||
const [code, setCode] = useState('')
|
||||
const [selectedChildId, setSelectedChildId] = useState<string | null>(null)
|
||||
|
||||
// Look up classroom by code
|
||||
const { data: classroom, isLoading: lookingUp, error: lookupError } = useClassroomByCode(code)
|
||||
|
||||
// Enrollment mutation
|
||||
const createRequest = useCreateEnrollmentRequest()
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!classroom || !selectedChildId) return
|
||||
|
||||
try {
|
||||
await createRequest.mutateAsync({
|
||||
classroomId: classroom.id,
|
||||
playerId: selectedChildId,
|
||||
})
|
||||
onSuccess?.()
|
||||
} catch {
|
||||
// Error handled by mutation state
|
||||
}
|
||||
}, [classroom, selectedChildId, createRequest, onSuccess])
|
||||
|
||||
const selectedChild = children.find((c) => c.id === selectedChildId)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="enroll-child-flow"
|
||||
className={css({
|
||||
padding: '24px',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '16px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
maxWidth: '450px',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Enroll in a Classroom
|
||||
</h2>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
marginBottom: '20px',
|
||||
})}
|
||||
>
|
||||
Enter the classroom code from your teacher to enroll your child.
|
||||
</p>
|
||||
|
||||
{/* Step 1: Enter code */}
|
||||
<div className={css({ marginBottom: '20px' })}>
|
||||
<label
|
||||
htmlFor="classroom-code"
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
marginBottom: '6px',
|
||||
})}
|
||||
>
|
||||
Classroom Code
|
||||
</label>
|
||||
<input
|
||||
id="classroom-code"
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
placeholder="e.g., ABC123"
|
||||
maxLength={6}
|
||||
data-input="classroom-code"
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '12px 14px',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.50',
|
||||
border: '2px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.200',
|
||||
borderRadius: '8px',
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
fontFamily: 'monospace',
|
||||
letterSpacing: '0.15em',
|
||||
textAlign: 'center',
|
||||
textTransform: 'uppercase',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.15s ease',
|
||||
_focus: {
|
||||
borderColor: isDark ? 'blue.500' : 'blue.400',
|
||||
},
|
||||
_placeholder: {
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
fontWeight: 'normal',
|
||||
letterSpacing: 'normal',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Lookup status */}
|
||||
{code.length >= 4 && (
|
||||
<div className={css({ marginTop: '12px' })}>
|
||||
{lookingUp && (
|
||||
<p className={css({ fontSize: '0.875rem', color: isDark ? 'gray.400' : 'gray.500' })}>
|
||||
Looking up classroom...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!lookingUp && !classroom && code.length >= 6 && (
|
||||
<p className={css({ fontSize: '0.875rem', color: 'red.500' })}>
|
||||
No classroom found with this code
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step 2: Classroom found - show info */}
|
||||
{classroom && (
|
||||
<div
|
||||
data-section="classroom-found"
|
||||
className={css({
|
||||
padding: '16px',
|
||||
backgroundColor: isDark ? 'green.900/20' : 'green.50',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'green.700' : 'green.200',
|
||||
marginBottom: '20px',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
|
||||
<span className={css({ fontSize: '1.25rem' })}>🏫</span>
|
||||
<div>
|
||||
<p
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'green.300' : 'green.700',
|
||||
})}
|
||||
>
|
||||
{classroom.name}
|
||||
</p>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
color: isDark ? 'green.400' : 'green.600',
|
||||
})}
|
||||
>
|
||||
Teacher: {(classroom as { teacher?: { name?: string } }).teacher?.name ?? 'Teacher'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Select child */}
|
||||
{classroom && children.length > 0 && (
|
||||
<div className={css({ marginBottom: '20px' })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
marginBottom: '10px',
|
||||
})}
|
||||
>
|
||||
Select child to enroll
|
||||
</label>
|
||||
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '8px' })}>
|
||||
{children.map((child) => (
|
||||
<button
|
||||
key={child.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedChildId(child.id)}
|
||||
data-selected={selectedChildId === child.id}
|
||||
data-action={`select-child-${child.id}`}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '12px 14px',
|
||||
backgroundColor:
|
||||
selectedChildId === child.id
|
||||
? isDark
|
||||
? 'blue.900/30'
|
||||
: 'blue.50'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'gray.50',
|
||||
border: '2px solid',
|
||||
borderColor:
|
||||
selectedChildId === child.id
|
||||
? isDark
|
||||
? 'blue.500'
|
||||
: 'blue.400'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.200',
|
||||
borderRadius: '10px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
textAlign: 'left',
|
||||
_hover: {
|
||||
borderColor: isDark ? 'blue.500' : 'blue.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '1.125rem',
|
||||
})}
|
||||
style={{ backgroundColor: child.color }}
|
||||
>
|
||||
{child.emoji}
|
||||
</span>
|
||||
<span
|
||||
className={css({
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
})}
|
||||
>
|
||||
{child.name}
|
||||
</span>
|
||||
{selectedChildId === child.id && (
|
||||
<span
|
||||
className={css({
|
||||
marginLeft: 'auto',
|
||||
color: isDark ? 'blue.400' : 'blue.600',
|
||||
fontSize: '1.25rem',
|
||||
})}
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{createRequest.error && (
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
color: 'red.500',
|
||||
marginBottom: '12px',
|
||||
})}
|
||||
>
|
||||
{createRequest.error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Success display */}
|
||||
{createRequest.isSuccess && (
|
||||
<div
|
||||
className={css({
|
||||
padding: '16px',
|
||||
backgroundColor: isDark ? 'green.900/20' : 'green.50',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'green.700' : 'green.200',
|
||||
marginBottom: '20px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'green.300' : 'green.700',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
Enrollment Request Sent!
|
||||
</p>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'green.400' : 'green.600',
|
||||
})}
|
||||
>
|
||||
The teacher will review and approve the enrollment.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className={css({ display: 'flex', gap: '12px' })}>
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={createRequest.isPending}
|
||||
data-action="cancel-enroll"
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.200',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.600' : 'gray.300',
|
||||
},
|
||||
_disabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!createRequest.isSuccess && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!classroom || !selectedChildId || createRequest.isPending}
|
||||
data-action="submit-enrollment"
|
||||
className={css({
|
||||
flex: 2,
|
||||
padding: '12px',
|
||||
backgroundColor:
|
||||
classroom && selectedChildId
|
||||
? isDark
|
||||
? 'green.700'
|
||||
: 'green.500'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'gray.300',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold',
|
||||
cursor: classroom && selectedChildId ? 'pointer' : 'not-allowed',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
backgroundColor:
|
||||
classroom && selectedChildId ? (isDark ? 'green.600' : 'green.600') : undefined,
|
||||
},
|
||||
_disabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{createRequest.isPending ? 'Enrolling...' : `Enroll ${selectedChild?.name || 'Child'}`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{createRequest.isSuccess && onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
data-action="done"
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
backgroundColor: isDark ? 'green.700' : 'green.500',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'green.600' : 'green.600',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* No children message */}
|
||||
{classroom && children.length === 0 && (
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
textAlign: 'center',
|
||||
marginTop: '16px',
|
||||
})}
|
||||
>
|
||||
You need to add a child first before enrolling in a classroom.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import type { Classroom } from '@/db/schema'
|
||||
import { useCallback, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Classroom, Player } from '@/db/schema'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import {
|
||||
useEnrolledStudents,
|
||||
usePendingEnrollmentRequests,
|
||||
useApproveEnrollmentRequest,
|
||||
useDenyEnrollmentRequest,
|
||||
useUnenrollStudent,
|
||||
type EnrollmentRequestWithRelations,
|
||||
} from '@/hooks/useClassroom'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { ClassroomCodeShare } from './ClassroomCodeShare'
|
||||
|
||||
@@ -12,14 +22,63 @@ interface StudentManagerTabProps {
|
||||
/**
|
||||
* StudentManagerTab - Manage enrolled students
|
||||
*
|
||||
* Shows all students enrolled in the classroom.
|
||||
* For Phase 3, this is an empty state.
|
||||
* Phase 4 will add enrollment functionality.
|
||||
* Shows all students enrolled in the classroom with options to:
|
||||
* - View student progress (links to student page)
|
||||
* - Approve/deny pending enrollment requests
|
||||
* - Unenroll students
|
||||
*/
|
||||
export function StudentManagerTab({ classroom }: StudentManagerTabProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
// Fetch enrolled students and pending requests
|
||||
const { data: students = [], isLoading: loadingStudents } = useEnrolledStudents(classroom.id)
|
||||
const { data: pendingRequests = [], isLoading: loadingRequests } = usePendingEnrollmentRequests(
|
||||
classroom.id
|
||||
)
|
||||
|
||||
// Mutations
|
||||
const approveRequest = useApproveEnrollmentRequest()
|
||||
const denyRequest = useDenyEnrollmentRequest()
|
||||
const unenrollStudent = useUnenrollStudent()
|
||||
|
||||
// Confirmation state
|
||||
const [confirmUnenroll, setConfirmUnenroll] = useState<string | null>(null)
|
||||
|
||||
const handleApprove = useCallback(
|
||||
(request: EnrollmentRequestWithRelations) => {
|
||||
approveRequest.mutate({
|
||||
classroomId: classroom.id,
|
||||
requestId: request.id,
|
||||
})
|
||||
},
|
||||
[approveRequest, classroom.id]
|
||||
)
|
||||
|
||||
const handleDeny = useCallback(
|
||||
(request: EnrollmentRequestWithRelations) => {
|
||||
denyRequest.mutate({
|
||||
classroomId: classroom.id,
|
||||
requestId: request.id,
|
||||
})
|
||||
},
|
||||
[denyRequest, classroom.id]
|
||||
)
|
||||
|
||||
const handleUnenroll = useCallback(
|
||||
(playerId: string) => {
|
||||
unenrollStudent.mutate({
|
||||
classroomId: classroom.id,
|
||||
playerId,
|
||||
})
|
||||
setConfirmUnenroll(null)
|
||||
},
|
||||
[unenrollStudent, classroom.id]
|
||||
)
|
||||
|
||||
const isLoading = loadingStudents || loadingRequests
|
||||
const isEmpty = students.length === 0 && pendingRequests.length === 0
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="student-manager-tab"
|
||||
@@ -29,88 +88,433 @@ export function StudentManagerTab({ classroom }: StudentManagerTabProps) {
|
||||
gap: '24px',
|
||||
})}
|
||||
>
|
||||
{/* Pending Enrollment Requests */}
|
||||
{pendingRequests.length > 0 && (
|
||||
<section
|
||||
data-section="pending-requests"
|
||||
className={css({
|
||||
padding: '20px',
|
||||
backgroundColor: isDark ? 'yellow.900/20' : 'yellow.50',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'yellow.700' : 'yellow.200',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'yellow.300' : 'yellow.700',
|
||||
marginBottom: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
})}
|
||||
>
|
||||
<span>Pending Enrollment Requests</span>
|
||||
<span
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '22px',
|
||||
height: '22px',
|
||||
padding: '0 6px',
|
||||
borderRadius: '11px',
|
||||
backgroundColor: isDark ? 'yellow.700' : 'yellow.500',
|
||||
color: 'white',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
{pendingRequests.length}
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '12px' })}>
|
||||
{pendingRequests.map((request) => (
|
||||
<EnrollmentRequestCard
|
||||
key={request.id}
|
||||
request={request}
|
||||
onApprove={() => handleApprove(request)}
|
||||
onDeny={() => handleDeny(request)}
|
||||
isApproving={
|
||||
approveRequest.isPending && approveRequest.variables?.requestId === request.id
|
||||
}
|
||||
isDenying={denyRequest.isPending && denyRequest.variables?.requestId === request.id}
|
||||
isDark={isDark}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Enrolled Students */}
|
||||
{students.length > 0 && (
|
||||
<section data-section="enrolled-students">
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
Enrolled Students ({students.length})
|
||||
</h3>
|
||||
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '12px' })}>
|
||||
{students.map((student) => (
|
||||
<EnrolledStudentCard
|
||||
key={student.id}
|
||||
student={student}
|
||||
onUnenroll={() => setConfirmUnenroll(student.id)}
|
||||
isConfirming={confirmUnenroll === student.id}
|
||||
onConfirmUnenroll={() => handleUnenroll(student.id)}
|
||||
onCancelUnenroll={() => setConfirmUnenroll(null)}
|
||||
isUnenrolling={
|
||||
unenrollStudent.isPending && unenrollStudent.variables?.playerId === student.id
|
||||
}
|
||||
isDark={isDark}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
padding: '48px 24px',
|
||||
backgroundColor: isDark ? 'gray.800' : 'gray.50',
|
||||
borderRadius: '16px',
|
||||
border: '2px dashed',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
{isEmpty && !isLoading && (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '3rem',
|
||||
marginBottom: '16px',
|
||||
textAlign: 'center',
|
||||
padding: '48px 24px',
|
||||
backgroundColor: isDark ? 'gray.800' : 'gray.50',
|
||||
borderRadius: '16px',
|
||||
border: '2px dashed',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
👥
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
No Students Enrolled
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.9375rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
marginBottom: '24px',
|
||||
maxWidth: '400px',
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
})}
|
||||
>
|
||||
Parents can enroll their children using your classroom code. Once enrolled, you can view
|
||||
their progress and skills here.
|
||||
</p>
|
||||
<div className={css({ fontSize: '3rem', marginBottom: '16px' })}>👥</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
No Students Enrolled
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.9375rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
marginBottom: '24px',
|
||||
maxWidth: '400px',
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
})}
|
||||
>
|
||||
Parents can enroll their children using your classroom code. Once enrolled, you can view
|
||||
their progress and skills here.
|
||||
</p>
|
||||
|
||||
<ClassroomCodeShare code={classroom.code} />
|
||||
<ClassroomCodeShare code={classroom.code} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info section */}
|
||||
{students.length > 0 && (
|
||||
<div
|
||||
className={css({
|
||||
padding: '16px 20px',
|
||||
backgroundColor: isDark ? 'blue.900/30' : 'blue.50',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'blue.800' : 'blue.200',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'blue.200' : 'blue.700',
|
||||
})}
|
||||
>
|
||||
Click on a student to view their skills and practice history. Need more students? Share
|
||||
your classroom code: <strong>{classroom.code}</strong>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sub-components
|
||||
// ============================================================================
|
||||
|
||||
interface EnrollmentRequestCardProps {
|
||||
request: EnrollmentRequestWithRelations
|
||||
onApprove: () => void
|
||||
onDeny: () => void
|
||||
isApproving: boolean
|
||||
isDenying: boolean
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
function EnrollmentRequestCard({
|
||||
request,
|
||||
onApprove,
|
||||
onDeny,
|
||||
isApproving,
|
||||
isDenying,
|
||||
isDark,
|
||||
}: EnrollmentRequestCardProps) {
|
||||
const player = request.player
|
||||
|
||||
return (
|
||||
<div
|
||||
data-element="enrollment-request-card"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '14px 16px',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '12px' })}>
|
||||
<span
|
||||
className={css({
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '1.25rem',
|
||||
})}
|
||||
style={{ backgroundColor: player?.color ?? '#ccc' }}
|
||||
>
|
||||
{player?.emoji ?? '?'}
|
||||
</span>
|
||||
<div>
|
||||
<p
|
||||
className={css({
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
})}
|
||||
>
|
||||
{player?.name ?? 'Unknown Student'}
|
||||
</p>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
})}
|
||||
>
|
||||
Enrollment requested by parent
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What you'll see when students enroll */}
|
||||
<div
|
||||
className={css({
|
||||
padding: '20px',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<h4
|
||||
className={css({
|
||||
fontSize: '0.9375rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
marginBottom: '12px',
|
||||
})}
|
||||
>
|
||||
When students enroll, you can:
|
||||
</h4>
|
||||
<ul
|
||||
<div className={css({ display: 'flex', gap: '8px' })}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDeny}
|
||||
disabled={isDenying || isApproving}
|
||||
data-action="deny-enrollment"
|
||||
className={css({
|
||||
padding: '8px 14px',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.200',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.300' : 'gray.600',
|
||||
paddingLeft: '20px',
|
||||
listStyleType: 'disc',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
_hover: { backgroundColor: isDark ? 'gray.600' : 'gray.300' },
|
||||
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
|
||||
})}
|
||||
>
|
||||
<li>View each student's skill mastery and progress</li>
|
||||
<li>See their practice session history</li>
|
||||
<li>Observe live practice sessions</li>
|
||||
<li>Guide them through tutorials remotely</li>
|
||||
</ul>
|
||||
{isDenying ? 'Denying...' : 'Deny'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onApprove}
|
||||
disabled={isApproving || isDenying}
|
||||
data-action="approve-enrollment"
|
||||
className={css({
|
||||
padding: '8px 14px',
|
||||
backgroundColor: isDark ? 'green.700' : 'green.500',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
_hover: { backgroundColor: isDark ? 'green.600' : 'green.600' },
|
||||
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
|
||||
})}
|
||||
>
|
||||
{isApproving ? 'Approving...' : 'Approve'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface EnrolledStudentCardProps {
|
||||
student: Player
|
||||
onUnenroll: () => void
|
||||
isConfirming: boolean
|
||||
onConfirmUnenroll: () => void
|
||||
onCancelUnenroll: () => void
|
||||
isUnenrolling: boolean
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
function EnrolledStudentCard({
|
||||
student,
|
||||
onUnenroll,
|
||||
isConfirming,
|
||||
onConfirmUnenroll,
|
||||
onCancelUnenroll,
|
||||
isUnenrolling,
|
||||
isDark,
|
||||
}: EnrolledStudentCardProps) {
|
||||
return (
|
||||
<div
|
||||
data-element="enrolled-student-card"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '14px 16px',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
transition: 'all 0.15s ease',
|
||||
})}
|
||||
>
|
||||
<Link
|
||||
href={`/practice/${student.id}/resume`}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
textDecoration: 'none',
|
||||
flex: 1,
|
||||
_hover: { opacity: 0.8 },
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '1.25rem',
|
||||
})}
|
||||
style={{ backgroundColor: student.color }}
|
||||
>
|
||||
{student.emoji}
|
||||
</span>
|
||||
<div>
|
||||
<p
|
||||
className={css({
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
})}
|
||||
>
|
||||
{student.name}
|
||||
</p>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
color: isDark ? 'blue.400' : 'blue.600',
|
||||
})}
|
||||
>
|
||||
View progress
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Unenroll actions */}
|
||||
<div className={css({ display: 'flex', gap: '8px' })}>
|
||||
{isConfirming ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancelUnenroll}
|
||||
disabled={isUnenrolling}
|
||||
data-action="cancel-unenroll"
|
||||
className={css({
|
||||
padding: '8px 14px',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.200',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
_hover: { backgroundColor: isDark ? 'gray.600' : 'gray.300' },
|
||||
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
|
||||
})}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirmUnenroll}
|
||||
disabled={isUnenrolling}
|
||||
data-action="confirm-unenroll"
|
||||
className={css({
|
||||
padding: '8px 14px',
|
||||
backgroundColor: isDark ? 'red.700' : 'red.500',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
_hover: { backgroundColor: isDark ? 'red.600' : 'red.600' },
|
||||
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
|
||||
})}
|
||||
>
|
||||
{isUnenrolling ? 'Removing...' : 'Confirm'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUnenroll}
|
||||
data-action="unenroll-student"
|
||||
className={css({
|
||||
padding: '8px 14px',
|
||||
backgroundColor: 'transparent',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.300',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.8125rem',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.100',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.400',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Unenroll
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,4 +2,5 @@ export { ClassroomCodeShare } from './ClassroomCodeShare'
|
||||
export { ClassroomDashboard } from './ClassroomDashboard'
|
||||
export { ClassroomTab } from './ClassroomTab'
|
||||
export { CreateClassroomForm } from './CreateClassroomForm'
|
||||
export { EnrollChildFlow } from './EnrollChildFlow'
|
||||
export { StudentManagerTab } from './StudentManagerTab'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { Classroom, User } from '@/db/schema'
|
||||
import type { Classroom, EnrollmentRequest, Player, User } from '@/db/schema'
|
||||
import { api } from '@/lib/queryClient'
|
||||
import { classroomKeys } from '@/lib/queryKeys'
|
||||
|
||||
@@ -16,6 +16,11 @@ export interface ClassroomWithTeacher extends Classroom {
|
||||
teacher?: User
|
||||
}
|
||||
|
||||
export interface EnrollmentRequestWithRelations extends EnrollmentRequest {
|
||||
player?: Player
|
||||
classroom?: Classroom
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Functions
|
||||
// ============================================================================
|
||||
@@ -118,3 +123,203 @@ export function useIsTeacher() {
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Enrollment API Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch enrolled students for a classroom
|
||||
*/
|
||||
async function fetchEnrolledStudents(classroomId: string): Promise<Player[]> {
|
||||
const res = await api(`classrooms/${classroomId}/enrollments`)
|
||||
if (!res.ok) throw new Error('Failed to fetch enrolled students')
|
||||
const data = await res.json()
|
||||
return data.students
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch pending enrollment requests for a classroom
|
||||
*/
|
||||
async function fetchPendingRequests(
|
||||
classroomId: string
|
||||
): Promise<EnrollmentRequestWithRelations[]> {
|
||||
const res = await api(`classrooms/${classroomId}/enrollment-requests`)
|
||||
if (!res.ok) throw new Error('Failed to fetch enrollment requests')
|
||||
const data = await res.json()
|
||||
return data.requests
|
||||
}
|
||||
|
||||
/**
|
||||
* Create enrollment request
|
||||
*/
|
||||
async function createEnrollmentRequest(params: {
|
||||
classroomId: string
|
||||
playerId: string
|
||||
}): Promise<EnrollmentRequest> {
|
||||
const res = await api(`classrooms/${params.classroomId}/enrollment-requests`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ playerId: params.playerId }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || 'Failed to create enrollment request')
|
||||
}
|
||||
const data = await res.json()
|
||||
return data.request
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve enrollment request
|
||||
*/
|
||||
async function approveRequest(params: {
|
||||
classroomId: string
|
||||
requestId: string
|
||||
}): Promise<{ request: EnrollmentRequest; fullyApproved: boolean }> {
|
||||
const res = await api(
|
||||
`classrooms/${params.classroomId}/enrollment-requests/${params.requestId}/approve`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || 'Failed to approve request')
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deny enrollment request
|
||||
*/
|
||||
async function denyRequest(params: {
|
||||
classroomId: string
|
||||
requestId: string
|
||||
}): Promise<EnrollmentRequest> {
|
||||
const res = await api(
|
||||
`classrooms/${params.classroomId}/enrollment-requests/${params.requestId}/deny`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || 'Failed to deny request')
|
||||
}
|
||||
const data = await res.json()
|
||||
return data.request
|
||||
}
|
||||
|
||||
/**
|
||||
* Unenroll student from classroom
|
||||
*/
|
||||
async function unenrollStudent(params: { classroomId: string; playerId: string }): Promise<void> {
|
||||
const res = await api(`classrooms/${params.classroomId}/enrollments/${params.playerId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || 'Failed to unenroll student')
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Enrollment Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get enrolled students in a classroom
|
||||
*/
|
||||
export function useEnrolledStudents(classroomId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: classroomKeys.enrollments(classroomId ?? ''),
|
||||
queryFn: () => fetchEnrolledStudents(classroomId!),
|
||||
enabled: !!classroomId,
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending enrollment requests for a classroom
|
||||
*/
|
||||
export function usePendingEnrollmentRequests(classroomId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...classroomKeys.detail(classroomId ?? ''), 'pending-requests'],
|
||||
queryFn: () => fetchPendingRequests(classroomId!),
|
||||
enabled: !!classroomId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create enrollment request mutation
|
||||
*
|
||||
* Used by parents to request enrollment of their child in a classroom.
|
||||
*/
|
||||
export function useCreateEnrollmentRequest() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: createEnrollmentRequest,
|
||||
onSuccess: (_, { classroomId }) => {
|
||||
// Invalidate pending requests for this classroom
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...classroomKeys.detail(classroomId), 'pending-requests'],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve enrollment request mutation (for teachers)
|
||||
*/
|
||||
export function useApproveEnrollmentRequest() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: approveRequest,
|
||||
onSuccess: (result, { classroomId }) => {
|
||||
// Invalidate pending requests
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...classroomKeys.detail(classroomId), 'pending-requests'],
|
||||
})
|
||||
// If fully approved, also invalidate enrollments
|
||||
if (result.fullyApproved) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: classroomKeys.enrollments(classroomId),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Deny enrollment request mutation (for teachers)
|
||||
*/
|
||||
export function useDenyEnrollmentRequest() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: denyRequest,
|
||||
onSuccess: (_, { classroomId }) => {
|
||||
// Invalidate pending requests
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...classroomKeys.detail(classroomId), 'pending-requests'],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Unenroll student mutation
|
||||
*/
|
||||
export function useUnenrollStudent() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: unenrollStudent,
|
||||
onSuccess: (_, { classroomId }) => {
|
||||
// Invalidate enrollments
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: classroomKeys.enrollments(classroomId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user