Checkpoint React frontend migration
This commit is contained in:
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL=http://localhost:8000/api/v1
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
27
frontend/Dockerfile
Normal 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
73
frontend/README.md
Normal 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
20
frontend/components.json
Normal 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
22
frontend/eslint.config.js
Normal 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
13
frontend/index.html
Normal 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
34
frontend/nginx.conf
Normal 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
4638
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
56
frontend/package.json
Normal file
56
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
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
24
frontend/public/icons.svg
Normal 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
184
frontend/src/App.css
Normal 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
98
frontend/src/App.tsx
Normal 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
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal 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 |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
38
frontend/src/components/AppErrorBoundary.tsx
Normal file
38
frontend/src/components/AppErrorBoundary.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
64
frontend/src/components/SafeHtml.tsx
Normal file
64
frontend/src/components/SafeHtml.tsx
Normal 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),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
61
frontend/src/components/WebsiteSelector.tsx
Normal file
61
frontend/src/components/WebsiteSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
frontend/src/components/ui/accordion.tsx
Normal file
58
frontend/src/components/ui/accordion.tsx
Normal 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 }
|
||||
59
frontend/src/components/ui/alert.tsx
Normal file
59
frontend/src/components/ui/alert.tsx
Normal 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 }
|
||||
36
frontend/src/components/ui/badge.tsx
Normal file
36
frontend/src/components/ui/badge.tsx
Normal 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 }
|
||||
56
frontend/src/components/ui/button.tsx
Normal file
56
frontend/src/components/ui/button.tsx
Normal 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 }
|
||||
79
frontend/src/components/ui/card.tsx
Normal file
79
frontend/src/components/ui/card.tsx
Normal 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 }
|
||||
9
frontend/src/components/ui/collapsible.tsx
Normal file
9
frontend/src/components/ui/collapsible.tsx
Normal 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 }
|
||||
122
frontend/src/components/ui/dialog.tsx
Normal file
122
frontend/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
22
frontend/src/components/ui/input.tsx
Normal file
22
frontend/src/components/ui/input.tsx
Normal 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 }
|
||||
26
frontend/src/components/ui/label.tsx
Normal file
26
frontend/src/components/ui/label.tsx
Normal 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 }
|
||||
28
frontend/src/components/ui/progress.tsx
Normal file
28
frontend/src/components/ui/progress.tsx
Normal 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 }
|
||||
42
frontend/src/components/ui/radio-group.tsx
Normal file
42
frontend/src/components/ui/radio-group.tsx
Normal 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 }
|
||||
158
frontend/src/components/ui/select.tsx
Normal file
158
frontend/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
15
frontend/src/components/ui/skeleton.tsx
Normal file
15
frontend/src/components/ui/skeleton.tsx
Normal 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 }
|
||||
45
frontend/src/components/ui/sonner.tsx
Normal file
45
frontend/src/components/ui/sonner.tsx
Normal 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 }
|
||||
117
frontend/src/components/ui/table.tsx
Normal file
117
frontend/src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
53
frontend/src/components/ui/tabs.tsx
Normal file
53
frontend/src/components/ui/tabs.tsx
Normal 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 }
|
||||
22
frontend/src/components/ui/textarea.tsx
Normal file
22
frontend/src/components/ui/textarea.tsx
Normal 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
91
frontend/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
75
frontend/src/layouts/AdminLayout.tsx
Normal file
75
frontend/src/layouts/AdminLayout.tsx
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
274
frontend/src/pages/admin/Dashboard.tsx
Normal file
274
frontend/src/pages/admin/Dashboard.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '@/lib/api'
|
||||
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
|
||||
import { useAppStore } from '@/store/useAppStore'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
FileText,
|
||||
Users,
|
||||
CheckCircle2,
|
||||
Target,
|
||||
AlertCircle,
|
||||
Activity,
|
||||
Bot
|
||||
} from 'lucide-react'
|
||||
|
||||
// Adjust type for dashboard stats
|
||||
interface DashboardStats {
|
||||
metrics: {
|
||||
tryouts: number
|
||||
items: number
|
||||
sessions: number
|
||||
completed_sessions: number
|
||||
completion_rate: number
|
||||
calibration_percentage: number
|
||||
}
|
||||
recent_sessions: Array<{
|
||||
id: number
|
||||
wp_user_id: string
|
||||
tryout_id: string
|
||||
end_time: string
|
||||
NM: number | null
|
||||
NN: number | null
|
||||
}>
|
||||
recent_ai_runs: Array<{
|
||||
id: number
|
||||
requested_count: number
|
||||
basis_item_id: number
|
||||
created_at: string
|
||||
status: string
|
||||
pending_review_count?: number
|
||||
}>
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { websiteId } = useAppStore()
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: scopedQueryKey(websiteId, 'dashboard-stats'),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<DashboardStats>('/admin/dashboard/stats')
|
||||
return res.data
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId),
|
||||
})
|
||||
|
||||
if (!hasWebsiteScope(websiteId)) {
|
||||
return (
|
||||
<div className="p-4 border rounded-md bg-muted/30 text-muted-foreground">
|
||||
Select a website to load dashboard statistics.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasPendingAIReview =
|
||||
data?.recent_ai_runs.some((run) => run.status === 'pending_review' || (run.pending_review_count || 0) > 0) ?? false
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Good Morning, Admin</h1>
|
||||
<p className="text-muted-foreground">Here is your system overview for today.</p>
|
||||
</div>
|
||||
|
||||
<Card className="bg-primary/5 border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle>Getting Started & Workflow</CardTitle>
|
||||
<CardDescription>Follow these steps to generate and calibrate IRT questions.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-4">
|
||||
<div className="flex flex-col gap-2 p-4 bg-background rounded-lg border">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-sm">1</div>
|
||||
Create Tryout
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Import Tryout snapshots from Sejoli to initialize the question tree.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 p-4 bg-background rounded-lg border">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-sm">2</div>
|
||||
Generate Variants
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Use AI to generate parallel question variants (Mudah/Sedang/Sulit).</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 p-4 bg-background rounded-lg border">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-sm">3</div>
|
||||
Gather Data
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Students complete tryouts to gather participant answer data.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 p-4 bg-background rounded-lg border">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-sm">4</div>
|
||||
Calibrate & Normalize
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">System calibrates IRT parameters (p-value, IRT b) and calculates NN score.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-1/2 mb-2" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-1/3" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : isError || !data ? (
|
||||
<div className="p-4 border border-destructive/50 bg-destructive/10 text-destructive rounded-md">
|
||||
Failed to load dashboard statistics.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* System Overview KPIs */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Active Exams</CardTitle>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{data.metrics.tryouts}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Questions</CardTitle>
|
||||
<Target className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{data.metrics.items}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{data.metrics.calibration_percentage}% Calibrated
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Student Attempts</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{data.metrics.sessions}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{data.metrics.completed_sessions} completed
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Completion Rate</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{data.metrics.completion_rate}%</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Attention Needed */}
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
Attention Needed
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{data.metrics.calibration_percentage < 100 && (
|
||||
<div className="flex items-start gap-3 p-3 bg-muted/50 rounded-lg">
|
||||
<Target className="h-5 w-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">Questions need calibration</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Some questions have enough data but haven't been calibrated yet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasPendingAIReview && (
|
||||
<div className="flex items-start gap-3 p-3 bg-muted/50 rounded-lg">
|
||||
<Bot className="h-5 w-5 text-blue-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">AI generated questions pending review</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You have new AI-generated questions waiting for your approval.
|
||||
</p>
|
||||
<Link to="/admin/ai-generation" className="text-sm text-primary hover:underline mt-1 block">
|
||||
Review now
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.metrics.calibration_percentage === 100 && !hasPendingAIReview && (
|
||||
<p className="text-muted-foreground text-sm">You're all caught up! No urgent tasks.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Recent Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{data.recent_sessions.length === 0 && data.recent_ai_runs.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">No recent activity.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{data.recent_sessions.map((session) => (
|
||||
<div key={`session-${session.id}`} className="flex items-start justify-between border-b pb-2 last:border-0 last:pb-0">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Student {session.wp_user_id}</p>
|
||||
<p className="text-xs text-muted-foreground">Completed Tryout: {session.tryout_id}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Badge variant="secondary">Score: {session.NN ?? session.NM ?? 0}</Badge>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
{new Date(session.end_time).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data.recent_ai_runs.map((run) => (
|
||||
<div key={`run-${run.id}`} className="flex items-start justify-between border-b pb-2 last:border-0 last:pb-0">
|
||||
<div>
|
||||
<p className="text-sm font-medium">AI Generation Run #{run.id}</p>
|
||||
<p className="text-xs text-muted-foreground">Target: {run.requested_count} questions</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Badge variant={run.status === 'completed' ? 'default' : 'outline'}>{run.status}</Badge>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
{new Date(run.created_at).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
398
frontend/src/pages/admin/ai/PendingReviews.tsx
Normal file
398
frontend/src/pages/admin/ai/PendingReviews.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
|
||||
import { useAppStore } from '@/store/useAppStore'
|
||||
import { SafeHtml } from '@/components/SafeHtml'
|
||||
import type { AIGenerationRun, Question } from '@/types'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Check, Eye, X } from 'lucide-react'
|
||||
|
||||
type ReviewStatus = 'active' | 'rejected'
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : 'Request failed.'
|
||||
}
|
||||
|
||||
function VariantStatusBadge({ status }: { status?: string }) {
|
||||
const normalized = status || 'unknown'
|
||||
return (
|
||||
<Badge variant={normalized === 'draft' ? 'secondary' : normalized === 'rejected' ? 'destructive' : 'default'} className="capitalize">
|
||||
{normalized.replace('_', ' ')}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PendingReviews() {
|
||||
const queryClient = useQueryClient()
|
||||
const { websiteId } = useAppStore()
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([])
|
||||
|
||||
const pendingKey = scopedQueryKey(websiteId, 'ai-pending-reviews')
|
||||
const variantsKey = scopedQueryKey(websiteId, 'ai-variants')
|
||||
const runsKey = scopedQueryKey(websiteId, 'ai-runs')
|
||||
|
||||
const pendingQuery = useQuery({
|
||||
queryKey: pendingKey,
|
||||
queryFn: async () => {
|
||||
const res = await api.get<{ items: Question[] }>('/admin/ai/pending-reviews')
|
||||
return res.data.items
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId),
|
||||
})
|
||||
|
||||
const variantsQuery = useQuery({
|
||||
queryKey: variantsKey,
|
||||
queryFn: async () => {
|
||||
const res = await api.get<{ items: Question[] }>('/admin/ai/variants')
|
||||
return res.data.items
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId),
|
||||
})
|
||||
|
||||
const runsQuery = useQuery({
|
||||
queryKey: runsKey,
|
||||
queryFn: async () => {
|
||||
const res = await api.get<{ runs: AIGenerationRun[] }>('/admin/ai/runs')
|
||||
return res.data.runs
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId),
|
||||
})
|
||||
|
||||
const invalidateAIQueries = () => {
|
||||
queryClient.invalidateQueries({ queryKey: pendingKey })
|
||||
queryClient.invalidateQueries({ queryKey: variantsKey })
|
||||
queryClient.invalidateQueries({ queryKey: runsKey })
|
||||
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'admin-questions') })
|
||||
}
|
||||
|
||||
const reviewMutation = useMutation({
|
||||
mutationFn: async ({ id, status }: { id: number; status: ReviewStatus }) => {
|
||||
await api.post(`/admin/ai/review/${id}?status=${status}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSelectedIds([])
|
||||
invalidateAIQueries()
|
||||
},
|
||||
})
|
||||
|
||||
const bulkReviewMutation = useMutation({
|
||||
mutationFn: async (status: ReviewStatus) => {
|
||||
await api.post('/admin/ai/review-bulk', {
|
||||
item_ids: selectedIds,
|
||||
status,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSelectedIds([])
|
||||
invalidateAIQueries()
|
||||
},
|
||||
})
|
||||
|
||||
const pendingItems = useMemo(() => pendingQuery.data ?? [], [pendingQuery.data])
|
||||
const variants = useMemo(() => variantsQuery.data ?? [], [variantsQuery.data])
|
||||
const runs = useMemo(() => runsQuery.data ?? [], [runsQuery.data])
|
||||
const allPendingSelected = pendingItems.length > 0 && pendingItems.every((item) => selectedIds.includes(item.id))
|
||||
|
||||
const variantCounts = useMemo(() => {
|
||||
return variants.reduce<Record<string, number>>((acc, item) => {
|
||||
const key = item.variant_status || 'unknown'
|
||||
acc[key] = (acc[key] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
}, [variants])
|
||||
|
||||
const toggleItem = (id: number, checked: boolean) => {
|
||||
setSelectedIds((current) => (checked ? [...new Set([...current, id])] : current.filter((itemId) => itemId !== id)))
|
||||
}
|
||||
|
||||
if (!hasWebsiteScope(websiteId)) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-muted-foreground">Select a website to load AI review data.</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const anyError = pendingQuery.error || variantsQuery.error || runsQuery.error || reviewMutation.error || bulkReviewMutation.error
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{anyError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>AI review request failed</AlertTitle>
|
||||
<AlertDescription>{getErrorMessage(anyError)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground">Pending</div>
|
||||
<div className="text-2xl font-bold">{pendingItems.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground">Active</div>
|
||||
<div className="text-2xl font-bold">{variantCounts.active || 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground">Rejected</div>
|
||||
<div className="text-2xl font-bold">{variantCounts.rejected || 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground">Runs</div>
|
||||
<div className="text-2xl font-bold">{runs.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="pending">
|
||||
<TabsList>
|
||||
<TabsTrigger value="pending">Pending Review</TabsTrigger>
|
||||
<TabsTrigger value="variants">Variants</TabsTrigger>
|
||||
<TabsTrigger value="runs">Run History</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pending">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{pendingQuery.isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map(i => <Skeleton key={i} className="h-16 w-full" />)}
|
||||
</div>
|
||||
) : pendingQuery.isError ? (
|
||||
<div className="text-destructive">Failed to load pending reviews.</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-sm text-muted-foreground">{selectedIds.length} selected</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={selectedIds.length === 0 || bulkReviewMutation.isPending}
|
||||
onClick={() => bulkReviewMutation.mutate('active')}
|
||||
className="text-green-700"
|
||||
>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Approve Selected
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={selectedIds.length === 0 || bulkReviewMutation.isPending}
|
||||
onClick={() => bulkReviewMutation.mutate('rejected')}
|
||||
className="text-red-700"
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Reject Selected
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-muted text-muted-foreground">
|
||||
<tr>
|
||||
<th className="w-12 p-3 font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Select all pending AI variants"
|
||||
checked={allPendingSelected}
|
||||
onChange={(event) => setSelectedIds(event.target.checked ? pendingItems.map((item) => item.id) : [])}
|
||||
/>
|
||||
</th>
|
||||
<th className="p-3 font-medium">Question Preview</th>
|
||||
<th className="p-3 font-medium">Target Level</th>
|
||||
<th className="p-3 font-medium">AI Model</th>
|
||||
<th className="p-3 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{pendingItems.map(item => (
|
||||
<tr key={item.id} className="hover:bg-muted/50">
|
||||
<td className="p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Select AI variant ${item.id}`}
|
||||
checked={selectedIds.includes(item.id)}
|
||||
onChange={(event) => toggleItem(item.id, event.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<SafeHtml html={item.stem_text} className="max-w-md truncate" />
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Basis ID: {item.basis_item_id} | Tryout: {item.tryout_id}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={item.level === 'mudah' ? 'secondary' : item.level === 'sedang' ? 'default' : 'destructive'}>
|
||||
{item.level}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-xs">{item.ai_model}</td>
|
||||
<td className="p-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to={`/admin/questions/${item.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-green-700"
|
||||
onClick={() => reviewMutation.mutate({ id: item.id, status: 'active' })}
|
||||
disabled={reviewMutation.isPending}
|
||||
>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-red-700"
|
||||
onClick={() => reviewMutation.mutate({ id: item.id, status: 'rejected' })}
|
||||
disabled={reviewMutation.isPending}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{pendingItems.length === 0 && (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
No pending AI generated questions to review.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="variants">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{variantsQuery.isLoading ? (
|
||||
<Skeleton className="h-56 w-full" />
|
||||
) : variantsQuery.isError ? (
|
||||
<div className="text-destructive">Failed to load variants.</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-muted text-muted-foreground">
|
||||
<tr>
|
||||
<th className="p-3 font-medium">Variant</th>
|
||||
<th className="p-3 font-medium">Tryout</th>
|
||||
<th className="p-3 font-medium">Basis</th>
|
||||
<th className="p-3 font-medium">Status</th>
|
||||
<th className="p-3 font-medium text-right">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{variants.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="p-3">
|
||||
<SafeHtml html={item.stem_text} className="max-w-lg truncate" />
|
||||
<div className="text-xs text-muted-foreground mt-1">Model: {item.ai_model || '-'}</div>
|
||||
</td>
|
||||
<td className="p-3">{item.tryout_id}</td>
|
||||
<td className="p-3">{item.basis_item_id || '-'}</td>
|
||||
<td className="p-3">
|
||||
<VariantStatusBadge status={item.variant_status} />
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={`/admin/questions/${item.id}`}>Open</Link>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{variants.length === 0 && (
|
||||
<div className="p-8 text-center text-muted-foreground">No AI variants found.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="runs">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{runsQuery.isLoading ? (
|
||||
<Skeleton className="h-56 w-full" />
|
||||
) : runsQuery.isError ? (
|
||||
<div className="text-destructive">Failed to load run history.</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-muted text-muted-foreground">
|
||||
<tr>
|
||||
<th className="p-3 font-medium">Run</th>
|
||||
<th className="p-3 font-medium">Basis</th>
|
||||
<th className="p-3 font-medium">Target</th>
|
||||
<th className="p-3 font-medium">Generated</th>
|
||||
<th className="p-3 font-medium">Status</th>
|
||||
<th className="p-3 font-medium">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{runs.map((run) => (
|
||||
<tr key={run.id}>
|
||||
<td className="p-3 font-medium">#{run.id}</td>
|
||||
<td className="p-3">
|
||||
{run.basis_item_id ? (
|
||||
<Link to={`/admin/questions/${run.basis_item_id}`} className="text-primary hover:underline">
|
||||
Basis #{run.basis_item_id}
|
||||
</Link>
|
||||
) : '-'}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Tryout {run.basis_tryout_id || '-'} · Slot {run.basis_slot ?? '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 capitalize">{run.target_level}</td>
|
||||
<td className="p-3">{run.generated_count} / {run.requested_count}</td>
|
||||
<td className="p-3">
|
||||
<VariantStatusBadge status={run.status} />
|
||||
{run.pending_review_count > 0 && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{run.pending_review_count} pending review
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 text-xs">{new Date(run.created_at).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{runs.length === 0 && (
|
||||
<div className="p-8 text-center text-muted-foreground">No AI runs found.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
frontend/src/pages/admin/ai/index.tsx
Normal file
16
frontend/src/pages/admin/ai/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import PendingReviews from './PendingReviews'
|
||||
|
||||
export default function AIGeneration() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">AI Generation</h1>
|
||||
<p className="text-muted-foreground mt-1">Review AI-generated questions awaiting approval.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PendingReviews />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
241
frontend/src/pages/admin/exams/ImportTryoutModal.tsx
Normal file
241
frontend/src/pages/admin/exams/ImportTryoutModal.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
|
||||
import { useAppStore } from '@/store/useAppStore'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus, Upload, Loader2, FileJson, AlertCircle } from 'lucide-react'
|
||||
|
||||
type TryoutJsonPreview = {
|
||||
tryout_count?: number
|
||||
tryouts?: Array<{
|
||||
source_key: string
|
||||
title: string
|
||||
source_tryout_id: string
|
||||
}>
|
||||
totals?: {
|
||||
new_questions: number
|
||||
updated_questions: number
|
||||
unchanged_questions: number
|
||||
removed_questions: number
|
||||
missing_option_labels: number
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { detail?: unknown } } }).response
|
||||
if (typeof response?.data?.detail === 'string') {
|
||||
return response.data.detail
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function ImportTryoutModal() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [preview, setPreview] = useState<TryoutJsonPreview | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { websiteId } = useAppStore()
|
||||
|
||||
// Preview Mutation
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: async (fileData: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', fileData)
|
||||
const res = await api.post('/import-export/tryout-json/preview', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
return res.data as TryoutJsonPreview
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setPreview(data)
|
||||
setError(null)
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
setPreview(null)
|
||||
setError(getErrorMessage(err, 'Failed to parse JSON file.'))
|
||||
}
|
||||
})
|
||||
|
||||
// Import Mutation
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (fileData: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', fileData)
|
||||
const res = await api.post('/import-export/tryout-json', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'tryouts') })
|
||||
setOpen(false)
|
||||
resetState()
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
setError(getErrorMessage(err, 'Failed to import JSON file.'))
|
||||
}
|
||||
})
|
||||
|
||||
const resetState = () => {
|
||||
setFile(null)
|
||||
setPreview(null)
|
||||
setError(null)
|
||||
previewMutation.reset()
|
||||
importMutation.reset()
|
||||
}
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setOpen(newOpen)
|
||||
if (!newOpen) {
|
||||
resetState()
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const selectedFile = e.target.files[0]
|
||||
setFile(selectedFile)
|
||||
previewMutation.mutate(selectedFile)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = () => {
|
||||
if (file) {
|
||||
importMutation.mutate(file)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button disabled={!hasWebsiteScope(websiteId)}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Import Tryout JSON
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Tryout</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload a Sejoli tryout export JSON file to import it as a read-only snapshot.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex flex-col items-center justify-center p-6 border-2 border-dashed rounded-lg bg-muted/50 transition-colors hover:bg-muted/80">
|
||||
<input
|
||||
type="file"
|
||||
id="json-upload"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
disabled={previewMutation.isPending || importMutation.isPending}
|
||||
/>
|
||||
<label
|
||||
htmlFor="json-upload"
|
||||
className="flex flex-col items-center cursor-pointer w-full text-center space-y-2"
|
||||
>
|
||||
{file ? (
|
||||
<FileJson className="h-10 w-10 text-primary" />
|
||||
) : (
|
||||
<Upload className="h-10 w-10 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{file ? file.name : "Click to select a JSON file"}
|
||||
</span>
|
||||
{!file && <span className="text-xs text-muted-foreground">Only .json files are supported</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{previewMutation.isPending && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground p-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Analyzing file...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<div className="break-words max-w-full overflow-hidden">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && !error && (
|
||||
<div className="p-4 bg-primary/5 border border-primary/20 rounded-md space-y-3 max-h-[250px] overflow-y-auto">
|
||||
<h4 className="font-medium text-primary sticky top-0 bg-background/95 backdrop-blur py-1 -mt-1 -mx-1 px-1">Preview Result</h4>
|
||||
{preview.tryout_count !== undefined ? (
|
||||
<>
|
||||
<div className="text-sm space-y-1">
|
||||
<div><span className="text-muted-foreground">Tryouts found:</span> <span className="font-medium">{preview.tryout_count}</span></div>
|
||||
{preview.tryouts?.map((t) => (
|
||||
<div key={t.source_key} className="ml-2 mt-2 py-1 border-l-2 border-primary/20 pl-3">
|
||||
<div className="font-medium text-sm leading-tight">{t.title}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">ID: {t.source_tryout_id}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{preview.totals && (
|
||||
<div className="text-sm pt-2 border-t border-primary/10">
|
||||
<div className="font-medium mb-1">Questions Summary:</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-muted-foreground">
|
||||
<div>New: <span className="text-foreground">{preview.totals.new_questions}</span></div>
|
||||
<div>Updated: <span className="text-foreground">{preview.totals.updated_questions}</span></div>
|
||||
<div>Unchanged: <span className="text-foreground">{preview.totals.unchanged_questions}</span></div>
|
||||
<div>Removed: <span className="text-foreground">{preview.totals.removed_questions}</span></div>
|
||||
</div>
|
||||
{preview.totals.missing_option_labels > 0 && (
|
||||
<div className="text-amber-600 text-xs mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{preview.totals.missing_option_labels} questions have missing option labels
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm">Ready to import tryout payload.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={importMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!preview || !!error || importMutation.isPending}
|
||||
>
|
||||
{importMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Importing...
|
||||
</>
|
||||
) : (
|
||||
'Confirm Import'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
161
frontend/src/pages/admin/exams/index.tsx
Normal file
161
frontend/src/pages/admin/exams/index.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Users, LayoutList, Settings, Globe, ClipboardList, CheckCircle2, Circle, AlertCircle, Percent } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
|
||||
import { useAppStore } from '@/store/useAppStore'
|
||||
import type { Tryout } from '@/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { ImportTryoutModal } from './ImportTryoutModal'
|
||||
|
||||
export default function Exams() {
|
||||
const { websiteId } = useAppStore()
|
||||
const { data: tryouts, isLoading, isError } = useQuery({
|
||||
queryKey: scopedQueryKey(websiteId, 'tryouts'),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<Tryout[]>('/tryout/')
|
||||
return res.data
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId),
|
||||
})
|
||||
|
||||
// Group tryouts by website
|
||||
const groupedTryouts = tryouts?.reduce((acc, t) => {
|
||||
const wId = t.website_id
|
||||
if (!acc[wId]) acc[wId] = []
|
||||
acc[wId].push(t)
|
||||
return acc
|
||||
}, {} as Record<number, Tryout[]>)
|
||||
|
||||
const renderCalibrationLegend = (itemCount: number = 0, calibratedCount: number = 0) => {
|
||||
if (itemCount === 0) return <span title="No Questions"><Circle className="h-5 w-5 text-muted-foreground" /></span>
|
||||
const percentage = Math.round((calibratedCount / itemCount) * 100)
|
||||
if (percentage >= 90) return <span title="Ready (≥90% calibrated)"><CheckCircle2 className="h-5 w-5 text-green-500" /></span>
|
||||
if (percentage >= 50) return <span title="Partial (50-89% calibrated)"><AlertCircle className="h-5 w-5 text-amber-500" /></span>
|
||||
return <span title="Needs Data (<50% calibrated)"><Circle className="h-5 w-5 text-red-500" /></span>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Tryouts</h1>
|
||||
<p className="text-muted-foreground mt-1">Manage active tryouts and question banks.</p>
|
||||
</div>
|
||||
<ImportTryoutModal />
|
||||
</div>
|
||||
|
||||
{!hasWebsiteScope(websiteId) ? (
|
||||
<div className="p-4 border rounded-md bg-muted/30 text-muted-foreground">
|
||||
Select a website to load tryouts.
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="p-4 border border-destructive/50 bg-destructive/10 text-destructive rounded-md">
|
||||
Failed to load tryouts. Please check your connection or credentials.
|
||||
</div>
|
||||
) : tryouts?.length === 0 ? (
|
||||
<div className="text-center p-12 border rounded-lg bg-card text-muted-foreground">
|
||||
No tryouts found. Import one to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{Object.entries(groupedTryouts || {}).map(([groupWebsiteId, wTryouts]) => (
|
||||
<div key={groupWebsiteId} className="space-y-4">
|
||||
<h2 className="flex items-center gap-2 text-xl font-semibold border-b pb-2">
|
||||
<Globe className="h-5 w-5" />
|
||||
Website {groupWebsiteId}
|
||||
</h2>
|
||||
|
||||
<Accordion type="multiple" className="w-full space-y-2">
|
||||
{wTryouts.map((t) => {
|
||||
const itemCount = t.item_count || 0;
|
||||
const calibratedCount = t.calibrated_item_count || 0;
|
||||
const calPercentage = itemCount > 0 ? Math.round((calibratedCount / itemCount) * 100) : 0;
|
||||
|
||||
return (
|
||||
<AccordionItem key={t.tryout_id} value={t.tryout_id} className="border rounded-lg bg-card px-4">
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center justify-between w-full pr-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardList className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="font-medium text-lg">{t.tryout_id} - {t.name || t.title}</span>
|
||||
</div>
|
||||
<div>
|
||||
{renderCalibrationLegend(itemCount, calibratedCount)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pt-2 pb-4">
|
||||
<Card className="bg-muted/30 border-muted mb-4">
|
||||
<CardContent className="p-4 flex flex-col md:flex-row gap-6 md:gap-12">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Users className="h-4 w-4" /> Participants
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{t.participant_count || 0}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Percent className="h-4 w-4" /> Averages
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">NM:</span> {t.rataan ? t.rataan.toFixed(1) : 'N/A'} <span className="mx-2 text-muted-foreground">|</span>
|
||||
<span className="font-medium">SB:</span> {t.sb ? t.sb.toFixed(1) : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="text-sm text-muted-foreground flex justify-between items-center">
|
||||
<span>Calibration Status ({calPercentage}%)</span>
|
||||
<span>{calibratedCount} / {itemCount} Items</span>
|
||||
</div>
|
||||
<Progress value={calPercentage} className="h-2" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-3 pt-4 border-t">
|
||||
<Link to={`/admin/tryouts/${t.tryout_id}/questions`}>
|
||||
<Button variant="outline" size="sm" className="bg-white">
|
||||
<LayoutList className="w-4 h-4 mr-2" /> Questions ({itemCount})
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={`/admin/tryouts/${t.tryout_id}/attempts`}>
|
||||
<Button variant="outline" size="sm" className="bg-white">
|
||||
<Users className="w-4 h-4 mr-2" /> Attempts ({t.participant_count || 0})
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={`/admin/tryouts/${t.tryout_id}/normalization`}>
|
||||
<Button variant="outline" size="sm" className="bg-white">
|
||||
<Percent className="w-4 h-4 mr-2" /> Normalization
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={`/admin/tryouts/${t.tryout_id}/settings`}>
|
||||
<Button variant="outline" size="sm" className="bg-white">
|
||||
<Settings className="w-4 h-4 mr-2" /> Settings
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
299
frontend/src/pages/admin/import/index.tsx
Normal file
299
frontend/src/pages/admin/import/index.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import { useState, type ChangeEvent } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
|
||||
import { useAppStore } from '@/store/useAppStore'
|
||||
import type { Tryout } from '@/types'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Upload, FileSpreadsheet, CheckCircle2, AlertCircle, Save } from 'lucide-react'
|
||||
|
||||
type PreviewQuestion = {
|
||||
item_id?: string
|
||||
stem_text?: string
|
||||
stem?: string
|
||||
level?: string
|
||||
slot?: number
|
||||
correct_key?: string
|
||||
}
|
||||
|
||||
type ImportPreview = {
|
||||
items_count: number
|
||||
preview: PreviewQuestion[]
|
||||
validation_errors: string[]
|
||||
has_errors: boolean
|
||||
}
|
||||
|
||||
type ImportResult = {
|
||||
message: string
|
||||
imported: number
|
||||
duplicates: number
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { detail?: unknown } } }).response
|
||||
const detail = response?.data?.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
if (detail && typeof detail === 'object' && 'error' in detail) {
|
||||
const typedDetail = detail as { error?: string; validation_errors?: string[]; errors?: string[] }
|
||||
return typedDetail.validation_errors?.join(', ') || typedDetail.errors?.join(', ') || typedDetail.error || 'Request failed.'
|
||||
}
|
||||
}
|
||||
return error instanceof Error ? error.message : 'Request failed.'
|
||||
}
|
||||
|
||||
function getTryoutLabel(tryout: Tryout) {
|
||||
return `${tryout.tryout_id} - ${tryout.name || tryout.title || 'Untitled tryout'}`
|
||||
}
|
||||
|
||||
export default function ImportQuestions() {
|
||||
const { websiteId } = useAppStore()
|
||||
const queryClient = useQueryClient()
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [manualTryoutId, setManualTryoutId] = useState('')
|
||||
const [previewData, setPreviewData] = useState<ImportPreview | null>(null)
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null)
|
||||
|
||||
const { data: tryouts, isLoading: isLoadingTryouts, isError: isTryoutsError } = useQuery({
|
||||
queryKey: scopedQueryKey(websiteId, 'tryouts'),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<Tryout[]>('/tryout/')
|
||||
return res.data
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId),
|
||||
})
|
||||
|
||||
const selectedTryoutId =
|
||||
tryouts?.some((tryout) => tryout.tryout_id === manualTryoutId)
|
||||
? manualTryoutId
|
||||
: tryouts?.[0]?.tryout_id || ''
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: async (formData: FormData) => {
|
||||
const res = await api.post<ImportPreview>('/import-export/preview', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setPreviewData(data)
|
||||
setImportResult(null)
|
||||
},
|
||||
})
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!file || !selectedTryoutId) throw new Error('Select a tryout and Excel file first.')
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('tryout_id', selectedTryoutId)
|
||||
const res = await api.post<ImportResult>('/import-export/questions', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setImportResult(data)
|
||||
setPreviewData(null)
|
||||
setFile(null)
|
||||
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'tryout-questions', selectedTryoutId) })
|
||||
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'tryouts') })
|
||||
},
|
||||
})
|
||||
|
||||
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
setFile(e.target.files[0])
|
||||
setPreviewData(null)
|
||||
setImportResult(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreview = () => {
|
||||
if (!file) return
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
previewMutation.mutate(formData)
|
||||
}
|
||||
|
||||
if (!hasWebsiteScope(websiteId)) {
|
||||
return <div className="text-muted-foreground">Select a website to import Excel questions.</div>
|
||||
}
|
||||
|
||||
const canPreview = Boolean(file) && !previewMutation.isPending
|
||||
const canImport = Boolean(file && selectedTryoutId && previewData && !previewData.has_errors) && !importMutation.isPending
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Excel Import</h1>
|
||||
<p className="text-muted-foreground mt-1">Preview and import questions from an Excel template.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Upload File</CardTitle>
|
||||
<CardDescription>Select a tryout and filled Excel template.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Tryout</Label>
|
||||
<Select
|
||||
value={selectedTryoutId}
|
||||
onValueChange={setManualTryoutId}
|
||||
disabled={isLoadingTryouts || !tryouts?.length}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select tryout" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tryouts?.map((tryout) => (
|
||||
<SelectItem key={tryout.tryout_id} value={tryout.tryout_id}>
|
||||
{getTryoutLabel(tryout)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="file">Excel File (.xlsx)</Label>
|
||||
<Input id="file" type="file" accept=".xlsx" onChange={handleFileChange} />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button onClick={handlePreview} disabled={!canPreview} className="w-full">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{previewMutation.isPending ? 'Processing...' : 'Preview Import'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-medium flex items-center gap-2 mb-2">
|
||||
<FileSpreadsheet className="w-4 h-4" /> Template Format
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The Excel file must match the backend import schema and include question stem, options,
|
||||
answer key, slot, and level columns.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
{isTryoutsError && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<AlertTitle>Tryouts Failed</AlertTitle>
|
||||
<AlertDescription>Could not load tryout options for this website.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{(previewMutation.isError || importMutation.isError) && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<AlertTitle>Import Failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
{getErrorMessage(previewMutation.error || importMutation.error)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{importResult && (
|
||||
<Alert className="mb-6">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<AlertTitle>{importResult.message}</AlertTitle>
|
||||
<AlertDescription>
|
||||
Imported {importResult.imported} questions. Duplicates skipped: {importResult.duplicates}.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{previewData && (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
||||
Preview Ready
|
||||
</CardTitle>
|
||||
<CardDescription>Found {previewData.items_count} questions in the workbook.</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => importMutation.mutate()}
|
||||
disabled={!canImport}
|
||||
className="bg-emerald-600 hover:bg-emerald-700"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{importMutation.isPending ? 'Importing...' : 'Confirm Import'}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{previewData.validation_errors.length > 0 && (
|
||||
<Alert variant={previewData.has_errors ? 'destructive' : 'default'}>
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<AlertTitle>Validation Notes</AlertTitle>
|
||||
<AlertDescription>{previewData.validation_errors.join(', ')}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead>Slot</TableHead>
|
||||
<TableHead>Level</TableHead>
|
||||
<TableHead>Answer</TableHead>
|
||||
<TableHead>Stem</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{previewData.preview.map((row, idx) => (
|
||||
<TableRow key={`${row.item_id || idx}-${idx}`}>
|
||||
<TableCell className="font-medium">{row.item_id || `Row ${idx + 2}`}</TableCell>
|
||||
<TableCell>{row.slot ?? '-'}</TableCell>
|
||||
<TableCell>{row.level || '-'}</TableCell>
|
||||
<TableCell>{row.correct_key || '-'}</TableCell>
|
||||
<TableCell className="max-w-md truncate">{row.stem_text || row.stem || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!previewData && !previewMutation.isPending && !previewMutation.isError && !importResult && (
|
||||
<Card className="h-full min-h-[400px] flex flex-col items-center justify-center border-dashed">
|
||||
<div className="text-center text-muted-foreground p-12">
|
||||
<FileSpreadsheet className="w-12 h-12 mb-4 mx-auto opacity-20" />
|
||||
<p>Choose a tryout and upload a workbook to preview import data.</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
179
frontend/src/pages/admin/overview/DataOverview.tsx
Normal file
179
frontend/src/pages/admin/overview/DataOverview.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
|
||||
import { useAppStore } from '@/store/useAppStore'
|
||||
import type { Question } from '@/types'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Network, Sparkles } from 'lucide-react'
|
||||
|
||||
type OverviewSnapshot = {
|
||||
id: number
|
||||
tryout_id: string
|
||||
title: string
|
||||
question_count: number
|
||||
created_at: string
|
||||
basis_items: Question[]
|
||||
}
|
||||
|
||||
type OverviewWebsite = {
|
||||
id: number
|
||||
name: string
|
||||
domain: string
|
||||
snapshots: OverviewSnapshot[]
|
||||
}
|
||||
|
||||
type HierarchyOverview = {
|
||||
summary: {
|
||||
websites: number
|
||||
snapshots: number
|
||||
source_questions: number
|
||||
basis_items: number
|
||||
ai_runs: number
|
||||
variants: number
|
||||
snapshots_without_basis: number
|
||||
basis_without_variants: number
|
||||
orphan_variants: number
|
||||
}
|
||||
websites: OverviewWebsite[]
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DataOverview() {
|
||||
const { websiteId } = useAppStore()
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: scopedQueryKey(websiteId, 'data-overview'),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<HierarchyOverview>('/admin/overview/hierarchy')
|
||||
return res.data
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId),
|
||||
})
|
||||
|
||||
if (!hasWebsiteScope(websiteId)) {
|
||||
return <div className="text-muted-foreground">Select a website to load the data overview.</div>
|
||||
}
|
||||
|
||||
if (isLoading) return <Skeleton className="h-[520px] w-full" />
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to load data overview</AlertTitle>
|
||||
<AlertDescription>Check the selected website and backend API availability.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Data Overview</h1>
|
||||
<p className="text-muted-foreground mt-1">Snapshot, basis question, AI run, and variant hierarchy.</p>
|
||||
</div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/admin/import">Open Import</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<SummaryCard label="Snapshots" value={data.summary.snapshots} />
|
||||
<SummaryCard label="Source Questions" value={data.summary.source_questions} />
|
||||
<SummaryCard label="Basis Items" value={data.summary.basis_items} />
|
||||
<SummaryCard label="AI Variants" value={data.summary.variants} />
|
||||
</div>
|
||||
|
||||
{(data.summary.snapshots_without_basis > 0 ||
|
||||
data.summary.basis_without_variants > 0 ||
|
||||
data.summary.orphan_variants > 0) && (
|
||||
<Alert>
|
||||
<Network className="h-4 w-4" />
|
||||
<AlertTitle>Hierarchy gaps detected</AlertTitle>
|
||||
<AlertDescription>
|
||||
{data.summary.snapshots_without_basis} snapshots without promoted basis items,{' '}
|
||||
{data.summary.basis_without_variants} basis items without variants, and{' '}
|
||||
{data.summary.orphan_variants} variants without a visible basis.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{data.websites.map((website) => (
|
||||
<div key={website.id} className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{website.name}</h2>
|
||||
<p className="text-sm text-muted-foreground">{website.domain}</p>
|
||||
</div>
|
||||
|
||||
{website.snapshots.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-8 text-center text-muted-foreground">
|
||||
No imported snapshots found for this website.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted text-muted-foreground">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">Snapshot</th>
|
||||
<th className="p-3 text-left font-medium">Tryout</th>
|
||||
<th className="p-3 text-right font-medium">Questions</th>
|
||||
<th className="p-3 text-right font-medium">Basis</th>
|
||||
<th className="p-3 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{website.snapshots.map((snapshot) => (
|
||||
<tr key={snapshot.id}>
|
||||
<td className="p-3">
|
||||
<div className="font-medium">{snapshot.title}</div>
|
||||
<div className="text-xs text-muted-foreground">Snapshot #{snapshot.id}</div>
|
||||
</td>
|
||||
<td className="p-3">{snapshot.tryout_id}</td>
|
||||
<td className="p-3 text-right">{snapshot.question_count}</td>
|
||||
<td className="p-3 text-right">
|
||||
<Badge variant={snapshot.basis_items.length ? 'default' : 'secondary'}>
|
||||
{snapshot.basis_items.length}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={`/admin/tryouts/${snapshot.tryout_id}/questions`}>Questions</Link>
|
||||
</Button>
|
||||
{snapshot.basis_items[0] && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={`/admin/tryouts/${snapshot.tryout_id}/questions/${snapshot.basis_items[0].id}/ai-workspace`}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
AI
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
238
frontend/src/pages/admin/questions/QuestionDetail.tsx
Normal file
238
frontend/src/pages/admin/questions/QuestionDetail.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
|
||||
import { useAppStore } from '@/store/useAppStore'
|
||||
import { SafeHtml } from '@/components/SafeHtml'
|
||||
import { getQuestionLevelLabel } from '@/lib/questionLabels'
|
||||
import type { Question } from '@/types'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Check, Sparkles, X } from 'lucide-react'
|
||||
|
||||
type QuestionDetailResponse = {
|
||||
item: Question
|
||||
basis_item: Question | null
|
||||
variants: Question[]
|
||||
usage: {
|
||||
impressions: number
|
||||
unique_users: number
|
||||
}
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="text-sm text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-lg font-semibold">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OptionsTable({ item }: { item: Question }) {
|
||||
const options = Object.entries(item.options || {})
|
||||
if (options.length === 0) {
|
||||
return <div className="text-sm text-muted-foreground">No options saved for this question.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y">
|
||||
{options.map(([key, value]) => (
|
||||
<tr key={key}>
|
||||
<td className="w-16 p-3 font-mono">{key}</td>
|
||||
<td className="p-3">
|
||||
<SafeHtml html={value} />
|
||||
</td>
|
||||
<td className="w-24 p-3 text-right">
|
||||
{item.correct_answer === key && <Badge>Correct</Badge>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function QuestionDetail() {
|
||||
const { questionId } = useParams<{ questionId: string }>()
|
||||
const { websiteId } = useAppStore()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const queryKey = scopedQueryKey(websiteId, 'question-detail', questionId)
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const res = await api.get<QuestionDetailResponse>(`/admin/questions/${questionId}`)
|
||||
return res.data
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId) && Boolean(questionId),
|
||||
})
|
||||
|
||||
const reviewMutation = useMutation({
|
||||
mutationFn: async (status: 'active' | 'rejected') => {
|
||||
await api.post(`/admin/ai/review/${questionId}?status=${status}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'ai-pending-reviews') })
|
||||
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'ai-variants') })
|
||||
},
|
||||
})
|
||||
|
||||
if (!hasWebsiteScope(websiteId)) {
|
||||
return <div className="text-muted-foreground">Select a website to load question detail.</div>
|
||||
}
|
||||
|
||||
if (isLoading) return <Skeleton className="h-[520px] w-full" />
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to load question</AlertTitle>
|
||||
<AlertDescription>Check the selected website and question ID.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
const item = data.item
|
||||
const canGenerate = item.level === 'sedang' && item.tryout_id
|
||||
const canReview = item.generated_by === 'ai' && item.variant_status === 'draft'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Question #{item.id}</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Tryout {item.tryout_id || '-'} · Slot {item.slot ?? '-'} · {item.generated_by || 'manual'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canGenerate && (
|
||||
<Button asChild>
|
||||
<Link to={`/admin/tryouts/${item.tryout_id}/questions/${item.id}/ai-workspace`}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Generate Variant
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{canReview && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => reviewMutation.mutate('active')}
|
||||
disabled={reviewMutation.isPending}
|
||||
className="text-green-700"
|
||||
>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => reviewMutation.mutate('rejected')}
|
||||
disabled={reviewMutation.isPending}
|
||||
className="text-red-700"
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Reject
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Stat label="Level" value={getQuestionLevelLabel(item)} />
|
||||
<Stat label="P-value" value={item.p_value?.toFixed(3) ?? '-'} />
|
||||
<Stat label="IRT b" value={item.irt_b?.toFixed(3) ?? '-'} />
|
||||
<Stat label="Responses" value={data.usage.impressions} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Question Content</CardTitle>
|
||||
<CardDescription>
|
||||
Status: <span className="capitalize">{item.variant_status || (item.calibrated ? 'calibrated' : 'pending')}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="rounded-md border p-4">
|
||||
<SafeHtml html={item.stem || item.stem_text} />
|
||||
</div>
|
||||
<OptionsTable item={item} />
|
||||
{item.explanation && (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Explanation</h3>
|
||||
<div className="rounded-md border p-4">
|
||||
<SafeHtml html={item.explanation} allowEmbeds />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{data.basis_item && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Basis Question</CardTitle>
|
||||
<CardDescription>Original medium-level item used to create this variant.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/admin/questions/${data.basis_item.id}`}>Open basis question #{data.basis_item.id}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Variants</CardTitle>
|
||||
<CardDescription>{data.variants.length} generated variants connected to this basis question.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.variants.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No variants found.</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted text-muted-foreground">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-medium">Variant</th>
|
||||
<th className="p-3 text-left font-medium">Level</th>
|
||||
<th className="p-3 text-left font-medium">Status</th>
|
||||
<th className="p-3 text-right font-medium">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data.variants.map((variant) => (
|
||||
<tr key={variant.id}>
|
||||
<td className="p-3">
|
||||
<SafeHtml html={variant.stem_text} className="max-w-lg truncate" />
|
||||
</td>
|
||||
<td className="p-3 capitalize">{getQuestionLevelLabel(variant)}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="secondary" className="capitalize">{variant.variant_status || '-'}</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={`/admin/questions/${variant.id}`}>Open</Link>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
169
frontend/src/pages/admin/questions/QuestionQuality.tsx
Normal file
169
frontend/src/pages/admin/questions/QuestionQuality.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
|
||||
import { isOriginalQuestion } from '@/lib/questionLabels'
|
||||
import { useAppStore } from '@/store/useAppStore'
|
||||
import type { Question } from '@/types'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Target, Activity, CheckCircle2, AlertCircle, type LucideIcon } from 'lucide-react'
|
||||
|
||||
function formatNumber(value: number | null | undefined, digits = 2) {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) return '-'
|
||||
return Number(value).toFixed(digits)
|
||||
}
|
||||
|
||||
function average(values: number[]) {
|
||||
if (values.length === 0) return null
|
||||
return values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
icon: Icon,
|
||||
}: {
|
||||
title: string
|
||||
value: string | number
|
||||
description: string
|
||||
icon: LucideIcon
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function QuestionQuality() {
|
||||
const { websiteId } = useAppStore()
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: scopedQueryKey(websiteId, 'admin-questions'),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<{ items: Question[] }>('/admin/questions')
|
||||
return res.data.items
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId),
|
||||
})
|
||||
|
||||
if (!hasWebsiteScope(websiteId)) {
|
||||
return <div className="text-muted-foreground">Select a website to load question quality metrics.</div>
|
||||
}
|
||||
|
||||
if (isLoading) return <Skeleton className="h-[280px] w-full" />
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to load question quality</AlertTitle>
|
||||
<AlertDescription>Check the selected website and admin API connection.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
const calibrated = data.filter((question) => question.calibrated)
|
||||
const needsCalibration = data.filter(
|
||||
(question) => !question.calibrated && question.calibration_sample_size >= 30
|
||||
)
|
||||
const irtValues = data.map((question) => question.irt_b).filter((value): value is number => value !== null)
|
||||
const seValues = data
|
||||
.map((question) => question.irt_se)
|
||||
.filter((value): value is number => value !== null && value !== undefined)
|
||||
const levelRows = [
|
||||
{ key: 'original', label: 'Original', filter: (question: Question) => isOriginalQuestion(question) },
|
||||
{ key: 'mudah', label: 'mudah', filter: (question: Question) => !isOriginalQuestion(question) && question.level === 'mudah' },
|
||||
{ key: 'sedang', label: 'sedang', filter: (question: Question) => !isOriginalQuestion(question) && question.level === 'sedang' },
|
||||
{ key: 'sulit', label: 'sulit', filter: (question: Question) => !isOriginalQuestion(question) && question.level === 'sulit' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="Ready for IRT"
|
||||
value={calibrated.length}
|
||||
description={`${data.length} total questions`}
|
||||
icon={CheckCircle2}
|
||||
/>
|
||||
<StatCard
|
||||
title="Needs Calibration"
|
||||
value={needsCalibration.length}
|
||||
description="Uncalibrated with enough response data"
|
||||
icon={AlertCircle}
|
||||
/>
|
||||
<StatCard
|
||||
title="Mean Difficulty (b)"
|
||||
value={formatNumber(average(irtValues), 2)}
|
||||
description="Average calibrated IRT parameter"
|
||||
icon={Target}
|
||||
/>
|
||||
<StatCard
|
||||
title="Std Error Range"
|
||||
value={seValues.length ? `${formatNumber(Math.min(...seValues), 2)}-${formatNumber(Math.max(...seValues), 2)}` : '-'}
|
||||
description="Observed calibration precision"
|
||||
icon={Activity}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Calibration Diagnostics</CardTitle>
|
||||
<CardDescription>Breakdown by difficulty level from the current website question bank.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Level</TableHead>
|
||||
<TableHead>Total</TableHead>
|
||||
<TableHead>Calibrated</TableHead>
|
||||
<TableHead>Avg Sample</TableHead>
|
||||
<TableHead>Avg P</TableHead>
|
||||
<TableHead>Avg IRT b</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{levelRows.map((row) => {
|
||||
const questions = data.filter(row.filter)
|
||||
const avgSample = average(questions.map((question) => question.calibration_sample_size))
|
||||
const avgP = average(
|
||||
questions.map((question) => question.p_value).filter((value): value is number => value !== null)
|
||||
)
|
||||
const avgB = average(
|
||||
questions.map((question) => question.irt_b).filter((value): value is number => value !== null)
|
||||
)
|
||||
return (
|
||||
<TableRow key={row.key}>
|
||||
<TableCell className="font-medium">{row.label}</TableCell>
|
||||
<TableCell>{questions.length}</TableCell>
|
||||
<TableCell>{questions.filter((question) => question.calibrated).length}</TableCell>
|
||||
<TableCell>{formatNumber(avgSample, 1)}</TableCell>
|
||||
<TableCell>{formatNumber(avgP, 3)}</TableCell>
|
||||
<TableCell>{formatNumber(avgB, 2)}</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
178
frontend/src/pages/admin/questions/QuestionsList.tsx
Normal file
178
frontend/src/pages/admin/questions/QuestionsList.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
|
||||
import { useAppStore } from '@/store/useAppStore'
|
||||
import { SafeHtml } from '@/components/SafeHtml'
|
||||
import { getQuestionLevelLabel, isOriginalQuestion } from '@/lib/questionLabels'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { Question } from '@/types'
|
||||
import { Eye, Search } from 'lucide-react'
|
||||
|
||||
export default function QuestionsList() {
|
||||
const { websiteId } = useAppStore()
|
||||
const [search, setSearch] = useState('')
|
||||
const [sourceFilter, setSourceFilter] = useState('all')
|
||||
const [levelFilter, setLevelFilter] = useState('all')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: scopedQueryKey(websiteId, 'admin-questions'),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<{ items: Question[] }>('/admin/questions')
|
||||
return res.data.items
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId),
|
||||
})
|
||||
|
||||
const questions = useMemo(() => data ?? [], [data])
|
||||
const filteredQuestions = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase()
|
||||
return questions.filter((item) => {
|
||||
const haystack = [
|
||||
item.stem_text,
|
||||
item.tryout_id,
|
||||
item.item_id,
|
||||
item.level,
|
||||
item.generated_by,
|
||||
item.variant_status,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
const matchesSearch = !needle || haystack.includes(needle)
|
||||
const matchesSource = sourceFilter === 'all' || item.generated_by === sourceFilter
|
||||
const matchesLevel = levelFilter === 'all' || item.level === levelFilter
|
||||
const matchesStatus =
|
||||
statusFilter === 'all' ||
|
||||
(statusFilter === 'calibrated' && item.calibrated) ||
|
||||
(statusFilter === 'uncalibrated' && !item.calibrated) ||
|
||||
item.variant_status === statusFilter
|
||||
return matchesSearch && matchesSource && matchesLevel && matchesStatus
|
||||
})
|
||||
}, [questions, search, sourceFilter, levelFilter, statusFilter])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{!hasWebsiteScope(websiteId) ? (
|
||||
<div className="text-muted-foreground">Select a website to load questions.</div>
|
||||
) : isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map(i => <Skeleton key={i} className="h-16 w-full" />)}
|
||||
</div>
|
||||
) : isError || !data ? (
|
||||
<div className="text-destructive">Failed to load questions.</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(220px,1fr)_160px_160px_170px]">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Search stem, item, or tryout"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Select value={sourceFilter} onValueChange={setSourceFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All sources</SelectItem>
|
||||
<SelectItem value="manual">Manual</SelectItem>
|
||||
<SelectItem value="ai">AI</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={levelFilter} onValueChange={setLevelFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All levels</SelectItem>
|
||||
<SelectItem value="mudah">Mudah</SelectItem>
|
||||
<SelectItem value="sedang">Sedang</SelectItem>
|
||||
<SelectItem value="sulit">Sulit</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="calibrated">Calibrated</SelectItem>
|
||||
<SelectItem value="uncalibrated">Uncalibrated</SelectItem>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="rejected">Rejected</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-muted text-muted-foreground">
|
||||
<tr>
|
||||
<th className="p-3 font-medium">Question</th>
|
||||
<th className="p-3 font-medium">Difficulty</th>
|
||||
<th className="p-3 font-medium">Source</th>
|
||||
<th className="p-3 font-medium">Tryout</th>
|
||||
<th className="p-3 font-medium">Status</th>
|
||||
<th className="p-3 font-medium text-right">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{filteredQuestions.map(item => (
|
||||
<tr key={item.id} className="hover:bg-muted/50">
|
||||
<td className="p-3">
|
||||
<SafeHtml html={item.stem_text} className="max-w-md truncate" />
|
||||
<div className="text-[10px] text-muted-foreground mt-1">Item #{item.id}</div>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={isOriginalQuestion(item) || item.level === 'sedang' ? 'default' : item.level === 'mudah' ? 'secondary' : 'destructive'}>
|
||||
{getQuestionLevelLabel(item)}
|
||||
</Badge>
|
||||
<div className="text-[10px] text-muted-foreground mt-1">p: {item.p_value?.toFixed(2) ?? '-'}</div>
|
||||
</td>
|
||||
<td className="p-3 capitalize">{item.generated_by}</td>
|
||||
<td className="p-3">{item.tryout_id}</td>
|
||||
<td className="p-3">
|
||||
{item.calibrated ? (
|
||||
<Badge variant="outline" className="text-green-600 bg-green-50 border-green-200">Calibrated</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-amber-600 bg-amber-50 border-amber-200">Pending</Badge>
|
||||
)}
|
||||
{item.variant_status && (
|
||||
<div className="mt-1">
|
||||
<Badge variant="secondary" className="capitalize">{item.variant_status}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={`/admin/questions/${item.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredQuestions.length === 0 && (
|
||||
<div className="p-4 text-center text-muted-foreground">No questions found.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
89
frontend/src/pages/admin/questions/TemplatesList.tsx
Normal file
89
frontend/src/pages/admin/questions/TemplatesList.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '@/lib/api'
|
||||
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
|
||||
import { useAppStore } from '@/store/useAppStore'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Bot, Sparkles } from 'lucide-react'
|
||||
|
||||
interface Template {
|
||||
id: number
|
||||
tryout_id: string
|
||||
stem_text: string
|
||||
p_value: number | null
|
||||
created_at: string
|
||||
variants_count: number
|
||||
}
|
||||
|
||||
export default function TemplatesList() {
|
||||
const { websiteId } = useAppStore()
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: scopedQueryKey(websiteId, 'admin-templates'),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<{ items: Template[] }>('/admin/templates')
|
||||
return res.data
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-muted/30 p-4 rounded-lg border">
|
||||
<div>
|
||||
<h3 className="font-medium">Question Templates</h3>
|
||||
<p className="text-sm text-muted-foreground">Original questions used to generate AI variants.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasWebsiteScope(websiteId) ? (
|
||||
<div className="p-8 text-center text-muted-foreground border rounded-lg border-dashed">
|
||||
Select a website to load templates.
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[1, 2].map(i => <Skeleton key={i} className="h-32 w-full" />)}
|
||||
</div>
|
||||
) : isError || !data ? (
|
||||
<div className="text-destructive">Failed to load templates.</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{data.items.map(template => (
|
||||
<Card key={template.id}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-base line-clamp-2" title={template.stem_text}>
|
||||
{template.stem_text}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Tryout: {template.tryout_id} | Difficulty (p): {template.p_value?.toFixed(2) ?? '-'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
{template.variants_count} AI Variants Generated
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" asChild>
|
||||
<Link to={`/admin/tryouts/${template.tryout_id}/questions/${template.id}/ai-workspace`}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Generate More
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{data.items.length === 0 && (
|
||||
<div className="col-span-full p-8 text-center text-muted-foreground border rounded-lg border-dashed">
|
||||
No templates found. A template must be a question with 'sedang' difficulty created manually.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
frontend/src/pages/admin/questions/index.tsx
Normal file
20
frontend/src/pages/admin/questions/index.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import QuestionsList from './QuestionsList'
|
||||
import QuestionQuality from './QuestionQuality'
|
||||
|
||||
export default function Questions() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Questions Bank</h1>
|
||||
<p className="text-muted-foreground mt-1">Manage all global questions.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<QuestionQuality />
|
||||
<QuestionsList />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
470
frontend/src/pages/admin/reports/index.tsx
Normal file
470
frontend/src/pages/admin/reports/index.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { api } from '@/lib/api'
|
||||
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
|
||||
import { useAppStore } from '@/store/useAppStore'
|
||||
import type { Tryout } from '@/types'
|
||||
import { AlertCircle, Download } from 'lucide-react'
|
||||
|
||||
type CalibrationItem = {
|
||||
item_id: string
|
||||
slot: number
|
||||
level: string
|
||||
sample_size: number
|
||||
calibrated: boolean
|
||||
irt_b: number | null
|
||||
irt_se: number | null
|
||||
ctt_p: number | null
|
||||
}
|
||||
|
||||
type CalibrationStatusReport = {
|
||||
total_items: number
|
||||
calibrated_items: number
|
||||
calibration_percentage: number
|
||||
items_awaiting_calibration: CalibrationItem[]
|
||||
avg_calibration_sample_size: number | null
|
||||
ready_for_irt_rollout: boolean
|
||||
items: CalibrationItem[]
|
||||
}
|
||||
|
||||
type ItemAnalysisRecord = {
|
||||
item_id: string
|
||||
slot: number
|
||||
level: string
|
||||
ctt_p: number | null
|
||||
ctt_bobot: number | null
|
||||
ctt_category: string | null
|
||||
irt_b: number | null
|
||||
irt_se: number | null
|
||||
calibrated: boolean
|
||||
calibration_sample_size: number
|
||||
correctness_rate: number | null
|
||||
item_total_correlation: number | null
|
||||
}
|
||||
|
||||
type ItemAnalysisReport = {
|
||||
total_items: number
|
||||
items: ItemAnalysisRecord[]
|
||||
summary: Record<string, unknown>
|
||||
}
|
||||
|
||||
type StudentPerformanceRecord = {
|
||||
session_id: string
|
||||
wp_user_id: string
|
||||
NM: number | null
|
||||
NN: number | null
|
||||
theta: number | null
|
||||
theta_se: number | null
|
||||
total_benar: number
|
||||
time_spent: number | null
|
||||
start_time: string
|
||||
end_time: string | null
|
||||
scoring_mode_used: string
|
||||
}
|
||||
|
||||
type StudentPerformanceReport = {
|
||||
aggregate: {
|
||||
participant_count: number
|
||||
avg_nm: number | null
|
||||
std_nm: number | null
|
||||
min_nm: number | null
|
||||
max_nm: number | null
|
||||
median_nm: number | null
|
||||
avg_nn: number | null
|
||||
std_nn: number | null
|
||||
avg_theta: number | null
|
||||
pass_rate: number | null
|
||||
avg_time_spent: number | null
|
||||
}
|
||||
individual_records: StudentPerformanceRecord[]
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null | undefined, digits = 2) {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) return '-'
|
||||
return Number(value).toFixed(digits)
|
||||
}
|
||||
|
||||
function getTryoutLabel(tryout: Tryout) {
|
||||
return `${tryout.tryout_id} - ${tryout.name || tryout.title || 'Untitled tryout'}`
|
||||
}
|
||||
|
||||
async function downloadReport(path: string, tryoutId: string, filename: string) {
|
||||
const res = await api.get<Blob>(path, {
|
||||
params: { tryout_id: tryoutId },
|
||||
responseType: 'blob',
|
||||
})
|
||||
const contentType = res.headers['content-type']
|
||||
const blob = new Blob([res.data], { type: typeof contentType === 'string' ? contentType : 'text/csv' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function ReportError({ title }: { title: string }) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
<AlertDescription>Check the selected website, tryout, and backend API availability.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryStat({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="rounded-md border p-4">
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CalibrationStatusReportView({ tryoutId, websiteId }: { tryoutId: string; websiteId: number }) {
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: scopedQueryKey(websiteId, 'report-calibration-status', tryoutId),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<CalibrationStatusReport>('/reports/calibration/status', {
|
||||
params: { tryout_id: tryoutId },
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId) && Boolean(tryoutId),
|
||||
})
|
||||
|
||||
if (isLoading) return <Skeleton className="h-[300px] w-full" />
|
||||
if (isError) return <ReportError title="Failed to load calibration report" />
|
||||
if (!data) return null
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true)
|
||||
try {
|
||||
await downloadReport(
|
||||
'/reports/calibration/status/export/csv',
|
||||
tryoutId,
|
||||
`calibration_status_${tryoutId}.csv`
|
||||
)
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Calibration Status</CardTitle>
|
||||
<CardDescription>Item calibration readiness for the selected tryout.</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleExport} disabled={isExporting}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{isExporting ? 'Exporting...' : 'Export CSV'}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<SummaryStat label="Total Items" value={data.total_items} />
|
||||
<SummaryStat label="Calibrated" value={data.calibrated_items} />
|
||||
<SummaryStat label="Progress" value={`${formatNumber(data.calibration_percentage, 1)}%`} />
|
||||
<SummaryStat label="Avg Sample" value={formatNumber(data.avg_calibration_sample_size, 1)} />
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead>Slot</TableHead>
|
||||
<TableHead>Level</TableHead>
|
||||
<TableHead>Sample</TableHead>
|
||||
<TableHead>CTT P</TableHead>
|
||||
<TableHead>IRT B</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.map((item) => (
|
||||
<TableRow key={`${item.item_id}-${item.slot}`}>
|
||||
<TableCell className="font-medium">{item.item_id}</TableCell>
|
||||
<TableCell>{item.slot}</TableCell>
|
||||
<TableCell>{item.level}</TableCell>
|
||||
<TableCell>{item.sample_size}</TableCell>
|
||||
<TableCell>{formatNumber(item.ctt_p, 3)}</TableCell>
|
||||
<TableCell>{formatNumber(item.irt_b, 3)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={item.calibrated ? 'default' : 'secondary'}>
|
||||
{item.calibrated ? 'Calibrated' : 'Needs data'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemAnalysisReportView({ tryoutId, websiteId }: { tryoutId: string; websiteId: number }) {
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: scopedQueryKey(websiteId, 'report-item-analysis', tryoutId),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<ItemAnalysisReport>('/reports/items/analysis', {
|
||||
params: { tryout_id: tryoutId },
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId) && Boolean(tryoutId),
|
||||
})
|
||||
|
||||
if (isLoading) return <Skeleton className="h-[300px] w-full" />
|
||||
if (isError) return <ReportError title="Failed to load item analysis" />
|
||||
if (!data) return null
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true)
|
||||
try {
|
||||
await downloadReport('/reports/items/analysis/export/csv', tryoutId, `item_analysis_${tryoutId}.csv`)
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Item Analysis</CardTitle>
|
||||
<CardDescription>Difficulty, discrimination, and calibration statistics.</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleExport} disabled={isExporting}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{isExporting ? 'Exporting...' : 'Export CSV'}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<SummaryStat label="Total Items" value={data.total_items} />
|
||||
<SummaryStat
|
||||
label="Calibrated"
|
||||
value={data.items.filter((item) => item.calibrated).length}
|
||||
/>
|
||||
<SummaryStat
|
||||
label="Avg Correctness"
|
||||
value={`${formatNumber(
|
||||
data.items.reduce((sum, item) => sum + (item.correctness_rate ?? 0), 0) /
|
||||
Math.max(data.items.filter((item) => item.correctness_rate !== null).length, 1),
|
||||
1
|
||||
)}%`}
|
||||
/>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead>Slot</TableHead>
|
||||
<TableHead>Level</TableHead>
|
||||
<TableHead>CTT P</TableHead>
|
||||
<TableHead>Bobot</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>IRT B</TableHead>
|
||||
<TableHead>Sample</TableHead>
|
||||
<TableHead>Correlation</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.map((item) => (
|
||||
<TableRow key={`${item.item_id}-${item.slot}`}>
|
||||
<TableCell className="font-medium">{item.item_id}</TableCell>
|
||||
<TableCell>{item.slot}</TableCell>
|
||||
<TableCell>{item.level}</TableCell>
|
||||
<TableCell>{formatNumber(item.ctt_p, 3)}</TableCell>
|
||||
<TableCell>{formatNumber(item.ctt_bobot, 2)}</TableCell>
|
||||
<TableCell>{item.ctt_category || '-'}</TableCell>
|
||||
<TableCell>{formatNumber(item.irt_b, 3)}</TableCell>
|
||||
<TableCell>{item.calibration_sample_size}</TableCell>
|
||||
<TableCell>{formatNumber(item.item_total_correlation, 3)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function StudentPerformanceReportView({ tryoutId, websiteId }: { tryoutId: string; websiteId: number }) {
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: scopedQueryKey(websiteId, 'report-student-performance', tryoutId),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<StudentPerformanceReport>('/reports/student/performance', {
|
||||
params: { tryout_id: tryoutId },
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId) && Boolean(tryoutId),
|
||||
})
|
||||
|
||||
if (isLoading) return <Skeleton className="h-[300px] w-full" />
|
||||
if (isError) return <ReportError title="Failed to load student performance" />
|
||||
if (!data) return null
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true)
|
||||
try {
|
||||
await downloadReport(
|
||||
'/reports/student/performance/export/csv',
|
||||
tryoutId,
|
||||
`student_performance_${tryoutId}.csv`
|
||||
)
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Student Performance</CardTitle>
|
||||
<CardDescription>Scores and completion statistics for the selected tryout.</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleExport} disabled={isExporting}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{isExporting ? 'Exporting...' : 'Export CSV'}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<SummaryStat label="Participants" value={data.aggregate.participant_count} />
|
||||
<SummaryStat label="Avg NM" value={formatNumber(data.aggregate.avg_nm, 2)} />
|
||||
<SummaryStat label="Avg NN" value={formatNumber(data.aggregate.avg_nn, 2)} />
|
||||
<SummaryStat label="Pass Rate" value={`${formatNumber(data.aggregate.pass_rate, 1)}%`} />
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Session</TableHead>
|
||||
<TableHead>WP User</TableHead>
|
||||
<TableHead>NM</TableHead>
|
||||
<TableHead>NN</TableHead>
|
||||
<TableHead>Theta</TableHead>
|
||||
<TableHead>Correct</TableHead>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Mode</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.individual_records.map((record) => (
|
||||
<TableRow key={record.session_id}>
|
||||
<TableCell className="font-medium">{record.session_id}</TableCell>
|
||||
<TableCell>{record.wp_user_id}</TableCell>
|
||||
<TableCell>{formatNumber(record.NM, 2)}</TableCell>
|
||||
<TableCell>{formatNumber(record.NN, 2)}</TableCell>
|
||||
<TableCell>{formatNumber(record.theta, 3)}</TableCell>
|
||||
<TableCell>{record.total_benar}</TableCell>
|
||||
<TableCell>{record.time_spent ? `${Math.round(record.time_spent / 60)}m` : '-'}</TableCell>
|
||||
<TableCell>{record.scoring_mode_used}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Reports() {
|
||||
const { websiteId } = useAppStore()
|
||||
const [manualTryoutId, setManualTryoutId] = useState('')
|
||||
const { data: tryouts, isLoading: isLoadingTryouts, isError: isTryoutsError } = useQuery({
|
||||
queryKey: scopedQueryKey(websiteId, 'tryouts'),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<Tryout[]>('/tryout/')
|
||||
return res.data
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId),
|
||||
})
|
||||
|
||||
const selectedTryoutId =
|
||||
tryouts?.some((tryout) => tryout.tryout_id === manualTryoutId)
|
||||
? manualTryoutId
|
||||
: tryouts?.[0]?.tryout_id || ''
|
||||
|
||||
if (!hasWebsiteScope(websiteId)) {
|
||||
return <div className="text-muted-foreground">Select a website to load reports.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">System Reports</h1>
|
||||
<p className="text-muted-foreground mt-1">View and export analytical reports.</p>
|
||||
</div>
|
||||
<div className="w-full md:w-80">
|
||||
<Select value={selectedTryoutId} onValueChange={setManualTryoutId} disabled={isLoadingTryouts}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select tryout" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tryouts?.map((tryout) => (
|
||||
<SelectItem key={tryout.tryout_id} value={tryout.tryout_id}>
|
||||
{getTryoutLabel(tryout)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoadingTryouts ? (
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
) : isTryoutsError ? (
|
||||
<ReportError title="Failed to load tryouts" />
|
||||
) : !selectedTryoutId ? (
|
||||
<Alert>
|
||||
<AlertTitle>No tryouts available</AlertTitle>
|
||||
<AlertDescription>Import or create a tryout before opening reports.</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Tabs defaultValue="calibration" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="calibration">Calibration Status</TabsTrigger>
|
||||
<TabsTrigger value="items">Item Statistics</TabsTrigger>
|
||||
<TabsTrigger value="students">Student Performance</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="calibration" className="space-y-4">
|
||||
<CalibrationStatusReportView tryoutId={selectedTryoutId} websiteId={websiteId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="items" className="space-y-4">
|
||||
<ItemAnalysisReportView tryoutId={selectedTryoutId} websiteId={websiteId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="students" className="space-y-4">
|
||||
<StudentPerformanceReportView tryoutId={selectedTryoutId} websiteId={websiteId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
209
frontend/src/pages/admin/settings/index.tsx
Normal file
209
frontend/src/pages/admin/settings/index.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Plus, Trash2, Edit2, AlertCircle } from 'lucide-react'
|
||||
|
||||
interface Website {
|
||||
id: number
|
||||
domain: string
|
||||
name: string
|
||||
}
|
||||
|
||||
function WebsitesManagement() {
|
||||
const queryClient = useQueryClient()
|
||||
const [isEditing, setIsEditing] = useState<number | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editDomain, setEditDomain] = useState('')
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newDomain, setNewDomain] = useState('')
|
||||
|
||||
const { data: websites, isLoading, isError } = useQuery({
|
||||
queryKey: ['websites'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<Website[]>('/websites')
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await api.post('/websites', {
|
||||
name: newName,
|
||||
domain: newDomain,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['websites'] })
|
||||
setNewName('')
|
||||
setNewDomain('')
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({ id, name, domain }: { id: number; name: string; domain: string }) => {
|
||||
await api.put(`/websites/${id}`, { name, domain })
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['websites'] })
|
||||
setIsEditing(null)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await api.delete(`/websites/${id}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['websites'] })
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) return <Skeleton className="h-[400px] w-full" />
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Failed to load websites</AlertTitle>
|
||||
<AlertDescription>Check your admin token and backend API connection.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Websites</CardTitle>
|
||||
<CardDescription>Manage allowed Sejoli websites.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-3 rounded-md border bg-muted/30 p-4 md:grid-cols-[1fr_1fr_auto] md:items-end">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website-name">Name</Label>
|
||||
<Input
|
||||
id="website-name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Main website"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website-domain">Domain</Label>
|
||||
<Input
|
||||
id="website-domain"
|
||||
value={newDomain}
|
||||
onChange={(e) => setNewDomain(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!newName.trim() || !newDomain.trim() || createMutation.isPending}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{createMutation.isPending ? 'Adding...' : 'Add Website'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(createMutation.isError || updateMutation.isError || deleteMutation.isError) && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Website update failed</AlertTitle>
|
||||
<AlertDescription>The backend rejected the website change.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{websites?.map((website) => (
|
||||
<div key={website.id} className="flex items-center justify-between p-4 border rounded-lg bg-card">
|
||||
{isEditing === website.id ? (
|
||||
<div className="flex-1 grid grid-cols-1 gap-4 mr-4 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Name</Label>
|
||||
<Input value={editName} onChange={(e) => setEditName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Domain</Label>
|
||||
<Input value={editDomain} onChange={(e) => setEditDomain(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold">{website.name}</h4>
|
||||
<p className="text-sm text-muted-foreground">{website.domain}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{isEditing === website.id ? (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={!editName.trim() || !editDomain.trim() || updateMutation.isPending}
|
||||
onClick={() => updateMutation.mutate({ id: website.id, name: editName, domain: editDomain })}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setIsEditing(null)}>Cancel</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setIsEditing(website.id)
|
||||
setEditName(website.name || '')
|
||||
setEditDomain(website.domain || '')
|
||||
}}
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (confirm('Delete this website?')) {
|
||||
deleteMutation.mutate(website.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{websites?.length === 0 && (
|
||||
<div className="text-center p-8 text-muted-foreground border border-dashed rounded-lg">
|
||||
No websites configured yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
|
||||
<p className="text-muted-foreground mt-1">Manage concrete system configuration available in the API.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WebsitesManagement />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
704
frontend/src/pages/admin/tryouts/AIWorkspace.tsx
Normal file
704
frontend/src/pages/admin/tryouts/AIWorkspace.tsx
Normal file
@@ -0,0 +1,704 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { SafeHtml } from '@/components/SafeHtml'
|
||||
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
|
||||
import { getQuestionLevelLabel } from '@/lib/questionLabels'
|
||||
import type { Question } from '@/types'
|
||||
import { useAppStore } from '@/store/useAppStore'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { ArrowLeft, CheckCircle2, ChevronLeft, ChevronRight, Layers, RefreshCw, Save, Sparkles } from 'lucide-react'
|
||||
|
||||
interface AIModel {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
pricing?: AIModelPricing | null
|
||||
}
|
||||
|
||||
type AIModelPricing = {
|
||||
prompt?: number | null
|
||||
completion?: number | null
|
||||
prompt_per_million?: number | null
|
||||
completion_per_million?: number | null
|
||||
currency?: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
type AIUsage = {
|
||||
prompt_tokens?: number | null
|
||||
completion_tokens?: number | null
|
||||
total_tokens?: number | null
|
||||
cost_usd?: number | null
|
||||
}
|
||||
|
||||
type QuestionDetailResponse = {
|
||||
item: Question
|
||||
basis_item: Question | null
|
||||
variants: Question[]
|
||||
usage: {
|
||||
impressions: number
|
||||
unique_users: number
|
||||
}
|
||||
}
|
||||
|
||||
type GeneratedPreviewResponse = {
|
||||
success: boolean
|
||||
stem?: string
|
||||
options?: Record<string, string>
|
||||
correct?: string
|
||||
explanation?: string | null
|
||||
ai_model?: string
|
||||
basis_item_id?: number
|
||||
target_level?: string
|
||||
usage?: AIUsage | null
|
||||
error?: string
|
||||
cached?: boolean
|
||||
}
|
||||
|
||||
type BatchGeneratedItem = {
|
||||
item_id: number
|
||||
stem: string
|
||||
options: Record<string, string>
|
||||
correct: string
|
||||
explanation?: string | null
|
||||
level: string
|
||||
variant_status: string
|
||||
usage?: AIUsage | null
|
||||
}
|
||||
|
||||
type BatchGenerateResponse = {
|
||||
success: boolean
|
||||
run_id?: number
|
||||
item_ids: number[]
|
||||
items: BatchGeneratedItem[]
|
||||
generated_count: number
|
||||
usage?: AIUsage | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
type PreviewCandidate = {
|
||||
client_id: string
|
||||
stem?: string
|
||||
options?: Record<string, string>
|
||||
correct?: string
|
||||
explanation?: string | null
|
||||
ai_model?: string
|
||||
basis_item_id?: number
|
||||
target_level?: string
|
||||
usage?: AIUsage | null
|
||||
saved_item_id?: number
|
||||
variant_status?: string
|
||||
}
|
||||
|
||||
type ReviewQuestion = {
|
||||
id?: number
|
||||
item_id?: number | string
|
||||
slot?: number
|
||||
level?: string
|
||||
stem?: string
|
||||
stem_text?: string
|
||||
options?: Record<string, string>
|
||||
correct?: string
|
||||
correct_answer?: string
|
||||
explanation?: string | null
|
||||
variant_status?: string
|
||||
usage?: AIUsage | null
|
||||
saved_item_id?: number
|
||||
}
|
||||
|
||||
function formatUsd(value?: number | null, maximumFractionDigits = 6) {
|
||||
if (value === undefined || value === null) return 'Cost unavailable'
|
||||
return `$${value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: value >= 0.01 ? 4 : 6,
|
||||
maximumFractionDigits,
|
||||
})}`
|
||||
}
|
||||
|
||||
function formatPerMillion(value?: number | null) {
|
||||
if (value === undefined || value === null) return '?'
|
||||
return `$${value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: value >= 1 ? 2 : 4,
|
||||
maximumFractionDigits: value >= 1 ? 2 : 4,
|
||||
})}`
|
||||
}
|
||||
|
||||
function formatPricing(pricing?: AIModelPricing | null) {
|
||||
if (!pricing) return 'pricing unavailable'
|
||||
return `Input ${formatPerMillion(pricing.prompt_per_million)}/1M · Output ${formatPerMillion(pricing.completion_per_million)}/1M`
|
||||
}
|
||||
|
||||
function formatTokenCount(value?: number | null) {
|
||||
return value === undefined || value === null ? '-' : value.toLocaleString()
|
||||
}
|
||||
|
||||
function formatUsage(usage?: AIUsage | null) {
|
||||
if (!usage) return null
|
||||
return `Input ${formatTokenCount(usage.prompt_tokens)} · Output ${formatTokenCount(usage.completion_tokens)} · Total ${formatTokenCount(usage.total_tokens)} · ${formatUsd(usage.cost_usd)}`
|
||||
}
|
||||
|
||||
function QuestionReviewPanel({
|
||||
item,
|
||||
isLoading,
|
||||
emptyText,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
item: ReviewQuestion | undefined
|
||||
isLoading: boolean
|
||||
emptyText: string
|
||||
title: string
|
||||
description?: string
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return (
|
||||
<div className="flex min-h-[320px] items-center justify-center rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const correct = item.correct_answer || item.correct
|
||||
const options = Object.entries(item.options || {})
|
||||
const usageLabel = formatUsage(item.usage)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
||||
{usageLabel && <p className="mt-1 text-xs text-muted-foreground">{usageLabel}</p>}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-3 text-sm">
|
||||
<SafeHtml html={item.stem || item.stem_text} className="max-w-none leading-relaxed" />
|
||||
</div>
|
||||
|
||||
{options.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{options.map(([key, value]) => (
|
||||
<div key={key} className="flex gap-2 rounded-md border p-2 text-sm">
|
||||
<span className="font-mono font-semibold">{key}</span>
|
||||
<SafeHtml html={value} className="flex-1" />
|
||||
{correct === key && <Badge>Correct</Badge>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.explanation && (
|
||||
<div className="rounded-md border p-3 text-sm">
|
||||
<div className="mb-2 font-medium">Explanation</div>
|
||||
<SafeHtml html={item.explanation} className="max-w-none leading-relaxed" allowEmbeds />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AIWorkspace() {
|
||||
const { id: tryoutId, questionId } = useParams<{ id: string; questionId?: string }>()
|
||||
const { websiteId } = useAppStore()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [targetLevel, setTargetLevel] = useState<'mudah' | 'sulit'>('mudah')
|
||||
const [aiModel, setAiModel] = useState<string>('')
|
||||
const [batchCount, setBatchCount] = useState('3')
|
||||
const [operatorNotes, setOperatorNotes] = useState('')
|
||||
const [activeReviewTab, setActiveReviewTab] = useState<'original' | 'preview' | 'batch'>('original')
|
||||
const [previewCandidates, setPreviewCandidates] = useState<PreviewCandidate[]>([])
|
||||
const [previewIndex, setPreviewIndex] = useState(0)
|
||||
const [batchIndex, setBatchIndex] = useState(0)
|
||||
|
||||
// Data Fetching
|
||||
const { data: modelsData } = useQuery({
|
||||
queryKey: ['ai-models'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<{ models: AIModel[] }>('/admin/ai/models')
|
||||
return res.data.models
|
||||
}
|
||||
})
|
||||
const routedQuestionId = questionId ? Number(questionId) : null
|
||||
|
||||
const { data: routedQuestionDetail, isLoading: isRoutedQuestionLoading, isError: isRoutedQuestionError } = useQuery({
|
||||
queryKey: scopedQueryKey(websiteId, 'question-detail', questionId),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<QuestionDetailResponse>(`/admin/questions/${questionId}`)
|
||||
return res.data
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId) && Boolean(questionId),
|
||||
})
|
||||
|
||||
const selectedBasisItemId = routedQuestionId ? routedQuestionId.toString() : ''
|
||||
const selectedBasisItem = useMemo(() => {
|
||||
const selectedId = Number(selectedBasisItemId)
|
||||
if (!selectedId) return undefined
|
||||
|
||||
return routedQuestionDetail?.item.id === selectedId ? routedQuestionDetail.item : undefined
|
||||
}, [routedQuestionDetail, selectedBasisItemId])
|
||||
|
||||
const isBasisLoading = Boolean(selectedBasisItemId) && !selectedBasisItem && isRoutedQuestionLoading
|
||||
const isBasisUnavailable = Boolean(selectedBasisItemId) && !selectedBasisItem && !isBasisLoading
|
||||
|
||||
// Mutations
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await api.post('/admin/ai/generate-preview', {
|
||||
basis_item_id: Number(selectedBasisItemId),
|
||||
target_level: targetLevel,
|
||||
ai_model: aiModel
|
||||
})
|
||||
return res.data as GeneratedPreviewResponse
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
const candidate: PreviewCandidate = {
|
||||
client_id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
stem: data.stem,
|
||||
options: data.options,
|
||||
correct: data.correct,
|
||||
explanation: data.explanation,
|
||||
ai_model: data.ai_model || aiModel,
|
||||
basis_item_id: data.basis_item_id,
|
||||
target_level: data.target_level || targetLevel,
|
||||
usage: data.usage,
|
||||
}
|
||||
setPreviewCandidates((current) => {
|
||||
const next = [...current, candidate]
|
||||
setPreviewIndex(next.length - 1)
|
||||
return next
|
||||
})
|
||||
setActiveReviewTab('preview')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const generated = previewCandidates[previewIndex]
|
||||
if (!generated) return
|
||||
|
||||
const basisItem = selectedBasisItem
|
||||
if (!basisItem?.slot || !websiteId || !tryoutId) {
|
||||
throw new Error('Basis question metadata is incomplete. Refresh this page and try again.')
|
||||
}
|
||||
|
||||
const res = await api.post('/admin/ai/generate-save', {
|
||||
stem: generated.stem,
|
||||
options: generated.options,
|
||||
correct: generated.correct,
|
||||
explanation: generated.explanation,
|
||||
tryout_id: tryoutId,
|
||||
website_id: websiteId,
|
||||
basis_item_id: Number(selectedBasisItemId),
|
||||
slot: basisItem.slot,
|
||||
level: generated.target_level || targetLevel,
|
||||
variant_status: 'active',
|
||||
ai_model: generated.ai_model || aiModel
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
const activeCandidateId = previewCandidates[previewIndex]?.client_id
|
||||
if (activeCandidateId) {
|
||||
setPreviewCandidates((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.client_id === activeCandidateId
|
||||
? {
|
||||
...candidate,
|
||||
saved_item_id: data?.item_id,
|
||||
variant_status: 'active',
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
)
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'tryout-questions', tryoutId) })
|
||||
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'ai-pending-reviews') })
|
||||
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'ai-runs') })
|
||||
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'ai-variants') })
|
||||
}
|
||||
})
|
||||
|
||||
const batchMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await api.post('/admin/ai/generate-batch', {
|
||||
basis_item_id: Number(selectedBasisItemId),
|
||||
target_level: targetLevel,
|
||||
ai_model: aiModel,
|
||||
count: Number(batchCount),
|
||||
operator_notes: operatorNotes.trim() || null,
|
||||
})
|
||||
return res.data as BatchGenerateResponse
|
||||
},
|
||||
onSuccess: () => {
|
||||
setBatchIndex(0)
|
||||
setActiveReviewTab('batch')
|
||||
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'tryout-questions', tryoutId) })
|
||||
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'ai-pending-reviews') })
|
||||
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'ai-runs') })
|
||||
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'ai-variants') })
|
||||
}
|
||||
})
|
||||
|
||||
const handleGeneratePreview = () => {
|
||||
saveMutation.reset()
|
||||
generateMutation.mutate()
|
||||
}
|
||||
|
||||
const handleGenerateBatch = () => {
|
||||
setBatchIndex(0)
|
||||
setActiveReviewTab('batch')
|
||||
batchMutation.mutate()
|
||||
}
|
||||
|
||||
const activePreviewCandidate = previewCandidates[previewIndex]
|
||||
const hasPreviewCandidates = previewCandidates.length > 0
|
||||
const handlePreviewStep = (direction: -1 | 1) => {
|
||||
saveMutation.reset()
|
||||
setPreviewIndex((current) => {
|
||||
const next = current + direction
|
||||
return Math.min(Math.max(next, 0), Math.max(previewCandidates.length - 1, 0))
|
||||
})
|
||||
}
|
||||
const batchItems = batchMutation.data?.items ?? []
|
||||
const activeBatchItem = batchItems[batchIndex]
|
||||
const questionsPath = tryoutId ? `/admin/tryouts/${tryoutId}/questions` : '/admin/tryouts'
|
||||
|
||||
if (!hasWebsiteScope(websiteId)) {
|
||||
return <div className="text-muted-foreground">Select a website to use the AI workspace.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">AI Question Workspace</h2>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={questionsPath}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
All Questions
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AI Configuration</CardTitle>
|
||||
<CardDescription>Generate variants from the selected original question.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Original Reference</Label>
|
||||
<div className="rounded-md border px-3 py-2 text-sm">
|
||||
{selectedBasisItem ? `Question #${selectedBasisItem.id} · Slot ${selectedBasisItem.slot ?? '-'}` : 'No original selected'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Target Difficulty</Label>
|
||||
<Select value={targetLevel} onValueChange={(v) => setTargetLevel(v as 'mudah' | 'sulit')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mudah">Mudah</SelectItem>
|
||||
<SelectItem value="sulit">Sulit</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>AI Model</Label>
|
||||
<Select value={aiModel} onValueChange={setAiModel}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelsData?.map(m => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span>{m.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{formatPricing(m.pricing)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="batch-count">Batch Count</Label>
|
||||
<Input
|
||||
id="batch-count"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={batchCount}
|
||||
onChange={(event) => setBatchCount(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="operator-notes">Run Notes</Label>
|
||||
<Textarea
|
||||
id="operator-notes"
|
||||
value={operatorNotes}
|
||||
onChange={(event) => setOperatorNotes(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleGeneratePreview}
|
||||
disabled={!selectedBasisItemId || !aiModel || generateMutation.isPending || isBasisUnavailable}
|
||||
>
|
||||
{generateMutation.isPending ? (
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Generate Preview
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleGenerateBatch}
|
||||
disabled={
|
||||
!selectedBasisItemId ||
|
||||
!aiModel ||
|
||||
batchMutation.isPending ||
|
||||
isBasisUnavailable ||
|
||||
Number(batchCount) < 1 ||
|
||||
Number(batchCount) > 10
|
||||
}
|
||||
>
|
||||
{batchMutation.isPending ? (
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Layers className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Generate Trusted Batch
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{isRoutedQuestionError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Basis question failed to load</AlertTitle>
|
||||
<AlertDescription>Check the selected website, tryout, and question ID.</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="h-full min-h-[500px]">
|
||||
<CardHeader>
|
||||
<CardTitle>Preview Workspace</CardTitle>
|
||||
<CardDescription>Compare previews manually, or inspect trusted batch results.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs value={activeReviewTab} onValueChange={(value) => setActiveReviewTab(value as 'original' | 'preview' | 'batch')}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="original">Original</TabsTrigger>
|
||||
<TabsTrigger value="preview">Preview</TabsTrigger>
|
||||
<TabsTrigger value="batch">Batch</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="original" className="pt-4">
|
||||
<QuestionReviewPanel
|
||||
item={selectedBasisItem}
|
||||
isLoading={isBasisLoading}
|
||||
title={selectedBasisItem ? `Original Question #${selectedBasisItem.id}` : 'Original Question'}
|
||||
description={
|
||||
selectedBasisItem
|
||||
? `Slot ${selectedBasisItem.slot ?? '-'} · ${getQuestionLevelLabel(selectedBasisItem)}`
|
||||
: undefined
|
||||
}
|
||||
emptyText="No original question selected."
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="preview" className="pt-4">
|
||||
<div className="space-y-4">
|
||||
{generateMutation.isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Generation Failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
{generateMutation.error instanceof Error ? generateMutation.error.message : 'Unknown error occurred'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{generateMutation.data && !generateMutation.data.success && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Generation Failed</AlertTitle>
|
||||
<AlertDescription>{generateMutation.data.error || 'No variant was generated.'}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{saveMutation.isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Save Failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
{saveMutation.error instanceof Error ? saveMutation.error.message : 'Unknown error occurred'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasPreviewCandidates && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border px-3 py-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handlePreviewStep(-1)}
|
||||
disabled={previewIndex === 0}
|
||||
aria-label="Previous preview variant"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm font-medium">
|
||||
Preview {previewIndex + 1} of {previewCandidates.length}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handlePreviewStep(1)}
|
||||
disabled={previewIndex >= previewCandidates.length - 1}
|
||||
aria-label="Next preview variant"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<QuestionReviewPanel
|
||||
item={activePreviewCandidate}
|
||||
isLoading={generateMutation.isPending}
|
||||
title={
|
||||
activePreviewCandidate?.saved_item_id
|
||||
? `Preview Variant · Saved #${activePreviewCandidate.saved_item_id}`
|
||||
: 'Preview Variant'
|
||||
}
|
||||
description={
|
||||
activePreviewCandidate
|
||||
? `${activePreviewCandidate.target_level || targetLevel} · ${
|
||||
activePreviewCandidate.saved_item_id ? 'active' : 'not saved yet'
|
||||
}`
|
||||
: undefined
|
||||
}
|
||||
emptyText="Generate previews to compare candidates before approving one."
|
||||
/>
|
||||
|
||||
{activePreviewCandidate && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending || Boolean(activePreviewCandidate.saved_item_id)}
|
||||
className="bg-emerald-600 hover:bg-emerald-700"
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : activePreviewCandidate.saved_item_id ? (
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{activePreviewCandidate.saved_item_id ? 'Saved as Active' : 'Approve & Save'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="batch" className="pt-4">
|
||||
<div className="space-y-4">
|
||||
{batchMutation.isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Batch Generation Failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
{batchMutation.error instanceof Error ? batchMutation.error.message : 'Unknown error occurred'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{batchMutation.data && (
|
||||
<Alert variant={batchMutation.data.success ? 'default' : 'destructive'}>
|
||||
<AlertTitle>
|
||||
{batchMutation.data.success ? 'Trusted batch auto-approved' : 'Batch did not save variants'}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{batchMutation.data.success
|
||||
? `Run #${batchMutation.data.run_id} created ${batchMutation.data.generated_count} active variants.${
|
||||
formatUsage(batchMutation.data.usage) ? ` ${formatUsage(batchMutation.data.usage)}.` : ''
|
||||
}`
|
||||
: batchMutation.data.error || 'No variants were created.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{batchItems.length > 0 && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border px-3 py-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setBatchIndex((current) => Math.max(current - 1, 0))}
|
||||
disabled={batchIndex === 0}
|
||||
aria-label="Previous batch variant"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm font-medium">
|
||||
Variant {batchIndex + 1} of {batchItems.length}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setBatchIndex((current) => Math.min(current + 1, batchItems.length - 1))}
|
||||
disabled={batchIndex >= batchItems.length - 1}
|
||||
aria-label="Next batch variant"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<QuestionReviewPanel
|
||||
item={activeBatchItem}
|
||||
isLoading={batchMutation.isPending}
|
||||
title={activeBatchItem ? `Trusted Variant #${activeBatchItem.item_id}` : 'Trusted Batch'}
|
||||
description={activeBatchItem ? `${activeBatchItem.level} · ${activeBatchItem.variant_status}` : undefined}
|
||||
emptyText="Generate a trusted batch to create active variants automatically."
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
91
frontend/src/pages/admin/tryouts/AttemptList.tsx
Normal file
91
frontend/src/pages/admin/tryouts/AttemptList.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
|
||||
import { useAppStore } from '@/store/useAppStore'
|
||||
import type { Attempt } from '@/types'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function AttemptList() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { websiteId } = useAppStore()
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: scopedQueryKey(websiteId, 'tryout-attempts', id),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<{ tryout_id: string; attempts: Attempt[] }>(`/admin/tryouts/${id}/attempts`)
|
||||
return res.data
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId) && Boolean(id),
|
||||
})
|
||||
|
||||
if (!hasWebsiteScope(websiteId)) {
|
||||
return <div className="text-muted-foreground">Select a website to load attempts.</div>
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="space-y-4">
|
||||
{[1, 2, 3].map(i => <Skeleton key={i} className="h-12 w-full" />)}
|
||||
</div>
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div className="text-destructive">Failed to load attempts.</div>
|
||||
}
|
||||
|
||||
const attempts = data?.attempts || []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold">Student Attempts ({attempts.length})</h2>
|
||||
</div>
|
||||
|
||||
{attempts.length === 0 ? (
|
||||
<div className="text-center p-12 border rounded-lg border-dashed text-muted-foreground">
|
||||
No attempts have been made yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User ID</TableHead>
|
||||
<TableHead>Start Time</TableHead>
|
||||
<TableHead className="text-center">Status</TableHead>
|
||||
<TableHead className="text-right">NM</TableHead>
|
||||
<TableHead className="text-right">NN</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{attempts.map((attempt) => (
|
||||
<TableRow key={attempt.id}>
|
||||
<TableCell className="font-medium">{attempt.wp_user_id}</TableCell>
|
||||
<TableCell>{new Date(attempt.start_time).toLocaleString()}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{attempt.is_completed ? (
|
||||
<Badge variant="outline" className="text-emerald-500 border-emerald-500">Completed</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">In Progress</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{attempt.NM !== null ? attempt.NM : '-'}</TableCell>
|
||||
<TableCell className="text-right font-semibold text-primary">{attempt.NN !== null ? attempt.NN : '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
185
frontend/src/pages/admin/tryouts/Normalization.tsx
Normal file
185
frontend/src/pages/admin/tryouts/Normalization.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
|
||||
import { useAppStore } from '@/store/useAppStore'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Save, RefreshCw } from 'lucide-react'
|
||||
|
||||
type TryoutConfig = {
|
||||
normalization_mode: 'static' | 'dynamic' | 'hybrid'
|
||||
static_rataan: number
|
||||
static_sb: number
|
||||
current_stats?: {
|
||||
participant_count: number
|
||||
rataan: number | null
|
||||
sb: number | null
|
||||
last_calculated: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
type NormalizationMode = 'static' | 'dynamic' | 'hybrid'
|
||||
|
||||
export default function Normalization() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const queryClient = useQueryClient()
|
||||
const { websiteId } = useAppStore()
|
||||
const [draft, setDraft] = useState<{
|
||||
key: string
|
||||
rataan?: number
|
||||
sb?: number
|
||||
mode?: NormalizationMode
|
||||
}>({ key: '' })
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null)
|
||||
|
||||
const { data: config, isLoading, isError, error } = useQuery({
|
||||
queryKey: scopedQueryKey(websiteId, 'tryout-config', id),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<TryoutConfig>(`/tryout/${id}/config`)
|
||||
return res.data
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId) && Boolean(id),
|
||||
})
|
||||
|
||||
const draftKey = `${websiteId ?? 'none'}:${id ?? ''}:${config?.normalization_mode ?? ''}:${config?.static_rataan ?? ''}:${config?.static_sb ?? ''}`
|
||||
const rataan = draft.key === draftKey && draft.rataan !== undefined ? draft.rataan : config?.static_rataan ?? 500
|
||||
const sb = draft.key === draftKey && draft.sb !== undefined ? draft.sb : config?.static_sb ?? 100
|
||||
const mode = draft.key === draftKey && draft.mode ? draft.mode : config?.normalization_mode ?? 'static'
|
||||
const updateDraft = (changes: Partial<Omit<typeof draft, 'key'>>) => {
|
||||
setDraft({ key: draftKey, rataan, sb, mode, ...changes })
|
||||
}
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return await api.put(`/tryout/${id}/normalization`, {
|
||||
normalization_mode: mode,
|
||||
static_rataan: rataan,
|
||||
static_sb: sb,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
setStatusMessage('Normalization settings saved.')
|
||||
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'tryout-config', id) })
|
||||
},
|
||||
})
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return await api.post(`/admin/${id}/reset-normalization`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
setStatusMessage('Normalization stats reset to static values.')
|
||||
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'tryout-config', id) })
|
||||
},
|
||||
})
|
||||
|
||||
if (!hasWebsiteScope(websiteId)) {
|
||||
return <div className="text-muted-foreground">Select a website to configure normalization.</div>
|
||||
}
|
||||
|
||||
if (isLoading) return <Skeleton className="h-[400px] w-full" />
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to load normalization settings</AlertTitle>
|
||||
<AlertDescription>
|
||||
{error instanceof Error ? error.message : 'Please check your connection and selected website.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{statusMessage && (
|
||||
<Alert>
|
||||
<AlertTitle>Saved</AlertTitle>
|
||||
<AlertDescription>{statusMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{(saveMutation.isError || resetMutation.isError) && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Update failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
{(saveMutation.error instanceof Error && saveMutation.error.message) ||
|
||||
(resetMutation.error instanceof Error && resetMutation.error.message) ||
|
||||
'The backend rejected the request.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Normalization Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Configure the normalization parameters for Tryout {id}.
|
||||
The formula used is: NN = Rataan + SB × ((NM - Mean) / StdDev)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>Mode</Label>
|
||||
<Select value={mode} onValueChange={(value) => updateDraft({ mode: value as NormalizationMode })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">Static (Fixed values)</SelectItem>
|
||||
<SelectItem value="dynamic">Dynamic (Calculate from Data)</SelectItem>
|
||||
<SelectItem value="hybrid">Hybrid</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Target Mean (Rataan)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={rataan}
|
||||
onChange={e => updateDraft({ rataan: parseFloat(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Target Standard Deviation (SB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={sb}
|
||||
onChange={e => updateDraft({ sb: parseFloat(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 pt-4 border-t">
|
||||
<Button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{saveMutation.isPending ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (window.confirm('Reset normalization stats for this tryout?')) {
|
||||
resetMutation.mutate()
|
||||
}
|
||||
}}
|
||||
disabled={resetMutation.isPending}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{resetMutation.isPending ? 'Resetting...' : 'Reset Stats'}
|
||||
</Button>
|
||||
</div>
|
||||
{config?.current_stats && (
|
||||
<div className="rounded-md border bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||
Current participants: {config.current_stats.participant_count}. Dynamic rataan:{' '}
|
||||
{config.current_stats.rataan ?? '-'}; dynamic SB: {config.current_stats.sb ?? '-'}.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
312
frontend/src/pages/admin/tryouts/QuestionManagement.tsx
Normal file
312
frontend/src/pages/admin/tryouts/QuestionManagement.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate, useParams, Link } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { SafeHtml } from '@/components/SafeHtml'
|
||||
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
|
||||
import { useAppStore } from '@/store/useAppStore'
|
||||
import type { Question, SnapshotQuestion } from '@/types'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { CheckCircle2, Eye, Sparkles } from 'lucide-react'
|
||||
|
||||
export default function QuestionManagement() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { websiteId } = useAppStore()
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedSnapshotQuestionIds, setSelectedSnapshotQuestionIds] = useState<number[]>([])
|
||||
const [readQuestion, setReadQuestion] = useState<SnapshotQuestion | null>(null)
|
||||
|
||||
const queryKey = scopedQueryKey(websiteId, 'tryout-questions', id)
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const res = await api.get<{ tryout_id: string; items: Question[]; snapshot_questions: SnapshotQuestion[] }>(`/admin/tryouts/${id}/questions`)
|
||||
return res.data
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId) && Boolean(id),
|
||||
})
|
||||
|
||||
const snapshotQuestions = useMemo(() => data?.snapshot_questions || [], [data?.snapshot_questions])
|
||||
const selectedSnapshotQuestions = useMemo(
|
||||
() => snapshotQuestions.filter((question) => selectedSnapshotQuestionIds.includes(question.id)),
|
||||
[selectedSnapshotQuestionIds, snapshotQuestions]
|
||||
)
|
||||
|
||||
const promoteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const groups = selectedSnapshotQuestions.reduce<Record<number, number[]>>((acc, question) => {
|
||||
if (!question.latest_snapshot_id || question.promoted_item) return acc
|
||||
acc[question.latest_snapshot_id] = acc[question.latest_snapshot_id] || []
|
||||
acc[question.latest_snapshot_id].push(question.id)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const entries = Object.entries(groups)
|
||||
if (entries.length === 0) {
|
||||
throw new Error('Select at least one unpromoted snapshot question.')
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
entries.map(([snapshotId, ids]) =>
|
||||
api.post(`/admin/snapshots/${snapshotId}/promote`, {
|
||||
snapshot_question_ids: ids,
|
||||
})
|
||||
)
|
||||
)
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSelectedSnapshotQuestionIds([])
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'admin-questions') })
|
||||
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'data-overview') })
|
||||
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'admin-templates') })
|
||||
},
|
||||
})
|
||||
|
||||
if (!hasWebsiteScope(websiteId)) {
|
||||
return <div className="text-muted-foreground">Select a website to load questions.</div>
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="space-y-4">
|
||||
{[1, 2, 3].map(i => <Skeleton key={i} className="h-12 w-full" />)}
|
||||
</div>
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div className="text-destructive">Failed to load questions.</div>
|
||||
}
|
||||
|
||||
const items = data?.items || []
|
||||
const promotableSnapshotQuestions = snapshotQuestions.filter((question) => !question.promoted_item && question.latest_snapshot_id)
|
||||
const allPromotableSelected =
|
||||
promotableSnapshotQuestions.length > 0 &&
|
||||
promotableSnapshotQuestions.every((question) => selectedSnapshotQuestionIds.includes(question.id))
|
||||
|
||||
const toggleSnapshotQuestion = (questionId: number, checked: boolean) => {
|
||||
setSelectedSnapshotQuestionIds((current) =>
|
||||
checked ? [...new Set([...current, questionId])] : current.filter((id) => id !== questionId)
|
||||
)
|
||||
}
|
||||
|
||||
const toggleAllPromotable = (checked: boolean) => {
|
||||
setSelectedSnapshotQuestionIds(checked ? promotableSnapshotQuestions.map((question) => question.id) : [])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold">Questions ({items.length || snapshotQuestions.length})</h2>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
snapshotQuestions.length === 0 ? (
|
||||
<div className="text-center p-12 border rounded-lg border-dashed text-muted-foreground">
|
||||
No questions found for this tryout.
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<div className="rounded-md border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Item ID</TableHead>
|
||||
<TableHead className="w-[40%]">Question Preview</TableHead>
|
||||
<TableHead className="text-right">P-Value</TableHead>
|
||||
<TableHead className="text-right">IRT b</TableHead>
|
||||
<TableHead className="text-center">Status</TableHead>
|
||||
<TableHead className="text-right">AI</TableHead>
|
||||
<TableHead className="text-right">Detail</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium">{item.item_id}</TableCell>
|
||||
<TableCell>
|
||||
<SafeHtml html={item.stem_text} className="line-clamp-2 text-sm text-muted-foreground" />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{item.p_value !== null ? item.p_value.toFixed(3) : '-'}</TableCell>
|
||||
<TableCell className="text-right">{item.irt_b !== null ? item.irt_b.toFixed(3) : '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.calibrated ? (
|
||||
<Badge variant="default" className="bg-emerald-500 hover:bg-emerald-600">Calibrated</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Draft</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{item.level === 'sedang' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/admin/tryouts/${id}/questions/${item.id}/ai-workspace`)}
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Generate
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={`/admin/questions/${item.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snapshotQuestions.length > 0 && (
|
||||
<div className="space-y-4 pt-8 border-t">
|
||||
<div className="flex flex-wrap justify-between items-center gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-muted-foreground">Imported Snapshot Questions ({snapshotQuestions.length})</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{promotableSnapshotQuestions.length} unpromoted questions can become live medium-level basis items.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => promoteMutation.mutate()}
|
||||
disabled={selectedSnapshotQuestions.length === 0 || promoteMutation.isPending}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{promoteMutation.isPending ? 'Promoting...' : `Promote Selected (${selectedSnapshotQuestions.length})`}
|
||||
</Button>
|
||||
</div>
|
||||
{promoteMutation.isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Promotion failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
{promoteMutation.error instanceof Error ? promoteMutation.error.message : 'Could not promote selected questions.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="rounded-md border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Select all promotable snapshot questions"
|
||||
checked={allPromotableSelected}
|
||||
onChange={(event) => toggleAllPromotable(event.target.checked)}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Source ID</TableHead>
|
||||
<TableHead className="w-[56%]">Question</TableHead>
|
||||
<TableHead className="text-center">Answer</TableHead>
|
||||
<TableHead className="text-center">Status</TableHead>
|
||||
<TableHead className="text-right">Read</TableHead>
|
||||
<TableHead className="text-right">Live Item</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{snapshotQuestions.map((sq) => (
|
||||
<TableRow key={sq.id}>
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Select snapshot question ${sq.source_question_id}`}
|
||||
checked={selectedSnapshotQuestionIds.includes(sq.id)}
|
||||
disabled={Boolean(sq.promoted_item) || !sq.latest_snapshot_id || promoteMutation.isPending}
|
||||
onChange={(event) => toggleSnapshotQuestion(sq.id, event.target.checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-xs text-muted-foreground">{sq.source_question_id}</TableCell>
|
||||
<TableCell>
|
||||
<SafeHtml html={sq.question_html} className="max-w-none text-sm leading-relaxed" />
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-mono">{sq.correct_answer}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{sq.promoted_item ? (
|
||||
<Badge variant="default">Promoted</Badge>
|
||||
) : sq.is_active ? (
|
||||
<Badge variant="outline" className="text-emerald-600 border-emerald-600">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-muted-foreground">Inactive</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="outline" size="sm" onClick={() => setReadQuestion(sq)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Read
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{sq.promoted_item ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={`/admin/questions/${sq.promoted_item.id}`}>Open #{sq.promoted_item.id}</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Dialog open={Boolean(readQuestion)} onOpenChange={(open) => !open && setReadQuestion(null)}>
|
||||
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Question {readQuestion?.source_question_id}</DialogTitle>
|
||||
<DialogDescription>Original imported question text.</DialogDescription>
|
||||
</DialogHeader>
|
||||
{readQuestion && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border p-4">
|
||||
<SafeHtml html={readQuestion.question_html} className="max-w-none leading-relaxed" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="text-sm text-muted-foreground">Correct Answer</div>
|
||||
<div className="mt-1 font-mono text-lg font-semibold">{readQuestion.correct_answer}</div>
|
||||
</div>
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="text-sm text-muted-foreground">Source ID</div>
|
||||
<div className="mt-1 font-mono text-sm">{readQuestion.source_question_id}</div>
|
||||
</div>
|
||||
</div>
|
||||
{readQuestion.explanation_html && (
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="mb-2 font-medium">Explanation</div>
|
||||
<SafeHtml html={readQuestion.explanation_html} className="max-w-none leading-relaxed" allowEmbeds />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
frontend/src/pages/admin/tryouts/TryoutLayout.tsx
Normal file
49
frontend/src/pages/admin/tryouts/TryoutLayout.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Link, Outlet, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
|
||||
export default function TryoutLayout() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
let currentTab = 'questions'
|
||||
if (location.pathname.includes('/questions')) currentTab = 'questions'
|
||||
if (location.pathname.includes('/attempts')) currentTab = 'attempts'
|
||||
if (location.pathname.includes('/normalization')) currentTab = 'normalization'
|
||||
if (location.pathname.includes('/settings')) currentTab = 'settings'
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
navigate(`/admin/tryouts/${id}/${value}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline" size="icon" asChild>
|
||||
<Link to="/admin/dashboard">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Tryout Workspace</h1>
|
||||
<p className="text-muted-foreground mt-1">ID: {id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={currentTab} onValueChange={handleTabChange} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4 md:w-[560px]">
|
||||
<TabsTrigger value="questions">Questions</TabsTrigger>
|
||||
<TabsTrigger value="attempts">Attempts</TabsTrigger>
|
||||
<TabsTrigger value="normalization">Normalization</TabsTrigger>
|
||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-6 bg-card border rounded-xl p-6 shadow-sm min-h-[500px]">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
291
frontend/src/pages/admin/tryouts/TryoutSettings.tsx
Normal file
291
frontend/src/pages/admin/tryouts/TryoutSettings.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
|
||||
import { useAppStore } from '@/store/useAppStore'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Percent, Save } from 'lucide-react'
|
||||
|
||||
type TryoutConfig = {
|
||||
tryout_id: string
|
||||
name: string
|
||||
description: string | null
|
||||
scoring_mode: string
|
||||
selection_mode: string
|
||||
normalization_mode: string
|
||||
static_rataan: number
|
||||
static_sb: number
|
||||
min_sample_for_dynamic: number
|
||||
ai_generation_enabled: boolean
|
||||
min_calibration_sample: number
|
||||
theta_estimation_method: string
|
||||
fallback_to_ctt_on_error: boolean
|
||||
}
|
||||
|
||||
type TryoutConfigForm = {
|
||||
name: string
|
||||
description: string
|
||||
scoring_mode: string
|
||||
selection_mode: string
|
||||
normalization_mode: string
|
||||
static_rataan: string
|
||||
static_sb: string
|
||||
min_sample_for_dynamic: string
|
||||
ai_generation_enabled: boolean
|
||||
min_calibration_sample: string
|
||||
theta_estimation_method: string
|
||||
fallback_to_ctt_on_error: boolean
|
||||
}
|
||||
|
||||
function configToForm(config: TryoutConfig): TryoutConfigForm {
|
||||
return {
|
||||
name: config.name || '',
|
||||
description: config.description || '',
|
||||
scoring_mode: config.scoring_mode,
|
||||
selection_mode: config.selection_mode,
|
||||
normalization_mode: config.normalization_mode,
|
||||
static_rataan: String(config.static_rataan),
|
||||
static_sb: String(config.static_sb),
|
||||
min_sample_for_dynamic: String(config.min_sample_for_dynamic),
|
||||
ai_generation_enabled: config.ai_generation_enabled,
|
||||
min_calibration_sample: String(config.min_calibration_sample),
|
||||
theta_estimation_method: config.theta_estimation_method,
|
||||
fallback_to_ctt_on_error: config.fallback_to_ctt_on_error,
|
||||
}
|
||||
}
|
||||
|
||||
export default function TryoutSettings() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { websiteId } = useAppStore()
|
||||
const queryClient = useQueryClient()
|
||||
const [draft, setDraft] = useState<TryoutConfigForm | null>(null)
|
||||
const queryKey = scopedQueryKey(websiteId, 'tryout-config', id)
|
||||
const { data: config, isLoading, isError } = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const res = await api.get<TryoutConfig>(`/tryout/${id}/config`)
|
||||
return res.data
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId) && Boolean(id),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const activeForm = draft || (config ? configToForm(config) : null)
|
||||
if (!activeForm) throw new Error('Settings form is not ready.')
|
||||
const payload = {
|
||||
name: activeForm.name.trim(),
|
||||
description: activeForm.description.trim() || null,
|
||||
scoring_mode: activeForm.scoring_mode,
|
||||
selection_mode: activeForm.selection_mode,
|
||||
normalization_mode: activeForm.normalization_mode,
|
||||
static_rataan: Number(activeForm.static_rataan),
|
||||
static_sb: Number(activeForm.static_sb),
|
||||
min_sample_for_dynamic: Number(activeForm.min_sample_for_dynamic),
|
||||
ai_generation_enabled: activeForm.ai_generation_enabled,
|
||||
min_calibration_sample: Number(activeForm.min_calibration_sample),
|
||||
theta_estimation_method: activeForm.theta_estimation_method,
|
||||
fallback_to_ctt_on_error: activeForm.fallback_to_ctt_on_error,
|
||||
}
|
||||
const res = await api.put<TryoutConfig>(`/tryout/${id}/config`, payload)
|
||||
return res.data
|
||||
},
|
||||
onSuccess: (updatedConfig) => {
|
||||
setDraft(configToForm(updatedConfig))
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
queryClient.invalidateQueries({ queryKey: scopedQueryKey(websiteId, 'tryouts') })
|
||||
},
|
||||
})
|
||||
|
||||
if (!hasWebsiteScope(websiteId)) {
|
||||
return <div className="text-muted-foreground">Select a website to load tryout settings.</div>
|
||||
}
|
||||
|
||||
if (isLoading) return <Skeleton className="h-[360px] w-full" />
|
||||
|
||||
if (isError || !config) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to load tryout settings</AlertTitle>
|
||||
<AlertDescription>Check the selected website and tryout ID.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
const form = draft || configToForm(config)
|
||||
|
||||
const updateField = <K extends keyof TryoutConfigForm>(field: K, value: TryoutConfigForm[K]) => {
|
||||
setDraft((current) => ({ ...(current || configToForm(config)), [field]: value }))
|
||||
}
|
||||
|
||||
const canSave =
|
||||
form.name.trim().length > 0 &&
|
||||
Number(form.static_rataan) >= 0 &&
|
||||
Number(form.static_sb) > 0 &&
|
||||
Number(form.min_sample_for_dynamic) > 0 &&
|
||||
Number(form.min_calibration_sample) > 0 &&
|
||||
!updateMutation.isPending
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>{config.name}</CardTitle>
|
||||
<CardDescription>{config.description || `Configuration for tryout ${config.tryout_id}`}</CardDescription>
|
||||
</div>
|
||||
<Badge variant={config.ai_generation_enabled ? 'default' : 'secondary'}>
|
||||
AI {config.ai_generation_enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{updateMutation.isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Update failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
{updateMutation.error instanceof Error ? updateMutation.error.message : 'Could not save settings.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{updateMutation.isSuccess && (
|
||||
<Alert>
|
||||
<AlertTitle>Settings saved</AlertTitle>
|
||||
<AlertDescription>Tryout configuration has been updated.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tryout-name">Name</Label>
|
||||
<Input id="tryout-name" value={form.name} onChange={(event) => updateField('name', event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Scoring Mode</Label>
|
||||
<Select value={form.scoring_mode} onValueChange={(value) => updateField('scoring_mode', value)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ctt">CTT</SelectItem>
|
||||
<SelectItem value="irt">IRT</SelectItem>
|
||||
<SelectItem value="hybrid">Hybrid</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="tryout-description">Description</Label>
|
||||
<Textarea
|
||||
id="tryout-description"
|
||||
value={form.description}
|
||||
onChange={(event) => updateField('description', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Selection Mode</Label>
|
||||
<Select value={form.selection_mode} onValueChange={(value) => updateField('selection_mode', value)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fixed">Fixed</SelectItem>
|
||||
<SelectItem value="adaptive">Adaptive</SelectItem>
|
||||
<SelectItem value="hybrid">Hybrid</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Normalization Mode</Label>
|
||||
<Select value={form.normalization_mode} onValueChange={(value) => updateField('normalization_mode', value)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">Static</SelectItem>
|
||||
<SelectItem value="dynamic">Dynamic</SelectItem>
|
||||
<SelectItem value="hybrid">Hybrid</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="static-rataan">Static Rataan</Label>
|
||||
<Input
|
||||
id="static-rataan"
|
||||
type="number"
|
||||
value={form.static_rataan}
|
||||
onChange={(event) => updateField('static_rataan', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="static-sb">Static SB</Label>
|
||||
<Input
|
||||
id="static-sb"
|
||||
type="number"
|
||||
value={form.static_sb}
|
||||
onChange={(event) => updateField('static_sb', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dynamic-minimum">Dynamic Sample Minimum</Label>
|
||||
<Input
|
||||
id="dynamic-minimum"
|
||||
type="number"
|
||||
value={form.min_sample_for_dynamic}
|
||||
onChange={(event) => updateField('min_sample_for_dynamic', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="calibration-minimum">Calibration Sample Minimum</Label>
|
||||
<Input
|
||||
id="calibration-minimum"
|
||||
type="number"
|
||||
value={form.min_calibration_sample}
|
||||
onChange={(event) => updateField('min_calibration_sample', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Theta Method</Label>
|
||||
<Select value={form.theta_estimation_method} onValueChange={(value) => updateField('theta_estimation_method', value)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mle">MLE</SelectItem>
|
||||
<SelectItem value="map">MAP</SelectItem>
|
||||
<SelectItem value="eap">EAP</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 rounded-md border p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.ai_generation_enabled}
|
||||
onChange={(event) => updateField('ai_generation_enabled', event.target.checked)}
|
||||
/>
|
||||
<span className="text-sm font-medium">AI generation enabled</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 rounded-md border p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.fallback_to_ctt_on_error}
|
||||
onChange={(event) => updateField('fallback_to_ctt_on_error', event.target.checked)}
|
||||
/>
|
||||
<span className="text-sm font-medium">Fallback to CTT on IRT errors</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => updateMutation.mutate()} disabled={!canSave}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/admin/tryouts/${id}/normalization`}>
|
||||
<Percent className="mr-2 h-4 w-4" />
|
||||
Edit Normalization
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
109
frontend/src/pages/auth/Login.tsx
Normal file
109
frontend/src/pages/auth/Login.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react'
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { LogIn, AlertCircle } from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
import { useAppStore } from '@/store/useAppStore'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const { token, setToken } = useAppStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// If already logged in, redirect to dashboard
|
||||
if (token) {
|
||||
return <Navigate to="/admin/dashboard" replace />
|
||||
}
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const response = await api.post('/auth/admin-login', {
|
||||
username,
|
||||
password,
|
||||
})
|
||||
|
||||
const { access_token } = response.data
|
||||
setToken(access_token)
|
||||
navigate('/admin/dashboard', { replace: true })
|
||||
} catch (err: unknown) {
|
||||
if (axios.isAxiosError(err) && err.response) {
|
||||
setError(err.response.data?.detail || 'Invalid username or password')
|
||||
} else {
|
||||
setError('Network error. Please make sure the backend is running.')
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full flex-col items-center justify-center bg-zinc-50 p-4">
|
||||
<div className="w-full max-w-[400px] overflow-hidden rounded-2xl border bg-white shadow-xl">
|
||||
<div className="flex flex-col items-center justify-center bg-zinc-900 p-8 text-center">
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-yellow-500 text-zinc-900 shadow-md">
|
||||
<LogIn className="h-8 w-8" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white">IRT System</h1>
|
||||
<p className="text-sm text-zinc-400">Admin Control Center</p>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<form onSubmit={handleLogin} className="space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive" className="bg-red-50 text-red-600 border-red-200">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" className="text-sm font-medium text-zinc-700">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="admin"
|
||||
required
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-sm font-medium text-zinc-700">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-11 w-full bg-zinc-900 hover:bg-zinc-800 text-white shadow-md transition-all"
|
||||
disabled={isLoading || !username || !password}
|
||||
>
|
||||
{isLoading ? 'Authenticating...' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
frontend/src/pages/student/StudentLayout.tsx
Normal file
27
frontend/src/pages/student/StudentLayout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Link, Outlet } from 'react-router-dom'
|
||||
import { WebsiteSelector } from '@/components/WebsiteSelector'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export default function StudentLayout() {
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/20">
|
||||
<header className="border-b bg-background">
|
||||
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-6">
|
||||
<div>
|
||||
<Link to="/student/tryouts" className="text-lg font-bold">Student Tryout</Link>
|
||||
<p className="text-xs text-muted-foreground">Session practice portal</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<WebsiteSelector />
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/admin/dashboard">Admin</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-6xl px-6 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
116
frontend/src/pages/student/StudentResult.tsx
Normal file
116
frontend/src/pages/student/StudentResult.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
|
||||
import { useAppStore } from '@/store/useAppStore'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
type SessionResponse = {
|
||||
session_id: string
|
||||
wp_user_id: string
|
||||
tryout_id: string
|
||||
start_time: string
|
||||
end_time: string | null
|
||||
is_completed: boolean
|
||||
total_benar: number
|
||||
total_bobot_earned: number
|
||||
NM: number | null
|
||||
NN: number | null
|
||||
theta: number | null
|
||||
theta_se: number | null
|
||||
}
|
||||
|
||||
function ResultStat({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StudentResult() {
|
||||
const { sessionId } = useParams<{ sessionId: string }>()
|
||||
const { websiteId } = useAppStore()
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: scopedQueryKey(websiteId, 'student-result', sessionId),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<SessionResponse>(`/session/${sessionId}`)
|
||||
return res.data
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId) && Boolean(sessionId),
|
||||
})
|
||||
|
||||
if (!hasWebsiteScope(websiteId)) {
|
||||
return <div className="text-muted-foreground">Select a website to load the result.</div>
|
||||
}
|
||||
|
||||
if (isLoading) return <Skeleton className="h-[360px] w-full" />
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Result failed to load</AlertTitle>
|
||||
<AlertDescription>Check the active website and session ID.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Result Summary</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{data.tryout_id} · Student {data.wp_user_id} · {data.is_completed ? 'Completed' : 'In progress'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!data.is_completed && (
|
||||
<Button asChild>
|
||||
<Link to={`/student/session/${data.session_id}`}>Resume Session</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/student/tryouts">Tryouts</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<ResultStat label="Nilai Nasional" value={data.NN ?? '-'} />
|
||||
<ResultStat label="Nilai Mentah" value={data.NM ?? '-'} />
|
||||
<ResultStat label="Correct Answers" value={data.total_benar} />
|
||||
<ResultStat label="Theta" value={data.theta?.toFixed(3) ?? '-'} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="grid gap-4 pt-6 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Started</div>
|
||||
<div className="font-medium">{new Date(data.start_time).toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Ended</div>
|
||||
<div className="font-medium">{data.end_time ? new Date(data.end_time).toLocaleString() : '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Total Bobot</div>
|
||||
<div className="font-medium">{data.total_bobot_earned.toFixed(3)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Theta SE</div>
|
||||
<div className="font-medium">{data.theta_se?.toFixed(3) ?? '-'}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
259
frontend/src/pages/student/StudentSession.tsx
Normal file
259
frontend/src/pages/student/StudentSession.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
|
||||
import { useAppStore } from '@/store/useAppStore'
|
||||
import { SafeHtml } from '@/components/SafeHtml'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { CheckCircle2, Send } from 'lucide-react'
|
||||
|
||||
const ACTIVE_SESSION_KEY = 'irt-student-active-session'
|
||||
|
||||
type SessionResponse = {
|
||||
session_id: string
|
||||
wp_user_id: string
|
||||
website_id: number
|
||||
tryout_id: string
|
||||
start_time: string
|
||||
end_time: string | null
|
||||
expires_at: string | null
|
||||
is_completed: boolean
|
||||
total_benar: number
|
||||
NM: number | null
|
||||
NN: number | null
|
||||
}
|
||||
|
||||
type NextItemResponse = {
|
||||
status: 'item' | 'completed'
|
||||
item_id?: number
|
||||
stem?: string
|
||||
options?: Record<string, string>
|
||||
slot?: number
|
||||
level?: string
|
||||
display_level?: string
|
||||
generated_by?: string
|
||||
source_snapshot_question_id?: number | null
|
||||
reason?: string
|
||||
items_answered?: number
|
||||
}
|
||||
|
||||
function formatRemaining(seconds: number | null) {
|
||||
if (seconds === null) return '-'
|
||||
const clamped = Math.max(0, seconds)
|
||||
const minutes = Math.floor(clamped / 60)
|
||||
const rest = clamped % 60
|
||||
return `${minutes}:${String(rest).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : 'Request failed.'
|
||||
}
|
||||
|
||||
export default function StudentSession() {
|
||||
const { sessionId } = useParams<{ sessionId: string }>()
|
||||
const { websiteId } = useAppStore()
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const [selectedResponse, setSelectedResponse] = useState('')
|
||||
const [now, setNow] = useState(0)
|
||||
const itemStartedAtRef = useRef(0)
|
||||
|
||||
const sessionKey = scopedQueryKey(websiteId, 'student-session', sessionId)
|
||||
const nextItemKey = scopedQueryKey(websiteId, 'student-next-item', sessionId)
|
||||
|
||||
const sessionQuery = useQuery({
|
||||
queryKey: sessionKey,
|
||||
queryFn: async () => {
|
||||
const res = await api.get<SessionResponse>(`/session/${sessionId}`)
|
||||
return res.data
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId) && Boolean(sessionId),
|
||||
})
|
||||
|
||||
const nextItemQuery = useQuery({
|
||||
queryKey: nextItemKey,
|
||||
queryFn: async () => {
|
||||
const res = await api.get<NextItemResponse>(`/session/${sessionId}/next_item`)
|
||||
return res.data
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId) && Boolean(sessionId) && !sessionQuery.data?.is_completed,
|
||||
})
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!nextItemQuery.data?.item_id) throw new Error('No active item.')
|
||||
const startedAt = itemStartedAtRef.current || Date.now()
|
||||
const timeSpent = Math.max(0, Math.round((Date.now() - startedAt) / 1000))
|
||||
await api.post(`/session/${sessionId}/submit_answer`, {
|
||||
item_id: nextItemQuery.data.item_id,
|
||||
response: selectedResponse,
|
||||
time_spent: timeSpent,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSelectedResponse('')
|
||||
itemStartedAtRef.current = Date.now()
|
||||
queryClient.invalidateQueries({ queryKey: nextItemKey })
|
||||
queryClient.invalidateQueries({ queryKey: sessionKey })
|
||||
},
|
||||
})
|
||||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await api.post(`/session/${sessionId}/complete`, {
|
||||
end_time: new Date().toISOString(),
|
||||
user_answers: [],
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
localStorage.removeItem(ACTIVE_SESSION_KEY)
|
||||
queryClient.invalidateQueries({ queryKey: sessionKey })
|
||||
navigate(`/student/result/${sessionId}`)
|
||||
},
|
||||
})
|
||||
|
||||
const expiresAt = sessionQuery.data?.expires_at
|
||||
useEffect(() => {
|
||||
if (!expiresAt) {
|
||||
return
|
||||
}
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [expiresAt])
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionId && !sessionQuery.data?.is_completed) {
|
||||
localStorage.setItem(ACTIVE_SESSION_KEY, sessionId)
|
||||
}
|
||||
}, [sessionId, sessionQuery.data?.is_completed])
|
||||
|
||||
useEffect(() => {
|
||||
if (nextItemQuery.data?.item_id) {
|
||||
itemStartedAtRef.current = Date.now()
|
||||
}
|
||||
}, [nextItemQuery.data?.item_id])
|
||||
|
||||
const options = useMemo(() => Object.entries(nextItemQuery.data?.options || {}), [nextItemQuery.data?.options])
|
||||
const remainingSeconds = useMemo(() => {
|
||||
if (!expiresAt || !now) return null
|
||||
return Math.ceil((new Date(expiresAt).getTime() - now) / 1000)
|
||||
}, [expiresAt, now])
|
||||
|
||||
if (!hasWebsiteScope(websiteId)) {
|
||||
return <div className="text-muted-foreground">Select a website to load the session.</div>
|
||||
}
|
||||
|
||||
if (sessionQuery.isLoading || nextItemQuery.isLoading) {
|
||||
return <Skeleton className="h-[520px] w-full" />
|
||||
}
|
||||
|
||||
if (sessionQuery.isError || !sessionQuery.data) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Session failed to load</AlertTitle>
|
||||
<AlertDescription>Check the active website and session ID.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
if (sessionQuery.data.is_completed) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Session Completed</CardTitle>
|
||||
<CardDescription>{sessionQuery.data.session_id}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={() => navigate(`/student/result/${sessionId}`)}>View Result</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const nextItem = nextItemQuery.data
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Tryout Session</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{sessionQuery.data.tryout_id} · Student {sessionQuery.data.wp_user_id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="secondary">Answered {nextItem?.items_answered ?? 0}</Badge>
|
||||
<Badge variant={remainingSeconds !== null && remainingSeconds < 300 ? 'destructive' : 'outline'}>
|
||||
{formatRemaining(remainingSeconds)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(submitMutation.isError || completeMutation.isError || nextItemQuery.isError) && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Session action failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
{getErrorMessage(submitMutation.error || completeMutation.error || nextItemQuery.error)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{nextItem?.status === 'completed' ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ready to Complete</CardTitle>
|
||||
<CardDescription>{nextItem.reason || 'No more questions are available for this session.'}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={() => completeMutation.mutate()} disabled={completeMutation.isPending}>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{completeMutation.isPending ? 'Completing...' : 'Complete Session'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Question {nextItem?.slot ?? '-'}</CardTitle>
|
||||
<CardDescription className="capitalize">{nextItem?.display_level || nextItem?.level || 'item'}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="rounded-md border p-4">
|
||||
<SafeHtml html={nextItem?.stem || ''} />
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{options.map(([key, value]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setSelectedResponse(key)}
|
||||
className={`rounded-md border p-4 text-left transition-colors ${
|
||||
selectedResponse === key ? 'border-primary bg-primary/5' : 'hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono font-semibold">{key}.</span>{' '}
|
||||
<SafeHtml html={value} className="inline" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => submitMutation.mutate()}
|
||||
disabled={!selectedResponse || submitMutation.isPending}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{submitMutation.isPending ? 'Submitting...' : 'Submit Answer'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
147
frontend/src/pages/student/StudentTryouts.tsx
Normal file
147
frontend/src/pages/student/StudentTryouts.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys'
|
||||
import { useAppStore } from '@/store/useAppStore'
|
||||
import type { Tryout } from '@/types'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Play, RotateCcw } from 'lucide-react'
|
||||
|
||||
const STUDENT_USER_KEY = 'irt-student-wp-user-id'
|
||||
const ACTIVE_SESSION_KEY = 'irt-student-active-session'
|
||||
|
||||
type SessionResponse = {
|
||||
session_id: string
|
||||
wp_user_id: string
|
||||
website_id: number
|
||||
tryout_id: string
|
||||
expires_at: string | null
|
||||
is_completed: boolean
|
||||
}
|
||||
|
||||
function getTryoutLabel(tryout: Tryout) {
|
||||
return tryout.name || tryout.title || `Tryout ${tryout.tryout_id}`
|
||||
}
|
||||
|
||||
export default function StudentTryouts() {
|
||||
const navigate = useNavigate()
|
||||
const { websiteId } = useAppStore()
|
||||
const [wpUserId, setWpUserId] = useState(() => localStorage.getItem(STUDENT_USER_KEY) || 'demo-student')
|
||||
const activeSession = localStorage.getItem(ACTIVE_SESSION_KEY)
|
||||
|
||||
const { data: tryouts, isLoading, isError } = useQuery({
|
||||
queryKey: scopedQueryKey(websiteId, 'student-tryouts'),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<Tryout[]>('/tryout/')
|
||||
return res.data
|
||||
},
|
||||
enabled: hasWebsiteScope(websiteId),
|
||||
})
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: async (tryout: Tryout) => {
|
||||
if (!websiteId) throw new Error('Select a website first.')
|
||||
const sessionId = `student-${websiteId}-${tryout.tryout_id}-${Date.now()}`
|
||||
const res = await api.post<SessionResponse>('/session/', {
|
||||
session_id: sessionId,
|
||||
wp_user_id: wpUserId.trim(),
|
||||
website_id: websiteId,
|
||||
tryout_id: tryout.tryout_id,
|
||||
scoring_mode: tryout.scoring_mode || 'ctt',
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
onSuccess: (session) => {
|
||||
localStorage.setItem(STUDENT_USER_KEY, wpUserId.trim())
|
||||
localStorage.setItem(ACTIVE_SESSION_KEY, session.session_id)
|
||||
navigate(`/student/session/${session.session_id}`)
|
||||
},
|
||||
})
|
||||
|
||||
if (!hasWebsiteScope(websiteId)) {
|
||||
return <div className="text-muted-foreground">Select a website to load student tryouts.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Available Tryouts</h1>
|
||||
<p className="text-muted-foreground mt-1">Start or resume a student session using the session APIs.</p>
|
||||
</div>
|
||||
{activeSession && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/student/session/${activeSession}`}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Resume
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Student Identity</CardTitle>
|
||||
<CardDescription>Stored locally for session recovery.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="max-w-sm space-y-2">
|
||||
<Label htmlFor="wp-user-id">WordPress User ID</Label>
|
||||
<Input id="wp-user-id" value={wpUserId} onChange={(event) => setWpUserId(event.target.value)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{startMutation.isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Could not start session</AlertTitle>
|
||||
<AlertDescription>
|
||||
{startMutation.error instanceof Error ? startMutation.error.message : 'Session creation failed.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{[1, 2, 3, 4].map((index) => <Skeleton key={index} className="h-36 w-full" />)}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Tryouts failed to load</AlertTitle>
|
||||
<AlertDescription>Check the selected website and backend API availability.</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(tryouts || []).map((tryout) => (
|
||||
<Card key={tryout.tryout_id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{getTryoutLabel(tryout)}</CardTitle>
|
||||
<CardDescription>
|
||||
{tryout.tryout_id} · {tryout.scoring_mode || 'ctt'} · {tryout.item_count ?? 0} questions
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
onClick={() => startMutation.mutate(tryout)}
|
||||
disabled={!wpUserId.trim() || startMutation.isPending}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Session
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{tryouts?.length === 0 && (
|
||||
<div className="rounded-md border border-dashed p-8 text-center text-muted-foreground md:col-span-2">
|
||||
No tryouts available for this website.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
25
frontend/src/store/useAppStore.ts
Normal file
25
frontend/src/store/useAppStore.ts
Normal 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
|
||||
}
|
||||
)
|
||||
)
|
||||
97
frontend/src/types/index.ts
Normal file
97
frontend/src/types/index.ts
Normal 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
|
||||
}
|
||||
30
frontend/tsconfig.app.json
Normal file
30
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
frontend/tsconfig.node.json
Normal file
24
frontend/tsconfig.node.json
Normal 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
13
frontend/vite.config.ts
Normal 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"),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user