Checkpoint React frontend migration

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

1
frontend/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:8000/api/v1

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

27
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
# Build Stage
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
# Pass build arg to env for vite
ARG VITE_API_URL
ENV VITE_API_URL=${VITE_API_URL}
RUN npm run build
# Production Stage
FROM nginx:alpine
# Copy built assets
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

20
frontend/components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

22
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

34
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,34 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 10240;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml;
gzip_disable "MSIE [1-6]\.";
# Try files for React Router
location / {
try_files $uri $uri/ /index.html;
# Add security headers
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "1; mode=block";
add_header X-Content-Type-Options "nosniff";
# Don't cache index.html
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2|woff|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, no-transform";
}
}

4638
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

56
frontend/package.json Normal file
View File

@@ -0,0 +1,56 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.14",
"@radix-ui/react-collapsible": "^1.1.14",
"@radix-ui/react-dialog": "^1.1.17",
"@radix-ui/react-label": "^2.1.10",
"@radix-ui/react-progress": "^1.1.10",
"@radix-ui/react-radio-group": "^1.4.1",
"@radix-ui/react-select": "^2.3.1",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-tabs": "^1.1.15",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.18.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.4.11",
"lucide-react": "^1.20.0",
"next-themes": "^0.4.6",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.18.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7",
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/postcss": "^4.3.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"postcss": "^8.5.15",
"tailwindcss": "^4.3.1",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
autoprefixer: {},
},
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

184
frontend/src/App.css Normal file
View File

@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

98
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,98 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useAppStore } from '@/store/useAppStore'
import { AppErrorBoundary } from '@/components/AppErrorBoundary'
import { AdminLayout } from '@/layouts/AdminLayout'
import Dashboard from '@/pages/admin/Dashboard'
import TryoutLayout from '@/pages/admin/tryouts/TryoutLayout'
import TryoutSettings from '@/pages/admin/tryouts/TryoutSettings'
import QuestionManagement from '@/pages/admin/tryouts/QuestionManagement'
import AttemptList from '@/pages/admin/tryouts/AttemptList'
import AIWorkspace from '@/pages/admin/tryouts/AIWorkspace'
import Normalization from '@/pages/admin/tryouts/Normalization'
import Questions from '@/pages/admin/questions'
import QuestionDetail from '@/pages/admin/questions/QuestionDetail'
import AIGenerator from '@/pages/admin/ai'
import Exams from '@/pages/admin/exams'
import Reports from '@/pages/admin/reports'
import Settings from '@/pages/admin/settings'
import ImportQuestions from '@/pages/admin/import'
import DataOverview from '@/pages/admin/overview/DataOverview'
import StudentLayout from '@/pages/student/StudentLayout'
import StudentTryouts from '@/pages/student/StudentTryouts'
import StudentSession from '@/pages/student/StudentSession'
import StudentResult from '@/pages/student/StudentResult'
const queryClient = new QueryClient()
import Login from '@/pages/auth/Login'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { token } = useAppStore()
if (!token) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<AppErrorBoundary>
<Router>
<div className="min-h-screen bg-background font-sans antialiased text-foreground">
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/admin/*"
element={
<ProtectedRoute>
<AdminLayout />
</ProtectedRoute>
}
>
<Route path="dashboard" element={<Dashboard />} />
<Route path="questions" element={<Questions />} />
<Route path="questions/:questionId" element={<QuestionDetail />} />
<Route path="ai-generation" element={<AIGenerator />} />
<Route path="tryouts" element={<Exams />} />
<Route path="overview" element={<DataOverview />} />
<Route path="reports" element={<Reports />} />
<Route path="settings" element={<Settings />} />
<Route path="import" element={<ImportQuestions />} />
<Route path="tryouts/:id" element={<TryoutLayout />}>
<Route path="settings" element={<TryoutSettings />} />
<Route path="questions" element={<QuestionManagement />} />
<Route path="questions/ai-workspace" element={<AIWorkspace />} />
<Route path="questions/:questionId/ai-workspace" element={<AIWorkspace />} />
<Route path="attempts" element={<AttemptList />} />
<Route path="normalization" element={<Normalization />} />
<Route path="*" element={<Navigate to="questions" replace />} />
</Route>
<Route path="*" element={<Navigate to="dashboard" replace />} />
</Route>
<Route
path="/student/*"
element={
<ProtectedRoute>
<StudentLayout />
</ProtectedRoute>
}
>
<Route path="tryouts" element={<StudentTryouts />} />
<Route path="session/:sessionId" element={<StudentSession />} />
<Route path="result/:sessionId" element={<StudentResult />} />
<Route path="*" element={<Navigate to="tryouts" replace />} />
</Route>
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
</Routes>
</div>
</Router>
</AppErrorBoundary>
</QueryClientProvider>
)
}
export default App

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,38 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
type AppErrorBoundaryProps = {
children: ReactNode
}
type AppErrorBoundaryState = {
error: Error | null
}
export class AppErrorBoundary extends Component<AppErrorBoundaryProps, AppErrorBoundaryState> {
state: AppErrorBoundaryState = { error: null }
static getDerivedStateFromError(error: Error) {
return { error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('React render error', error, errorInfo)
}
render() {
if (this.state.error) {
return (
<div className="min-h-screen bg-background p-6 text-foreground">
<div className="mx-auto max-w-3xl rounded-md border border-destructive/40 bg-destructive/10 p-4">
<h1 className="text-lg font-semibold text-destructive">Frontend render failed</h1>
<p className="mt-2 text-sm text-muted-foreground">
{this.state.error.message || 'Unknown render error'}
</p>
</div>
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,64 @@
import DOMPurify from 'dompurify'
import type { Config, UponSanitizeElementHook } from 'dompurify'
type SafeHtmlProps = {
html: string | null | undefined
className?: string
allowEmbeds?: boolean
}
const ALLOWED_EMBED_HOSTS = new Set(['videos.cdn.spotlightr.com'])
function isAllowedEmbedSrc(src: string | null) {
if (!src) return false
try {
const url = new URL(src, 'https://invalid.local')
return url.protocol === 'https:' && ALLOWED_EMBED_HOSTS.has(url.hostname)
} catch {
return false
}
}
function sanitizeHtml(html: string | null | undefined, allowEmbeds: boolean) {
const config: Config = {
USE_PROFILES: { html: true },
ADD_ATTR: allowEmbeds
? ['style', 'allow', 'allowfullscreen', 'allowtransparency', 'frameborder', 'scrolling', 'name', 'src']
: ['style'],
}
if (!allowEmbeds) {
return DOMPurify.sanitize(html ?? '', config)
}
const removeUntrustedIframe: UponSanitizeElementHook = (currentNode) => {
if (
currentNode instanceof HTMLIFrameElement &&
!isAllowedEmbedSrc(currentNode.getAttribute('src'))
) {
currentNode.remove()
}
}
DOMPurify.addHook('uponSanitizeElement', removeUntrustedIframe)
try {
return DOMPurify.sanitize(html ?? '', {
...config,
ADD_TAGS: ['iframe'],
})
} finally {
DOMPurify.removeHook('uponSanitizeElement', removeUntrustedIframe)
}
}
export function SafeHtml({ html, className, allowEmbeds = false }: SafeHtmlProps) {
return (
<div
className={className}
dangerouslySetInnerHTML={{
__html: sanitizeHtml(html, allowEmbeds),
}}
/>
)
}

View File

@@ -0,0 +1,61 @@
import { useEffect } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { useAppStore } from '@/store/useAppStore'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
interface Website {
id: number
domain: string
name: string
}
export function WebsiteSelector() {
const { websiteId, setWebsiteId } = useAppStore()
const queryClient = useQueryClient()
const { data: websites, isLoading } = useQuery({
queryKey: ['websites'],
queryFn: async () => {
const res = await api.get<Website[]>('/websites')
return res.data
}
})
// Auto-select first website if none selected
useEffect(() => {
if (websites && websites.length > 0 && !websiteId) {
setWebsiteId(websites[0].id)
}
}, [websites, websiteId, setWebsiteId])
if (isLoading) {
return <Skeleton className="h-9 w-40" />
}
if (!websites || websites.length === 0) {
return <div className="text-sm text-muted-foreground">No websites found</div>
}
return (
<Select
value={websiteId?.toString()}
onValueChange={(val) => {
setWebsiteId(parseInt(val))
queryClient.invalidateQueries({ queryKey: ['website'] })
}}
>
<SelectTrigger className="w-full sm:w-[200px] h-9">
<SelectValue placeholder="Select a Website" />
</SelectTrigger>
<SelectContent>
{websites.map((ws) => (
<SelectItem key={ws.id} value={ws.id.toString()}>
{ws.name || ws.domain}
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge }

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button }

View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,42 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,158 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,45 @@
"use client"
import {
CircleCheck,
Info,
LoaderCircle,
OctagonX,
TriangleAlert,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheck className="h-4 w-4" />,
info: <Info className="h-4 w-4" />,
warning: <TriangleAlert className="h-4 w-4" />,
error: <OctagonX className="h-4 w-4" />,
loading: <LoaderCircle className="h-4 w-4 animate-spin" />,
}}
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

91
frontend/src/index.css Normal file
View File

@@ -0,0 +1,91 @@
@import "tailwindcss";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
@theme {
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,75 @@
import { Link, Outlet, useLocation } from 'react-router-dom'
import { LayoutDashboard, LogOut, BookOpen, GraduationCap, BarChart, Settings } from 'lucide-react'
import { useAppStore } from '@/store/useAppStore'
import { WebsiteSelector } from '@/components/WebsiteSelector'
export function AdminLayout() {
const { logout } = useAppStore()
const location = useLocation()
const navItems = [
{ name: 'Dashboard', path: '/admin/dashboard', icon: LayoutDashboard },
{ name: 'Questions', path: '/admin/questions', icon: BookOpen },
{ name: 'Tryouts', path: '/admin/tryouts', icon: GraduationCap },
{ name: 'Reports', path: '/admin/reports', icon: BarChart },
{ name: 'Settings', path: '/admin/settings', icon: Settings },
]
return (
<div className="flex h-screen overflow-hidden bg-muted/20">
{/* Sidebar */}
<aside className="w-64 flex-shrink-0 border-r bg-background">
<div className="flex h-16 items-center px-6 border-b">
<span className="text-lg font-bold">IRT Bank Soal</span>
</div>
<nav className="p-4 space-y-1">
{navItems.map((item) => {
const isActive = location.pathname.startsWith(item.path)
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
isActive
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted text-muted-foreground hover:text-foreground'
}`}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
</nav>
<div className="absolute bottom-4 left-4 right-4">
<button
onClick={() => {
logout()
window.location.href = '/login'
}}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-destructive hover:bg-destructive/10 transition-colors"
>
<LogOut className="h-4 w-4" />
Logout
</button>
</div>
</aside>
{/* Main Content Area */}
<main className="flex-1 overflow-y-auto">
<div className="h-16 flex items-center justify-between px-6 border-b bg-background">
<div className="flex items-center gap-4">
<WebsiteSelector />
</div>
<div className="text-sm text-muted-foreground flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500"></span>
System Online
</div>
</div>
<div className="p-8">
<Outlet />
</div>
</main>
</div>
)
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface AppState {
websiteId: number | null
token: string | null
setWebsiteId: (id: number | null) => void
setToken: (token: string | null) => void
logout: () => void
}
export const useAppStore = create<AppState>()(
persist(
(set) => ({
websiteId: null,
token: null,
setWebsiteId: (id) => set({ websiteId: id }),
setToken: (token) => set({ token }),
logout: () => set({ websiteId: null, token: null }),
}),
{
name: 'irt-admin-auth', // unique name for localStorage key
}
)
)

View File

@@ -0,0 +1,97 @@
export interface Tryout {
id: number
tryout_id: string
website_id: number
title?: string
name?: string
status?: string
scoring_mode?: string
selection_mode?: string
normalization_mode?: string
participant_count?: number
rataan?: number | null
sb?: number | null
item_count?: number
calibrated_item_count?: number
duration?: number
start_time?: string
end_time?: string | null
created_at?: string
}
export interface Question {
id: number
item_id: string
website_id?: number
tryout_id?: string
slot?: number
level?: string
stem?: string
stem_text: string
options?: Record<string, string>
correct_answer?: string
explanation?: string | null
p_value: number | null
ctt_bobot?: number | null
ctt_category?: string | null
irt_b: number | null
irt_se?: number | null
calibrated: boolean
calibration_sample_size: number
generated_by?: string
ai_model?: string | null
basis_item_id?: number | null
generation_run_id?: number | null
source_snapshot_question_id?: number | null
variant_status?: string
reviewed_by?: string | null
reviewed_at?: string | null
review_notes?: string | null
created_at: string
updated_at?: string
}
export interface Attempt {
id: number
session_id: string
wp_user_id: string
start_time: string
end_time: string | null
expires_at: string | null
is_completed: boolean
scoring_mode_used: string
NM: number | null
NN: number | null
total_benar: number
}
export interface SnapshotQuestion {
id: number
latest_snapshot_id: number | null
source_question_id: string
question_title: string
question_html: string
explanation_html?: string | null
correct_answer: string
option_count?: number
has_option_labels?: boolean
is_active: boolean
promoted_item?: Question | null
created_at?: string
}
export interface AIGenerationRun {
id: number
basis_item_id: number
target_level: string
requested_count: number
model: string
created_by: string
created_at: string
status: string
generated_count: number
pending_review_count: number
reviewed_count: number
basis_tryout_id?: string | null
basis_slot?: number | null
}

View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"paths": {
"@/*": [
"./src/*"
]
},
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client", "node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

13
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})