Files
yellow-bank-soal/frontend/src/pages/student/StudentSession.tsx
2026-06-20 01:43:39 +07:00

260 lines
8.9 KiB
TypeScript

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