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