Checkpoint React frontend migration

This commit is contained in:
Dwindi Ramadhana
2026-06-20 01:43:39 +07:00
parent ab86c254d1
commit b8e201b45f
173 changed files with 34116 additions and 782 deletions

View File

@@ -0,0 +1,274 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { api } from '@/lib/api'
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
import { useAppStore } from '@/store/useAppStore'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import {
FileText,
Users,
CheckCircle2,
Target,
AlertCircle,
Activity,
Bot
} from 'lucide-react'
// Adjust type for dashboard stats
interface DashboardStats {
metrics: {
tryouts: number
items: number
sessions: number
completed_sessions: number
completion_rate: number
calibration_percentage: number
}
recent_sessions: Array<{
id: number
wp_user_id: string
tryout_id: string
end_time: string
NM: number | null
NN: number | null
}>
recent_ai_runs: Array<{
id: number
requested_count: number
basis_item_id: number
created_at: string
status: string
pending_review_count?: number
}>
}
export default function Dashboard() {
const { websiteId } = useAppStore()
const { data, isLoading, isError } = useQuery({
queryKey: scopedQueryKey(websiteId, 'dashboard-stats'),
queryFn: async () => {
const res = await api.get<DashboardStats>('/admin/dashboard/stats')
return res.data
},
enabled: hasWebsiteScope(websiteId),
})
if (!hasWebsiteScope(websiteId)) {
return (
<div className="p-4 border rounded-md bg-muted/30 text-muted-foreground">
Select a website to load dashboard statistics.
</div>
)
}
const hasPendingAIReview =
data?.recent_ai_runs.some((run) => run.status === 'pending_review' || (run.pending_review_count || 0) > 0) ?? false
return (
<div className="space-y-6">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Good Morning, Admin</h1>
<p className="text-muted-foreground">Here is your system overview for today.</p>
</div>
<Card className="bg-primary/5 border-primary/20">
<CardHeader>
<CardTitle>Getting Started & Workflow</CardTitle>
<CardDescription>Follow these steps to generate and calibrate IRT questions.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-4">
<div className="flex flex-col gap-2 p-4 bg-background rounded-lg border">
<div className="flex items-center gap-2 font-semibold">
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-sm">1</div>
Create Tryout
</div>
<p className="text-sm text-muted-foreground">Import Tryout snapshots from Sejoli to initialize the question tree.</p>
</div>
<div className="flex flex-col gap-2 p-4 bg-background rounded-lg border">
<div className="flex items-center gap-2 font-semibold">
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-sm">2</div>
Generate Variants
</div>
<p className="text-sm text-muted-foreground">Use AI to generate parallel question variants (Mudah/Sedang/Sulit).</p>
</div>
<div className="flex flex-col gap-2 p-4 bg-background rounded-lg border">
<div className="flex items-center gap-2 font-semibold">
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-sm">3</div>
Gather Data
</div>
<p className="text-sm text-muted-foreground">Students complete tryouts to gather participant answer data.</p>
</div>
<div className="flex flex-col gap-2 p-4 bg-background rounded-lg border">
<div className="flex items-center gap-2 font-semibold">
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-sm">4</div>
Calibrate & Normalize
</div>
<p className="text-sm text-muted-foreground">System calibrates IRT parameters (p-value, IRT b) and calculates NN score.</p>
</div>
</CardContent>
</Card>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-1/2 mb-2" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-1/3" />
</CardContent>
</Card>
))}
</div>
) : isError || !data ? (
<div className="p-4 border border-destructive/50 bg-destructive/10 text-destructive rounded-md">
Failed to load dashboard statistics.
</div>
) : (
<>
{/* System Overview KPIs */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Active Exams</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data.metrics.tryouts}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Questions</CardTitle>
<Target className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data.metrics.items}</div>
<p className="text-xs text-muted-foreground mt-1">
{data.metrics.calibration_percentage}% Calibrated
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Student Attempts</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data.metrics.sessions}</div>
<p className="text-xs text-muted-foreground mt-1">
{data.metrics.completed_sessions} completed
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Completion Rate</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data.metrics.completion_rate}%</div>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Attention Needed */}
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-5 w-5" />
Attention Needed
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{data.metrics.calibration_percentage < 100 && (
<div className="flex items-start gap-3 p-3 bg-muted/50 rounded-lg">
<Target className="h-5 w-5 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium">Questions need calibration</p>
<p className="text-sm text-muted-foreground">
Some questions have enough data but haven't been calibrated yet.
</p>
</div>
</div>
)}
{hasPendingAIReview && (
<div className="flex items-start gap-3 p-3 bg-muted/50 rounded-lg">
<Bot className="h-5 w-5 text-blue-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium">AI generated questions pending review</p>
<p className="text-sm text-muted-foreground">
You have new AI-generated questions waiting for your approval.
</p>
<Link to="/admin/ai-generation" className="text-sm text-primary hover:underline mt-1 block">
Review now
</Link>
</div>
</div>
)}
{data.metrics.calibration_percentage === 100 && !hasPendingAIReview && (
<p className="text-muted-foreground text-sm">You're all caught up! No urgent tasks.</p>
)}
</CardContent>
</Card>
{/* Recent Activity */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Recent Activity
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{data.recent_sessions.length === 0 && data.recent_ai_runs.length === 0 ? (
<p className="text-muted-foreground text-sm">No recent activity.</p>
) : (
<div className="space-y-4">
{data.recent_sessions.map((session) => (
<div key={`session-${session.id}`} className="flex items-start justify-between border-b pb-2 last:border-0 last:pb-0">
<div>
<p className="text-sm font-medium">Student {session.wp_user_id}</p>
<p className="text-xs text-muted-foreground">Completed Tryout: {session.tryout_id}</p>
</div>
<div className="text-right">
<Badge variant="secondary">Score: {session.NN ?? session.NM ?? 0}</Badge>
<p className="text-[10px] text-muted-foreground mt-1">
{new Date(session.end_time).toLocaleTimeString()}
</p>
</div>
</div>
))}
{data.recent_ai_runs.map((run) => (
<div key={`run-${run.id}`} className="flex items-start justify-between border-b pb-2 last:border-0 last:pb-0">
<div>
<p className="text-sm font-medium">AI Generation Run #{run.id}</p>
<p className="text-xs text-muted-foreground">Target: {run.requested_count} questions</p>
</div>
<div className="text-right">
<Badge variant={run.status === 'completed' ? 'default' : 'outline'}>{run.status}</Badge>
<p className="text-[10px] text-muted-foreground mt-1">
{new Date(run.created_at).toLocaleTimeString()}
</p>
</div>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,398 @@
import { useMemo, useState } from 'react'
import { Link } 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 type { AIGenerationRun, Question } from '@/types'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Check, Eye, X } from 'lucide-react'
type ReviewStatus = 'active' | 'rejected'
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : 'Request failed.'
}
function VariantStatusBadge({ status }: { status?: string }) {
const normalized = status || 'unknown'
return (
<Badge variant={normalized === 'draft' ? 'secondary' : normalized === 'rejected' ? 'destructive' : 'default'} className="capitalize">
{normalized.replace('_', ' ')}
</Badge>
)
}
export default function PendingReviews() {
const queryClient = useQueryClient()
const { websiteId } = useAppStore()
const [selectedIds, setSelectedIds] = useState<number[]>([])
const pendingKey = scopedQueryKey(websiteId, 'ai-pending-reviews')
const variantsKey = scopedQueryKey(websiteId, 'ai-variants')
const runsKey = scopedQueryKey(websiteId, 'ai-runs')
const pendingQuery = useQuery({
queryKey: pendingKey,
queryFn: async () => {
const res = await api.get<{ items: Question[] }>('/admin/ai/pending-reviews')
return res.data.items
},
enabled: hasWebsiteScope(websiteId),
})
const variantsQuery = useQuery({
queryKey: variantsKey,
queryFn: async () => {
const res = await api.get<{ items: Question[] }>('/admin/ai/variants')
return res.data.items
},
enabled: hasWebsiteScope(websiteId),
})
const runsQuery = useQuery({
queryKey: runsKey,
queryFn: async () => {
const res = await api.get<{ runs: AIGenerationRun[] }>('/admin/ai/runs')
return res.data.runs
},
enabled: hasWebsiteScope(websiteId),
})
const invalidateAIQueries = () => {
queryClient.invalidateQueries({ queryKey: pendingKey })
queryClient.invalidateQueries({ queryKey: variantsKey })
queryClient.invalidateQueries({ queryKey: runsKey })
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'admin-questions') })
}
const reviewMutation = useMutation({
mutationFn: async ({ id, status }: { id: number; status: ReviewStatus }) => {
await api.post(`/admin/ai/review/${id}?status=${status}`)
},
onSuccess: () => {
setSelectedIds([])
invalidateAIQueries()
},
})
const bulkReviewMutation = useMutation({
mutationFn: async (status: ReviewStatus) => {
await api.post('/admin/ai/review-bulk', {
item_ids: selectedIds,
status,
})
},
onSuccess: () => {
setSelectedIds([])
invalidateAIQueries()
},
})
const pendingItems = useMemo(() => pendingQuery.data ?? [], [pendingQuery.data])
const variants = useMemo(() => variantsQuery.data ?? [], [variantsQuery.data])
const runs = useMemo(() => runsQuery.data ?? [], [runsQuery.data])
const allPendingSelected = pendingItems.length > 0 && pendingItems.every((item) => selectedIds.includes(item.id))
const variantCounts = useMemo(() => {
return variants.reduce<Record<string, number>>((acc, item) => {
const key = item.variant_status || 'unknown'
acc[key] = (acc[key] || 0) + 1
return acc
}, {})
}, [variants])
const toggleItem = (id: number, checked: boolean) => {
setSelectedIds((current) => (checked ? [...new Set([...current, id])] : current.filter((itemId) => itemId !== id)))
}
if (!hasWebsiteScope(websiteId)) {
return (
<Card>
<CardContent className="pt-6 text-muted-foreground">Select a website to load AI review data.</CardContent>
</Card>
)
}
const anyError = pendingQuery.error || variantsQuery.error || runsQuery.error || reviewMutation.error || bulkReviewMutation.error
return (
<div className="space-y-4">
{anyError && (
<Alert variant="destructive">
<AlertTitle>AI review request failed</AlertTitle>
<AlertDescription>{getErrorMessage(anyError)}</AlertDescription>
</Alert>
)}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground">Pending</div>
<div className="text-2xl font-bold">{pendingItems.length}</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground">Active</div>
<div className="text-2xl font-bold">{variantCounts.active || 0}</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground">Rejected</div>
<div className="text-2xl font-bold">{variantCounts.rejected || 0}</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground">Runs</div>
<div className="text-2xl font-bold">{runs.length}</div>
</CardContent>
</Card>
</div>
<Tabs defaultValue="pending">
<TabsList>
<TabsTrigger value="pending">Pending Review</TabsTrigger>
<TabsTrigger value="variants">Variants</TabsTrigger>
<TabsTrigger value="runs">Run History</TabsTrigger>
</TabsList>
<TabsContent value="pending">
<Card>
<CardContent className="pt-6">
{pendingQuery.isLoading ? (
<div className="space-y-4">
{[1, 2, 3].map(i => <Skeleton key={i} className="h-16 w-full" />)}
</div>
) : pendingQuery.isError ? (
<div className="text-destructive">Failed to load pending reviews.</div>
) : (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-sm text-muted-foreground">{selectedIds.length} selected</div>
<div className="flex gap-2">
<Button
variant="outline"
disabled={selectedIds.length === 0 || bulkReviewMutation.isPending}
onClick={() => bulkReviewMutation.mutate('active')}
className="text-green-700"
>
<Check className="mr-2 h-4 w-4" />
Approve Selected
</Button>
<Button
variant="outline"
disabled={selectedIds.length === 0 || bulkReviewMutation.isPending}
onClick={() => bulkReviewMutation.mutate('rejected')}
className="text-red-700"
>
<X className="mr-2 h-4 w-4" />
Reject Selected
</Button>
</div>
</div>
<div className="rounded-md border">
<table className="w-full text-sm text-left">
<thead className="bg-muted text-muted-foreground">
<tr>
<th className="w-12 p-3 font-medium">
<input
type="checkbox"
aria-label="Select all pending AI variants"
checked={allPendingSelected}
onChange={(event) => setSelectedIds(event.target.checked ? pendingItems.map((item) => item.id) : [])}
/>
</th>
<th className="p-3 font-medium">Question Preview</th>
<th className="p-3 font-medium">Target Level</th>
<th className="p-3 font-medium">AI Model</th>
<th className="p-3 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y">
{pendingItems.map(item => (
<tr key={item.id} className="hover:bg-muted/50">
<td className="p-3">
<input
type="checkbox"
aria-label={`Select AI variant ${item.id}`}
checked={selectedIds.includes(item.id)}
onChange={(event) => toggleItem(item.id, event.target.checked)}
/>
</td>
<td className="p-3">
<SafeHtml html={item.stem_text} className="max-w-md truncate" />
<div className="text-xs text-muted-foreground mt-1">
Basis ID: {item.basis_item_id} | Tryout: {item.tryout_id}
</div>
</td>
<td className="p-3">
<Badge variant={item.level === 'mudah' ? 'secondary' : item.level === 'sedang' ? 'default' : 'destructive'}>
{item.level}
</Badge>
</td>
<td className="p-3 text-xs">{item.ai_model}</td>
<td className="p-3">
<div className="flex justify-end gap-2">
<Button size="sm" variant="outline" asChild>
<Link to={`/admin/questions/${item.id}`}>
<Eye className="mr-2 h-4 w-4" />
View
</Link>
</Button>
<Button
size="sm"
variant="outline"
className="text-green-700"
onClick={() => reviewMutation.mutate({ id: item.id, status: 'active' })}
disabled={reviewMutation.isPending}
>
<Check className="mr-2 h-4 w-4" />
Approve
</Button>
<Button
size="sm"
variant="outline"
className="text-red-700"
onClick={() => reviewMutation.mutate({ id: item.id, status: 'rejected' })}
disabled={reviewMutation.isPending}
>
<X className="mr-2 h-4 w-4" />
Reject
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{pendingItems.length === 0 && (
<div className="p-8 text-center text-muted-foreground">
No pending AI generated questions to review.
</div>
)}
</div>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="variants">
<Card>
<CardContent className="pt-6">
{variantsQuery.isLoading ? (
<Skeleton className="h-56 w-full" />
) : variantsQuery.isError ? (
<div className="text-destructive">Failed to load variants.</div>
) : (
<div className="rounded-md border">
<table className="w-full text-sm text-left">
<thead className="bg-muted text-muted-foreground">
<tr>
<th className="p-3 font-medium">Variant</th>
<th className="p-3 font-medium">Tryout</th>
<th className="p-3 font-medium">Basis</th>
<th className="p-3 font-medium">Status</th>
<th className="p-3 font-medium text-right">Detail</th>
</tr>
</thead>
<tbody className="divide-y">
{variants.map((item) => (
<tr key={item.id}>
<td className="p-3">
<SafeHtml html={item.stem_text} className="max-w-lg truncate" />
<div className="text-xs text-muted-foreground mt-1">Model: {item.ai_model || '-'}</div>
</td>
<td className="p-3">{item.tryout_id}</td>
<td className="p-3">{item.basis_item_id || '-'}</td>
<td className="p-3">
<VariantStatusBadge status={item.variant_status} />
</td>
<td className="p-3 text-right">
<Button variant="outline" size="sm" asChild>
<Link to={`/admin/questions/${item.id}`}>Open</Link>
</Button>
</td>
</tr>
))}
</tbody>
</table>
{variants.length === 0 && (
<div className="p-8 text-center text-muted-foreground">No AI variants found.</div>
)}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="runs">
<Card>
<CardContent className="pt-6">
{runsQuery.isLoading ? (
<Skeleton className="h-56 w-full" />
) : runsQuery.isError ? (
<div className="text-destructive">Failed to load run history.</div>
) : (
<div className="rounded-md border">
<table className="w-full text-sm text-left">
<thead className="bg-muted text-muted-foreground">
<tr>
<th className="p-3 font-medium">Run</th>
<th className="p-3 font-medium">Basis</th>
<th className="p-3 font-medium">Target</th>
<th className="p-3 font-medium">Generated</th>
<th className="p-3 font-medium">Status</th>
<th className="p-3 font-medium">Created</th>
</tr>
</thead>
<tbody className="divide-y">
{runs.map((run) => (
<tr key={run.id}>
<td className="p-3 font-medium">#{run.id}</td>
<td className="p-3">
{run.basis_item_id ? (
<Link to={`/admin/questions/${run.basis_item_id}`} className="text-primary hover:underline">
Basis #{run.basis_item_id}
</Link>
) : '-'}
<div className="text-xs text-muted-foreground">
Tryout {run.basis_tryout_id || '-'} · Slot {run.basis_slot ?? '-'}
</div>
</td>
<td className="p-3 capitalize">{run.target_level}</td>
<td className="p-3">{run.generated_count} / {run.requested_count}</td>
<td className="p-3">
<VariantStatusBadge status={run.status} />
{run.pending_review_count > 0 && (
<div className="text-xs text-muted-foreground mt-1">
{run.pending_review_count} pending review
</div>
)}
</td>
<td className="p-3 text-xs">{new Date(run.created_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
{runs.length === 0 && (
<div className="p-8 text-center text-muted-foreground">No AI runs found.</div>
)}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,16 @@
import PendingReviews from './PendingReviews'
export default function AIGeneration() {
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">AI Generation</h1>
<p className="text-muted-foreground mt-1">Review AI-generated questions awaiting approval.</p>
</div>
</div>
<PendingReviews />
</div>
)
}

View File

@@ -0,0 +1,241 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
import { useAppStore } from '@/store/useAppStore'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Plus, Upload, Loader2, FileJson, AlertCircle } from 'lucide-react'
type TryoutJsonPreview = {
tryout_count?: number
tryouts?: Array<{
source_key: string
title: string
source_tryout_id: string
}>
totals?: {
new_questions: number
updated_questions: number
unchanged_questions: number
removed_questions: number
missing_option_labels: number
}
}
function getErrorMessage(error: unknown, fallback: string) {
if (error && typeof error === 'object' && 'response' in error) {
const response = (error as { response?: { data?: { detail?: unknown } } }).response
if (typeof response?.data?.detail === 'string') {
return response.data.detail
}
}
return fallback
}
export function ImportTryoutModal() {
const [open, setOpen] = useState(false)
const [file, setFile] = useState<File | null>(null)
const [preview, setPreview] = useState<TryoutJsonPreview | null>(null)
const [error, setError] = useState<string | null>(null)
const queryClient = useQueryClient()
const { websiteId } = useAppStore()
// Preview Mutation
const previewMutation = useMutation({
mutationFn: async (fileData: File) => {
const formData = new FormData()
formData.append('file', fileData)
const res = await api.post('/import-export/tryout-json/preview', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return res.data as TryoutJsonPreview
},
onSuccess: (data) => {
setPreview(data)
setError(null)
},
onError: (err: unknown) => {
setPreview(null)
setError(getErrorMessage(err, 'Failed to parse JSON file.'))
}
})
// Import Mutation
const importMutation = useMutation({
mutationFn: async (fileData: File) => {
const formData = new FormData()
formData.append('file', fileData)
const res = await api.post('/import-export/tryout-json', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return res.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'tryouts') })
setOpen(false)
resetState()
},
onError: (err: unknown) => {
setError(getErrorMessage(err, 'Failed to import JSON file.'))
}
})
const resetState = () => {
setFile(null)
setPreview(null)
setError(null)
previewMutation.reset()
importMutation.reset()
}
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen)
if (!newOpen) {
resetState()
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const selectedFile = e.target.files[0]
setFile(selectedFile)
previewMutation.mutate(selectedFile)
}
}
const handleImport = () => {
if (file) {
importMutation.mutate(file)
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button disabled={!hasWebsiteScope(websiteId)}>
<Plus className="mr-2 h-4 w-4" /> Import Tryout JSON
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Import Tryout</DialogTitle>
<DialogDescription>
Upload a Sejoli tryout export JSON file to import it as a read-only snapshot.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex flex-col items-center justify-center p-6 border-2 border-dashed rounded-lg bg-muted/50 transition-colors hover:bg-muted/80">
<input
type="file"
id="json-upload"
accept=".json"
className="hidden"
onChange={handleFileChange}
disabled={previewMutation.isPending || importMutation.isPending}
/>
<label
htmlFor="json-upload"
className="flex flex-col items-center cursor-pointer w-full text-center space-y-2"
>
{file ? (
<FileJson className="h-10 w-10 text-primary" />
) : (
<Upload className="h-10 w-10 text-muted-foreground" />
)}
<span className="text-sm font-medium">
{file ? file.name : "Click to select a JSON file"}
</span>
{!file && <span className="text-xs text-muted-foreground">Only .json files are supported</span>}
</label>
</div>
{previewMutation.isPending && (
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground p-4">
<Loader2 className="h-4 w-4 animate-spin" />
Analyzing file...
</div>
)}
{error && (
<div className="p-3 text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md flex items-start gap-2">
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
<div className="break-words max-w-full overflow-hidden">{error}</div>
</div>
)}
{preview && !error && (
<div className="p-4 bg-primary/5 border border-primary/20 rounded-md space-y-3 max-h-[250px] overflow-y-auto">
<h4 className="font-medium text-primary sticky top-0 bg-background/95 backdrop-blur py-1 -mt-1 -mx-1 px-1">Preview Result</h4>
{preview.tryout_count !== undefined ? (
<>
<div className="text-sm space-y-1">
<div><span className="text-muted-foreground">Tryouts found:</span> <span className="font-medium">{preview.tryout_count}</span></div>
{preview.tryouts?.map((t) => (
<div key={t.source_key} className="ml-2 mt-2 py-1 border-l-2 border-primary/20 pl-3">
<div className="font-medium text-sm leading-tight">{t.title}</div>
<div className="text-xs text-muted-foreground mt-0.5">ID: {t.source_tryout_id}</div>
</div>
))}
</div>
{preview.totals && (
<div className="text-sm pt-2 border-t border-primary/10">
<div className="font-medium mb-1">Questions Summary:</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-muted-foreground">
<div>New: <span className="text-foreground">{preview.totals.new_questions}</span></div>
<div>Updated: <span className="text-foreground">{preview.totals.updated_questions}</span></div>
<div>Unchanged: <span className="text-foreground">{preview.totals.unchanged_questions}</span></div>
<div>Removed: <span className="text-foreground">{preview.totals.removed_questions}</span></div>
</div>
{preview.totals.missing_option_labels > 0 && (
<div className="text-amber-600 text-xs mt-1 flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{preview.totals.missing_option_labels} questions have missing option labels
</div>
)}
</div>
)}
</>
) : (
<div className="text-sm">Ready to import tryout payload.</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={importMutation.isPending}>
Cancel
</Button>
<Button
onClick={handleImport}
disabled={!preview || !!error || importMutation.isPending}
>
{importMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Importing...
</>
) : (
'Confirm Import'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,161 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Users, LayoutList, Settings, Globe, ClipboardList, CheckCircle2, Circle, AlertCircle, Percent } from 'lucide-react'
import { api } from '@/lib/api'
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
import { useAppStore } from '@/store/useAppStore'
import type { Tryout } from '@/types'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { Progress } from '@/components/ui/progress'
import { ImportTryoutModal } from './ImportTryoutModal'
export default function Exams() {
const { websiteId } = useAppStore()
const { data: tryouts, isLoading, isError } = useQuery({
queryKey: scopedQueryKey(websiteId, 'tryouts'),
queryFn: async () => {
const res = await api.get<Tryout[]>('/tryout/')
return res.data
},
enabled: hasWebsiteScope(websiteId),
})
// Group tryouts by website
const groupedTryouts = tryouts?.reduce((acc, t) => {
const wId = t.website_id
if (!acc[wId]) acc[wId] = []
acc[wId].push(t)
return acc
}, {} as Record<number, Tryout[]>)
const renderCalibrationLegend = (itemCount: number = 0, calibratedCount: number = 0) => {
if (itemCount === 0) return <span title="No Questions"><Circle className="h-5 w-5 text-muted-foreground" /></span>
const percentage = Math.round((calibratedCount / itemCount) * 100)
if (percentage >= 90) return <span title="Ready (≥90% calibrated)"><CheckCircle2 className="h-5 w-5 text-green-500" /></span>
if (percentage >= 50) return <span title="Partial (50-89% calibrated)"><AlertCircle className="h-5 w-5 text-amber-500" /></span>
return <span title="Needs Data (<50% calibrated)"><Circle className="h-5 w-5 text-red-500" /></span>
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Tryouts</h1>
<p className="text-muted-foreground mt-1">Manage active tryouts and question banks.</p>
</div>
<ImportTryoutModal />
</div>
{!hasWebsiteScope(websiteId) ? (
<div className="p-4 border rounded-md bg-muted/30 text-muted-foreground">
Select a website to load tryouts.
</div>
) : isLoading ? (
<div className="space-y-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : isError ? (
<div className="p-4 border border-destructive/50 bg-destructive/10 text-destructive rounded-md">
Failed to load tryouts. Please check your connection or credentials.
</div>
) : tryouts?.length === 0 ? (
<div className="text-center p-12 border rounded-lg bg-card text-muted-foreground">
No tryouts found. Import one to get started.
</div>
) : (
<div className="space-y-8">
{Object.entries(groupedTryouts || {}).map(([groupWebsiteId, wTryouts]) => (
<div key={groupWebsiteId} className="space-y-4">
<h2 className="flex items-center gap-2 text-xl font-semibold border-b pb-2">
<Globe className="h-5 w-5" />
Website {groupWebsiteId}
</h2>
<Accordion type="multiple" className="w-full space-y-2">
{wTryouts.map((t) => {
const itemCount = t.item_count || 0;
const calibratedCount = t.calibrated_item_count || 0;
const calPercentage = itemCount > 0 ? Math.round((calibratedCount / itemCount) * 100) : 0;
return (
<AccordionItem key={t.tryout_id} value={t.tryout_id} className="border rounded-lg bg-card px-4">
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center justify-between w-full pr-4">
<div className="flex items-center gap-3">
<ClipboardList className="h-5 w-5 text-muted-foreground" />
<span className="font-medium text-lg">{t.tryout_id} - {t.name || t.title}</span>
</div>
<div>
{renderCalibrationLegend(itemCount, calibratedCount)}
</div>
</div>
</AccordionTrigger>
<AccordionContent className="pt-2 pb-4">
<Card className="bg-muted/30 border-muted mb-4">
<CardContent className="p-4 flex flex-col md:flex-row gap-6 md:gap-12">
<div className="space-y-1">
<div className="text-sm text-muted-foreground flex items-center gap-2">
<Users className="h-4 w-4" /> Participants
</div>
<div className="text-2xl font-bold">{t.participant_count || 0}</div>
</div>
<div className="space-y-1">
<div className="text-sm text-muted-foreground flex items-center gap-2">
<Percent className="h-4 w-4" /> Averages
</div>
<div className="text-sm">
<span className="font-medium">NM:</span> {t.rataan ? t.rataan.toFixed(1) : 'N/A'} <span className="mx-2 text-muted-foreground">|</span>
<span className="font-medium">SB:</span> {t.sb ? t.sb.toFixed(1) : 'N/A'}
</div>
</div>
<div className="flex-1 space-y-2">
<div className="text-sm text-muted-foreground flex justify-between items-center">
<span>Calibration Status ({calPercentage}%)</span>
<span>{calibratedCount} / {itemCount} Items</span>
</div>
<Progress value={calPercentage} className="h-2" />
</div>
</CardContent>
</Card>
<div className="flex flex-wrap gap-3 pt-4 border-t">
<Link to={`/admin/tryouts/${t.tryout_id}/questions`}>
<Button variant="outline" size="sm" className="bg-white">
<LayoutList className="w-4 h-4 mr-2" /> Questions ({itemCount})
</Button>
</Link>
<Link to={`/admin/tryouts/${t.tryout_id}/attempts`}>
<Button variant="outline" size="sm" className="bg-white">
<Users className="w-4 h-4 mr-2" /> Attempts ({t.participant_count || 0})
</Button>
</Link>
<Link to={`/admin/tryouts/${t.tryout_id}/normalization`}>
<Button variant="outline" size="sm" className="bg-white">
<Percent className="w-4 h-4 mr-2" /> Normalization
</Button>
</Link>
<Link to={`/admin/tryouts/${t.tryout_id}/settings`}>
<Button variant="outline" size="sm" className="bg-white">
<Settings className="w-4 h-4 mr-2" /> Settings
</Button>
</Link>
</div>
</AccordionContent>
</AccordionItem>
)
})}
</Accordion>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,299 @@
import { useState, type ChangeEvent } from 'react'
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 type { Tryout } from '@/types'
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Upload, FileSpreadsheet, CheckCircle2, AlertCircle, Save } from 'lucide-react'
type PreviewQuestion = {
item_id?: string
stem_text?: string
stem?: string
level?: string
slot?: number
correct_key?: string
}
type ImportPreview = {
items_count: number
preview: PreviewQuestion[]
validation_errors: string[]
has_errors: boolean
}
type ImportResult = {
message: string
imported: number
duplicates: number
}
function getErrorMessage(error: unknown) {
if (error && typeof error === 'object' && 'response' in error) {
const response = (error as { response?: { data?: { detail?: unknown } } }).response
const detail = response?.data?.detail
if (typeof detail === 'string') return detail
if (detail && typeof detail === 'object' && 'error' in detail) {
const typedDetail = detail as { error?: string; validation_errors?: string[]; errors?: string[] }
return typedDetail.validation_errors?.join(', ') || typedDetail.errors?.join(', ') || typedDetail.error || 'Request failed.'
}
}
return error instanceof Error ? error.message : 'Request failed.'
}
function getTryoutLabel(tryout: Tryout) {
return `${tryout.tryout_id} - ${tryout.name || tryout.title || 'Untitled tryout'}`
}
export default function ImportQuestions() {
const { websiteId } = useAppStore()
const queryClient = useQueryClient()
const [file, setFile] = useState<File | null>(null)
const [manualTryoutId, setManualTryoutId] = useState('')
const [previewData, setPreviewData] = useState<ImportPreview | null>(null)
const [importResult, setImportResult] = useState<ImportResult | null>(null)
const { data: tryouts, isLoading: isLoadingTryouts, isError: isTryoutsError } = useQuery({
queryKey: scopedQueryKey(websiteId, 'tryouts'),
queryFn: async () => {
const res = await api.get<Tryout[]>('/tryout/')
return res.data
},
enabled: hasWebsiteScope(websiteId),
})
const selectedTryoutId =
tryouts?.some((tryout) => tryout.tryout_id === manualTryoutId)
? manualTryoutId
: tryouts?.[0]?.tryout_id || ''
const previewMutation = useMutation({
mutationFn: async (formData: FormData) => {
const res = await api.post<ImportPreview>('/import-export/preview', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return res.data
},
onSuccess: (data) => {
setPreviewData(data)
setImportResult(null)
},
})
const importMutation = useMutation({
mutationFn: async () => {
if (!file || !selectedTryoutId) throw new Error('Select a tryout and Excel file first.')
const formData = new FormData()
formData.append('file', file)
formData.append('tryout_id', selectedTryoutId)
const res = await api.post<ImportResult>('/import-export/questions', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return res.data
},
onSuccess: (data) => {
setImportResult(data)
setPreviewData(null)
setFile(null)
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'tryout-questions', selectedTryoutId) })
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'tryouts') })
},
})
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
setFile(e.target.files[0])
setPreviewData(null)
setImportResult(null)
}
}
const handlePreview = () => {
if (!file) return
const formData = new FormData()
formData.append('file', file)
previewMutation.mutate(formData)
}
if (!hasWebsiteScope(websiteId)) {
return <div className="text-muted-foreground">Select a website to import Excel questions.</div>
}
const canPreview = Boolean(file) && !previewMutation.isPending
const canImport = Boolean(file && selectedTryoutId && previewData && !previewData.has_errors) && !importMutation.isPending
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Excel Import</h1>
<p className="text-muted-foreground mt-1">Preview and import questions from an Excel template.</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1 space-y-6">
<Card>
<CardHeader>
<CardTitle>Upload File</CardTitle>
<CardDescription>Select a tryout and filled Excel template.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Tryout</Label>
<Select
value={selectedTryoutId}
onValueChange={setManualTryoutId}
disabled={isLoadingTryouts || !tryouts?.length}
>
<SelectTrigger>
<SelectValue placeholder="Select tryout" />
</SelectTrigger>
<SelectContent>
{tryouts?.map((tryout) => (
<SelectItem key={tryout.tryout_id} value={tryout.tryout_id}>
{getTryoutLabel(tryout)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="file">Excel File (.xlsx)</Label>
<Input id="file" type="file" accept=".xlsx" onChange={handleFileChange} />
</div>
</CardContent>
<CardFooter>
<Button onClick={handlePreview} disabled={!canPreview} className="w-full">
<Upload className="w-4 h-4 mr-2" />
{previewMutation.isPending ? 'Processing...' : 'Preview Import'}
</Button>
</CardFooter>
</Card>
<Card className="bg-muted/50">
<CardContent className="pt-6">
<h3 className="font-medium flex items-center gap-2 mb-2">
<FileSpreadsheet className="w-4 h-4" /> Template Format
</h3>
<p className="text-sm text-muted-foreground">
The Excel file must match the backend import schema and include question stem, options,
answer key, slot, and level columns.
</p>
</CardContent>
</Card>
</div>
<div className="lg:col-span-2">
{isTryoutsError && (
<Alert variant="destructive" className="mb-6">
<AlertCircle className="w-4 h-4" />
<AlertTitle>Tryouts Failed</AlertTitle>
<AlertDescription>Could not load tryout options for this website.</AlertDescription>
</Alert>
)}
{(previewMutation.isError || importMutation.isError) && (
<Alert variant="destructive" className="mb-6">
<AlertCircle className="w-4 h-4" />
<AlertTitle>Import Failed</AlertTitle>
<AlertDescription>
{getErrorMessage(previewMutation.error || importMutation.error)}
</AlertDescription>
</Alert>
)}
{importResult && (
<Alert className="mb-6">
<CheckCircle2 className="w-4 h-4" />
<AlertTitle>{importResult.message}</AlertTitle>
<AlertDescription>
Imported {importResult.imported} questions. Duplicates skipped: {importResult.duplicates}.
</AlertDescription>
</Alert>
)}
{previewData && (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div>
<CardTitle className="flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
Preview Ready
</CardTitle>
<CardDescription>Found {previewData.items_count} questions in the workbook.</CardDescription>
</div>
<Button
onClick={() => importMutation.mutate()}
disabled={!canImport}
className="bg-emerald-600 hover:bg-emerald-700"
>
<Save className="w-4 h-4 mr-2" />
{importMutation.isPending ? 'Importing...' : 'Confirm Import'}
</Button>
</CardHeader>
<CardContent className="space-y-4">
{previewData.validation_errors.length > 0 && (
<Alert variant={previewData.has_errors ? 'destructive' : 'default'}>
<AlertCircle className="w-4 h-4" />
<AlertTitle>Validation Notes</AlertTitle>
<AlertDescription>{previewData.validation_errors.join(', ')}</AlertDescription>
</Alert>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Slot</TableHead>
<TableHead>Level</TableHead>
<TableHead>Answer</TableHead>
<TableHead>Stem</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{previewData.preview.map((row, idx) => (
<TableRow key={`${row.item_id || idx}-${idx}`}>
<TableCell className="font-medium">{row.item_id || `Row ${idx + 2}`}</TableCell>
<TableCell>{row.slot ?? '-'}</TableCell>
<TableCell>{row.level || '-'}</TableCell>
<TableCell>{row.correct_key || '-'}</TableCell>
<TableCell className="max-w-md truncate">{row.stem_text || row.stem || '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{!previewData && !previewMutation.isPending && !previewMutation.isError && !importResult && (
<Card className="h-full min-h-[400px] flex flex-col items-center justify-center border-dashed">
<div className="text-center text-muted-foreground p-12">
<FileSpreadsheet className="w-12 h-12 mb-4 mx-auto opacity-20" />
<p>Choose a tryout and upload a workbook to preview import data.</p>
</div>
</Card>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,179 @@
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
import { useAppStore } from '@/store/useAppStore'
import type { Question } from '@/types'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Network, Sparkles } from 'lucide-react'
type OverviewSnapshot = {
id: number
tryout_id: string
title: string
question_count: number
created_at: string
basis_items: Question[]
}
type OverviewWebsite = {
id: number
name: string
domain: string
snapshots: OverviewSnapshot[]
}
type HierarchyOverview = {
summary: {
websites: number
snapshots: number
source_questions: number
basis_items: number
ai_runs: number
variants: number
snapshots_without_basis: number
basis_without_variants: number
orphan_variants: number
}
websites: OverviewWebsite[]
}
function SummaryCard({ label, value }: { label: string; value: number }) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
</CardContent>
</Card>
)
}
export default function DataOverview() {
const { websiteId } = useAppStore()
const { data, isLoading, isError } = useQuery({
queryKey: scopedQueryKey(websiteId, 'data-overview'),
queryFn: async () => {
const res = await api.get<HierarchyOverview>('/admin/overview/hierarchy')
return res.data
},
enabled: hasWebsiteScope(websiteId),
})
if (!hasWebsiteScope(websiteId)) {
return <div className="text-muted-foreground">Select a website to load the data overview.</div>
}
if (isLoading) return <Skeleton className="h-[520px] w-full" />
if (isError || !data) {
return (
<Alert variant="destructive">
<AlertTitle>Failed to load data overview</AlertTitle>
<AlertDescription>Check the selected website and backend API availability.</AlertDescription>
</Alert>
)
}
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Data Overview</h1>
<p className="text-muted-foreground mt-1">Snapshot, basis question, AI run, and variant hierarchy.</p>
</div>
<Button variant="outline" asChild>
<Link to="/admin/import">Open Import</Link>
</Button>
</div>
<div className="grid gap-4 md:grid-cols-4">
<SummaryCard label="Snapshots" value={data.summary.snapshots} />
<SummaryCard label="Source Questions" value={data.summary.source_questions} />
<SummaryCard label="Basis Items" value={data.summary.basis_items} />
<SummaryCard label="AI Variants" value={data.summary.variants} />
</div>
{(data.summary.snapshots_without_basis > 0 ||
data.summary.basis_without_variants > 0 ||
data.summary.orphan_variants > 0) && (
<Alert>
<Network className="h-4 w-4" />
<AlertTitle>Hierarchy gaps detected</AlertTitle>
<AlertDescription>
{data.summary.snapshots_without_basis} snapshots without promoted basis items,{' '}
{data.summary.basis_without_variants} basis items without variants, and{' '}
{data.summary.orphan_variants} variants without a visible basis.
</AlertDescription>
</Alert>
)}
{data.websites.map((website) => (
<div key={website.id} className="space-y-4">
<div>
<h2 className="text-xl font-semibold">{website.name}</h2>
<p className="text-sm text-muted-foreground">{website.domain}</p>
</div>
{website.snapshots.length === 0 ? (
<div className="rounded-md border border-dashed p-8 text-center text-muted-foreground">
No imported snapshots found for this website.
</div>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted text-muted-foreground">
<tr>
<th className="p-3 text-left font-medium">Snapshot</th>
<th className="p-3 text-left font-medium">Tryout</th>
<th className="p-3 text-right font-medium">Questions</th>
<th className="p-3 text-right font-medium">Basis</th>
<th className="p-3 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y">
{website.snapshots.map((snapshot) => (
<tr key={snapshot.id}>
<td className="p-3">
<div className="font-medium">{snapshot.title}</div>
<div className="text-xs text-muted-foreground">Snapshot #{snapshot.id}</div>
</td>
<td className="p-3">{snapshot.tryout_id}</td>
<td className="p-3 text-right">{snapshot.question_count}</td>
<td className="p-3 text-right">
<Badge variant={snapshot.basis_items.length ? 'default' : 'secondary'}>
{snapshot.basis_items.length}
</Badge>
</td>
<td className="p-3 text-right">
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" asChild>
<Link to={`/admin/tryouts/${snapshot.tryout_id}/questions`}>Questions</Link>
</Button>
{snapshot.basis_items[0] && (
<Button variant="outline" size="sm" asChild>
<Link to={`/admin/tryouts/${snapshot.tryout_id}/questions/${snapshot.basis_items[0].id}/ai-workspace`}>
<Sparkles className="mr-2 h-4 w-4" />
AI
</Link>
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,238 @@
import { Link, 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 { getQuestionLevelLabel } from '@/lib/questionLabels'
import type { Question } from '@/types'
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 { Check, Sparkles, X } from 'lucide-react'
type QuestionDetailResponse = {
item: Question
basis_item: Question | null
variants: Question[]
usage: {
impressions: number
unique_users: number
}
}
function Stat({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-md border p-4">
<div className="text-sm text-muted-foreground">{label}</div>
<div className="mt-1 text-lg font-semibold">{value}</div>
</div>
)
}
function OptionsTable({ item }: { item: Question }) {
const options = Object.entries(item.options || {})
if (options.length === 0) {
return <div className="text-sm text-muted-foreground">No options saved for this question.</div>
}
return (
<div className="rounded-md border">
<table className="w-full text-sm">
<tbody className="divide-y">
{options.map(([key, value]) => (
<tr key={key}>
<td className="w-16 p-3 font-mono">{key}</td>
<td className="p-3">
<SafeHtml html={value} />
</td>
<td className="w-24 p-3 text-right">
{item.correct_answer === key && <Badge>Correct</Badge>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
export default function QuestionDetail() {
const { questionId } = useParams<{ questionId: string }>()
const { websiteId } = useAppStore()
const queryClient = useQueryClient()
const queryKey = scopedQueryKey(websiteId, 'question-detail', questionId)
const { data, isLoading, isError } = useQuery({
queryKey,
queryFn: async () => {
const res = await api.get<QuestionDetailResponse>(`/admin/questions/${questionId}`)
return res.data
},
enabled: hasWebsiteScope(websiteId) && Boolean(questionId),
})
const reviewMutation = useMutation({
mutationFn: async (status: 'active' | 'rejected') => {
await api.post(`/admin/ai/review/${questionId}?status=${status}`)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey })
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'ai-pending-reviews') })
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'ai-variants') })
},
})
if (!hasWebsiteScope(websiteId)) {
return <div className="text-muted-foreground">Select a website to load question detail.</div>
}
if (isLoading) return <Skeleton className="h-[520px] w-full" />
if (isError || !data) {
return (
<Alert variant="destructive">
<AlertTitle>Failed to load question</AlertTitle>
<AlertDescription>Check the selected website and question ID.</AlertDescription>
</Alert>
)
}
const item = data.item
const canGenerate = item.level === 'sedang' && item.tryout_id
const canReview = item.generated_by === 'ai' && item.variant_status === 'draft'
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">Question #{item.id}</h1>
<p className="text-muted-foreground mt-1">
Tryout {item.tryout_id || '-'} · Slot {item.slot ?? '-'} · {item.generated_by || 'manual'}
</p>
</div>
<div className="flex flex-wrap gap-2">
{canGenerate && (
<Button asChild>
<Link to={`/admin/tryouts/${item.tryout_id}/questions/${item.id}/ai-workspace`}>
<Sparkles className="mr-2 h-4 w-4" />
Generate Variant
</Link>
</Button>
)}
{canReview && (
<>
<Button
variant="outline"
onClick={() => reviewMutation.mutate('active')}
disabled={reviewMutation.isPending}
className="text-green-700"
>
<Check className="mr-2 h-4 w-4" />
Approve
</Button>
<Button
variant="outline"
onClick={() => reviewMutation.mutate('rejected')}
disabled={reviewMutation.isPending}
className="text-red-700"
>
<X className="mr-2 h-4 w-4" />
Reject
</Button>
</>
)}
</div>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Stat label="Level" value={getQuestionLevelLabel(item)} />
<Stat label="P-value" value={item.p_value?.toFixed(3) ?? '-'} />
<Stat label="IRT b" value={item.irt_b?.toFixed(3) ?? '-'} />
<Stat label="Responses" value={data.usage.impressions} />
</div>
<Card>
<CardHeader>
<CardTitle>Question Content</CardTitle>
<CardDescription>
Status: <span className="capitalize">{item.variant_status || (item.calibrated ? 'calibrated' : 'pending')}</span>
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="rounded-md border p-4">
<SafeHtml html={item.stem || item.stem_text} />
</div>
<OptionsTable item={item} />
{item.explanation && (
<div>
<h3 className="mb-2 font-medium">Explanation</h3>
<div className="rounded-md border p-4">
<SafeHtml html={item.explanation} allowEmbeds />
</div>
</div>
)}
</CardContent>
</Card>
{data.basis_item && (
<Card>
<CardHeader>
<CardTitle>Basis Question</CardTitle>
<CardDescription>Original medium-level item used to create this variant.</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" asChild>
<Link to={`/admin/questions/${data.basis_item.id}`}>Open basis question #{data.basis_item.id}</Link>
</Button>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Variants</CardTitle>
<CardDescription>{data.variants.length} generated variants connected to this basis question.</CardDescription>
</CardHeader>
<CardContent>
{data.variants.length === 0 ? (
<div className="text-sm text-muted-foreground">No variants found.</div>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted text-muted-foreground">
<tr>
<th className="p-3 text-left font-medium">Variant</th>
<th className="p-3 text-left font-medium">Level</th>
<th className="p-3 text-left font-medium">Status</th>
<th className="p-3 text-right font-medium">Detail</th>
</tr>
</thead>
<tbody className="divide-y">
{data.variants.map((variant) => (
<tr key={variant.id}>
<td className="p-3">
<SafeHtml html={variant.stem_text} className="max-w-lg truncate" />
</td>
<td className="p-3 capitalize">{getQuestionLevelLabel(variant)}</td>
<td className="p-3">
<Badge variant="secondary" className="capitalize">{variant.variant_status || '-'}</Badge>
</td>
<td className="p-3 text-right">
<Button variant="outline" size="sm" asChild>
<Link to={`/admin/questions/${variant.id}`}>Open</Link>
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,169 @@
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
import { isOriginalQuestion } from '@/lib/questionLabels'
import { useAppStore } from '@/store/useAppStore'
import type { Question } from '@/types'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Target, Activity, CheckCircle2, AlertCircle, type LucideIcon } from 'lucide-react'
function formatNumber(value: number | null | undefined, digits = 2) {
if (value === null || value === undefined || Number.isNaN(value)) return '-'
return Number(value).toFixed(digits)
}
function average(values: number[]) {
if (values.length === 0) return null
return values.reduce((sum, value) => sum + value, 0) / values.length
}
function StatCard({
title,
value,
description,
icon: Icon,
}: {
title: string
value: string | number
description: string
icon: LucideIcon
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
<p className="text-xs text-muted-foreground mt-1">{description}</p>
</CardContent>
</Card>
)
}
export default function QuestionQuality() {
const { websiteId } = useAppStore()
const { data, isLoading, isError } = useQuery({
queryKey: scopedQueryKey(websiteId, 'admin-questions'),
queryFn: async () => {
const res = await api.get<{ items: Question[] }>('/admin/questions')
return res.data.items
},
enabled: hasWebsiteScope(websiteId),
})
if (!hasWebsiteScope(websiteId)) {
return <div className="text-muted-foreground">Select a website to load question quality metrics.</div>
}
if (isLoading) return <Skeleton className="h-[280px] w-full" />
if (isError || !data) {
return (
<Alert variant="destructive">
<AlertTitle>Failed to load question quality</AlertTitle>
<AlertDescription>Check the selected website and admin API connection.</AlertDescription>
</Alert>
)
}
const calibrated = data.filter((question) => question.calibrated)
const needsCalibration = data.filter(
(question) => !question.calibrated && question.calibration_sample_size >= 30
)
const irtValues = data.map((question) => question.irt_b).filter((value): value is number => value !== null)
const seValues = data
.map((question) => question.irt_se)
.filter((value): value is number => value !== null && value !== undefined)
const levelRows = [
{ key: 'original', label: 'Original', filter: (question: Question) => isOriginalQuestion(question) },
{ key: 'mudah', label: 'mudah', filter: (question: Question) => !isOriginalQuestion(question) && question.level === 'mudah' },
{ key: 'sedang', label: 'sedang', filter: (question: Question) => !isOriginalQuestion(question) && question.level === 'sedang' },
{ key: 'sulit', label: 'sulit', filter: (question: Question) => !isOriginalQuestion(question) && question.level === 'sulit' },
]
return (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
title="Ready for IRT"
value={calibrated.length}
description={`${data.length} total questions`}
icon={CheckCircle2}
/>
<StatCard
title="Needs Calibration"
value={needsCalibration.length}
description="Uncalibrated with enough response data"
icon={AlertCircle}
/>
<StatCard
title="Mean Difficulty (b)"
value={formatNumber(average(irtValues), 2)}
description="Average calibrated IRT parameter"
icon={Target}
/>
<StatCard
title="Std Error Range"
value={seValues.length ? `${formatNumber(Math.min(...seValues), 2)}-${formatNumber(Math.max(...seValues), 2)}` : '-'}
description="Observed calibration precision"
icon={Activity}
/>
</div>
<Card>
<CardHeader>
<CardTitle>Calibration Diagnostics</CardTitle>
<CardDescription>Breakdown by difficulty level from the current website question bank.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Level</TableHead>
<TableHead>Total</TableHead>
<TableHead>Calibrated</TableHead>
<TableHead>Avg Sample</TableHead>
<TableHead>Avg P</TableHead>
<TableHead>Avg IRT b</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{levelRows.map((row) => {
const questions = data.filter(row.filter)
const avgSample = average(questions.map((question) => question.calibration_sample_size))
const avgP = average(
questions.map((question) => question.p_value).filter((value): value is number => value !== null)
)
const avgB = average(
questions.map((question) => question.irt_b).filter((value): value is number => value !== null)
)
return (
<TableRow key={row.key}>
<TableCell className="font-medium">{row.label}</TableCell>
<TableCell>{questions.length}</TableCell>
<TableCell>{questions.filter((question) => question.calibrated).length}</TableCell>
<TableCell>{formatNumber(avgSample, 1)}</TableCell>
<TableCell>{formatNumber(avgP, 3)}</TableCell>
<TableCell>{formatNumber(avgB, 2)}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,178 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } 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 { getQuestionLevelLabel, isOriginalQuestion } from '@/lib/questionLabels'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { Question } from '@/types'
import { Eye, Search } from 'lucide-react'
export default function QuestionsList() {
const { websiteId } = useAppStore()
const [search, setSearch] = useState('')
const [sourceFilter, setSourceFilter] = useState('all')
const [levelFilter, setLevelFilter] = useState('all')
const [statusFilter, setStatusFilter] = useState('all')
const { data, isLoading, isError } = useQuery({
queryKey: scopedQueryKey(websiteId, 'admin-questions'),
queryFn: async () => {
const res = await api.get<{ items: Question[] }>('/admin/questions')
return res.data.items
},
enabled: hasWebsiteScope(websiteId),
})
const questions = useMemo(() => data ?? [], [data])
const filteredQuestions = useMemo(() => {
const needle = search.trim().toLowerCase()
return questions.filter((item) => {
const haystack = [
item.stem_text,
item.tryout_id,
item.item_id,
item.level,
item.generated_by,
item.variant_status,
]
.filter(Boolean)
.join(' ')
.toLowerCase()
const matchesSearch = !needle || haystack.includes(needle)
const matchesSource = sourceFilter === 'all' || item.generated_by === sourceFilter
const matchesLevel = levelFilter === 'all' || item.level === levelFilter
const matchesStatus =
statusFilter === 'all' ||
(statusFilter === 'calibrated' && item.calibrated) ||
(statusFilter === 'uncalibrated' && !item.calibrated) ||
item.variant_status === statusFilter
return matchesSearch && matchesSource && matchesLevel && matchesStatus
})
}, [questions, search, sourceFilter, levelFilter, statusFilter])
return (
<Card>
<CardContent className="pt-6">
{!hasWebsiteScope(websiteId) ? (
<div className="text-muted-foreground">Select a website to load questions.</div>
) : isLoading ? (
<div className="space-y-4">
{[1, 2, 3].map(i => <Skeleton key={i} className="h-16 w-full" />)}
</div>
) : isError || !data ? (
<div className="text-destructive">Failed to load questions.</div>
) : (
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-[minmax(220px,1fr)_160px_160px_170px]">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
className="pl-9"
placeholder="Search stem, item, or tryout"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
<Select value={sourceFilter} onValueChange={setSourceFilter}>
<SelectTrigger>
<SelectValue placeholder="Source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All sources</SelectItem>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="ai">AI</SelectItem>
</SelectContent>
</Select>
<Select value={levelFilter} onValueChange={setLevelFilter}>
<SelectTrigger>
<SelectValue placeholder="Level" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All levels</SelectItem>
<SelectItem value="mudah">Mudah</SelectItem>
<SelectItem value="sedang">Sedang</SelectItem>
<SelectItem value="sulit">Sulit</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="calibrated">Calibrated</SelectItem>
<SelectItem value="uncalibrated">Uncalibrated</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
</SelectContent>
</Select>
</div>
<div className="rounded-md border">
<table className="w-full text-sm text-left">
<thead className="bg-muted text-muted-foreground">
<tr>
<th className="p-3 font-medium">Question</th>
<th className="p-3 font-medium">Difficulty</th>
<th className="p-3 font-medium">Source</th>
<th className="p-3 font-medium">Tryout</th>
<th className="p-3 font-medium">Status</th>
<th className="p-3 font-medium text-right">Detail</th>
</tr>
</thead>
<tbody className="divide-y">
{filteredQuestions.map(item => (
<tr key={item.id} className="hover:bg-muted/50">
<td className="p-3">
<SafeHtml html={item.stem_text} className="max-w-md truncate" />
<div className="text-[10px] text-muted-foreground mt-1">Item #{item.id}</div>
</td>
<td className="p-3">
<Badge variant={isOriginalQuestion(item) || item.level === 'sedang' ? 'default' : item.level === 'mudah' ? 'secondary' : 'destructive'}>
{getQuestionLevelLabel(item)}
</Badge>
<div className="text-[10px] text-muted-foreground mt-1">p: {item.p_value?.toFixed(2) ?? '-'}</div>
</td>
<td className="p-3 capitalize">{item.generated_by}</td>
<td className="p-3">{item.tryout_id}</td>
<td className="p-3">
{item.calibrated ? (
<Badge variant="outline" className="text-green-600 bg-green-50 border-green-200">Calibrated</Badge>
) : (
<Badge variant="outline" className="text-amber-600 bg-amber-50 border-amber-200">Pending</Badge>
)}
{item.variant_status && (
<div className="mt-1">
<Badge variant="secondary" className="capitalize">{item.variant_status}</Badge>
</div>
)}
</td>
<td className="p-3 text-right">
<Button variant="outline" size="sm" asChild>
<Link to={`/admin/questions/${item.id}`}>
<Eye className="mr-2 h-4 w-4" />
View
</Link>
</Button>
</td>
</tr>
))}
</tbody>
</table>
{filteredQuestions.length === 0 && (
<div className="p-4 text-center text-muted-foreground">No questions found.</div>
)}
</div>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,89 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { api } from '@/lib/api'
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
import { useAppStore } from '@/store/useAppStore'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Bot, Sparkles } from 'lucide-react'
interface Template {
id: number
tryout_id: string
stem_text: string
p_value: number | null
created_at: string
variants_count: number
}
export default function TemplatesList() {
const { websiteId } = useAppStore()
const { data, isLoading, isError } = useQuery({
queryKey: scopedQueryKey(websiteId, 'admin-templates'),
queryFn: async () => {
const res = await api.get<{ items: Template[] }>('/admin/templates')
return res.data
},
enabled: hasWebsiteScope(websiteId),
})
return (
<div className="space-y-4">
<div className="bg-muted/30 p-4 rounded-lg border">
<div>
<h3 className="font-medium">Question Templates</h3>
<p className="text-sm text-muted-foreground">Original questions used to generate AI variants.</p>
</div>
</div>
{!hasWebsiteScope(websiteId) ? (
<div className="p-8 text-center text-muted-foreground border rounded-lg border-dashed">
Select a website to load templates.
</div>
) : isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1, 2].map(i => <Skeleton key={i} className="h-32 w-full" />)}
</div>
) : isError || !data ? (
<div className="text-destructive">Failed to load templates.</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{data.items.map(template => (
<Card key={template.id}>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<CardTitle className="text-base line-clamp-2" title={template.stem_text}>
{template.stem_text}
</CardTitle>
</div>
<CardDescription>
Tryout: {template.tryout_id} | Difficulty (p): {template.p_value?.toFixed(2) ?? '-'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex justify-between items-center pt-2">
<div className="text-sm text-muted-foreground flex items-center gap-2">
<Bot className="h-4 w-4" />
{template.variants_count} AI Variants Generated
</div>
<Button variant="secondary" size="sm" asChild>
<Link to={`/admin/tryouts/${template.tryout_id}/questions/${template.id}/ai-workspace`}>
<Sparkles className="mr-2 h-4 w-4" />
Generate More
</Link>
</Button>
</div>
</CardContent>
</Card>
))}
{data.items.length === 0 && (
<div className="col-span-full p-8 text-center text-muted-foreground border rounded-lg border-dashed">
No templates found. A template must be a question with 'sedang' difficulty created manually.
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,20 @@
import QuestionsList from './QuestionsList'
import QuestionQuality from './QuestionQuality'
export default function Questions() {
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Questions Bank</h1>
<p className="text-muted-foreground mt-1">Manage all global questions.</p>
</div>
</div>
<div className="space-y-4">
<QuestionQuality />
<QuestionsList />
</div>
</div>
)
}

View File

@@ -0,0 +1,470 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { api } from '@/lib/api'
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
import { useAppStore } from '@/store/useAppStore'
import type { Tryout } from '@/types'
import { AlertCircle, Download } from 'lucide-react'
type CalibrationItem = {
item_id: string
slot: number
level: string
sample_size: number
calibrated: boolean
irt_b: number | null
irt_se: number | null
ctt_p: number | null
}
type CalibrationStatusReport = {
total_items: number
calibrated_items: number
calibration_percentage: number
items_awaiting_calibration: CalibrationItem[]
avg_calibration_sample_size: number | null
ready_for_irt_rollout: boolean
items: CalibrationItem[]
}
type ItemAnalysisRecord = {
item_id: string
slot: number
level: string
ctt_p: number | null
ctt_bobot: number | null
ctt_category: string | null
irt_b: number | null
irt_se: number | null
calibrated: boolean
calibration_sample_size: number
correctness_rate: number | null
item_total_correlation: number | null
}
type ItemAnalysisReport = {
total_items: number
items: ItemAnalysisRecord[]
summary: Record<string, unknown>
}
type StudentPerformanceRecord = {
session_id: string
wp_user_id: string
NM: number | null
NN: number | null
theta: number | null
theta_se: number | null
total_benar: number
time_spent: number | null
start_time: string
end_time: string | null
scoring_mode_used: string
}
type StudentPerformanceReport = {
aggregate: {
participant_count: number
avg_nm: number | null
std_nm: number | null
min_nm: number | null
max_nm: number | null
median_nm: number | null
avg_nn: number | null
std_nn: number | null
avg_theta: number | null
pass_rate: number | null
avg_time_spent: number | null
}
individual_records: StudentPerformanceRecord[]
}
function formatNumber(value: number | null | undefined, digits = 2) {
if (value === null || value === undefined || Number.isNaN(value)) return '-'
return Number(value).toFixed(digits)
}
function getTryoutLabel(tryout: Tryout) {
return `${tryout.tryout_id} - ${tryout.name || tryout.title || 'Untitled tryout'}`
}
async function downloadReport(path: string, tryoutId: string, filename: string) {
const res = await api.get<Blob>(path, {
params: { tryout_id: tryoutId },
responseType: 'blob',
})
const contentType = res.headers['content-type']
const blob = new Blob([res.data], { type: typeof contentType === 'string' ? contentType : 'text/csv' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
}
function ReportError({ title }: { title: string }) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{title}</AlertTitle>
<AlertDescription>Check the selected website, tryout, and backend API availability.</AlertDescription>
</Alert>
)
}
function SummaryStat({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-md border p-4">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold">{value}</p>
</div>
)
}
function CalibrationStatusReportView({ tryoutId, websiteId }: { tryoutId: string; websiteId: number }) {
const [isExporting, setIsExporting] = useState(false)
const { data, isLoading, isError } = useQuery({
queryKey: scopedQueryKey(websiteId, 'report-calibration-status', tryoutId),
queryFn: async () => {
const res = await api.get<CalibrationStatusReport>('/reports/calibration/status', {
params: { tryout_id: tryoutId },
})
return res.data
},
enabled: hasWebsiteScope(websiteId) && Boolean(tryoutId),
})
if (isLoading) return <Skeleton className="h-[300px] w-full" />
if (isError) return <ReportError title="Failed to load calibration report" />
if (!data) return null
const handleExport = async () => {
setIsExporting(true)
try {
await downloadReport(
'/reports/calibration/status/export/csv',
tryoutId,
`calibration_status_${tryoutId}.csv`
)
} finally {
setIsExporting(false)
}
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div>
<CardTitle>Calibration Status</CardTitle>
<CardDescription>Item calibration readiness for the selected tryout.</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={handleExport} disabled={isExporting}>
<Download className="mr-2 h-4 w-4" />
{isExporting ? 'Exporting...' : 'Export CSV'}
</Button>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<SummaryStat label="Total Items" value={data.total_items} />
<SummaryStat label="Calibrated" value={data.calibrated_items} />
<SummaryStat label="Progress" value={`${formatNumber(data.calibration_percentage, 1)}%`} />
<SummaryStat label="Avg Sample" value={formatNumber(data.avg_calibration_sample_size, 1)} />
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Slot</TableHead>
<TableHead>Level</TableHead>
<TableHead>Sample</TableHead>
<TableHead>CTT P</TableHead>
<TableHead>IRT B</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((item) => (
<TableRow key={`${item.item_id}-${item.slot}`}>
<TableCell className="font-medium">{item.item_id}</TableCell>
<TableCell>{item.slot}</TableCell>
<TableCell>{item.level}</TableCell>
<TableCell>{item.sample_size}</TableCell>
<TableCell>{formatNumber(item.ctt_p, 3)}</TableCell>
<TableCell>{formatNumber(item.irt_b, 3)}</TableCell>
<TableCell>
<Badge variant={item.calibrated ? 'default' : 'secondary'}>
{item.calibrated ? 'Calibrated' : 'Needs data'}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)
}
function ItemAnalysisReportView({ tryoutId, websiteId }: { tryoutId: string; websiteId: number }) {
const [isExporting, setIsExporting] = useState(false)
const { data, isLoading, isError } = useQuery({
queryKey: scopedQueryKey(websiteId, 'report-item-analysis', tryoutId),
queryFn: async () => {
const res = await api.get<ItemAnalysisReport>('/reports/items/analysis', {
params: { tryout_id: tryoutId },
})
return res.data
},
enabled: hasWebsiteScope(websiteId) && Boolean(tryoutId),
})
if (isLoading) return <Skeleton className="h-[300px] w-full" />
if (isError) return <ReportError title="Failed to load item analysis" />
if (!data) return null
const handleExport = async () => {
setIsExporting(true)
try {
await downloadReport('/reports/items/analysis/export/csv', tryoutId, `item_analysis_${tryoutId}.csv`)
} finally {
setIsExporting(false)
}
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div>
<CardTitle>Item Analysis</CardTitle>
<CardDescription>Difficulty, discrimination, and calibration statistics.</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={handleExport} disabled={isExporting}>
<Download className="mr-2 h-4 w-4" />
{isExporting ? 'Exporting...' : 'Export CSV'}
</Button>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-3">
<SummaryStat label="Total Items" value={data.total_items} />
<SummaryStat
label="Calibrated"
value={data.items.filter((item) => item.calibrated).length}
/>
<SummaryStat
label="Avg Correctness"
value={`${formatNumber(
data.items.reduce((sum, item) => sum + (item.correctness_rate ?? 0), 0) /
Math.max(data.items.filter((item) => item.correctness_rate !== null).length, 1),
1
)}%`}
/>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Slot</TableHead>
<TableHead>Level</TableHead>
<TableHead>CTT P</TableHead>
<TableHead>Bobot</TableHead>
<TableHead>Category</TableHead>
<TableHead>IRT B</TableHead>
<TableHead>Sample</TableHead>
<TableHead>Correlation</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((item) => (
<TableRow key={`${item.item_id}-${item.slot}`}>
<TableCell className="font-medium">{item.item_id}</TableCell>
<TableCell>{item.slot}</TableCell>
<TableCell>{item.level}</TableCell>
<TableCell>{formatNumber(item.ctt_p, 3)}</TableCell>
<TableCell>{formatNumber(item.ctt_bobot, 2)}</TableCell>
<TableCell>{item.ctt_category || '-'}</TableCell>
<TableCell>{formatNumber(item.irt_b, 3)}</TableCell>
<TableCell>{item.calibration_sample_size}</TableCell>
<TableCell>{formatNumber(item.item_total_correlation, 3)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)
}
function StudentPerformanceReportView({ tryoutId, websiteId }: { tryoutId: string; websiteId: number }) {
const [isExporting, setIsExporting] = useState(false)
const { data, isLoading, isError } = useQuery({
queryKey: scopedQueryKey(websiteId, 'report-student-performance', tryoutId),
queryFn: async () => {
const res = await api.get<StudentPerformanceReport>('/reports/student/performance', {
params: { tryout_id: tryoutId },
})
return res.data
},
enabled: hasWebsiteScope(websiteId) && Boolean(tryoutId),
})
if (isLoading) return <Skeleton className="h-[300px] w-full" />
if (isError) return <ReportError title="Failed to load student performance" />
if (!data) return null
const handleExport = async () => {
setIsExporting(true)
try {
await downloadReport(
'/reports/student/performance/export/csv',
tryoutId,
`student_performance_${tryoutId}.csv`
)
} finally {
setIsExporting(false)
}
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div>
<CardTitle>Student Performance</CardTitle>
<CardDescription>Scores and completion statistics for the selected tryout.</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={handleExport} disabled={isExporting}>
<Download className="mr-2 h-4 w-4" />
{isExporting ? 'Exporting...' : 'Export CSV'}
</Button>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<SummaryStat label="Participants" value={data.aggregate.participant_count} />
<SummaryStat label="Avg NM" value={formatNumber(data.aggregate.avg_nm, 2)} />
<SummaryStat label="Avg NN" value={formatNumber(data.aggregate.avg_nn, 2)} />
<SummaryStat label="Pass Rate" value={`${formatNumber(data.aggregate.pass_rate, 1)}%`} />
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Session</TableHead>
<TableHead>WP User</TableHead>
<TableHead>NM</TableHead>
<TableHead>NN</TableHead>
<TableHead>Theta</TableHead>
<TableHead>Correct</TableHead>
<TableHead>Time</TableHead>
<TableHead>Mode</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.individual_records.map((record) => (
<TableRow key={record.session_id}>
<TableCell className="font-medium">{record.session_id}</TableCell>
<TableCell>{record.wp_user_id}</TableCell>
<TableCell>{formatNumber(record.NM, 2)}</TableCell>
<TableCell>{formatNumber(record.NN, 2)}</TableCell>
<TableCell>{formatNumber(record.theta, 3)}</TableCell>
<TableCell>{record.total_benar}</TableCell>
<TableCell>{record.time_spent ? `${Math.round(record.time_spent / 60)}m` : '-'}</TableCell>
<TableCell>{record.scoring_mode_used}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)
}
export default function Reports() {
const { websiteId } = useAppStore()
const [manualTryoutId, setManualTryoutId] = useState('')
const { data: tryouts, isLoading: isLoadingTryouts, isError: isTryoutsError } = useQuery({
queryKey: scopedQueryKey(websiteId, 'tryouts'),
queryFn: async () => {
const res = await api.get<Tryout[]>('/tryout/')
return res.data
},
enabled: hasWebsiteScope(websiteId),
})
const selectedTryoutId =
tryouts?.some((tryout) => tryout.tryout_id === manualTryoutId)
? manualTryoutId
: tryouts?.[0]?.tryout_id || ''
if (!hasWebsiteScope(websiteId)) {
return <div className="text-muted-foreground">Select a website to load reports.</div>
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">System Reports</h1>
<p className="text-muted-foreground mt-1">View and export analytical reports.</p>
</div>
<div className="w-full md:w-80">
<Select value={selectedTryoutId} onValueChange={setManualTryoutId} disabled={isLoadingTryouts}>
<SelectTrigger>
<SelectValue placeholder="Select tryout" />
</SelectTrigger>
<SelectContent>
{tryouts?.map((tryout) => (
<SelectItem key={tryout.tryout_id} value={tryout.tryout_id}>
{getTryoutLabel(tryout)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{isLoadingTryouts ? (
<Skeleton className="h-[300px] w-full" />
) : isTryoutsError ? (
<ReportError title="Failed to load tryouts" />
) : !selectedTryoutId ? (
<Alert>
<AlertTitle>No tryouts available</AlertTitle>
<AlertDescription>Import or create a tryout before opening reports.</AlertDescription>
</Alert>
) : (
<Tabs defaultValue="calibration" className="space-y-4">
<TabsList>
<TabsTrigger value="calibration">Calibration Status</TabsTrigger>
<TabsTrigger value="items">Item Statistics</TabsTrigger>
<TabsTrigger value="students">Student Performance</TabsTrigger>
</TabsList>
<TabsContent value="calibration" className="space-y-4">
<CalibrationStatusReportView tryoutId={selectedTryoutId} websiteId={websiteId} />
</TabsContent>
<TabsContent value="items" className="space-y-4">
<ItemAnalysisReportView tryoutId={selectedTryoutId} websiteId={websiteId} />
</TabsContent>
<TabsContent value="students" className="space-y-4">
<StudentPerformanceReportView tryoutId={selectedTryoutId} websiteId={websiteId} />
</TabsContent>
</Tabs>
)}
</div>
)
}

View File

@@ -0,0 +1,209 @@
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Plus, Trash2, Edit2, AlertCircle } from 'lucide-react'
interface Website {
id: number
domain: string
name: string
}
function WebsitesManagement() {
const queryClient = useQueryClient()
const [isEditing, setIsEditing] = useState<number | null>(null)
const [editName, setEditName] = useState('')
const [editDomain, setEditDomain] = useState('')
const [newName, setNewName] = useState('')
const [newDomain, setNewDomain] = useState('')
const { data: websites, isLoading, isError } = useQuery({
queryKey: ['websites'],
queryFn: async () => {
const res = await api.get<Website[]>('/websites')
return res.data
},
})
const createMutation = useMutation({
mutationFn: async () => {
await api.post('/websites', {
name: newName,
domain: newDomain,
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['websites'] })
setNewName('')
setNewDomain('')
},
})
const updateMutation = useMutation({
mutationFn: async ({ id, name, domain }: { id: number; name: string; domain: string }) => {
await api.put(`/websites/${id}`, { name, domain })
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['websites'] })
setIsEditing(null)
},
})
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
await api.delete(`/websites/${id}`)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['websites'] })
},
})
if (isLoading) return <Skeleton className="h-[400px] w-full" />
if (isError) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Failed to load websites</AlertTitle>
<AlertDescription>Check your admin token and backend API connection.</AlertDescription>
</Alert>
)
}
return (
<Card>
<CardHeader>
<CardTitle>Websites</CardTitle>
<CardDescription>Manage allowed Sejoli websites.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-3 rounded-md border bg-muted/30 p-4 md:grid-cols-[1fr_1fr_auto] md:items-end">
<div className="space-y-2">
<Label htmlFor="website-name">Name</Label>
<Input
id="website-name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Main website"
/>
</div>
<div className="space-y-2">
<Label htmlFor="website-domain">Domain</Label>
<Input
id="website-domain"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
placeholder="https://example.com"
/>
</div>
<Button
onClick={() => createMutation.mutate()}
disabled={!newName.trim() || !newDomain.trim() || createMutation.isPending}
>
<Plus className="mr-2 h-4 w-4" />
{createMutation.isPending ? 'Adding...' : 'Add Website'}
</Button>
</div>
{(createMutation.isError || updateMutation.isError || deleteMutation.isError) && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Website update failed</AlertTitle>
<AlertDescription>The backend rejected the website change.</AlertDescription>
</Alert>
)}
<div className="space-y-4">
{websites?.map((website) => (
<div key={website.id} className="flex items-center justify-between p-4 border rounded-lg bg-card">
{isEditing === website.id ? (
<div className="flex-1 grid grid-cols-1 gap-4 mr-4 md:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">Name</Label>
<Input value={editName} onChange={(e) => setEditName(e.target.value)} />
</div>
<div className="space-y-1">
<Label className="text-xs">Domain</Label>
<Input value={editDomain} onChange={(e) => setEditDomain(e.target.value)} />
</div>
</div>
) : (
<div className="flex-1">
<h4 className="font-semibold">{website.name}</h4>
<p className="text-sm text-muted-foreground">{website.domain}</p>
</div>
)}
<div className="flex gap-2">
{isEditing === website.id ? (
<>
<Button
variant="default"
size="sm"
disabled={!editName.trim() || !editDomain.trim() || updateMutation.isPending}
onClick={() => updateMutation.mutate({ id: website.id, name: editName, domain: editDomain })}
>
Save
</Button>
<Button variant="ghost" size="sm" onClick={() => setIsEditing(null)}>Cancel</Button>
</>
) : (
<>
<Button
variant="outline"
size="icon"
onClick={() => {
setIsEditing(website.id)
setEditName(website.name || '')
setEditDomain(website.domain || '')
}}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => {
if (confirm('Delete this website?')) {
deleteMutation.mutate(website.id)
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</div>
</div>
))}
{websites?.length === 0 && (
<div className="text-center p-8 text-muted-foreground border border-dashed rounded-lg">
No websites configured yet.
</div>
)}
</div>
</CardContent>
</Card>
)
}
export default function Settings() {
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
<p className="text-muted-foreground mt-1">Manage concrete system configuration available in the API.</p>
</div>
</div>
<WebsitesManagement />
</div>
)
}

View File

@@ -0,0 +1,704 @@
import { useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { SafeHtml } from '@/components/SafeHtml'
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
import { getQuestionLevelLabel } from '@/lib/questionLabels'
import type { Question } from '@/types'
import { useAppStore } from '@/store/useAppStore'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ArrowLeft, CheckCircle2, ChevronLeft, ChevronRight, Layers, RefreshCw, Save, Sparkles } from 'lucide-react'
interface AIModel {
id: string
name: string
description: string
pricing?: AIModelPricing | null
}
type AIModelPricing = {
prompt?: number | null
completion?: number | null
prompt_per_million?: number | null
completion_per_million?: number | null
currency?: string
source?: string
}
type AIUsage = {
prompt_tokens?: number | null
completion_tokens?: number | null
total_tokens?: number | null
cost_usd?: number | null
}
type QuestionDetailResponse = {
item: Question
basis_item: Question | null
variants: Question[]
usage: {
impressions: number
unique_users: number
}
}
type GeneratedPreviewResponse = {
success: boolean
stem?: string
options?: Record<string, string>
correct?: string
explanation?: string | null
ai_model?: string
basis_item_id?: number
target_level?: string
usage?: AIUsage | null
error?: string
cached?: boolean
}
type BatchGeneratedItem = {
item_id: number
stem: string
options: Record<string, string>
correct: string
explanation?: string | null
level: string
variant_status: string
usage?: AIUsage | null
}
type BatchGenerateResponse = {
success: boolean
run_id?: number
item_ids: number[]
items: BatchGeneratedItem[]
generated_count: number
usage?: AIUsage | null
error?: string
}
type PreviewCandidate = {
client_id: string
stem?: string
options?: Record<string, string>
correct?: string
explanation?: string | null
ai_model?: string
basis_item_id?: number
target_level?: string
usage?: AIUsage | null
saved_item_id?: number
variant_status?: string
}
type ReviewQuestion = {
id?: number
item_id?: number | string
slot?: number
level?: string
stem?: string
stem_text?: string
options?: Record<string, string>
correct?: string
correct_answer?: string
explanation?: string | null
variant_status?: string
usage?: AIUsage | null
saved_item_id?: number
}
function formatUsd(value?: number | null, maximumFractionDigits = 6) {
if (value === undefined || value === null) return 'Cost unavailable'
return `$${value.toLocaleString(undefined, {
minimumFractionDigits: value >= 0.01 ? 4 : 6,
maximumFractionDigits,
})}`
}
function formatPerMillion(value?: number | null) {
if (value === undefined || value === null) return '?'
return `$${value.toLocaleString(undefined, {
minimumFractionDigits: value >= 1 ? 2 : 4,
maximumFractionDigits: value >= 1 ? 2 : 4,
})}`
}
function formatPricing(pricing?: AIModelPricing | null) {
if (!pricing) return 'pricing unavailable'
return `Input ${formatPerMillion(pricing.prompt_per_million)}/1M · Output ${formatPerMillion(pricing.completion_per_million)}/1M`
}
function formatTokenCount(value?: number | null) {
return value === undefined || value === null ? '-' : value.toLocaleString()
}
function formatUsage(usage?: AIUsage | null) {
if (!usage) return null
return `Input ${formatTokenCount(usage.prompt_tokens)} · Output ${formatTokenCount(usage.completion_tokens)} · Total ${formatTokenCount(usage.total_tokens)} · ${formatUsd(usage.cost_usd)}`
}
function QuestionReviewPanel({
item,
isLoading,
emptyText,
title,
description,
}: {
item: ReviewQuestion | undefined
isLoading: boolean
emptyText: string
title: string
description?: string
}) {
if (isLoading) {
return (
<div className="space-y-3">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
)
}
if (!item) {
return (
<div className="flex min-h-[320px] items-center justify-center rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
{emptyText}
</div>
)
}
const correct = item.correct_answer || item.correct
const options = Object.entries(item.options || {})
const usageLabel = formatUsage(item.usage)
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">{title}</h3>
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
{usageLabel && <p className="mt-1 text-xs text-muted-foreground">{usageLabel}</p>}
</div>
<div className="rounded-md border p-3 text-sm">
<SafeHtml html={item.stem || item.stem_text} className="max-w-none leading-relaxed" />
</div>
{options.length > 0 && (
<div className="space-y-2">
{options.map(([key, value]) => (
<div key={key} className="flex gap-2 rounded-md border p-2 text-sm">
<span className="font-mono font-semibold">{key}</span>
<SafeHtml html={value} className="flex-1" />
{correct === key && <Badge>Correct</Badge>}
</div>
))}
</div>
)}
{item.explanation && (
<div className="rounded-md border p-3 text-sm">
<div className="mb-2 font-medium">Explanation</div>
<SafeHtml html={item.explanation} className="max-w-none leading-relaxed" allowEmbeds />
</div>
)}
</div>
)
}
export default function AIWorkspace() {
const { id: tryoutId, questionId } = useParams<{ id: string; questionId?: string }>()
const { websiteId } = useAppStore()
const queryClient = useQueryClient()
const [targetLevel, setTargetLevel] = useState<'mudah' | 'sulit'>('mudah')
const [aiModel, setAiModel] = useState<string>('')
const [batchCount, setBatchCount] = useState('3')
const [operatorNotes, setOperatorNotes] = useState('')
const [activeReviewTab, setActiveReviewTab] = useState<'original' | 'preview' | 'batch'>('original')
const [previewCandidates, setPreviewCandidates] = useState<PreviewCandidate[]>([])
const [previewIndex, setPreviewIndex] = useState(0)
const [batchIndex, setBatchIndex] = useState(0)
// Data Fetching
const { data: modelsData } = useQuery({
queryKey: ['ai-models'],
queryFn: async () => {
const res = await api.get<{ models: AIModel[] }>('/admin/ai/models')
return res.data.models
}
})
const routedQuestionId = questionId ? Number(questionId) : null
const { data: routedQuestionDetail, isLoading: isRoutedQuestionLoading, isError: isRoutedQuestionError } = useQuery({
queryKey: scopedQueryKey(websiteId, 'question-detail', questionId),
queryFn: async () => {
const res = await api.get<QuestionDetailResponse>(`/admin/questions/${questionId}`)
return res.data
},
enabled: hasWebsiteScope(websiteId) && Boolean(questionId),
})
const selectedBasisItemId = routedQuestionId ? routedQuestionId.toString() : ''
const selectedBasisItem = useMemo(() => {
const selectedId = Number(selectedBasisItemId)
if (!selectedId) return undefined
return routedQuestionDetail?.item.id === selectedId ? routedQuestionDetail.item : undefined
}, [routedQuestionDetail, selectedBasisItemId])
const isBasisLoading = Boolean(selectedBasisItemId) && !selectedBasisItem && isRoutedQuestionLoading
const isBasisUnavailable = Boolean(selectedBasisItemId) && !selectedBasisItem && !isBasisLoading
// Mutations
const generateMutation = useMutation({
mutationFn: async () => {
const res = await api.post('/admin/ai/generate-preview', {
basis_item_id: Number(selectedBasisItemId),
target_level: targetLevel,
ai_model: aiModel
})
return res.data as GeneratedPreviewResponse
},
onSuccess: (data) => {
if (data.success) {
const candidate: PreviewCandidate = {
client_id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
stem: data.stem,
options: data.options,
correct: data.correct,
explanation: data.explanation,
ai_model: data.ai_model || aiModel,
basis_item_id: data.basis_item_id,
target_level: data.target_level || targetLevel,
usage: data.usage,
}
setPreviewCandidates((current) => {
const next = [...current, candidate]
setPreviewIndex(next.length - 1)
return next
})
setActiveReviewTab('preview')
}
},
})
const saveMutation = useMutation({
mutationFn: async () => {
const generated = previewCandidates[previewIndex]
if (!generated) return
const basisItem = selectedBasisItem
if (!basisItem?.slot || !websiteId || !tryoutId) {
throw new Error('Basis question metadata is incomplete. Refresh this page and try again.')
}
const res = await api.post('/admin/ai/generate-save', {
stem: generated.stem,
options: generated.options,
correct: generated.correct,
explanation: generated.explanation,
tryout_id: tryoutId,
website_id: websiteId,
basis_item_id: Number(selectedBasisItemId),
slot: basisItem.slot,
level: generated.target_level || targetLevel,
variant_status: 'active',
ai_model: generated.ai_model || aiModel
})
return res.data
},
onSuccess: (data) => {
const activeCandidateId = previewCandidates[previewIndex]?.client_id
if (activeCandidateId) {
setPreviewCandidates((current) =>
current.map((candidate) =>
candidate.client_id === activeCandidateId
? {
...candidate,
saved_item_id: data?.item_id,
variant_status: 'active',
}
: candidate,
),
)
}
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'tryout-questions', tryoutId) })
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'ai-pending-reviews') })
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'ai-runs') })
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'ai-variants') })
}
})
const batchMutation = useMutation({
mutationFn: async () => {
const res = await api.post('/admin/ai/generate-batch', {
basis_item_id: Number(selectedBasisItemId),
target_level: targetLevel,
ai_model: aiModel,
count: Number(batchCount),
operator_notes: operatorNotes.trim() || null,
})
return res.data as BatchGenerateResponse
},
onSuccess: () => {
setBatchIndex(0)
setActiveReviewTab('batch')
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'tryout-questions', tryoutId) })
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'ai-pending-reviews') })
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'ai-runs') })
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'ai-variants') })
}
})
const handleGeneratePreview = () => {
saveMutation.reset()
generateMutation.mutate()
}
const handleGenerateBatch = () => {
setBatchIndex(0)
setActiveReviewTab('batch')
batchMutation.mutate()
}
const activePreviewCandidate = previewCandidates[previewIndex]
const hasPreviewCandidates = previewCandidates.length > 0
const handlePreviewStep = (direction: -1 | 1) => {
saveMutation.reset()
setPreviewIndex((current) => {
const next = current + direction
return Math.min(Math.max(next, 0), Math.max(previewCandidates.length - 1, 0))
})
}
const batchItems = batchMutation.data?.items ?? []
const activeBatchItem = batchItems[batchIndex]
const questionsPath = tryoutId ? `/admin/tryouts/${tryoutId}/questions` : '/admin/tryouts'
if (!hasWebsiteScope(websiteId)) {
return <div className="text-muted-foreground">Select a website to use the AI workspace.</div>
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">AI Question Workspace</h2>
<Button variant="outline" size="sm" asChild>
<Link to={questionsPath}>
<ArrowLeft className="h-4 w-4" />
All Questions
</Link>
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1 space-y-6">
<Card>
<CardHeader>
<CardTitle>AI Configuration</CardTitle>
<CardDescription>Generate variants from the selected original question.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Original Reference</Label>
<div className="rounded-md border px-3 py-2 text-sm">
{selectedBasisItem ? `Question #${selectedBasisItem.id} · Slot ${selectedBasisItem.slot ?? '-'}` : 'No original selected'}
</div>
</div>
<div className="space-y-2">
<Label>Target Difficulty</Label>
<Select value={targetLevel} onValueChange={(v) => setTargetLevel(v as 'mudah' | 'sulit')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mudah">Mudah</SelectItem>
<SelectItem value="sulit">Sulit</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>AI Model</Label>
<Select value={aiModel} onValueChange={setAiModel}>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{modelsData?.map(m => (
<SelectItem key={m.id} value={m.id}>
<div className="flex flex-col gap-0.5">
<span>{m.name}</span>
<span className="text-xs text-muted-foreground">{formatPricing(m.pricing)}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="batch-count">Batch Count</Label>
<Input
id="batch-count"
type="number"
min="1"
max="10"
value={batchCount}
onChange={(event) => setBatchCount(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="operator-notes">Run Notes</Label>
<Textarea
id="operator-notes"
value={operatorNotes}
onChange={(event) => setOperatorNotes(event.target.value)}
/>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-2">
<Button
className="w-full"
onClick={handleGeneratePreview}
disabled={!selectedBasisItemId || !aiModel || generateMutation.isPending || isBasisUnavailable}
>
{generateMutation.isPending ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
Generate Preview
</Button>
<Button
variant="outline"
className="w-full"
onClick={handleGenerateBatch}
disabled={
!selectedBasisItemId ||
!aiModel ||
batchMutation.isPending ||
isBasisUnavailable ||
Number(batchCount) < 1 ||
Number(batchCount) > 10
}
>
{batchMutation.isPending ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : (
<Layers className="mr-2 h-4 w-4" />
)}
Generate Trusted Batch
</Button>
</CardFooter>
</Card>
{isRoutedQuestionError ? (
<Alert variant="destructive">
<AlertTitle>Basis question failed to load</AlertTitle>
<AlertDescription>Check the selected website, tryout, and question ID.</AlertDescription>
</Alert>
) : null}
</div>
<div className="lg:col-span-2">
<Card className="h-full min-h-[500px]">
<CardHeader>
<CardTitle>Preview Workspace</CardTitle>
<CardDescription>Compare previews manually, or inspect trusted batch results.</CardDescription>
</CardHeader>
<CardContent>
<Tabs value={activeReviewTab} onValueChange={(value) => setActiveReviewTab(value as 'original' | 'preview' | 'batch')}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="original">Original</TabsTrigger>
<TabsTrigger value="preview">Preview</TabsTrigger>
<TabsTrigger value="batch">Batch</TabsTrigger>
</TabsList>
<TabsContent value="original" className="pt-4">
<QuestionReviewPanel
item={selectedBasisItem}
isLoading={isBasisLoading}
title={selectedBasisItem ? `Original Question #${selectedBasisItem.id}` : 'Original Question'}
description={
selectedBasisItem
? `Slot ${selectedBasisItem.slot ?? '-'} · ${getQuestionLevelLabel(selectedBasisItem)}`
: undefined
}
emptyText="No original question selected."
/>
</TabsContent>
<TabsContent value="preview" className="pt-4">
<div className="space-y-4">
{generateMutation.isError && (
<Alert variant="destructive">
<AlertTitle>Generation Failed</AlertTitle>
<AlertDescription>
{generateMutation.error instanceof Error ? generateMutation.error.message : 'Unknown error occurred'}
</AlertDescription>
</Alert>
)}
{generateMutation.data && !generateMutation.data.success && (
<Alert variant="destructive">
<AlertTitle>Generation Failed</AlertTitle>
<AlertDescription>{generateMutation.data.error || 'No variant was generated.'}</AlertDescription>
</Alert>
)}
{saveMutation.isError && (
<Alert variant="destructive">
<AlertTitle>Save Failed</AlertTitle>
<AlertDescription>
{saveMutation.error instanceof Error ? saveMutation.error.message : 'Unknown error occurred'}
</AlertDescription>
</Alert>
)}
{hasPreviewCandidates && (
<div className="flex items-center justify-between gap-3 rounded-md border px-3 py-2">
<Button
variant="outline"
size="icon"
onClick={() => handlePreviewStep(-1)}
disabled={previewIndex === 0}
aria-label="Previous preview variant"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="text-sm font-medium">
Preview {previewIndex + 1} of {previewCandidates.length}
</div>
<Button
variant="outline"
size="icon"
onClick={() => handlePreviewStep(1)}
disabled={previewIndex >= previewCandidates.length - 1}
aria-label="Next preview variant"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
<QuestionReviewPanel
item={activePreviewCandidate}
isLoading={generateMutation.isPending}
title={
activePreviewCandidate?.saved_item_id
? `Preview Variant · Saved #${activePreviewCandidate.saved_item_id}`
: 'Preview Variant'
}
description={
activePreviewCandidate
? `${activePreviewCandidate.target_level || targetLevel} · ${
activePreviewCandidate.saved_item_id ? 'active' : 'not saved yet'
}`
: undefined
}
emptyText="Generate previews to compare candidates before approving one."
/>
{activePreviewCandidate && (
<div className="flex justify-end">
<Button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || Boolean(activePreviewCandidate.saved_item_id)}
className="bg-emerald-600 hover:bg-emerald-700"
>
{saveMutation.isPending ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : activePreviewCandidate.saved_item_id ? (
<CheckCircle2 className="mr-2 h-4 w-4" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{activePreviewCandidate.saved_item_id ? 'Saved as Active' : 'Approve & Save'}
</Button>
</div>
)}
</div>
</TabsContent>
<TabsContent value="batch" className="pt-4">
<div className="space-y-4">
{batchMutation.isError && (
<Alert variant="destructive">
<AlertTitle>Batch Generation Failed</AlertTitle>
<AlertDescription>
{batchMutation.error instanceof Error ? batchMutation.error.message : 'Unknown error occurred'}
</AlertDescription>
</Alert>
)}
{batchMutation.data && (
<Alert variant={batchMutation.data.success ? 'default' : 'destructive'}>
<AlertTitle>
{batchMutation.data.success ? 'Trusted batch auto-approved' : 'Batch did not save variants'}
</AlertTitle>
<AlertDescription>
{batchMutation.data.success
? `Run #${batchMutation.data.run_id} created ${batchMutation.data.generated_count} active variants.${
formatUsage(batchMutation.data.usage) ? ` ${formatUsage(batchMutation.data.usage)}.` : ''
}`
: batchMutation.data.error || 'No variants were created.'}
</AlertDescription>
</Alert>
)}
{batchItems.length > 0 && (
<div className="flex items-center justify-between gap-3 rounded-md border px-3 py-2">
<Button
variant="outline"
size="icon"
onClick={() => setBatchIndex((current) => Math.max(current - 1, 0))}
disabled={batchIndex === 0}
aria-label="Previous batch variant"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="text-sm font-medium">
Variant {batchIndex + 1} of {batchItems.length}
</div>
<Button
variant="outline"
size="icon"
onClick={() => setBatchIndex((current) => Math.min(current + 1, batchItems.length - 1))}
disabled={batchIndex >= batchItems.length - 1}
aria-label="Next batch variant"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
<QuestionReviewPanel
item={activeBatchItem}
isLoading={batchMutation.isPending}
title={activeBatchItem ? `Trusted Variant #${activeBatchItem.item_id}` : 'Trusted Batch'}
description={activeBatchItem ? `${activeBatchItem.level} · ${activeBatchItem.variant_status}` : undefined}
emptyText="Generate a trusted batch to create active variants automatically."
/>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,91 @@
import { useParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
import { useAppStore } from '@/store/useAppStore'
import type { Attempt } from '@/types'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
export default function AttemptList() {
const { id } = useParams<{ id: string }>()
const { websiteId } = useAppStore()
const { data, isLoading, isError } = useQuery({
queryKey: scopedQueryKey(websiteId, 'tryout-attempts', id),
queryFn: async () => {
const res = await api.get<{ tryout_id: string; attempts: Attempt[] }>(`/admin/tryouts/${id}/attempts`)
return res.data
},
enabled: hasWebsiteScope(websiteId) && Boolean(id),
})
if (!hasWebsiteScope(websiteId)) {
return <div className="text-muted-foreground">Select a website to load attempts.</div>
}
if (isLoading) {
return <div className="space-y-4">
{[1, 2, 3].map(i => <Skeleton key={i} className="h-12 w-full" />)}
</div>
}
if (isError) {
return <div className="text-destructive">Failed to load attempts.</div>
}
const attempts = data?.attempts || []
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold">Student Attempts ({attempts.length})</h2>
</div>
{attempts.length === 0 ? (
<div className="text-center p-12 border rounded-lg border-dashed text-muted-foreground">
No attempts have been made yet.
</div>
) : (
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>User ID</TableHead>
<TableHead>Start Time</TableHead>
<TableHead className="text-center">Status</TableHead>
<TableHead className="text-right">NM</TableHead>
<TableHead className="text-right">NN</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{attempts.map((attempt) => (
<TableRow key={attempt.id}>
<TableCell className="font-medium">{attempt.wp_user_id}</TableCell>
<TableCell>{new Date(attempt.start_time).toLocaleString()}</TableCell>
<TableCell className="text-center">
{attempt.is_completed ? (
<Badge variant="outline" className="text-emerald-500 border-emerald-500">Completed</Badge>
) : (
<Badge variant="secondary">In Progress</Badge>
)}
</TableCell>
<TableCell className="text-right">{attempt.NM !== null ? attempt.NM : '-'}</TableCell>
<TableCell className="text-right font-semibold text-primary">{attempt.NN !== null ? attempt.NN : '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,185 @@
import { useState } from 'react'
import { useParams } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
import { useAppStore } from '@/store/useAppStore'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Save, RefreshCw } from 'lucide-react'
type TryoutConfig = {
normalization_mode: 'static' | 'dynamic' | 'hybrid'
static_rataan: number
static_sb: number
current_stats?: {
participant_count: number
rataan: number | null
sb: number | null
last_calculated: string | null
} | null
}
type NormalizationMode = 'static' | 'dynamic' | 'hybrid'
export default function Normalization() {
const { id } = useParams<{ id: string }>()
const queryClient = useQueryClient()
const { websiteId } = useAppStore()
const [draft, setDraft] = useState<{
key: string
rataan?: number
sb?: number
mode?: NormalizationMode
}>({ key: '' })
const [statusMessage, setStatusMessage] = useState<string | null>(null)
const { data: config, isLoading, isError, error } = useQuery({
queryKey: scopedQueryKey(websiteId, 'tryout-config', id),
queryFn: async () => {
const res = await api.get<TryoutConfig>(`/tryout/${id}/config`)
return res.data
},
enabled: hasWebsiteScope(websiteId) && Boolean(id),
})
const draftKey = `${websiteId ?? 'none'}:${id ?? ''}:${config?.normalization_mode ?? ''}:${config?.static_rataan ?? ''}:${config?.static_sb ?? ''}`
const rataan = draft.key === draftKey && draft.rataan !== undefined ? draft.rataan : config?.static_rataan ?? 500
const sb = draft.key === draftKey && draft.sb !== undefined ? draft.sb : config?.static_sb ?? 100
const mode = draft.key === draftKey && draft.mode ? draft.mode : config?.normalization_mode ?? 'static'
const updateDraft = (changes: Partial<Omit<typeof draft, 'key'>>) => {
setDraft({ key: draftKey, rataan, sb, mode, ...changes })
}
const saveMutation = useMutation({
mutationFn: async () => {
return await api.put(`/tryout/${id}/normalization`, {
normalization_mode: mode,
static_rataan: rataan,
static_sb: sb,
})
},
onSuccess: () => {
setStatusMessage('Normalization settings saved.')
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'tryout-config', id) })
},
})
const resetMutation = useMutation({
mutationFn: async () => {
return await api.post(`/admin/${id}/reset-normalization`)
},
onSuccess: () => {
setStatusMessage('Normalization stats reset to static values.')
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'tryout-config', id) })
},
})
if (!hasWebsiteScope(websiteId)) {
return <div className="text-muted-foreground">Select a website to configure normalization.</div>
}
if (isLoading) return <Skeleton className="h-[400px] w-full" />
return (
<div className="max-w-2xl space-y-6">
{isError && (
<Alert variant="destructive">
<AlertTitle>Failed to load normalization settings</AlertTitle>
<AlertDescription>
{error instanceof Error ? error.message : 'Please check your connection and selected website.'}
</AlertDescription>
</Alert>
)}
{statusMessage && (
<Alert>
<AlertTitle>Saved</AlertTitle>
<AlertDescription>{statusMessage}</AlertDescription>
</Alert>
)}
{(saveMutation.isError || resetMutation.isError) && (
<Alert variant="destructive">
<AlertTitle>Update failed</AlertTitle>
<AlertDescription>
{(saveMutation.error instanceof Error && saveMutation.error.message) ||
(resetMutation.error instanceof Error && resetMutation.error.message) ||
'The backend rejected the request.'}
</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle>Normalization Settings</CardTitle>
<CardDescription>
Configure the normalization parameters for Tryout {id}.
The formula used is: NN = Rataan + SB × ((NM - Mean) / StdDev)
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>Mode</Label>
<Select value={mode} onValueChange={(value) => updateDraft({ mode: value as NormalizationMode })}>
<SelectTrigger>
<SelectValue placeholder="Select mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="static">Static (Fixed values)</SelectItem>
<SelectItem value="dynamic">Dynamic (Calculate from Data)</SelectItem>
<SelectItem value="hybrid">Hybrid</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Target Mean (Rataan)</Label>
<Input
type="number"
value={rataan}
onChange={e => updateDraft({ rataan: parseFloat(e.target.value) })}
/>
</div>
<div className="space-y-2">
<Label>Target Standard Deviation (SB)</Label>
<Input
type="number"
value={sb}
onChange={e => updateDraft({ sb: parseFloat(e.target.value) })}
/>
</div>
<div className="flex gap-4 pt-4 border-t">
<Button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
<Save className="w-4 h-4 mr-2" />
{saveMutation.isPending ? 'Saving...' : 'Save Settings'}
</Button>
<Button
variant="outline"
onClick={() => {
if (window.confirm('Reset normalization stats for this tryout?')) {
resetMutation.mutate()
}
}}
disabled={resetMutation.isPending}
>
<RefreshCw className="w-4 h-4 mr-2" />
{resetMutation.isPending ? 'Resetting...' : 'Reset Stats'}
</Button>
</div>
{config?.current_stats && (
<div className="rounded-md border bg-muted/30 p-4 text-sm text-muted-foreground">
Current participants: {config.current_stats.participant_count}. Dynamic rataan:{' '}
{config.current_stats.rataan ?? '-'}; dynamic SB: {config.current_stats.sb ?? '-'}.
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,312 @@
import { useMemo, useState } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { SafeHtml } from '@/components/SafeHtml'
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
import { useAppStore } from '@/store/useAppStore'
import type { Question, SnapshotQuestion } from '@/types'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@/components/ui/button'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { CheckCircle2, Eye, Sparkles } from 'lucide-react'
export default function QuestionManagement() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { websiteId } = useAppStore()
const queryClient = useQueryClient()
const [selectedSnapshotQuestionIds, setSelectedSnapshotQuestionIds] = useState<number[]>([])
const [readQuestion, setReadQuestion] = useState<SnapshotQuestion | null>(null)
const queryKey = scopedQueryKey(websiteId, 'tryout-questions', id)
const { data, isLoading, isError } = useQuery({
queryKey,
queryFn: async () => {
const res = await api.get<{ tryout_id: string; items: Question[]; snapshot_questions: SnapshotQuestion[] }>(`/admin/tryouts/${id}/questions`)
return res.data
},
enabled: hasWebsiteScope(websiteId) && Boolean(id),
})
const snapshotQuestions = useMemo(() => data?.snapshot_questions || [], [data?.snapshot_questions])
const selectedSnapshotQuestions = useMemo(
() => snapshotQuestions.filter((question) => selectedSnapshotQuestionIds.includes(question.id)),
[selectedSnapshotQuestionIds, snapshotQuestions]
)
const promoteMutation = useMutation({
mutationFn: async () => {
const groups = selectedSnapshotQuestions.reduce<Record<number, number[]>>((acc, question) => {
if (!question.latest_snapshot_id || question.promoted_item) return acc
acc[question.latest_snapshot_id] = acc[question.latest_snapshot_id] || []
acc[question.latest_snapshot_id].push(question.id)
return acc
}, {})
const entries = Object.entries(groups)
if (entries.length === 0) {
throw new Error('Select at least one unpromoted snapshot question.')
}
await Promise.all(
entries.map(([snapshotId, ids]) =>
api.post(`/admin/snapshots/${snapshotId}/promote`, {
snapshot_question_ids: ids,
})
)
)
},
onSuccess: () => {
setSelectedSnapshotQuestionIds([])
queryClient.invalidateQueries({ queryKey })
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'admin-questions') })
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'data-overview') })
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'admin-templates') })
},
})
if (!hasWebsiteScope(websiteId)) {
return <div className="text-muted-foreground">Select a website to load questions.</div>
}
if (isLoading) {
return <div className="space-y-4">
{[1, 2, 3].map(i => <Skeleton key={i} className="h-12 w-full" />)}
</div>
}
if (isError) {
return <div className="text-destructive">Failed to load questions.</div>
}
const items = data?.items || []
const promotableSnapshotQuestions = snapshotQuestions.filter((question) => !question.promoted_item && question.latest_snapshot_id)
const allPromotableSelected =
promotableSnapshotQuestions.length > 0 &&
promotableSnapshotQuestions.every((question) => selectedSnapshotQuestionIds.includes(question.id))
const toggleSnapshotQuestion = (questionId: number, checked: boolean) => {
setSelectedSnapshotQuestionIds((current) =>
checked ? [...new Set([...current, questionId])] : current.filter((id) => id !== questionId)
)
}
const toggleAllPromotable = (checked: boolean) => {
setSelectedSnapshotQuestionIds(checked ? promotableSnapshotQuestions.map((question) => question.id) : [])
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold">Questions ({items.length || snapshotQuestions.length})</h2>
</div>
{items.length === 0 ? (
snapshotQuestions.length === 0 ? (
<div className="text-center p-12 border rounded-lg border-dashed text-muted-foreground">
No questions found for this tryout.
</div>
) : null
) : (
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item ID</TableHead>
<TableHead className="w-[40%]">Question Preview</TableHead>
<TableHead className="text-right">P-Value</TableHead>
<TableHead className="text-right">IRT b</TableHead>
<TableHead className="text-center">Status</TableHead>
<TableHead className="text-right">AI</TableHead>
<TableHead className="text-right">Detail</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.item_id}</TableCell>
<TableCell>
<SafeHtml html={item.stem_text} className="line-clamp-2 text-sm text-muted-foreground" />
</TableCell>
<TableCell className="text-right">{item.p_value !== null ? item.p_value.toFixed(3) : '-'}</TableCell>
<TableCell className="text-right">{item.irt_b !== null ? item.irt_b.toFixed(3) : '-'}</TableCell>
<TableCell className="text-center">
{item.calibrated ? (
<Badge variant="default" className="bg-emerald-500 hover:bg-emerald-600">Calibrated</Badge>
) : (
<Badge variant="secondary">Draft</Badge>
)}
</TableCell>
<TableCell className="text-right">
{item.level === 'sedang' ? (
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/admin/tryouts/${id}/questions/${item.id}/ai-workspace`)}
>
<Sparkles className="mr-2 h-4 w-4" />
Generate
</Button>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-right">
<Button variant="outline" size="sm" asChild>
<Link to={`/admin/questions/${item.id}`}>
<Eye className="mr-2 h-4 w-4" />
View
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{snapshotQuestions.length > 0 && (
<div className="space-y-4 pt-8 border-t">
<div className="flex flex-wrap justify-between items-center gap-3">
<div>
<h2 className="text-xl font-semibold text-muted-foreground">Imported Snapshot Questions ({snapshotQuestions.length})</h2>
<p className="text-sm text-muted-foreground">
{promotableSnapshotQuestions.length} unpromoted questions can become live medium-level basis items.
</p>
</div>
<Button
onClick={() => promoteMutation.mutate()}
disabled={selectedSnapshotQuestions.length === 0 || promoteMutation.isPending}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
{promoteMutation.isPending ? 'Promoting...' : `Promote Selected (${selectedSnapshotQuestions.length})`}
</Button>
</div>
{promoteMutation.isError && (
<Alert variant="destructive">
<AlertTitle>Promotion failed</AlertTitle>
<AlertDescription>
{promoteMutation.error instanceof Error ? promoteMutation.error.message : 'Could not promote selected questions.'}
</AlertDescription>
</Alert>
)}
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<input
type="checkbox"
aria-label="Select all promotable snapshot questions"
checked={allPromotableSelected}
onChange={(event) => toggleAllPromotable(event.target.checked)}
/>
</TableHead>
<TableHead>Source ID</TableHead>
<TableHead className="w-[56%]">Question</TableHead>
<TableHead className="text-center">Answer</TableHead>
<TableHead className="text-center">Status</TableHead>
<TableHead className="text-right">Read</TableHead>
<TableHead className="text-right">Live Item</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{snapshotQuestions.map((sq) => (
<TableRow key={sq.id}>
<TableCell>
<input
type="checkbox"
aria-label={`Select snapshot question ${sq.source_question_id}`}
checked={selectedSnapshotQuestionIds.includes(sq.id)}
disabled={Boolean(sq.promoted_item) || !sq.latest_snapshot_id || promoteMutation.isPending}
onChange={(event) => toggleSnapshotQuestion(sq.id, event.target.checked)}
/>
</TableCell>
<TableCell className="font-medium text-xs text-muted-foreground">{sq.source_question_id}</TableCell>
<TableCell>
<SafeHtml html={sq.question_html} className="max-w-none text-sm leading-relaxed" />
</TableCell>
<TableCell className="text-center font-mono">{sq.correct_answer}</TableCell>
<TableCell className="text-center">
{sq.promoted_item ? (
<Badge variant="default">Promoted</Badge>
) : sq.is_active ? (
<Badge variant="outline" className="text-emerald-600 border-emerald-600">Active</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">Inactive</Badge>
)}
</TableCell>
<TableCell className="text-right">
<Button variant="outline" size="sm" onClick={() => setReadQuestion(sq)}>
<Eye className="mr-2 h-4 w-4" />
Read
</Button>
</TableCell>
<TableCell className="text-right">
{sq.promoted_item ? (
<Button variant="outline" size="sm" asChild>
<Link to={`/admin/questions/${sq.promoted_item.id}`}>Open #{sq.promoted_item.id}</Link>
</Button>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Dialog open={Boolean(readQuestion)} onOpenChange={(open) => !open && setReadQuestion(null)}>
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>Question {readQuestion?.source_question_id}</DialogTitle>
<DialogDescription>Original imported question text.</DialogDescription>
</DialogHeader>
{readQuestion && (
<div className="space-y-4">
<div className="rounded-md border p-4">
<SafeHtml html={readQuestion.question_html} className="max-w-none leading-relaxed" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-md border p-4">
<div className="text-sm text-muted-foreground">Correct Answer</div>
<div className="mt-1 font-mono text-lg font-semibold">{readQuestion.correct_answer}</div>
</div>
<div className="rounded-md border p-4">
<div className="text-sm text-muted-foreground">Source ID</div>
<div className="mt-1 font-mono text-sm">{readQuestion.source_question_id}</div>
</div>
</div>
{readQuestion.explanation_html && (
<div className="rounded-md border p-4">
<div className="mb-2 font-medium">Explanation</div>
<SafeHtml html={readQuestion.explanation_html} className="max-w-none leading-relaxed" allowEmbeds />
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,49 @@
import { Link, Outlet, useLocation, useNavigate, useParams } from 'react-router-dom'
import { ArrowLeft } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
export default function TryoutLayout() {
const { id } = useParams<{ id: string }>()
const location = useLocation()
const navigate = useNavigate()
let currentTab = 'questions'
if (location.pathname.includes('/questions')) currentTab = 'questions'
if (location.pathname.includes('/attempts')) currentTab = 'attempts'
if (location.pathname.includes('/normalization')) currentTab = 'normalization'
if (location.pathname.includes('/settings')) currentTab = 'settings'
const handleTabChange = (value: string) => {
navigate(`/admin/tryouts/${id}/${value}`)
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="outline" size="icon" asChild>
<Link to="/admin/dashboard">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight">Tryout Workspace</h1>
<p className="text-muted-foreground mt-1">ID: {id}</p>
</div>
</div>
<Tabs value={currentTab} onValueChange={handleTabChange} className="w-full">
<TabsList className="grid w-full grid-cols-4 md:w-[560px]">
<TabsTrigger value="questions">Questions</TabsTrigger>
<TabsTrigger value="attempts">Attempts</TabsTrigger>
<TabsTrigger value="normalization">Normalization</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
</Tabs>
<div className="mt-6 bg-card border rounded-xl p-6 shadow-sm min-h-[500px]">
<Outlet />
</div>
</div>
)
}

View File

@@ -0,0 +1,291 @@
import { Link, useParams } from 'react-router-dom'
import { useState } from 'react'
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Percent, Save } from 'lucide-react'
type TryoutConfig = {
tryout_id: string
name: string
description: string | null
scoring_mode: string
selection_mode: string
normalization_mode: string
static_rataan: number
static_sb: number
min_sample_for_dynamic: number
ai_generation_enabled: boolean
min_calibration_sample: number
theta_estimation_method: string
fallback_to_ctt_on_error: boolean
}
type TryoutConfigForm = {
name: string
description: string
scoring_mode: string
selection_mode: string
normalization_mode: string
static_rataan: string
static_sb: string
min_sample_for_dynamic: string
ai_generation_enabled: boolean
min_calibration_sample: string
theta_estimation_method: string
fallback_to_ctt_on_error: boolean
}
function configToForm(config: TryoutConfig): TryoutConfigForm {
return {
name: config.name || '',
description: config.description || '',
scoring_mode: config.scoring_mode,
selection_mode: config.selection_mode,
normalization_mode: config.normalization_mode,
static_rataan: String(config.static_rataan),
static_sb: String(config.static_sb),
min_sample_for_dynamic: String(config.min_sample_for_dynamic),
ai_generation_enabled: config.ai_generation_enabled,
min_calibration_sample: String(config.min_calibration_sample),
theta_estimation_method: config.theta_estimation_method,
fallback_to_ctt_on_error: config.fallback_to_ctt_on_error,
}
}
export default function TryoutSettings() {
const { id } = useParams<{ id: string }>()
const { websiteId } = useAppStore()
const queryClient = useQueryClient()
const [draft, setDraft] = useState<TryoutConfigForm | null>(null)
const queryKey = scopedQueryKey(websiteId, 'tryout-config', id)
const { data: config, isLoading, isError } = useQuery({
queryKey,
queryFn: async () => {
const res = await api.get<TryoutConfig>(`/tryout/${id}/config`)
return res.data
},
enabled: hasWebsiteScope(websiteId) && Boolean(id),
})
const updateMutation = useMutation({
mutationFn: async () => {
const activeForm = draft || (config ? configToForm(config) : null)
if (!activeForm) throw new Error('Settings form is not ready.')
const payload = {
name: activeForm.name.trim(),
description: activeForm.description.trim() || null,
scoring_mode: activeForm.scoring_mode,
selection_mode: activeForm.selection_mode,
normalization_mode: activeForm.normalization_mode,
static_rataan: Number(activeForm.static_rataan),
static_sb: Number(activeForm.static_sb),
min_sample_for_dynamic: Number(activeForm.min_sample_for_dynamic),
ai_generation_enabled: activeForm.ai_generation_enabled,
min_calibration_sample: Number(activeForm.min_calibration_sample),
theta_estimation_method: activeForm.theta_estimation_method,
fallback_to_ctt_on_error: activeForm.fallback_to_ctt_on_error,
}
const res = await api.put<TryoutConfig>(`/tryout/${id}/config`, payload)
return res.data
},
onSuccess: (updatedConfig) => {
setDraft(configToForm(updatedConfig))
queryClient.invalidateQueries({ queryKey })
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'tryouts') })
},
})
if (!hasWebsiteScope(websiteId)) {
return <div className="text-muted-foreground">Select a website to load tryout settings.</div>
}
if (isLoading) return <Skeleton className="h-[360px] w-full" />
if (isError || !config) {
return (
<Alert variant="destructive">
<AlertTitle>Failed to load tryout settings</AlertTitle>
<AlertDescription>Check the selected website and tryout ID.</AlertDescription>
</Alert>
)
}
const form = draft || configToForm(config)
const updateField = <K extends keyof TryoutConfigForm>(field: K, value: TryoutConfigForm[K]) => {
setDraft((current) => ({ ...(current || configToForm(config)), [field]: value }))
}
const canSave =
form.name.trim().length > 0 &&
Number(form.static_rataan) >= 0 &&
Number(form.static_sb) > 0 &&
Number(form.min_sample_for_dynamic) > 0 &&
Number(form.min_calibration_sample) > 0 &&
!updateMutation.isPending
return (
<div className="space-y-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div>
<CardTitle>{config.name}</CardTitle>
<CardDescription>{config.description || `Configuration for tryout ${config.tryout_id}`}</CardDescription>
</div>
<Badge variant={config.ai_generation_enabled ? 'default' : 'secondary'}>
AI {config.ai_generation_enabled ? 'Enabled' : 'Disabled'}
</Badge>
</CardHeader>
<CardContent className="space-y-6">
{updateMutation.isError && (
<Alert variant="destructive">
<AlertTitle>Update failed</AlertTitle>
<AlertDescription>
{updateMutation.error instanceof Error ? updateMutation.error.message : 'Could not save settings.'}
</AlertDescription>
</Alert>
)}
{updateMutation.isSuccess && (
<Alert>
<AlertTitle>Settings saved</AlertTitle>
<AlertDescription>Tryout configuration has been updated.</AlertDescription>
</Alert>
)}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tryout-name">Name</Label>
<Input id="tryout-name" value={form.name} onChange={(event) => updateField('name', event.target.value)} />
</div>
<div className="space-y-2">
<Label>Scoring Mode</Label>
<Select value={form.scoring_mode} onValueChange={(value) => updateField('scoring_mode', value)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="ctt">CTT</SelectItem>
<SelectItem value="irt">IRT</SelectItem>
<SelectItem value="hybrid">Hybrid</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="tryout-description">Description</Label>
<Textarea
id="tryout-description"
value={form.description}
onChange={(event) => updateField('description', event.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Selection Mode</Label>
<Select value={form.selection_mode} onValueChange={(value) => updateField('selection_mode', value)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="fixed">Fixed</SelectItem>
<SelectItem value="adaptive">Adaptive</SelectItem>
<SelectItem value="hybrid">Hybrid</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Normalization Mode</Label>
<Select value={form.normalization_mode} onValueChange={(value) => updateField('normalization_mode', value)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="static">Static</SelectItem>
<SelectItem value="dynamic">Dynamic</SelectItem>
<SelectItem value="hybrid">Hybrid</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="static-rataan">Static Rataan</Label>
<Input
id="static-rataan"
type="number"
value={form.static_rataan}
onChange={(event) => updateField('static_rataan', event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="static-sb">Static SB</Label>
<Input
id="static-sb"
type="number"
value={form.static_sb}
onChange={(event) => updateField('static_sb', event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="dynamic-minimum">Dynamic Sample Minimum</Label>
<Input
id="dynamic-minimum"
type="number"
value={form.min_sample_for_dynamic}
onChange={(event) => updateField('min_sample_for_dynamic', event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="calibration-minimum">Calibration Sample Minimum</Label>
<Input
id="calibration-minimum"
type="number"
value={form.min_calibration_sample}
onChange={(event) => updateField('min_calibration_sample', event.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Theta Method</Label>
<Select value={form.theta_estimation_method} onValueChange={(value) => updateField('theta_estimation_method', value)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="mle">MLE</SelectItem>
<SelectItem value="map">MAP</SelectItem>
<SelectItem value="eap">EAP</SelectItem>
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-3 rounded-md border p-4">
<input
type="checkbox"
checked={form.ai_generation_enabled}
onChange={(event) => updateField('ai_generation_enabled', event.target.checked)}
/>
<span className="text-sm font-medium">AI generation enabled</span>
</label>
<label className="flex items-center gap-3 rounded-md border p-4">
<input
type="checkbox"
checked={form.fallback_to_ctt_on_error}
onChange={(event) => updateField('fallback_to_ctt_on_error', event.target.checked)}
/>
<span className="text-sm font-medium">Fallback to CTT on IRT errors</span>
</label>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={() => updateMutation.mutate()} disabled={!canSave}>
<Save className="mr-2 h-4 w-4" />
{updateMutation.isPending ? 'Saving...' : 'Save Settings'}
</Button>
<Button variant="outline" asChild>
<Link to={`/admin/tryouts/${id}/normalization`}>
<Percent className="mr-2 h-4 w-4" />
Edit Normalization
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,109 @@
import { useState } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import { LogIn, AlertCircle } from 'lucide-react'
import axios from 'axios'
import { useAppStore } from '@/store/useAppStore'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { api } from '@/lib/api'
export default function Login() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const { token, setToken } = useAppStore()
const navigate = useNavigate()
// If already logged in, redirect to dashboard
if (token) {
return <Navigate to="/admin/dashboard" replace />
}
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError('')
try {
const response = await api.post('/auth/admin-login', {
username,
password,
})
const { access_token } = response.data
setToken(access_token)
navigate('/admin/dashboard', { replace: true })
} catch (err: unknown) {
if (axios.isAxiosError(err) && err.response) {
setError(err.response.data?.detail || 'Invalid username or password')
} else {
setError('Network error. Please make sure the backend is running.')
}
} finally {
setIsLoading(false)
}
}
return (
<div className="flex min-h-screen w-full flex-col items-center justify-center bg-zinc-50 p-4">
<div className="w-full max-w-[400px] overflow-hidden rounded-2xl border bg-white shadow-xl">
<div className="flex flex-col items-center justify-center bg-zinc-900 p-8 text-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-yellow-500 text-zinc-900 shadow-md">
<LogIn className="h-8 w-8" />
</div>
<h1 className="text-2xl font-bold tracking-tight text-white">IRT System</h1>
<p className="text-sm text-zinc-400">Admin Control Center</p>
</div>
<div className="p-8">
<form onSubmit={handleLogin} className="space-y-6">
{error && (
<Alert variant="destructive" className="bg-red-50 text-red-600 border-red-200">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="username" className="text-sm font-medium text-zinc-700">Username</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="admin"
required
className="h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium text-zinc-700">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
className="h-11"
/>
</div>
<Button
type="submit"
className="h-11 w-full bg-zinc-900 hover:bg-zinc-800 text-white shadow-md transition-all"
disabled={isLoading || !username || !password}
>
{isLoading ? 'Authenticating...' : 'Sign In'}
</Button>
</form>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,27 @@
import { Link, Outlet } from 'react-router-dom'
import { WebsiteSelector } from '@/components/WebsiteSelector'
import { Button } from '@/components/ui/button'
export default function StudentLayout() {
return (
<div className="min-h-screen bg-muted/20">
<header className="border-b bg-background">
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-6">
<div>
<Link to="/student/tryouts" className="text-lg font-bold">Student Tryout</Link>
<p className="text-xs text-muted-foreground">Session practice portal</p>
</div>
<div className="flex items-center gap-3">
<WebsiteSelector />
<Button variant="outline" asChild>
<Link to="/admin/dashboard">Admin</Link>
</Button>
</div>
</div>
</header>
<main className="mx-auto max-w-6xl px-6 py-8">
<Outlet />
</main>
</div>
)
}

View File

@@ -0,0 +1,116 @@
import { Link, useParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
import { useAppStore } from '@/store/useAppStore'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
type SessionResponse = {
session_id: string
wp_user_id: string
tryout_id: string
start_time: string
end_time: string | null
is_completed: boolean
total_benar: number
total_bobot_earned: number
NM: number | null
NN: number | null
theta: number | null
theta_se: number | null
}
function ResultStat({ label, value }: { label: string; value: string | number }) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{value}</div>
</CardContent>
</Card>
)
}
export default function StudentResult() {
const { sessionId } = useParams<{ sessionId: string }>()
const { websiteId } = useAppStore()
const { data, isLoading, isError } = useQuery({
queryKey: scopedQueryKey(websiteId, 'student-result', sessionId),
queryFn: async () => {
const res = await api.get<SessionResponse>(`/session/${sessionId}`)
return res.data
},
enabled: hasWebsiteScope(websiteId) && Boolean(sessionId),
})
if (!hasWebsiteScope(websiteId)) {
return <div className="text-muted-foreground">Select a website to load the result.</div>
}
if (isLoading) return <Skeleton className="h-[360px] w-full" />
if (isError || !data) {
return (
<Alert variant="destructive">
<AlertTitle>Result failed to load</AlertTitle>
<AlertDescription>Check the active website and session ID.</AlertDescription>
</Alert>
)
}
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">Result Summary</h1>
<p className="text-muted-foreground mt-1">
{data.tryout_id} · Student {data.wp_user_id} · {data.is_completed ? 'Completed' : 'In progress'}
</p>
</div>
<div className="flex gap-2">
{!data.is_completed && (
<Button asChild>
<Link to={`/student/session/${data.session_id}`}>Resume Session</Link>
</Button>
)}
<Button variant="outline" asChild>
<Link to="/student/tryouts">Tryouts</Link>
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-4">
<ResultStat label="Nilai Nasional" value={data.NN ?? '-'} />
<ResultStat label="Nilai Mentah" value={data.NM ?? '-'} />
<ResultStat label="Correct Answers" value={data.total_benar} />
<ResultStat label="Theta" value={data.theta?.toFixed(3) ?? '-'} />
</div>
<Card>
<CardContent className="grid gap-4 pt-6 md:grid-cols-2">
<div>
<div className="text-sm text-muted-foreground">Started</div>
<div className="font-medium">{new Date(data.start_time).toLocaleString()}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Ended</div>
<div className="font-medium">{data.end_time ? new Date(data.end_time).toLocaleString() : '-'}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Total Bobot</div>
<div className="font-medium">{data.total_bobot_earned.toFixed(3)}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Theta SE</div>
<div className="font-medium">{data.theta_se?.toFixed(3) ?? '-'}</div>
</div>
</CardContent>
</Card>
</div>
)
}

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

View File

@@ -0,0 +1,147 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useMutation, useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
import { useAppStore } from '@/store/useAppStore'
import type { Tryout } from '@/types'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Play, RotateCcw } from 'lucide-react'
const STUDENT_USER_KEY = 'irt-student-wp-user-id'
const ACTIVE_SESSION_KEY = 'irt-student-active-session'
type SessionResponse = {
session_id: string
wp_user_id: string
website_id: number
tryout_id: string
expires_at: string | null
is_completed: boolean
}
function getTryoutLabel(tryout: Tryout) {
return tryout.name || tryout.title || `Tryout ${tryout.tryout_id}`
}
export default function StudentTryouts() {
const navigate = useNavigate()
const { websiteId } = useAppStore()
const [wpUserId, setWpUserId] = useState(() => localStorage.getItem(STUDENT_USER_KEY) || 'demo-student')
const activeSession = localStorage.getItem(ACTIVE_SESSION_KEY)
const { data: tryouts, isLoading, isError } = useQuery({
queryKey: scopedQueryKey(websiteId, 'student-tryouts'),
queryFn: async () => {
const res = await api.get<Tryout[]>('/tryout/')
return res.data
},
enabled: hasWebsiteScope(websiteId),
})
const startMutation = useMutation({
mutationFn: async (tryout: Tryout) => {
if (!websiteId) throw new Error('Select a website first.')
const sessionId = `student-${websiteId}-${tryout.tryout_id}-${Date.now()}`
const res = await api.post<SessionResponse>('/session/', {
session_id: sessionId,
wp_user_id: wpUserId.trim(),
website_id: websiteId,
tryout_id: tryout.tryout_id,
scoring_mode: tryout.scoring_mode || 'ctt',
})
return res.data
},
onSuccess: (session) => {
localStorage.setItem(STUDENT_USER_KEY, wpUserId.trim())
localStorage.setItem(ACTIVE_SESSION_KEY, session.session_id)
navigate(`/student/session/${session.session_id}`)
},
})
if (!hasWebsiteScope(websiteId)) {
return <div className="text-muted-foreground">Select a website to load student tryouts.</div>
}
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">Available Tryouts</h1>
<p className="text-muted-foreground mt-1">Start or resume a student session using the session APIs.</p>
</div>
{activeSession && (
<Button variant="outline" asChild>
<Link to={`/student/session/${activeSession}`}>
<RotateCcw className="mr-2 h-4 w-4" />
Resume
</Link>
</Button>
)}
</div>
<Card>
<CardHeader>
<CardTitle>Student Identity</CardTitle>
<CardDescription>Stored locally for session recovery.</CardDescription>
</CardHeader>
<CardContent className="max-w-sm space-y-2">
<Label htmlFor="wp-user-id">WordPress User ID</Label>
<Input id="wp-user-id" value={wpUserId} onChange={(event) => setWpUserId(event.target.value)} />
</CardContent>
</Card>
{startMutation.isError && (
<Alert variant="destructive">
<AlertTitle>Could not start session</AlertTitle>
<AlertDescription>
{startMutation.error instanceof Error ? startMutation.error.message : 'Session creation failed.'}
</AlertDescription>
</Alert>
)}
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2">
{[1, 2, 3, 4].map((index) => <Skeleton key={index} className="h-36 w-full" />)}
</div>
) : isError ? (
<Alert variant="destructive">
<AlertTitle>Tryouts failed to load</AlertTitle>
<AlertDescription>Check the selected website and backend API availability.</AlertDescription>
</Alert>
) : (
<div className="grid gap-4 md:grid-cols-2">
{(tryouts || []).map((tryout) => (
<Card key={tryout.tryout_id}>
<CardHeader>
<CardTitle>{getTryoutLabel(tryout)}</CardTitle>
<CardDescription>
{tryout.tryout_id} · {tryout.scoring_mode || 'ctt'} · {tryout.item_count ?? 0} questions
</CardDescription>
</CardHeader>
<CardContent>
<Button
onClick={() => startMutation.mutate(tryout)}
disabled={!wpUserId.trim() || startMutation.isPending}
>
<Play className="mr-2 h-4 w-4" />
Start Session
</Button>
</CardContent>
</Card>
))}
{tryouts?.length === 0 && (
<div className="rounded-md border border-dashed p-8 text-center text-muted-foreground md:col-span-2">
No tryouts available for this website.
</div>
)}
</div>
)}
</div>
)
}