Checkpoint React frontend migration
This commit is contained in:
259
frontend/src/pages/student/StudentSession.tsx
Normal file
259
frontend/src/pages/student/StudentSession.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
|
||||
import { useAppStore } from '@/store/useAppStore'
|
||||
import { SafeHtml } from '@/components/SafeHtml'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { CheckCircle2, Send } from 'lucide-react'
|
||||
|
||||
const ACTIVE_SESSION_KEY = 'irt-student-active-session'
|
||||
|
||||
type SessionResponse = {
|
||||
session_id: string
|
||||
wp_user_id: string
|
||||
website_id: number
|
||||
tryout_id: string
|
||||
start_time: string
|
||||
end_time: string | null
|
||||
expires_at: string | null
|
||||
is_completed: boolean
|
||||
total_benar: number
|
||||
NM: number | null
|
||||
NN: number | null
|
||||
}
|
||||
|
||||
type NextItemResponse = {
|
||||
status: 'item' | 'completed'
|
||||
item_id?: number
|
||||
stem?: string
|
||||
options?: Record<string, string>
|
||||
slot?: number
|
||||
level?: string
|
||||
display_level?: string
|
||||
generated_by?: string
|
||||
source_snapshot_question_id?: number | null
|
||||
reason?: string
|
||||
items_answered?: number
|
||||
}
|
||||
|
||||
function formatRemaining(seconds: number | null) {
|
||||
if (seconds === null) return '-'
|
||||
const clamped = Math.max(0, seconds)
|
||||
const minutes = Math.floor(clamped / 60)
|
||||
const rest = clamped % 60
|
||||
return `${minutes}:${String(rest).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : 'Request failed.'
|
||||
}
|
||||
|
||||
export default function StudentSession() {
|
||||
const { sessionId } = useParams<{ sessionId: string }>()
|
||||
const { websiteId } = useAppStore()
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const [selectedResponse, setSelectedResponse] = useState('')
|
||||
const [now, setNow] = useState(0)
|
||||
const itemStartedAtRef = useRef(0)
|
||||
|
||||
const sessionKey = scopedQueryKey(websiteId, 'student-session', sessionId)
|
||||
const nextItemKey = scopedQueryKey(websiteId, 'student-next-item', sessionId)
|
||||
|
||||
const sessionQuery = useQuery({
|
||||
queryKey: sessionKey,
|
||||
queryFn: async () => {
|
||||
const res = await api.get<SessionResponse>(`/session/${sessionId}`)
|
||||
return res.data
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId) && Boolean(sessionId),
|
||||
})
|
||||
|
||||
const nextItemQuery = useQuery({
|
||||
queryKey: nextItemKey,
|
||||
queryFn: async () => {
|
||||
const res = await api.get<NextItemResponse>(`/session/${sessionId}/next_item`)
|
||||
return res.data
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId) && Boolean(sessionId) && !sessionQuery.data?.is_completed,
|
||||
})
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!nextItemQuery.data?.item_id) throw new Error('No active item.')
|
||||
const startedAt = itemStartedAtRef.current || Date.now()
|
||||
const timeSpent = Math.max(0, Math.round((Date.now() - startedAt) / 1000))
|
||||
await api.post(`/session/${sessionId}/submit_answer`, {
|
||||
item_id: nextItemQuery.data.item_id,
|
||||
response: selectedResponse,
|
||||
time_spent: timeSpent,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSelectedResponse('')
|
||||
itemStartedAtRef.current = Date.now()
|
||||
queryClient.invalidateQueries({ queryKey: nextItemKey })
|
||||
queryClient.invalidateQueries({ queryKey: sessionKey })
|
||||
},
|
||||
})
|
||||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await api.post(`/session/${sessionId}/complete`, {
|
||||
end_time: new Date().toISOString(),
|
||||
user_answers: [],
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
localStorage.removeItem(ACTIVE_SESSION_KEY)
|
||||
queryClient.invalidateQueries({ queryKey: sessionKey })
|
||||
navigate(`/student/result/${sessionId}`)
|
||||
},
|
||||
})
|
||||
|
||||
const expiresAt = sessionQuery.data?.expires_at
|
||||
useEffect(() => {
|
||||
if (!expiresAt) {
|
||||
return
|
||||
}
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [expiresAt])
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionId && !sessionQuery.data?.is_completed) {
|
||||
localStorage.setItem(ACTIVE_SESSION_KEY, sessionId)
|
||||
}
|
||||
}, [sessionId, sessionQuery.data?.is_completed])
|
||||
|
||||
useEffect(() => {
|
||||
if (nextItemQuery.data?.item_id) {
|
||||
itemStartedAtRef.current = Date.now()
|
||||
}
|
||||
}, [nextItemQuery.data?.item_id])
|
||||
|
||||
const options = useMemo(() => Object.entries(nextItemQuery.data?.options || {}), [nextItemQuery.data?.options])
|
||||
const remainingSeconds = useMemo(() => {
|
||||
if (!expiresAt || !now) return null
|
||||
return Math.ceil((new Date(expiresAt).getTime() - now) / 1000)
|
||||
}, [expiresAt, now])
|
||||
|
||||
if (!hasWebsiteScope(websiteId)) {
|
||||
return <div className="text-muted-foreground">Select a website to load the session.</div>
|
||||
}
|
||||
|
||||
if (sessionQuery.isLoading || nextItemQuery.isLoading) {
|
||||
return <Skeleton className="h-[520px] w-full" />
|
||||
}
|
||||
|
||||
if (sessionQuery.isError || !sessionQuery.data) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Session failed to load</AlertTitle>
|
||||
<AlertDescription>Check the active website and session ID.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
if (sessionQuery.data.is_completed) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Session Completed</CardTitle>
|
||||
<CardDescription>{sessionQuery.data.session_id}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={() => navigate(`/student/result/${sessionId}`)}>View Result</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const nextItem = nextItemQuery.data
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Tryout Session</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{sessionQuery.data.tryout_id} · Student {sessionQuery.data.wp_user_id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="secondary">Answered {nextItem?.items_answered ?? 0}</Badge>
|
||||
<Badge variant={remainingSeconds !== null && remainingSeconds < 300 ? 'destructive' : 'outline'}>
|
||||
{formatRemaining(remainingSeconds)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(submitMutation.isError || completeMutation.isError || nextItemQuery.isError) && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Session action failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
{getErrorMessage(submitMutation.error || completeMutation.error || nextItemQuery.error)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{nextItem?.status === 'completed' ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ready to Complete</CardTitle>
|
||||
<CardDescription>{nextItem.reason || 'No more questions are available for this session.'}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={() => completeMutation.mutate()} disabled={completeMutation.isPending}>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{completeMutation.isPending ? 'Completing...' : 'Complete Session'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Question {nextItem?.slot ?? '-'}</CardTitle>
|
||||
<CardDescription className="capitalize">{nextItem?.display_level || nextItem?.level || 'item'}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="rounded-md border p-4">
|
||||
<SafeHtml html={nextItem?.stem || ''} />
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{options.map(([key, value]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setSelectedResponse(key)}
|
||||
className={`rounded-md border p-4 text-left transition-colors ${
|
||||
selectedResponse === key ? 'border-primary bg-primary/5' : 'hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono font-semibold">{key}.</span>{' '}
|
||||
<SafeHtml html={value} className="inline" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => submitMutation.mutate()}
|
||||
disabled={!selectedResponse || submitMutation.isPending}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{submitMutation.isPending ? 'Submitting...' : 'Submit Answer'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user