feat(classroom): add unified TeacherClassroomCard with auto-enrollment
- Create TeacherClassroomCard component that combines: - Editable classroom name - Share code chip - Add student button (auto-enrolls) - Settings popover - Embedded filter tabs (Enrolled/Present/Active) - Add auto-enrollment when teachers create students from classroom: - AddStudentModal accepts classroomId/classroomName props - Shows notice: "This student will be auto-enrolled in..." - Button text changes to "Add & Enroll" - Calls directEnrollStudent API after creation - Add directEnrollStudent functionality: - New function in enrollment-manager.ts - POST /api/classrooms/[id]/enrollments endpoint - useDirectEnrollStudent hook in useClassroom.ts - Refactor filter bar layout: - Move TeacherClassroomCard inside ViewSelector - Align filter chips to bottom - Match border radius and padding consistency 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { getEnrolledStudents, getTeacherClassroom } from '@/lib/classroom'
|
||||
import { directEnrollStudent, getEnrolledStudents, getTeacherClassroom } from '@/lib/classroom'
|
||||
import { emitEnrollmentCompleted } from '@/lib/classroom/socket-emitter'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
@@ -50,3 +51,77 @@ export async function GET(req: NextRequest, { params }: RouteParams) {
|
||||
return NextResponse.json({ error: 'Failed to fetch enrolled students' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/classrooms/[classroomId]/enrollments
|
||||
* Directly enroll a student (teacher only, bypasses request workflow)
|
||||
*
|
||||
* Body: { playerId: string }
|
||||
* Returns: { enrolled: boolean }
|
||||
*/
|
||||
export async function POST(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(user.id)
|
||||
if (!classroom || classroom.id !== classroomId) {
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { playerId } = body
|
||||
|
||||
if (!playerId) {
|
||||
return NextResponse.json({ error: 'playerId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify the player exists and belongs to this teacher
|
||||
const player = await db.query.players.findFirst({
|
||||
where: eq(schema.players.id, playerId),
|
||||
})
|
||||
|
||||
if (!player) {
|
||||
return NextResponse.json({ error: 'Player not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Verify teacher owns this player
|
||||
if (player.userId !== user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Can only directly enroll students you created' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Directly enroll the student
|
||||
const enrolled = await directEnrollStudent(classroomId, playerId)
|
||||
|
||||
if (enrolled) {
|
||||
// Emit socket event for real-time updates
|
||||
try {
|
||||
await emitEnrollmentCompleted(
|
||||
{
|
||||
classroomId,
|
||||
classroomName: classroom.name,
|
||||
playerId,
|
||||
playerName: player.name,
|
||||
},
|
||||
{
|
||||
classroomId,
|
||||
userIds: [], // No parents to notify since teacher created this student
|
||||
playerIds: [playerId],
|
||||
}
|
||||
)
|
||||
} catch (socketError) {
|
||||
console.error('[DirectEnroll] Failed to emit socket event:', socketError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ enrolled })
|
||||
} catch (error) {
|
||||
console.error('Failed to directly enroll student:', error)
|
||||
return NextResponse.json({ error: 'Failed to enroll student' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { EmojiPicker } from '@/components/EmojiPicker'
|
||||
import { LinkChildForm } from '@/components/family'
|
||||
import { PLAYER_EMOJIS } from '@/constants/playerEmojis'
|
||||
import { useDirectEnrollStudent } from '@/hooks/useClassroom'
|
||||
import { useCreatePlayer } from '@/hooks/useUserPlayers'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
@@ -27,13 +28,23 @@ interface AddStudentModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
isDark: boolean
|
||||
/** If provided, student will be auto-enrolled in this classroom */
|
||||
classroomId?: string
|
||||
/** Name of the classroom for display */
|
||||
classroomName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for adding a new student
|
||||
* Uses React Query mutation for proper cache management
|
||||
*/
|
||||
export function AddStudentModal({ isOpen, onClose, isDark }: AddStudentModalProps) {
|
||||
export function AddStudentModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
isDark,
|
||||
classroomId,
|
||||
classroomName,
|
||||
}: AddStudentModalProps) {
|
||||
// Form state
|
||||
const [formName, setFormName] = useState('')
|
||||
const [formEmoji, setFormEmoji] = useState(PLAYER_EMOJIS[0])
|
||||
@@ -41,8 +52,9 @@ export function AddStudentModal({ isOpen, onClose, isDark }: AddStudentModalProp
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
const [showLinkForm, setShowLinkForm] = useState(false)
|
||||
|
||||
// React Query mutation
|
||||
// React Query mutations
|
||||
const createPlayer = useCreatePlayer()
|
||||
const directEnroll = useDirectEnrollStudent()
|
||||
|
||||
// Reset form and pick random emoji/color when opened
|
||||
useEffect(() => {
|
||||
@@ -66,12 +78,26 @@ export function AddStudentModal({ isOpen, onClose, isDark }: AddStudentModalProp
|
||||
color: formColor,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onClose()
|
||||
onSuccess: (player) => {
|
||||
// If classroomId is provided, auto-enroll the new student
|
||||
if (classroomId && player) {
|
||||
directEnroll.mutate(
|
||||
{ classroomId, playerId: player.id },
|
||||
{
|
||||
onSettled: () => {
|
||||
onClose()
|
||||
},
|
||||
}
|
||||
)
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
}, [formName, formEmoji, formColor, createPlayer, onClose])
|
||||
}, [formName, formEmoji, formColor, createPlayer, classroomId, directEnroll, onClose])
|
||||
|
||||
const isPending = createPlayer.isPending || directEnroll.isPending
|
||||
|
||||
// Handle keyboard events
|
||||
const handleKeyDown = useCallback(
|
||||
@@ -82,16 +108,11 @@ export function AddStudentModal({ isOpen, onClose, isDark }: AddStudentModalProp
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
} else if (
|
||||
e.key === 'Enter' &&
|
||||
formName.trim() &&
|
||||
!createPlayer.isPending &&
|
||||
!showEmojiPicker
|
||||
) {
|
||||
} else if (e.key === 'Enter' && formName.trim() && !isPending && !showEmojiPicker) {
|
||||
handleSubmit()
|
||||
}
|
||||
},
|
||||
[formName, createPlayer.isPending, handleSubmit, onClose, showEmojiPicker]
|
||||
[formName, isPending, handleSubmit, onClose, showEmojiPicker]
|
||||
)
|
||||
|
||||
if (!isOpen) return null
|
||||
@@ -167,7 +188,7 @@ export function AddStudentModal({ isOpen, onClose, isDark }: AddStudentModalProp
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '1.5rem',
|
||||
marginBottom: classroomId ? '0.75rem' : '1.5rem',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
@@ -178,7 +199,7 @@ export function AddStudentModal({ isOpen, onClose, isDark }: AddStudentModalProp
|
||||
color: isDark ? 'gray.100' : 'gray.800',
|
||||
})}
|
||||
>
|
||||
Add New Student
|
||||
{classroomId ? 'Add Student to Classroom' : 'Add New Student'}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@@ -206,6 +227,36 @@ export function AddStudentModal({ isOpen, onClose, isDark }: AddStudentModalProp
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Classroom notice - shown when adding to a classroom */}
|
||||
{classroomId && (
|
||||
<div
|
||||
data-element="classroom-enrollment-notice"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '10px 12px',
|
||||
marginBottom: '1rem',
|
||||
backgroundColor: isDark ? 'blue.900/50' : 'blue.50',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'blue.700' : 'blue.200',
|
||||
borderRadius: '8px',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: '16px', flexShrink: 0 })}>📚</span>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'blue.200' : 'blue.700',
|
||||
lineHeight: '1.4',
|
||||
})}
|
||||
>
|
||||
This student will be automatically enrolled in{' '}
|
||||
<strong>{classroomName || 'your classroom'}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Avatar Preview - clickable to open full picker */}
|
||||
<div
|
||||
className={css({
|
||||
@@ -347,7 +398,7 @@ export function AddStudentModal({ isOpen, onClose, isDark }: AddStudentModalProp
|
||||
type="button"
|
||||
data-action="cancel"
|
||||
onClick={onClose}
|
||||
disabled={createPlayer.isPending}
|
||||
disabled={isPending}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '0.75rem',
|
||||
@@ -372,19 +423,19 @@ export function AddStudentModal({ isOpen, onClose, isDark }: AddStudentModalProp
|
||||
type="button"
|
||||
data-action="add-student"
|
||||
onClick={handleSubmit}
|
||||
disabled={createPlayer.isPending || !formName.trim()}
|
||||
disabled={isPending || !formName.trim()}
|
||||
className={css({
|
||||
flex: 2,
|
||||
padding: '0.75rem',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
backgroundColor: createPlayer.isPending ? 'gray.400' : 'green.500',
|
||||
backgroundColor: isPending ? 'gray.400' : 'green.500',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
cursor: createPlayer.isPending ? 'not-allowed' : 'pointer',
|
||||
cursor: isPending ? 'not-allowed' : 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: createPlayer.isPending ? 'gray.400' : 'green.600',
|
||||
backgroundColor: isPending ? 'gray.400' : 'green.600',
|
||||
},
|
||||
_disabled: {
|
||||
opacity: 0.5,
|
||||
@@ -392,7 +443,13 @@ export function AddStudentModal({ isOpen, onClose, isDark }: AddStudentModalProp
|
||||
},
|
||||
})}
|
||||
>
|
||||
{createPlayer.isPending ? 'Adding...' : 'Add Student'}
|
||||
{isPending
|
||||
? classroomId
|
||||
? 'Adding & Enrolling...'
|
||||
: 'Adding...'
|
||||
: classroomId
|
||||
? 'Add & Enroll'
|
||||
: 'Add Student'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -111,6 +111,8 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
|
||||
|
||||
// Add student modal state (parent mode - create new child)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
// Track if we're adding to classroom (auto-enroll mode)
|
||||
const [addToClassroomMode, setAddToClassroomMode] = useState(false)
|
||||
|
||||
// Add student modal state (teacher mode - add by family code)
|
||||
const [showAddByFamilyCode, setShowAddByFamilyCode] = useState(false)
|
||||
@@ -297,13 +299,21 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
|
||||
}
|
||||
}, [promptEligibleIds, bulkEntryPrompt, showSuccess, showError])
|
||||
|
||||
// Handle add student - show create modal for both teachers and parents
|
||||
// Handle add student - show create modal (parent mode, no auto-enroll)
|
||||
const handleAddStudent = useCallback(() => {
|
||||
setAddToClassroomMode(false)
|
||||
setShowAddModal(true)
|
||||
}, [])
|
||||
|
||||
// Handle add student to classroom - show create modal with auto-enroll
|
||||
const handleAddStudentToClassroom = useCallback(() => {
|
||||
setAddToClassroomMode(true)
|
||||
setShowAddModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseAddModal = useCallback(() => {
|
||||
setShowAddModal(false)
|
||||
setAddToClassroomMode(false)
|
||||
}, [])
|
||||
|
||||
// Handle session observation - find the student and open observer modal
|
||||
@@ -388,6 +398,7 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
|
||||
onShowArchivedChange={setShowArchived}
|
||||
archivedCount={archivedCount}
|
||||
onAddStudent={handleAddStudent}
|
||||
onAddStudentToClassroom={isTeacher ? handleAddStudentToClassroom : undefined}
|
||||
selectedCount={selectedIds.size}
|
||||
onBulkArchive={handleBulkArchive}
|
||||
onBulkPromptToEnter={isTeacher ? handleBulkPromptToEnter : undefined}
|
||||
@@ -692,8 +703,14 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Add Student Modal (Parent - create new child) */}
|
||||
<AddStudentModal isOpen={showAddModal} onClose={handleCloseAddModal} isDark={isDark} />
|
||||
{/* Add Student Modal (Parent - create new child, or Teacher - create & enroll) */}
|
||||
<AddStudentModal
|
||||
isOpen={showAddModal}
|
||||
onClose={handleCloseAddModal}
|
||||
isDark={isDark}
|
||||
classroomId={addToClassroomMode ? classroomId : undefined}
|
||||
classroomName={addToClassroomMode ? classroom?.name : undefined}
|
||||
/>
|
||||
|
||||
{/* Add Student Modal (Teacher - add by family code) */}
|
||||
{classroomId && (
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type SkillSearchResult,
|
||||
} from '@/utils/skillSearch'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { ViewSelector, type StudentView } from './ViewSelector'
|
||||
import { ViewSelector, TeacherCompoundChip, type StudentView } from './ViewSelector'
|
||||
|
||||
interface StudentFilterBarProps {
|
||||
/** Currently selected view */
|
||||
@@ -42,8 +42,10 @@ interface StudentFilterBarProps {
|
||||
onShowArchivedChange: (show: boolean) => void
|
||||
/** Number of archived students (for badge) */
|
||||
archivedCount: number
|
||||
/** Callback when add student button is clicked */
|
||||
/** Callback when add student button is clicked (parent mode - no auto-enroll) */
|
||||
onAddStudent?: () => void
|
||||
/** Callback when add student button is clicked from classroom controls (auto-enrolls) */
|
||||
onAddStudentToClassroom?: () => void
|
||||
/** Number of selected students (for bulk actions bar) */
|
||||
selectedCount?: number
|
||||
/** Callback when bulk archive is clicked */
|
||||
@@ -80,6 +82,7 @@ export function StudentFilterBar({
|
||||
onShowArchivedChange,
|
||||
archivedCount,
|
||||
onAddStudent,
|
||||
onAddStudentToClassroom,
|
||||
selectedCount = 0,
|
||||
onBulkArchive,
|
||||
onBulkPromptToEnter,
|
||||
@@ -183,15 +186,26 @@ export function StudentFilterBar({
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
{/* View selector with optional classroom card inline */}
|
||||
<ViewSelector
|
||||
currentView={currentView}
|
||||
onViewChange={onViewChange}
|
||||
availableViews={availableViews}
|
||||
viewCounts={viewCounts}
|
||||
hideTeacherCompound={!!classroom}
|
||||
classroomCard={
|
||||
classroom ? (
|
||||
<TeacherClassroomCard
|
||||
classroom={classroom}
|
||||
currentView={currentView}
|
||||
onViewChange={onViewChange}
|
||||
availableViews={availableViews}
|
||||
viewCounts={viewCounts}
|
||||
onAddStudentToClassroom={onAddStudentToClassroom}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Classroom code and settings - teachers only */}
|
||||
{classroom && <ClassroomChipWithSettings classroom={classroom} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -514,8 +528,8 @@ export function StudentFilterBar({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Add Student FAB */}
|
||||
{onAddStudent && (
|
||||
{/* Add Student FAB - only for parents (teachers have button in classroom card) */}
|
||||
{onAddStudent && !classroom && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddStudent}
|
||||
@@ -641,8 +655,367 @@ const EXPIRY_OPTIONS = [
|
||||
{ value: 120, label: '2 hours' },
|
||||
] as const
|
||||
|
||||
interface TeacherClassroomCardProps {
|
||||
classroom: Classroom
|
||||
currentView: StudentView
|
||||
onViewChange: (view: StudentView) => void
|
||||
availableViews: StudentView[]
|
||||
viewCounts?: Partial<Record<StudentView, number>>
|
||||
/** Callback for adding student (auto-enrolls in classroom) */
|
||||
onAddStudentToClassroom?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Classroom share chip with settings popover
|
||||
* Unified classroom control card for teachers
|
||||
*
|
||||
* ┌──────────────────────────────────────────────────────────────────┐
|
||||
* │ 📚 Mrs. Smith's Class [ABC-123] [+Student] [⚙️] │
|
||||
* ├──────────────────────────────────────────────────────────────────┤
|
||||
* │ [📋 Enrolled (10)] [🏫 Present (5)] [🎯 Active (2)] │
|
||||
* └──────────────────────────────────────────────────────────────────┘
|
||||
*/
|
||||
function TeacherClassroomCard({
|
||||
classroom,
|
||||
currentView,
|
||||
onViewChange,
|
||||
availableViews,
|
||||
viewCounts = {},
|
||||
onAddStudentToClassroom,
|
||||
}: TeacherClassroomCardProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const shareCode = useShareCode({ type: 'classroom', code: classroom.code })
|
||||
const updateClassroom = useUpdateClassroom()
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [nameValue, setNameValue] = useState(classroom.name)
|
||||
const nameInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Reset name value when classroom changes or popover opens
|
||||
useEffect(() => {
|
||||
setNameValue(classroom.name)
|
||||
setEditingName(false)
|
||||
}, [classroom.name, isSettingsOpen])
|
||||
|
||||
// Focus input when editing starts
|
||||
useEffect(() => {
|
||||
if (editingName && nameInputRef.current) {
|
||||
nameInputRef.current.focus()
|
||||
nameInputRef.current.select()
|
||||
}
|
||||
}, [editingName])
|
||||
|
||||
const handleNameSave = useCallback(() => {
|
||||
const trimmedName = nameValue.trim()
|
||||
if (trimmedName && trimmedName !== classroom.name) {
|
||||
updateClassroom.mutate({
|
||||
classroomId: classroom.id,
|
||||
name: trimmedName,
|
||||
})
|
||||
}
|
||||
setEditingName(false)
|
||||
}, [classroom.id, classroom.name, nameValue, updateClassroom])
|
||||
|
||||
const handleExpiryChange = useCallback(
|
||||
(value: number | null) => {
|
||||
updateClassroom.mutate({
|
||||
classroomId: classroom.id,
|
||||
entryPromptExpiryMinutes: value,
|
||||
})
|
||||
},
|
||||
[classroom.id, updateClassroom]
|
||||
)
|
||||
|
||||
const currentExpiry = classroom.entryPromptExpiryMinutes
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="teacher-classroom-card"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
bg: isDark ? 'gray.750' : 'gray.50',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
{/* Header row: Classroom name + actions */}
|
||||
<div
|
||||
data-element="classroom-card-header"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '12px',
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
{/* Classroom name - editable */}
|
||||
<div
|
||||
data-element="classroom-name"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: '14px', flexShrink: 0 })}>📚</span>
|
||||
{editingName ? (
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={nameValue}
|
||||
onChange={(e) => setNameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleNameSave()
|
||||
} else if (e.key === 'Escape') {
|
||||
setNameValue(classroom.name)
|
||||
setEditingName(false)
|
||||
}
|
||||
}}
|
||||
onBlur={handleNameSave}
|
||||
disabled={updateClassroom.isPending}
|
||||
className={css({
|
||||
flex: 1,
|
||||
minWidth: '100px',
|
||||
padding: '2px 6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.500',
|
||||
backgroundColor: isDark ? 'gray.700' : 'white',
|
||||
color: isDark ? 'gray.100' : 'gray.800',
|
||||
outline: 'none',
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingName(true)}
|
||||
className={css({
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'gray.100' : 'gray.800',
|
||||
cursor: 'pointer',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '4px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
_hover: {
|
||||
bg: isDark ? 'gray.600' : 'gray.200',
|
||||
},
|
||||
})}
|
||||
title="Click to edit classroom name"
|
||||
>
|
||||
{classroom.name}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div
|
||||
data-element="classroom-actions"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
{/* Share code chip */}
|
||||
<ShareCodePanel shareCode={shareCode} compact showRegenerate={false} />
|
||||
|
||||
{/* Add student button - auto-enrolls in classroom */}
|
||||
{onAddStudentToClassroom && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddStudentToClassroom}
|
||||
data-action="add-student-to-classroom"
|
||||
title="Add Student to Classroom"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'green.700' : 'green.300',
|
||||
backgroundColor: isDark ? 'green.900' : 'green.50',
|
||||
color: isDark ? 'green.400' : 'green.600',
|
||||
fontSize: '16px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'green.800' : 'green.100',
|
||||
borderColor: isDark ? 'green.600' : 'green.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Settings button with popover */}
|
||||
<Popover.Root open={isSettingsOpen} onOpenChange={setIsSettingsOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-action="open-classroom-settings"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
backgroundColor: isDark ? 'gray.700' : 'white',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.600' : 'gray.100',
|
||||
borderColor: isDark ? 'gray.500' : 'gray.400',
|
||||
},
|
||||
})}
|
||||
aria-label="Classroom settings"
|
||||
>
|
||||
⚙️
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
data-component="classroom-settings-popover"
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className={css({
|
||||
width: '240px',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
boxShadow: 'lg',
|
||||
padding: '12px',
|
||||
zIndex: Z_INDEX.POPOVER,
|
||||
animation: 'fadeIn 0.15s ease',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
marginBottom: '12px',
|
||||
})}
|
||||
>
|
||||
Classroom Settings
|
||||
</h3>
|
||||
|
||||
{/* Entry prompt expiry setting */}
|
||||
<div data-setting="entry-prompt-expiry">
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
Entry prompt expires after
|
||||
</label>
|
||||
<select
|
||||
value={currentExpiry ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
handleExpiryChange(val === '' ? null : Number(val))
|
||||
}}
|
||||
disabled={updateClassroom.isPending}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '6px 8px',
|
||||
fontSize: '13px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
backgroundColor: isDark ? 'gray.700' : 'white',
|
||||
color: isDark ? 'gray.100' : 'gray.800',
|
||||
cursor: updateClassroom.isPending ? 'wait' : 'pointer',
|
||||
opacity: updateClassroom.isPending ? 0.7 : 1,
|
||||
_focus: {
|
||||
outline: '2px solid',
|
||||
outlineColor: 'blue.500',
|
||||
outlineOffset: '1px',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{EXPIRY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value ?? 'default'} value={opt.value ?? ''}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '11px',
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
marginTop: '4px',
|
||||
lineHeight: '1.4',
|
||||
})}
|
||||
>
|
||||
How long parents have to respond before the entry prompt expires
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Popover.Arrow
|
||||
className={css({
|
||||
fill: isDark ? 'gray.800' : 'white',
|
||||
})}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter row: Embedded compound chip */}
|
||||
<div
|
||||
data-element="classroom-card-filters"
|
||||
className={css({
|
||||
padding: '6px 8px',
|
||||
})}
|
||||
>
|
||||
<TeacherCompoundChip
|
||||
currentView={currentView}
|
||||
onViewChange={onViewChange}
|
||||
viewCounts={viewCounts}
|
||||
availableViews={availableViews}
|
||||
embedded
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Classroom share chip with settings popover (legacy, keeping for reference)
|
||||
* @deprecated Use TeacherClassroomCard instead
|
||||
*/
|
||||
function ClassroomChipWithSettings({ classroom }: { classroom: Classroom }) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
@@ -693,7 +1066,7 @@ function ClassroomChipWithSettings({ classroom }: { classroom: Classroom }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
data-element="classroom-chip-with-settings"
|
||||
data-element="classroom-share-and-settings"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -706,7 +1079,7 @@ function ClassroomChipWithSettings({ classroom }: { classroom: Classroom }) {
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-action="classroom-settings"
|
||||
data-action="open-classroom-settings"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
@@ -91,6 +92,10 @@ interface ViewSelectorProps {
|
||||
availableViews: StudentView[]
|
||||
/** Counts per view (e.g., { all: 5, 'my-children': 3 }) */
|
||||
viewCounts?: Partial<Record<StudentView, number>>
|
||||
/** Hide the teacher compound chip (when rendered externally in a card) */
|
||||
hideTeacherCompound?: boolean
|
||||
/** Optional classroom card to render inline (for teachers) */
|
||||
classroomCard?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,6 +109,8 @@ export function ViewSelector({
|
||||
onViewChange,
|
||||
availableViews,
|
||||
viewCounts = {},
|
||||
hideTeacherCompound = false,
|
||||
classroomCard,
|
||||
}: ViewSelectorProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
@@ -133,7 +140,7 @@ export function ViewSelector({
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-end',
|
||||
// Mobile: horizontal scroll, Desktop: wrap
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
@@ -193,7 +200,7 @@ export function ViewSelector({
|
||||
})}
|
||||
|
||||
{/* Teacher compound chip: Enrolled → In Classroom → Active */}
|
||||
{hasTeacherCompound && (
|
||||
{hasTeacherCompound && !hideTeacherCompound && (
|
||||
<TeacherCompoundChip
|
||||
currentView={currentView}
|
||||
onViewChange={onViewChange}
|
||||
@@ -202,6 +209,9 @@ export function ViewSelector({
|
||||
isDark={isDark}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Classroom card (for teachers) - rendered inline last */}
|
||||
{classroomCard}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -515,12 +525,14 @@ function CompoundChip({
|
||||
)
|
||||
}
|
||||
|
||||
interface TeacherCompoundChipProps {
|
||||
export interface TeacherCompoundChipProps {
|
||||
currentView: StudentView
|
||||
onViewChange: (view: StudentView) => void
|
||||
viewCounts: Partial<Record<StudentView, number>>
|
||||
availableViews: StudentView[]
|
||||
isDark: boolean
|
||||
isDark?: boolean
|
||||
/** When true, removes outer border/radius (for embedding in a card) */
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -531,13 +543,16 @@ interface TeacherCompoundChipProps {
|
||||
* └──────────────────────────────────────────────────────────┘
|
||||
* ↑ all enrolled ↑ in classroom ↑ practicing
|
||||
*/
|
||||
function TeacherCompoundChip({
|
||||
export function TeacherCompoundChip({
|
||||
currentView,
|
||||
onViewChange,
|
||||
viewCounts,
|
||||
availableViews,
|
||||
isDark,
|
||||
isDark: isDarkProp,
|
||||
embedded = false,
|
||||
}: TeacherCompoundChipProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = isDarkProp ?? resolvedTheme === 'dark'
|
||||
const enrolledConfig = VIEW_CONFIGS.find((c) => c.id === 'enrolled')!
|
||||
const inClassroomConfig = VIEW_CONFIGS.find((c) => c.id === 'in-classroom')!
|
||||
const activeConfig = VIEW_CONFIGS.find((c) => c.id === 'in-classroom-active')!
|
||||
@@ -554,6 +569,7 @@ function TeacherCompoundChip({
|
||||
<div
|
||||
data-component="teacher-compound-chip"
|
||||
data-active={isAnyActive}
|
||||
data-embedded={embedded}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
@@ -645,10 +661,10 @@ function ChipSegment({
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '5px',
|
||||
padding: '6px 10px',
|
||||
gap: '6px',
|
||||
padding: '6px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 'medium',
|
||||
transition: 'all 0.15s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
|
||||
@@ -74,8 +74,14 @@ export { StudentSelector } from './StudentSelector'
|
||||
export { StudentActionMenu } from './StudentActionMenu'
|
||||
export { StudentFilterBar } from './StudentFilterBar'
|
||||
export { VerticalProblem } from './VerticalProblem'
|
||||
export type { StudentView } from './ViewSelector'
|
||||
export { ViewSelector, VIEW_CONFIGS, getAvailableViews, getDefaultView } from './ViewSelector'
|
||||
export type { StudentView, TeacherCompoundChipProps } from './ViewSelector'
|
||||
export {
|
||||
ViewSelector,
|
||||
VIEW_CONFIGS,
|
||||
getAvailableViews,
|
||||
getDefaultView,
|
||||
TeacherCompoundChip,
|
||||
} from './ViewSelector'
|
||||
export { VirtualizedSessionList } from './VirtualizedSessionList'
|
||||
// Part transition components
|
||||
export type { PartTransitionScreenProps } from './PartTransitionScreen'
|
||||
|
||||
@@ -271,6 +271,26 @@ async function unenrollStudent(params: { classroomId: string; playerId: string }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Directly enroll a student (bypasses request workflow)
|
||||
* Used when teacher creates a student and wants to auto-enroll them
|
||||
*/
|
||||
async function directEnrollStudent(params: {
|
||||
classroomId: string
|
||||
playerId: string
|
||||
}): Promise<{ enrolled: boolean }> {
|
||||
const res = await api(`classrooms/${params.classroomId}/enrollments`, {
|
||||
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 enroll student')
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Enrollment Hooks
|
||||
// ============================================================================
|
||||
@@ -394,6 +414,27 @@ export function useUnenrollStudent() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Directly enroll a student (bypasses request workflow)
|
||||
*
|
||||
* Use this when a teacher creates a student and wants to auto-enroll them
|
||||
* in their classroom. Since the teacher created the student, there's no
|
||||
* parent to approve.
|
||||
*/
|
||||
export function useDirectEnrollStudent() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: directEnrollStudent,
|
||||
onSuccess: (_, { classroomId }) => {
|
||||
// Invalidate enrollments so the student appears in the list
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: classroomKeys.enrollments(classroomId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Parent Enrollment Approval API Functions
|
||||
// ============================================================================
|
||||
|
||||
@@ -440,5 +440,40 @@ export async function getEnrolledClassrooms(playerId: string): Promise<Classroom
|
||||
return classroomList
|
||||
}
|
||||
|
||||
/**
|
||||
* Directly enroll a student in a classroom (bypasses request workflow)
|
||||
*
|
||||
* Use this when:
|
||||
* - Teacher is enrolling a student they just created (no parent exists yet)
|
||||
* - Direct enrollment is authorized (e.g., teacher owns both classroom and student)
|
||||
*
|
||||
* @returns true if enrolled, false if already enrolled
|
||||
*/
|
||||
export async function directEnrollStudent(
|
||||
classroomId: string,
|
||||
playerId: string
|
||||
): Promise<boolean> {
|
||||
// Check if already enrolled
|
||||
const existing = await db.query.classroomEnrollments.findFirst({
|
||||
where: and(
|
||||
eq(classroomEnrollments.classroomId, classroomId),
|
||||
eq(classroomEnrollments.playerId, playerId)
|
||||
),
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return false // Already enrolled
|
||||
}
|
||||
|
||||
// Create enrollment directly
|
||||
await db.insert(classroomEnrollments).values({
|
||||
id: createId(),
|
||||
classroomId,
|
||||
playerId,
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Re-export helper functions from schema
|
||||
export { getRequiredApprovals, isFullyApproved, isDenied } from '@/db/schema'
|
||||
|
||||
@@ -54,6 +54,7 @@ export {
|
||||
getEnrolledStudents,
|
||||
unenrollStudent,
|
||||
getEnrolledClassrooms,
|
||||
directEnrollStudent,
|
||||
getRequiredApprovals,
|
||||
isFullyApproved,
|
||||
isDenied,
|
||||
|
||||
Reference in New Issue
Block a user