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:
Thomas Hallock
2025-12-29 18:26:47 -06:00
parent 5fee1297e1
commit 4d6adf359e
9 changed files with 666 additions and 45 deletions

View File

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

View File

@@ -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>

View File

@@ -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 && (

View File

@@ -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',

View File

@@ -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',

View File

@@ -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'

View File

@@ -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
// ============================================================================

View File

@@ -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'

View File

@@ -54,6 +54,7 @@ export {
getEnrolledStudents,
unenrollStudent,
getEnrolledClassrooms,
directEnrollStudent,
getRequiredApprovals,
isFullyApproved,
isDenied,