Compare commits
26 Commits
435930a827
...
release-v4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdc5097ce8 | ||
|
|
a0cee77628 | ||
| c9bf12ad8e | |||
|
|
8df6804dcf | ||
|
|
b4b5868223 | ||
|
|
c2a07a6e5c | ||
|
|
01a32e7785 | ||
|
|
724edea349 | ||
|
|
45408acd5c | ||
|
|
bbb2b44ae0 | ||
|
|
dcb7727a4b | ||
|
|
96a2945512 | ||
|
|
a4e92d1f40 | ||
|
|
fbd58fa5a6 | ||
|
|
b02c2e7526 | ||
|
|
9b20d6ce66 | ||
|
|
000969b0b9 | ||
|
|
95938c0079 | ||
| d6413a0496 | |||
| 4383db7fa1 | |||
| 6543081011 | |||
|
|
b66a623141 | ||
| 41846a6eb6 | |||
|
|
110a0b7105 | ||
| b04db0b06c | |||
| 3ebcec2b43 |
57
.gitea/workflows/deploy.yml
Normal file
57
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Deploy Frontend to Staging
|
||||
|
||||
# Triggered on every push to main.
|
||||
# Syncs this repo's source into the backend monorepo's frontend/
|
||||
# directory and rebuilds the encoach-frontend Docker container.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: deploy-frontend
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy frontend to staging
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Sync frontend source to backend repo
|
||||
run: |
|
||||
FRONTEND_SRC="/opt/encoach/encoach_frontend_new_v2"
|
||||
FRONTEND_DEST="/opt/encoach/encoach_backend_new_v2/frontend"
|
||||
|
||||
# Keep a clean clone of this repo on the server
|
||||
if [ -d "$FRONTEND_SRC/.git" ]; then
|
||||
git -C "$FRONTEND_SRC" fetch origin
|
||||
git -C "$FRONTEND_SRC" reset --hard origin/main
|
||||
else
|
||||
git clone http://localhost:3003/devops/encoach_frontend_new_v2.git "$FRONTEND_SRC"
|
||||
fi
|
||||
|
||||
echo "Syncing to backend frontend/ dir..."
|
||||
rsync -a --delete \
|
||||
--exclude '.git' \
|
||||
--exclude 'node_modules' \
|
||||
--exclude 'dist' \
|
||||
--exclude 'docs' \
|
||||
"$FRONTEND_SRC/" "$FRONTEND_DEST/"
|
||||
echo "Latest commit: $(git -C $FRONTEND_SRC log -1 --oneline)"
|
||||
|
||||
- name: Rebuild frontend container
|
||||
run: |
|
||||
cd /opt/encoach/encoach_backend_new_v2
|
||||
docker compose \
|
||||
-f docker-compose.yml \
|
||||
-f /opt/encoach/overrides/encoach.override.yml \
|
||||
up -d --build frontend
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}" | grep encoach-frontend
|
||||
|
||||
- name: Smoke test
|
||||
run: |
|
||||
sleep 5
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/)
|
||||
echo "frontend / => HTTP $STATUS"
|
||||
test "$STATUS" = "200" || exit 1
|
||||
echo "Deploy successful!"
|
||||
14
Dockerfile
14
Dockerfile
@@ -18,13 +18,25 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Default nginx body cap is 1 MB which causes HTTP 413 on:
|
||||
# * resource uploads (PDF / audio / video)
|
||||
# * /api/exam/generation/submit (whole exam with generated questions)
|
||||
# * /api/approval-requests (attachments)
|
||||
# Match Odoo's raised limit in odoo-docker.conf (128 MB).
|
||||
client_max_body_size 128m;
|
||||
client_body_buffer_size 1m;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8069/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
# OpenAI streaming calls + multi-module AI generation can run >3 min.
|
||||
# Keep read/send timeouts above Odoo's limit_time_real (900s).
|
||||
proxy_read_timeout 900s;
|
||||
proxy_send_timeout 900s;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
||||
27
e2e/login.spec.ts
Normal file
27
e2e/login.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Smoke test: the app loads, serves the login page, and the main form
|
||||
* controls are present and reachable. We deliberately avoid hitting the
|
||||
* real backend — this is a lightweight sanity check that catches broken
|
||||
* routing / bundle / asset problems before they reach production.
|
||||
*/
|
||||
test("login page renders the sign-in form", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /sign in|login|welcome/i }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const email = page.getByLabel(/email/i);
|
||||
const password = page.getByLabel(/password/i);
|
||||
await expect(email).toBeVisible();
|
||||
await expect(password).toBeVisible();
|
||||
|
||||
await expect(page.getByRole("button", { name: /sign in|log in/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test("root redirects to login for anonymous users", async ({ page }) => {
|
||||
const response = await page.goto("/");
|
||||
expect(response?.ok()).toBeTruthy();
|
||||
await page.waitForURL(/\/login/i, { timeout: 10_000 });
|
||||
});
|
||||
@@ -17,7 +17,7 @@
|
||||
<meta property="og:type" content="website" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@300;400;500;600;700;800&family=DM+Sans:wght@400;500;600;700&family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
162
package-lock.json
generated
162
package-lock.json
generated
@@ -42,6 +42,8 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"i18next": "^26.0.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next-themes": "^0.3.0",
|
||||
@@ -49,6 +51,7 @@
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.61.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-resizable-panels": "^2.1.9",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"recharts": "^2.15.4",
|
||||
@@ -60,6 +63,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
@@ -76,6 +80,7 @@
|
||||
"lovable-tagger": "^1.1.13",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-rtl": "^0.9.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.38.0",
|
||||
"vite": "^5.4.19",
|
||||
@@ -905,6 +910,22 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||
@@ -5480,6 +5501,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
|
||||
@@ -5509,6 +5539,46 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.0.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.6.tgz",
|
||||
"integrity": "sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-browser-languagedetector": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
|
||||
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
@@ -6215,6 +6285,53 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.9",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
|
||||
@@ -6547,6 +6664,33 @@
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.4.tgz",
|
||||
"integrity": "sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.0.1",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
@@ -7157,6 +7301,13 @@
|
||||
"tailwindcss": ">=3.0.0 || insiders"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss-rtl": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss-rtl/-/tailwindcss-rtl-0.9.0.tgz",
|
||||
"integrity": "sha512-y7yC8QXjluDBEFMSX33tV6xMYrf0B3sa+tOB5JSQb6/G6laBU313a+Z+qxu55M1Qyn8tDMttjomsA8IsJD+k+w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss/node_modules/postcss-selector-parser": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||
@@ -7369,7 +7520,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -8164,6 +8315,15 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
|
||||
|
||||
11
package.json
11
package.json
@@ -10,7 +10,9 @@
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:install": "playwright install --with-deps chromium"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
@@ -47,6 +49,8 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"i18next": "^26.0.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next-themes": "^0.3.0",
|
||||
@@ -54,6 +58,7 @@
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.61.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-resizable-panels": "^2.1.9",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"recharts": "^2.15.4",
|
||||
@@ -65,9 +70,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/node": "^22.16.5",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
@@ -81,6 +87,7 @@
|
||||
"lovable-tagger": "^1.1.13",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-rtl": "^0.9.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.38.0",
|
||||
"vite": "^5.4.19",
|
||||
|
||||
42
playwright.config.ts
Normal file
42
playwright.config.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Playwright configuration for EnCoach frontend smoke tests.
|
||||
*
|
||||
* By default we run against a Vite preview server on localhost:4173. In CI
|
||||
* this is started by the `playwright` step in .github/workflows/ci.yml after
|
||||
* `npm run build`.
|
||||
*
|
||||
* Override the base URL with `PLAYWRIGHT_BASE_URL=https://... npx playwright
|
||||
* test` to run against a deployed environment.
|
||||
*/
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:4173";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 5_000 },
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: process.env.CI ? [["github"], ["list"]] : [["list"]],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
webServer: process.env.PLAYWRIGHT_SKIP_WEBSERVER
|
||||
? undefined
|
||||
: {
|
||||
command: "npm run preview -- --host 127.0.0.1 --port 4173",
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60_000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 48 KiB |
12
public/logo.svg
Normal file
12
public/logo.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="EnCoach">
|
||||
<defs>
|
||||
<linearGradient id="eg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#4f46e5"/>
|
||||
<stop offset="50%" stop-color="#7c3aed"/>
|
||||
<stop offset="100%" stop-color="#2563eb"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#eg)"/>
|
||||
<path d="M20 20h22a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H26v6h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H26v6h16a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H20a2 2 0 0 1-2-2V22a2 2 0 0 1 2-2Z" fill="#ffffff"/>
|
||||
<circle cx="48" cy="48" r="5" fill="#facc15" stroke="#ffffff" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 697 B |
353
src/App.tsx
353
src/App.tsx
@@ -1,3 +1,5 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
@@ -8,146 +10,178 @@ import ProtectedRoute from "@/components/ProtectedRoute";
|
||||
import StudentLayout from "@/components/StudentLayout";
|
||||
import TeacherLayout from "@/components/TeacherLayout";
|
||||
import AdminLmsLayout from "@/components/AdminLmsLayout";
|
||||
import Login from "@/pages/Login";
|
||||
import Register from "@/pages/Register";
|
||||
import EmailVerification from "@/pages/EmailVerification";
|
||||
import OnboardingWizard from "@/pages/OnboardingWizard";
|
||||
import ForgotPassword from "@/pages/ForgotPassword";
|
||||
import ResetPassword from "@/pages/ResetPassword";
|
||||
import ScoreVerification from "@/pages/ScoreVerification";
|
||||
// Original platform pages
|
||||
import AdminDashboard from "@/pages/AdminDashboard";
|
||||
import UsersPage from "@/pages/UsersPage";
|
||||
import EntitiesPage from "@/pages/EntitiesPage";
|
||||
import AssignmentsPage from "@/pages/AssignmentsPage";
|
||||
import ExamsListPage from "@/pages/ExamsListPage";
|
||||
import ExamStructuresPage from "@/pages/ExamStructuresPage";
|
||||
import RubricsPage from "@/pages/RubricsPage";
|
||||
import GenerationPage from "@/pages/GenerationPage";
|
||||
import ApprovalWorkflowsPage from "@/pages/ApprovalWorkflowsPage";
|
||||
import ClassroomsPage from "@/pages/ClassroomsPage";
|
||||
import StudentPerformancePage from "@/pages/StudentPerformancePage";
|
||||
import StatsCorporatePage from "@/pages/StatsCorporatePage";
|
||||
import RecordPage from "@/pages/RecordPage";
|
||||
import VocabularyPage from "@/pages/VocabularyPage";
|
||||
import GrammarPage from "@/pages/GrammarPage";
|
||||
import PaymentRecordPage from "@/pages/PaymentRecordPage";
|
||||
import TicketsPage from "@/pages/TicketsPage";
|
||||
import SettingsPage from "@/pages/SettingsPage";
|
||||
import ProfilePage from "@/pages/ProfilePage";
|
||||
import ExamPage from "@/pages/ExamPage";
|
||||
// Student pages
|
||||
import StudentDashboard from "@/pages/student/StudentDashboard";
|
||||
import StudentCourses from "@/pages/student/StudentCourses";
|
||||
import StudentCourseDetail from "@/pages/student/StudentCourseDetail";
|
||||
import StudentAssignments from "@/pages/student/StudentAssignments";
|
||||
import StudentGrades from "@/pages/student/StudentGrades";
|
||||
import StudentAttendance from "@/pages/student/StudentAttendance";
|
||||
import StudentTimetable from "@/pages/student/StudentTimetable";
|
||||
import StudentProfile from "@/pages/student/StudentProfile";
|
||||
// Adaptive learning pages
|
||||
import SubjectSelection from "@/pages/student/SubjectSelection";
|
||||
import DiagnosticTest from "@/pages/student/DiagnosticTest";
|
||||
import ProficiencyProfile from "@/pages/student/ProficiencyProfile";
|
||||
import LearningPlanPage from "@/pages/student/LearningPlan";
|
||||
import TopicLearning from "@/pages/student/TopicLearning";
|
||||
// Teacher pages
|
||||
import TeacherDashboard from "@/pages/teacher/TeacherDashboard";
|
||||
import TeacherCourses from "@/pages/teacher/TeacherCourses";
|
||||
import CourseBuilder from "@/pages/teacher/CourseBuilder";
|
||||
import TeacherAssignments from "@/pages/teacher/TeacherAssignments";
|
||||
import TeacherAssignmentDetail from "@/pages/teacher/TeacherAssignmentDetail";
|
||||
import TeacherAttendance from "@/pages/teacher/TeacherAttendance";
|
||||
import TeacherStudents from "@/pages/teacher/TeacherStudents";
|
||||
import TeacherTimetable from "@/pages/teacher/TeacherTimetable";
|
||||
import TeacherProfile from "@/pages/teacher/TeacherProfile";
|
||||
import TeacherLibrary from "@/pages/teacher/TeacherLibrary";
|
||||
import AdaptiveSettings from "@/pages/teacher/AdaptiveSettings";
|
||||
// Admin LMS pages
|
||||
import AdminLmsDashboard from "@/pages/admin/AdminLmsDashboard";
|
||||
import AdminCourses from "@/pages/admin/AdminCourses";
|
||||
import AdminStudents from "@/pages/admin/AdminStudents";
|
||||
import AdminTeachers from "@/pages/admin/AdminTeachers";
|
||||
import AdminBatches from "@/pages/admin/AdminBatches";
|
||||
import AdminBatchDetail from "@/pages/admin/AdminBatchDetail";
|
||||
import AdminTimetable from "@/pages/admin/AdminTimetable";
|
||||
import AdminReports from "@/pages/admin/AdminReports";
|
||||
import AdminSettings from "@/pages/admin/AdminSettings";
|
||||
import AdminProfileLms from "@/pages/admin/AdminProfile";
|
||||
import TaxonomyManager from "@/pages/admin/TaxonomyManager";
|
||||
import ResourceManager from "@/pages/admin/ResourceManager";
|
||||
import AcademicYearManager from "@/pages/admin/AcademicYearManager";
|
||||
import DepartmentManager from "@/pages/admin/DepartmentManager";
|
||||
import AdmissionList from "@/pages/admin/AdmissionList";
|
||||
import AdmissionDetail from "@/pages/admin/AdmissionDetail";
|
||||
import AdmissionRegisterPage from "@/pages/admin/AdmissionRegisterPage";
|
||||
import InstitutionalExamSessions from "@/pages/admin/InstitutionalExamSessions";
|
||||
import MarksheetManager from "@/pages/admin/MarksheetManager";
|
||||
import AdminStudentLeave from "@/pages/admin/AdminStudentLeave";
|
||||
import AdminFees from "@/pages/admin/AdminFees";
|
||||
import AdminLessons from "@/pages/admin/AdminLessons";
|
||||
import AdminGradebook from "@/pages/admin/AdminGradebook";
|
||||
import AdminStudentProgress from "@/pages/admin/AdminStudentProgress";
|
||||
import AdminLibrary from "@/pages/admin/AdminLibrary";
|
||||
import AdminActivities from "@/pages/admin/AdminActivities";
|
||||
import AdminFacilities from "@/pages/admin/AdminFacilities";
|
||||
import AdmissionApplication from "@/pages/AdmissionApplication";
|
||||
import SubjectRegistrationPage from "@/pages/student/SubjectRegistrationPage";
|
||||
// Courseware pages
|
||||
import CourseChapters from "@/pages/teacher/CourseChapters";
|
||||
import ChapterDetail from "@/pages/teacher/ChapterDetail";
|
||||
import AiWorkbench from "@/pages/teacher/AiWorkbench";
|
||||
import TeacherDiscussionBoard from "@/pages/teacher/TeacherDiscussionBoard";
|
||||
import TeacherAnnouncements from "@/pages/teacher/TeacherAnnouncements";
|
||||
import StudentChapterView from "@/pages/student/StudentChapterView";
|
||||
import StudentDiscussionBoard from "@/pages/student/StudentDiscussionBoard";
|
||||
import StudentAnnouncements from "@/pages/student/StudentAnnouncements";
|
||||
import StudentMessages from "@/pages/student/StudentMessages";
|
||||
import StudentJourney from "@/pages/student/StudentJourney";
|
||||
import AiEnglishCourse from "@/pages/student/AiEnglishCourse";
|
||||
import AiIeltsCourse from "@/pages/student/AiIeltsCourse";
|
||||
import ExamSession from "@/pages/student/ExamSession";
|
||||
import ExamStatus from "@/pages/student/ExamStatus";
|
||||
import ExamResults from "@/pages/student/ExamResults";
|
||||
import GapAnalysis from "@/pages/student/GapAnalysis";
|
||||
import CourseDelivery from "@/pages/student/CourseDelivery";
|
||||
import GradingQueue from "@/pages/admin/GradingQueue";
|
||||
import CourseConfig from "@/pages/admin/CourseConfig";
|
||||
import ModuleBuilder from "@/pages/admin/ModuleBuilder";
|
||||
import CourseProgress from "@/pages/teacher/CourseProgress";
|
||||
import PlacementBriefing from "@/pages/student/PlacementBriefing";
|
||||
import PlacementTest from "@/pages/student/PlacementTest";
|
||||
import PlacementResults from "@/pages/student/PlacementResults";
|
||||
import PlacementAccess from "@/pages/student/PlacementAccess";
|
||||
import FaqManager from "@/pages/admin/FaqManager";
|
||||
import NotificationRules from "@/pages/admin/NotificationRules";
|
||||
import ApprovalWorkflowConfig from "@/pages/admin/ApprovalWorkflowConfig";
|
||||
import RolesPermissions from "@/pages/admin/RolesPermissions";
|
||||
import AuthorityMatrix from "@/pages/admin/AuthorityMatrix";
|
||||
import UserRoles from "@/pages/admin/UserRoles";
|
||||
import BulkStudentUpload from "@/pages/admin/BulkStudentUpload";
|
||||
import CredentialDashboard from "@/pages/admin/CredentialDashboard";
|
||||
import AiEnglishQuality from "@/pages/admin/AiEnglishQuality";
|
||||
import AiEnglishTaxonomy from "@/pages/admin/AiEnglishTaxonomy";
|
||||
import AiIeltsValidation from "@/pages/admin/AiIeltsValidation";
|
||||
import AdaptiveDashboard from "@/pages/admin/AdaptiveDashboard";
|
||||
import AdaptiveStudentDetail from "@/pages/admin/AdaptiveStudentDetail";
|
||||
import LevelMappingConfig from "@/pages/admin/LevelMappingConfig";
|
||||
import WhiteLabelBranding from "@/pages/admin/WhiteLabelBranding";
|
||||
import ScoreApprovalQueue from "@/pages/admin/ScoreApprovalQueue";
|
||||
import ExamTemplateSelection from "@/pages/admin/ExamTemplateSelection";
|
||||
import IeltsExamCreate from "@/pages/admin/IeltsExamCreate";
|
||||
import IeltsSkillConfig from "@/pages/admin/IeltsSkillConfig";
|
||||
import IeltsContentPool from "@/pages/admin/IeltsContentPool";
|
||||
import IeltsExamValidation from "@/pages/admin/IeltsExamValidation";
|
||||
import CustomExamCreate from "@/pages/admin/CustomExamCreate";
|
||||
import OfficialExamAccess from "@/pages/OfficialExamAccess";
|
||||
import FaqPage from "@/pages/FaqPage";
|
||||
import NotFound from "@/pages/NotFound";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Lazy-loaded route pages
|
||||
// -----------------------------------------------------------------------------
|
||||
// Keeping the initial bundle small matters on slow networks / modest hardware
|
||||
// (many teachers/students open the app from school Wi-Fi). Each page below is
|
||||
// split into its own chunk and only fetched when the user hits the matching
|
||||
// route. The shell (layouts, providers, router) is still eagerly imported so
|
||||
// the first paint stays snappy.
|
||||
|
||||
// Auth
|
||||
const Login = lazy(() => import("@/pages/Login"));
|
||||
const Register = lazy(() => import("@/pages/Register"));
|
||||
const EmailVerification = lazy(() => import("@/pages/EmailVerification"));
|
||||
const OnboardingWizard = lazy(() => import("@/pages/OnboardingWizard"));
|
||||
const ForgotPassword = lazy(() => import("@/pages/ForgotPassword"));
|
||||
const ResetPassword = lazy(() => import("@/pages/ResetPassword"));
|
||||
const ScoreVerification = lazy(() => import("@/pages/ScoreVerification"));
|
||||
|
||||
// Original platform pages
|
||||
const AdminDashboard = lazy(() => import("@/pages/AdminDashboard"));
|
||||
const UsersPage = lazy(() => import("@/pages/UsersPage"));
|
||||
const EntitiesPage = lazy(() => import("@/pages/EntitiesPage"));
|
||||
const AssignmentsPage = lazy(() => import("@/pages/AssignmentsPage"));
|
||||
const ExamsListPage = lazy(() => import("@/pages/ExamsListPage"));
|
||||
const ExamStructuresPage = lazy(() => import("@/pages/ExamStructuresPage"));
|
||||
const RubricsPage = lazy(() => import("@/pages/RubricsPage"));
|
||||
const GenerationPage = lazy(() => import("@/pages/GenerationPage"));
|
||||
const ApprovalWorkflowsPage = lazy(() => import("@/pages/ApprovalWorkflowsPage"));
|
||||
const ClassroomsPage = lazy(() => import("@/pages/ClassroomsPage"));
|
||||
const StudentPerformancePage = lazy(() => import("@/pages/StudentPerformancePage"));
|
||||
const StatsCorporatePage = lazy(() => import("@/pages/StatsCorporatePage"));
|
||||
const RecordPage = lazy(() => import("@/pages/RecordPage"));
|
||||
const VocabularyPage = lazy(() => import("@/pages/VocabularyPage"));
|
||||
const GrammarPage = lazy(() => import("@/pages/GrammarPage"));
|
||||
const PaymentRecordPage = lazy(() => import("@/pages/PaymentRecordPage"));
|
||||
const TicketsPage = lazy(() => import("@/pages/TicketsPage"));
|
||||
const SettingsPage = lazy(() => import("@/pages/SettingsPage"));
|
||||
|
||||
// Student pages
|
||||
const StudentDashboard = lazy(() => import("@/pages/student/StudentDashboard"));
|
||||
const StudentCourses = lazy(() => import("@/pages/student/StudentCourses"));
|
||||
const StudentCourseDetail = lazy(() => import("@/pages/student/StudentCourseDetail"));
|
||||
const StudentAssignments = lazy(() => import("@/pages/student/StudentAssignments"));
|
||||
const StudentGrades = lazy(() => import("@/pages/student/StudentGrades"));
|
||||
const StudentAttendance = lazy(() => import("@/pages/student/StudentAttendance"));
|
||||
const StudentTimetable = lazy(() => import("@/pages/student/StudentTimetable"));
|
||||
const StudentProfile = lazy(() => import("@/pages/student/StudentProfile"));
|
||||
|
||||
// Adaptive learning pages
|
||||
const SubjectSelection = lazy(() => import("@/pages/student/SubjectSelection"));
|
||||
const DiagnosticTest = lazy(() => import("@/pages/student/DiagnosticTest"));
|
||||
const ProficiencyProfile = lazy(() => import("@/pages/student/ProficiencyProfile"));
|
||||
const LearningPlanPage = lazy(() => import("@/pages/student/LearningPlan"));
|
||||
const TopicLearning = lazy(() => import("@/pages/student/TopicLearning"));
|
||||
|
||||
// Teacher pages
|
||||
const TeacherDashboard = lazy(() => import("@/pages/teacher/TeacherDashboard"));
|
||||
const TeacherQuickSetup = lazy(() => import("@/pages/teacher/TeacherQuickSetup"));
|
||||
const TeacherCourses = lazy(() => import("@/pages/teacher/TeacherCourses"));
|
||||
const CourseBuilder = lazy(() => import("@/pages/teacher/CourseBuilder"));
|
||||
const TeacherAssignments = lazy(() => import("@/pages/teacher/TeacherAssignments"));
|
||||
const TeacherAssignmentDetail = lazy(() => import("@/pages/teacher/TeacherAssignmentDetail"));
|
||||
const TeacherAttendance = lazy(() => import("@/pages/teacher/TeacherAttendance"));
|
||||
const TeacherStudents = lazy(() => import("@/pages/teacher/TeacherStudents"));
|
||||
const TeacherTimetable = lazy(() => import("@/pages/teacher/TeacherTimetable"));
|
||||
const TeacherProfile = lazy(() => import("@/pages/teacher/TeacherProfile"));
|
||||
const TeacherLibrary = lazy(() => import("@/pages/teacher/TeacherLibrary"));
|
||||
const AdaptiveSettings = lazy(() => import("@/pages/teacher/AdaptiveSettings"));
|
||||
|
||||
// Admin LMS pages
|
||||
const AdminLmsDashboard = lazy(() => import("@/pages/admin/AdminLmsDashboard"));
|
||||
const AdminQuickSetup = lazy(() => import("@/pages/admin/AdminQuickSetup"));
|
||||
const SmartWizardHub = lazy(() => import("@/pages/admin/SmartWizardHub"));
|
||||
const RubricWizard = lazy(() => import("@/pages/admin/wizards/RubricWizard"));
|
||||
const ExamStructureWizard = lazy(() => import("@/pages/admin/wizards/ExamStructureWizard"));
|
||||
const CourseWizard = lazy(() => import("@/pages/admin/wizards/CourseWizard"));
|
||||
const CoursePlanWizard = lazy(() => import("@/pages/admin/wizards/CoursePlanWizard"));
|
||||
const AdminCoursePlans = lazy(() => import("@/pages/admin/AdminCoursePlans"));
|
||||
const AdminCoursePlanDetail = lazy(() => import("@/pages/admin/AdminCoursePlanDetail"));
|
||||
const AdminCourses = lazy(() => import("@/pages/admin/AdminCourses"));
|
||||
const AdminStudents = lazy(() => import("@/pages/admin/AdminStudents"));
|
||||
const AdminTeachers = lazy(() => import("@/pages/admin/AdminTeachers"));
|
||||
const AdminBranches = lazy(() => import("@/pages/admin/AdminBranches"));
|
||||
const AdminBatches = lazy(() => import("@/pages/admin/AdminBatches"));
|
||||
const AdminBatchDetail = lazy(() => import("@/pages/admin/AdminBatchDetail"));
|
||||
const AdminTimetable = lazy(() => import("@/pages/admin/AdminTimetable"));
|
||||
const AdminReports = lazy(() => import("@/pages/admin/AdminReports"));
|
||||
const AdminSettings = lazy(() => import("@/pages/admin/AdminSettings"));
|
||||
const AdminProfileLms = lazy(() => import("@/pages/admin/AdminProfile"));
|
||||
const TaxonomyManager = lazy(() => import("@/pages/admin/TaxonomyManager"));
|
||||
const ResourceManager = lazy(() => import("@/pages/admin/ResourceManager"));
|
||||
const AcademicYearManager = lazy(() => import("@/pages/admin/AcademicYearManager"));
|
||||
const DepartmentManager = lazy(() => import("@/pages/admin/DepartmentManager"));
|
||||
const AdmissionList = lazy(() => import("@/pages/admin/AdmissionList"));
|
||||
const AdmissionDetail = lazy(() => import("@/pages/admin/AdmissionDetail"));
|
||||
const AdmissionRegisterPage = lazy(() => import("@/pages/admin/AdmissionRegisterPage"));
|
||||
const InstitutionalExamSessions = lazy(() => import("@/pages/admin/InstitutionalExamSessions"));
|
||||
const MarksheetManager = lazy(() => import("@/pages/admin/MarksheetManager"));
|
||||
const AdminStudentLeave = lazy(() => import("@/pages/admin/AdminStudentLeave"));
|
||||
const AdminFees = lazy(() => import("@/pages/admin/AdminFees"));
|
||||
const AdminLessons = lazy(() => import("@/pages/admin/AdminLessons"));
|
||||
const AdminGradebook = lazy(() => import("@/pages/admin/AdminGradebook"));
|
||||
const AdminStudentProgress = lazy(() => import("@/pages/admin/AdminStudentProgress"));
|
||||
const AdminLibrary = lazy(() => import("@/pages/admin/AdminLibrary"));
|
||||
const AdminActivities = lazy(() => import("@/pages/admin/AdminActivities"));
|
||||
const AdminFacilities = lazy(() => import("@/pages/admin/AdminFacilities"));
|
||||
const AdmissionApplication = lazy(() => import("@/pages/AdmissionApplication"));
|
||||
const SubjectRegistrationPage = lazy(() => import("@/pages/student/SubjectRegistrationPage"));
|
||||
|
||||
// Courseware pages
|
||||
const CourseChapters = lazy(() => import("@/pages/teacher/CourseChapters"));
|
||||
const ChapterDetail = lazy(() => import("@/pages/teacher/ChapterDetail"));
|
||||
const AiWorkbench = lazy(() => import("@/pages/teacher/AiWorkbench"));
|
||||
const TeacherDiscussionBoard = lazy(() => import("@/pages/teacher/TeacherDiscussionBoard"));
|
||||
const TeacherAnnouncements = lazy(() => import("@/pages/teacher/TeacherAnnouncements"));
|
||||
const StudentChapterView = lazy(() => import("@/pages/student/StudentChapterView"));
|
||||
const StudentDiscussionBoard = lazy(() => import("@/pages/student/StudentDiscussionBoard"));
|
||||
const StudentAnnouncements = lazy(() => import("@/pages/student/StudentAnnouncements"));
|
||||
const StudentMessages = lazy(() => import("@/pages/student/StudentMessages"));
|
||||
const StudentJourney = lazy(() => import("@/pages/student/StudentJourney"));
|
||||
const StudentCoursePlans = lazy(() => import("@/pages/student/StudentCoursePlans"));
|
||||
const StudentCoursePlanDetail = lazy(() => import("@/pages/student/StudentCoursePlanDetail"));
|
||||
const AiEnglishCourse = lazy(() => import("@/pages/student/AiEnglishCourse"));
|
||||
const AiIeltsCourse = lazy(() => import("@/pages/student/AiIeltsCourse"));
|
||||
const ExamSession = lazy(() => import("@/pages/student/ExamSession"));
|
||||
const ExamStatus = lazy(() => import("@/pages/student/ExamStatus"));
|
||||
const ExamResults = lazy(() => import("@/pages/student/ExamResults"));
|
||||
const GapAnalysis = lazy(() => import("@/pages/student/GapAnalysis"));
|
||||
const CourseDelivery = lazy(() => import("@/pages/student/CourseDelivery"));
|
||||
const GradingQueue = lazy(() => import("@/pages/admin/GradingQueue"));
|
||||
const CourseConfig = lazy(() => import("@/pages/admin/CourseConfig"));
|
||||
const ModuleBuilder = lazy(() => import("@/pages/admin/ModuleBuilder"));
|
||||
const CourseProgress = lazy(() => import("@/pages/teacher/CourseProgress"));
|
||||
const PlacementBriefing = lazy(() => import("@/pages/student/PlacementBriefing"));
|
||||
const PlacementTest = lazy(() => import("@/pages/student/PlacementTest"));
|
||||
const PlacementResults = lazy(() => import("@/pages/student/PlacementResults"));
|
||||
const PlacementAccess = lazy(() => import("@/pages/student/PlacementAccess"));
|
||||
const FaqManager = lazy(() => import("@/pages/admin/FaqManager"));
|
||||
const NotificationRules = lazy(() => import("@/pages/admin/NotificationRules"));
|
||||
const ApprovalWorkflowConfig = lazy(() => import("@/pages/admin/ApprovalWorkflowConfig"));
|
||||
const RolesPermissions = lazy(() => import("@/pages/admin/RolesPermissions"));
|
||||
const AuthorityMatrix = lazy(() => import("@/pages/admin/AuthorityMatrix"));
|
||||
const UserRoles = lazy(() => import("@/pages/admin/UserRoles"));
|
||||
const BulkStudentUpload = lazy(() => import("@/pages/admin/BulkStudentUpload"));
|
||||
const CredentialDashboard = lazy(() => import("@/pages/admin/CredentialDashboard"));
|
||||
const AiEnglishQuality = lazy(() => import("@/pages/admin/AiEnglishQuality"));
|
||||
const AiEnglishTaxonomy = lazy(() => import("@/pages/admin/AiEnglishTaxonomy"));
|
||||
const AiIeltsValidation = lazy(() => import("@/pages/admin/AiIeltsValidation"));
|
||||
const AdaptiveDashboard = lazy(() => import("@/pages/admin/AdaptiveDashboard"));
|
||||
const AdaptiveStudentDetail = lazy(() => import("@/pages/admin/AdaptiveStudentDetail"));
|
||||
const LevelMappingConfig = lazy(() => import("@/pages/admin/LevelMappingConfig"));
|
||||
const WhiteLabelBranding = lazy(() => import("@/pages/admin/WhiteLabelBranding"));
|
||||
const ScoreApprovalQueue = lazy(() => import("@/pages/admin/ScoreApprovalQueue"));
|
||||
const ExamTemplateSelection = lazy(() => import("@/pages/admin/ExamTemplateSelection"));
|
||||
const IeltsExamCreate = lazy(() => import("@/pages/admin/IeltsExamCreate"));
|
||||
const IeltsSkillConfig = lazy(() => import("@/pages/admin/IeltsSkillConfig"));
|
||||
const IeltsContentPool = lazy(() => import("@/pages/admin/IeltsContentPool"));
|
||||
const IeltsExamValidation = lazy(() => import("@/pages/admin/IeltsExamValidation"));
|
||||
const CustomExamCreate = lazy(() => import("@/pages/admin/CustomExamCreate"));
|
||||
const ExamReviewQueue = lazy(() => import("@/pages/admin/ExamReviewQueue"));
|
||||
const ExamReviewDetail = lazy(() => import("@/pages/admin/ExamReviewDetail"));
|
||||
const AIPromptEditor = lazy(() => import("@/pages/admin/AIPromptEditor"));
|
||||
const AIFeedbackTriage = lazy(() => import("@/pages/admin/AIFeedbackTriage"));
|
||||
const PrivacyCenter = lazy(() => import("@/pages/PrivacyCenter"));
|
||||
const OfficialExamAccess = lazy(() => import("@/pages/OfficialExamAccess"));
|
||||
const FaqPage = lazy(() => import("@/pages/FaqPage"));
|
||||
const NotFound = lazy(() => import("@/pages/NotFound"));
|
||||
|
||||
function StudentSubscriptionPlaceholder() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
@@ -158,14 +192,35 @@ function StudentSubscriptionPlaceholder() {
|
||||
);
|
||||
}
|
||||
|
||||
function RouteFallback() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const App = () => (
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
storageKey="encoach-theme"
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
<BrowserRouter>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
<AuthProvider>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Routes>
|
||||
{/* Auth routes */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
@@ -200,6 +255,7 @@ const App = () => (
|
||||
<Route path="/student/attendance" element={<StudentAttendance />} />
|
||||
<Route path="/student/timetable" element={<StudentTimetable />} />
|
||||
<Route path="/student/profile" element={<StudentProfile />} />
|
||||
<Route path="/student/privacy" element={<PrivacyCenter />} />
|
||||
<Route path="/student/subjects" element={<SubjectSelection />} />
|
||||
<Route path="/student/diagnostic/:subjectId" element={<DiagnosticTest />} />
|
||||
<Route path="/student/proficiency/:subjectId" element={<ProficiencyProfile />} />
|
||||
@@ -216,6 +272,8 @@ const App = () => (
|
||||
<Route path="/student/placement/access" element={<PlacementAccess />} />
|
||||
<Route path="/student/exam/:examId/results" element={<ExamResults />} />
|
||||
<Route path="/student/course/generate" element={<GapAnalysis />} />
|
||||
<Route path="/student/course-plans" element={<StudentCoursePlans />} />
|
||||
<Route path="/student/course-plans/:planId" element={<StudentCoursePlanDetail />} />
|
||||
<Route path="/student/course/ai-english/:courseId" element={<AiEnglishCourse />} />
|
||||
<Route path="/student/course/ai-ielts/:courseId" element={<AiIeltsCourse />} />
|
||||
<Route path="/student/course/:courseId" element={<CourseDelivery />} />
|
||||
@@ -226,6 +284,7 @@ const App = () => (
|
||||
<Route element={<ProtectedRoute allowedRoles={["teacher", "admin", "developer"]} />}>
|
||||
<Route element={<TeacherLayout />}>
|
||||
<Route path="/teacher/dashboard" element={<TeacherDashboard />} />
|
||||
<Route path="/teacher/quick-setup" element={<TeacherQuickSetup />} />
|
||||
<Route path="/teacher/courses" element={<TeacherCourses />} />
|
||||
<Route path="/teacher/library" element={<TeacherLibrary />} />
|
||||
<Route path="/teacher/courses/new" element={<CourseBuilder />} />
|
||||
@@ -241,6 +300,7 @@ const App = () => (
|
||||
<Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} />
|
||||
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
|
||||
<Route path="/teacher/profile" element={<TeacherProfile />} />
|
||||
<Route path="/teacher/privacy" element={<PrivacyCenter />} />
|
||||
<Route path="/teacher/course/:courseId/progress" element={<CourseProgress />} />
|
||||
<Route path="/teacher/adaptive/settings" element={<AdaptiveSettings />} />
|
||||
</Route>
|
||||
@@ -251,18 +311,28 @@ const App = () => (
|
||||
<Route element={<AdminLmsLayout />}>
|
||||
{/* LMS Dashboard */}
|
||||
<Route path="/admin/dashboard" element={<AdminLmsDashboard />} />
|
||||
<Route path="/admin/quick-setup" element={<AdminQuickSetup />} />
|
||||
<Route path="/admin/smart-wizard" element={<SmartWizardHub />} />
|
||||
<Route path="/admin/smart-wizard/rubric" element={<RubricWizard />} />
|
||||
<Route path="/admin/smart-wizard/exam-structure" element={<ExamStructureWizard />} />
|
||||
<Route path="/admin/smart-wizard/course" element={<CourseWizard />} />
|
||||
<Route path="/admin/smart-wizard/course-plan" element={<CoursePlanWizard />} />
|
||||
<Route path="/admin/course-plans" element={<AdminCoursePlans />} />
|
||||
<Route path="/admin/course-plans/:planId" element={<AdminCoursePlanDetail />} />
|
||||
{/* Original platform dashboard */}
|
||||
<Route path="/admin/platform" element={<AdminDashboard />} />
|
||||
{/* LMS pages */}
|
||||
{/* LMS pages (entity-scoped, includes branch management) */}
|
||||
<Route path="/admin/courses" element={<AdminCourses />} />
|
||||
<Route path="/admin/students" element={<AdminStudents />} />
|
||||
<Route path="/admin/teachers" element={<AdminTeachers />} />
|
||||
<Route path="/admin/branches" element={<AdminBranches />} />
|
||||
<Route path="/admin/batches" element={<AdminBatches />} />
|
||||
<Route path="/admin/batches/:id" element={<AdminBatchDetail />} />
|
||||
<Route path="/admin/timetable" element={<AdminTimetable />} />
|
||||
<Route path="/admin/reports" element={<AdminReports />} />
|
||||
<Route path="/admin/settings" element={<AdminSettings />} />
|
||||
<Route path="/admin/profile" element={<AdminProfileLms />} />
|
||||
<Route path="/admin/privacy" element={<PrivacyCenter />} />
|
||||
{/* Original academic pages */}
|
||||
<Route path="/admin/assignments" element={<AssignmentsPage />} />
|
||||
<Route path="/admin/examsList" element={<ExamsListPage />} />
|
||||
@@ -285,13 +355,16 @@ const App = () => (
|
||||
<Route path="/admin/payment-record" element={<PaymentRecordPage />} />
|
||||
<Route path="/admin/tickets" element={<TicketsPage />} />
|
||||
<Route path="/admin/settings-platform" element={<SettingsPage />} />
|
||||
<Route path="/admin/exam" element={<ExamPage />} />
|
||||
<Route path="/admin/exam/create" element={<ExamTemplateSelection />} />
|
||||
<Route path="/admin/exam/ielts/create" element={<IeltsExamCreate />} />
|
||||
<Route path="/admin/exam/ielts/:examId/skills" element={<IeltsSkillConfig />} />
|
||||
<Route path="/admin/exam/ielts/:examId/content" element={<IeltsContentPool />} />
|
||||
<Route path="/admin/exam/ielts/:examId/validate" element={<IeltsExamValidation />} />
|
||||
<Route path="/admin/exam/custom/create" element={<CustomExamCreate />} />
|
||||
<Route path="/admin/exam/review-queue" element={<ExamReviewQueue />} />
|
||||
<Route path="/admin/exam/review/:examId" element={<ExamReviewDetail />} />
|
||||
<Route path="/admin/ai/prompts" element={<AIPromptEditor />} />
|
||||
<Route path="/admin/ai/feedback" element={<AIFeedbackTriage />} />
|
||||
<Route path="/admin/taxonomy" element={<TaxonomyManager />} />
|
||||
<Route path="/admin/resources" element={<ResourceManager />} />
|
||||
{/* Institutional LMS pages */}
|
||||
@@ -340,10 +413,12 @@ const App = () => (
|
||||
<Route path="/dashboard/admin" element={<Navigate to="/admin/platform" replace />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
|
||||
164
src/components/AIFeedbackButtons.tsx
Normal file
164
src/components/AIFeedbackButtons.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useAIFeedbackSummary,
|
||||
useSubmitAIFeedback,
|
||||
} from "@/hooks/queries/useAIFeedback";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
AIFeedbackRating,
|
||||
AIFeedbackSubjectType,
|
||||
} from "@/types/ai-feedback";
|
||||
|
||||
export interface AIFeedbackButtonsProps {
|
||||
subjectType: AIFeedbackSubjectType;
|
||||
subjectId: number;
|
||||
promptKey?: string;
|
||||
promptVersion?: number;
|
||||
aiLogId?: number;
|
||||
entityId?: number;
|
||||
courseId?: number;
|
||||
size?: "sm" | "md";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Thumbs up/down widget for any AI-generated artefact.
|
||||
*
|
||||
* Always renders the same UI regardless of whether the user has rated the
|
||||
* artefact before — a previous rating will show as highlighted. Clicking the
|
||||
* same button again is a no-op; clicking the opposite button updates the
|
||||
* server-side row in place. A thumbs-down always prompts the user for a short
|
||||
* comment (required by the backend).
|
||||
*/
|
||||
export function AIFeedbackButtons({
|
||||
subjectType,
|
||||
subjectId,
|
||||
promptKey,
|
||||
promptVersion,
|
||||
aiLogId,
|
||||
entityId,
|
||||
courseId,
|
||||
size = "sm",
|
||||
className,
|
||||
}: AIFeedbackButtonsProps) {
|
||||
const summary = useAIFeedbackSummary(subjectType, subjectId);
|
||||
const submit = useSubmitAIFeedback();
|
||||
|
||||
const [downOpen, setDownOpen] = useState(false);
|
||||
const [comment, setComment] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (downOpen) {
|
||||
setComment(summary.data?.my_comment ?? "");
|
||||
}
|
||||
}, [downOpen, summary.data?.my_comment]);
|
||||
|
||||
const myRating: AIFeedbackRating | null = summary.data?.my_rating ?? null;
|
||||
|
||||
const submitUp = async () => {
|
||||
if (myRating === "up") return;
|
||||
try {
|
||||
await submit.mutateAsync({
|
||||
subject_type: subjectType,
|
||||
subject_id: subjectId,
|
||||
rating: "up",
|
||||
prompt_key: promptKey,
|
||||
prompt_version: promptVersion,
|
||||
ai_log_id: aiLogId,
|
||||
entity_id: entityId,
|
||||
course_id: courseId,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to submit");
|
||||
}
|
||||
};
|
||||
|
||||
const submitDown = async () => {
|
||||
if (!comment.trim()) {
|
||||
toast.error("Please tell us what was wrong.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await submit.mutateAsync({
|
||||
subject_type: subjectType,
|
||||
subject_id: subjectId,
|
||||
rating: "down",
|
||||
comment: comment.trim(),
|
||||
prompt_key: promptKey,
|
||||
prompt_version: promptVersion,
|
||||
ai_log_id: aiLogId,
|
||||
entity_id: entityId,
|
||||
course_id: courseId,
|
||||
});
|
||||
setDownOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to submit");
|
||||
}
|
||||
};
|
||||
|
||||
const btnSize = size === "sm" ? "sm" : "default";
|
||||
const iconCls = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", className)}>
|
||||
<Button
|
||||
variant={myRating === "up" ? "default" : "outline"}
|
||||
size={btnSize}
|
||||
onClick={submitUp}
|
||||
disabled={submit.isPending}
|
||||
aria-label="Thumbs up"
|
||||
>
|
||||
<ThumbsUp className={iconCls} />
|
||||
<span className="ml-1 text-xs">{summary.data?.up ?? 0}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={myRating === "down" ? "default" : "outline"}
|
||||
size={btnSize}
|
||||
onClick={() => setDownOpen(true)}
|
||||
disabled={submit.isPending}
|
||||
aria-label="Thumbs down"
|
||||
>
|
||||
<ThumbsDown className={iconCls} />
|
||||
<span className="ml-1 text-xs">{summary.data?.down ?? 0}</span>
|
||||
</Button>
|
||||
|
||||
<Dialog open={downOpen} onOpenChange={setDownOpen}>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
description="Tell us what was wrong so we can improve this AI output."
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>What went wrong?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="e.g. Wrong answer, confusing wording, off-topic…"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDownOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submitDown} disabled={submit.isPending}>
|
||||
Submit feedback
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AIFeedbackButtons;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Outlet, Link, useNavigate, useLocation } from "react-router-dom";
|
||||
import { Outlet, Link, useNavigate, useLocation, useMatch } from "react-router-dom";
|
||||
import { Suspense } from "react";
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import {
|
||||
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
||||
@@ -9,10 +10,13 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
|
||||
import { NavLink } from "@/components/NavLink";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import NotificationDropdown from "@/components/NotificationDropdown";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { LanguageToggle } from "@/components/LanguageToggle";
|
||||
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
|
||||
import AiSearchBar from "@/components/ai/AiSearchBar";
|
||||
import { usePermissions } from "@/hooks/usePermissions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
@@ -29,115 +33,129 @@ import {
|
||||
CalendarDays, Landmark, UserPlus, ScrollText, Award,
|
||||
HelpCircle as FaqIcon, Bell, Workflow,
|
||||
CalendarOff, DollarSign, BookMarked, BarChartHorizontal, TrendingUp,
|
||||
Library, Activity, Warehouse, UserCog,
|
||||
Library, Activity, Warehouse, UserCog, Sparkles, Compass,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// ============= Navigation Config =============
|
||||
interface NavItem { title: string; url: string; icon: LucideIcon }
|
||||
// Items store i18n keys (`titleKey`) rather than literal strings so Arabic
|
||||
// language switches update the sidebar without the component having to
|
||||
// re-mount.
|
||||
interface NavItem { titleKey: string; url: string; icon: LucideIcon }
|
||||
|
||||
const overviewItems: NavItem[] = [
|
||||
{ title: "Admin Dashboard", url: "/admin/dashboard", icon: LayoutDashboard },
|
||||
{ title: "Platform Dashboard", url: "/admin/platform", icon: BarChart3 },
|
||||
{ titleKey: "nav.smartWizard", url: "/admin/smart-wizard", icon: Sparkles },
|
||||
{ titleKey: "nav.adminDashboard", url: "/admin/dashboard", icon: LayoutDashboard },
|
||||
{ titleKey: "nav.platformDashboard", url: "/admin/platform", icon: BarChart3 },
|
||||
];
|
||||
|
||||
const lmsItems: NavItem[] = [
|
||||
{ title: "Courses", url: "/admin/courses", icon: BookOpen },
|
||||
{ title: "Students", url: "/admin/students", icon: Users },
|
||||
{ title: "Teachers", url: "/admin/teachers", icon: GraduationCap },
|
||||
{ title: "Batches", url: "/admin/batches", icon: Layers },
|
||||
{ title: "Timetable", url: "/admin/timetable", icon: Calendar },
|
||||
{ title: "Reports", url: "/admin/reports", icon: BarChart3 },
|
||||
{ titleKey: "nav.courses", url: "/admin/courses", icon: BookOpen },
|
||||
{ titleKey: "nav.students", url: "/admin/students", icon: Users },
|
||||
{ titleKey: "nav.teachers", url: "/admin/teachers", icon: GraduationCap },
|
||||
{ titleKey: "nav.branches", url: "/admin/branches", icon: GitBranch },
|
||||
{ titleKey: "nav.classrooms", url: "/admin/classrooms", icon: GraduationCap },
|
||||
{ titleKey: "nav.batches", url: "/admin/batches", icon: Layers },
|
||||
{ titleKey: "nav.timetable", url: "/admin/timetable", icon: Calendar },
|
||||
{ titleKey: "nav.reports", url: "/admin/reports", icon: BarChart3 },
|
||||
];
|
||||
|
||||
const academicItems: NavItem[] = [
|
||||
{ title: "Assignments", url: "/admin/assignments", icon: ClipboardList },
|
||||
{ title: "Exams List", url: "/admin/examsList", icon: FileText },
|
||||
{ title: "Exam Structures", url: "/admin/exam-structures", icon: Layers },
|
||||
{ title: "Rubrics", url: "/admin/rubrics", icon: BookOpen },
|
||||
{ title: "Generation", url: "/admin/generation", icon: Wand2 },
|
||||
{ title: "Approval Workflows", url: "/admin/approval-workflows", icon: GitBranch },
|
||||
{ titleKey: "nav.assignments", url: "/admin/assignments", icon: ClipboardList },
|
||||
{ titleKey: "nav.examsList", url: "/admin/examsList", icon: FileText },
|
||||
{ titleKey: "nav.examStructures", url: "/admin/exam-structures", icon: Layers },
|
||||
{ titleKey: "nav.rubrics", url: "/admin/rubrics", icon: BookOpen },
|
||||
{ titleKey: "nav.generation", url: "/admin/generation", icon: Wand2 },
|
||||
{ titleKey: "nav.reviewQueue", url: "/admin/exam/review-queue", icon: Sparkles },
|
||||
{ titleKey: "nav.aiPrompts", url: "/admin/ai/prompts", icon: Wand2 },
|
||||
{ titleKey: "nav.aiFeedback", url: "/admin/ai/feedback", icon: Sparkles },
|
||||
{ titleKey: "nav.coursePlans", url: "/admin/course-plans", icon: Compass },
|
||||
{ titleKey: "nav.approvalWorkflows", url: "/admin/approval-workflows", icon: GitBranch },
|
||||
];
|
||||
|
||||
const adaptiveItems: NavItem[] = [
|
||||
{ title: "Taxonomy", url: "/admin/taxonomy", icon: Target },
|
||||
{ title: "Resources", url: "/admin/resources", icon: FolderOpen },
|
||||
{ titleKey: "nav.taxonomy", url: "/admin/taxonomy", icon: Target },
|
||||
{ titleKey: "nav.resources", url: "/admin/resources", icon: FolderOpen },
|
||||
];
|
||||
|
||||
const institutionalItems: NavItem[] = [
|
||||
{ title: "Academic Years", url: "/admin/academic-years", icon: CalendarDays },
|
||||
{ title: "Departments", url: "/admin/departments", icon: Landmark },
|
||||
{ title: "Admissions", url: "/admin/admissions", icon: UserPlus },
|
||||
{ title: "Admission Register", url: "/admin/admission-register", icon: ScrollText },
|
||||
{ title: "Exam Sessions", url: "/admin/exam-sessions", icon: FileText },
|
||||
{ title: "Marksheets", url: "/admin/marksheets", icon: Award },
|
||||
{ title: "Student Leave", url: "/admin/student-leave", icon: CalendarOff },
|
||||
{ title: "Fees & Payments", url: "/admin/fees", icon: DollarSign },
|
||||
{ title: "Lessons", url: "/admin/lessons", icon: BookMarked },
|
||||
{ title: "Gradebook", url: "/admin/gradebook", icon: BarChartHorizontal },
|
||||
{ title: "Student Progress", url: "/admin/student-progress", icon: TrendingUp },
|
||||
{ title: "Library", url: "/admin/library", icon: Library },
|
||||
{ title: "Activities", url: "/admin/activities", icon: Activity },
|
||||
{ title: "Facilities", url: "/admin/facilities", icon: Warehouse },
|
||||
{ titleKey: "nav.academicYears", url: "/admin/academic-years", icon: CalendarDays },
|
||||
{ titleKey: "nav.departments", url: "/admin/departments", icon: Landmark },
|
||||
{ titleKey: "nav.admissions", url: "/admin/admissions", icon: UserPlus },
|
||||
{ titleKey: "nav.admissionRegister", url: "/admin/admission-register", icon: ScrollText },
|
||||
{ titleKey: "nav.examSessions", url: "/admin/exam-sessions", icon: FileText },
|
||||
{ titleKey: "nav.marksheets", url: "/admin/marksheets", icon: Award },
|
||||
{ titleKey: "nav.studentLeave", url: "/admin/student-leave", icon: CalendarOff },
|
||||
{ titleKey: "nav.fees", url: "/admin/fees", icon: DollarSign },
|
||||
{ titleKey: "nav.lessons", url: "/admin/lessons", icon: BookMarked },
|
||||
{ titleKey: "nav.gradebook", url: "/admin/gradebook", icon: BarChartHorizontal },
|
||||
{ titleKey: "nav.studentProgress", url: "/admin/student-progress", icon: TrendingUp },
|
||||
{ titleKey: "nav.library", url: "/admin/library", icon: Library },
|
||||
{ titleKey: "nav.activities", url: "/admin/activities", icon: Activity },
|
||||
{ titleKey: "nav.facilities", url: "/admin/facilities", icon: Warehouse },
|
||||
];
|
||||
|
||||
const managementItems: NavItem[] = [
|
||||
{ title: "Users", url: "/admin/users", icon: Users },
|
||||
{ title: "Entities", url: "/admin/entities", icon: Building2 },
|
||||
{ title: "Classrooms", url: "/admin/classrooms", icon: GraduationCap },
|
||||
{ title: "User Roles", url: "/admin/user-roles", icon: UserCog },
|
||||
{ title: "Roles & Permissions", url: "/admin/roles-permissions", icon: Settings },
|
||||
{ title: "Authority Matrix", url: "/admin/authority-matrix", icon: Workflow },
|
||||
{ titleKey: "nav.users", url: "/admin/users", icon: Users },
|
||||
{ titleKey: "nav.entities", url: "/admin/entities", icon: Building2 },
|
||||
{ titleKey: "nav.userRoles", url: "/admin/user-roles", icon: UserCog },
|
||||
{ titleKey: "nav.rolesPermissions", url: "/admin/roles-permissions", icon: Settings },
|
||||
{ titleKey: "nav.authorityMatrix", url: "/admin/authority-matrix", icon: Workflow },
|
||||
];
|
||||
|
||||
const reportItems: NavItem[] = [
|
||||
{ title: "Student Performance", url: "/admin/student-performance", icon: BarChart3 },
|
||||
{ title: "Stats Corporate", url: "/admin/stats-corporate", icon: Building2 },
|
||||
{ title: "Record", url: "/admin/record", icon: History },
|
||||
{ titleKey: "nav.studentPerformance", url: "/admin/student-performance", icon: BarChart3 },
|
||||
{ titleKey: "nav.statsCorporate", url: "/admin/stats-corporate", icon: Building2 },
|
||||
{ titleKey: "nav.record", url: "/admin/record", icon: History },
|
||||
];
|
||||
|
||||
const trainingItems: NavItem[] = [
|
||||
{ title: "Vocabulary", url: "/admin/training/vocabulary", icon: BookA },
|
||||
{ title: "Grammar", url: "/admin/training/grammar", icon: PenTool },
|
||||
{ titleKey: "nav.vocabulary", url: "/admin/training/vocabulary", icon: BookA },
|
||||
{ titleKey: "nav.grammar", url: "/admin/training/grammar", icon: PenTool },
|
||||
];
|
||||
|
||||
const configItems: NavItem[] = [
|
||||
{ title: "FAQ Manager", url: "/admin/faq", icon: FaqIcon },
|
||||
{ title: "Notification Rules", url: "/admin/notification-rules", icon: Bell },
|
||||
{ title: "Approval Config", url: "/admin/approval-config", icon: Workflow },
|
||||
{ titleKey: "nav.faqManager", url: "/admin/faq", icon: FaqIcon },
|
||||
{ titleKey: "nav.notificationRules", url: "/admin/notification-rules", icon: Bell },
|
||||
{ titleKey: "nav.approvalConfig", url: "/admin/approval-config", icon: Workflow },
|
||||
];
|
||||
|
||||
const supportItems: NavItem[] = [
|
||||
{ title: "Payment Record", url: "/admin/payment-record", icon: CreditCard },
|
||||
{ title: "Tickets", url: "/admin/tickets", icon: Ticket },
|
||||
{ title: "Settings", url: "/admin/settings-platform", icon: Settings },
|
||||
{ titleKey: "nav.paymentRecord", url: "/admin/payment-record", icon: CreditCard },
|
||||
{ titleKey: "nav.tickets", url: "/admin/tickets", icon: Ticket },
|
||||
{ titleKey: "nav.settings", url: "/admin/settings-platform", icon: Settings },
|
||||
];
|
||||
|
||||
// ============= Reusable sidebar group =============
|
||||
function SidebarNavGroup({ label, items }: { label: string; items: NavItem[] }) {
|
||||
function SidebarNavGroup({ labelKey, items }: { labelKey: string; items: NavItem[] }) {
|
||||
const { state } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
const collapsed = state === "collapsed";
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
||||
<SidebarGroupLabel>{t(labelKey)}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton asChild tooltip={item.title}>
|
||||
<NavLink
|
||||
to={item.url}
|
||||
end={item.url.endsWith("/dashboard") || item.url.endsWith("/platform")}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{item.title}</span>}
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{items.map((item) => {
|
||||
const label = t(item.titleKey);
|
||||
return (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton asChild tooltip={label}>
|
||||
<NavLink
|
||||
to={item.url}
|
||||
end={item.url.endsWith("/dashboard") || item.url.endsWith("/platform")}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
@@ -145,40 +163,52 @@ function SidebarNavGroup({ label, items }: { label: string; items: NavItem[] })
|
||||
}
|
||||
|
||||
// ============= Breadcrumbs =============
|
||||
const routeLabels: Record<string, string> = {
|
||||
admin: "Admin", dashboard: "Dashboard", platform: "Platform", courses: "Courses",
|
||||
students: "Students", teachers: "Teachers", batches: "Batches", timetable: "Timetable",
|
||||
reports: "Reports", assignments: "Assignments", examsList: "Exams List",
|
||||
"exam-structures": "Exam Structures", rubrics: "Rubrics", generation: "Generation",
|
||||
"approval-workflows": "Approval Workflows", users: "Users", entities: "Entities",
|
||||
classrooms: "Classrooms", "student-performance": "Student Performance",
|
||||
"stats-corporate": "Stats Corporate", record: "Record", training: "Training",
|
||||
vocabulary: "Vocabulary", grammar: "Grammar", "payment-record": "Payment Record",
|
||||
tickets: "Tickets", "settings-platform": "Settings", settings: "Settings",
|
||||
profile: "Profile", exam: "Exam",
|
||||
"academic-years": "Academic Years", departments: "Departments",
|
||||
admissions: "Admissions", "admission-register": "Admission Register",
|
||||
"exam-sessions": "Exam Sessions", marksheets: "Marksheets",
|
||||
faq: "FAQ Manager", "notification-rules": "Notification Rules",
|
||||
"approval-config": "Approval Config",
|
||||
"student-leave": "Student Leave", fees: "Fees & Payments", lessons: "Lessons",
|
||||
gradebook: "Gradebook", "student-progress": "Student Progress",
|
||||
library: "Library", activities: "Activities", facilities: "Facilities",
|
||||
// Map URL segments to i18n keys so breadcrumbs follow the active locale.
|
||||
// Segments without an entry fall back to a Title-cased version of the slug.
|
||||
const routeLabelKeys: Record<string, string> = {
|
||||
admin: "breadcrumb.admin", dashboard: "breadcrumb.dashboard",
|
||||
platform: "breadcrumb.platform", courses: "nav.courses",
|
||||
students: "nav.students", teachers: "nav.teachers", branches: "nav.branches", batches: "nav.batches",
|
||||
timetable: "nav.timetable", reports: "nav.reports",
|
||||
assignments: "nav.assignments", examsList: "nav.examsList",
|
||||
"exam-structures": "nav.examStructures", rubrics: "nav.rubrics",
|
||||
generation: "nav.generation", "review-queue": "nav.reviewQueue",
|
||||
review: "breadcrumb.review", prompts: "nav.aiPrompts", ai: "breadcrumb.ai",
|
||||
feedback: "breadcrumb.feedback",
|
||||
"approval-workflows": "nav.approvalWorkflows", users: "nav.users",
|
||||
entities: "nav.entities", classrooms: "nav.classrooms",
|
||||
"student-performance": "nav.studentPerformance",
|
||||
"stats-corporate": "nav.statsCorporate", record: "nav.record",
|
||||
training: "sidebarGroup.training", vocabulary: "nav.vocabulary",
|
||||
grammar: "nav.grammar", "payment-record": "nav.paymentRecord",
|
||||
tickets: "nav.tickets", "settings-platform": "nav.settings",
|
||||
settings: "nav.settings", profile: "nav.profile", exam: "breadcrumb.exam",
|
||||
"academic-years": "nav.academicYears", departments: "nav.departments",
|
||||
admissions: "nav.admissions", "admission-register": "nav.admissionRegister",
|
||||
"exam-sessions": "nav.examSessions", marksheets: "nav.marksheets",
|
||||
faq: "nav.faqManager", "notification-rules": "nav.notificationRules",
|
||||
"approval-config": "nav.approvalConfig",
|
||||
"student-leave": "nav.studentLeave", fees: "nav.fees",
|
||||
lessons: "nav.lessons", gradebook: "nav.gradebook",
|
||||
"student-progress": "nav.studentProgress", library: "nav.library",
|
||||
activities: "nav.activities", facilities: "nav.facilities",
|
||||
};
|
||||
|
||||
function AppBreadcrumbs() {
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const segments = location.pathname.split("/").filter(Boolean);
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild><Link to="/admin/dashboard">Home</Link></BreadcrumbLink>
|
||||
<BreadcrumbLink asChild><Link to="/admin/dashboard">{t("common.home")}</Link></BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
{segments.map((seg, i) => {
|
||||
const path = "/" + segments.slice(0, i + 1).join("/");
|
||||
const label = routeLabels[seg] || seg.charAt(0).toUpperCase() + seg.slice(1);
|
||||
const key = routeLabelKeys[seg];
|
||||
const label = key ? t(key) : seg.charAt(0).toUpperCase() + seg.slice(1);
|
||||
const isLast = i === segments.length - 1;
|
||||
return (
|
||||
<React.Fragment key={path}>
|
||||
@@ -194,10 +224,32 @@ function AppBreadcrumbs() {
|
||||
);
|
||||
}
|
||||
|
||||
// ============= Route content fallback =============
|
||||
// Shown only inside the main content area while a lazy-loaded route chunk
|
||||
// is fetching. Keeping the fallback local means the sidebar and header
|
||||
// stay mounted during navigation — previously the page felt like a full
|
||||
// browser reload because the outer App-level Suspense replaced everything
|
||||
// with a full-viewport spinner.
|
||||
function RouteContentFallback() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============= Main Layout =============
|
||||
export default function AdminLmsLayout() {
|
||||
const { user, logout } = useAuth();
|
||||
const { user, logout, selectedEntity, setSelectedEntityId } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
// Hide the floating "Need help?" pill on multi-step wizard routes.
|
||||
// It sits at `fixed bottom-6 end-6` z-50 and was intercepting clicks
|
||||
// on the wizard's own Next/Finish footer (real bug repro: trying to
|
||||
// click "Next" in the Course-plan wizard at the standard 1024×768
|
||||
// viewport hits the pill instead). The AI Assistant orb also at the
|
||||
// bottom-right is preserved — it's smaller and useful in-wizard.
|
||||
const isWizardRoute = !!useMatch("/admin/smart-wizard/*");
|
||||
|
||||
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
|
||||
|
||||
@@ -213,13 +265,53 @@ export default function AdminLmsLayout() {
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<SidebarTrigger />
|
||||
<SidebarTrigger aria-label={t("chrome.toggleSidebar")} />
|
||||
<AppBreadcrumbs />
|
||||
</div>
|
||||
<AiSearchBar />
|
||||
<div className="flex items-center gap-2">
|
||||
{user?.entities?.length ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="max-w-40 truncate">
|
||||
{selectedEntity?.name ?? user.entities[0]?.name ?? t("nav.entities")}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{t("nav.entities")}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{user.entities.map((entity) => (
|
||||
<DropdownMenuItem
|
||||
key={entity.id}
|
||||
onClick={() => {
|
||||
setSelectedEntityId(entity.id);
|
||||
// Force page data to refresh against the newly
|
||||
// selected entity scope.
|
||||
window.location.reload();
|
||||
}}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<span className="truncate">{entity.name}</span>
|
||||
{selectedEntity?.id === entity.id && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{t("common.active", "Active")}
|
||||
</Badge>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<LanguageToggle />
|
||||
<ThemeToggle />
|
||||
<NotificationDropdown />
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/tickets")} className="text-muted-foreground hover:text-foreground">
|
||||
<Button variant="ghost" size="icon" aria-label={t("chrome.ticketsTooltip")} onClick={() => navigate("/admin/tickets")} className="text-muted-foreground hover:text-foreground">
|
||||
<Ticket className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
@@ -232,37 +324,41 @@ export default function AdminLmsLayout() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-sm font-medium">{user?.name ?? "User"}</p>
|
||||
<p className="text-sm font-medium">{user?.name ?? t("userMenu.userFallback")}</p>
|
||||
<p className="text-xs text-muted-foreground">{user?.email ?? ""}</p>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => navigate("/admin/profile")}>
|
||||
<User className="mr-2 h-4 w-4" /> Profile
|
||||
<User className="me-2 h-4 w-4" /> {t("userMenu.profile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate("/admin/settings-platform")}>
|
||||
<Settings className="mr-2 h-4 w-4" /> Settings
|
||||
<Settings className="me-2 h-4 w-4" /> {t("userMenu.settings")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" /> Logout
|
||||
<LogOut className="me-2 h-4 w-4" /> {t("userMenu.logout")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<AiAssistantDrawer />
|
||||
<Link
|
||||
to="/admin/tickets"
|
||||
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Need help?</span>
|
||||
</Link>
|
||||
{!isWizardRoute && (
|
||||
<Link
|
||||
to="/admin/tickets"
|
||||
className="fixed bottom-6 end-6 z-40 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">{t("chrome.needHelp")}</span>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -273,67 +369,78 @@ function AdminSidebar() {
|
||||
const collapsed = state === "collapsed";
|
||||
const { user } = useAuth();
|
||||
const { hasAnyPermission, isAdmin } = usePermissions();
|
||||
const { t, i18n } = useTranslation();
|
||||
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
|
||||
|
||||
const showManagement = isAdmin || hasAnyPermission(["view_entities", "view_students", "view_teachers"]);
|
||||
const showReports = isAdmin || hasAnyPermission(["view_statistics", "view_student_performance", "view_entity_statistics"]);
|
||||
const showPayments = isAdmin || hasAnyPermission(["view_payment_record", "pay_entity"]);
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<Sidebar side={sidebarSide} collapsible="icon">
|
||||
<SidebarHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/logo.png" alt="EnCoach" className="h-8 w-8 rounded-lg shrink-0 object-contain" />
|
||||
{!collapsed && (
|
||||
<span className="text-lg font-bold tracking-tight text-sidebar-foreground">
|
||||
<span className="text-[hsl(42,40%,62%)]">En</span>
|
||||
<span>Coach</span>
|
||||
</span>
|
||||
{collapsed ? (
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt={t("chrome.adminAlt")}
|
||||
className="h-9 w-9 shrink-0 rounded-lg object-contain"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt={t("chrome.adminAltLong")}
|
||||
className="h-14 w-auto shrink-0 object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarSeparator />
|
||||
<SidebarContent>
|
||||
<SidebarNavGroup label="Overview" items={overviewItems} />
|
||||
<SidebarNavGroup label="LMS" items={lmsItems} />
|
||||
<SidebarNavGroup label="Adaptive Learning" items={adaptiveItems} />
|
||||
<SidebarNavGroup label="Institutional" items={institutionalItems} />
|
||||
<SidebarNavGroup label="Academic" items={academicItems} />
|
||||
{showManagement && <SidebarNavGroup label="Management" items={managementItems} />}
|
||||
{showReports && <SidebarNavGroup label="Reports" items={reportItems} />}
|
||||
<SidebarNavGroup label="Configuration" items={configItems} />
|
||||
<SidebarNavGroup labelKey="sidebarGroup.overview" items={overviewItems} />
|
||||
<SidebarNavGroup labelKey="sidebarGroup.lms" items={lmsItems} />
|
||||
<SidebarNavGroup labelKey="sidebarGroup.adaptiveLearning" items={adaptiveItems} />
|
||||
<SidebarNavGroup labelKey="sidebarGroup.institutional" items={institutionalItems} />
|
||||
<SidebarNavGroup labelKey="sidebarGroup.academic" items={academicItems} />
|
||||
{showManagement && <SidebarNavGroup labelKey="sidebarGroup.management" items={managementItems} />}
|
||||
{showReports && <SidebarNavGroup labelKey="sidebarGroup.reports" items={reportItems} />}
|
||||
<SidebarNavGroup labelKey="sidebarGroup.configuration" items={configItems} />
|
||||
|
||||
<SidebarGroup>
|
||||
<Collapsible defaultOpen className="group/collapsible">
|
||||
<SidebarGroupLabel asChild>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between">
|
||||
Training
|
||||
{t("sidebarGroup.training")}
|
||||
{!collapsed && <ChevronDown className="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />}
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{trainingItems.map((item) => (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton asChild tooltip={item.title}>
|
||||
<NavLink
|
||||
to={item.url}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{item.title}</span>}
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{trainingItems.map((item) => {
|
||||
const label = t(item.titleKey);
|
||||
return (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton asChild tooltip={label}>
|
||||
<NavLink
|
||||
to={item.url}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarNavGroup label="Support" items={showPayments ? supportItems : supportItems.filter(i => i.url !== "/admin/payment-record")} />
|
||||
<SidebarNavGroup labelKey="sidebarGroup.support" items={showPayments ? supportItems : supportItems.filter(i => i.url !== "/admin/payment-record")} />
|
||||
</SidebarContent>
|
||||
<SidebarSeparator />
|
||||
<SidebarFooter className="p-3">
|
||||
@@ -343,7 +450,7 @@ function AdminSidebar() {
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name ?? "User"}</span>
|
||||
<span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name ?? t("userMenu.userFallback")}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{user?.email ?? ""}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Outlet, useLocation, Link, useNavigate } from "react-router-dom";
|
||||
import { Outlet, useLocation, Link, useNavigate, useMatch } from "react-router-dom";
|
||||
import { Suspense } from "react";
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { AppSidebar } from "@/components/AppSidebar";
|
||||
import {
|
||||
@@ -6,14 +7,16 @@ import {
|
||||
BreadcrumbPage, BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Ticket, Settings, User, LogOut, HelpCircle } from "lucide-react";
|
||||
import { Ticket, Settings, User, LogOut, HelpCircle, Building2, ChevronDown } from "lucide-react";
|
||||
import React from "react";
|
||||
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
|
||||
import AiSearchBar from "@/components/ai/AiSearchBar";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
const routeLabels: Record<string, string> = {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -77,8 +80,26 @@ function AppBreadcrumbs() {
|
||||
);
|
||||
}
|
||||
|
||||
// Local Suspense fallback so the sidebar/header keep rendering while a
|
||||
// lazy route chunk loads. See AdminLmsLayout for the full rationale.
|
||||
function RouteContentFallback() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppLayout() {
|
||||
const navigate = useNavigate();
|
||||
const { user, selectedEntity, setSelectedEntityId } = useAuth();
|
||||
// Hide the floating "Need help?" pill on multi-step wizard routes.
|
||||
// The pill sits at `fixed bottom-6 right-6` with z-50 and was
|
||||
// intercepting clicks on the wizard's own Next/Finish footer at most
|
||||
// viewport heights, blocking users from finishing the flow. The AI
|
||||
// Assistant orb (also bottom-right) stays — it's smaller and
|
||||
// genuinely useful inside the wizard.
|
||||
const isWizardRoute = !!useMatch("/admin/smart-wizard/*");
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
@@ -92,6 +113,34 @@ export default function AppLayout() {
|
||||
</div>
|
||||
<AiSearchBar />
|
||||
<div className="flex items-center gap-2">
|
||||
{user?.entities?.length ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="max-w-40 truncate">
|
||||
{selectedEntity?.name ?? user.entities[0]?.name ?? "Entity"}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
{user.entities.map((entity) => {
|
||||
const active = selectedEntity?.id === entity.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={entity.id}
|
||||
onClick={() => setSelectedEntityId(entity.id)}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="truncate">{entity.name}</span>
|
||||
{active ? <Badge variant="secondary">Current</Badge> : null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/tickets")} className="text-muted-foreground hover:text-foreground">
|
||||
<Ticket className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -127,19 +176,22 @@ export default function AppLayout() {
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<AiAssistantDrawer />
|
||||
{/* Floating help button */}
|
||||
<Link
|
||||
to="/tickets"
|
||||
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Need help?</span>
|
||||
</Link>
|
||||
{!isWizardRoute && (
|
||||
<Link
|
||||
to="/tickets"
|
||||
className="fixed bottom-6 right-6 z-40 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Need help?</span>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { NavLink } from "@/components/NavLink";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
||||
SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
|
||||
@@ -21,12 +22,12 @@ const mainItems = [
|
||||
{ title: "Rubrics", url: "/rubrics", icon: BookOpen },
|
||||
{ title: "Generation", url: "/generation", icon: Wand2 },
|
||||
{ title: "Approval Workflows", url: "/approval-workflows", icon: GitBranch },
|
||||
{ title: "Classrooms", url: "/classrooms", icon: GraduationCap },
|
||||
];
|
||||
|
||||
const managementItems = [
|
||||
{ title: "Users", url: "/users", icon: Users },
|
||||
{ title: "Entities", url: "/entities", icon: Building2 },
|
||||
{ title: "Classrooms", url: "/classrooms", icon: GraduationCap },
|
||||
];
|
||||
|
||||
const reportItems = [
|
||||
@@ -81,9 +82,11 @@ export function AppSidebar() {
|
||||
const { state } = useSidebar();
|
||||
const collapsed = state === "collapsed";
|
||||
const location = useLocation();
|
||||
const { i18n } = useTranslation();
|
||||
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<Sidebar side={sidebarSide} collapsible="icon">
|
||||
<SidebarHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center shrink-0">
|
||||
@@ -100,7 +103,7 @@ export function AppSidebar() {
|
||||
<SidebarSeparator />
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarNavGroup label="Academic" items={mainItems} />
|
||||
<SidebarNavGroup label="LMS" items={mainItems} />
|
||||
<SidebarNavGroup label="Management" items={managementItems} />
|
||||
<SidebarNavGroup label="Reports" items={reportItems} />
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { withTranslation, type WithTranslation } from "react-i18next";
|
||||
|
||||
interface Props {
|
||||
interface Props extends WithTranslation {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
@@ -10,7 +11,12 @@ interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
/**
|
||||
* Error boundary is a classical React class component, so we bridge it to
|
||||
* i18next via the `withTranslation` HOC. That keeps the tree-shaking of
|
||||
* `react-i18next`'s hook path intact while giving us a `t` prop here.
|
||||
*/
|
||||
class ErrorBoundaryInner extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
@@ -27,6 +33,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) return this.props.fallback;
|
||||
const { t } = this.props;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-6">
|
||||
@@ -36,13 +43,13 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">Something went wrong</h2>
|
||||
<h2 className="text-xl font-semibold">{t("errors.somethingWrong")}</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
An unexpected error occurred. Please try refreshing the page.
|
||||
{t("errors.unexpectedDescription")}
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<details className="text-left text-xs text-muted-foreground bg-muted rounded-lg p-3">
|
||||
<summary className="cursor-pointer font-medium">Error details</summary>
|
||||
<details className="text-start text-xs text-muted-foreground bg-muted rounded-lg p-3">
|
||||
<summary className="cursor-pointer font-medium">{t("errors.errorDetails")}</summary>
|
||||
<pre className="mt-2 whitespace-pre-wrap break-words">{this.state.error.message}</pre>
|
||||
</details>
|
||||
)}
|
||||
@@ -50,7 +57,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
onClick={() => window.location.reload()}
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Refresh Page
|
||||
{t("errors.refreshPage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,3 +67,5 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export const ErrorBoundary = withTranslation()(ErrorBoundaryInner);
|
||||
|
||||
48
src/components/LanguageToggle.tsx
Normal file
48
src/components/LanguageToggle.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Languages } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { SUPPORTED_LANGS, type SupportedLang } from "@/i18n";
|
||||
|
||||
export function LanguageToggle() {
|
||||
const { i18n, t } = useTranslation();
|
||||
const current = (i18n.language?.split("-")[0] || "en") as SupportedLang;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("language.change")}
|
||||
title={t("language.change")}
|
||||
>
|
||||
<Languages className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{t("language.label")}</DropdownMenuLabel>
|
||||
{SUPPORTED_LANGS.map((lang) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={lang.code}
|
||||
checked={current === lang.code}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) void i18n.changeLanguage(lang.code);
|
||||
}}
|
||||
>
|
||||
{lang.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default LanguageToggle;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NavLink as RouterNavLink, NavLinkProps } from "react-router-dom";
|
||||
import { NavLink as RouterNavLink, NavLinkProps, useNavigate } from "react-router-dom";
|
||||
import { forwardRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -9,7 +9,14 @@ interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
|
||||
}
|
||||
|
||||
const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
|
||||
({ className, activeClassName, pendingClassName, to, ...props }, ref) => {
|
||||
({ className, activeClassName, pendingClassName, to, onClick, ...props }, ref) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isExternalTo = (value: NavLinkProps["to"]): boolean => {
|
||||
if (typeof value !== "string") return false;
|
||||
return /^(https?:)?\/\//.test(value) || value.startsWith("mailto:") || value.startsWith("tel:");
|
||||
};
|
||||
|
||||
return (
|
||||
<RouterNavLink
|
||||
ref={ref}
|
||||
@@ -17,6 +24,31 @@ const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
|
||||
className={({ isActive, isPending }) =>
|
||||
cn(className, isActive && activeClassName, isPending && pendingClassName)
|
||||
}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.altKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
props.target === "_blank" ||
|
||||
isExternalTo(to)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Force client-side route transitions for app menu links.
|
||||
event.preventDefault();
|
||||
navigate(to, {
|
||||
replace: props.replace,
|
||||
state: props.state,
|
||||
relative: props.relative,
|
||||
preventScrollReset: props.preventScrollReset,
|
||||
viewTransition: props.viewTransition,
|
||||
});
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,35 +1,50 @@
|
||||
import { Bell } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export default function NotificationDropdown() {
|
||||
const { t } = useTranslation();
|
||||
const notifications: { id: string; title: string; message: string; time: string; read: boolean; type: string }[] = [];
|
||||
const unread = notifications.filter((n) => !n.read).length;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative text-muted-foreground hover:text-foreground">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("notifications.ariaLabel")}
|
||||
title={t("notifications.ariaLabel")}
|
||||
className="relative text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
{unread > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 h-4 w-4 rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground flex items-center justify-center">
|
||||
<span className="absolute -top-0.5 -end-0.5 h-4 w-4 rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground flex items-center justify-center">
|
||||
{unread}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
<div className="px-3 py-2 font-semibold text-sm">Notifications</div>
|
||||
<div className="px-3 py-2 font-semibold text-sm">{t("notifications.title")}</div>
|
||||
<DropdownMenuSeparator />
|
||||
{notifications.slice(0, 4).map((n) => (
|
||||
<DropdownMenuItem key={n.id} className="flex flex-col items-start gap-0.5 py-2.5 cursor-pointer">
|
||||
<span className={`text-sm font-medium ${!n.read ? "text-foreground" : "text-muted-foreground"}`}>{n.title}</span>
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">{n.message}</span>
|
||||
<span className="text-[10px] text-muted-foreground/70">{n.time}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{t("notifications.empty")}
|
||||
</div>
|
||||
) : (
|
||||
notifications.slice(0, 4).map((n) => (
|
||||
<DropdownMenuItem key={n.id} className="flex flex-col items-start gap-0.5 py-2.5 cursor-pointer">
|
||||
<span className={`text-sm font-medium ${!n.read ? "text-foreground" : "text-muted-foreground"}`}>{n.title}</span>
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">{n.message}</span>
|
||||
<span className="text-[10px] text-muted-foreground/70">{n.time}</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
250
src/components/QuickSetupWizard.tsx
Normal file
250
src/components/QuickSetupWizard.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LucideIcon, Check, ChevronRight, Sparkles } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Smart-setup wizard primitives.
|
||||
*
|
||||
* The wizard is purposefully thin: each step / quick-create card is a deep
|
||||
* link into an existing creation page that already knows how to talk to the
|
||||
* backend. We don't re-implement forms here — the value of this screen is
|
||||
* the guided sequence, the visual "what's next" hint, and the completion
|
||||
* ticks that show which parts of the setup still need attention.
|
||||
*
|
||||
* Completion detection is optional and client-only. A caller can supply a
|
||||
* `check` function that returns a Promise<boolean>; the wizard runs it via
|
||||
* react-query so the UI refreshes automatically when the user returns from
|
||||
* a sub-page without any extra plumbing.
|
||||
*/
|
||||
|
||||
export interface WizardStep {
|
||||
/** Stable key used for react-query cache. */
|
||||
id: string;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
/** Destination page that performs the actual creation. */
|
||||
to: string;
|
||||
icon: LucideIcon;
|
||||
/** Optional completion check. When omitted the step is never auto-ticked. */
|
||||
check?: () => Promise<boolean>;
|
||||
/** Optional i18n key for a longer help tooltip. */
|
||||
helpKey?: string;
|
||||
}
|
||||
|
||||
export interface QuickCreate {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
to: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export interface QuickSetupWizardProps {
|
||||
/** Page heading i18n key, e.g. "quickSetup.adminTitle". */
|
||||
titleKey: string;
|
||||
/** Short lead paragraph i18n key. */
|
||||
subtitleKey: string;
|
||||
/** Ordered "Recommended flow" steps. */
|
||||
steps: WizardStep[];
|
||||
/** Side-grid of one-click creates that don't belong to the main flow. */
|
||||
quickCreates: QuickCreate[];
|
||||
}
|
||||
|
||||
export function QuickSetupWizard({
|
||||
titleKey,
|
||||
subtitleKey,
|
||||
steps,
|
||||
quickCreates,
|
||||
}: QuickSetupWizardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Run all completion checks in parallel. Each step with a `check` gets its
|
||||
// own cached query keyed by the step id. Steps without a `check` just
|
||||
// resolve to `undefined` and render as neutral.
|
||||
const queries = useQueries({
|
||||
queries: steps.map((step) => ({
|
||||
queryKey: ["quick-setup-check", step.id],
|
||||
queryFn: step.check ?? (async () => undefined),
|
||||
enabled: Boolean(step.check),
|
||||
// Re-check when the user comes back from a sub-page — that's the most
|
||||
// common path to "un-greying" a step after they've created a rubric /
|
||||
// structure / exam.
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 30_000,
|
||||
})),
|
||||
});
|
||||
|
||||
const completedCount = queries.filter((q) => q.data === true).length;
|
||||
const totalCheckable = steps.filter((s) => s.check).length;
|
||||
const progress = totalCheckable > 0 ? Math.round((completedCount / totalCheckable) * 100) : 0;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="space-y-6">
|
||||
{/* Heading + progress */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t(titleKey)}</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground max-w-2xl">{t(subtitleKey)}</p>
|
||||
</div>
|
||||
{totalCheckable > 0 && (
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("quickSetup.progressLabel")}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">
|
||||
{completedCount}/{totalCheckable}
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 w-32 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recommended flow */}
|
||||
<section aria-labelledby="quick-setup-flow">
|
||||
<h2
|
||||
id="quick-setup-flow"
|
||||
className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3"
|
||||
>
|
||||
{t("quickSetup.recommendedFlow")}
|
||||
</h2>
|
||||
|
||||
<ol className="space-y-3">
|
||||
{steps.map((step, index) => {
|
||||
const query = queries[index];
|
||||
const Icon = step.icon;
|
||||
const isDone = query?.data === true;
|
||||
|
||||
return (
|
||||
<li key={step.id}>
|
||||
<Card
|
||||
className={cn(
|
||||
"transition-shadow hover:shadow-md",
|
||||
isDone && "border-green-500/40 bg-green-500/5",
|
||||
)}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
{/* Step number / done tick */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full font-semibold",
|
||||
isDone
|
||||
? "bg-green-500 text-white"
|
||||
: "bg-primary/10 text-primary",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{isDone ? <Check className="h-5 w-5" /> : index + 1}
|
||||
</div>
|
||||
|
||||
{/* Title + description */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<h3 className="font-medium">{t(step.titleKey)}</h3>
|
||||
{isDone && (
|
||||
<Badge variant="outline" className="border-green-500/50 text-green-600 text-[10px]">
|
||||
{t("quickSetup.ready")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{t(step.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{step.helpKey && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground"
|
||||
aria-label={t("quickSetup.helpAria")}
|
||||
>
|
||||
?
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-xs">
|
||||
{t(step.helpKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button asChild size="sm" variant={isDone ? "outline" : "default"}>
|
||||
<Link to={step.to} className="flex items-center gap-1">
|
||||
{isDone ? t("quickSetup.review") : t("quickSetup.start")}
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Quick creates */}
|
||||
{quickCreates.length > 0 && (
|
||||
<section aria-labelledby="quick-setup-other" className="pt-2">
|
||||
<h2
|
||||
id="quick-setup-other"
|
||||
className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3"
|
||||
>
|
||||
{t("quickSetup.otherQuickCreates")}
|
||||
</h2>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{quickCreates.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Card key={item.id} className="transition-all hover:shadow-md hover:-translate-y-0.5">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<CardTitle className="text-base">{t(item.titleKey)}</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="line-clamp-2">
|
||||
{t(item.descriptionKey)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild variant="secondary" size="sm" className="w-full">
|
||||
<Link to={item.to} className="flex items-center justify-center gap-1">
|
||||
{t("quickSetup.open")}
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuickSetupWizard;
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Outlet, Link, useNavigate, useLocation } from "react-router-dom";
|
||||
import { Outlet, useNavigate } from "react-router-dom";
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import {
|
||||
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
||||
@@ -8,22 +10,28 @@ import {
|
||||
import { NavLink } from "@/components/NavLink";
|
||||
import { useAuth, UserRole } from "@/contexts/AuthContext";
|
||||
import NotificationDropdown from "@/components/NotificationDropdown";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { LanguageToggle } from "@/components/LanguageToggle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { LogOut, User, Settings, LucideIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { LogOut, User, LucideIcon } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Non-admin portal shell (student / teacher / etc.). Nav items hold i18n
|
||||
* keys (`titleKey` / `labelKey`) rather than literal strings so they react
|
||||
* to language changes without re-mounting the layout.
|
||||
*/
|
||||
export interface NavItem {
|
||||
title: string;
|
||||
titleKey: string;
|
||||
url: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export interface NavGroup {
|
||||
label: string;
|
||||
labelKey: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
@@ -34,30 +42,34 @@ interface RoleLayoutProps {
|
||||
|
||||
function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) {
|
||||
const { state } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
const collapsed = state === "collapsed";
|
||||
|
||||
return (
|
||||
<>
|
||||
{navGroups.map((group) => (
|
||||
<SidebarGroup key={group.label}>
|
||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||||
<SidebarGroup key={group.labelKey}>
|
||||
<SidebarGroupLabel>{t(group.labelKey)}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton asChild tooltip={item.title}>
|
||||
<NavLink
|
||||
to={item.url}
|
||||
end={item.url.endsWith("/dashboard")}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{item.title}</span>}
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{group.items.map((item) => {
|
||||
const label = t(item.titleKey);
|
||||
return (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton asChild tooltip={label}>
|
||||
<NavLink
|
||||
to={item.url}
|
||||
end={item.url.endsWith("/dashboard")}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
@@ -66,9 +78,21 @@ function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Keeps the student/teacher sidebar + header mounted while the lazy route
|
||||
// chunk is fetching. See AdminLmsLayout for the rationale.
|
||||
function RouteContentFallback() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation();
|
||||
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
|
||||
|
||||
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
|
||||
|
||||
@@ -77,17 +101,25 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
const roleLabel = t(`roles.${role}`, { defaultValue: role });
|
||||
const portalTitle = `${roleLabel} ${t("chrome.portalSuffix")}`.trim();
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="min-h-screen flex w-full">
|
||||
<Sidebar collapsible="icon">
|
||||
<Sidebar side={sidebarSide} collapsible="icon">
|
||||
<SidebarHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/logo.png" alt="EnCoach" className="h-8 w-8 rounded-lg shrink-0 object-contain" />
|
||||
<span className="text-lg font-bold tracking-tight text-sidebar-foreground group-data-[collapsible=icon]:hidden">
|
||||
<span className="text-[hsl(42,40%,62%)]">En</span>
|
||||
<span>Coach</span>
|
||||
</span>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt={t("chrome.adminAlt")}
|
||||
className="h-9 w-9 shrink-0 rounded-lg object-contain group-data-[collapsible=icon]:block hidden"
|
||||
/>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt={t("chrome.adminAltLong")}
|
||||
className="h-14 w-auto shrink-0 object-contain group-data-[collapsible=icon]:hidden"
|
||||
/>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarSeparator />
|
||||
@@ -101,7 +133,9 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
||||
<span className="text-xs font-semibold text-primary">{initials}</span>
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 group-data-[collapsible=icon]:hidden">
|
||||
<span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name}</span>
|
||||
<span className="text-sm font-medium truncate text-sidebar-foreground">
|
||||
{user?.name ?? t("userMenu.userFallback")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{user?.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,10 +145,12 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<SidebarTrigger />
|
||||
<span className="text-sm font-medium text-muted-foreground capitalize">{role} Portal</span>
|
||||
<SidebarTrigger aria-label={t("chrome.toggleSidebar")} />
|
||||
<span className="text-sm font-medium text-muted-foreground">{portalTitle}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LanguageToggle />
|
||||
<ThemeToggle />
|
||||
<NotificationDropdown />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -126,23 +162,25 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-sm font-medium">{user?.name}</p>
|
||||
<p className="text-sm font-medium">{user?.name ?? t("userMenu.userFallback")}</p>
|
||||
<p className="text-xs text-muted-foreground">{user?.email}</p>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => navigate(`/${role}/profile`)}>
|
||||
<User className="mr-2 h-4 w-4" /> Profile
|
||||
<User className="me-2 h-4 w-4" /> {t("userMenu.profile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" /> Logout
|
||||
<LogOut className="me-2 h-4 w-4" /> {t("userMenu.logout")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,47 +2,53 @@ import RoleLayout, { NavGroup } from "./RoleLayout";
|
||||
import ExamPopup from "./student/ExamPopup";
|
||||
import {
|
||||
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
|
||||
CalendarCheck, Calendar, User, Target, GraduationCap, ListChecks,
|
||||
MessageSquare, Megaphone, Mail, TrendingUp,
|
||||
CalendarCheck, Calendar, User, Target, ListChecks,
|
||||
MessageSquare, Megaphone, Mail, TrendingUp, Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* Student portal shell. Nav items hold i18n keys which `RoleLayout`
|
||||
* resolves via `useTranslation`, so switching language does not require
|
||||
* re-mounting the layout.
|
||||
*/
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
label: "Learning",
|
||||
labelKey: "sidebarGroup.learning",
|
||||
items: [
|
||||
{ title: "Dashboard", url: "/student/dashboard", icon: LayoutDashboard },
|
||||
{ title: "My Courses", url: "/student/courses", icon: BookOpen },
|
||||
{ title: "Subject Registration", url: "/student/subject-registration", icon: ListChecks },
|
||||
{ title: "Assignments", url: "/student/assignments", icon: ClipboardList },
|
||||
{ titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard },
|
||||
{ titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen },
|
||||
{ titleKey: "nav.myCoursePlans", url: "/student/course-plans", icon: Sparkles },
|
||||
{ titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
|
||||
{ titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Adaptive Learning",
|
||||
labelKey: "sidebarGroup.adaptiveLearning",
|
||||
items: [
|
||||
{ title: "My Subjects", url: "/student/subjects", icon: Target },
|
||||
{ titleKey: "nav.mySubjects", url: "/student/subjects", icon: Target },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Progress",
|
||||
labelKey: "sidebarGroup.progress",
|
||||
items: [
|
||||
{ title: "Grades", url: "/student/grades", icon: BarChart3 },
|
||||
{ title: "Attendance", url: "/student/attendance", icon: CalendarCheck },
|
||||
{ title: "Timetable", url: "/student/timetable", icon: Calendar },
|
||||
{ title: "My Journey", url: "/student/journey", icon: TrendingUp },
|
||||
{ titleKey: "nav.grades", url: "/student/grades", icon: BarChart3 },
|
||||
{ titleKey: "nav.attendance", url: "/student/attendance", icon: CalendarCheck },
|
||||
{ titleKey: "nav.timetable", url: "/student/timetable", icon: Calendar },
|
||||
{ titleKey: "nav.myJourney", url: "/student/journey", icon: TrendingUp },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Communication",
|
||||
labelKey: "sidebarGroup.communication",
|
||||
items: [
|
||||
{ title: "Discussions", url: "/student/discussions", icon: MessageSquare },
|
||||
{ title: "Messages", url: "/student/messages", icon: Mail },
|
||||
{ title: "Announcements", url: "/student/announcements", icon: Megaphone },
|
||||
{ titleKey: "nav.discussions", url: "/student/discussions", icon: MessageSquare },
|
||||
{ titleKey: "nav.messages", url: "/student/messages", icon: Mail },
|
||||
{ titleKey: "nav.announcements", url: "/student/announcements", icon: Megaphone },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Account",
|
||||
labelKey: "sidebarGroup.account",
|
||||
items: [
|
||||
{ title: "Profile", url: "/student/profile", icon: User },
|
||||
{ titleKey: "nav.profile", url: "/student/profile", icon: User },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,39 +1,41 @@
|
||||
import RoleLayout, { NavGroup } from "./RoleLayout";
|
||||
import {
|
||||
LayoutDashboard, BookOpen, ClipboardList, FileText,
|
||||
LayoutDashboard, BookOpen, ClipboardList,
|
||||
CalendarCheck, Users, Calendar, User, MessageSquare,
|
||||
Megaphone, Wand2, Library,
|
||||
Megaphone, Library, Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
/** Teacher portal shell. See `StudentLayout` for the nav-config rationale. */
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
label: "Teaching",
|
||||
labelKey: "sidebarGroup.teaching",
|
||||
items: [
|
||||
{ title: "Dashboard", url: "/teacher/dashboard", icon: LayoutDashboard },
|
||||
{ title: "Courses", url: "/teacher/courses", icon: BookOpen },
|
||||
{ title: "Resource Library", url: "/teacher/library", icon: Library },
|
||||
{ title: "Assignments", url: "/teacher/assignments", icon: ClipboardList },
|
||||
{ titleKey: "nav.quickSetup", url: "/teacher/quick-setup", icon: Sparkles },
|
||||
{ titleKey: "nav.dashboard", url: "/teacher/dashboard", icon: LayoutDashboard },
|
||||
{ titleKey: "nav.courses", url: "/teacher/courses", icon: BookOpen },
|
||||
{ titleKey: "nav.resourceLibrary", url: "/teacher/library", icon: Library },
|
||||
{ titleKey: "nav.assignments", url: "/teacher/assignments", icon: ClipboardList },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Management",
|
||||
labelKey: "sidebarGroup.management",
|
||||
items: [
|
||||
{ title: "Attendance", url: "/teacher/attendance", icon: CalendarCheck },
|
||||
{ title: "Students", url: "/teacher/students", icon: Users },
|
||||
{ title: "Timetable", url: "/teacher/timetable", icon: Calendar },
|
||||
{ titleKey: "nav.attendance", url: "/teacher/attendance", icon: CalendarCheck },
|
||||
{ titleKey: "nav.students", url: "/teacher/students", icon: Users },
|
||||
{ titleKey: "nav.timetable", url: "/teacher/timetable", icon: Calendar },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Communication",
|
||||
labelKey: "sidebarGroup.communication",
|
||||
items: [
|
||||
{ title: "Discussions", url: "/teacher/discussions", icon: MessageSquare },
|
||||
{ title: "Announcements", url: "/teacher/announcements", icon: Megaphone },
|
||||
{ titleKey: "nav.discussions", url: "/teacher/discussions", icon: MessageSquare },
|
||||
{ titleKey: "nav.announcements", url: "/teacher/announcements", icon: Megaphone },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Account",
|
||||
labelKey: "sidebarGroup.account",
|
||||
items: [
|
||||
{ title: "Profile", url: "/teacher/profile", icon: User },
|
||||
{ titleKey: "nav.profile", url: "/teacher/profile", icon: User },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
71
src/components/ThemeToggle.tsx
Normal file
71
src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Moon, Sun, Monitor } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
/**
|
||||
* Light/dark/system theme toggle.
|
||||
*
|
||||
* We intentionally render a neutral placeholder until `next-themes` has
|
||||
* hydrated — otherwise the first client render disagrees with the server-less
|
||||
* pre-render and the icon briefly flashes the wrong state.
|
||||
*/
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const isDark = mounted && (resolvedTheme ?? theme) === "dark";
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("theme.toggleLabel")}
|
||||
title={t("theme.toggleLabel")}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{mounted ? (
|
||||
isDark ? (
|
||||
<Moon className="h-4 w-4" />
|
||||
) : (
|
||||
<Sun className="h-4 w-4" />
|
||||
)
|
||||
) : (
|
||||
<Sun className="h-4 w-4 opacity-0" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
<Sun className="me-2 h-4 w-4" /> {t("theme.light")}
|
||||
{theme === "light" && <span className="ms-auto text-xs text-muted-foreground">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
<Moon className="me-2 h-4 w-4" /> {t("theme.dark")}
|
||||
{theme === "dark" && <span className="ms-auto text-xs text-muted-foreground">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
<Monitor className="me-2 h-4 w-4" /> {t("theme.system")}
|
||||
{theme === "system" && <span className="ms-auto text-xs text-muted-foreground">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThemeToggle;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, X, Sparkles, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { analyticsService } from "@/services/analytics.service";
|
||||
@@ -7,6 +8,7 @@ import { analyticsService } from "@/services/analytics.service";
|
||||
export default function AiAlertBanner() {
|
||||
const [dismissedIds, setDismissedIds] = useState<Set<string>>(() => new Set());
|
||||
const [errorDismissed, setErrorDismissed] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: resp, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["ai", "alerts"],
|
||||
@@ -20,20 +22,20 @@ export default function AiAlertBanner() {
|
||||
return (
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-center gap-3">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-warning shrink-0" />
|
||||
<p className="text-sm text-muted-foreground">Loading alerts…</p>
|
||||
<p className="text-sm text-muted-foreground">{t("ai.loadingAlerts")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError && !errorDismissed) {
|
||||
return (
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3">
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3" dir="auto">
|
||||
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3 text-primary" /> Alerts unavailable
|
||||
<Sparkles className="h-3 w-3 text-primary shrink-0" /> {t("ai.alertsUnavailable")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{error instanceof Error ? error.message : "Could not load alerts."}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{error instanceof Error ? error.message : t("ai.couldNotLoadAlerts")}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setErrorDismissed(true)}>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -48,7 +50,7 @@ export default function AiAlertBanner() {
|
||||
return (
|
||||
<div className="rounded-lg border border-muted bg-muted/20 p-4 flex items-start gap-3">
|
||||
<Sparkles className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-muted-foreground">No AI alerts right now.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("ai.noAlertsRightNow")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,11 +60,11 @@ export default function AiAlertBanner() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{visible.map((alert, idx) => (
|
||||
<div key={idx} className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3">
|
||||
<div key={idx} className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3" dir="auto">
|
||||
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3 text-primary" /> {alert.title}
|
||||
<Sparkles className="h-3 w-3 text-primary shrink-0" /> {alert.title}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{alert.description}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -8,19 +9,27 @@ import { Sparkles, Send, Loader2 } from "lucide-react";
|
||||
import { coachingService } from "@/services/coaching.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const quickActions = [
|
||||
"Platform health summary",
|
||||
"Show dropout risks",
|
||||
"Compare course performance",
|
||||
"Suggest batch improvements",
|
||||
];
|
||||
|
||||
export default function AiAssistantDrawer() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<{ role: "user" | "ai"; text: string }[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const location = useLocation();
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Re-compute when language switches so quick-action chips show translated
|
||||
// labels immediately. Chips are stored by label (what we send to the
|
||||
// backend), so localizing the label is safe here — the backend treats
|
||||
// them as free-form prompts.
|
||||
const quickActions = useMemo(
|
||||
() => [
|
||||
t("ai.quickHealth"),
|
||||
t("ai.quickDropouts"),
|
||||
t("ai.quickCompare"),
|
||||
t("ai.quickBatch"),
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const chatMutation = useMutation({
|
||||
mutationFn: (message: string) =>
|
||||
@@ -31,15 +40,12 @@ export default function AiAssistantDrawer() {
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not get a reply",
|
||||
description: err.message || "Something went wrong. Try again.",
|
||||
title: t("ai.replyErrorTitle"),
|
||||
description: err.message || t("ai.replyErrorDesc"),
|
||||
});
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: "ai",
|
||||
text: "Sorry, I could not reach the assistant. Please try again in a moment.",
|
||||
},
|
||||
{ role: "ai", text: t("ai.fallbackReply") },
|
||||
]);
|
||||
},
|
||||
});
|
||||
@@ -56,8 +62,9 @@ export default function AiAssistantDrawer() {
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed bottom-20 right-6 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
aria-label="AI Assistant"
|
||||
className="fixed bottom-20 end-6 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
aria-label={t("ai.assistantLabel")}
|
||||
title={t("ai.assistantLabel")}
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -67,7 +74,7 @@ export default function AiAssistantDrawer() {
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
EnCoach AI Assistant
|
||||
{t("ai.assistantTitle")}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -90,8 +97,8 @@ export default function AiAssistantDrawer() {
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center text-muted-foreground text-sm py-8">
|
||||
<Sparkles className="h-8 w-8 mx-auto mb-3 text-primary/40" />
|
||||
<p>Ask me anything about the platform,</p>
|
||||
<p>or click a quick action above.</p>
|
||||
<p>{t("ai.emptyLine1")}</p>
|
||||
<p>{t("ai.emptyLine2")}</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
@@ -99,27 +106,27 @@ export default function AiAssistantDrawer() {
|
||||
key={i}
|
||||
className={`rounded-lg p-3 text-sm ${
|
||||
msg.role === "user"
|
||||
? "bg-primary text-primary-foreground ml-8"
|
||||
: "bg-muted mr-8"
|
||||
? "bg-primary text-primary-foreground ms-8"
|
||||
: "bg-muted me-8"
|
||||
}`}
|
||||
>
|
||||
{msg.role === "ai" && (
|
||||
<Sparkles className="h-3 w-3 text-primary inline mr-1.5 -mt-0.5" />
|
||||
<Sparkles className="h-3 w-3 text-primary inline me-1.5 -mt-0.5" />
|
||||
)}
|
||||
{msg.text}
|
||||
</div>
|
||||
))}
|
||||
{chatMutation.isPending && (
|
||||
<div className="bg-muted rounded-lg p-3 text-sm mr-8 flex items-center gap-2">
|
||||
<div className="bg-muted rounded-lg p-3 text-sm me-8 flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<span className="text-muted-foreground">Thinking...</span>
|
||||
<span className="text-muted-foreground">{t("ai.thinking")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-3 border-t mt-auto">
|
||||
<Input
|
||||
placeholder="Ask anything..."
|
||||
placeholder={t("ai.placeholder")}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSend(input)}
|
||||
@@ -128,6 +135,8 @@ export default function AiAssistantDrawer() {
|
||||
size="icon"
|
||||
onClick={() => handleSend(input)}
|
||||
disabled={!input.trim() || chatMutation.isPending}
|
||||
aria-label={t("ai.send")}
|
||||
title={t("ai.send")}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Sparkles, TrendingUp, AlertTriangle, Info, Loader2 } from "lucide-react";
|
||||
import { analyticsService, type AiInsightItem } from "@/services/analytics.service";
|
||||
@@ -35,14 +36,15 @@ interface Props {
|
||||
|
||||
export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
const payloadKey = useMemo(() => JSON.stringify(data), [data]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (payload: Record<string, unknown>) => analyticsService.getInsights(payload),
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
title: "Insights unavailable",
|
||||
description: err.message || "Could not load AI insights.",
|
||||
title: t("ai.insightsUnavailable"),
|
||||
description: err.message || t("ai.couldNotLoadInsights"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
@@ -60,21 +62,21 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
AI Platform Insights
|
||||
{t("ai.platformInsightsTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mutation.isPending && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-8 justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
Loading insights…
|
||||
{t("ai.loadingInsights")}
|
||||
</div>
|
||||
)}
|
||||
{mutation.isError && !mutation.isPending && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">Could not load insights.</p>
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">{t("ai.couldNotLoadInsights")}</p>
|
||||
)}
|
||||
{mutation.isSuccess && items.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">No insights available for this view.</p>
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">{t("ai.noInsightsAvailable")}</p>
|
||||
)}
|
||||
{!mutation.isPending && items.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
@@ -82,10 +84,10 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
|
||||
const Icon = insightIcon(item.severity);
|
||||
const color = insightColor(item.severity);
|
||||
return (
|
||||
<div key={idx} className="rounded-lg border bg-muted/30 p-4">
|
||||
<div key={idx} className="rounded-lg border bg-muted/30 p-4" dir="auto">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className={`h-4 w-4 ${color}`} />
|
||||
<span className="text-sm font-semibold">{item.title}</span>
|
||||
<Icon className={`h-4 w-4 shrink-0 ${color}`} />
|
||||
<span className="text-sm font-semibold min-w-0">{item.title}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{item.description}</p>
|
||||
{item.recommendation && (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Sparkles, Search, Loader2, X } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -10,14 +11,15 @@ export default function AiSearchBar() {
|
||||
const [query, setQuery] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const searchMutation = useMutation({
|
||||
mutationFn: (q: string) => analyticsService.search(q),
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Search failed",
|
||||
description: err.message || "Could not complete AI search.",
|
||||
title: t("chrome.aiSearchFailedTitle"),
|
||||
description: err.message || t("chrome.aiSearchFailedDesc"),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -32,11 +34,11 @@ export default function AiSearchBar() {
|
||||
return (
|
||||
<div className="relative max-w-md w-full">
|
||||
<div className="relative">
|
||||
<Sparkles className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary" />
|
||||
<Search className="absolute left-7 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Sparkles className="absolute start-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary" />
|
||||
<Search className="absolute start-7 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Ask anything... e.g. 'Show students with low attendance'"
|
||||
className="pl-12 pr-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1"
|
||||
placeholder={t("chrome.aiSearchPlaceholder")}
|
||||
className="ps-12 pe-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
@@ -50,7 +52,7 @@ export default function AiSearchBar() {
|
||||
setQuery("");
|
||||
searchMutation.reset();
|
||||
}}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
className="absolute end-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -58,11 +60,11 @@ export default function AiSearchBar() {
|
||||
</div>
|
||||
|
||||
{(searchMutation.isPending || result !== undefined) && (
|
||||
<div className="absolute top-full mt-1 left-0 right-0 z-50 rounded-lg border bg-popover p-3 shadow-md">
|
||||
<div className="absolute top-full mt-1 start-0 end-0 z-50 rounded-lg border bg-popover p-3 shadow-md">
|
||||
{searchMutation.isPending ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
AI is searching...
|
||||
{t("chrome.aiSearching")}
|
||||
</div>
|
||||
) : result?.answer ? (
|
||||
<div className="text-sm space-y-2 max-h-64 overflow-y-auto">
|
||||
@@ -72,7 +74,7 @@ export default function AiSearchBar() {
|
||||
</div>
|
||||
{result.suggestions?.length > 0 && (
|
||||
<div className="border-t pt-2 space-y-1">
|
||||
<p className="text-xs font-semibold text-primary">Related queries</p>
|
||||
<p className="text-xs font-semibold text-primary">{t("chrome.aiRelatedQueries")}</p>
|
||||
{result.suggestions.map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
@@ -99,9 +101,7 @@ export default function AiSearchBar() {
|
||||
) : (
|
||||
<div className="text-sm flex items-start gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
<p>
|
||||
No results for "{query}". Try a different question or keyword.
|
||||
</p>
|
||||
<p>{t("chrome.aiNoResults", { q: query })}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sparkles, X, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { coachingService } from "@/services/coaching.service";
|
||||
@@ -12,6 +13,7 @@ interface Props {
|
||||
|
||||
export default function AiTipBanner({ context = "dashboard", variant = "tip", dismissible = true }: Props) {
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["ai", "tip", context],
|
||||
@@ -22,23 +24,30 @@ export default function AiTipBanner({ context = "dashboard", variant = "tip", di
|
||||
|
||||
const bgClass = variant === "recommendation" ? "bg-accent/50 border-accent" : variant === "insight" ? "bg-primary/5 border-primary/20" : "bg-muted/50 border-muted-foreground/10";
|
||||
|
||||
const variantLabel =
|
||||
variant === "insight"
|
||||
? t("ai.insightLabel")
|
||||
: variant === "recommendation"
|
||||
? t("ai.recommendationLabel")
|
||||
: t("ai.tipLabel");
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-center gap-3`}>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">Loading AI tip…</span>
|
||||
<span className="text-sm text-muted-foreground">{t("ai.loadingTip")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`}>
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`} dir="auto">
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<span className="text-xs font-semibold text-primary">AI Tip</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="block text-xs font-semibold text-primary">{t("ai.tipLabel")}</span>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{isError ? (error instanceof Error ? error.message : "Could not load tip.") : "No tip available."}
|
||||
{isError ? (error instanceof Error ? error.message : t("ai.couldNotLoadTip")) : t("ai.noTipAvailable")}
|
||||
</p>
|
||||
</div>
|
||||
{dismissible && (
|
||||
@@ -52,25 +61,25 @@ export default function AiTipBanner({ context = "dashboard", variant = "tip", di
|
||||
|
||||
if (!data.tip?.trim()) {
|
||||
return (
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`}>
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`} dir="auto">
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<span className="text-xs font-semibold text-primary">AI {variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}</span>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">No tip for this context yet.</p>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="block text-xs font-semibold text-primary">{variantLabel}</span>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{t("ai.noTipForContext")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const label = data.category && data.category !== "general"
|
||||
? `AI ${data.category.charAt(0).toUpperCase() + data.category.slice(1)} Tip`
|
||||
: `AI ${variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}`;
|
||||
? t("ai.categoryTipLabel", { category: data.category.charAt(0).toUpperCase() + data.category.slice(1) })
|
||||
: variantLabel;
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3 animate-in fade-in slide-in-from-top-2 duration-300`}>
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3 animate-in fade-in slide-in-from-top-2 duration-300`} dir="auto">
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<span className="text-xs font-semibold text-primary">{label}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="block text-xs font-semibold text-primary">{label}</span>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{data.tip}</p>
|
||||
</div>
|
||||
{dismissible && (
|
||||
|
||||
867
src/components/coursePlan/InteractiveWorkbook.tsx
Normal file
867
src/components/coursePlan/InteractiveWorkbook.tsx
Normal file
@@ -0,0 +1,867 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Award,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
GripVertical,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Save,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { coursePlanService } from "@/services/coursePlan.service";
|
||||
import type {
|
||||
CoursePlanMaterial,
|
||||
GapFillExercise,
|
||||
MatchPairsExercise,
|
||||
MultipleChoiceExercise,
|
||||
ReorderWordsExercise,
|
||||
ShortAnswerExercise,
|
||||
TransformationExercise,
|
||||
WorkbookAttempt,
|
||||
WorkbookAttemptScoreItem,
|
||||
WorkbookBody,
|
||||
WorkbookExercise,
|
||||
} from "@/types";
|
||||
|
||||
/**
|
||||
* Workbook renderer for `material_type === "interactive_workbook"`.
|
||||
*
|
||||
* Modes:
|
||||
* - `student` → answers persist server-side (graded, scored, saved).
|
||||
* - `preview` → no persistence, no Submit; "Show answers" toggle reveals
|
||||
* the key so the admin can verify the v2 generator's output.
|
||||
*
|
||||
* The renderer also accepts a `WorkbookBody` directly (for skill bodies
|
||||
* that embed `interactive_workbook: { exercises: [...] }`), so it can be
|
||||
* reused inside other material renderers without duplicating the logic.
|
||||
*/
|
||||
export type WorkbookMode = "student" | "preview";
|
||||
|
||||
interface InteractiveWorkbookProps {
|
||||
material: Pick<
|
||||
CoursePlanMaterial,
|
||||
"id" | "plan_id" | "body" | "title" | "summary" | "material_type"
|
||||
>;
|
||||
/** Optional override — when present we read exercises from here instead
|
||||
* of `material.body`. Used by skill bodies that embed a workbook one
|
||||
* level deep. */
|
||||
bodyOverride?: WorkbookBody;
|
||||
mode?: WorkbookMode;
|
||||
}
|
||||
|
||||
type AnswerValue = string | string[] | number[][] | undefined;
|
||||
|
||||
function toExercises(body: unknown): WorkbookExercise[] {
|
||||
if (!body || typeof body !== "object") return [];
|
||||
const b = body as Record<string, unknown>;
|
||||
if (Array.isArray(b.exercises)) {
|
||||
return b.exercises as WorkbookExercise[];
|
||||
}
|
||||
// Skill bodies sometimes nest the workbook under `interactive_workbook`.
|
||||
const nested = b.interactive_workbook as { exercises?: WorkbookExercise[] } | undefined;
|
||||
if (nested && Array.isArray(nested.exercises)) {
|
||||
return nested.exercises;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function toLessonPlan(body: unknown): WorkbookBody["lesson_plan"] | undefined {
|
||||
if (!body || typeof body !== "object") return undefined;
|
||||
const b = body as Record<string, unknown>;
|
||||
if (b.lesson_plan && typeof b.lesson_plan === "object") {
|
||||
return b.lesson_plan as WorkbookBody["lesson_plan"];
|
||||
}
|
||||
const nested = b.interactive_workbook as { lesson_plan?: WorkbookBody["lesson_plan"] } | undefined;
|
||||
return nested?.lesson_plan;
|
||||
}
|
||||
|
||||
// Local copies of the backend grading functions for instant per-exercise
|
||||
// feedback while typing. The authoritative score still comes from the
|
||||
// server when we POST — these are only used for the "Check" button.
|
||||
function normText(s: unknown): string {
|
||||
if (s == null) return "";
|
||||
return String(s).replace(/\s+/g, " ").trim().toLowerCase();
|
||||
}
|
||||
function normPunct(s: unknown): string {
|
||||
return normText(s).replace(/[\s.?!,]+$/u, "");
|
||||
}
|
||||
function globMatch(pattern: string, value: string): boolean {
|
||||
const pat = normText(pattern);
|
||||
const val = normText(value);
|
||||
if (!pat) return false;
|
||||
if (!pat.includes("*")) return pat === val;
|
||||
const rx = new RegExp(
|
||||
"^" + pat.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*") + "$",
|
||||
);
|
||||
return rx.test(val);
|
||||
}
|
||||
|
||||
function gradeOne(ex: WorkbookExercise, given: AnswerValue): boolean {
|
||||
if (given === undefined) return false;
|
||||
switch (ex.type) {
|
||||
case "gap_fill": {
|
||||
const accepted = [ex.answer, ...(ex.alt ?? [])];
|
||||
const n = normText(given as string);
|
||||
return accepted.some((a) => normText(a) === n);
|
||||
}
|
||||
case "multiple_choice": {
|
||||
const opts = ex.options ?? [];
|
||||
const ans = ex.answer;
|
||||
const g = normText(given as string);
|
||||
if (g === normText(ans)) return true;
|
||||
if (typeof ans === "string" && ans.length === 1 && /[a-z]/i.test(ans)) {
|
||||
const idx = ans.toUpperCase().charCodeAt(0) - 65;
|
||||
if (opts[idx] && normText(opts[idx]) === g) return true;
|
||||
}
|
||||
if (typeof given === "string" && given.length === 1 && /[a-z]/i.test(given)) {
|
||||
const idx = given.toUpperCase().charCodeAt(0) - 65;
|
||||
if (opts[idx] && normText(opts[idx]) === normText(ans)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "match_pairs": {
|
||||
const pairs = (given as number[][]) ?? [];
|
||||
const expected = ex.answer ?? [];
|
||||
const setOf = (rows: number[][]) =>
|
||||
new Set(rows.filter((p) => p.length === 2).map((p) => `${p[0]},${p[1]}`));
|
||||
const a = setOf(expected);
|
||||
const b = setOf(pairs);
|
||||
if (a.size !== b.size) return false;
|
||||
for (const v of a) if (!b.has(v)) return false;
|
||||
return true;
|
||||
}
|
||||
case "reorder_words": {
|
||||
const value = Array.isArray(given) ? (given as string[]).join(" ") : String(given ?? "");
|
||||
return normText(value) === normText(ex.answer);
|
||||
}
|
||||
case "transformation": {
|
||||
const accepted = [ex.answer, ...(ex.alt ?? [])];
|
||||
const n = normPunct(given as string);
|
||||
return accepted.some((a) => normPunct(a) === n);
|
||||
}
|
||||
case "short_answer": {
|
||||
const accepted = [...(ex.accepted ?? []), ex.answer].filter(Boolean) as string[];
|
||||
return accepted.some((p) => globMatch(p, given as string));
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function InteractiveWorkbook({
|
||||
material,
|
||||
bodyOverride,
|
||||
mode = "student",
|
||||
}: InteractiveWorkbookProps) {
|
||||
const exercises = useMemo<WorkbookExercise[]>(
|
||||
() => toExercises(bodyOverride ?? material.body),
|
||||
[bodyOverride, material.body],
|
||||
);
|
||||
const lessonPlan = useMemo(
|
||||
() => toLessonPlan(bodyOverride ?? material.body),
|
||||
[bodyOverride, material.body],
|
||||
);
|
||||
|
||||
const { toast } = useToast();
|
||||
const [answers, setAnswers] = useState<Record<string, AnswerValue>>({});
|
||||
const [revealedIds, setRevealedIds] = useState<Set<string>>(new Set());
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [serverScore, setServerScore] = useState<{
|
||||
correct: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
items?: WorkbookAttemptScoreItem[];
|
||||
is_final?: boolean;
|
||||
} | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(mode === "student");
|
||||
|
||||
// Load existing attempt (student mode only).
|
||||
useEffect(() => {
|
||||
if (mode !== "student") return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await coursePlanService.myWorkbookAttempt(
|
||||
material.plan_id,
|
||||
material.id,
|
||||
);
|
||||
if (cancelled) return;
|
||||
const attempt = res.data;
|
||||
if (attempt) {
|
||||
setAnswers((attempt.answers ?? {}) as Record<string, AnswerValue>);
|
||||
if (attempt.score && "items" in attempt.score) {
|
||||
setServerScore({
|
||||
correct: attempt.correct_count,
|
||||
total: attempt.total_count,
|
||||
percent: attempt.percent,
|
||||
items: attempt.score.items,
|
||||
is_final: attempt.is_final,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — empty workbook on first open.
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [material.id, material.plan_id, mode]);
|
||||
|
||||
const isFinal = !!serverScore?.is_final;
|
||||
const liveCorrect = useMemo(
|
||||
() => exercises.filter((ex) => gradeOne(ex, answers[ex.id])).length,
|
||||
[answers, exercises],
|
||||
);
|
||||
const livePercent = exercises.length
|
||||
? Math.round((liveCorrect / exercises.length) * 100)
|
||||
: 0;
|
||||
|
||||
const onChange = (id: string, value: AnswerValue) => {
|
||||
setAnswers((prev) => ({ ...prev, [id]: value }));
|
||||
};
|
||||
|
||||
const onCheck = (id: string) => {
|
||||
setRevealedIds((prev) => new Set(prev).add(id));
|
||||
if (mode === "student") void persist(false);
|
||||
};
|
||||
|
||||
const persist = async (finalize: boolean) => {
|
||||
if (mode !== "student") return;
|
||||
if (finalize) setSubmitting(true);
|
||||
else setSaving(true);
|
||||
try {
|
||||
const res = await coursePlanService.saveWorkbookAttempt(
|
||||
material.plan_id,
|
||||
material.id,
|
||||
{ answers, finalize },
|
||||
);
|
||||
const attempt: WorkbookAttempt = res.data;
|
||||
setServerScore({
|
||||
correct: attempt.correct_count,
|
||||
total: attempt.total_count,
|
||||
percent: attempt.percent,
|
||||
items: attempt.score && "items" in attempt.score ? attempt.score.items : [],
|
||||
is_final: attempt.is_final,
|
||||
});
|
||||
if (finalize) {
|
||||
toast({
|
||||
title: "Submitted",
|
||||
description: `${attempt.correct_count} / ${attempt.total_count} correct (${attempt.percent.toFixed(0)}%)`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: finalize ? "Submit failed" : "Save failed",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Debounce per-keystroke saves: every 1.5s after the last change.
|
||||
const saveTimer = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (mode !== "student" || isFinal || loading) return;
|
||||
if (saveTimer.current) window.clearTimeout(saveTimer.current);
|
||||
saveTimer.current = window.setTimeout(() => {
|
||||
void persist(false);
|
||||
}, 1500);
|
||||
return () => {
|
||||
if (saveTimer.current) window.clearTimeout(saveTimer.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [answers]);
|
||||
|
||||
const reset = () => {
|
||||
setAnswers({});
|
||||
setRevealedIds(new Set());
|
||||
};
|
||||
|
||||
if (!exercises.length) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/30 p-6 text-sm text-muted-foreground">
|
||||
This workbook has no exercises yet — generate or extract first.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{mode === "preview" && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-900 flex items-center justify-between">
|
||||
<span>
|
||||
<strong>Preview</strong> — answers won't be saved. Toggle the
|
||||
answer key to verify the generated content.
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={showKey ? "default" : "outline"}
|
||||
onClick={() => setShowKey((v) => !v)}
|
||||
>
|
||||
<Eye className="mr-1 h-4 w-4" />
|
||||
{showKey ? "Hide answers" : "Show answers"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lessonPlan && Object.values(lessonPlan).some(Boolean) && (
|
||||
<details className="rounded-lg border bg-background/70 p-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Teacher's lesson plan
|
||||
</summary>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2 text-sm">
|
||||
{(["warmup", "presentation", "controlled_practice", "freer_practice", "exit_ticket"] as const).map(
|
||||
(k) =>
|
||||
lessonPlan[k] ? (
|
||||
<div key={k}>
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{k.replace(/_/g, " ")}
|
||||
</div>
|
||||
<div className="leading-6">{lessonPlan[k]}</div>
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-background/70 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Award className="h-4 w-4 text-primary" />
|
||||
<strong>{liveCorrect}</strong>
|
||||
<span className="text-muted-foreground">/ {exercises.length} correct (live)</span>
|
||||
{serverScore && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
Server: {serverScore.correct}/{serverScore.total} ({serverScore.percent.toFixed(0)}%)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{mode === "student" && (
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Saving…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-3 w-3" />{" "}
|
||||
{serverScore ? "Auto-saved" : "Auto-save on"}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Progress value={livePercent} className="h-2" />
|
||||
</div>
|
||||
|
||||
<ol className="space-y-3 list-none p-0">
|
||||
{exercises.map((ex, idx) => {
|
||||
const value = answers[ex.id];
|
||||
const checked = revealedIds.has(ex.id) || isFinal || showKey;
|
||||
const correct = checked ? gradeOne(ex, value) : null;
|
||||
return (
|
||||
<li
|
||||
key={ex.id || idx}
|
||||
className={cn(
|
||||
"rounded-lg border p-4 bg-background/70 space-y-3",
|
||||
checked && correct === true && "border-emerald-400 bg-emerald-50/40",
|
||||
checked && correct === false && "border-rose-400 bg-rose-50/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Exercise {idx + 1} · {ex.type.replace(/_/g, " ")}
|
||||
</div>
|
||||
{checked && (
|
||||
<Badge variant={correct ? "default" : "destructive"} className="capitalize">
|
||||
{correct ? (
|
||||
<>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" /> Correct
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="mr-1 h-3 w-3" /> Try again
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderExercise(ex, value, (v) => onChange(ex.id, v), {
|
||||
disabled: mode === "preview" || isFinal,
|
||||
showKey: showKey,
|
||||
})}
|
||||
|
||||
{ex.hint && !checked && (
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Lightbulb className="h-3 w-3" /> {ex.hint}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checked && (
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
{!correct && (
|
||||
<div>
|
||||
Expected: <span className="font-medium">{formatAnswer(ex)}</span>
|
||||
</div>
|
||||
)}
|
||||
{ex.rationale && <div>{ex.rationale}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isFinal && mode !== "preview" && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onCheck(ex.id)}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-4 w-4" /> Check
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{mode === "student" && (
|
||||
<div className="flex items-center justify-between rounded-lg border bg-background/70 p-3">
|
||||
<Button type="button" variant="ghost" onClick={reset} disabled={isFinal}>
|
||||
<RotateCcw className="mr-1 h-4 w-4" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={submitting || isFinal}
|
||||
onClick={() => persist(true)}
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{isFinal ? "Submitted" : "Submit final"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-type renderers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderExercise(
|
||||
ex: WorkbookExercise,
|
||||
value: AnswerValue,
|
||||
onChange: (v: AnswerValue) => void,
|
||||
opts: { disabled?: boolean; showKey?: boolean },
|
||||
): React.ReactNode {
|
||||
switch (ex.type) {
|
||||
case "gap_fill":
|
||||
return <GapFill ex={ex} value={value as string} onChange={onChange} disabled={opts.disabled} />;
|
||||
case "multiple_choice":
|
||||
return (
|
||||
<MultipleChoice
|
||||
ex={ex}
|
||||
value={value as string}
|
||||
onChange={onChange}
|
||||
disabled={opts.disabled}
|
||||
/>
|
||||
);
|
||||
case "match_pairs":
|
||||
return (
|
||||
<MatchPairs
|
||||
ex={ex}
|
||||
value={value as number[][]}
|
||||
onChange={onChange}
|
||||
disabled={opts.disabled}
|
||||
/>
|
||||
);
|
||||
case "reorder_words":
|
||||
return (
|
||||
<ReorderWords
|
||||
ex={ex}
|
||||
value={value as string[]}
|
||||
onChange={onChange}
|
||||
disabled={opts.disabled}
|
||||
/>
|
||||
);
|
||||
case "transformation":
|
||||
return (
|
||||
<Transformation
|
||||
ex={ex}
|
||||
value={value as string}
|
||||
onChange={onChange}
|
||||
disabled={opts.disabled}
|
||||
/>
|
||||
);
|
||||
case "short_answer":
|
||||
return (
|
||||
<ShortAnswer
|
||||
ex={ex}
|
||||
value={value as string}
|
||||
onChange={onChange}
|
||||
disabled={opts.disabled}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <p className="text-sm text-muted-foreground">Unsupported exercise type.</p>;
|
||||
}
|
||||
}
|
||||
|
||||
function formatAnswer(ex: WorkbookExercise): string {
|
||||
switch (ex.type) {
|
||||
case "match_pairs":
|
||||
return (ex.answer ?? [])
|
||||
.map((p) => `${ex.left[p[0]] ?? "?"} ↔ ${ex.right[p[1]] ?? "?"}`)
|
||||
.join(", ");
|
||||
case "reorder_words":
|
||||
return ex.answer;
|
||||
default:
|
||||
return String((ex as { answer?: unknown }).answer ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type-specific input subcomponents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function GapFill({
|
||||
ex,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
ex: GapFillExercise;
|
||||
value: string | undefined;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
// Replace ___ in the stem with an inline input. Falls back to a stem
|
||||
// label + standalone input when there's no underscore marker.
|
||||
const parts = ex.stem.split(/_{2,}/);
|
||||
if (parts.length === 1) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="leading-7">{ex.stem}</p>
|
||||
<Input
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder="Type your answer"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="leading-8 text-base flex flex-wrap items-center gap-1">
|
||||
{parts.map((part, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1">
|
||||
<span>{part}</span>
|
||||
{i < parts.length - 1 && (
|
||||
<Input
|
||||
type="text"
|
||||
className="inline-block w-32 align-baseline"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder="…"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function MultipleChoice({
|
||||
ex,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
ex: MultipleChoiceExercise;
|
||||
value: string | undefined;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="leading-7">{ex.stem}</p>
|
||||
<RadioGroup
|
||||
value={value ?? ""}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled}
|
||||
className="grid gap-2 sm:grid-cols-2"
|
||||
>
|
||||
{ex.options.map((opt, i) => {
|
||||
const id = `${ex.id}_opt_${i}`;
|
||||
return (
|
||||
<Label
|
||||
key={id}
|
||||
htmlFor={id}
|
||||
className="flex items-start gap-2 rounded-md border p-2 cursor-pointer hover:bg-muted"
|
||||
>
|
||||
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
|
||||
<span className="text-sm">{opt}</span>
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchPairs({
|
||||
ex,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
ex: MatchPairsExercise;
|
||||
value: number[][] | undefined;
|
||||
onChange: (v: number[][]) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [selectedLeft, setSelectedLeft] = useState<number | null>(null);
|
||||
const pairs = value ?? [];
|
||||
const pairedLeft = new Set(pairs.map((p) => p[0]));
|
||||
const pairedRight = new Set(pairs.map((p) => p[1]));
|
||||
|
||||
const tapLeft = (idx: number) => {
|
||||
if (disabled) return;
|
||||
setSelectedLeft(idx);
|
||||
};
|
||||
const tapRight = (idx: number) => {
|
||||
if (disabled || selectedLeft == null) return;
|
||||
const next = pairs.filter((p) => p[0] !== selectedLeft && p[1] !== idx);
|
||||
next.push([selectedLeft, idx]);
|
||||
onChange(next);
|
||||
setSelectedLeft(null);
|
||||
};
|
||||
const clear = (li: number) => {
|
||||
if (disabled) return;
|
||||
onChange(pairs.filter((p) => p[0] !== li));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">Match these…</div>
|
||||
{ex.left.map((item, i) => {
|
||||
const paired = pairs.find((p) => p[0] === i);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`L${i}`}
|
||||
onClick={() => (paired ? clear(i) : tapLeft(i))}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"block w-full rounded border px-2 py-1.5 text-left",
|
||||
selectedLeft === i && "border-primary ring-1 ring-primary",
|
||||
paired && "bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground mr-1">{i + 1}.</span>
|
||||
{item}
|
||||
{paired ? (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
→ {ex.right[paired[1]]}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">…with these</div>
|
||||
{ex.right.map((item, j) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`R${j}`}
|
||||
onClick={() => tapRight(j)}
|
||||
disabled={disabled || selectedLeft == null}
|
||||
className={cn(
|
||||
"block w-full rounded border px-2 py-1.5 text-left",
|
||||
pairedRight.has(j) && "bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground mr-1">{String.fromCharCode(65 + j)}.</span>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="col-span-2 text-xs text-muted-foreground">
|
||||
Tap a left item, then tap its match on the right. Tap a paired left
|
||||
item to clear it.
|
||||
</div>
|
||||
{!pairedLeft.size && (
|
||||
<div className="col-span-2 text-xs text-muted-foreground">
|
||||
{ex.left.length} pair(s) to match.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReorderWords({
|
||||
ex,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
ex: ReorderWordsExercise;
|
||||
value: string[] | undefined;
|
||||
onChange: (v: string[]) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
// Stateful tap-to-insert: tap a token in the bank to append; tap a
|
||||
// token in the answer strip to remove. Keeps it touch-friendly.
|
||||
const order = value ?? [];
|
||||
const remaining: { tok: string; bankIdx: number }[] = [];
|
||||
const used: number[] = [];
|
||||
// Build maps so duplicate tokens stay independently selectable.
|
||||
const usedCounts = new Map<string, number>();
|
||||
order.forEach((t) => usedCounts.set(t, (usedCounts.get(t) ?? 0) + 1));
|
||||
const seenWhileBuilding = new Map<string, number>();
|
||||
ex.tokens.forEach((tok, idx) => {
|
||||
const seen = seenWhileBuilding.get(tok) ?? 0;
|
||||
const usesLeft = (usedCounts.get(tok) ?? 0) - seen;
|
||||
if (usesLeft > 0) {
|
||||
used.push(idx);
|
||||
seenWhileBuilding.set(tok, seen + 1);
|
||||
} else {
|
||||
remaining.push({ tok, bankIdx: idx });
|
||||
}
|
||||
});
|
||||
|
||||
const append = (tok: string) => {
|
||||
if (disabled) return;
|
||||
onChange([...order, tok]);
|
||||
};
|
||||
const removeAt = (i: number) => {
|
||||
if (disabled) return;
|
||||
onChange(order.filter((_, idx) => idx !== i));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="rounded border bg-muted/30 p-2 min-h-10 flex flex-wrap gap-1">
|
||||
{order.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">Tap tokens below to build the sentence…</span>
|
||||
) : (
|
||||
order.map((tok, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`a${i}`}
|
||||
onClick={() => removeAt(i)}
|
||||
disabled={disabled}
|
||||
className="rounded bg-background border px-2 py-1"
|
||||
>
|
||||
<GripVertical className="mr-1 inline h-3 w-3 text-muted-foreground" />
|
||||
{tok}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{remaining.map(({ tok, bankIdx }) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`b${bankIdx}`}
|
||||
onClick={() => append(tok)}
|
||||
disabled={disabled}
|
||||
className="rounded border px-2 py-1 hover:bg-muted"
|
||||
>
|
||||
{tok}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Transformation({
|
||||
ex,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
ex: TransformationExercise;
|
||||
value: string | undefined;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border bg-muted/30 px-3 py-2 text-sm">
|
||||
<Badge variant="outline" className="mr-2">
|
||||
{ex.instruction}
|
||||
</Badge>
|
||||
<span className="font-medium">{ex.from}</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
rows={2}
|
||||
placeholder="Transformed sentence"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortAnswer({
|
||||
ex,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
ex: ShortAnswerExercise;
|
||||
value: string | undefined;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="leading-7">{ex.stem}</p>
|
||||
<Textarea
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
rows={3}
|
||||
placeholder="Type your answer"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
214
src/components/coursePlan/LibraryPickerDialog.tsx
Normal file
214
src/components/coursePlan/LibraryPickerDialog.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Library, Loader2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
import { resourcesService } from "@/services/resources.service";
|
||||
import type { Resource } from "@/types";
|
||||
|
||||
/**
|
||||
* Generic, reusable picker over `/api/resources`.
|
||||
*
|
||||
* Two callers wire this up today:
|
||||
*
|
||||
* 1. **AdminCoursePlanDetail** — for an *existing* plan, so it forwards
|
||||
* the picked resources to `coursePlanService.attachResources` in the
|
||||
* `onConfirm` handler and invalidates the sources query.
|
||||
*
|
||||
* 2. **CoursePlanWizard** — the plan does not exist yet at pick time,
|
||||
* so the wizard simply pushes the picks into its draft state and
|
||||
* attaches them in one batch after the plan is created.
|
||||
*
|
||||
* The dialog itself is purposefully agnostic: it only reports the
|
||||
* selected `Resource` rows; the parent decides what to do.
|
||||
*/
|
||||
export function LibraryPickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
alreadyLinkedIds,
|
||||
onConfirm,
|
||||
isPending,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Resources whose ids are in this set render disabled + checked. */
|
||||
alreadyLinkedIds?: Set<number>;
|
||||
/** Called when the admin clicks "Attach selected". */
|
||||
onConfirm: (resources: Resource[]) => void | Promise<void>;
|
||||
/** Optional spinner state from the parent's mutation. */
|
||||
isPending?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
|
||||
// Reset every time the dialog re-opens so a previous session's
|
||||
// selection doesn't bleed into the next attach.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelected(new Set());
|
||||
setSearch("");
|
||||
setTypeFilter("all");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["library-resources", { search, type: typeFilter }],
|
||||
queryFn: () =>
|
||||
resourcesService.list({
|
||||
search: search || undefined,
|
||||
resource_type: typeFilter === "all" ? undefined : typeFilter,
|
||||
review_status: "approved",
|
||||
}),
|
||||
enabled: open,
|
||||
});
|
||||
const resources: Resource[] = data?.items ?? [];
|
||||
|
||||
const toggle = (id: number) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const picked = resources.filter((r) => selected.has(r.id));
|
||||
if (!picked.length) return;
|
||||
await onConfirm(picked);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[640px] max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Library className="h-5 w-5 text-primary" />
|
||||
{t("coursePlan.sources.libraryTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("coursePlan.sources.libraryDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Input
|
||||
placeholder={t("coursePlan.sources.librarySearchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("coursePlan.sources.libraryTypeAll")}
|
||||
</SelectItem>
|
||||
<SelectItem value="pdf">PDF</SelectItem>
|
||||
<SelectItem value="document">DOCX</SelectItem>
|
||||
<SelectItem value="link">URL</SelectItem>
|
||||
<SelectItem value="article">Article</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto -mx-2 px-2 space-y-1">
|
||||
{isLoading && <Skeleton className="h-32 w-full" />}
|
||||
{!isLoading && resources.length === 0 && (
|
||||
<p className="text-center text-sm text-muted-foreground py-8">
|
||||
{t("coursePlan.sources.libraryEmpty")}
|
||||
</p>
|
||||
)}
|
||||
{!isLoading &&
|
||||
resources.map((r) => {
|
||||
const isLinked = alreadyLinkedIds?.has(r.id) ?? false;
|
||||
const isSelected = selected.has(r.id);
|
||||
return (
|
||||
<label
|
||||
key={r.id}
|
||||
className={`flex items-start gap-3 rounded-md border p-2 text-sm transition-colors ${
|
||||
isLinked
|
||||
? "opacity-60 cursor-not-allowed bg-muted/40"
|
||||
: "cursor-pointer hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isLinked || isSelected}
|
||||
disabled={isLinked}
|
||||
onCheckedChange={() => !isLinked && toggle(r.id)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium truncate">{r.name}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] capitalize"
|
||||
>
|
||||
{r.resource_type || r.type || "resource"}
|
||||
</Badge>
|
||||
{isLinked && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{t("coursePlan.sources.libraryAlreadyLinked")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{(r.subject_name || (r.topic_names ?? []).length > 0) && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{[r.subject_name, ...(r.topic_names?.slice(0, 2) ?? [])]
|
||||
.filter(Boolean)
|
||||
.join(" › ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t pt-3">
|
||||
<span className="mr-auto text-xs text-muted-foreground self-center">
|
||||
{t("coursePlan.sources.librarySelectedCount", {
|
||||
count: selected.size,
|
||||
})}
|
||||
</span>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selected.size || isPending}
|
||||
>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t("coursePlan.sources.libraryAttach")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
139
src/components/coursePlan/MaterialBookView.tsx
Normal file
139
src/components/coursePlan/MaterialBookView.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CoursePlanMaterial, WorkbookBody } from "@/types";
|
||||
|
||||
import InteractiveWorkbook, { type WorkbookMode } from "./InteractiveWorkbook";
|
||||
|
||||
export const SKILL_STYLE: Record<string, string> = {
|
||||
reading: "bg-blue-100 text-blue-800 border-blue-200",
|
||||
writing: "bg-purple-100 text-purple-800 border-purple-200",
|
||||
listening: "bg-emerald-100 text-emerald-800 border-emerald-200",
|
||||
speaking: "bg-amber-100 text-amber-800 border-amber-200",
|
||||
grammar: "bg-rose-100 text-rose-800 border-rose-200",
|
||||
vocabulary: "bg-cyan-100 text-cyan-800 border-cyan-200",
|
||||
integrated: "bg-slate-100 text-slate-800 border-slate-200",
|
||||
};
|
||||
|
||||
export function SkillBadge({ skill }: { skill: string }) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("capitalize border", SKILL_STYLE[skill] ?? SKILL_STYLE.integrated)}
|
||||
>
|
||||
{skill || "integrated"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function renderAny(value: unknown): React.ReactNode {
|
||||
if (value == null) return null;
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return <p className="leading-7">{String(value)}</p>;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return null;
|
||||
return (
|
||||
<ul className="list-disc ps-5 space-y-1">
|
||||
{value.map((item, idx) => (
|
||||
<li key={idx}>{renderAny(item)}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{entries.map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<h5 className="font-medium capitalize text-sm text-muted-foreground mb-1">
|
||||
{k.replace(/_/g, " ")}
|
||||
</h5>
|
||||
<div className="text-sm">{renderAny(v)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type MaterialForBook = Pick<
|
||||
CoursePlanMaterial,
|
||||
"id" | "plan_id" | "body" | "body_text" | "material_type" | "title" | "summary"
|
||||
>;
|
||||
|
||||
export default function MaterialBookView({
|
||||
material,
|
||||
mode = "student",
|
||||
}: {
|
||||
// The legacy callsites only pass body/body_text/material_type, so we
|
||||
// tolerate that by making id/plan_id optional in the prop type even
|
||||
// though the types/index file marks them as required. Required props
|
||||
// are validated at runtime when interactive_workbook is dispatched.
|
||||
material: Partial<MaterialForBook> &
|
||||
Pick<CoursePlanMaterial, "body" | "body_text" | "material_type">;
|
||||
mode?: WorkbookMode;
|
||||
}) {
|
||||
const body = material.body ?? {};
|
||||
const hasStructured = Object.keys(body).length > 0;
|
||||
|
||||
// Dispatch to the interactive renderer for workbook materials. The
|
||||
// skill-bodied workbooks (grammar/vocabulary that embed their own
|
||||
// `interactive_workbook` block) render via the generic walker AND
|
||||
// the embedded workbook below for the exercises portion.
|
||||
if (
|
||||
material.material_type === "interactive_workbook" &&
|
||||
typeof material.id === "number" &&
|
||||
typeof material.plan_id === "number"
|
||||
) {
|
||||
return (
|
||||
<InteractiveWorkbook
|
||||
material={material as MaterialForBook}
|
||||
mode={mode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// For grammar / vocabulary that embed an interactive workbook inside
|
||||
// their body, we render the prose first and then the workbook UI so
|
||||
// students can practise without leaving the lesson.
|
||||
const embeddedWorkbook = (body as { interactive_workbook?: WorkbookBody })
|
||||
.interactive_workbook;
|
||||
const hasEmbedded =
|
||||
embeddedWorkbook &&
|
||||
Array.isArray(embeddedWorkbook.exercises) &&
|
||||
embeddedWorkbook.exercises.length > 0 &&
|
||||
typeof material.id === "number" &&
|
||||
typeof material.plan_id === "number";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background/70 p-4 space-y-4">
|
||||
{hasStructured ? (
|
||||
renderAny(stripEmbedded(body))
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap leading-7">
|
||||
{material.body_text || "No content available yet."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasEmbedded && (
|
||||
<div className="border-t pt-3">
|
||||
<div className="text-sm font-medium mb-2">Practice</div>
|
||||
<InteractiveWorkbook
|
||||
material={material as MaterialForBook}
|
||||
bodyOverride={embeddedWorkbook!}
|
||||
mode={mode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function stripEmbedded(body: Record<string, unknown>): Record<string, unknown> {
|
||||
if (!body || typeof body !== "object") return body;
|
||||
if (!("interactive_workbook" in body)) return body;
|
||||
const { interactive_workbook: _drop, ...rest } = body;
|
||||
return rest;
|
||||
}
|
||||
390
src/components/coursePlan/PlanReader.tsx
Normal file
390
src/components/coursePlan/PlanReader.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BookOpen,
|
||||
Calendar,
|
||||
ClipboardList,
|
||||
Headphones,
|
||||
Image as ImageIcon,
|
||||
Library,
|
||||
Link2,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
Music,
|
||||
PenSquare,
|
||||
Sparkles,
|
||||
Type,
|
||||
Video,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { withAuthQuery } from "@/lib/api-client";
|
||||
import MaterialBookView, {
|
||||
SkillBadge,
|
||||
} from "@/components/coursePlan/MaterialBookView";
|
||||
import type { WorkbookMode } from "@/components/coursePlan/InteractiveWorkbook";
|
||||
import type {
|
||||
CoursePlan,
|
||||
CoursePlanMaterial,
|
||||
CoursePlanMedia,
|
||||
CoursePlanWeek,
|
||||
} from "@/types";
|
||||
|
||||
const SKILL_ICONS: Record<string, LucideIcon> = {
|
||||
reading: BookOpen,
|
||||
writing: PenSquare,
|
||||
listening: Headphones,
|
||||
speaking: Mic,
|
||||
grammar: Type,
|
||||
vocabulary: Library,
|
||||
integrated: MessageSquare,
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-only renderer for a course plan that BOTH the student page and
|
||||
* the admin "View as Student" preview share. The only difference is
|
||||
* the workbook `mode`:
|
||||
*
|
||||
* - `mode="student"` → answers persist server-side (real attempts).
|
||||
* - `mode="preview"` → InteractiveWorkbook runs in preview mode (no
|
||||
* persistence) and surfaces an "Show answer key" toggle so admins
|
||||
* can verify the v2 generator's output.
|
||||
*
|
||||
* Materials are rendered through `MaterialBookView`, which dispatches
|
||||
* `interactive_workbook` materials into the live workbook component.
|
||||
*/
|
||||
export interface PlanReaderProps {
|
||||
plan: CoursePlan;
|
||||
mode?: WorkbookMode;
|
||||
/** Heading level for accessibility — admins use this inside their own
|
||||
* Card; students use it as the page's main grid block. Defaults to 'h3'. */
|
||||
headingClassName?: string;
|
||||
}
|
||||
|
||||
export default function PlanReader({ plan, mode = "student" }: PlanReaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const materialsByWeek = useMemo(() => {
|
||||
const map = new Map<number, CoursePlanMaterial[]>();
|
||||
if (!plan.materials) return map;
|
||||
for (const m of plan.materials) {
|
||||
const list = map.get(m.week_number) ?? [];
|
||||
list.push(m);
|
||||
map.set(m.week_number, list);
|
||||
}
|
||||
return map;
|
||||
}, [plan.materials]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-2xl">{plan.name}</CardTitle>
|
||||
{plan.description && (
|
||||
<CardDescription className="max-w-3xl">
|
||||
{plan.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Badge variant="secondary" className="uppercase">
|
||||
{plan.cefr_level}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{t("coursePlan.hoursPerWeek", {
|
||||
count: plan.contact_hours_per_week,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{plan.assignment && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{plan.assignment.due_date
|
||||
? t("coursePlan.student.due", { date: plan.assignment.due_date })
|
||||
: t("coursePlan.student.noDue")}
|
||||
{plan.assignment.assigned_by_name && (
|
||||
<span>
|
||||
·{" "}
|
||||
{t("coursePlan.student.assignedBy", {
|
||||
name: plan.assignment.assigned_by_name,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{plan.assignment?.message && (
|
||||
<p className="text-sm bg-muted/40 rounded-md px-3 py-2 mt-2">
|
||||
{plan.assignment.message}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{plan.objectives && plan.objectives.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-primary" />
|
||||
{t("coursePlan.sections.objectives")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ol className="list-decimal list-inside space-y-1 text-sm">
|
||||
{plan.objectives.map((o, i) => (
|
||||
<li key={i}>{o}</li>
|
||||
))}
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{plan.weeks && plan.weeks.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
{t("coursePlan.sections.delivery")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("coursePlan.deliveryHint")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{plan.weeks.map((w) => (
|
||||
<ReaderWeek
|
||||
key={w.id}
|
||||
week={w}
|
||||
materials={materialsByWeek.get(w.week_number) ?? []}
|
||||
mode={mode}
|
||||
/>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReaderWeek({
|
||||
week,
|
||||
materials,
|
||||
mode,
|
||||
}: {
|
||||
week: CoursePlanWeek;
|
||||
materials: CoursePlanMaterial[];
|
||||
mode: WorkbookMode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [skillFilter, setSkillFilter] = useState<string>("all");
|
||||
const skills = useMemo(
|
||||
() => Array.from(new Set(materials.map((m) => m.skill))).sort(),
|
||||
[materials],
|
||||
);
|
||||
const filteredMaterials = useMemo(
|
||||
() =>
|
||||
materials.filter(
|
||||
(m) => skillFilter === "all" || m.skill === skillFilter,
|
||||
),
|
||||
[materials, skillFilter],
|
||||
);
|
||||
return (
|
||||
<AccordionItem value={String(week.week_number)}>
|
||||
<AccordionTrigger className="text-left">
|
||||
<div className="flex-1 flex items-center gap-3 min-w-0">
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{t("coursePlan.weekN", { n: week.week_number })}
|
||||
</Badge>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{week.focus || week.unit || "—"}
|
||||
</div>
|
||||
{week.date_label && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{week.date_label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3">
|
||||
{skills.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={skillFilter === "all" ? "default" : "outline"}
|
||||
onClick={() => setSkillFilter("all")}
|
||||
>
|
||||
{t("common.all", "All")}
|
||||
</Button>
|
||||
{skills.map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
size="sm"
|
||||
variant={skillFilter === s ? "default" : "outline"}
|
||||
onClick={() => setSkillFilter(s)}
|
||||
className="capitalize"
|
||||
>
|
||||
{s}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{materials.length === 0 && (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
{t("coursePlan.media.noMedia")}
|
||||
</p>
|
||||
)}
|
||||
{filteredMaterials.map((m) => (
|
||||
<ReaderMaterial key={m.id} material={m} mode={mode} />
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ReaderMaterial({
|
||||
material,
|
||||
mode,
|
||||
}: {
|
||||
material: CoursePlanMaterial;
|
||||
mode: WorkbookMode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Icon className="h-4 w-4 text-primary" />
|
||||
<CardTitle className="text-base flex-1 min-w-0">
|
||||
{material.title}
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{t(
|
||||
`coursePlan.materialType.${material.material_type}`,
|
||||
material.material_type,
|
||||
)}
|
||||
</Badge>
|
||||
<SkillBadge skill={material.skill} />
|
||||
<GroundingBadge material={material} />
|
||||
</div>
|
||||
{material.summary && (
|
||||
<CardDescription>{material.summary}</CardDescription>
|
||||
)}
|
||||
{material.share_date && (
|
||||
<CardDescription>
|
||||
{t("coursePlan.shareDate", "Share date")}: {material.share_date}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(material.media ?? []).map((m) => (
|
||||
<ReaderMediaTile key={m.id} media={m} />
|
||||
))}
|
||||
<MaterialBookView material={material} mode={mode} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ReaderMediaTile({ media }: { media: CoursePlanMedia }) {
|
||||
const url = withAuthQuery(media.preview_url || media.download_url || "");
|
||||
if (!url) return null;
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/20 p-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{media.kind === "audio" && <Music className="h-4 w-4" />}
|
||||
{media.kind === "image" && <ImageIcon className="h-4 w-4" />}
|
||||
{media.kind === "video" && <Video className="h-4 w-4" />}
|
||||
<span className="capitalize text-xs text-muted-foreground">
|
||||
{media.kind}
|
||||
</span>
|
||||
</div>
|
||||
{media.kind === "audio" && <audio src={url} controls className="w-full" />}
|
||||
{media.kind === "image" && (
|
||||
<img
|
||||
src={url}
|
||||
alt={media.title}
|
||||
className="w-full rounded-md max-h-72 object-contain bg-muted/30"
|
||||
/>
|
||||
)}
|
||||
{media.kind === "video" && (
|
||||
<video src={url} controls className="w-full rounded-md max-h-72" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pill that surfaces RAG / extraction provenance for one material. */
|
||||
export function GroundingBadge({ material }: { material: CoursePlanMaterial }) {
|
||||
const grounded = material.grounded_on ?? [];
|
||||
const extracted = material.extracted_from ?? null;
|
||||
if (!grounded.length && !extracted) return null;
|
||||
const total =
|
||||
grounded.reduce((acc, g) => acc + (g.chunks_used || 0), 0) +
|
||||
(extracted ? 1 : 0);
|
||||
return (
|
||||
<HoverCard openDelay={150}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Badge variant="secondary" className="cursor-help">
|
||||
<Link2 className="mr-1 h-3 w-3" />
|
||||
Grounded on {total} reference{total === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-72 text-xs">
|
||||
{grounded.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-sm">RAG sources</div>
|
||||
<ul className="space-y-1">
|
||||
{grounded.map((g) => (
|
||||
<li key={g.source_id} className="flex justify-between gap-2">
|
||||
<span className="truncate">{g.title || `Source #${g.source_id}`}</span>
|
||||
<span className="text-muted-foreground">{g.chunks_used} chunk(s)</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{extracted && (
|
||||
<div className={grounded.length ? "mt-3 border-t pt-2" : ""}>
|
||||
<div className="font-medium text-sm">Extracted from</div>
|
||||
<div className="text-muted-foreground">
|
||||
{extracted.source_title || `Source #${extracted.source_id}`}
|
||||
{extracted.page_hint ? ` · ${extracted.page_hint}` : ""}
|
||||
</div>
|
||||
{extracted.topic_keywords && extracted.topic_keywords.length > 0 && (
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
Topics: {extracted.topic_keywords.slice(0, 5).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -10,6 +11,7 @@ import type { StudentExamAssignment } from "@/types";
|
||||
|
||||
export default function ExamPopup() {
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dismissed, setDismissed] = useState<Set<number>>(new Set());
|
||||
|
||||
@@ -30,11 +32,14 @@ export default function ExamPopup() {
|
||||
|
||||
if (pendingExams.length === 0) return null;
|
||||
|
||||
// Localize dates with the active language so Arabic users see ar-EG
|
||||
// month names instead of "Apr 19, 2026". The locale is taken from i18n.
|
||||
const dateLocale = i18n.language?.startsWith("ar") ? "ar-EG" : "en-US";
|
||||
const formatDate = (iso: string | null) => {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) +
|
||||
" " + d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
|
||||
return d.toLocaleDateString(dateLocale, { month: "short", day: "numeric", year: "numeric" }) +
|
||||
" " + d.toLocaleTimeString(dateLocale, { hour: "2-digit", minute: "2-digit" });
|
||||
};
|
||||
|
||||
const handleStart = (exam: StudentExamAssignment) => {
|
||||
@@ -54,10 +59,10 @@ export default function ExamPopup() {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-primary" />
|
||||
Upcoming Exams ({pendingExams.length})
|
||||
{t("examPopup.title", { n: pendingExams.length })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pe-1">
|
||||
{pendingExams.map((exam) => (
|
||||
<div key={exam.id} className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -66,7 +71,7 @@ export default function ExamPopup() {
|
||||
variant={exam.schedule_state === "active" ? "default" : "secondary"}
|
||||
className="capitalize text-xs"
|
||||
>
|
||||
{exam.schedule_state === "active" ? "Active Now" : exam.schedule_state}
|
||||
{exam.schedule_state === "active" ? t("examPopup.activeNow") : exam.schedule_state}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -77,11 +82,11 @@ export default function ExamPopup() {
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
From: {formatDate(exam.start_date)}
|
||||
{t("examPopup.from")} {formatDate(exam.start_date)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
To: {formatDate(exam.end_date)}
|
||||
{t("examPopup.to")} {formatDate(exam.end_date)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -93,20 +98,20 @@ export default function ExamPopup() {
|
||||
className="gap-1.5"
|
||||
>
|
||||
<PlayCircle className="h-3.5 w-3.5" />
|
||||
{exam.can_start ? "Start Exam" : "Not Available Yet"}
|
||||
{exam.can_start ? t("examPopup.startExam") : t("examPopup.notAvailableYet")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDismiss(exam.id)}
|
||||
>
|
||||
Dismiss
|
||||
{t("common.dismiss")}
|
||||
</Button>
|
||||
{!exam.can_start && (
|
||||
<span className="text-[11px] text-muted-foreground italic ml-auto">
|
||||
<span className="text-[11px] text-muted-foreground italic ms-auto">
|
||||
{exam.schedule_state === "planned"
|
||||
? "Exam will be available once it becomes active"
|
||||
: "Exam is not currently active"}
|
||||
? t("examPopup.willBeAvailable")
|
||||
: t("examPopup.notActive")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -25,22 +25,72 @@ const AlertDialogOverlay = React.forwardRef<
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
function hasAlertDialogDescription(children: React.ReactNode): boolean {
|
||||
let found = false;
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (found || !React.isValidElement(child)) return;
|
||||
const type = child.type as { displayName?: string } | undefined;
|
||||
if (
|
||||
type === AlertDialogPrimitive.Description ||
|
||||
(type && type.displayName === AlertDialogPrimitive.Description.displayName)
|
||||
) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
const nested = (child.props as { children?: React.ReactNode } | undefined)
|
||||
?.children;
|
||||
if (nested !== undefined && hasAlertDialogDescription(nested)) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
type AlertDialogContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof AlertDialogPrimitive.Content
|
||||
> & {
|
||||
/**
|
||||
* Optional accessible description rendered visually-hidden. When omitted and
|
||||
* no `AlertDialogDescription` is found in the subtree, a generic fallback is
|
||||
* emitted so screen readers still have something to announce.
|
||||
*/
|
||||
description?: React.ReactNode;
|
||||
};
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.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}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContentProps
|
||||
>(({ className, children, description, ...props }, ref) => {
|
||||
const needsFallback =
|
||||
!props["aria-describedby"] &&
|
||||
description === undefined &&
|
||||
!hasAlertDialogDescription(children);
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.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}
|
||||
>
|
||||
{description !== undefined && (
|
||||
<AlertDialogPrimitive.Description className="sr-only">
|
||||
{description}
|
||||
</AlertDialogPrimitive.Description>
|
||||
)}
|
||||
{needsFallback && (
|
||||
<AlertDialogPrimitive.Description className="sr-only">
|
||||
Alert dialog content
|
||||
</AlertDialogPrimitive.Description>
|
||||
)}
|
||||
{children}
|
||||
</AlertDialogPrimitive.Content>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
});
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
|
||||
@@ -22,8 +22,11 @@ const badgeVariants = cva(
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||
({ className, variant, ...props }, ref) => (
|
||||
<div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
),
|
||||
);
|
||||
Badge.displayName = "Badge";
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -60,7 +60,12 @@ const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWit
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage";
|
||||
|
||||
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
|
||||
<li role="presentation" aria-hidden="true" className={cn("[&>svg]:size-3.5", className)} {...props}>
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5 rtl:[&>svg]:rotate-180", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -42,8 +42,8 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
|
||||
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4 rtl:rotate-180" />,
|
||||
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4 rtl:rotate-180" />,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -185,7 +185,7 @@ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProp
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<ArrowLeft className="h-4 w-4 rtl:rotate-180" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
@@ -213,7 +213,7 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ const ContextMenuSubTrigger = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
<ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
));
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
@@ -27,29 +27,86 @@ const DialogOverlay = React.forwardRef<
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
/**
|
||||
* Walks `children` looking for any element whose component is
|
||||
* `DialogPrimitive.Description` (or our re-exported `DialogDescription`).
|
||||
* Radix emits an a11y warning when a `DialogContent` has neither a
|
||||
* `Description` nor an explicit `aria-describedby`, so we use this to decide
|
||||
* whether to inject a visually-hidden fallback description.
|
||||
*/
|
||||
function hasDialogDescription(children: React.ReactNode): boolean {
|
||||
let found = false;
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (found || !React.isValidElement(child)) return;
|
||||
const type = child.type as { displayName?: string } | undefined;
|
||||
if (
|
||||
type === DialogPrimitive.Description ||
|
||||
(type && type.displayName === DialogPrimitive.Description.displayName)
|
||||
) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
const nested = (child.props as { children?: React.ReactNode } | undefined)
|
||||
?.children;
|
||||
if (nested !== undefined && hasDialogDescription(nested)) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
type DialogContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof DialogPrimitive.Content
|
||||
> & {
|
||||
/**
|
||||
* Optional accessible description. When supplied, a visually-hidden
|
||||
* `DialogDescription` is rendered so screen readers announce it without
|
||||
* affecting layout. Prefer this for simple dialogs; for richer descriptions
|
||||
* continue to place a `<DialogDescription>` inside `<DialogHeader>`.
|
||||
*/
|
||||
description?: React.ReactNode;
|
||||
};
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
aria-describedby={props["aria-describedby"] ?? undefined}
|
||||
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 data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContentProps
|
||||
>(({ className, children, description, ...props }, ref) => {
|
||||
const hasExplicitDescribedBy = !!props["aria-describedby"];
|
||||
const needsFallback =
|
||||
!hasExplicitDescribedBy &&
|
||||
description === undefined &&
|
||||
!hasDialogDescription(children);
|
||||
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
{description !== undefined && (
|
||||
<DialogPrimitive.Description className="sr-only">
|
||||
{description}
|
||||
</DialogPrimitive.Description>
|
||||
)}
|
||||
{needsFallback && (
|
||||
<DialogPrimitive.Description className="sr-only">
|
||||
Dialog content
|
||||
</DialogPrimitive.Description>
|
||||
)}
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<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>) => (
|
||||
|
||||
@@ -32,7 +32,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
<ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
@@ -57,7 +57,7 @@ const MenubarSubTrigger = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
<ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
));
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
|
||||
|
||||
@@ -47,17 +47,17 @@ const PaginationLink = ({ className, isActive, size = "icon", ...props }: Pagina
|
||||
PaginationLink.displayName = "PaginationLink";
|
||||
|
||||
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 pl-2.5", className)} {...props}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 ps-2.5", className)} {...props}>
|
||||
<ChevronLeft className="h-4 w-4 rtl:rotate-180" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationPrevious.displayName = "PaginationPrevious";
|
||||
|
||||
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 pr-2.5", className)} {...props}>
|
||||
<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 pe-2.5", className)} {...props}>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<ChevronRight className="h-4 w-4 rtl:rotate-180" />
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationNext.displayName = "PaginationNext";
|
||||
|
||||
@@ -49,21 +49,61 @@ const sheetVariants = cva(
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
VariantProps<typeof sheetVariants> {
|
||||
/**
|
||||
* Optional accessible description rendered visually-hidden. Falls back to a
|
||||
* generic string when no `SheetDescription` is found in the subtree, which
|
||||
* keeps Radix happy without forcing every call-site to add one.
|
||||
*/
|
||||
description?: React.ReactNode;
|
||||
}
|
||||
|
||||
function hasSheetDescription(children: React.ReactNode): boolean {
|
||||
let found = false;
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (found || !React.isValidElement(child)) return;
|
||||
const type = child.type as { displayName?: string } | undefined;
|
||||
if (
|
||||
type === SheetPrimitive.Description ||
|
||||
(type && type.displayName === SheetPrimitive.Description.displayName)
|
||||
) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
const nested = (child.props as { children?: React.ReactNode } | undefined)
|
||||
?.children;
|
||||
if (nested !== undefined && hasSheetDescription(nested)) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
|
||||
({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
),
|
||||
({ side = "right", className, children, description, ...props }, ref) => {
|
||||
const needsFallback =
|
||||
!props["aria-describedby"] &&
|
||||
description === undefined &&
|
||||
!hasSheetDescription(children);
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{description !== undefined && (
|
||||
<SheetPrimitive.Description className="sr-only">{description}</SheetPrimitive.Description>
|
||||
)}
|
||||
{needsFallback && (
|
||||
<SheetPrimitive.Description className="sr-only">Sheet content</SheetPrimitive.Description>
|
||||
)}
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
},
|
||||
);
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ const Sidebar = React.forwardRef<
|
||||
Sidebar.displayName = "Sidebar";
|
||||
|
||||
const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
|
||||
({ className, onClick, ...props }, ref) => {
|
||||
({ className, onClick, "aria-label": ariaLabel, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
@@ -226,6 +226,7 @@ const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.C
|
||||
data-sidebar="trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={ariaLabel ?? "Toggle sidebar"}
|
||||
className={cn("h-7 w-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
@@ -233,8 +234,8 @@ const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.C
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeft />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
<PanelLeft className="rtl:rotate-180" />
|
||||
<span className="sr-only">{ariaLabel ?? "Toggle sidebar"}</span>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
|
||||
234
src/components/wizard/StepWizard.tsx
Normal file
234
src/components/wizard/StepWizard.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { ReactNode, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowLeft, ArrowRight, Check, ChevronLeft } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Generic multi-step wizard shell.
|
||||
*
|
||||
* A wizard is defined as an ordered array of {@link WizardStepDef} items.
|
||||
* Each step owns its own piece of the accumulated state and returns a node
|
||||
* that renders the form. The shell handles:
|
||||
*
|
||||
* - rendering the numbered stepper (with `completed` / `active` styles)
|
||||
* - Back / Next navigation
|
||||
* - a final "Finish" button that calls `onFinish(state)`
|
||||
* - per-step validation via `step.validate(state)` → return an error
|
||||
* message or `null` when valid
|
||||
* - a persistent progress bar
|
||||
*
|
||||
* Keeping this shell free of domain logic means every scenario wizard
|
||||
* (rubric, course, structure, …) is just a tiny file that defines steps
|
||||
* and wires the final submit to the right service.
|
||||
*/
|
||||
|
||||
export interface WizardStepDef<TState> {
|
||||
id: string;
|
||||
/** i18n key for the step title. */
|
||||
titleKey: string;
|
||||
/** Optional i18n key for a short description shown under the title. */
|
||||
descriptionKey?: string;
|
||||
/**
|
||||
* Render the step's form. Call `update(patch)` to merge fields into the
|
||||
* shared state. Do **not** call `onNext` directly — the shell handles
|
||||
* Next/Back; the render function should just update local fields.
|
||||
*/
|
||||
render: (props: StepRenderProps<TState>) => ReactNode;
|
||||
/**
|
||||
* Synchronous validator. Return a human-readable error message that will
|
||||
* be displayed under the form and block Next, or `null` when valid.
|
||||
*/
|
||||
validate?: (state: TState) => string | null;
|
||||
}
|
||||
|
||||
export interface StepRenderProps<TState> {
|
||||
state: TState;
|
||||
update: (patch: Partial<TState>) => void;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface StepWizardProps<TState> {
|
||||
/** i18n key for the wizard heading. */
|
||||
titleKey: string;
|
||||
/** i18n key for the short lead paragraph shown under the heading. */
|
||||
subtitleKey?: string;
|
||||
/** Optional back-link to the hub. */
|
||||
backTo?: string;
|
||||
backLabelKey?: string;
|
||||
steps: WizardStepDef<TState>[];
|
||||
initialState: TState;
|
||||
/** Called once the user clicks Finish on the last step. */
|
||||
onFinish: (state: TState) => Promise<void> | void;
|
||||
/** i18n key for the Finish button label. Defaults to "wizard.finish". */
|
||||
finishLabelKey?: string;
|
||||
/** Disable all form controls (used by consumers while submitting). */
|
||||
submitting?: boolean;
|
||||
}
|
||||
|
||||
export function StepWizard<TState>({
|
||||
titleKey,
|
||||
subtitleKey,
|
||||
backTo,
|
||||
backLabelKey = "wizard.backToHub",
|
||||
steps,
|
||||
initialState,
|
||||
onFinish,
|
||||
finishLabelKey = "wizard.finish",
|
||||
submitting = false,
|
||||
}: StepWizardProps<TState>) {
|
||||
const { t } = useTranslation();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [state, setState] = useState<TState>(initialState);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submittingInternal, setSubmittingInternal] = useState(false);
|
||||
|
||||
const busy = submitting || submittingInternal;
|
||||
const current = steps[index];
|
||||
const isLast = index === steps.length - 1;
|
||||
const progress = Math.round(((index + 1) / steps.length) * 100);
|
||||
|
||||
const update = (patch: Partial<TState>) => {
|
||||
setState((prev) => ({ ...prev, ...patch }));
|
||||
// Clear validation error as soon as the user starts typing again.
|
||||
if (error) setError(null);
|
||||
};
|
||||
|
||||
const validateAndAdvance = async () => {
|
||||
const msg = current.validate?.(state) ?? null;
|
||||
if (msg) {
|
||||
setError(msg);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
|
||||
if (!isLast) {
|
||||
setIndex((i) => i + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Last step: submit.
|
||||
try {
|
||||
setSubmittingInternal(true);
|
||||
await onFinish(state);
|
||||
} finally {
|
||||
setSubmittingInternal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stepStatus = useMemo(
|
||||
() =>
|
||||
steps.map((_, i) => {
|
||||
if (i < index) return "done" as const;
|
||||
if (i === index) return "active" as const;
|
||||
return "upcoming" as const;
|
||||
}),
|
||||
[steps, index],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
{/* Heading + back link */}
|
||||
<div className="space-y-2">
|
||||
{backTo && (
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 text-muted-foreground">
|
||||
<Link to={backTo} className="flex items-center gap-1">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t(backLabelKey)}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t(titleKey)}</h1>
|
||||
{subtitleKey && <p className="text-muted-foreground">{t(subtitleKey)}</p>}
|
||||
</div>
|
||||
|
||||
{/* Stepper */}
|
||||
<ol className="flex items-center gap-2 overflow-x-auto pb-2" role="list">
|
||||
{steps.map((step, i) => {
|
||||
const s = stepStatus[i];
|
||||
return (
|
||||
<li key={step.id} className="flex items-center gap-2 shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold",
|
||||
s === "done" && "bg-primary text-primary-foreground",
|
||||
s === "active" && "bg-primary/10 text-primary ring-2 ring-primary",
|
||||
s === "upcoming" && "bg-muted text-muted-foreground",
|
||||
)}
|
||||
aria-current={s === "active" ? "step" : undefined}
|
||||
>
|
||||
{s === "done" ? <Check className="h-4 w-4" /> : i + 1}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm whitespace-nowrap",
|
||||
s === "active" ? "font-medium" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t(step.titleKey)}
|
||||
</span>
|
||||
{i < steps.length - 1 && <div className="mx-1 h-px w-8 bg-border" />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Active step */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<h2 className="text-lg font-semibold">{t(current.titleKey)}</h2>
|
||||
{current.descriptionKey && (
|
||||
<p className="text-sm text-muted-foreground">{t(current.descriptionKey)}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{current.render({ state, update, error })}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIndex((i) => Math.max(0, i - 1))}
|
||||
disabled={busy || index === 0}
|
||||
>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
{t("wizard.back")}
|
||||
</Button>
|
||||
|
||||
<div className="text-xs text-muted-foreground tabular-nums">
|
||||
{t("wizard.stepOf", { current: index + 1, total: steps.length })}
|
||||
</div>
|
||||
|
||||
<Button onClick={validateAndAdvance} disabled={busy}>
|
||||
{isLast ? t(finishLabelKey) : t("wizard.next")}
|
||||
{!isLast && <ArrowRight className="ml-1 h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StepWizard;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
|
||||
import { authService } from "@/services/auth.service";
|
||||
import { clearToken } from "@/lib/api-client";
|
||||
import { clearToken, getActiveEntityId, setActiveEntityId } from "@/lib/api-client";
|
||||
import type { User, UserRole } from "@/types/auth";
|
||||
|
||||
export type { UserRole };
|
||||
@@ -9,6 +9,9 @@ interface AuthContextType {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
selectedEntityId: number | null;
|
||||
selectedEntity: User["entities"][number] | null;
|
||||
setSelectedEntityId: (entityId: number | null) => void;
|
||||
login: (email: string, password: string) => Promise<User>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
@@ -17,8 +20,23 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [selectedEntityId, setSelectedEntityIdState] = useState<number | null>(getActiveEntityId());
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const syncEntitySelection = useCallback((nextUser: User | null) => {
|
||||
const available = nextUser?.entities ?? [];
|
||||
if (available.length === 0) {
|
||||
setSelectedEntityIdState(null);
|
||||
setActiveEntityId(null);
|
||||
return;
|
||||
}
|
||||
const stored = getActiveEntityId();
|
||||
const validStored = stored && available.some((e) => e.id === stored) ? stored : null;
|
||||
const next = validStored ?? available[0].id;
|
||||
setSelectedEntityIdState(next);
|
||||
setActiveEntityId(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("encoach_token");
|
||||
if (!token) {
|
||||
@@ -28,30 +46,61 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
authService
|
||||
.getUser()
|
||||
.then(setUser)
|
||||
.then((u) => {
|
||||
setUser(u);
|
||||
syncEntitySelection(u);
|
||||
})
|
||||
.catch(() => {
|
||||
clearToken();
|
||||
setActiveEntityId(null);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
}, [syncEntitySelection]);
|
||||
|
||||
const login = useCallback(async (email: string, password: string): Promise<User> => {
|
||||
const res = await authService.login({ login: email, password });
|
||||
setUser(res.user);
|
||||
syncEntitySelection(res.user);
|
||||
return res.user;
|
||||
}, []);
|
||||
}, [syncEntitySelection]);
|
||||
|
||||
const setSelectedEntityId = useCallback((entityId: number | null) => {
|
||||
if (!user) {
|
||||
setSelectedEntityIdState(null);
|
||||
setActiveEntityId(null);
|
||||
return;
|
||||
}
|
||||
if (entityId == null) {
|
||||
setSelectedEntityIdState(null);
|
||||
setActiveEntityId(null);
|
||||
return;
|
||||
}
|
||||
if (!user.entities.some((e) => e.id === entityId)) return;
|
||||
setSelectedEntityIdState(entityId);
|
||||
setActiveEntityId(entityId);
|
||||
}, [user]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await authService.logout();
|
||||
setUser(null);
|
||||
setSelectedEntityIdState(null);
|
||||
setActiveEntityId(null);
|
||||
}, []);
|
||||
|
||||
const selectedEntity =
|
||||
user?.entities.find((e) => e.id === selectedEntityId) ??
|
||||
user?.entities[0] ??
|
||||
null;
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
isLoading,
|
||||
selectedEntityId,
|
||||
selectedEntity,
|
||||
setSelectedEntityId,
|
||||
login,
|
||||
logout,
|
||||
}}
|
||||
|
||||
@@ -4,244 +4,246 @@ export const queryKeys = {
|
||||
},
|
||||
users: {
|
||||
all: ["users"] as const,
|
||||
list: (params: Record<string, unknown>) => ["users", "list", params] as const,
|
||||
list: (params: object) => ["users", "list", params] as const,
|
||||
detail: (id: number) => ["users", id] as const,
|
||||
},
|
||||
entities: {
|
||||
all: ["entities"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["entities", "list", params] as const,
|
||||
list: (params?: object) => ["entities", "list", params] as const,
|
||||
detail: (id: number) => ["entities", id] as const,
|
||||
roles: (entityId: number) => ["entities", entityId, "roles"] as const,
|
||||
permissions: (entityId: number) => ["entities", entityId, "permissions"] as const,
|
||||
},
|
||||
exams: {
|
||||
all: ["exams"] as const,
|
||||
list: (module: string, params?: Record<string, unknown>) => ["exams", module, params] as const,
|
||||
list: (module: string, params?: object) => ["exams", module, params] as const,
|
||||
detail: (module: string, id: number) => ["exams", module, id] as const,
|
||||
rubrics: (params?: Record<string, unknown>) => ["exams", "rubrics", params] as const,
|
||||
rubricGroups: (params?: Record<string, unknown>) => ["exams", "rubric-groups", params] as const,
|
||||
structures: (params?: Record<string, unknown>) => ["exams", "structures", params] as const,
|
||||
rubrics: (params?: object) => ["exams", "rubrics", params] as const,
|
||||
rubricGroups: (params?: object) => ["exams", "rubric-groups", params] as const,
|
||||
structures: (params?: object) => ["exams", "structures", params] as const,
|
||||
avatars: ["exams", "avatars"] as const,
|
||||
reviewQueue: (params?: object) => ["exams", "review", "queue", params] as const,
|
||||
reviewDetail: (id: number) => ["exams", "review", "detail", id] as const,
|
||||
},
|
||||
assignments: {
|
||||
all: ["assignments"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["assignments", "list", params] as const,
|
||||
list: (params?: object) => ["assignments", "list", params] as const,
|
||||
detail: (id: number) => ["assignments", id] as const,
|
||||
},
|
||||
classrooms: {
|
||||
all: ["classrooms"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["classrooms", "list", params] as const,
|
||||
list: (params?: object) => ["classrooms", "list", params] as const,
|
||||
detail: (id: number) => ["classrooms", id] as const,
|
||||
},
|
||||
stats: {
|
||||
sessions: (params?: Record<string, unknown>) => ["stats", "sessions", params] as const,
|
||||
stats: (params?: Record<string, unknown>) => ["stats", "stats", params] as const,
|
||||
statistical: (params?: Record<string, unknown>) => ["stats", "statistical", params] as const,
|
||||
performance: (params?: Record<string, unknown>) => ["stats", "performance", params] as const,
|
||||
sessions: (params?: object) => ["stats", "sessions", params] as const,
|
||||
stats: (params?: object) => ["stats", "stats", params] as const,
|
||||
statistical: (params?: object) => ["stats", "statistical", params] as const,
|
||||
performance: (params?: object) => ["stats", "performance", params] as const,
|
||||
},
|
||||
training: {
|
||||
all: ["training"] as const,
|
||||
tips: (params?: Record<string, unknown>) => ["training", "tips", params] as const,
|
||||
tips: (params?: object) => ["training", "tips", params] as const,
|
||||
walkthroughs: (module?: string) => ["training", "walkthroughs", module] as const,
|
||||
},
|
||||
subscriptions: {
|
||||
packages: ["subscriptions", "packages"] as const,
|
||||
payments: (params?: Record<string, unknown>) => ["subscriptions", "payments", params] as const,
|
||||
discounts: (params?: Record<string, unknown>) => ["subscriptions", "discounts", params] as const,
|
||||
payments: (params?: object) => ["subscriptions", "payments", params] as const,
|
||||
discounts: (params?: object) => ["subscriptions", "discounts", params] as const,
|
||||
},
|
||||
tickets: {
|
||||
all: ["tickets"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["tickets", "list", params] as const,
|
||||
list: (params?: object) => ["tickets", "list", params] as const,
|
||||
assigned: ["tickets", "assigned"] as const,
|
||||
},
|
||||
approvals: {
|
||||
all: ["approvals"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["approvals", "list", params] as const,
|
||||
list: (params?: object) => ["approvals", "list", params] as const,
|
||||
},
|
||||
taxonomy: {
|
||||
subjects: ["taxonomy", "subjects"] as const,
|
||||
subject: (id: number) => ["taxonomy", "subjects", id] as const,
|
||||
tree: (subjectId: number) => ["taxonomy", "tree", subjectId] as const,
|
||||
domains: (params?: Record<string, unknown>) => ["taxonomy", "domains", params] as const,
|
||||
topics: (params?: Record<string, unknown>) => ["taxonomy", "topics", params] as const,
|
||||
domains: (params?: object) => ["taxonomy", "domains", params] as const,
|
||||
topics: (params?: object) => ["taxonomy", "topics", params] as const,
|
||||
},
|
||||
adaptive: {
|
||||
proficiency: (params?: Record<string, unknown>) => ["adaptive", "proficiency", params] as const,
|
||||
proficiency: (params?: object) => ["adaptive", "proficiency", params] as const,
|
||||
summary: ["adaptive", "proficiency", "summary"] as const,
|
||||
plan: (params?: Record<string, unknown>) => ["adaptive", "plan", params] as const,
|
||||
plan: (params?: object) => ["adaptive", "plan", params] as const,
|
||||
topicContent: (topicId: number) => ["adaptive", "topic", topicId, "content"] as const,
|
||||
diagnostic: (sessionId: number) => ["adaptive", "diagnostic", sessionId] as const,
|
||||
},
|
||||
resources: {
|
||||
all: ["resources"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["resources", "list", params] as const,
|
||||
list: (params?: object) => ["resources", "list", params] as const,
|
||||
},
|
||||
lms: {
|
||||
courses: (params?: Record<string, unknown>) => ["lms", "courses", params] as const,
|
||||
courses: (params?: object) => ["lms", "courses", params] as const,
|
||||
course: (id: number) => ["lms", "courses", id] as const,
|
||||
myCourses: ["lms", "my-courses"] as const,
|
||||
students: (params?: Record<string, unknown>) => ["lms", "students", params] as const,
|
||||
teachers: (params?: Record<string, unknown>) => ["lms", "teachers", params] as const,
|
||||
batches: (params?: Record<string, unknown>) => ["lms", "batches", params] as const,
|
||||
students: (params?: object) => ["lms", "students", params] as const,
|
||||
teachers: (params?: object) => ["lms", "teachers", params] as const,
|
||||
batches: (params?: object) => ["lms", "batches", params] as const,
|
||||
batch: (id: number) => ["lms", "batches", id] as const,
|
||||
timetable: (params?: Record<string, unknown>) => ["lms", "timetable", params] as const,
|
||||
attendance: (params?: Record<string, unknown>) => ["lms", "attendance", params] as const,
|
||||
grades: (params?: Record<string, unknown>) => ["lms", "grades", params] as const,
|
||||
timetable: (params?: object) => ["lms", "timetable", params] as const,
|
||||
attendance: (params?: object) => ["lms", "attendance", params] as const,
|
||||
grades: (params?: object) => ["lms", "grades", params] as const,
|
||||
},
|
||||
analytics: {
|
||||
student: (params?: Record<string, unknown>) => ["analytics", "student", params] as const,
|
||||
class: (params?: Record<string, unknown>) => ["analytics", "class", params] as const,
|
||||
subject: (params?: Record<string, unknown>) => ["analytics", "subject", params] as const,
|
||||
contentGaps: (params?: Record<string, unknown>) => ["analytics", "content-gaps", params] as const,
|
||||
student: (params?: object) => ["analytics", "student", params] as const,
|
||||
class: (params?: object) => ["analytics", "class", params] as const,
|
||||
subject: (params?: object) => ["analytics", "subject", params] as const,
|
||||
contentGaps: (params?: object) => ["analytics", "content-gaps", params] as const,
|
||||
alerts: ["analytics", "alerts"] as const,
|
||||
},
|
||||
academicYears: {
|
||||
all: ["academic-years"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["academic-years", "list", params] as const,
|
||||
list: (params?: object) => ["academic-years", "list", params] as const,
|
||||
detail: (id: number) => ["academic-years", id] as const,
|
||||
},
|
||||
academicTerms: {
|
||||
all: ["academic-terms"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["academic-terms", "list", params] as const,
|
||||
list: (params?: object) => ["academic-terms", "list", params] as const,
|
||||
detail: (id: number) => ["academic-terms", id] as const,
|
||||
},
|
||||
departments: {
|
||||
all: ["departments"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["departments", "list", params] as const,
|
||||
list: (params?: object) => ["departments", "list", params] as const,
|
||||
detail: (id: number) => ["departments", id] as const,
|
||||
},
|
||||
admissionRegisters: {
|
||||
all: ["admission-registers"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["admission-registers", "list", params] as const,
|
||||
list: (params?: object) => ["admission-registers", "list", params] as const,
|
||||
detail: (id: number) => ["admission-registers", id] as const,
|
||||
},
|
||||
admissions: {
|
||||
all: ["admissions"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["admissions", "list", params] as const,
|
||||
list: (params?: object) => ["admissions", "list", params] as const,
|
||||
detail: (id: number) => ["admissions", id] as const,
|
||||
},
|
||||
instExamSessions: {
|
||||
all: ["inst-exam-sessions"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["inst-exam-sessions", "list", params] as const,
|
||||
list: (params?: object) => ["inst-exam-sessions", "list", params] as const,
|
||||
detail: (id: number) => ["inst-exam-sessions", id] as const,
|
||||
},
|
||||
examTypes: {
|
||||
all: ["exam-types"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["exam-types", "list", params] as const,
|
||||
list: (params?: object) => ["exam-types", "list", params] as const,
|
||||
},
|
||||
gradeConfigs: {
|
||||
all: ["grade-configs"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["grade-configs", "list", params] as const,
|
||||
list: (params?: object) => ["grade-configs", "list", params] as const,
|
||||
},
|
||||
resultTemplates: {
|
||||
all: ["result-templates"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["result-templates", "list", params] as const,
|
||||
list: (params?: object) => ["result-templates", "list", params] as const,
|
||||
},
|
||||
marksheets: {
|
||||
all: ["marksheets"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["marksheets", "list", params] as const,
|
||||
list: (params?: object) => ["marksheets", "list", params] as const,
|
||||
detail: (id: number) => ["marksheets", id] as const,
|
||||
},
|
||||
courseAssignments: {
|
||||
all: ["course-assignments"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["course-assignments", "list", params] as const,
|
||||
list: (params?: object) => ["course-assignments", "list", params] as const,
|
||||
detail: (id: number) => ["course-assignments", id] as const,
|
||||
},
|
||||
assignmentTypes: {
|
||||
all: ["assignment-types"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["assignment-types", "list", params] as const,
|
||||
list: (params?: object) => ["assignment-types", "list", params] as const,
|
||||
},
|
||||
subjectRegistrations: {
|
||||
all: ["subject-registrations"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["subject-registrations", "list", params] as const,
|
||||
available: (params?: Record<string, unknown>) => ["subject-registrations", "available", params] as const,
|
||||
list: (params?: object) => ["subject-registrations", "list", params] as const,
|
||||
available: (params?: object) => ["subject-registrations", "available", params] as const,
|
||||
},
|
||||
courseCompletion: (courseId: number) => ["course-completion", courseId] as const,
|
||||
chapters: (courseId: number) => ["chapters", "list", courseId] as const,
|
||||
chapter: (id: number) => ["chapters", "detail", id] as const,
|
||||
chapterMaterials: (chapterId: number) => ["chapter-materials", chapterId] as const,
|
||||
chapterProgress: (chapterId: number) => ["chapter-progress", chapterId] as const,
|
||||
discussionBoards: (params?: Record<string, unknown>) => ["discussion-boards", params] as const,
|
||||
posts: (boardId: number, params?: Record<string, unknown>) => ["posts", boardId, params] as const,
|
||||
announcements: (params?: Record<string, unknown>) => ["announcements", params] as const,
|
||||
messages: (params?: Record<string, unknown>) => ["messages", params] as const,
|
||||
sentMessages: (params?: Record<string, unknown>) => ["messages", "sent", params] as const,
|
||||
discussionBoards: (params?: object) => ["discussion-boards", params] as const,
|
||||
posts: (boardId: number, params?: object) => ["posts", boardId, params] as const,
|
||||
announcements: (params?: object) => ["announcements", params] as const,
|
||||
messages: (params?: object) => ["messages", params] as const,
|
||||
sentMessages: (params?: object) => ["messages", "sent", params] as const,
|
||||
unreadMessages: ["messages", "unread-count"] as const,
|
||||
notifications: (params?: Record<string, unknown>) => ["notifications", params] as const,
|
||||
notifications: (params?: object) => ["notifications", params] as const,
|
||||
unreadNotifications: ["notifications", "unread-count"] as const,
|
||||
notificationRules: ["notification-rules"] as const,
|
||||
notificationPreferences: ["notification-preferences"] as const,
|
||||
faqCategories: (audience?: string) => ["faq", "categories", audience] as const,
|
||||
faqItems: (params?: Record<string, unknown>) => ["faq", "items", params] as const,
|
||||
faqItems: (params?: object) => ["faq", "items", params] as const,
|
||||
studentLeaves: {
|
||||
all: ["student-leaves"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["student-leaves", "list", params] as const,
|
||||
list: (params?: object) => ["student-leaves", "list", params] as const,
|
||||
},
|
||||
studentLeaveTypes: {
|
||||
all: ["student-leave-types"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["student-leave-types", "list", params] as const,
|
||||
list: (params?: object) => ["student-leave-types", "list", params] as const,
|
||||
},
|
||||
feesPlans: {
|
||||
all: ["fees-plans"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["fees-plans", "list", params] as const,
|
||||
list: (params?: object) => ["fees-plans", "list", params] as const,
|
||||
detail: (id: number) => ["fees-plans", id] as const,
|
||||
},
|
||||
studentFees: {
|
||||
all: ["student-fees"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["student-fees", "list", params] as const,
|
||||
list: (params?: object) => ["student-fees", "list", params] as const,
|
||||
},
|
||||
feesTerms: {
|
||||
all: ["fees-terms"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["fees-terms", "list", params] as const,
|
||||
list: (params?: object) => ["fees-terms", "list", params] as const,
|
||||
},
|
||||
lessons: {
|
||||
all: ["lessons"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["lessons", "list", params] as const,
|
||||
list: (params?: object) => ["lessons", "list", params] as const,
|
||||
},
|
||||
gradebooks: {
|
||||
all: ["gradebooks"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["gradebooks", "list", params] as const,
|
||||
list: (params?: object) => ["gradebooks", "list", params] as const,
|
||||
},
|
||||
gradebookLines: {
|
||||
all: ["gradebook-lines"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["gradebook-lines", "list", params] as const,
|
||||
list: (params?: object) => ["gradebook-lines", "list", params] as const,
|
||||
},
|
||||
gradingAssignments: {
|
||||
all: ["grading-assignments"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["grading-assignments", "list", params] as const,
|
||||
list: (params?: object) => ["grading-assignments", "list", params] as const,
|
||||
},
|
||||
studentProgress: {
|
||||
all: ["student-progress"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["student-progress", "list", params] as const,
|
||||
list: (params?: object) => ["student-progress", "list", params] as const,
|
||||
detail: (id: number) => ["student-progress", "detail", id] as const,
|
||||
},
|
||||
libraryMedia: {
|
||||
all: ["library-media"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["library-media", "list", params] as const,
|
||||
list: (params?: object) => ["library-media", "list", params] as const,
|
||||
},
|
||||
libraryMovements: {
|
||||
all: ["library-movements"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["library-movements", "list", params] as const,
|
||||
list: (params?: object) => ["library-movements", "list", params] as const,
|
||||
},
|
||||
libraryCards: {
|
||||
all: ["library-cards"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["library-cards", "list", params] as const,
|
||||
list: (params?: object) => ["library-cards", "list", params] as const,
|
||||
},
|
||||
activities: {
|
||||
all: ["activities"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["activities", "list", params] as const,
|
||||
list: (params?: object) => ["activities", "list", params] as const,
|
||||
},
|
||||
activityTypes: {
|
||||
all: ["activity-types"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["activity-types", "list", params] as const,
|
||||
list: (params?: object) => ["activity-types", "list", params] as const,
|
||||
},
|
||||
facilities: {
|
||||
all: ["facilities"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["facilities", "list", params] as const,
|
||||
list: (params?: object) => ["facilities", "list", params] as const,
|
||||
},
|
||||
assets: {
|
||||
all: ["assets"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["assets", "list", params] as const,
|
||||
list: (params?: object) => ["assets", "list", params] as const,
|
||||
},
|
||||
signup: {
|
||||
goals: ["signup", "goals"] as const,
|
||||
@@ -258,7 +260,7 @@ export const queryKeys = {
|
||||
},
|
||||
ieltsExam: {
|
||||
skills: (examId: number) => ["ielts-exam", examId, "skills"] as const,
|
||||
contentPool: (examId: number, filters?: Record<string, unknown>) => ["ielts-exam", examId, "content-pool", filters] as const,
|
||||
contentPool: (examId: number, filters?: object) => ["ielts-exam", examId, "content-pool", filters] as const,
|
||||
validation: (examId: number) => ["ielts-exam", examId, "validation"] as const,
|
||||
},
|
||||
examSession: {
|
||||
@@ -266,7 +268,7 @@ export const queryKeys = {
|
||||
status: (examId: number) => ["exam-session", examId, "status"] as const,
|
||||
},
|
||||
grading: {
|
||||
queue: (params?: Record<string, unknown>) => ["grading", "queue", params] as const,
|
||||
queue: (params?: object) => ["grading", "queue", params] as const,
|
||||
response: (attemptId: number, skill: string) => ["grading", attemptId, "response", skill] as const,
|
||||
rubric: (attemptId: number, skill: string) => ["grading", attemptId, "rubric", skill] as const,
|
||||
},
|
||||
@@ -282,11 +284,11 @@ export const queryKeys = {
|
||||
englishTaxonomy: ["ai-course", "english", "taxonomy"] as const,
|
||||
},
|
||||
entityOnboarding: {
|
||||
credentials: (params?: Record<string, unknown>) => ["entity-onboarding", "credentials", params] as const,
|
||||
credentials: (params?: object) => ["entity-onboarding", "credentials", params] as const,
|
||||
},
|
||||
adaptiveEngine: {
|
||||
dashboard: ["adaptive-engine", "dashboard"] as const,
|
||||
students: (params?: Record<string, unknown>) => ["adaptive-engine", "students", params] as const,
|
||||
students: (params?: object) => ["adaptive-engine", "students", params] as const,
|
||||
signals: (studentId: number) => ["adaptive-engine", "signals", studentId] as const,
|
||||
ability: (studentId: number) => ["adaptive-engine", "ability", studentId] as const,
|
||||
settings: ["adaptive-engine", "settings"] as const,
|
||||
@@ -299,7 +301,7 @@ export const queryKeys = {
|
||||
current: ["branding", "current"] as const,
|
||||
},
|
||||
scoreRelease: {
|
||||
pending: (params?: Record<string, unknown>) => ["score-release", "pending", params] as const,
|
||||
pending: (params?: object) => ["score-release", "pending", params] as const,
|
||||
},
|
||||
verification: {
|
||||
verify: (hash: string) => ["verification", hash] as const,
|
||||
|
||||
70
src/hooks/queries/useAIFeedback.ts
Normal file
70
src/hooks/queries/useAIFeedback.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { aiFeedbackService } from "@/services/ai-feedback.service";
|
||||
import type {
|
||||
AIFeedbackStatus,
|
||||
AIFeedbackSubjectType,
|
||||
AIFeedbackSubmitInput,
|
||||
} from "@/types/ai-feedback";
|
||||
|
||||
const KEY_ROOT = ["ai", "feedback"] as const;
|
||||
|
||||
export function useAIFeedbackSummary(
|
||||
subjectType: AIFeedbackSubjectType | undefined,
|
||||
subjectId: number | undefined,
|
||||
) {
|
||||
return useQuery({
|
||||
enabled: !!subjectType && !!subjectId,
|
||||
queryKey: [...KEY_ROOT, "summary", subjectType, subjectId] as const,
|
||||
queryFn: () =>
|
||||
aiFeedbackService.summary(
|
||||
subjectType as AIFeedbackSubjectType,
|
||||
subjectId as number,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubmitAIFeedback() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: AIFeedbackSubmitInput) => aiFeedbackService.submit(input),
|
||||
onSuccess: (_res, vars) => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: [...KEY_ROOT, "summary", vars.subject_type, vars.subject_id],
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: [...KEY_ROOT, "list"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAIFeedbackList(
|
||||
params: {
|
||||
status?: AIFeedbackStatus;
|
||||
rating?: "up" | "down";
|
||||
subject_type?: AIFeedbackSubjectType;
|
||||
prompt_key?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
} = {},
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [...KEY_ROOT, "list", params] as const,
|
||||
queryFn: () => aiFeedbackService.list(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useResolveAIFeedback() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
status,
|
||||
notes,
|
||||
}: {
|
||||
id: number;
|
||||
status: Exclude<AIFeedbackStatus, "open">;
|
||||
notes?: string;
|
||||
}) => aiFeedbackService.resolve(id, status, notes),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
|
||||
});
|
||||
}
|
||||
52
src/hooks/queries/useAIPrompts.ts
Normal file
52
src/hooks/queries/useAIPrompts.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { aiPromptService } from "@/services/ai-prompt.service";
|
||||
import type { AIPromptCreateInput } from "@/types/ai-prompt";
|
||||
|
||||
const KEY_ROOT = ["ai", "prompts"] as const;
|
||||
|
||||
export function useAIPromptKeys(params: { search?: string; page?: number; size?: number } = {}) {
|
||||
return useQuery({
|
||||
queryKey: [...KEY_ROOT, "keys", params] as const,
|
||||
queryFn: () => aiPromptService.listKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAIPromptVersions(key: string | undefined) {
|
||||
return useQuery({
|
||||
enabled: !!key,
|
||||
queryKey: [...KEY_ROOT, "versions", key] as const,
|
||||
queryFn: () => aiPromptService.listVersions(key as string),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAIPrompt(id: number | undefined) {
|
||||
return useQuery({
|
||||
enabled: !!id,
|
||||
queryKey: [...KEY_ROOT, "detail", id] as const,
|
||||
queryFn: () => aiPromptService.get(id as number),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAIPrompt() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: AIPromptCreateInput) => aiPromptService.create(input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useActivateAIPrompt() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => aiPromptService.activate(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRenderAIPrompt() {
|
||||
return useMutation({
|
||||
mutationFn: ({ id, variables }: { id: number; variables: Record<string, string> }) =>
|
||||
aiPromptService.render(id, variables),
|
||||
});
|
||||
}
|
||||
47
src/hooks/queries/useExamReview.ts
Normal file
47
src/hooks/queries/useExamReview.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
examReviewService,
|
||||
type ExamReviewQueueParams,
|
||||
} from "@/services/exam-review.service";
|
||||
|
||||
import { queryKeys } from "./keys";
|
||||
|
||||
export function useExamReviewQueue(params: ExamReviewQueueParams = {}) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.exams.reviewQueue(params),
|
||||
queryFn: () => examReviewService.queue(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useExamReviewDetail(examId: number | undefined) {
|
||||
return useQuery({
|
||||
enabled: !!examId,
|
||||
queryKey: queryKeys.exams.reviewDetail(examId ?? -1),
|
||||
queryFn: () => examReviewService.detail(examId as number),
|
||||
});
|
||||
}
|
||||
|
||||
export function useApproveExamReview() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ examId, notes }: { examId: number; notes?: string }) =>
|
||||
examReviewService.approve(examId, notes),
|
||||
onSuccess: (_data, { examId }) => {
|
||||
qc.invalidateQueries({ queryKey: ["exams", "review"] });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.exams.reviewDetail(examId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRejectExamReview() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ examId, notes }: { examId: number; notes: string }) =>
|
||||
examReviewService.reject(examId, notes),
|
||||
onSuccess: (_data, { examId }) => {
|
||||
qc.invalidateQueries({ queryKey: ["exams", "review"] });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.exams.reviewDetail(examId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export function useIeltsAutoAssemble() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useIeltsValidation(examId: number) {
|
||||
export function useIeltsExamValidation(examId: number) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.ieltsExam.validation(examId),
|
||||
queryFn: () => ieltsExamService.validate(examId),
|
||||
|
||||
80
src/i18n/index.ts
Normal file
80
src/i18n/index.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* EnCoach i18n bootstrap.
|
||||
*
|
||||
* We keep the translation strings in TypeScript modules (one per locale) so
|
||||
* they go through type-checking and tree-shaking, and so non-translators
|
||||
* can't ship a broken JSON file that blocks the build.
|
||||
*
|
||||
* Language selection order (product decision: English is the default UI
|
||||
* language at startup; Arabic is opt-in via the language toggle):
|
||||
* 1. localStorage key ``encoach-lang`` (explicit user pick, persisted)
|
||||
* 2. ``en`` — fallback used on first visit, regardless of browser locale
|
||||
*
|
||||
* We intentionally do **not** read ``navigator.language`` anymore: an
|
||||
* Arabic-locale browser should still land on the English UI the first time
|
||||
* a user visits, because the QA team and the product manager both sign off
|
||||
* against the English baseline. Users that actively want Arabic flip the
|
||||
* toggle once and the preference is remembered from then on.
|
||||
*
|
||||
* RTL handling: whenever the active language switches to one of RTL_LANGS,
|
||||
* we flip ``document.documentElement.dir`` so Tailwind's RTL utilities +
|
||||
* Radix primitives pick up the change automatically.
|
||||
*/
|
||||
|
||||
import i18n from "i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import ar from "./locales/ar";
|
||||
import en from "./locales/en";
|
||||
|
||||
const RTL_LANGS = new Set(["ar", "he", "fa", "ur"]);
|
||||
|
||||
export const SUPPORTED_LANGS = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "ar", label: "العربية" },
|
||||
] as const;
|
||||
|
||||
export type SupportedLang = (typeof SUPPORTED_LANGS)[number]["code"];
|
||||
|
||||
export const DEFAULT_LANG: SupportedLang = "en";
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
ar: { translation: ar },
|
||||
},
|
||||
// Explicit default — english is the baseline UI language at startup.
|
||||
lng: DEFAULT_LANG,
|
||||
fallbackLng: DEFAULT_LANG,
|
||||
supportedLngs: SUPPORTED_LANGS.map((l) => l.code),
|
||||
interpolation: { escapeValue: false },
|
||||
detection: {
|
||||
// Only honour the user's saved pick. Navigator/htmlTag detection was
|
||||
// removed so Arabic-locale browsers don't silently boot the UI in
|
||||
// Arabic before the user has chosen.
|
||||
order: ["localStorage"],
|
||||
caches: ["localStorage"],
|
||||
lookupLocalStorage: "encoach-lang",
|
||||
},
|
||||
returnEmptyString: false,
|
||||
});
|
||||
|
||||
export function applyDirectionForLang(lang: string) {
|
||||
const base = lang.split("-")[0];
|
||||
const dir = RTL_LANGS.has(base) ? "rtl" : "ltr";
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.dir = dir;
|
||||
document.documentElement.lang = base;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
applyDirectionForLang(i18n.language || "en");
|
||||
i18n.on("languageChanged", applyDirectionForLang);
|
||||
}
|
||||
|
||||
export default i18n;
|
||||
1134
src/i18n/locales/ar.ts
Normal file
1134
src/i18n/locales/ar.ts
Normal file
File diff suppressed because it is too large
Load Diff
1196
src/i18n/locales/en.ts
Normal file
1196
src/i18n/locales/en.ts
Normal file
File diff suppressed because it is too large
Load Diff
114
src/index.css
114
src/index.css
@@ -51,6 +51,14 @@
|
||||
--sidebar-accent-foreground: 8 40% 78%;
|
||||
--sidebar-border: 240 18% 20%;
|
||||
--sidebar-ring: 8 50% 58%;
|
||||
|
||||
/* Chart palette — used by Recharts via hsl(var(--chart-N)). Kept on the
|
||||
warm/cool axis so pairs read well together when stacked. */
|
||||
--chart-1: 8 50% 54%;
|
||||
--chart-2: 220 70% 52%;
|
||||
--chart-3: 152 60% 42%;
|
||||
--chart-4: 38 92% 50%;
|
||||
--chart-5: 280 55% 55%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -99,6 +107,12 @@
|
||||
--sidebar-accent-foreground: 8 40% 75%;
|
||||
--sidebar-border: 240 18% 13%;
|
||||
--sidebar-ring: 8 50% 55%;
|
||||
|
||||
--chart-1: 8 52% 62%;
|
||||
--chart-2: 220 65% 65%;
|
||||
--chart-3: 152 50% 55%;
|
||||
--chart-4: 38 75% 60%;
|
||||
--chart-5: 280 55% 68%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,3 +134,103 @@
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* RTL / Arabic support
|
||||
*
|
||||
* `tailwindcss-rtl` handles the bulk of the work: physical margin / padding /
|
||||
* position / border / rounded / text-align / space-x utilities are mirrored
|
||||
* automatically when `html[dir="rtl"]` is active.
|
||||
*
|
||||
* Below we cover the things Tailwind can't: the webfont (Inter has weak
|
||||
* Arabic glyphs, so swap in Cairo), recharts tooltips/axes which have their
|
||||
* own DOM, and a few icons that should NOT mirror (arrows used as pure
|
||||
* visual affordance e.g. ChevronRight in a dropdown).
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
html[dir="rtl"] body,
|
||||
html[dir="rtl"] h1,
|
||||
html[dir="rtl"] h2,
|
||||
html[dir="rtl"] h3,
|
||||
html[dir="rtl"] h4,
|
||||
html[dir="rtl"] h5,
|
||||
html[dir="rtl"] h6,
|
||||
html[dir="rtl"] input,
|
||||
html[dir="rtl"] textarea,
|
||||
html[dir="rtl"] button,
|
||||
html[dir="rtl"] select {
|
||||
font-family: 'Cairo', 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html[dir="rtl"] code,
|
||||
html[dir="rtl"] pre {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .recharts-wrapper,
|
||||
html[dir="rtl"] .recharts-legend-wrapper {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
/* Breadcrumb and submenu chevrons: always pointing "forward" in reading
|
||||
direction. Lucide's ChevronRight is ">" which is forward in LTR, but in
|
||||
RTL the same arrow must become "<". We flip via CSS so no component has
|
||||
to know about direction. */
|
||||
html[dir="rtl"] nav[aria-label="breadcrumb"] li[role="presentation"] > svg,
|
||||
html[dir="rtl"] [data-radix-menu-content] [role="menuitem"] > svg:last-child,
|
||||
html[dir="rtl"] [data-radix-popper-content-wrapper] [role="menuitem"] > svg:last-child {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Numbers, percentages, emails, URLs, dates should stay LTR-isolated even
|
||||
when embedded in Arabic paragraphs, otherwise slashes / percent signs /
|
||||
dashes drift to the wrong side and the value becomes unreadable.
|
||||
<bdi> already gives us this per-element; the helper classes below are
|
||||
for places where adding a wrapper element is awkward. */
|
||||
.ltr-nums,
|
||||
.dir-ltr {
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
/* Pure-number cells & badges align to the end (right in LTR, left in RTL)
|
||||
but the digits themselves still read left-to-right. */
|
||||
html[dir="rtl"] .numeric {
|
||||
text-align: end;
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
/* Some lucide icons are pure affordances (play, external-link, send) and
|
||||
should NOT be mirrored even if tailwindcss-rtl would otherwise flip the
|
||||
button that contains them. Authors opt-in with data-no-flip. */
|
||||
html[dir="rtl"] [data-no-flip] {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* Keep code snippets, identifiers, and file paths readable in RTL. */
|
||||
html[dir="rtl"] code,
|
||||
html[dir="rtl"] pre,
|
||||
html[dir="rtl"] kbd,
|
||||
html[dir="rtl"] samp,
|
||||
html[dir="rtl"] [data-monospace] {
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
/* Scrollbars inside horizontally-scrollable tables already flip naturally
|
||||
in RTL, but the shadow Radix portals (dropdowns, tooltips, dialogs) use
|
||||
`right-0` / `left-0` positioning computed in JS. For safety, make sure
|
||||
they inherit the document direction. */
|
||||
html[dir="rtl"] [data-radix-popper-content-wrapper] {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
/* Inputs with dir="ltr" (email, password, URL) override but keep the
|
||||
placeholder aligned to the visual start (right in RTL, left in LTR). */
|
||||
html[dir="rtl"] input[dir="ltr"]::placeholder,
|
||||
html[dir="rtl"] textarea[dir="ltr"]::placeholder {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
/** Base path or absolute URL for Odoo JSON API (dev: `/api` + Vite proxy). */
|
||||
/**
|
||||
* Odoo REST client with transparent access-token rotation.
|
||||
*
|
||||
* Tokens live in ``localStorage`` under three keys:
|
||||
* - ``encoach_token`` — short-lived access JWT (1h)
|
||||
* - ``encoach_refresh_token`` — long-lived refresh JWT (7d)
|
||||
* - ``encoach_token_exp`` — epoch seconds for the access token
|
||||
*
|
||||
* When a request receives ``401`` *and* a refresh token is present, the client
|
||||
* silently rotates the pair via ``POST /api/auth/refresh`` and retries the
|
||||
* original request exactly once. Multiple concurrent 401s coalesce onto the
|
||||
* same refresh promise so we never fire more than one rotation in flight.
|
||||
*
|
||||
* Motivation: before this change, every expired access token forced a full
|
||||
* re-login, which interrupted long admin dashboards (stats, reports) multiple
|
||||
* times per hour. With the refresh loop, access tokens can be short-lived
|
||||
* (1h) without hurting UX, giving us the security benefit of short access
|
||||
* windows without an eager logout.
|
||||
*/
|
||||
|
||||
export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL?.trim() || "/api").replace(/\/$/, "");
|
||||
const BASE_URL = API_BASE_URL;
|
||||
|
||||
@@ -21,120 +40,388 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem("encoach_token");
|
||||
/**
|
||||
* Turn an arbitrary thrown value from an API mutation into a human-readable,
|
||||
* actionable toast description. Deployment QA kept reporting generic
|
||||
* "Submit failed" / "Upload failed" toasts — this helper surfaces the HTTP
|
||||
* status and, when we recognise the code, a hint about what to do next
|
||||
* (payload too big → ask ops to raise nginx, timeout → retry, etc.).
|
||||
*
|
||||
* @param err the error thrown by an `api.*` call (usually an ApiError)
|
||||
* @param fallback text to show if we can't classify the error
|
||||
* @param context short context word used in the 413/504 hints
|
||||
* ("file", "request", "payload" …)
|
||||
*/
|
||||
export function describeApiError(
|
||||
err: unknown,
|
||||
fallback = "Something went wrong",
|
||||
context = "request",
|
||||
): string {
|
||||
const e = err as { status?: number; message?: string; data?: unknown } | null;
|
||||
const status = e?.status;
|
||||
// Try to extract a server-side error body even if the generic ApiError
|
||||
// message wasn't populated (nginx 413/504 respond with an HTML page so
|
||||
// `data.error` is empty and `message` looks like "413 ").
|
||||
const serverMsg =
|
||||
e?.data && typeof e.data === "object" && "error" in (e.data as object)
|
||||
? String((e.data as { error: unknown }).error || "").trim()
|
||||
: "";
|
||||
|
||||
if (status === 413) {
|
||||
return `The ${context} is too large for the server to accept (HTTP 413). Try a smaller file / fewer tasks, or ask an admin to raise the nginx \`client_max_body_size\` and Odoo \`limit_request\`.`;
|
||||
}
|
||||
if (status === 504) {
|
||||
return `The server took too long to respond (HTTP 504). The ${context} may still be processing — wait a minute and reload before retrying.`;
|
||||
}
|
||||
if (status === 502) {
|
||||
return "The backend is unreachable (HTTP 502). Odoo may be restarting — try again in a moment.";
|
||||
}
|
||||
if (status === 401) {
|
||||
return "Your session expired — please sign in again.";
|
||||
}
|
||||
if (status === 403) {
|
||||
return serverMsg || "You don't have permission to do that (HTTP 403).";
|
||||
}
|
||||
if (status === 404) {
|
||||
return serverMsg || "The requested item no longer exists (HTTP 404).";
|
||||
}
|
||||
if (status === 422 || status === 400) {
|
||||
return serverMsg || `The server rejected the ${context} as invalid (HTTP ${status}).`;
|
||||
}
|
||||
if (status && status >= 500) {
|
||||
return `${serverMsg || "The server returned an error"} (HTTP ${status}). Check the Odoo logs for a stack trace.`;
|
||||
}
|
||||
|
||||
// Network failure / fetch aborted / CORS — ApiError not thrown at all.
|
||||
if (!status && e?.message) return e.message;
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const ACCESS_KEY = "encoach_token";
|
||||
const REFRESH_KEY = "encoach_refresh_token";
|
||||
const EXP_KEY = "encoach_token_exp";
|
||||
const ENTITY_KEY = "encoach_entity_id";
|
||||
|
||||
function getAccessToken(): string | null {
|
||||
return localStorage.getItem(ACCESS_KEY);
|
||||
}
|
||||
|
||||
export function getActiveEntityId(): number | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(ENTITY_KEY);
|
||||
if (!raw) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setActiveEntityId(entityId: number | null | undefined): void {
|
||||
try {
|
||||
if (entityId && Number.isFinite(entityId)) {
|
||||
localStorage.setItem(ENTITY_KEY, String(Math.floor(entityId)));
|
||||
} else {
|
||||
localStorage.removeItem(ENTITY_KEY);
|
||||
}
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a URL to a media-streaming endpoint with the JWT attached as a
|
||||
* query parameter. Used by ``<img>`` / ``<audio>`` / ``<video>`` tags
|
||||
* (which can't attach custom Authorization headers) and by ``<a download>``
|
||||
* tags so the browser can fetch the binary directly without going through
|
||||
* a fetch + blob URL dance.
|
||||
*
|
||||
* Returns the original path unchanged when no token is stored, which lets
|
||||
* callers render a placeholder rather than crashing on ``null``.
|
||||
*/
|
||||
export function withAuthQuery(path: string): string {
|
||||
if (!path) return path;
|
||||
const token = getAccessToken();
|
||||
if (!token) return path;
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
return `${path}${sep}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
function getRefreshToken(): string | null {
|
||||
return localStorage.getItem(REFRESH_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem("encoach_token", token);
|
||||
localStorage.setItem(ACCESS_KEY, token);
|
||||
}
|
||||
|
||||
export function setRefreshToken(token: string | null | undefined): void {
|
||||
if (token) {
|
||||
localStorage.setItem(REFRESH_KEY, token);
|
||||
} else {
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function setTokenExpiry(epochSeconds: number | null | undefined): void {
|
||||
if (epochSeconds && Number.isFinite(epochSeconds)) {
|
||||
localStorage.setItem(EXP_KEY, String(Math.floor(epochSeconds)));
|
||||
} else {
|
||||
localStorage.removeItem(EXP_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the full token bundle returned by /api/login or /api/auth/refresh. */
|
||||
export function persistTokenBundle(bundle: {
|
||||
access_token?: string;
|
||||
token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
}): void {
|
||||
const access = bundle.access_token || bundle.token;
|
||||
if (access) setToken(access);
|
||||
setRefreshToken(bundle.refresh_token || null);
|
||||
if (bundle.expires_in) {
|
||||
setTokenExpiry(Math.floor(Date.now() / 1000) + bundle.expires_in);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
localStorage.removeItem("encoach_token");
|
||||
localStorage.removeItem(ACCESS_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
localStorage.removeItem(EXP_KEY);
|
||||
}
|
||||
|
||||
async function handleResponse<T>(response: Response): Promise<T> {
|
||||
const data = await response.json().catch(() => null);
|
||||
// Shared refresh promise — any 401 that arrives while a refresh is in flight
|
||||
// waits on the existing request instead of firing a duplicate.
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
if (response.status === 401) {
|
||||
const hadToken = !!getToken();
|
||||
clearToken();
|
||||
// Login failure is also 401 — do not hard-redirect when no session existed.
|
||||
if (hadToken) {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
throw new ApiError(401, response.statusText, data);
|
||||
async function performRefresh(): Promise<boolean> {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) return false;
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data: {
|
||||
access_token?: string;
|
||||
token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
} = await res.json().catch(() => ({} as never));
|
||||
if (!data.access_token && !data.token) return false;
|
||||
persistTokenBundle(data);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, response.statusText, data);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
function buildHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...extra,
|
||||
};
|
||||
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
function refreshOnce(): Promise<boolean> {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = performRefresh().finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
function getCurrentLanguage(): string {
|
||||
// Mirrors the i18n bootstrap in src/i18n/index.ts: the user's explicit
|
||||
// pick wins; everything else falls back to English. We intentionally do
|
||||
// not consult navigator.language here so AI-generated content stays in
|
||||
// English on first visit (matching the UI default) until the user flips
|
||||
// the language toggle.
|
||||
try {
|
||||
const stored = localStorage.getItem("encoach-lang");
|
||||
if (stored) return stored;
|
||||
} catch {
|
||||
// localStorage unavailable (SSR, sandboxing, etc.)
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
function buildHeaders(extra?: Record<string, string>, withJson = true): Record<string, string> {
|
||||
const headers: Record<string, string> = { ...extra };
|
||||
if (withJson) headers["Content-Type"] = headers["Content-Type"] || "application/json";
|
||||
|
||||
const token = getAccessToken();
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
// Tell the backend which UI language the user is on so AI-generated content
|
||||
// (coaching tips, insights, alerts, narratives) can be returned in the same
|
||||
// language instead of always defaulting to English.
|
||||
if (!headers["Accept-Language"]) {
|
||||
headers["Accept-Language"] = getCurrentLanguage();
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
|
||||
/**
|
||||
* Query param values we know how to serialise into a URL. Arrays are joined
|
||||
* with commas because that's what our Odoo controllers expect for multi-value
|
||||
* filters (e.g. `?state=draft,confirmed`).
|
||||
*/
|
||||
export type QueryParamValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| undefined
|
||||
| Array<string | number | boolean>;
|
||||
|
||||
/**
|
||||
* Accept any object-shaped bag of query params. We intentionally use `object`
|
||||
* here rather than `Record<string, QueryParamValue>` because typed interfaces
|
||||
* (e.g. `PaginationParams`) don't satisfy a `Record<...>` index signature by
|
||||
* default, which would force every call-site to cast.
|
||||
*/
|
||||
export type QueryParams = object;
|
||||
|
||||
const ENTITY_QUERY_SCOPE_RE =
|
||||
/^\/(courses|students|teachers|batches|branches|classrooms|student\/my-courses|ai\/course-plan)(\/|$)/;
|
||||
const ENTITY_BODY_SCOPE_RE =
|
||||
/^\/(courses|students|teachers|batches|branches|classrooms|ai\/course-plan)(\/|$)/;
|
||||
|
||||
function buildUrl(path: string, params?: QueryParams): string {
|
||||
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
url.searchParams.set(key, String(value));
|
||||
for (const [key, rawValue] of Object.entries(params as Record<string, unknown>)) {
|
||||
if (rawValue === undefined || rawValue === null) continue;
|
||||
if (Array.isArray(rawValue)) {
|
||||
if (rawValue.length === 0) continue;
|
||||
url.searchParams.set(key, rawValue.map(v => String(v)).join(","));
|
||||
continue;
|
||||
}
|
||||
});
|
||||
url.searchParams.set(key, String(rawValue));
|
||||
}
|
||||
}
|
||||
// Multi-entity LMS scope: when the user has selected an active entity
|
||||
// in the UI, append it to entity-scoped endpoints unless the caller
|
||||
// already provided an explicit entity_id.
|
||||
const activeEntityId = getActiveEntityId();
|
||||
if (
|
||||
activeEntityId &&
|
||||
!url.searchParams.has("entity_id") &&
|
||||
ENTITY_QUERY_SCOPE_RE.test(path)
|
||||
) {
|
||||
url.searchParams.set("entity_id", String(activeEntityId));
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function maybeInjectEntityIntoBody(path: string, body: unknown): unknown {
|
||||
const activeEntityId = getActiveEntityId();
|
||||
if (!activeEntityId) return body;
|
||||
if (!ENTITY_BODY_SCOPE_RE.test(path)) return body;
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
|
||||
const rec = body as Record<string, unknown>;
|
||||
if (rec.entity_id !== undefined && rec.entity_id !== null) return body;
|
||||
return { ...rec, entity_id: activeEntityId };
|
||||
}
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new ApiError(response.status, response.statusText, data);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
type RequestInitWithSkip = RequestInit & { _skipRetry?: boolean };
|
||||
|
||||
async function performRequest<T>(url: string, init: RequestInitWithSkip): Promise<T> {
|
||||
const response = await fetch(url, init);
|
||||
|
||||
// Auth/refresh endpoints opt out of the retry loop — otherwise a bad
|
||||
// refresh token would recurse forever.
|
||||
const isAuthEndpoint = url.includes("/auth/refresh") || url.includes("/login");
|
||||
|
||||
if (response.status === 401 && !init._skipRetry && !isAuthEndpoint) {
|
||||
const hadRefresh = !!getRefreshToken();
|
||||
if (hadRefresh) {
|
||||
const rotated = await refreshOnce();
|
||||
if (rotated) {
|
||||
// Rebuild headers so the new access token is attached.
|
||||
const newInit: RequestInitWithSkip = {
|
||||
...init,
|
||||
_skipRetry: true,
|
||||
headers: {
|
||||
...(init.headers as Record<string, string>),
|
||||
Authorization: `Bearer ${getAccessToken()}`,
|
||||
},
|
||||
};
|
||||
return performRequest<T>(url, newInit);
|
||||
}
|
||||
}
|
||||
const hadAccess = !!getAccessToken();
|
||||
clearToken();
|
||||
if (hadAccess || hadRefresh) {
|
||||
// Use SPA-style navigation when possible; fall back to a hard nav only
|
||||
// when we're inside a worker / non-browser context. A full document
|
||||
// reload here used to feel like "the browser refreshes on every click"
|
||||
// whenever an access token silently expired.
|
||||
if (typeof window !== "undefined" && !window.location.pathname.startsWith("/login")) {
|
||||
window.history.pushState({}, "", "/login");
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
}
|
||||
}
|
||||
throw new ApiError(401, response.statusText, await response.json().catch(() => null));
|
||||
}
|
||||
|
||||
return parseResponse<T>(response);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async get<T>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
|
||||
const res = await fetch(buildUrl(path, params), {
|
||||
async get<T>(path: string, params?: QueryParams): Promise<T> {
|
||||
return performRequest<T>(buildUrl(path, params), {
|
||||
method: "GET",
|
||||
headers: buildHeaders(),
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async post<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
const payload = maybeInjectEntityIntoBody(path, body);
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "POST",
|
||||
headers: buildHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async patch<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
const payload = maybeInjectEntityIntoBody(path, body);
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "PATCH",
|
||||
headers: buildHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async put<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
const payload = maybeInjectEntityIntoBody(path, body);
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "PUT",
|
||||
headers: buildHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async delete<T>(path: string): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
async delete<T>(path: string, payload?: Record<string, unknown>): Promise<T> {
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "DELETE",
|
||||
headers: buildHeaders(),
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async upload<T>(path: string, formData: FormData): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(buildUrl(path), {
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "POST",
|
||||
headers,
|
||||
headers: buildHeaders(undefined, false),
|
||||
body: formData,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
import "./i18n";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
@@ -31,9 +31,10 @@ interface PlatformStats {
|
||||
export default function AdminDashboard() {
|
||||
const { data: stats } = useQuery<PlatformStats>({
|
||||
queryKey: ["platform", "stats"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<{ data: PlatformStats }>("/stats");
|
||||
return (res as { data: PlatformStats }).data ?? res;
|
||||
queryFn: async (): Promise<PlatformStats> => {
|
||||
const res = await api.get<{ data: PlatformStats } | PlatformStats>("/stats");
|
||||
const wrapped = res as { data?: PlatformStats };
|
||||
return wrapped.data ?? (res as PlatformStats);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -114,6 +114,7 @@ export default function AssignmentsPage() {
|
||||
const [stateFilter, setStateFilter] = useState<ScheduleState | "all">("all");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState<FormState>(emptyForm());
|
||||
const [studentQuery, setStudentQuery] = useState("");
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
@@ -144,6 +145,14 @@ export default function AssignmentsPage() {
|
||||
|
||||
const studentsQ = useStudents({ size: 200 });
|
||||
const students = studentsQ.data?.items ?? [];
|
||||
const filteredStudents = useMemo(() => {
|
||||
const q = studentQuery.trim().toLowerCase();
|
||||
if (!q) return students;
|
||||
return students.filter((s) =>
|
||||
(s.name || "").toLowerCase().includes(q) ||
|
||||
(s.email || "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [students, studentQuery]);
|
||||
|
||||
const stateCounts = useMemo(() => {
|
||||
const all = schedulesQ.data?.items ?? [];
|
||||
@@ -468,9 +477,23 @@ export default function AssignmentsPage() {
|
||||
{/* Individual student selection */}
|
||||
{form.assign_mode === "individual" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Select Students</Label>
|
||||
<div className="max-h-48 overflow-y-auto border rounded-md p-2 space-y-1">
|
||||
{students.map((s) => (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs">Select Students</Label>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{form.student_ids.size} selected · {students.length} total
|
||||
</span>
|
||||
</div>
|
||||
{/* Search box — needed because QA reported long student lists
|
||||
made bottom entries unreachable inside the inner scroll
|
||||
container. Filtering keeps the list short and predictable. */}
|
||||
<Input
|
||||
placeholder="Search students by name or email…"
|
||||
value={studentQuery}
|
||||
onChange={(e) => setStudentQuery(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<div className="max-h-64 overflow-y-auto border rounded-md p-2 space-y-1">
|
||||
{filteredStudents.map((s) => (
|
||||
<label key={s.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
||||
<Checkbox
|
||||
checked={form.student_ids.has(s.id)}
|
||||
@@ -487,6 +510,11 @@ export default function AssignmentsPage() {
|
||||
{s.email && <span className="text-xs text-muted-foreground ml-auto">{s.email}</span>}
|
||||
</label>
|
||||
))}
|
||||
{filteredStudents.length === 0 && students.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground italic text-center py-2">
|
||||
No students match “{studentQuery}”.
|
||||
</p>
|
||||
)}
|
||||
{students.length === 0 && <p className="text-xs text-muted-foreground italic text-center py-2">No students available</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -7,10 +7,12 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Search, Plus, Building2, Trash2, Pencil, Users, Shield } from "lucide-react";
|
||||
import { Search, Plus, Building2, Trash2, Pencil, Users, Shield, UserCog } from "lucide-react";
|
||||
import { entitiesService } from "@/services/entities.service";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
const ENTITY_TYPES = [
|
||||
{ value: "corporate", label: "Corporate" },
|
||||
@@ -21,10 +23,16 @@ const ENTITY_TYPES = [
|
||||
];
|
||||
|
||||
export default function EntitiesPage() {
|
||||
const { user } = useAuth();
|
||||
const isAdminUser = user?.user_type === "admin";
|
||||
const [search, setSearch] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editEntity, setEditEntity] = useState<{ id: number; name: string; code: string; type: string } | null>(null);
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
const [manageEntity, setManageEntity] = useState<{ id: number; name: string } | null>(null);
|
||||
const [usersSearch, setUsersSearch] = useState("");
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<Set<number>>(new Set());
|
||||
const [form, setForm] = useState({ name: "", code: "", type: "" });
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
@@ -34,6 +42,18 @@ export default function EntitiesPage() {
|
||||
queryFn: () => entitiesService.list({ size: 200 }),
|
||||
});
|
||||
|
||||
const platformUsersQ = useQuery({
|
||||
queryKey: ["platform-users-for-entities"],
|
||||
queryFn: () => entitiesService.listPlatformUsers({ size: 500 }),
|
||||
enabled: isAdminUser && manageOpen,
|
||||
});
|
||||
|
||||
const entityUsersQ = useQuery({
|
||||
queryKey: ["entity-users", manageEntity?.id],
|
||||
queryFn: () => entitiesService.listEntityUsers(manageEntity!.id),
|
||||
enabled: isAdminUser && !!manageEntity?.id,
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (data: { name: string; code?: string; type?: string }) =>
|
||||
entitiesService.create(data as Partial<import("@/types").Entity>),
|
||||
@@ -66,11 +86,26 @@ export default function EntitiesPage() {
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const raw = entitiesQ.data;
|
||||
const saveEntityUsersMut = useMutation({
|
||||
mutationFn: ({ entityId, userIds }: { entityId: number; userIds: number[] }) =>
|
||||
entitiesService.updateEntityUsers(entityId, userIds),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["entities"] });
|
||||
qc.invalidateQueries({ queryKey: ["entity-users"] });
|
||||
setManageOpen(false);
|
||||
setManageEntity(null);
|
||||
setUsersSearch("");
|
||||
setSelectedUserIds(new Set());
|
||||
toast({ title: "Entity users updated" });
|
||||
},
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const raw = entitiesQ.data as unknown;
|
||||
const entities: { id: number; name: string; code: string; type: string; user_count: number; role_count: number; active: boolean }[] = (() => {
|
||||
if (!raw) return [];
|
||||
if (Array.isArray(raw)) return raw as never[];
|
||||
const r = raw as Record<string, unknown>;
|
||||
const r = raw as { items?: unknown; data?: unknown };
|
||||
const arr = (r.items ?? r.data ?? []) as never[];
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
})();
|
||||
@@ -82,11 +117,43 @@ export default function EntitiesPage() {
|
||||
);
|
||||
const loading = entitiesQ.isLoading;
|
||||
|
||||
useEffect(() => {
|
||||
if (!entityUsersQ.data) return;
|
||||
setSelectedUserIds(new Set(entityUsersQ.data.map((u) => u.id)));
|
||||
}, [entityUsersQ.data]);
|
||||
|
||||
const filteredPlatformUsers = useMemo(() => {
|
||||
const all = platformUsersQ.data ?? [];
|
||||
const q = usersSearch.trim().toLowerCase();
|
||||
if (!q) return all;
|
||||
return all.filter((u) =>
|
||||
(u.name || "").toLowerCase().includes(q)
|
||||
|| (u.email || "").toLowerCase().includes(q)
|
||||
|| (u.login || "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [platformUsersQ.data, usersSearch]);
|
||||
|
||||
function openEdit(e: typeof entities[0]) {
|
||||
setEditEntity({ id: e.id, name: e.name, code: e.code, type: e.type });
|
||||
setEditOpen(true);
|
||||
}
|
||||
|
||||
function openManageUsers(e: typeof entities[0]) {
|
||||
setManageEntity({ id: e.id, name: e.name });
|
||||
setUsersSearch("");
|
||||
setSelectedUserIds(new Set());
|
||||
setManageOpen(true);
|
||||
}
|
||||
|
||||
function toggleUser(userId: number) {
|
||||
setSelectedUserIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(userId)) next.delete(userId);
|
||||
else next.add(userId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -159,6 +226,11 @@ export default function EntitiesPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
{isAdminUser ? (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openManageUsers(e)}>
|
||||
<UserCog className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(e)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -279,6 +351,79 @@ export default function EntitiesPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Manage Users Dialog (admin only) */}
|
||||
<Dialog open={manageOpen} onOpenChange={(open) => {
|
||||
setManageOpen(open);
|
||||
if (!open) {
|
||||
setManageEntity(null);
|
||||
setUsersSearch("");
|
||||
setSelectedUserIds(new Set());
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Users — {manageEntity?.name ?? ""}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{!isAdminUser ? (
|
||||
<div className="text-sm text-muted-foreground py-4">Only admin users can manage entity members.</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search users by name/email/login..."
|
||||
className="pl-9"
|
||||
value={usersSearch}
|
||||
onChange={(e) => setUsersSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border max-h-[420px] overflow-y-auto">
|
||||
{platformUsersQ.isLoading || entityUsersQ.isLoading ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">Loading users...</div>
|
||||
) : filteredPlatformUsers.length === 0 ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">No users found.</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{filteredPlatformUsers.map((u) => (
|
||||
<label key={u.id} className="flex items-center gap-3 p-3 hover:bg-muted/40 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={selectedUserIds.has(u.id)}
|
||||
onCheckedChange={() => toggleUser(u.id)}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{u.name || u.login || u.email}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{u.email || u.login}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<div className="text-xs text-muted-foreground mr-auto">
|
||||
{selectedUserIds.size} user(s) selected
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setManageOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={!manageEntity || saveEntityUsersMut.isPending || !isAdminUser}
|
||||
onClick={() => {
|
||||
if (!manageEntity) return;
|
||||
saveEntityUsersMut.mutate({
|
||||
entityId: manageEntity.id,
|
||||
userIds: Array.from(selectedUserIds),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{saveEntityUsersMut.isPending ? "Saving..." : "Save Users"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GraduationCap, Play, Sparkles } from "lucide-react";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
|
||||
export default function ExamPage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh] gap-4 max-w-md mx-auto">
|
||||
<AiTipBanner context="exam" variant="recommendation" />
|
||||
|
||||
<Card className="border-0 shadow-sm w-full">
|
||||
<CardContent className="p-8 text-center space-y-6">
|
||||
<div className="mx-auto h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<GraduationCap className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-1">Ready to Start?</h2>
|
||||
<p className="text-muted-foreground text-sm">IELTS Academic Mock Exam</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Student</span>
|
||||
<span className="font-medium">Sarah Johnson</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Level</span>
|
||||
<Badge variant="outline">B2</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Modules</span>
|
||||
<span className="font-medium">4</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Duration</span>
|
||||
<span className="font-medium">180 min</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Mode</span>
|
||||
<Badge variant="secondary">Practice</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-primary/5 border border-primary/20 p-3 text-left">
|
||||
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-1"><Sparkles className="h-3 w-3" /> AI Pre-Exam Tip</p>
|
||||
<p className="text-xs text-muted-foreground">Your last mock scored 7.5. To target 8.0, focus on time management in Writing Task 2 — you spent 45 min last time vs recommended 40 min.</p>
|
||||
</div>
|
||||
<Button className="w-full" size="lg">
|
||||
<Play className="h-4 w-4 mr-2" /> Begin Exam
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1355,11 +1355,11 @@ export default function ExamStructuresPage() {
|
||||
const examType = getExamTypeBadge(s);
|
||||
return (
|
||||
<Card key={s.id} className="border-0 shadow-sm hover:shadow-md transition-shadow cursor-pointer" onClick={() => setEditTarget(s)}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-primary" />{s.name}
|
||||
</CardTitle>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-primary" />{s.name}
|
||||
</CardTitle>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-primary"
|
||||
onClick={(e) => { e.stopPropagation(); setEditTarget(s); }}>
|
||||
@@ -1370,9 +1370,9 @@ export default function ExamStructuresPage() {
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-3 flex-wrap">
|
||||
{s.entity_name && <span>Entity: <span className="text-foreground font-medium">{s.entity_name}</span></span>}
|
||||
{s.industry && <span>Industry: <span className="text-foreground font-medium">{s.industry}</span></span>}
|
||||
@@ -1382,9 +1382,9 @@ export default function ExamStructuresPage() {
|
||||
{(Array.isArray(s.modules) ? s.modules : []).map((m) => (
|
||||
<Badge key={m} variant="secondary" className="capitalize text-xs">{m}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -37,12 +37,14 @@ export default function ExamsListPage() {
|
||||
});
|
||||
|
||||
const sessionsQ = useInstitutionalExamSessions();
|
||||
const sessionItems = sessionsQ.data?.data ?? sessionsQ.data?.items ?? [];
|
||||
const sessions = Array.isArray(sessionItems) ? sessionItems : [];
|
||||
const sessionItems = sessionsQ.data?.items ?? [];
|
||||
const sessions: Record<string, string>[] = Array.isArray(sessionItems)
|
||||
? (sessionItems as unknown as Record<string, string>[])
|
||||
: [];
|
||||
|
||||
const customExams = customQ.data?.items ?? [];
|
||||
const q = search.toLowerCase();
|
||||
const filteredSessions = sessions.filter((s: Record<string, string>) =>
|
||||
const filteredSessions = sessions.filter((s) =>
|
||||
s.name?.toLowerCase().includes(q) || s.course_name?.toLowerCase().includes(q),
|
||||
);
|
||||
|
||||
@@ -140,7 +142,7 @@ export default function ExamsListPage() {
|
||||
{filteredSessions.length === 0 && (
|
||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No exam sessions found.</TableCell></TableRow>
|
||||
)}
|
||||
{filteredSessions.map((s: Record<string, string>, i: number) => (
|
||||
{filteredSessions.map((s, i) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell>{i + 1}</TableCell>
|
||||
<TableCell className="font-medium">{s.name}</TableCell>
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function ForgotPassword() {
|
||||
) : null}
|
||||
<div className="mt-6 text-center">
|
||||
<Link to="/login" className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
|
||||
<ArrowLeft className="h-3 w-3" /> Back to sign in
|
||||
<ArrowLeft className="h-3 w-3 rtl:rotate-180" /> Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -61,9 +61,9 @@ import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
import { generationService } from "@/services/generation.service";
|
||||
import { mediaService, type Avatar } from "@/services/media.service";
|
||||
import { examsService } from "@/services/exams.service";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { api, describeApiError } from "@/lib/api-client";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { ExamStructureConfig } from "@/types";
|
||||
import type { ExamStructure, ExamStructureConfig } from "@/types";
|
||||
|
||||
type ModuleKey = "reading" | "listening" | "writing" | "speaking" | "level" | "industry";
|
||||
|
||||
@@ -398,7 +398,7 @@ export default function GenerationPage() {
|
||||
|
||||
const createStructureMut = useMutation({
|
||||
mutationFn: (data: { name: string; modules: string[] }) =>
|
||||
examsService.createStructure(data),
|
||||
examsService.createStructure(data as unknown as Partial<ExamStructure>),
|
||||
onSuccess: (created) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["exam-structures"] });
|
||||
setExamStructure(String(created.id));
|
||||
@@ -693,7 +693,7 @@ export default function GenerationPage() {
|
||||
try {
|
||||
const status = await mediaService.getVideoStatus(videoId);
|
||||
if (status.status === "done" || status.status === "completed" || status.video_url) {
|
||||
updatedParts[pp.index] = { ...part, videoUrl: status.video_url || status.url || "" };
|
||||
updatedParts[pp.index] = { ...part, videoUrl: status.video_url || "" };
|
||||
changed = true;
|
||||
toast({ title: "Video ready!", description: "Avatar video has been generated." });
|
||||
} else if (status.status === "error" || status.status === "failed") {
|
||||
@@ -709,8 +709,51 @@ export default function GenerationPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeModule, moduleStates]);
|
||||
|
||||
// Compute the minimum required counts from the selected exam structure
|
||||
// so the submit mutation can block under-fulfilled modules instead of
|
||||
// silently dropping tasks/sections (QA: "if structure has Task 1 + Task 2
|
||||
// the user should not be able to submit with only Task 1").
|
||||
const requiredWritingTaskCount = (() => {
|
||||
const cfg = selectedStructureConfig;
|
||||
if (!cfg?.writing) return 0;
|
||||
return Math.max(
|
||||
cfg.writing.tasks?.length ?? 0,
|
||||
cfg.writing.task2 ? 2 : cfg.writing.task1 ? 1 : 0,
|
||||
);
|
||||
})();
|
||||
const requiredListeningPartCount = selectedStructureConfig?.listening?.parts?.length ?? 0;
|
||||
const requiredReadingPassageCount = selectedStructureConfig?.reading?.passages?.length ?? 0;
|
||||
const requiredSpeakingPartCount = selectedStructureConfig?.speaking?.parts?.length ?? 0;
|
||||
|
||||
const submitMut = useMutation({
|
||||
mutationFn: (skipApproval: boolean) => {
|
||||
// Enforce structure-defined task/section/part counts before submission.
|
||||
if (examMode === "official" && selectedStructure) {
|
||||
const w = getModuleState("writing");
|
||||
if (selectedModules.has("writing") && requiredWritingTaskCount > 0 && w.writingTasks.length < requiredWritingTaskCount) {
|
||||
throw new Error(
|
||||
`This structure defines ${requiredWritingTaskCount} writing tasks — you currently have ${w.writingTasks.length}. Please add the missing task before submitting.`,
|
||||
);
|
||||
}
|
||||
const l = getModuleState("listening");
|
||||
if (selectedModules.has("listening") && requiredListeningPartCount > 0 && l.listeningSections.length < requiredListeningPartCount) {
|
||||
throw new Error(
|
||||
`This structure defines ${requiredListeningPartCount} listening parts — you currently have ${l.listeningSections.length}.`,
|
||||
);
|
||||
}
|
||||
const r = getModuleState("reading");
|
||||
if (selectedModules.has("reading") && requiredReadingPassageCount > 0 && r.passages.length < requiredReadingPassageCount) {
|
||||
throw new Error(
|
||||
`This structure defines ${requiredReadingPassageCount} reading passages — you currently have ${r.passages.length}.`,
|
||||
);
|
||||
}
|
||||
const sp = getModuleState("speaking");
|
||||
if (selectedModules.has("speaking") && requiredSpeakingPartCount > 0 && sp.speakingParts.length < requiredSpeakingPartCount) {
|
||||
throw new Error(
|
||||
`This structure defines ${requiredSpeakingPartCount} speaking parts — you currently have ${sp.speakingParts.length}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const modulesPayload: Record<string, unknown> = {};
|
||||
for (const mod of selectedModules) {
|
||||
const st = getModuleState(mod);
|
||||
@@ -726,7 +769,7 @@ export default function GenerationPage() {
|
||||
totalMarks: st.totalMarks,
|
||||
passages: mod === "reading" ? st.passages.map((p) => ({
|
||||
text: p.text, category: p.category, type: p.type,
|
||||
exercises: p.exercises?.map((ex: Record<string, unknown>) => ({
|
||||
exercises: p.exercises?.map((ex) => ({
|
||||
type: ex.type, prompt: ex.prompt, options: ex.options,
|
||||
correct_answer: ex.correct_answer, explanation: ex.explanation,
|
||||
instructions: ex.instructions, marks: ex.marks || 1,
|
||||
@@ -735,7 +778,7 @@ export default function GenerationPage() {
|
||||
})) : undefined,
|
||||
sections: mod === "listening" ? st.listeningSections.map((s) => ({
|
||||
type: s.type, context: s.context, audioUrl: s.audioUrl,
|
||||
exercises: s.exercises?.map((ex: Record<string, unknown>) => ({
|
||||
exercises: s.exercises?.map((ex) => ({
|
||||
type: ex.type, prompt: ex.prompt, options: ex.options,
|
||||
correct_answer: ex.correct_answer, explanation: ex.explanation,
|
||||
instructions: ex.instructions, marks: ex.marks || 1,
|
||||
@@ -764,7 +807,12 @@ export default function GenerationPage() {
|
||||
description: `Exam #${res.exam_id} created with status "${res.status}". ${res.status === "published" ? "The exam is now live." : "Pending approval."}`,
|
||||
duration: 8000,
|
||||
}),
|
||||
onError: (err: Error) => toast({ variant: "destructive", title: "Submit failed", description: err.message }),
|
||||
onError: (err) =>
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Submit failed",
|
||||
description: describeApiError(err, "Submit failed", "module"),
|
||||
}),
|
||||
});
|
||||
|
||||
const anyGenerating = generatePassageMut.isPending || generateExercisesMut.isPending ||
|
||||
@@ -773,6 +821,11 @@ export default function GenerationPage() {
|
||||
|
||||
const renderCommonConfig = (mod: ModuleKey) => {
|
||||
const st = getModuleState(mod);
|
||||
// Rubrics only apply to subjective skills (Writing & Speaking).
|
||||
// Listening & Reading are auto-graded against a correct_answer, so
|
||||
// surfacing the rubric picker here is misleading — QA asked to either
|
||||
// grey it out or mark it "Not applicable".
|
||||
const rubricApplies = mod === "writing" || mod === "speaking";
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
|
||||
<div className="space-y-1">
|
||||
@@ -781,30 +834,44 @@ export default function GenerationPage() {
|
||||
</div>
|
||||
{examMode === "official" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Rubric</Label>
|
||||
<Select value={st.rubricId} onValueChange={(v) => updateModuleState(mod, { rubricId: v })}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{rubrics.length > 0 && (
|
||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
|
||||
)}
|
||||
{rubrics.map((r) => (
|
||||
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
|
||||
))}
|
||||
{rubricGroups.length > 0 && (
|
||||
<>
|
||||
<div className="border-t my-1" />
|
||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
|
||||
</>
|
||||
)}
|
||||
{rubricGroups.map((g) => (
|
||||
<SelectItem key={`g-${g.id}`} value={`group-${g.id}`}>{g.name}</SelectItem>
|
||||
))}
|
||||
{rubrics.length === 0 && rubricGroups.length === 0 && (
|
||||
<SelectItem value="_none" disabled>No rubrics available</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label className="text-xs flex items-center gap-1">
|
||||
Rubric
|
||||
{!rubricApplies && (
|
||||
<span className="text-[10px] font-normal text-muted-foreground">(Not applicable)</span>
|
||||
)}
|
||||
</Label>
|
||||
{rubricApplies ? (
|
||||
<Select value={st.rubricId} onValueChange={(v) => updateModuleState(mod, { rubricId: v })}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{rubrics.length > 0 && (
|
||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
|
||||
)}
|
||||
{rubrics.map((r) => (
|
||||
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
|
||||
))}
|
||||
{rubricGroups.length > 0 && (
|
||||
<>
|
||||
<div className="border-t my-1" />
|
||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
|
||||
</>
|
||||
)}
|
||||
{rubricGroups.map((g) => (
|
||||
<SelectItem key={`g-${g.id}`} value={`group-${g.id}`}>{g.name}</SelectItem>
|
||||
))}
|
||||
{rubrics.length === 0 && rubricGroups.length === 0 && (
|
||||
<SelectItem value="_none" disabled>No rubrics available</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div
|
||||
className="h-8 text-xs flex items-center px-3 rounded-md border border-input bg-muted/40 text-muted-foreground select-none"
|
||||
title="Reading and Listening are auto-graded from a correct-answer key; rubrics only apply to Writing and Speaking."
|
||||
>
|
||||
Auto-graded — no rubric
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
@@ -1202,6 +1269,12 @@ export default function GenerationPage() {
|
||||
<SelectItem value="page_break">Page Break</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground mt-1 leading-snug">
|
||||
Controls how this section is visually separated from the next in the student view: <b>None</b> — no separator; <b>Line</b> — horizontal rule; <b>Space</b> — extra vertical gap; <b>Page Break</b> — section starts on a new page (paginated delivery only).
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-1 leading-snug">
|
||||
Write instructions in the box above the audio (or the <i>Generate Instructions</i> card). Use the imperative voice, e.g. “Listen to the conversation and answer the questions below.”
|
||||
</p>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<Collapsible>
|
||||
@@ -1407,7 +1480,7 @@ export default function GenerationPage() {
|
||||
<div key={i} className="flex items-center gap-1 bg-muted/50 rounded px-2 py-0.5">
|
||||
<Checkbox checked id={`wt-${i}`} />
|
||||
<Label htmlFor={`wt-${i}`} className="text-xs">Task {i + 1}</Label>
|
||||
{st.writingTasks.length > 1 && (
|
||||
{st.writingTasks.length > Math.max(1, requiredWritingTaskCount) && (
|
||||
<button
|
||||
className="ml-1 text-muted-foreground/60 hover:text-destructive transition-colors"
|
||||
onClick={() => {
|
||||
@@ -1428,6 +1501,11 @@ export default function GenerationPage() {
|
||||
}}><Plus className="h-3 w-3 mr-1" /> Add Task 2</Button>
|
||||
)}
|
||||
</div>
|
||||
{requiredWritingTaskCount > 0 && st.writingTasks.length < requiredWritingTaskCount && (
|
||||
<p className="text-[11px] text-destructive">
|
||||
This structure requires {requiredWritingTaskCount} tasks. Add the missing task to submit.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={() => setModuleStates((prev) => ({ ...prev, writing: defaultModuleState("writing") }))} className="text-xs">
|
||||
@@ -1485,6 +1563,12 @@ export default function GenerationPage() {
|
||||
<SelectItem value="page_break">Page Break</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground mt-1 leading-snug">
|
||||
Visual separator between Task 1 and Task 2 in the student exam: <b>None</b> — no gap; <b>Line</b> — horizontal rule; <b>Space</b> — extra padding; <b>Page Break</b> — Task 2 opens on a new page.
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-1 leading-snug">
|
||||
Instructions are written in the <i>Generate Instructions</i> box below. Use the imperative voice and name the genre + audience, e.g. “Write a 250-word academic essay arguing…”.
|
||||
</p>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<Collapsible>
|
||||
@@ -2196,6 +2280,54 @@ export default function GenerationPage() {
|
||||
setSelectedModules(next);
|
||||
if (next.size > 0) setActiveModule([...next][0]);
|
||||
}
|
||||
// Pre-populate writingTasks / listeningSections / readingPassages
|
||||
// / speakingParts to match the structure. QA flagged that selecting
|
||||
// a structure with "Task 1 + Task 2" still let you submit with only
|
||||
// Task 1 because the UI defaulted to a single task.
|
||||
const cfg = (s.config && typeof s.config === "object" ? s.config : null) as ExamStructureConfig | null;
|
||||
if (cfg) {
|
||||
const writingTaskCount = Math.max(
|
||||
cfg.writing?.tasks?.length ?? 0,
|
||||
cfg.writing?.task2 ? 2 : cfg.writing?.task1 ? 1 : 0,
|
||||
);
|
||||
if (writingTaskCount > 0) {
|
||||
updateModuleState("writing", {
|
||||
writingTasks: Array.from({ length: writingTaskCount }, (_, i) => {
|
||||
const base = defaultWritingTask();
|
||||
if (i === 1) {
|
||||
base.type = "essay";
|
||||
base.wordLimit = 250;
|
||||
}
|
||||
const taskCfg = cfg.writing?.tasks?.[i];
|
||||
if (taskCfg) {
|
||||
if (taskCfg.type) base.type = taskCfg.type;
|
||||
if (taskCfg.min_words) base.wordLimit = taskCfg.min_words;
|
||||
}
|
||||
return base;
|
||||
}),
|
||||
});
|
||||
}
|
||||
const listeningPartCount = cfg.listening?.parts?.length ?? 0;
|
||||
if (listeningPartCount > 0) {
|
||||
updateModuleState("listening", {
|
||||
listeningSections: Array.from({ length: listeningPartCount }, () => defaultListeningSection()),
|
||||
});
|
||||
}
|
||||
const readingPassageCount = cfg.reading?.passages?.length ?? 0;
|
||||
if (readingPassageCount > 0) {
|
||||
updateModuleState("reading", {
|
||||
passages: Array.from({ length: readingPassageCount }, () => defaultPassage()),
|
||||
});
|
||||
}
|
||||
const speakingPartCount = cfg.speaking?.parts?.length ?? 0;
|
||||
if (speakingPartCount > 0) {
|
||||
updateModuleState("speaking", {
|
||||
speakingParts: Array.from({ length: speakingPartCount }, (_, i) =>
|
||||
defaultSpeakingPart(i === 2 ? "interactive" : `speaking_${i + 1}`),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
// Update this page (the content is just a fallback if you fail to update the page)
|
||||
|
||||
const Index = () => {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<h1 className="mb-4 text-4xl font-bold">Welcome to Your Blank App</h1>
|
||||
<p className="text-xl text-muted-foreground">Start building your amazing project here!</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -10,6 +11,7 @@ import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { ApiError } from "@/lib/api-client";
|
||||
import type { UserRole } from "@/types/auth";
|
||||
import { LanguageToggle } from "@/components/LanguageToggle";
|
||||
|
||||
/** Keep in sync with `ProtectedRoute` post-login targets */
|
||||
function getRoleDashboard(role: string): string {
|
||||
@@ -48,11 +50,16 @@ export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email || !password) {
|
||||
toast({ title: "Error", description: "Please enter email and password", variant: "destructive" });
|
||||
toast({
|
||||
title: t("auth.errorTitle"),
|
||||
description: t("auth.missingCredentials"),
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,7 +68,11 @@ export default function Login() {
|
||||
const user = await login(email, password);
|
||||
navigate(getRoleDashboard(user.user_type));
|
||||
} catch (err: unknown) {
|
||||
toast({ title: "Login Failed", description: loginErrorMessage(err), variant: "destructive" });
|
||||
toast({
|
||||
title: t("auth.loginFailedTitle"),
|
||||
description: loginErrorMessage(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -69,35 +80,39 @@ export default function Login() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<div className="absolute top-4 right-4">
|
||||
<LanguageToggle />
|
||||
</div>
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center gap-3 mb-8">
|
||||
<img src="/logo.png" alt="EnCoach" className="h-12 w-12 rounded-xl object-contain" />
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<span className="text-[hsl(42,40%,62%)]">En</span>
|
||||
<span className="text-[hsl(240,20%,30%)]">Coach</span>
|
||||
</h1>
|
||||
<div className="flex items-center justify-center mb-8">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="EnCoach — Unlock your potential with AI powered platform"
|
||||
className="h-36 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-lg border-0 bg-card">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<CardTitle className="text-xl">Welcome back</CardTitle>
|
||||
<CardDescription>Sign in to your account to continue</CardDescription>
|
||||
<CardTitle className="text-xl">{t("auth.welcomeBack")}</CardTitle>
|
||||
<CardDescription>{t("auth.signInDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Label htmlFor="email">{t("auth.email")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="text"
|
||||
placeholder="you@example.com"
|
||||
placeholder={t("auth.emailPlaceholder")}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={loading}
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
@@ -106,11 +121,13 @@ export default function Login() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
dir="ltr"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showPassword ? t("auth.password") : t("auth.password")}
|
||||
className="absolute end-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
@@ -119,19 +136,25 @@ export default function Login() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox id="remember" />
|
||||
<Label htmlFor="remember" className="text-sm font-normal cursor-pointer">Remember me</Label>
|
||||
<Label htmlFor="remember" className="text-sm font-normal cursor-pointer">
|
||||
{t("auth.rememberMe")}
|
||||
</Label>
|
||||
</div>
|
||||
<Link to="/forgot-password" className="text-sm text-primary hover:underline">Forgot password?</Link>
|
||||
<Link to="/forgot-password" className="text-sm text-primary hover:underline">
|
||||
{t("auth.forgotPassword")}
|
||||
</Link>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Sign in
|
||||
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
|
||||
{t("auth.signIn")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{" "}
|
||||
<Link to="/register" className="text-primary font-medium hover:underline">Sign up</Link>
|
||||
{t("auth.needAccount")}{" "}
|
||||
<Link to="/register" className="text-primary font-medium hover:underline">
|
||||
{t("auth.signUp")}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const NotFound = () => {
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
|
||||
@@ -11,10 +13,10 @@ const NotFound = () => {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted">
|
||||
<div className="text-center">
|
||||
<h1 className="mb-4 text-4xl font-bold">404</h1>
|
||||
<p className="mb-4 text-xl text-muted-foreground">Oops! Page not found</p>
|
||||
<h1 className="mb-4 text-4xl font-bold">{t("errors.notFoundCode")}</h1>
|
||||
<p className="mb-4 text-xl text-muted-foreground">{t("errors.notFoundMessage")}</p>
|
||||
<a href="/" className="text-primary underline hover:text-primary/90">
|
||||
Return to Home
|
||||
{t("errors.returnHome")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
173
src/pages/PrivacyCenter.tsx
Normal file
173
src/pages/PrivacyCenter.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Download, ShieldAlert, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { gdprService } from "@/services/gdpr.service";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
export default function PrivacyCenter() {
|
||||
const navigate = useNavigate();
|
||||
const { logout } = useAuth();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [erasing, setErasing] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
|
||||
const downloadExport = async () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
const payload = await gdprService.exportMyData();
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `encoach-data-export-${new Date()
|
||||
.toISOString()
|
||||
.slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Data export downloaded");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Export failed");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const eraseAccount = async () => {
|
||||
try {
|
||||
setErasing(true);
|
||||
await gdprService.eraseMyAccount();
|
||||
toast.success("Your account has been erased. Signing out…");
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await logout();
|
||||
} catch {
|
||||
// ignore — the server may have already invalidated our tokens
|
||||
}
|
||||
navigate("/login", { replace: true });
|
||||
}, 1200);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Erasure failed");
|
||||
setErasing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
<ShieldAlert className="mr-1 inline h-5 w-5" />
|
||||
Privacy Center
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Manage your personal data held by EnCoach under GDPR and equivalent
|
||||
regulations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Download your data</CardTitle>
|
||||
<CardDescription>
|
||||
We'll package your profile, entity memberships, exam attempts,
|
||||
answers, AI feedback, and tickets into a single JSON file.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={downloadExport} disabled={exporting}>
|
||||
<Download className="mr-1 h-4 w-4" />
|
||||
{exporting ? "Preparing export…" : "Download my data"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Delete my account</CardTitle>
|
||||
<CardDescription>
|
||||
This anonymises your profile and removes personal fields from our
|
||||
records. Exam attempts are retained (without identifying
|
||||
information) for statistical integrity. This action cannot be
|
||||
undone.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
disabled={erasing}
|
||||
>
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
{erasing ? "Erasing…" : "Erase my account"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogContent description="Erasure is permanent — type DELETE to confirm.">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Erase your account?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
We'll anonymise your personal data and deactivate your login.
|
||||
Aggregate analytics (exam scores, attempt counts) will remain
|
||||
but will no longer be linked to you.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="confirm-delete">
|
||||
Type <strong>DELETE</strong> to confirm:
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-delete"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder="DELETE"
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setConfirmText("")}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={confirmText !== "DELETE" || erasing}
|
||||
onClick={async () => {
|
||||
setConfirmOpen(false);
|
||||
await eraseAccount();
|
||||
setConfirmText("");
|
||||
}}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Erase account
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, 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 { User } from "lucide-react";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const nameParts = (user?.name || "Admin User").split(" ");
|
||||
const [first, setFirst] = useState(nameParts[0] || "");
|
||||
const [last, setLast] = useState(nameParts.slice(1).join(" ") || "");
|
||||
const [email, setEmail] = useState(user?.email || "");
|
||||
const [curPw, setCurPw] = useState("");
|
||||
const [newPw, setNewPw] = useState("");
|
||||
const [confirmPw, setConfirmPw] = useState("");
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: () => api.patch("/user", { first_name: first, last_name: last, email }),
|
||||
onSuccess: () => toast({ title: "Profile saved" }),
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const pwMut = useMutation({
|
||||
mutationFn: () => api.post("/user/change-password", { current_password: curPw, new_password: newPw }),
|
||||
onSuccess: () => { setCurPw(""); setNewPw(""); setConfirmPw(""); toast({ title: "Password updated" }); },
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Profile</h1>
|
||||
<p className="text-muted-foreground">Manage your account information.</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader><CardTitle className="text-base">Personal Information</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<User className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2"><Label>First Name</Label><Input value={first} onChange={(e) => setFirst(e.target.value)} /></div>
|
||||
<div className="space-y-2"><Label>Last Name</Label><Input value={last} onChange={(e) => setLast(e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="space-y-2"><Label>Email</Label><Input value={email} onChange={(e) => setEmail(e.target.value)} type="email" /></div>
|
||||
<Button onClick={() => saveMut.mutate()} disabled={saveMut.isPending}>
|
||||
{saveMut.isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader><CardTitle className="text-base">Change Password</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2"><Label>Current Password</Label><Input type="password" value={curPw} onChange={(e) => setCurPw(e.target.value)} placeholder="••••••••" /></div>
|
||||
<div className="space-y-2"><Label>New Password</Label><Input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} placeholder="••••••••" /></div>
|
||||
<div className="space-y-2"><Label>Confirm New Password</Label><Input type="password" value={confirmPw} onChange={(e) => setConfirmPw(e.target.value)} placeholder="••••••••" /></div>
|
||||
{newPw && confirmPw && newPw !== confirmPw && <p className="text-sm text-destructive">Passwords do not match.</p>}
|
||||
<Button onClick={() => pwMut.mutate()} disabled={pwMut.isPending || !curPw || !newPw || newPw !== confirmPw}>
|
||||
{pwMut.isPending ? "Updating..." : "Update Password"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +1,212 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, Download } from "lucide-react";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
import AiReportNarrative from "@/components/ai/AiReportNarrative";
|
||||
import { reportsService, RecordRow } from "@/services/reports.service";
|
||||
|
||||
const records = [
|
||||
{ id: 1, assignment: "IELTS Prep Q1", exam: "EX-001", date: "2025-03-01", score: 7.5, duration: "175 min", status: "Completed" },
|
||||
{ id: 2, assignment: "Speaking Workshop", exam: "EX-005", date: "2025-03-05", score: 6.0, duration: "14 min", status: "Completed" },
|
||||
{ id: 3, assignment: "Full Mock Exam", exam: "EX-006", date: "2025-03-10", score: null, duration: "120 min", status: "In Progress" },
|
||||
{ id: 4, assignment: "Listening Bootcamp", exam: "EX-003", date: "2025-02-20", score: 5.5, duration: "28 min", status: "Completed" },
|
||||
];
|
||||
type Period = "all" | "day" | "week" | "month";
|
||||
|
||||
export default function RecordPage() {
|
||||
const [entityId, setEntityId] = useState("all");
|
||||
const [userId, setUserId] = useState("all");
|
||||
const [period, setPeriod] = useState<Period>("all");
|
||||
|
||||
const { data: filters } = useQuery({
|
||||
queryKey: ["reports-filters"],
|
||||
queryFn: () => reportsService.filters(),
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["record", entityId, userId, period],
|
||||
queryFn: () =>
|
||||
reportsService.record({
|
||||
entity_id: entityId === "all" ? undefined : Number(entityId),
|
||||
user_id: userId === "all" ? undefined : Number(userId),
|
||||
period: period === "all" ? undefined : period,
|
||||
size: 100,
|
||||
}),
|
||||
});
|
||||
|
||||
const records: RecordRow[] = data?.items ?? [];
|
||||
|
||||
const exportCsv = () => {
|
||||
const header = [
|
||||
"Student",
|
||||
"Assignment",
|
||||
"Exam",
|
||||
"Date",
|
||||
"Score",
|
||||
"Duration",
|
||||
"Status",
|
||||
];
|
||||
const lines = records.map((r) =>
|
||||
[
|
||||
r.student_name,
|
||||
r.assignment,
|
||||
r.exam_code || r.exam,
|
||||
r.date ?? "",
|
||||
r.score ?? "",
|
||||
r.duration,
|
||||
r.status_label,
|
||||
]
|
||||
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
|
||||
.join(","),
|
||||
);
|
||||
const blob = new Blob([[header.join(","), ...lines].join("\n")], {
|
||||
type: "text/csv",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `records-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const statusVariant = (status: string) => {
|
||||
if (status === "completed" || status === "released" || status === "scored")
|
||||
return "default";
|
||||
if (status === "in_progress" || status === "scoring") return "secondary";
|
||||
return "outline";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Record</h1>
|
||||
<p className="text-muted-foreground">Browse assignment and exam attempt history.</p>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Record</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Browse assignment and exam attempt history.
|
||||
{data && (
|
||||
<span className="ml-2 text-xs">({data.total} attempts)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={exportCsv}
|
||||
disabled={!records.length}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" /> Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AiTipBanner context="record" variant="insight" />
|
||||
|
||||
<AiReportNarrative report_type="record" data={{ records }} />
|
||||
{records.length > 0 && (
|
||||
<AiReportNarrative report_type="record" data={{ records }} />
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="Entity" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="acme">Acme Corp</SelectItem><SelectItem value="global">Global Ltd</SelectItem></SelectContent>
|
||||
<Select value={entityId} onValueChange={setEntityId}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All Entities" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Entities</SelectItem>
|
||||
{filters?.entities.map((e) => (
|
||||
<SelectItem key={e.id} value={String(e.id)}>
|
||||
{e.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select><SelectTrigger className="w-[180px]"><SelectValue placeholder="Select User" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="sarah">Sarah Johnson</SelectItem><SelectItem value="ahmed">Ahmed Hassan</SelectItem></SelectContent>
|
||||
<Select value={userId} onValueChange={setUserId}>
|
||||
<SelectTrigger className="w-[220px]">
|
||||
<SelectValue placeholder="All Users" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Users</SelectItem>
|
||||
{filters?.students.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex gap-1">
|
||||
{["Day", "Week", "Month"].map(t => (
|
||||
<Button key={t} variant="outline" size="sm">{t}</Button>
|
||||
{(["all", "day", "week", "month"] as Period[]).map((p) => (
|
||||
<Button
|
||||
key={p}
|
||||
variant={period === p ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setPeriod(p)}
|
||||
>
|
||||
{p === "all" ? "All" : p.charAt(0).toUpperCase() + p.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Assignment</TableHead><TableHead>Exam</TableHead><TableHead>Date</TableHead>
|
||||
<TableHead>Score</TableHead><TableHead>Duration</TableHead><TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-medium">{r.assignment}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{r.exam}</TableCell>
|
||||
<TableCell>{r.date}</TableCell>
|
||||
<TableCell>{r.score ? r.score.toFixed(1) : "—"}</TableCell>
|
||||
<TableCell>{r.duration}</TableCell>
|
||||
<TableCell><Badge variant={r.status === "Completed" ? "default" : "secondary"}>{r.status}</Badge></TableCell>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-12 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Loading
|
||||
attempts...
|
||||
</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
No attempts match these filters.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Student</TableHead>
|
||||
<TableHead>Assignment</TableHead>
|
||||
<TableHead>Exam</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Score</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-medium">
|
||||
{r.student_name || "—"}
|
||||
</TableCell>
|
||||
<TableCell>{r.assignment || "—"}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{r.exam_code}
|
||||
</TableCell>
|
||||
<TableCell>{r.date || "—"}</TableCell>
|
||||
<TableCell>
|
||||
{r.score ? r.score.toFixed(1) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>{r.duration}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(r.status)}>
|
||||
{r.status_label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,14 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Search, Plus, Loader2, Pencil, Trash2, X, Sparkles, ChevronDown } from "lucide-react";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
||||
@@ -81,12 +89,70 @@ interface RubricFormState {
|
||||
levels: Set<string>;
|
||||
}
|
||||
|
||||
const emptyCriterion = (levels: Set<string>): CriterionEntry => ({
|
||||
name: "",
|
||||
weight: 0,
|
||||
const emptyCriterion = (levels: Set<string>, name = "", weight = 0): CriterionEntry => ({
|
||||
name,
|
||||
weight,
|
||||
descriptors: Object.fromEntries([...levels].map((l) => [l, ""])),
|
||||
});
|
||||
|
||||
/**
|
||||
* Canonical criterion catalog by skill. QA asked the "Add Criterion" button
|
||||
* to offer a dropdown of predefined names instead of a blank row.
|
||||
* Source: official IELTS public band descriptors for Writing & Speaking.
|
||||
* "Custom…" keeps the old "add empty row and type it yourself" behaviour.
|
||||
*/
|
||||
const CRITERION_PRESETS: Record<string, { label: string; items: { name: string; weight: number }[] }[]> = {
|
||||
writing: [
|
||||
{
|
||||
label: "IELTS Writing — Task 1",
|
||||
items: [
|
||||
{ name: "Task Achievement", weight: 25 },
|
||||
{ name: "Coherence and Cohesion", weight: 25 },
|
||||
{ name: "Lexical Resource", weight: 25 },
|
||||
{ name: "Grammatical Range and Accuracy", weight: 25 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "IELTS Writing — Task 2",
|
||||
items: [
|
||||
{ name: "Task Response", weight: 25 },
|
||||
{ name: "Coherence and Cohesion", weight: 25 },
|
||||
{ name: "Lexical Resource", weight: 25 },
|
||||
{ name: "Grammatical Range and Accuracy", weight: 25 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Generic academic writing",
|
||||
items: [
|
||||
{ name: "Content & Ideas", weight: 25 },
|
||||
{ name: "Organization", weight: 25 },
|
||||
{ name: "Language Use", weight: 25 },
|
||||
{ name: "Mechanics", weight: 25 },
|
||||
],
|
||||
},
|
||||
],
|
||||
speaking: [
|
||||
{
|
||||
label: "IELTS Speaking",
|
||||
items: [
|
||||
{ name: "Fluency and Coherence", weight: 25 },
|
||||
{ name: "Lexical Resource", weight: 25 },
|
||||
{ name: "Grammatical Range and Accuracy", weight: 25 },
|
||||
{ name: "Pronunciation", weight: 25 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Generic oral skills",
|
||||
items: [
|
||||
{ name: "Content", weight: 25 },
|
||||
{ name: "Delivery", weight: 25 },
|
||||
{ name: "Interaction", weight: 25 },
|
||||
{ name: "Language", weight: 25 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const emptyForm = (): RubricFormState => ({
|
||||
name: "",
|
||||
skill: "writing",
|
||||
@@ -165,7 +231,7 @@ export default function RubricsPage() {
|
||||
queryKey: ["rubrics"],
|
||||
queryFn: () => examsService.listRubrics({}),
|
||||
});
|
||||
const rubrics = (rubricsQ.data?.items ?? []) as RubricItem[];
|
||||
const rubrics = (rubricsQ.data?.items ?? []) as unknown as RubricItem[];
|
||||
|
||||
const rubricGroupsQ = useQuery({
|
||||
queryKey: ["rubric-groups"],
|
||||
@@ -437,16 +503,41 @@ export default function RubricsPage() {
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-2 text-muted-foreground rounded-lg border border-dashed">
|
||||
<Sparkles className="h-5 w-5 text-violet-400" />
|
||||
<p className="text-sm">No criteria yet.</p>
|
||||
<p className="text-xs">Click <strong>Generate with AI</strong> to create criteria automatically, or add manually.</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-1"
|
||||
onClick={() => setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" /> Add Manually
|
||||
</Button>
|
||||
<p className="text-xs">Click <strong>Generate with AI</strong> to create criteria automatically, or pick a preset below.</p>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm" className="mt-1">
|
||||
<Plus className="h-3 w-3 mr-1" /> Add Manually <ChevronDown className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-72">
|
||||
{(CRITERION_PRESETS[form.skill] ?? []).map((group) => (
|
||||
<div key={group.label}>
|
||||
<DropdownMenuLabel className="text-[11px] text-muted-foreground">{group.label}</DropdownMenuLabel>
|
||||
{group.items.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={`${group.label}-${item.name}`}
|
||||
onClick={() =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
criteria: [...f.criteria, emptyCriterion(f.levels, item.name, item.weight)],
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item.name}
|
||||
<span className="ml-auto text-[11px] text-muted-foreground">{item.weight}%</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
</div>
|
||||
))}
|
||||
<DropdownMenuItem
|
||||
onClick={() => setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))}
|
||||
>
|
||||
Custom (blank row)
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -507,15 +598,40 @@ export default function RubricsPage() {
|
||||
</div>
|
||||
</Collapsible>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" /> Add Criterion
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm" className="w-full">
|
||||
<Plus className="h-3 w-3 mr-1" /> Add Criterion <ChevronDown className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-72">
|
||||
{(CRITERION_PRESETS[form.skill] ?? []).map((group) => (
|
||||
<div key={group.label}>
|
||||
<DropdownMenuLabel className="text-[11px] text-muted-foreground">{group.label}</DropdownMenuLabel>
|
||||
{group.items.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={`${group.label}-${item.name}`}
|
||||
onClick={() =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
criteria: [...f.criteria, emptyCriterion(f.levels, item.name, item.weight)],
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item.name}
|
||||
<span className="ml-auto text-[11px] text-muted-foreground">{item.weight}%</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
</div>
|
||||
))}
|
||||
<DropdownMenuItem
|
||||
onClick={() => setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))}
|
||||
>
|
||||
Custom (blank row)
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,126 +1,266 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, PieChart, Pie, Cell } from "recharts";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import AiReportNarrative from "@/components/ai/AiReportNarrative";
|
||||
import { reportsService } from "@/services/reports.service";
|
||||
|
||||
const thresholds = ["0%", "50%", "70%", "90%"];
|
||||
|
||||
const barData = [
|
||||
{ module: "Reading", score: 72 },
|
||||
{ module: "Listening", score: 68 },
|
||||
{ module: "Writing", score: 61 },
|
||||
{ module: "Speaking", score: 65 },
|
||||
];
|
||||
|
||||
const trendData = [
|
||||
{ month: "Jan", avg: 58 }, { month: "Feb", avg: 62 }, { month: "Mar", avg: 65 },
|
||||
{ month: "Apr", avg: 64 }, { month: "May", avg: 69 }, { month: "Jun", avg: 72 },
|
||||
];
|
||||
|
||||
const distData = [
|
||||
{ name: "A1", value: 15, color: "hsl(0, 72%, 51%)" },
|
||||
{ name: "A2", value: 22, color: "hsl(38, 92%, 50%)" },
|
||||
{ name: "B1", value: 30, color: "hsl(199, 89%, 48%)" },
|
||||
{ name: "B2", value: 20, color: "hsl(243, 75%, 59%)" },
|
||||
{ name: "C1", value: 10, color: "hsl(142, 71%, 45%)" },
|
||||
{ name: "C2", value: 3, color: "hsl(280, 65%, 50%)" },
|
||||
];
|
||||
|
||||
export default function StatsCorporatePage() {
|
||||
const [threshold, setThreshold] = useState("0%");
|
||||
const [entityId, setEntityId] = useState("all");
|
||||
|
||||
const { data: filters } = useQuery({
|
||||
queryKey: ["reports-filters"],
|
||||
queryFn: () => reportsService.filters(),
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["stats-corporate", threshold, entityId],
|
||||
queryFn: () =>
|
||||
reportsService.statsCorporate({
|
||||
entity_id: entityId === "all" ? undefined : Number(entityId),
|
||||
threshold: Number(threshold.replace("%", "")),
|
||||
months: 6,
|
||||
}),
|
||||
});
|
||||
|
||||
const barData = data?.by_module ?? [];
|
||||
const trendData = data?.trend ?? [];
|
||||
const distData = data?.distribution ?? [];
|
||||
const comparison = data?.comparison ?? [];
|
||||
const attemptsConsidered = data?.meta.attempts_considered ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Corporate Statistics</h1>
|
||||
<p className="text-muted-foreground">Entity-level performance analytics and reports.</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
Corporate Statistics
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Entity-level performance analytics and reports.
|
||||
{data && (
|
||||
<span className="ml-2 text-xs">
|
||||
({attemptsConsidered} scored attempts)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<div className="flex gap-1">
|
||||
{thresholds.map(t => (
|
||||
<Button key={t} variant={threshold === t ? "default" : "outline"} size="sm" onClick={() => setThreshold(t)}>{t}</Button>
|
||||
{thresholds.map((t) => (
|
||||
<Button
|
||||
key={t}
|
||||
variant={threshold === t ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setThreshold(t)}
|
||||
>
|
||||
{t}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="All Entities" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="all">All</SelectItem><SelectItem value="acme">Acme Corp</SelectItem></SelectContent>
|
||||
</Select>
|
||||
<Select><SelectTrigger className="w-[180px]"><SelectValue placeholder="All Assignments" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="all">All Assignments</SelectItem></SelectContent>
|
||||
<Select value={entityId} onValueChange={setEntityId}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All Entities" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Entities</SelectItem>
|
||||
{filters?.entities.map((e) => (
|
||||
<SelectItem key={e.id} value={String(e.id)}>
|
||||
{e.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="trends">Trends</TabsTrigger>
|
||||
<TabsTrigger value="distribution">Distribution</TabsTrigger>
|
||||
<TabsTrigger value="comparison">Comparison</TabsTrigger>
|
||||
</TabsList>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-12 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Aggregating
|
||||
attempts...
|
||||
</div>
|
||||
) : attemptsConsidered === 0 ? (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
No scored attempts match this threshold / entity combination yet.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="trends">Trends</TabsTrigger>
|
||||
<TabsTrigger value="distribution">Distribution</TabsTrigger>
|
||||
<TabsTrigger value="comparison">Comparison</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-4">
|
||||
<AiReportNarrative report_type="corporate-overview" data={{ modules: barData }} />
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader><CardTitle className="text-base">Average Score by Module</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={barData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(220, 16%, 90%)" />
|
||||
<XAxis dataKey="module" />
|
||||
<YAxis domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="score" fill="hsl(243, 75%, 59%)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="overview" className="mt-4">
|
||||
<AiReportNarrative
|
||||
report_type="corporate-overview"
|
||||
data={{ modules: barData }}
|
||||
/>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Average Score by Module
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={barData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(220, 16%, 90%)"
|
||||
/>
|
||||
<XAxis dataKey="module" />
|
||||
<YAxis domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Bar
|
||||
dataKey="score"
|
||||
fill="hsl(243, 75%, 59%)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="trends" className="mt-4">
|
||||
<AiReportNarrative report_type="corporate-trends" data={{ trends: trendData }} />
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader><CardTitle className="text-base">Score Trend Over Time</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={trendData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(220, 16%, 90%)" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="avg" stroke="hsl(243, 75%, 59%)" strokeWidth={2} dot={{ r: 4 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="trends" className="mt-4">
|
||||
<AiReportNarrative
|
||||
report_type="corporate-trends"
|
||||
data={{ trends: trendData }}
|
||||
/>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Score Trend Over Time
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={trendData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(220, 16%, 90%)"
|
||||
/>
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="avg"
|
||||
stroke="hsl(243, 75%, 59%)"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="distribution" className="mt-4">
|
||||
<AiReportNarrative report_type="corporate-distribution" data={{ distribution: distData }} />
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader><CardTitle className="text-base">Level Distribution</CardTitle></CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie data={distData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={100} label>
|
||||
{distData.map((entry, i) => <Cell key={i} fill={entry.color} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="distribution" className="mt-4">
|
||||
<AiReportNarrative
|
||||
report_type="corporate-distribution"
|
||||
data={{ distribution: distData }}
|
||||
/>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Level Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={distData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={100}
|
||||
label
|
||||
>
|
||||
{distData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="comparison" className="mt-4">
|
||||
<AiReportNarrative report_type="corporate-comparison" data={{ threshold }} />
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-8 text-center text-muted-foreground">Entity comparison charts will appear here based on selected filters.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value="comparison" className="mt-4">
|
||||
<AiReportNarrative
|
||||
report_type="corporate-comparison"
|
||||
data={{ threshold, entities: comparison }}
|
||||
/>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Entity Comparison (average band × 10)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{comparison.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground p-8">
|
||||
No entities have attempts above this threshold.
|
||||
</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={comparison}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(220, 16%, 90%)"
|
||||
/>
|
||||
<XAxis dataKey="entity_name" />
|
||||
<YAxis domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Bar
|
||||
dataKey="avg"
|
||||
fill="hsl(142, 71%, 45%)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,77 +1,270 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, Download } from "lucide-react";
|
||||
import AiStudyCoach from "@/components/ai/AiStudyCoach";
|
||||
import AiGradeExplainer from "@/components/ai/AiGradeExplainer";
|
||||
import { reportsService } from "@/services/reports.service";
|
||||
|
||||
const students = [
|
||||
{ name: "Sarah Johnson", entity: "Acme Corp", reading: 7.5, listening: 8.0, writing: 7.0, speaking: 7.5, overall: 7.5, level: "B2" },
|
||||
{ name: "Ahmed Hassan", entity: "Global Ltd", reading: 5.5, listening: 6.0, writing: 5.0, speaking: 5.5, overall: 5.5, level: "A2" },
|
||||
{ name: "Maria Garcia", entity: "Acme Corp", reading: 8.5, listening: 8.0, writing: 8.0, speaking: 8.5, overall: 8.25, level: "C1" },
|
||||
{ name: "Li Wei", entity: "Tech Co", reading: 6.5, listening: 6.0, writing: 6.0, speaking: 6.5, overall: 6.25, level: "B1" },
|
||||
{ name: "Emma Brown", entity: "Acme Corp", reading: 4.5, listening: 5.0, writing: 4.0, speaking: 4.5, overall: 4.5, level: "A1" },
|
||||
{ name: "John Park", entity: "Global Ltd", reading: 7.0, listening: 7.5, writing: 6.5, speaking: 7.0, overall: 7.0, level: "B2" },
|
||||
];
|
||||
const LEVELS = ["all", "A1", "A2", "B1", "B2", "C1", "C2"] as const;
|
||||
|
||||
function ScoreBadge({ score }: { score: number }) {
|
||||
const color = score >= 7.5 ? "bg-success/10 text-success" : score >= 6.0 ? "bg-warning/10 text-warning" : "bg-destructive/10 text-destructive";
|
||||
return <span className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold ${color}`}>{score.toFixed(1)}</span>;
|
||||
function ScoreBadge({ score }: { score: number | null }) {
|
||||
if (score === null || score === undefined) {
|
||||
return <span className="text-muted-foreground">—</span>;
|
||||
}
|
||||
const color =
|
||||
score >= 7.5
|
||||
? "bg-success/10 text-success"
|
||||
: score >= 6.0
|
||||
? "bg-warning/10 text-warning"
|
||||
: "bg-destructive/10 text-destructive";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold ${color}`}
|
||||
>
|
||||
{score.toFixed(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StudentPerformancePage() {
|
||||
const [showUtilisation, setShowUtilisation] = useState(false);
|
||||
const [entityId, setEntityId] = useState<string>("all");
|
||||
const [level, setLevel] = useState<(typeof LEVELS)[number]>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { data: filters } = useQuery({
|
||||
queryKey: ["reports-filters"],
|
||||
queryFn: () => reportsService.filters(),
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["student-performance", entityId, level, search],
|
||||
queryFn: () =>
|
||||
reportsService.studentPerformance({
|
||||
entity_id: entityId === "all" ? undefined : Number(entityId),
|
||||
level: level === "all" ? undefined : level,
|
||||
search: search || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (!rows.length) return null;
|
||||
const overall = rows
|
||||
.map((r) => r.overall)
|
||||
.filter((v): v is number => v !== null);
|
||||
const avg = overall.length
|
||||
? overall.reduce((a, b) => a + b, 0) / overall.length
|
||||
: null;
|
||||
return {
|
||||
total: rows.length,
|
||||
avg: avg !== null ? avg.toFixed(1) : "—",
|
||||
topLevel: rows[0]?.level ?? "—",
|
||||
};
|
||||
}, [rows]);
|
||||
|
||||
const exportCsv = () => {
|
||||
const header = [
|
||||
"Student",
|
||||
"Entity",
|
||||
"Level",
|
||||
"Reading",
|
||||
"Listening",
|
||||
"Writing",
|
||||
"Speaking",
|
||||
"Overall",
|
||||
"Attempts",
|
||||
];
|
||||
const lines = rows.map((r) =>
|
||||
[
|
||||
r.student_name,
|
||||
r.entity_name,
|
||||
r.level ?? "",
|
||||
r.reading ?? "",
|
||||
r.listening ?? "",
|
||||
r.writing ?? "",
|
||||
r.speaking ?? "",
|
||||
r.overall ?? "",
|
||||
r.attempts_count,
|
||||
]
|
||||
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
|
||||
.join(","),
|
||||
);
|
||||
const blob = new Blob([[header.join(","), ...lines].join("\n")], {
|
||||
type: "text/csv",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `student-performance-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Student Performance</h1>
|
||||
<p className="text-muted-foreground">Track student scores across all IELTS modules.</p>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
Student Performance
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Track student scores across all IELTS modules.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={exportCsv} disabled={!rows.length}>
|
||||
<Download className="h-4 w-4 mr-1" /> Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Students tracked</p>
|
||||
<p className="text-2xl font-bold">{summary.total}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Average overall band</p>
|
||||
<p className="text-2xl font-bold">{summary.avg}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Top performer level</p>
|
||||
<p className="text-2xl font-bold">{summary.topLevel}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AiStudyCoach />
|
||||
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="All Entities" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="all">All Entities</SelectItem><SelectItem value="acme">Acme Corp</SelectItem><SelectItem value="global">Global Ltd</SelectItem></SelectContent>
|
||||
<Input
|
||||
placeholder="Search student..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-[220px]"
|
||||
/>
|
||||
<Select value={entityId} onValueChange={setEntityId}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All Entities" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Entities</SelectItem>
|
||||
{filters?.entities.map((e) => (
|
||||
<SelectItem key={e.id} value={String(e.id)}>
|
||||
{e.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={level}
|
||||
onValueChange={(v) => setLevel(v as (typeof LEVELS)[number])}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LEVELS.map((l) => (
|
||||
<SelectItem key={l} value={l}>
|
||||
{l === "all" ? "All Levels" : l}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Switch id="util" checked={showUtilisation} onCheckedChange={setShowUtilisation} />
|
||||
<Label htmlFor="util" className="text-sm">Show Utilisation</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Student</TableHead><TableHead>Entity</TableHead><TableHead>Level</TableHead>
|
||||
<TableHead className="text-center">Reading</TableHead><TableHead className="text-center">Listening</TableHead>
|
||||
<TableHead className="text-center">Writing</TableHead><TableHead className="text-center">Speaking</TableHead>
|
||||
<TableHead className="text-center">Overall</TableHead>
|
||||
<TableHead className="w-10">AI</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{students.map((s) => (
|
||||
<TableRow key={s.name}>
|
||||
<TableCell className="font-medium">{s.name}</TableCell>
|
||||
<TableCell>{s.entity}</TableCell>
|
||||
<TableCell><Badge variant="outline">{s.level}</Badge></TableCell>
|
||||
<TableCell className="text-center"><ScoreBadge score={s.reading} /></TableCell>
|
||||
<TableCell className="text-center"><ScoreBadge score={s.listening} /></TableCell>
|
||||
<TableCell className="text-center"><ScoreBadge score={s.writing} /></TableCell>
|
||||
<TableCell className="text-center"><ScoreBadge score={s.speaking} /></TableCell>
|
||||
<TableCell className="text-center"><ScoreBadge score={s.overall} /></TableCell>
|
||||
<TableCell><AiGradeExplainer studentName={s.name} /></TableCell>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-12 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Loading
|
||||
performance data...
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
No completed attempts match these filters yet.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Student</TableHead>
|
||||
<TableHead>Entity</TableHead>
|
||||
<TableHead>Level</TableHead>
|
||||
<TableHead className="text-center">Reading</TableHead>
|
||||
<TableHead className="text-center">Listening</TableHead>
|
||||
<TableHead className="text-center">Writing</TableHead>
|
||||
<TableHead className="text-center">Speaking</TableHead>
|
||||
<TableHead className="text-center">Overall</TableHead>
|
||||
<TableHead className="text-center">Attempts</TableHead>
|
||||
<TableHead className="w-10">AI</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((r) => (
|
||||
<TableRow key={r.student_id}>
|
||||
<TableCell className="font-medium">
|
||||
{r.student_name}
|
||||
</TableCell>
|
||||
<TableCell>{r.entity_name || "—"}</TableCell>
|
||||
<TableCell>
|
||||
{r.level ? (
|
||||
<Badge variant="outline">{r.level}</Badge>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<ScoreBadge score={r.reading} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<ScoreBadge score={r.listening} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<ScoreBadge score={r.writing} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<ScoreBadge score={r.speaking} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<ScoreBadge score={r.overall} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm text-muted-foreground">
|
||||
{r.attempts_count}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AiGradeExplainer studentName={r.student_name} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
842
src/pages/admin/AIAgentsPanel.tsx
Normal file
842
src/pages/admin/AIAgentsPanel.tsx
Normal file
@@ -0,0 +1,842 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Activity,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
PencilLine,
|
||||
PlayCircle,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Settings2,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { aiAgentService } from "@/services/aiAgent.service";
|
||||
import type {
|
||||
AIAgent,
|
||||
AIAgentSummary,
|
||||
AIAgentTestResponse,
|
||||
AIAgentUpdateInput,
|
||||
AIToolSummary,
|
||||
} from "@/types/aiAgent";
|
||||
|
||||
const MODEL_OPTIONS = [
|
||||
{ value: "gpt-4o", label: "GPT-4o (quality)" },
|
||||
{ value: "gpt-4o-mini", label: "GPT-4o mini (cheap / fast)" },
|
||||
{ value: "gpt-4.1", label: "GPT-4.1" },
|
||||
{ value: "gpt-4.1-mini", label: "GPT-4.1 mini" },
|
||||
{ value: "gpt-3.5-turbo", label: "GPT-3.5 turbo (legacy)" },
|
||||
];
|
||||
|
||||
const GRAPH_OPTIONS = [
|
||||
{ value: "simple", labelKey: "agents.graph.simple" },
|
||||
{ value: "plan_review_revise", labelKey: "agents.graph.planReviewRevise" },
|
||||
{ value: "rag", labelKey: "agents.graph.rag" },
|
||||
{ value: "react", labelKey: "agents.graph.react" },
|
||||
];
|
||||
|
||||
function GraphTypeBadge({ value }: { value: string }) {
|
||||
const { t } = useTranslation();
|
||||
const text = t(`agents.graph.${value === "plan_review_revise" ? "planReviewRevise" : value}`, value);
|
||||
const tone =
|
||||
value === "react"
|
||||
? "bg-purple-500"
|
||||
: value === "rag"
|
||||
? "bg-blue-500"
|
||||
: value === "plan_review_revise"
|
||||
? "bg-emerald-500"
|
||||
: "bg-slate-500";
|
||||
return <Badge className={`${tone} text-white hover:${tone}`}>{text}</Badge>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agent list (left rail)
|
||||
// ============================================================================
|
||||
function AgentsList({
|
||||
agents,
|
||||
selectedId,
|
||||
onSelect,
|
||||
isLoading,
|
||||
search,
|
||||
onSearch,
|
||||
}: {
|
||||
agents: AIAgentSummary[];
|
||||
selectedId: number | null;
|
||||
onSelect: (id: number) => void;
|
||||
isLoading: boolean;
|
||||
search: string;
|
||||
onSearch: (v: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Bot className="h-4 w-4" />
|
||||
{t("agents.list.title", "Agents")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"agents.list.subtitle",
|
||||
"Pre-configured for every platform pillar — edit defaults below.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute start-2 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
placeholder={t("agents.list.search", "Search agents…")}
|
||||
className="ps-8"
|
||||
/>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : agents.length === 0 ? (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
{t("agents.list.empty", "No agents match your search.")}
|
||||
</p>
|
||||
) : (
|
||||
<ScrollArea className="h-[480px] pe-2">
|
||||
<ul className="space-y-1">
|
||||
{agents.map((a) => {
|
||||
const active = selectedId === a.id;
|
||||
return (
|
||||
<li key={a.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(a.id)}
|
||||
className={`w-full rounded-md border p-3 text-start transition-colors ${
|
||||
active
|
||||
? "border-primary bg-primary/5"
|
||||
: "hover:bg-muted/40 border-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{a.name}</div>
|
||||
<div className="text-muted-foreground truncate font-mono text-xs">
|
||||
{a.key}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="text-muted-foreground h-4 w-4 flex-shrink-0" />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<GraphTypeBadge value={a.graph_type} />
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{a.model}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Wrench className="me-1 h-3 w-3" />
|
||||
{a.tool_count}
|
||||
</Badge>
|
||||
{!a.active ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("common.disabled", "Disabled")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test panel (run a small input through the agent)
|
||||
// ============================================================================
|
||||
function AgentTestRunner({ agent }: { agent: AIAgent }) {
|
||||
const { t } = useTranslation();
|
||||
const [variables, setVariables] = useState<string>("{}");
|
||||
const [payload, setPayload] = useState<string>("");
|
||||
const [result, setResult] = useState<AIAgentTestResponse | null>(null);
|
||||
|
||||
const test = useMutation({
|
||||
mutationFn: async () => {
|
||||
let parsedVars: Record<string, unknown> = {};
|
||||
try {
|
||||
parsedVars = variables.trim() ? JSON.parse(variables) : {};
|
||||
} catch {
|
||||
throw new Error(t("agents.test.badVarsJson", "Variables must be valid JSON."));
|
||||
}
|
||||
let parsedPayload: unknown = payload;
|
||||
const trimmed = payload.trim();
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
parsedPayload = JSON.parse(trimmed);
|
||||
} catch {
|
||||
parsedPayload = payload;
|
||||
}
|
||||
}
|
||||
return aiAgentService.test(agent.id, {
|
||||
variables: parsedVars,
|
||||
payload: parsedPayload,
|
||||
});
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
setResult(res);
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
} else {
|
||||
toast.success(t("agents.test.ok", "Agent ran successfully"));
|
||||
}
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<PlayCircle className="h-4 w-4" />
|
||||
{t("agents.test.title", "Test runner")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"agents.test.subtitle",
|
||||
"Send a small payload and inspect the agent's output, tool calls, and quality issues.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">
|
||||
{t("agents.test.variables", "Variables (JSON)")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={variables}
|
||||
onChange={(e) => setVariables(e.target.value)}
|
||||
placeholder='{"cefr_level": "b1"}'
|
||||
className="min-h-[120px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">
|
||||
{t("agents.test.payload", "Payload (text or JSON)")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={payload}
|
||||
onChange={(e) => setPayload(e.target.value)}
|
||||
placeholder={t(
|
||||
"agents.test.payloadPlaceholder",
|
||||
"What should the agent do? e.g. 'Generate 5 MCQ for B1 reading.'",
|
||||
)}
|
||||
className="min-h-[120px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => test.mutate()} disabled={test.isPending}>
|
||||
<PlayCircle className="me-1 h-4 w-4" />
|
||||
{test.isPending
|
||||
? t("agents.test.running", "Running…")
|
||||
: t("agents.test.run", "Run agent")}
|
||||
</Button>
|
||||
{result ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<Badge variant="outline">
|
||||
<Activity className="me-1 h-3 w-3" />
|
||||
{t("agents.test.iterations", "Iterations")}: {result.iterations}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
<RotateCcw className="me-1 h-3 w-3" />
|
||||
{t("agents.test.revisions", "Revisions")}: {result.revisions_used}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{t("agents.test.toolCalls", "Tool calls")}: {result.tool_results.length}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{t("agents.test.retrievalHits", "Retrieval hits")}: {result.retrieval_hits}
|
||||
</Badge>
|
||||
{result.quality_issues.length ? (
|
||||
<Badge className="bg-amber-500 text-white hover:bg-amber-500">
|
||||
{t("agents.test.qualityIssues", "Quality issues")}: {result.quality_issues.length}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs uppercase tracking-wide">
|
||||
{t("agents.test.output", "Output")}
|
||||
</Label>
|
||||
<pre className="bg-muted/50 max-h-[300px] overflow-auto rounded-md p-3 text-xs">
|
||||
{typeof result.output === "string"
|
||||
? result.output
|
||||
: JSON.stringify(result.output, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
{result.tool_results.length ? (
|
||||
<details className="rounded-md border p-2">
|
||||
<summary className="cursor-pointer text-xs font-medium">
|
||||
{t("agents.test.toolTrace", "Tool trace")}
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-[260px] overflow-auto text-xs">
|
||||
{JSON.stringify(result.tool_results, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Configure dialog
|
||||
// ============================================================================
|
||||
function ConfigureAgentDialog({
|
||||
agent,
|
||||
tools,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
agent: AIAgent;
|
||||
tools: AIToolSummary[];
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [draft, setDraft] = useState<AIAgentUpdateInput>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDraft({
|
||||
name: agent.name,
|
||||
description: agent.description,
|
||||
system_prompt: agent.system_prompt,
|
||||
prompt_key: agent.prompt_key,
|
||||
model: agent.model,
|
||||
fallback_model: agent.fallback_model,
|
||||
temperature: agent.temperature,
|
||||
max_tokens: agent.max_tokens,
|
||||
max_revisions: agent.max_revisions,
|
||||
response_format: agent.response_format,
|
||||
graph_type: agent.graph_type,
|
||||
quality_checks: (agent.quality_checks || []).join(","),
|
||||
tool_keys: agent.tool_keys,
|
||||
active: agent.active,
|
||||
});
|
||||
}
|
||||
}, [open, agent]);
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: (input: AIAgentUpdateInput) => aiAgentService.update(agent.id, input),
|
||||
onSuccess: () => {
|
||||
toast.success(t("agents.config.saved", "Agent configuration saved"));
|
||||
qc.invalidateQueries({ queryKey: ["ai-agents"] });
|
||||
qc.invalidateQueries({ queryKey: ["ai-agent", agent.id] });
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const toggleTool = (key: string) => {
|
||||
const current = new Set(draft.tool_keys ?? []);
|
||||
if (current.has(key)) current.delete(key);
|
||||
else current.add(key);
|
||||
setDraft({ ...draft, tool_keys: Array.from(current) });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("agents.config.title", "Configure")} — {agent.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="font-mono text-xs">
|
||||
{agent.key}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Identity */}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t("agents.config.name", "Display name")}</Label>
|
||||
<Input
|
||||
value={draft.name ?? ""}
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">
|
||||
{t("agents.config.promptKey", "Prompt key (versioned)")}
|
||||
</Label>
|
||||
<Input
|
||||
value={draft.prompt_key ?? ""}
|
||||
placeholder="e.g. course_planner.system"
|
||||
onChange={(e) => setDraft({ ...draft, prompt_key: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t("agents.config.description", "Description")}</Label>
|
||||
<Textarea
|
||||
value={draft.description ?? ""}
|
||||
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
|
||||
className="min-h-[60px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t("agents.config.systemPrompt", "System prompt")}</Label>
|
||||
<Textarea
|
||||
value={draft.system_prompt ?? ""}
|
||||
onChange={(e) => setDraft({ ...draft, system_prompt: e.target.value })}
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(
|
||||
"agents.config.systemPromptHint",
|
||||
"If a prompt key is set above, the active version of that prompt overrides this field at runtime.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Runtime config */}
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t("agents.config.model", "Model")}</Label>
|
||||
<Select
|
||||
value={draft.model ?? agent.model}
|
||||
onValueChange={(v) => setDraft({ ...draft, model: v })}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{MODEL_OPTIONS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">
|
||||
{t("agents.config.fallbackModel", "Fallback model")}
|
||||
</Label>
|
||||
<Select
|
||||
value={draft.fallback_model ?? agent.fallback_model}
|
||||
onValueChange={(v) => setDraft({ ...draft, fallback_model: v })}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{MODEL_OPTIONS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t("agents.config.responseFormat", "Output format")}</Label>
|
||||
<Select
|
||||
value={draft.response_format ?? agent.response_format}
|
||||
onValueChange={(v) =>
|
||||
setDraft({ ...draft, response_format: v as "text" | "json" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="json">JSON</SelectItem>
|
||||
<SelectItem value="text">{t("agents.config.text", "Text")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">
|
||||
{t("agents.config.temperature", "Temperature")}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min={0}
|
||||
max={2}
|
||||
value={draft.temperature ?? agent.temperature}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, temperature: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">
|
||||
{t("agents.config.maxTokens", "Max tokens")}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={64}
|
||||
max={32000}
|
||||
value={draft.max_tokens ?? agent.max_tokens}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, max_tokens: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">
|
||||
{t("agents.config.maxRevisions", "Max revisions")}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={5}
|
||||
value={draft.max_revisions ?? agent.max_revisions}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, max_revisions: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Graph */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">
|
||||
{t("agents.config.graphType", "Graph topology")}
|
||||
</Label>
|
||||
<Select
|
||||
value={draft.graph_type ?? agent.graph_type}
|
||||
onValueChange={(v) =>
|
||||
setDraft({ ...draft, graph_type: v as AIAgent["graph_type"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{GRAPH_OPTIONS.map((g) => (
|
||||
<SelectItem key={g.value} value={g.value}>
|
||||
{t(g.labelKey, g.value)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">
|
||||
{t("agents.config.qualityChecks", "Quality checks (comma-separated tool keys)")}
|
||||
</Label>
|
||||
<Input
|
||||
value={draft.quality_checks ?? ""}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, quality_checks: e.target.value })
|
||||
}
|
||||
placeholder="quality.cefr_check,quality.ai_detect"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tools */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">{t("agents.config.tools", "Enabled tools")}</Label>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{tools.map((tool) => {
|
||||
const enabled = (draft.tool_keys ?? []).includes(tool.key);
|
||||
return (
|
||||
<label
|
||||
key={tool.id}
|
||||
className={`flex items-start gap-2 rounded-md border p-2 text-xs transition-colors ${
|
||||
enabled ? "border-primary bg-primary/5" : ""
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={enabled}
|
||||
onCheckedChange={() => toggleTool(tool.key)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="font-mono text-xs">{tool.key}</span>
|
||||
<Badge variant="outline" className="text-[10px] uppercase">
|
||||
{tool.category}
|
||||
</Badge>
|
||||
{tool.mutates ? (
|
||||
<Badge className="bg-amber-500 text-white hover:bg-amber-500 text-[10px]">
|
||||
{t("agents.config.mutates", "writes")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{tool.description}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={draft.active ?? agent.active}
|
||||
onCheckedChange={(v) => setDraft({ ...draft, active: v })}
|
||||
/>
|
||||
<Label className="text-xs">
|
||||
{t("agents.config.active", "Agent is active")}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => update.mutate(draft)} disabled={update.isPending}>
|
||||
<CheckCircle2 className="me-1 h-4 w-4" />
|
||||
{update.isPending
|
||||
? t("common.saving", "Saving…")
|
||||
: t("common.save", "Save changes")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Detail panel
|
||||
// ============================================================================
|
||||
function AgentDetail({ agent, tools }: { agent: AIAgent; tools: AIToolSummary[] }) {
|
||||
const { t } = useTranslation();
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
return (
|
||||
<div className="space-y-4 lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-xl">{agent.name}</CardTitle>
|
||||
<CardDescription className="font-mono text-xs">
|
||||
{agent.key}
|
||||
</CardDescription>
|
||||
{agent.description ? (
|
||||
<p className="mt-2 text-sm">{agent.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setConfigOpen(true)}>
|
||||
<Settings2 className="me-1 h-4 w-4" />
|
||||
{t("agents.detail.configure", "Configure")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm md:grid-cols-4">
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t("agents.detail.graph", "Graph")}
|
||||
</div>
|
||||
<div className="mt-1"><GraphTypeBadge value={agent.graph_type} /></div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t("agents.detail.model", "Model")}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-sm">{agent.model}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t("agents.detail.temperature", "Temperature")}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-sm">{agent.temperature.toFixed(2)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t("agents.detail.tokens", "Max tokens")}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-sm">{agent.max_tokens}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t("agents.detail.format", "Output format")}
|
||||
</div>
|
||||
<div className="mt-1 text-sm">
|
||||
{agent.response_format === "json" ? "JSON" : t("agents.config.text", "Text")}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t("agents.detail.fallback", "Fallback")}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-sm">
|
||||
{agent.fallback_model || "—"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t("agents.detail.revisions", "Max revisions")}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-sm">{agent.max_revisions}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t("agents.detail.promptKey", "Prompt key")}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-xs">
|
||||
{agent.prompt_key || "—"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Wrench className="h-4 w-4" />
|
||||
{t("agents.detail.toolsTitle", "Enabled tools")} ({agent.tools.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{agent.tools.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("agents.detail.noTools", "This agent has no tools enabled.")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{agent.tools.map((tool) => (
|
||||
<li key={tool.id} className="rounded-md border p-2 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-xs">{tool.key}</span>
|
||||
<Badge variant="outline" className="text-[10px] uppercase">
|
||||
{tool.category}
|
||||
</Badge>
|
||||
{tool.mutates ? (
|
||||
<Badge className="bg-amber-500 text-white hover:bg-amber-500 text-[10px]">
|
||||
{t("agents.config.mutates", "writes")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">{tool.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<PencilLine className="h-4 w-4" />
|
||||
{t("agents.detail.systemPrompt", "System prompt")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted/50 max-h-[260px] overflow-auto rounded-md p-3 text-xs">
|
||||
{agent.system_prompt || t("agents.detail.noPrompt", "(empty)")}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AgentTestRunner agent={agent} />
|
||||
|
||||
<ConfigureAgentDialog
|
||||
agent={agent}
|
||||
tools={tools}
|
||||
open={configOpen}
|
||||
onOpenChange={setConfigOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Top-level Agents tab
|
||||
// ============================================================================
|
||||
export function AIAgentsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
|
||||
const agentsQ = useQuery({
|
||||
queryKey: ["ai-agents", { search }],
|
||||
queryFn: () => aiAgentService.list(search ? { search } : undefined),
|
||||
});
|
||||
|
||||
const toolsQ = useQuery({
|
||||
queryKey: ["ai-agent-tools"],
|
||||
queryFn: () => aiAgentService.listTools(),
|
||||
});
|
||||
|
||||
// Auto-select first agent.
|
||||
useEffect(() => {
|
||||
if (!selectedId && agentsQ.data && agentsQ.data.length > 0) {
|
||||
setSelectedId(agentsQ.data[0].id);
|
||||
}
|
||||
}, [selectedId, agentsQ.data]);
|
||||
|
||||
const detailQ = useQuery({
|
||||
queryKey: ["ai-agent", selectedId],
|
||||
queryFn: () => aiAgentService.get(selectedId as number),
|
||||
enabled: !!selectedId,
|
||||
});
|
||||
|
||||
const filteredAgents = useMemo(() => agentsQ.data ?? [], [agentsQ.data]);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<AgentsList
|
||||
agents={filteredAgents}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
isLoading={agentsQ.isLoading}
|
||||
search={search}
|
||||
onSearch={setSearch}
|
||||
/>
|
||||
{detailQ.data ? (
|
||||
<AgentDetail agent={detailQ.data} tools={toolsQ.data ?? []} />
|
||||
) : selectedId ? (
|
||||
<Card className="lg:col-span-2">
|
||||
<CardContent className="p-6">
|
||||
<Skeleton className="h-72 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="lg:col-span-2">
|
||||
<CardContent className="text-muted-foreground p-6 text-sm">
|
||||
{t("agents.detail.empty", "Select an agent to inspect its configuration.")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
348
src/pages/admin/AIFeedbackTriage.tsx
Normal file
348
src/pages/admin/AIFeedbackTriage.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { CheckCircle2, Filter, MessageSquare, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useAIFeedbackList,
|
||||
useResolveAIFeedback,
|
||||
} from "@/hooks/queries/useAIFeedback";
|
||||
import type {
|
||||
AIFeedback,
|
||||
AIFeedbackStatus,
|
||||
AIFeedbackSubjectType,
|
||||
} from "@/types/ai-feedback";
|
||||
|
||||
const STATUS_COLORS: Record<AIFeedbackStatus, string> = {
|
||||
open: "bg-amber-100 text-amber-900 border-amber-300",
|
||||
acknowledged: "bg-sky-100 text-sky-900 border-sky-300",
|
||||
fixed: "bg-emerald-100 text-emerald-900 border-emerald-300",
|
||||
dismissed: "bg-slate-100 text-slate-700 border-slate-300",
|
||||
};
|
||||
|
||||
type ResolveChoice = Exclude<AIFeedbackStatus, "open">;
|
||||
|
||||
function ResolveDialog({
|
||||
feedback,
|
||||
onClose,
|
||||
}: {
|
||||
feedback: AIFeedback | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const resolve = useResolveAIFeedback();
|
||||
const [status, setStatus] = useState<ResolveChoice>("acknowledged");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const open = !!feedback;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => (!v ? onClose() : null)}>
|
||||
<DialogContent
|
||||
className="max-w-lg"
|
||||
description="Triage this feedback by setting its status and leaving a short note for the audit trail."
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Resolve feedback</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs uppercase">
|
||||
Status
|
||||
</div>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(v) => setStatus(v as ResolveChoice)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="acknowledged">Acknowledged</SelectItem>
|
||||
<SelectItem value="fixed">Fixed</SelectItem>
|
||||
<SelectItem value="dismissed">Dismissed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs uppercase">
|
||||
Notes
|
||||
</div>
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="What did you do? (optional)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!feedback) return;
|
||||
try {
|
||||
await resolve.mutateAsync({
|
||||
id: feedback.id,
|
||||
status,
|
||||
notes,
|
||||
});
|
||||
toast.success(`Marked ${status}`);
|
||||
setNotes("");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Resolve failed",
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={resolve.isPending}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-4 w-4" />
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AIFeedbackTriage() {
|
||||
const [statusFilter, setStatusFilter] = useState<AIFeedbackStatus | "all">("open");
|
||||
const [ratingFilter, setRatingFilter] = useState<"up" | "down" | "all">("down");
|
||||
const [subjectFilter, setSubjectFilter] = useState<
|
||||
AIFeedbackSubjectType | "all"
|
||||
>("all");
|
||||
const [selected, setSelected] = useState<AIFeedback | null>(null);
|
||||
|
||||
const params = useMemo(
|
||||
() => ({
|
||||
status: statusFilter === "all" ? undefined : statusFilter,
|
||||
rating: ratingFilter === "all" ? undefined : ratingFilter,
|
||||
subject_type: subjectFilter === "all" ? undefined : subjectFilter,
|
||||
page: 0,
|
||||
size: 50,
|
||||
}),
|
||||
[statusFilter, ratingFilter, subjectFilter],
|
||||
);
|
||||
const { data, isLoading } = useAIFeedbackList(params);
|
||||
const items: AIFeedback[] = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
<MessageSquare className="mr-1 inline h-5 w-5" />
|
||||
AI feedback triage
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Student thumbs up/down on AI output — use this to catch broken
|
||||
prompts, bad questions, and low-quality coach replies.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Filter className="mr-1 inline h-4 w-4" />
|
||||
Filters
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Default view surfaces open thumbs-down reports, which are usually
|
||||
the most actionable.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs uppercase">
|
||||
Status
|
||||
</div>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(v) =>
|
||||
setStatusFilter(v as AIFeedbackStatus | "all")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="open">Open</SelectItem>
|
||||
<SelectItem value="acknowledged">Acknowledged</SelectItem>
|
||||
<SelectItem value="fixed">Fixed</SelectItem>
|
||||
<SelectItem value="dismissed">Dismissed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs uppercase">
|
||||
Rating
|
||||
</div>
|
||||
<Select
|
||||
value={ratingFilter}
|
||||
onValueChange={(v) => setRatingFilter(v as "up" | "down" | "all")}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="down">Thumbs down</SelectItem>
|
||||
<SelectItem value="up">Thumbs up</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs uppercase">
|
||||
Type
|
||||
</div>
|
||||
<Select
|
||||
value={subjectFilter}
|
||||
onValueChange={(v) =>
|
||||
setSubjectFilter(v as AIFeedbackSubjectType | "all")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="question">Exam question</SelectItem>
|
||||
<SelectItem value="coach">Coach reply</SelectItem>
|
||||
<SelectItem value="explanation">Explanation</SelectItem>
|
||||
<SelectItem value="translation">Translation</SelectItem>
|
||||
<SelectItem value="narrative">Report narrative</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Feedback ({data?.total ?? 0})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No feedback matches the current filters.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>When</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Subject</TableHead>
|
||||
<TableHead>Rating</TableHead>
|
||||
<TableHead>Prompt</TableHead>
|
||||
<TableHead>Comment</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((f) => (
|
||||
<TableRow key={f.id}>
|
||||
<TableCell className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{f.create_date
|
||||
? new Date(f.create_date).toLocaleString()
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{f.user_name}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{f.subject_key}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{f.rating === "up" ? (
|
||||
<Badge className="bg-emerald-500 text-white hover:bg-emerald-500">
|
||||
<ThumbsUp className="mr-1 h-3 w-3" />
|
||||
Up
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-rose-500 text-white hover:bg-rose-500">
|
||||
<ThumbsDown className="mr-1 h-3 w-3" />
|
||||
Down
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{f.prompt_key
|
||||
? `${f.prompt_key} v${f.prompt_version ?? "?"}`
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md text-sm">
|
||||
{f.comment || <span className="text-muted-foreground">—</span>}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={STATUS_COLORS[f.status]}
|
||||
>
|
||||
{f.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{f.status === "open" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSelected(f)}
|
||||
>
|
||||
Resolve
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{f.resolution_notes || "—"}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ResolveDialog feedback={selected} onClose={() => setSelected(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
611
src/pages/admin/AIPromptEditor.tsx
Normal file
611
src/pages/admin/AIPromptEditor.tsx
Normal file
@@ -0,0 +1,611 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useAIPrompt,
|
||||
useAIPromptKeys,
|
||||
useAIPromptVersions,
|
||||
useActivateAIPrompt,
|
||||
useCreateAIPrompt,
|
||||
useRenderAIPrompt,
|
||||
} from "@/hooks/queries/useAIPrompts";
|
||||
import { AIAgentsPanel } from "@/pages/admin/AIAgentsPanel";
|
||||
import AIProviderSettings from "@/pages/admin/AIProviderSettings";
|
||||
import { AIToolsPanel } from "@/pages/admin/AIToolsPanel";
|
||||
import type { AIPromptSummary } from "@/types/ai-prompt";
|
||||
import {
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
History,
|
||||
KeyRound,
|
||||
Play,
|
||||
PlusCircle,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function SelectedKeyPanel({
|
||||
selectedKey,
|
||||
onSelectVersion,
|
||||
activePromptId,
|
||||
}: {
|
||||
selectedKey: string;
|
||||
onSelectVersion: (id: number) => void;
|
||||
activePromptId: number | null;
|
||||
}) {
|
||||
const { data, isLoading } = useAIPromptVersions(selectedKey);
|
||||
const activate = useActivateAIPrompt();
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-32 w-full" />;
|
||||
}
|
||||
const versions = data ?? [];
|
||||
if (!versions.length) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No versions yet for this prompt.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Version</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Author</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{versions.map((v) => (
|
||||
<TableRow
|
||||
key={v.id}
|
||||
className={activePromptId === v.id ? "bg-muted/50" : undefined}
|
||||
>
|
||||
<TableCell className="font-mono">v{v.version}</TableCell>
|
||||
<TableCell>{v.title}</TableCell>
|
||||
<TableCell>{v.author_name || "—"}</TableCell>
|
||||
<TableCell>
|
||||
{v.is_active ? (
|
||||
<Badge className="bg-emerald-500 text-white hover:bg-emerald-500">
|
||||
Active
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Archived</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="space-x-2 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onSelectVersion(v.id)}
|
||||
>
|
||||
<FileText className="mr-1 h-3 w-3" />
|
||||
Open
|
||||
</Button>
|
||||
{!v.is_active ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await activate.mutateAsync(v.id);
|
||||
toast.success(`Activated v${v.version}`);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Activate failed",
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={activate.isPending}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Activate
|
||||
</Button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptDetail({ promptId }: { promptId: number }) {
|
||||
const { data, isLoading } = useAIPrompt(promptId);
|
||||
const render = useRenderAIPrompt();
|
||||
|
||||
// Variables detected from the prompt body — keep local state keyed by name
|
||||
// so the editor can show one input per placeholder.
|
||||
const [sampleValues, setSampleValues] = useState<Record<string, string>>({});
|
||||
const [rendered, setRendered] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
setRendered("");
|
||||
setSampleValues({});
|
||||
}, [promptId]);
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <Skeleton className="h-40 w-full" />;
|
||||
}
|
||||
|
||||
const variables = data.variables ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{data.title}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({data.key} v{data.version})
|
||||
</span>
|
||||
</h3>
|
||||
{data.description ? (
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{data.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs uppercase tracking-wide">Template</Label>
|
||||
<ScrollArea className="bg-muted/30 mt-1 max-h-64 rounded border">
|
||||
<pre className="p-3 font-mono text-sm whitespace-pre-wrap">
|
||||
{data.content}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{variables.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs uppercase tracking-wide">
|
||||
Sample variables
|
||||
</Label>
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
{variables.map((v) => (
|
||||
<div key={v} className="space-y-1">
|
||||
<Label className="text-xs">{v}</Label>
|
||||
<Input
|
||||
value={sampleValues[v] ?? ""}
|
||||
onChange={(e) =>
|
||||
setSampleValues((prev) => ({
|
||||
...prev,
|
||||
[v]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={`Sample value for {${v}}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const r = await render.mutateAsync({
|
||||
id: data.id,
|
||||
variables: sampleValues,
|
||||
});
|
||||
setRendered(r.rendered);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Render failed",
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={render.isPending}
|
||||
>
|
||||
<Play className="mr-1 h-3 w-3" />
|
||||
Render preview
|
||||
</Button>
|
||||
{rendered ? (
|
||||
<ScrollArea className="bg-background mt-2 max-h-48 rounded border">
|
||||
<pre className="p-3 font-mono text-sm whitespace-pre-wrap">
|
||||
{rendered}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewVersionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultKey,
|
||||
defaultContent,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
defaultKey: string;
|
||||
defaultContent: string;
|
||||
}) {
|
||||
const create = useCreateAIPrompt();
|
||||
const [key, setKey] = useState(defaultKey);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [content, setContent] = useState(defaultContent);
|
||||
const [activate, setActivate] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setKey(defaultKey);
|
||||
setContent(defaultContent);
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setActivate(true);
|
||||
}
|
||||
}, [open, defaultKey, defaultContent]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-2xl"
|
||||
description="Create a new prompt version; the previous active version will be archived."
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New prompt version</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Key</Label>
|
||||
<Input
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value.trim())}
|
||||
placeholder="exam.mcq.generate"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Lowercase dotted identifier. Reuse an existing key to bump its
|
||||
version.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="MCQ generation — CEFR B2 — v6"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description (optional)</Label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="What changed? What should editors know?"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Template</Label>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
rows={10}
|
||||
className="font-mono text-sm"
|
||||
placeholder="You are an exam author. Generate {count} MCQs for {topic}…"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activate}
|
||||
onChange={(e) => setActivate(e.target.checked)}
|
||||
/>
|
||||
Activate this version immediately
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!key || !title || !content.trim()) {
|
||||
toast.error("Key, title, and content are required");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await create.mutateAsync({
|
||||
key,
|
||||
title,
|
||||
description,
|
||||
content,
|
||||
activate,
|
||||
});
|
||||
toast.success("Prompt version created");
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Create failed",
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={create.isPending}
|
||||
>
|
||||
Save version
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The original prompt-library UI, now scoped as the "Prompts" tab inside
|
||||
* the larger AI Agents & Tools configurator. Behaviour unchanged — only
|
||||
* the surrounding chrome (header + new-version button) is now contextual.
|
||||
*/
|
||||
function AIPromptsPanel() {
|
||||
const [search, setSearch] = useState("");
|
||||
const { data, isLoading } = useAIPromptKeys({ search, page: 1, size: 50 });
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [selectedPromptId, setSelectedPromptId] = useState<number | null>(null);
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
|
||||
const items: AIPromptSummary[] = data?.items ?? [];
|
||||
const activePromptId = useMemo(() => {
|
||||
if (!selectedKey) return null;
|
||||
const match = items.find((it) => it.key === selectedKey);
|
||||
return match?.id ?? null;
|
||||
}, [items, selectedKey]);
|
||||
|
||||
// Autoselect first key on load.
|
||||
useEffect(() => {
|
||||
if (!selectedKey && items.length) {
|
||||
setSelectedKey(items[0].key);
|
||||
setSelectedPromptId(items[0].id);
|
||||
}
|
||||
}, [items, selectedKey]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-end">
|
||||
<Button onClick={() => setNewOpen(true)}>
|
||||
<PlusCircle className="mr-1 h-4 w-4" />
|
||||
New version
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Prompt keys</CardTitle>
|
||||
<CardDescription>
|
||||
Each row shows the latest version of a prompt key. Click a row to
|
||||
inspect all versions.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Input
|
||||
placeholder="Search keys (e.g. exam.mcq)"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-32 w-full" />
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No prompts yet. Click <strong>New version</strong> to add one.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Key</TableHead>
|
||||
<TableHead>Latest</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Variables</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((p) => (
|
||||
<TableRow
|
||||
key={p.id}
|
||||
className={
|
||||
selectedKey === p.key
|
||||
? "bg-muted/40 cursor-pointer"
|
||||
: "cursor-pointer"
|
||||
}
|
||||
onClick={() => {
|
||||
setSelectedKey(p.key);
|
||||
setSelectedPromptId(p.id);
|
||||
}}
|
||||
>
|
||||
<TableCell className="font-mono text-xs">{p.key}</TableCell>
|
||||
<TableCell>
|
||||
v{p.version}{" "}
|
||||
{p.total_versions && p.total_versions > 1 ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
(of {p.total_versions})
|
||||
</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>{p.title}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{p.variables.slice(0, 4).map((v) => (
|
||||
<Badge key={v} variant="outline" className="text-xs">
|
||||
{v}
|
||||
</Badge>
|
||||
))}
|
||||
{p.variables.length > 4 ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{p.variables.length - 4}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{p.is_active ? (
|
||||
<Badge className="bg-emerald-500 text-white hover:bg-emerald-500">
|
||||
Active
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">No active</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedKey ? (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<History className="mr-1 inline h-4 w-4" />
|
||||
Versions of {selectedKey}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SelectedKeyPanel
|
||||
selectedKey={selectedKey}
|
||||
onSelectVersion={setSelectedPromptId}
|
||||
activePromptId={selectedPromptId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preview</CardTitle>
|
||||
<CardDescription>
|
||||
Inspect template body and try a dry-run render.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedPromptId ? (
|
||||
<PromptDetail promptId={selectedPromptId} />
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Select a version from the list.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<NewVersionDialog
|
||||
open={newOpen}
|
||||
onOpenChange={setNewOpen}
|
||||
defaultKey={selectedKey ?? ""}
|
||||
defaultContent=""
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level page mounted at /admin/ai/prompts.
|
||||
*
|
||||
* The original "AI prompt library" lives on as the third tab so existing
|
||||
* deep-links and saved bookmarks still work. The default tab is now the
|
||||
* Agents configurator — the user explicitly asked for this page to become
|
||||
* "config ai agent tools" with sensible defaults already shipped.
|
||||
*/
|
||||
export default function AIPromptEditor() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{t("aiAdmin.title", "AI Agents & Tools")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{t(
|
||||
"aiAdmin.subtitle",
|
||||
"Configure the LangGraph-backed agents that power course planning, exam generation, exercise generation, the LMS tutor, and grading. Defaults are pre-seeded and ready to use.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="agents" className="space-y-6">
|
||||
<TabsList className="h-auto w-full justify-start gap-2 bg-transparent p-0">
|
||||
<TabsTrigger
|
||||
value="agents"
|
||||
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-md border px-4 py-2"
|
||||
>
|
||||
<Bot className="me-1 h-4 w-4" />
|
||||
{t("aiAdmin.tabs.agents", "Agents")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="tools"
|
||||
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-md border px-4 py-2"
|
||||
>
|
||||
<Wrench className="me-1 h-4 w-4" />
|
||||
{t("aiAdmin.tabs.tools", "Tools")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="prompts"
|
||||
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-md border px-4 py-2"
|
||||
>
|
||||
<FileText className="me-1 h-4 w-4" />
|
||||
{t("aiAdmin.tabs.prompts", "Prompts")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="providers"
|
||||
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-md border px-4 py-2"
|
||||
>
|
||||
<KeyRound className="me-1 h-4 w-4" />
|
||||
{t("aiAdmin.tabs.providers", "Providers & Keys")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="agents" className="mt-2">
|
||||
<AIAgentsPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="tools" className="mt-2">
|
||||
<AIToolsPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="prompts" className="mt-2">
|
||||
<AIPromptsPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="providers" className="mt-2">
|
||||
<AIProviderSettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
704
src/pages/admin/AIProviderSettings.tsx
Normal file
704
src/pages/admin/AIProviderSettings.tsx
Normal file
@@ -0,0 +1,704 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
Save,
|
||||
Settings2,
|
||||
Volume2,
|
||||
Wand2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
aiSettingsService,
|
||||
type AISettingsPatchPayload,
|
||||
type AISettingsState,
|
||||
type CapabilityKey,
|
||||
type CapabilityState,
|
||||
type ProviderTestResult,
|
||||
} from "@/services/aiSettings.service";
|
||||
|
||||
/**
|
||||
* Admin UI for AI provider selection and API-key management.
|
||||
*
|
||||
* - Dropdowns set the active provider per capability (text / image / audio /
|
||||
* video). Selecting "auto" tells the backend to try paid providers first
|
||||
* and silently fall back to free providers on quota / billing errors.
|
||||
* - API-key inputs are write-only — the backend never returns a key value;
|
||||
* we only render a "saved" badge based on `keys_set[<name>]`.
|
||||
* - Changes persist to `ir.config_parameter` and take effect on the very
|
||||
* next request (no caching), so admins can flip OpenAI -> Mock without
|
||||
* restarting Odoo.
|
||||
*
|
||||
* Mounted as the fourth tab on `/admin/ai/prompts` so the user keeps a
|
||||
* single "AI" surface in the admin nav.
|
||||
*/
|
||||
|
||||
const CAPABILITY_META: Array<{
|
||||
key: CapabilityKey;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
i18nKey: string;
|
||||
defaultLabel: string;
|
||||
}> = [
|
||||
{ key: "text", icon: Wand2, i18nKey: "aiProviders.cap.text", defaultLabel: "Text generation" },
|
||||
{ key: "image", icon: ImageIcon, i18nKey: "aiProviders.cap.image", defaultLabel: "Image generation" },
|
||||
{ key: "audio", icon: Volume2, i18nKey: "aiProviders.cap.audio", defaultLabel: "Audio (TTS)" },
|
||||
{ key: "video", icon: Activity, i18nKey: "aiProviders.cap.video", defaultLabel: "Video composition" },
|
||||
];
|
||||
|
||||
interface KeyFieldDef {
|
||||
shortName: string;
|
||||
i18nKey: string;
|
||||
defaultLabel: string;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
hintI18nKey?: string;
|
||||
group: "openai" | "aws" | "elevenlabs" | "other" | "paymob";
|
||||
}
|
||||
|
||||
const KEY_FIELDS: KeyFieldDef[] = [
|
||||
// OpenAI
|
||||
{
|
||||
shortName: "openai_api_key",
|
||||
i18nKey: "aiProviders.keys.openai",
|
||||
defaultLabel: "OpenAI API key",
|
||||
placeholder: "sk-…",
|
||||
hintI18nKey: "aiProviders.keys.openai_hint",
|
||||
hint: "Used for GPT-4o, embeddings, and DALL-E 3.",
|
||||
group: "openai",
|
||||
},
|
||||
// AWS
|
||||
{
|
||||
shortName: "aws_access_key",
|
||||
i18nKey: "aiProviders.keys.aws_access",
|
||||
defaultLabel: "AWS Access Key ID",
|
||||
placeholder: "AKIA…",
|
||||
group: "aws",
|
||||
},
|
||||
{
|
||||
shortName: "aws_secret_key",
|
||||
i18nKey: "aiProviders.keys.aws_secret",
|
||||
defaultLabel: "AWS Secret Access Key",
|
||||
placeholder: "wJalr…",
|
||||
group: "aws",
|
||||
},
|
||||
// ElevenLabs
|
||||
{
|
||||
shortName: "elevenlabs_api_key",
|
||||
i18nKey: "aiProviders.keys.elevenlabs",
|
||||
defaultLabel: "ElevenLabs API key",
|
||||
placeholder: "el_…",
|
||||
group: "elevenlabs",
|
||||
},
|
||||
// GPTZero
|
||||
{
|
||||
shortName: "gptzero_api_key",
|
||||
i18nKey: "aiProviders.keys.gptzero",
|
||||
defaultLabel: "GPTZero API key",
|
||||
group: "other",
|
||||
},
|
||||
// Paymob (payments)
|
||||
{
|
||||
shortName: "paymob_api_key",
|
||||
i18nKey: "aiProviders.keys.paymob_api",
|
||||
defaultLabel: "Paymob API key",
|
||||
group: "paymob",
|
||||
},
|
||||
{
|
||||
shortName: "paymob_integration_id",
|
||||
i18nKey: "aiProviders.keys.paymob_integration",
|
||||
defaultLabel: "Paymob integration ID",
|
||||
group: "paymob",
|
||||
},
|
||||
{
|
||||
shortName: "paymob_iframe_id",
|
||||
i18nKey: "aiProviders.keys.paymob_iframe",
|
||||
defaultLabel: "Paymob iframe ID",
|
||||
group: "paymob",
|
||||
},
|
||||
{
|
||||
shortName: "paymob_hmac_secret",
|
||||
i18nKey: "aiProviders.keys.paymob_hmac",
|
||||
defaultLabel: "Paymob HMAC secret",
|
||||
group: "paymob",
|
||||
},
|
||||
];
|
||||
|
||||
const KEY_GROUPS: Array<{
|
||||
group: KeyFieldDef["group"];
|
||||
i18nKey: string;
|
||||
defaultLabel: string;
|
||||
}> = [
|
||||
{ group: "openai", i18nKey: "aiProviders.group.openai", defaultLabel: "OpenAI" },
|
||||
{ group: "aws", i18nKey: "aiProviders.group.aws", defaultLabel: "AWS Polly" },
|
||||
{ group: "elevenlabs", i18nKey: "aiProviders.group.elevenlabs", defaultLabel: "ElevenLabs" },
|
||||
{ group: "other", i18nKey: "aiProviders.group.other", defaultLabel: "Other AI services" },
|
||||
{ group: "paymob", i18nKey: "aiProviders.group.paymob", defaultLabel: "Paymob (payments)" },
|
||||
];
|
||||
|
||||
const KIND_BADGE: Record<string, { className: string; labelKey: string; defaultLabel: string }> = {
|
||||
paid: { className: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200",
|
||||
labelKey: "aiProviders.kind.paid", defaultLabel: "Paid" },
|
||||
free: { className: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200",
|
||||
labelKey: "aiProviders.kind.free", defaultLabel: "Free" },
|
||||
auto: { className: "bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-200",
|
||||
labelKey: "aiProviders.kind.auto", defaultLabel: "Auto" },
|
||||
};
|
||||
|
||||
function CapabilityCard({
|
||||
capKey,
|
||||
state,
|
||||
onChange,
|
||||
onTest,
|
||||
testing,
|
||||
testResult,
|
||||
}: {
|
||||
capKey: CapabilityKey;
|
||||
state: CapabilityState;
|
||||
onChange: (newValue: string) => void;
|
||||
onTest: () => void;
|
||||
testing: boolean;
|
||||
testResult: ProviderTestResult | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const meta = CAPABILITY_META.find((m) => m.key === capKey)!;
|
||||
const Icon = meta.icon;
|
||||
const activeOption = state.options.find((o) => o.value === state.active);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Icon className="h-4 w-4" />
|
||||
{t(meta.i18nKey, meta.defaultLabel)}
|
||||
</CardTitle>
|
||||
{activeOption ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={KIND_BADGE[activeOption.kind]?.className}
|
||||
>
|
||||
{t(
|
||||
KIND_BADGE[activeOption.kind]?.labelKey ?? "aiProviders.kind.auto",
|
||||
KIND_BADGE[activeOption.kind]?.defaultLabel ?? activeOption.kind,
|
||||
)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t(`${meta.i18nKey}_hint`, "Active provider for this capability.")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`provider-${capKey}`}>
|
||||
{t("aiProviders.activeProvider", "Active provider")}
|
||||
</Label>
|
||||
<Select value={state.active} onValueChange={onChange}>
|
||||
<SelectTrigger id={`provider-${capKey}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{state.options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
{opt.label}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${KIND_BADGE[opt.kind]?.className ?? ""}`}
|
||||
>
|
||||
{t(
|
||||
KIND_BADGE[opt.kind]?.labelKey ?? "aiProviders.kind.auto",
|
||||
KIND_BADGE[opt.kind]?.defaultLabel ?? opt.kind,
|
||||
)}
|
||||
</Badge>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{state.paid_with_credentials.length === 0 && capKey !== "video" ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(
|
||||
"aiProviders.noPaidKeys",
|
||||
"No paid API keys configured for this capability — free fallback will be used.",
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("aiProviders.paidConfigured", "Configured paid providers:")}{" "}
|
||||
{state.paid_with_credentials.join(", ") || "—"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onTest}
|
||||
disabled={testing}
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 className="me-1 h-3 w-3 animate-spin" />
|
||||
) : null}
|
||||
{t("aiProviders.testButton", "Test fallback chain")}
|
||||
</Button>
|
||||
{testResult ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{testResult.chain
|
||||
.map((c) => `${c.provider}${c.ok ? " ✓" : " ✗"}`)
|
||||
.join(" → ")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SecretField({
|
||||
field,
|
||||
isSet,
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
}: {
|
||||
field: KeyFieldDef;
|
||||
isSet: boolean;
|
||||
value: string | undefined;
|
||||
onChange: (v: string) => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const dirty = value !== undefined;
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor={`key-${field.shortName}`} className="text-sm">
|
||||
{t(field.i18nKey, field.defaultLabel)}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSet && !dirty ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200"
|
||||
>
|
||||
<CheckCircle2 className="me-1 h-3 w-3" />
|
||||
{t("aiProviders.keys.saved", "Saved")}
|
||||
</Badge>
|
||||
) : null}
|
||||
{dirty ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200"
|
||||
>
|
||||
{t("aiProviders.keys.unsaved", "Unsaved")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id={`key-${field.shortName}`}
|
||||
type={revealed ? "text" : "password"}
|
||||
placeholder={
|
||||
isSet
|
||||
? t("aiProviders.keys.placeholderSaved", "•••••• (click to replace)")
|
||||
: (field.placeholder ?? "")
|
||||
}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealed((v) => !v)}
|
||||
className="text-muted-foreground hover:text-foreground absolute end-2 top-1/2 -translate-y-1/2"
|
||||
tabIndex={-1}
|
||||
aria-label={revealed ? "Hide value" : "Show value"}
|
||||
>
|
||||
{revealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{isSet ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClear}
|
||||
>
|
||||
{t("aiProviders.keys.clear", "Clear")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{field.hint ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(field.hintI18nKey ?? "", field.hint)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlainField({
|
||||
shortName,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
shortName: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`plain-${shortName}`} className="text-sm">
|
||||
{label}
|
||||
</Label>
|
||||
<Input
|
||||
id={`plain-${shortName}`}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AIProviderSettings() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<AISettingsState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Pending diff applied on Save. Provider edits are confirmed
|
||||
// immediately so the UI reflects the active selection — but we still
|
||||
// batch them into one PATCH so we don't burn requests on every click.
|
||||
const [pendingProviders, setPendingProviders] = useState<
|
||||
Partial<Record<CapabilityKey, string>>
|
||||
>({});
|
||||
const [pendingKeys, setPendingKeys] = useState<Record<string, string>>({});
|
||||
const [pendingPlain, setPendingPlain] = useState<Record<string, string>>({});
|
||||
|
||||
const [testing, setTesting] = useState<CapabilityKey | null>(null);
|
||||
const [testResults, setTestResults] = useState<
|
||||
Partial<Record<CapabilityKey, ProviderTestResult>>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await aiSettingsService.get();
|
||||
setState(data);
|
||||
setPendingProviders({});
|
||||
setPendingKeys({});
|
||||
setPendingPlain({});
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(t("aiProviders.toast.loadFailed", "Could not load AI settings"), {
|
||||
description: msg,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const dirty = useMemo(
|
||||
() =>
|
||||
Object.keys(pendingProviders).length > 0 ||
|
||||
Object.keys(pendingKeys).length > 0 ||
|
||||
Object.keys(pendingPlain).length > 0,
|
||||
[pendingProviders, pendingKeys, pendingPlain],
|
||||
);
|
||||
|
||||
async function handleSave() {
|
||||
if (!state || !dirty) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: AISettingsPatchPayload = {};
|
||||
if (Object.keys(pendingProviders).length) payload.providers = pendingProviders;
|
||||
if (Object.keys(pendingKeys).length) payload.keys = pendingKeys;
|
||||
if (Object.keys(pendingPlain).length) payload.plain = pendingPlain;
|
||||
const updated = await aiSettingsService.update(payload);
|
||||
setState(updated);
|
||||
setPendingProviders({});
|
||||
setPendingKeys({});
|
||||
setPendingPlain({});
|
||||
toast.success(
|
||||
t("aiProviders.toast.saved", "AI provider settings saved"),
|
||||
{
|
||||
description: t(
|
||||
"aiProviders.toast.savedDescription",
|
||||
"Active providers updated. Changes apply to the next request.",
|
||||
),
|
||||
},
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(t("aiProviders.toast.saveFailed", "Could not save settings"), {
|
||||
description: msg,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest(cap: CapabilityKey) {
|
||||
setTesting(cap);
|
||||
try {
|
||||
const result = await aiSettingsService.test(cap);
|
||||
setTestResults((prev) => ({ ...prev, [cap]: result }));
|
||||
const allOk = result.chain.every((c) => c.ok);
|
||||
if (allOk) {
|
||||
toast.success(
|
||||
t("aiProviders.toast.testOk", "Provider chain ready") +
|
||||
` · ${result.chain.map((c) => c.provider).join(" → ")}`,
|
||||
);
|
||||
} else {
|
||||
toast.warning(
|
||||
t("aiProviders.toast.testPartial", "Some providers missing credentials") +
|
||||
` · ${result.chain
|
||||
.map((c) => `${c.provider}${c.ok ? "✓" : "✗"}`)
|
||||
.join(" → ")}`,
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(t("aiProviders.toast.testFailed", "Provider test failed"), {
|
||||
description: msg,
|
||||
});
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
return (
|
||||
<div className="text-muted-foreground py-12 text-center text-sm">
|
||||
{t("aiProviders.empty", "Settings could not be loaded.")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const effectiveProviders: Record<CapabilityKey, CapabilityState> = {
|
||||
text: { ...state.providers.text, active: pendingProviders.text ?? state.providers.text.active },
|
||||
image: { ...state.providers.image, active: pendingProviders.image ?? state.providers.image.active },
|
||||
audio: { ...state.providers.audio, active: pendingProviders.audio ?? state.providers.audio.active },
|
||||
video: { ...state.providers.video, active: pendingProviders.video ?? state.providers.video.active },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Settings2 className="h-5 w-5" />
|
||||
{t("aiProviders.title", "AI Providers & API Keys")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"aiProviders.subtitle",
|
||||
"Pick the active provider per capability and store API keys. Changes take effect on the next request — no Odoo restart required.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
className="shrink-0"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="me-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="me-1 h-4 w-4" />
|
||||
)}
|
||||
{t("aiProviders.save", "Save settings")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{CAPABILITY_META.map((meta) => (
|
||||
<CapabilityCard
|
||||
key={meta.key}
|
||||
capKey={meta.key}
|
||||
state={effectiveProviders[meta.key]}
|
||||
onChange={(newValue) =>
|
||||
setPendingProviders((prev) => ({ ...prev, [meta.key]: newValue }))
|
||||
}
|
||||
onTest={() => handleTest(meta.key)}
|
||||
testing={testing === meta.key}
|
||||
testResult={testResults[meta.key] ?? null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("aiProviders.keys.title", "API keys")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"aiProviders.keys.subtitle",
|
||||
"Keys are write-only and stored encrypted at rest in ir.config_parameter. They are never returned to the browser.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{KEY_GROUPS.map((group) => {
|
||||
const fields = KEY_FIELDS.filter((f) => f.group === group.group);
|
||||
if (!fields.length) return null;
|
||||
return (
|
||||
<div key={group.group} className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t(group.i18nKey, group.defaultLabel)}
|
||||
</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{fields.map((field) => (
|
||||
<SecretField
|
||||
key={field.shortName}
|
||||
field={field}
|
||||
isSet={Boolean(state.keys_set[field.shortName])}
|
||||
value={pendingKeys[field.shortName]}
|
||||
onChange={(v) =>
|
||||
setPendingKeys((prev) => ({
|
||||
...prev,
|
||||
[field.shortName]: v,
|
||||
}))
|
||||
}
|
||||
onClear={() =>
|
||||
setPendingKeys((prev) => ({
|
||||
...prev,
|
||||
[field.shortName]: "",
|
||||
}))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("aiProviders.plain.title", "Model & runtime")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"aiProviders.plain.subtitle",
|
||||
"Non-secret runtime parameters: default model, AWS region, request timeout.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<PlainField
|
||||
shortName="openai_model"
|
||||
label={t("aiProviders.plain.openai_model", "OpenAI model")}
|
||||
value={pendingPlain.openai_model ?? state.plain.openai_model ?? ""}
|
||||
onChange={(v) =>
|
||||
setPendingPlain((prev) => ({ ...prev, openai_model: v }))
|
||||
}
|
||||
/>
|
||||
<PlainField
|
||||
shortName="openai_fast_model"
|
||||
label={t("aiProviders.plain.openai_fast", "OpenAI fast model")}
|
||||
value={pendingPlain.openai_fast_model ?? state.plain.openai_fast_model ?? ""}
|
||||
onChange={(v) =>
|
||||
setPendingPlain((prev) => ({ ...prev, openai_fast_model: v }))
|
||||
}
|
||||
/>
|
||||
<PlainField
|
||||
shortName="aws_region"
|
||||
label={t("aiProviders.plain.aws_region", "AWS region")}
|
||||
value={pendingPlain.aws_region ?? state.plain.aws_region ?? ""}
|
||||
onChange={(v) =>
|
||||
setPendingPlain((prev) => ({ ...prev, aws_region: v }))
|
||||
}
|
||||
/>
|
||||
<PlainField
|
||||
shortName="elevenlabs_model"
|
||||
label={t("aiProviders.plain.elevenlabs_model", "ElevenLabs model")}
|
||||
value={pendingPlain.elevenlabs_model ?? state.plain.elevenlabs_model ?? ""}
|
||||
onChange={(v) =>
|
||||
setPendingPlain((prev) => ({ ...prev, elevenlabs_model: v }))
|
||||
}
|
||||
/>
|
||||
<PlainField
|
||||
shortName="request_timeout"
|
||||
label={t("aiProviders.plain.timeout", "Request timeout (seconds)")}
|
||||
value={pendingPlain.request_timeout ?? state.plain.request_timeout ?? ""}
|
||||
onChange={(v) =>
|
||||
setPendingPlain((prev) => ({ ...prev, request_timeout: v }))
|
||||
}
|
||||
/>
|
||||
<PlainField
|
||||
shortName="max_retries"
|
||||
label={t("aiProviders.plain.max_retries", "Max retries")}
|
||||
value={pendingPlain.max_retries ?? state.plain.max_retries ?? ""}
|
||||
onChange={(v) =>
|
||||
setPendingPlain((prev) => ({ ...prev, max_retries: v }))
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(
|
||||
"aiProviders.footer",
|
||||
"Provider settings are read fresh from ir.config_parameter on every request — no caching, no restart required.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
179
src/pages/admin/AIToolsPanel.tsx
Normal file
179
src/pages/admin/AIToolsPanel.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Search, Wrench } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { aiAgentService } from "@/services/aiAgent.service";
|
||||
import type { AIToolSummary } from "@/types/aiAgent";
|
||||
|
||||
const CATEGORY_TONES: Record<string, string> = {
|
||||
retrieval: "bg-blue-500",
|
||||
persistence: "bg-amber-500",
|
||||
quality: "bg-purple-500",
|
||||
scoring: "bg-emerald-500",
|
||||
reference: "bg-slate-500",
|
||||
other: "bg-zinc-500",
|
||||
};
|
||||
|
||||
export function AIToolsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const toolsQ = useQuery({
|
||||
queryKey: ["ai-agent-tools"],
|
||||
queryFn: () => aiAgentService.listTools(),
|
||||
});
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: (tool: AIToolSummary) =>
|
||||
aiAgentService.updateTool(tool.id, { active: !tool.active }),
|
||||
onSuccess: (tool) => {
|
||||
toast.success(
|
||||
tool.active
|
||||
? t("tools.toggle.enabled", "Tool enabled")
|
||||
: t("tools.toggle.disabled", "Tool disabled"),
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: ["ai-agent-tools"] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const items = toolsQ.data ?? [];
|
||||
if (!search.trim()) return items;
|
||||
const needle = search.toLowerCase();
|
||||
return items.filter(
|
||||
(t) =>
|
||||
t.key.toLowerCase().includes(needle) ||
|
||||
t.name.toLowerCase().includes(needle) ||
|
||||
(t.description || "").toLowerCase().includes(needle),
|
||||
);
|
||||
}, [toolsQ.data, search]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Wrench className="h-4 w-4" />
|
||||
{t("tools.title", "Agent tools")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"tools.subtitle",
|
||||
"Capabilities your agents can call. Toggle a tool off to take it out of every agent without editing each one.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="text-muted-foreground absolute start-2 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("tools.search", "Search tools…")}
|
||||
className="ps-8"
|
||||
/>
|
||||
</div>
|
||||
{toolsQ.isLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("tools.empty", "No tools match your search.")}
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("tools.col.key", "Key")}</TableHead>
|
||||
<TableHead>{t("tools.col.name", "Name")}</TableHead>
|
||||
<TableHead>{t("tools.col.category", "Category")}</TableHead>
|
||||
<TableHead>{t("tools.col.description", "Description")}</TableHead>
|
||||
<TableHead>{t("tools.col.params", "Params")}</TableHead>
|
||||
<TableHead className="w-[100px] text-end">
|
||||
{t("tools.col.active", "Active")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((tool) => {
|
||||
const tone = CATEGORY_TONES[tool.category] || "bg-zinc-500";
|
||||
const params =
|
||||
(tool.schema?.properties as Record<string, unknown>) || {};
|
||||
const paramKeys = Object.keys(params);
|
||||
return (
|
||||
<TableRow key={tool.id}>
|
||||
<TableCell className="font-mono text-xs">{tool.key}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{tool.name}</span>
|
||||
{tool.mutates ? (
|
||||
<Badge className="bg-amber-500 text-white text-[10px] hover:bg-amber-500">
|
||||
{t("tools.writes", "writes")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${tone} text-white hover:${tone}`}>
|
||||
{tool.category}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground max-w-[360px] text-xs">
|
||||
{tool.description}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{paramKeys.length === 0 ? (
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
) : (
|
||||
paramKeys.slice(0, 5).map((k) => (
|
||||
<Badge key={k} variant="outline" className="text-[10px]">
|
||||
{k}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
{paramKeys.length > 5 ? (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
+{paramKeys.length - 5}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-end">
|
||||
<Switch
|
||||
checked={tool.active}
|
||||
onCheckedChange={() => toggle.mutate(tool)}
|
||||
disabled={toggle.isPending}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export default function AdminBatchDetail() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild><Link to="/admin/batches"><ArrowLeft className="h-4 w-4" /></Link></Button>
|
||||
<Button variant="ghost" size="icon" asChild><Link to="/admin/batches"><ArrowLeft className="h-4 w-4 rtl:rotate-180" /></Link></Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{batch.name}</h1>
|
||||
<p className="text-muted-foreground">{batch.course_name} · {batch.teacher_name}</p>
|
||||
|
||||
@@ -4,34 +4,106 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useBatches, useCourses } from "@/hooks/queries";
|
||||
import { useBatches, useCourses, useTeachers } from "@/hooks/queries";
|
||||
import { lmsService } from "@/services/lms.service";
|
||||
import { classroomsService } from "@/services/classrooms.service";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Search, Plus, Trash2 } from "lucide-react";
|
||||
import { Search, Plus, Trash2, Pencil } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import AiBatchOptimizer from "@/components/ai/AiBatchOptimizer";
|
||||
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
|
||||
type BatchForm = {
|
||||
name: string;
|
||||
course_id: string;
|
||||
course_section_id: string;
|
||||
term_key: string;
|
||||
classroom_id: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
max_students: string;
|
||||
teacher_ids: number[];
|
||||
};
|
||||
|
||||
const EMPTY_FORM: BatchForm = {
|
||||
name: "",
|
||||
course_id: "",
|
||||
course_section_id: "",
|
||||
term_key: "",
|
||||
classroom_id: "",
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
max_students: "30",
|
||||
teacher_ids: [],
|
||||
};
|
||||
|
||||
export default function AdminBatches() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState({ name: "", course_id: "", start_date: "", end_date: "", max_students: "30" });
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editingBatchId, setEditingBatchId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<BatchForm>(EMPTY_FORM);
|
||||
const [editForm, setEditForm] = useState<BatchForm>(EMPTY_FORM);
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { data: batchesData, isLoading } = useBatches();
|
||||
const { data: coursesData } = useCourses({ size: 200 });
|
||||
const { data: teachersData } = useTeachers({ size: 200 });
|
||||
const classroomsQ = useQuery({
|
||||
queryKey: ["lms-classrooms-light"],
|
||||
queryFn: async () => (await classroomsService.list({ size: 200 })).items,
|
||||
});
|
||||
const createSectionsQ = useQuery({
|
||||
queryKey: ["course-sections", form.course_id],
|
||||
queryFn: () =>
|
||||
form.course_id
|
||||
? lmsService.listCourseSections(Number(form.course_id))
|
||||
: Promise.resolve({ items: [], total: 0 }),
|
||||
enabled: !!form.course_id,
|
||||
});
|
||||
const editSectionsQ = useQuery({
|
||||
queryKey: ["course-sections", editForm.course_id],
|
||||
queryFn: () =>
|
||||
editForm.course_id
|
||||
? lmsService.listCourseSections(Number(editForm.course_id))
|
||||
: Promise.resolve({ items: [], total: 0 }),
|
||||
enabled: !!editForm.course_id,
|
||||
});
|
||||
const batches = batchesData?.items ?? [];
|
||||
const courses = coursesData?.items ?? [];
|
||||
const teachers = teachersData?.items ?? [];
|
||||
const classrooms = classroomsQ.data ?? [];
|
||||
const createSections = createSectionsQ.data?.items ?? [];
|
||||
const editSections = editSectionsQ.data?.items ?? [];
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => lmsService.createBatch(data as never),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "batches"] }); setCreateOpen(false); toast({ title: "Batch created" }); setForm({ name: "", course_id: "", start_date: "", end_date: "", max_students: "30" }); },
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
||||
setCreateOpen(false);
|
||||
setForm(EMPTY_FORM);
|
||||
toast({ title: "Batch created" });
|
||||
},
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) => lmsService.updateBatch(id, data as never),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
||||
setEditOpen(false);
|
||||
setEditingBatchId(null);
|
||||
setEditForm(EMPTY_FORM);
|
||||
toast({ title: "Batch updated" });
|
||||
},
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
@@ -47,9 +119,26 @@ export default function AdminBatches() {
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(batch: (typeof batches)[number]) {
|
||||
setEditingBatchId(batch.id);
|
||||
setEditForm({
|
||||
name: batch.name || "",
|
||||
course_id: String(batch.course_id || ""),
|
||||
course_section_id: batch.course_section_id ? String(batch.course_section_id) : "",
|
||||
term_key: batch.term_key || "",
|
||||
classroom_id: batch.classroom_id ? String(batch.classroom_id) : "",
|
||||
start_date: batch.start_date || "",
|
||||
end_date: batch.end_date || "",
|
||||
max_students: String(batch.capacity || 30),
|
||||
teacher_ids: batch.teacher_ids || [],
|
||||
});
|
||||
setEditOpen(true);
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||
|
||||
const filtered = batches.filter(b => b.name.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -62,7 +151,81 @@ export default function AdminBatches() {
|
||||
</div>
|
||||
<AiTipBanner context="admin-batches" variant="recommendation" />
|
||||
<div className="relative max-w-sm"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="Search batches..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9" /></div>
|
||||
<Card><CardContent className="pt-6"><Table><TableHeader><TableRow><TableHead>Batch</TableHead><TableHead>Course</TableHead><TableHead>Teacher</TableHead><TableHead>Students</TableHead><TableHead>Schedule</TableHead><TableHead>Status</TableHead><TableHead></TableHead></TableRow></TableHeader><TableBody>{filtered.map(b => (<TableRow key={b.id}><TableCell className="font-medium">{b.name}</TableCell><TableCell>{b.course_name}</TableCell><TableCell>{b.teacher_name}</TableCell><TableCell>{b.student_count}/{b.capacity}</TableCell><TableCell className="text-xs">{b.schedule}</TableCell><TableCell><Badge variant={b.status === "active" ? "default" : "secondary"} className="capitalize">{b.status}</Badge></TableCell><TableCell><div className="flex gap-1"><Button size="sm" variant="ghost" asChild><Link to={`/admin/batches/${b.id}`}>View</Link></Button><Button variant="ghost" size="icon" onClick={() => handleDelete(b.id, b.name)}><Trash2 className="h-4 w-4 text-destructive" /></Button></div></TableCell></TableRow>))}</TableBody></Table></CardContent></Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Batch</TableHead>
|
||||
<TableHead>Course</TableHead>
|
||||
<TableHead>Section</TableHead>
|
||||
<TableHead>Term</TableHead>
|
||||
<TableHead>Classroom</TableHead>
|
||||
<TableHead>Teachers</TableHead>
|
||||
<TableHead>Students</TableHead>
|
||||
<TableHead>Schedule</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((b) => (
|
||||
<TableRow key={b.id}>
|
||||
<TableCell className="font-medium">{b.name}</TableCell>
|
||||
<TableCell>{b.course_name}</TableCell>
|
||||
<TableCell>
|
||||
{b.course_section_name ? (
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{b.course_section_code || b.course_section_name}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{b.term_key || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{b.classroom_name || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{b.teacher_names?.length ? b.teacher_names.join(", ") : b.teacher_name || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{b.student_count}/{b.capacity}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{b.schedule}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={b.status === "active" ? "default" : "secondary"}
|
||||
className="capitalize"
|
||||
>
|
||||
{b.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="ghost" asChild>
|
||||
<Link to={`/admin/batches/${b.id}`}>View</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => openEdit(b)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(b.id, b.name)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Create Batch</DialogTitle></DialogHeader>
|
||||
@@ -70,7 +233,16 @@ export default function AdminBatches() {
|
||||
<div className="space-y-2"><Label>Batch Name *</Label><Input placeholder="e.g. IELTS Morning B" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} /></div>
|
||||
<div className="space-y-2">
|
||||
<Label>Course *</Label>
|
||||
<Select value={form.course_id || "__none__"} onValueChange={v => setForm(f => ({ ...f, course_id: v === "__none__" ? "" : v }))}>
|
||||
<Select
|
||||
value={form.course_id || "__none__"}
|
||||
onValueChange={(v) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
course_id: v === "__none__" ? "" : v,
|
||||
course_section_id: "",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">— Select —</SelectItem>
|
||||
@@ -78,15 +250,233 @@ export default function AdminBatches() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Section (optional)</Label>
|
||||
<Select
|
||||
value={form.course_section_id || "__none__"}
|
||||
onValueChange={(v) =>
|
||||
setForm((f) => ({ ...f, course_section_id: v === "__none__" ? "" : v }))
|
||||
}
|
||||
disabled={!form.course_id}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Whole course" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">Whole course (no section)</SelectItem>
|
||||
{createSections.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.code ? `${s.code} · ${s.name}` : s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Pick a course section template to keep batches isolated per group.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Term Key (optional)</Label>
|
||||
<Input
|
||||
placeholder="e.g. 2026-T1"
|
||||
value={form.term_key}
|
||||
onChange={(e) => setForm((f) => ({ ...f, term_key: e.target.value }))}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
One batch per (classroom × course × section × term).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Classroom (optional)</Label>
|
||||
<Select value={form.classroom_id || "__none__"} onValueChange={v => setForm(f => ({ ...f, classroom_id: v === "__none__" ? "" : v }))}>
|
||||
<SelectTrigger><SelectValue placeholder="No classroom" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No classroom</SelectItem>
|
||||
{classrooms.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.name} ({c.student_count} students)</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">Selecting a classroom links the batch to that homeroom. Use the Classrooms page to auto-enroll the roster.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2"><Label>Start Date *</Label><Input type="date" value={form.start_date} onChange={e => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
|
||||
<div className="space-y-2"><Label>End Date *</Label><Input type="date" value={form.end_date} onChange={e => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
|
||||
</div>
|
||||
<div className="space-y-2"><Label>Capacity</Label><Input type="number" placeholder="30" value={form.max_students} onChange={e => setForm(f => ({ ...f, max_students: e.target.value }))} /></div>
|
||||
<div className="space-y-2">
|
||||
<Label>Teachers (multi-select)</Label>
|
||||
<div className="max-h-40 overflow-y-auto rounded border p-2 space-y-1">
|
||||
{teachers.map((t) => (
|
||||
<label key={t.id} className="flex items-center gap-2 rounded px-2 py-1 hover:bg-muted/50">
|
||||
<Checkbox
|
||||
checked={form.teacher_ids.includes(t.id)}
|
||||
onCheckedChange={() =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
teacher_ids: prev.teacher_ids.includes(t.id)
|
||||
? prev.teacher_ids.filter((id) => id !== t.id)
|
||||
: [...prev.teacher_ids, t.id],
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">{t.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button disabled={createMutation.isPending || !form.name || !form.course_id || !form.start_date || !form.end_date} onClick={() => createMutation.mutate({ name: form.name, course_id: Number(form.course_id), start_date: form.start_date, end_date: form.end_date, max_students: Number(form.max_students) || 30 })}>Create</Button>
|
||||
<Button
|
||||
disabled={
|
||||
createMutation.isPending ||
|
||||
!form.name ||
|
||||
!form.course_id ||
|
||||
!form.start_date ||
|
||||
!form.end_date
|
||||
}
|
||||
onClick={() =>
|
||||
createMutation.mutate({
|
||||
name: form.name,
|
||||
course_id: Number(form.course_id),
|
||||
course_section_id: form.course_section_id ? Number(form.course_section_id) : undefined,
|
||||
term_key: form.term_key.trim() || undefined,
|
||||
classroom_id: form.classroom_id ? Number(form.classroom_id) : undefined,
|
||||
start_date: form.start_date,
|
||||
end_date: form.end_date,
|
||||
max_students: Number(form.max_students) || 30,
|
||||
teacher_ids: form.teacher_ids,
|
||||
})
|
||||
}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Edit Batch</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2"><Label>Batch Name *</Label><Input value={editForm.name} onChange={e => setEditForm(f => ({ ...f, name: e.target.value }))} /></div>
|
||||
<div className="space-y-2">
|
||||
<Label>Course *</Label>
|
||||
<Select
|
||||
value={editForm.course_id || "__none__"}
|
||||
onValueChange={(v) =>
|
||||
setEditForm((f) => ({
|
||||
...f,
|
||||
course_id: v === "__none__" ? "" : v,
|
||||
course_section_id: "",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">— Select —</SelectItem>
|
||||
{courses.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.title}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Section (optional)</Label>
|
||||
<Select
|
||||
value={editForm.course_section_id || "__none__"}
|
||||
onValueChange={(v) =>
|
||||
setEditForm((f) => ({
|
||||
...f,
|
||||
course_section_id: v === "__none__" ? "" : v,
|
||||
}))
|
||||
}
|
||||
disabled={!editForm.course_id}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Whole course" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">Whole course (no section)</SelectItem>
|
||||
{editSections.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.code ? `${s.code} · ${s.name}` : s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Term Key (optional)</Label>
|
||||
<Input
|
||||
placeholder="e.g. 2026-T1"
|
||||
value={editForm.term_key}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, term_key: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Classroom (optional)</Label>
|
||||
<Select value={editForm.classroom_id || "__none__"} onValueChange={v => setEditForm(f => ({ ...f, classroom_id: v === "__none__" ? "" : v }))}>
|
||||
<SelectTrigger><SelectValue placeholder="No classroom" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No classroom</SelectItem>
|
||||
{classrooms.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.name} ({c.student_count} students)</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2"><Label>Start Date *</Label><Input type="date" value={editForm.start_date} onChange={e => setEditForm(f => ({ ...f, start_date: e.target.value }))} /></div>
|
||||
<div className="space-y-2"><Label>End Date *</Label><Input type="date" value={editForm.end_date} onChange={e => setEditForm(f => ({ ...f, end_date: e.target.value }))} /></div>
|
||||
</div>
|
||||
<div className="space-y-2"><Label>Capacity</Label><Input type="number" value={editForm.max_students} onChange={e => setEditForm(f => ({ ...f, max_students: e.target.value }))} /></div>
|
||||
<div className="space-y-2">
|
||||
<Label>Teachers (multi-select)</Label>
|
||||
<div className="max-h-40 overflow-y-auto rounded border p-2 space-y-1">
|
||||
{teachers.map((t) => (
|
||||
<label key={t.id} className="flex items-center gap-2 rounded px-2 py-1 hover:bg-muted/50">
|
||||
<Checkbox
|
||||
checked={editForm.teacher_ids.includes(t.id)}
|
||||
onCheckedChange={() =>
|
||||
setEditForm((prev) => ({
|
||||
...prev,
|
||||
teacher_ids: prev.teacher_ids.includes(t.id)
|
||||
? prev.teacher_ids.filter((id) => id !== t.id)
|
||||
: [...prev.teacher_ids, t.id],
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">{t.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={updateMutation.isPending || !editingBatchId || !editForm.name || !editForm.course_id || !editForm.start_date || !editForm.end_date}
|
||||
onClick={() => {
|
||||
if (!editingBatchId) return;
|
||||
updateMutation.mutate({
|
||||
id: editingBatchId,
|
||||
data: {
|
||||
name: editForm.name,
|
||||
course_id: Number(editForm.course_id),
|
||||
course_section_id: editForm.course_section_id
|
||||
? Number(editForm.course_section_id)
|
||||
: null,
|
||||
term_key: editForm.term_key.trim(),
|
||||
classroom_id: editForm.classroom_id ? Number(editForm.classroom_id) : null,
|
||||
start_date: editForm.start_date,
|
||||
end_date: editForm.end_date,
|
||||
max_students: Number(editForm.max_students) || 30,
|
||||
teacher_ids: editForm.teacher_ids,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
260
src/pages/admin/AdminBranches.tsx
Normal file
260
src/pages/admin/AdminBranches.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Building2, Loader2, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { lmsService } from "@/services/lms.service";
|
||||
import type { LmsBranch } from "@/types";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
type BranchForm = {
|
||||
name: string;
|
||||
code: string;
|
||||
notes: string;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_FORM: BranchForm = { name: "", code: "", notes: "", active: true };
|
||||
|
||||
export default function AdminBranches() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedEntity } = useAuth();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<LmsBranch | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [form, setForm] = useState<BranchForm>(EMPTY_FORM);
|
||||
|
||||
const branchesQ = useQuery({
|
||||
queryKey: ["lms-branches", selectedEntity?.id ?? 0, search],
|
||||
queryFn: async () => {
|
||||
const res = await lmsService.listBranches({
|
||||
size: 200,
|
||||
search: search.trim() || undefined,
|
||||
});
|
||||
return res.items;
|
||||
},
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (payload: Partial<LmsBranch>) => lmsService.createBranch(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["lms-branches"] });
|
||||
setOpen(false);
|
||||
setEditing(null);
|
||||
setForm(EMPTY_FORM);
|
||||
toast({ title: "Branch created" });
|
||||
},
|
||||
onError: () => toast({ title: "Error", description: "Failed to create branch", variant: "destructive" }),
|
||||
});
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: number; payload: Partial<LmsBranch> }) =>
|
||||
lmsService.updateBranch(id, payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["lms-branches"] });
|
||||
setOpen(false);
|
||||
setEditing(null);
|
||||
setForm(EMPTY_FORM);
|
||||
toast({ title: "Branch updated" });
|
||||
},
|
||||
onError: () => toast({ title: "Error", description: "Failed to update branch", variant: "destructive" }),
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: number) => lmsService.deleteBranch(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["lms-branches"] });
|
||||
toast({ title: "Branch deleted" });
|
||||
},
|
||||
onError: () => toast({ title: "Error", description: "Failed to delete branch", variant: "destructive" }),
|
||||
});
|
||||
|
||||
const isSaving = createMut.isPending || updateMut.isPending;
|
||||
const rows = useMemo(() => branchesQ.data ?? [], [branchesQ.data]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row: LmsBranch) => {
|
||||
setEditing(row);
|
||||
setForm({
|
||||
name: row.name || "",
|
||||
code: row.code || "",
|
||||
notes: row.notes || "",
|
||||
active: !!row.active,
|
||||
});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const onSave = () => {
|
||||
const payload: Partial<LmsBranch> = {
|
||||
name: form.name.trim(),
|
||||
code: form.code.trim(),
|
||||
notes: form.notes.trim(),
|
||||
active: form.active,
|
||||
};
|
||||
if (!payload.name) return;
|
||||
if (editing) {
|
||||
updateMut.mutate({ id: editing.id, payload });
|
||||
return;
|
||||
}
|
||||
createMut.mutate(payload);
|
||||
};
|
||||
|
||||
const onDelete = (row: LmsBranch) => {
|
||||
if (!window.confirm(`Delete branch "${row.name}"?`)) return;
|
||||
deleteMut.mutate(row.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Branches</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Create branches per entity to organize courses, batches, teachers, and students.
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Branch
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? "Edit Branch" : "Create Branch"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Branch name</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="e.g. Riyadh Main Campus"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Branch code</Label>
|
||||
<Input
|
||||
value={form.code}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, code: e.target.value }))}
|
||||
placeholder="e.g. RUH_MAIN"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Notes</Label>
|
||||
<Textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
|
||||
placeholder="Optional notes for managers"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Active</p>
|
||||
<p className="text-xs text-muted-foreground">Inactive branches stay in history but are hidden from active workflows.</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.active}
|
||||
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, active: checked }))}
|
||||
/>
|
||||
</div>
|
||||
<Button className="w-full" onClick={onSave} disabled={isSaving || !form.name.trim()}>
|
||||
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{editing ? "Update Branch" : "Create Branch"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search branches by name or code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{branchesQ.isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead>Entity</TableHead>
|
||||
<TableHead>Shared LMS</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-medium">{row.name}</TableCell>
|
||||
<TableCell>{row.code || "—"}</TableCell>
|
||||
<TableCell>{row.entity_name || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
C:{row.course_count} B:{row.batch_count} S:{row.student_count} T:{row.teacher_count}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.active ? (
|
||||
<span className="inline-flex items-center rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-700">Active</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-600">Inactive</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => openEdit(row)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => onDelete(row)} disabled={deleteMut.isPending}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-12 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Building2 className="h-5 w-5" />
|
||||
<span>No branches found for this entity.</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
2129
src/pages/admin/AdminCoursePlanDetail.tsx
Normal file
2129
src/pages/admin/AdminCoursePlanDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
175
src/pages/admin/AdminCoursePlans.tsx
Normal file
175
src/pages/admin/AdminCoursePlans.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
BookOpen,
|
||||
Plus,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Calendar,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { coursePlanService } from "@/services/coursePlan.service";
|
||||
import { describeApiError } from "@/lib/api-client";
|
||||
|
||||
/**
|
||||
* Admin list of AI-generated course plans.
|
||||
*
|
||||
* Each plan row shows name + CEFR + weeks + status, plus an "Open"
|
||||
* button that deep-links to the detail page. A prominent "Generate new"
|
||||
* CTA routes to the Smart Wizard's CoursePlanWizard.
|
||||
*/
|
||||
export default function AdminCoursePlans() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["course-plans", { search }],
|
||||
queryFn: () =>
|
||||
coursePlanService.list({
|
||||
page: 0,
|
||||
size: 50,
|
||||
search: search || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: (id: number) => coursePlanService.remove(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["course-plans"] });
|
||||
toast.success(t("coursePlan.deleted"));
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(describeApiError(err, t("coursePlan.deleteFailed"))),
|
||||
});
|
||||
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{t("coursePlan.listTitle")}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground max-w-2xl">
|
||||
{t("coursePlan.listSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate("/admin/smart-wizard/course-plan")}>
|
||||
<Sparkles className="mr-1 h-4 w-4" />
|
||||
{t("coursePlan.generateNew")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-w-sm">
|
||||
<Input
|
||||
placeholder={t("coursePlan.searchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-40 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{describeApiError(error, t("coursePlan.loadFailed"))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && items.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center gap-3 py-12">
|
||||
<Sparkles className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<div className="font-medium">{t("coursePlan.emptyTitle")}</div>
|
||||
<div className="text-sm text-muted-foreground max-w-md">
|
||||
{t("coursePlan.emptySubtitle")}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => navigate("/admin/smart-wizard/course-plan")}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{t("coursePlan.generateNew")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isLoading && items.length > 0 && (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((plan) => (
|
||||
<Card key={plan.id} className="flex flex-col">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-base line-clamp-2">{plan.name}</CardTitle>
|
||||
<Badge variant={plan.status === "approved" ? "default" : "outline"}>
|
||||
{t(`coursePlan.status.${plan.status}`, plan.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription className="line-clamp-2">
|
||||
{plan.description || t("coursePlan.noDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Badge variant="secondary" className="text-[10px] uppercase">
|
||||
{plan.cefr_level || "—"}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
{t("coursePlan.materialsCount", { count: plan.material_count })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="default" size="sm" className="flex-1">
|
||||
<Link to={`/admin/course-plans/${plan.id}`}>
|
||||
{t("coursePlan.open")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
if (!window.confirm(t("coursePlan.confirmDelete", { name: plan.name }))) return;
|
||||
removeMut.mutate(plan.id);
|
||||
}}
|
||||
aria-label={t("coursePlan.delete")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,12 +13,12 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useCourses, useCreateCourse, useStudents, useBulkEnroll, useBatches } from "@/hooks/queries";
|
||||
import { lmsService, taxonomyService, resourcesService } from "@/services";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Search, Plus, Trash2, UserPlus, FileEdit, Pencil, BookOpen, Target, Loader2, Users, GraduationCap } from "lucide-react";
|
||||
import { Search, Plus, Trash2, UserPlus, FileEdit, Pencil, BookOpen, Target, Loader2, Users, GraduationCap, Layers, Sparkles, X } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { Course, CourseCreateRequest } from "@/types";
|
||||
import type { Course, CourseCreateRequest, CourseSection } from "@/types";
|
||||
import type { ResourceTag } from "@/types/adaptive";
|
||||
|
||||
const DIFFICULTY_OPTIONS = [
|
||||
@@ -253,7 +253,13 @@ function CourseFormDialog({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Difficulty</Label>
|
||||
<Label className="flex items-center gap-1">
|
||||
Difficulty
|
||||
<span
|
||||
className="text-[11px] text-muted-foreground cursor-help"
|
||||
title="General hardness tag shown to students when browsing courses (Beginner / Intermediate / Advanced). Use this to set expectations."
|
||||
>(?)</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={form.difficulty_level || "none"}
|
||||
onValueChange={(v) => setForm((f) => ({ ...f, difficulty_level: v === "none" ? "" : v }))}
|
||||
@@ -266,9 +272,18 @@ function CourseFormDialog({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
A plain-language tag (Beginner / Intermediate / Advanced).
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>CEFR Level</Label>
|
||||
<Label className="flex items-center gap-1">
|
||||
CEFR Level
|
||||
<span
|
||||
className="text-[11px] text-muted-foreground cursor-help"
|
||||
title="Common European Framework of Reference level (A1-C2). Used by placement logic, adaptive engine and rubric mapping."
|
||||
>(?)</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={form.cefr_level || "none"}
|
||||
onValueChange={(v) => setForm((f) => ({ ...f, cefr_level: v === "none" ? "" : v }))}
|
||||
@@ -281,6 +296,9 @@ function CourseFormDialog({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Standard proficiency band (A1-C2); drives placement & rubrics.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -391,10 +409,335 @@ function courseToFormData(c: Course): CourseFormData {
|
||||
};
|
||||
}
|
||||
|
||||
function CourseSectionsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
course,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
course: Course;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newCode, setNewCode] = useState("");
|
||||
const [newSeq, setNewSeq] = useState<number>(10);
|
||||
const [editing, setEditing] = useState<CourseSection | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editCode, setEditCode] = useState("");
|
||||
const [editSeq, setEditSeq] = useState<number>(10);
|
||||
const [editActive, setEditActive] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const sectionsQ = useQuery({
|
||||
queryKey: ["course-sections", course.id],
|
||||
queryFn: () => lmsService.listCourseSections(course.id),
|
||||
enabled: open,
|
||||
});
|
||||
const sections = sectionsQ.data?.items ?? [];
|
||||
|
||||
function refreshAll() {
|
||||
qc.invalidateQueries({ queryKey: ["course-sections", course.id] });
|
||||
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
|
||||
qc.invalidateQueries({ queryKey: ["lms-classroom-detail"] });
|
||||
}
|
||||
|
||||
async function handleGenerateDefaults() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await lmsService.generateDefaultCourseSections(course.id);
|
||||
toast({
|
||||
title: res.created_count > 0 ? `Generated ${res.created_count} section(s)` : "Defaults already exist",
|
||||
});
|
||||
refreshAll();
|
||||
} catch (e: unknown) {
|
||||
toast({
|
||||
title: "Generation failed",
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
const code = newCode.trim().toUpperCase();
|
||||
const name = newName.trim() || `Section ${code}`;
|
||||
if (!code) {
|
||||
toast({ title: "Code is required", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await lmsService.createCourseSection(course.id, {
|
||||
name,
|
||||
code,
|
||||
sequence: newSeq || 10,
|
||||
active: true,
|
||||
});
|
||||
toast({ title: `Section ${code} added` });
|
||||
setNewName("");
|
||||
setNewCode("");
|
||||
setNewSeq(10);
|
||||
refreshAll();
|
||||
} catch (e: unknown) {
|
||||
toast({
|
||||
title: "Add failed",
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
function startEdit(s: CourseSection) {
|
||||
setEditing(s);
|
||||
setEditName(s.name);
|
||||
setEditCode(s.code);
|
||||
setEditSeq(s.sequence || 10);
|
||||
setEditActive(s.active);
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editing) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await lmsService.updateCourseSection(course.id, editing.id, {
|
||||
name: editName.trim() || editing.name,
|
||||
code: editCode.trim().toUpperCase() || editing.code,
|
||||
sequence: editSeq || editing.sequence,
|
||||
active: editActive,
|
||||
});
|
||||
toast({ title: "Section updated" });
|
||||
setEditing(null);
|
||||
refreshAll();
|
||||
} catch (e: unknown) {
|
||||
toast({
|
||||
title: "Update failed",
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
async function handleDelete(s: CourseSection) {
|
||||
if ((s.batch_count ?? 0) > 0) {
|
||||
toast({
|
||||
title: "Cannot delete",
|
||||
description: `Section "${s.code}" has ${s.batch_count} batch(es). Reassign or remove them first.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Delete section "${s.name}" (${s.code})?`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await lmsService.deleteCourseSection(course.id, s.id);
|
||||
toast({ title: "Section deleted" });
|
||||
refreshAll();
|
||||
} catch (e: unknown) {
|
||||
toast({
|
||||
title: "Delete failed",
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[640px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Layers className="h-5 w-5" /> Sections — {course.title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Course sections (A, B, C…) are templates. Each section can be hosted by a classroom,
|
||||
which auto-creates a batch and enrolls the classroom roster.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Quick start</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create the standard <strong>A · B · C</strong> sections for this course in one click.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleGenerateDefaults}
|
||||
disabled={busy || sectionsQ.isLoading}
|
||||
variant="secondary"
|
||||
>
|
||||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
|
||||
Generate A / B / C
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Existing sections</Label>
|
||||
{sectionsQ.isLoading ? (
|
||||
<div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading…
|
||||
</div>
|
||||
) : sections.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-3">
|
||||
No sections yet. Add one below or click <em>Generate A / B / C</em>.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 border rounded-md divide-y">
|
||||
{sections.map((s) => (
|
||||
<div key={s.id} className="flex items-center justify-between gap-3 px-3 py-2">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{s.code}
|
||||
</Badge>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{s.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
seq {s.sequence}
|
||||
{s.batch_count != null && (
|
||||
<span className="ml-2">· {s.batch_count} batch(es)</span>
|
||||
)}
|
||||
{!s.active && <span className="ml-2 text-amber-600">· inactive</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title="Edit section"
|
||||
onClick={() => startEdit(s)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title="Delete section"
|
||||
onClick={() => handleDelete(s)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-3 space-y-3">
|
||||
<Label className="text-sm font-medium">Add a new section</Label>
|
||||
<div className="grid grid-cols-12 gap-2">
|
||||
<div className="col-span-3">
|
||||
<Label className="text-[11px] text-muted-foreground">Code *</Label>
|
||||
<Input
|
||||
placeholder="A"
|
||||
value={newCode}
|
||||
onChange={(e) => setNewCode(e.target.value)}
|
||||
maxLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-7">
|
||||
<Label className="text-[11px] text-muted-foreground">Name</Label>
|
||||
<Input
|
||||
placeholder="Section A"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-[11px] text-muted-foreground">Seq</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={newSeq}
|
||||
onChange={(e) => setNewSeq(Number(e.target.value) || 10)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleAdd} disabled={busy || !newCode.trim()}>
|
||||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Plus className="mr-2 h-4 w-4" />}
|
||||
Add section
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<Dialog open={!!editing} onOpenChange={(open) => !open && setEditing(null)}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Pencil className="h-4 w-4" /> Edit section
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label>Code</Label>
|
||||
<Input
|
||||
value={editCode}
|
||||
onChange={(e) => setEditCode(e.target.value)}
|
||||
maxLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Sequence</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editSeq}
|
||||
onChange={(e) => setEditSeq(Number(e.target.value) || 10)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Name</Label>
|
||||
<Input value={editName} onChange={(e) => setEditName(e.target.value)} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={editActive}
|
||||
onCheckedChange={(v) => setEditActive(Boolean(v))}
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={saveEdit} disabled={busy}>
|
||||
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminCourses() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
|
||||
const [sectionsCourse, setSectionsCourse] = useState<Course | null>(null);
|
||||
const [enrollOpen, setEnrollOpen] = useState(false);
|
||||
const [enrollCourseId, setEnrollCourseId] = useState<number | null>(null);
|
||||
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]);
|
||||
@@ -521,10 +864,11 @@ export default function AdminCourses() {
|
||||
<TableHead>Course</TableHead>
|
||||
<TableHead>Subject / Tags</TableHead>
|
||||
<TableHead>Difficulty</TableHead>
|
||||
<TableHead>Sections</TableHead>
|
||||
<TableHead>Chapters</TableHead>
|
||||
<TableHead>Enrolled / Cap</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-[150px]" />
|
||||
<TableHead className="w-[180px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -576,6 +920,36 @@ export default function AdminCourses() {
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSectionsCourse(c)}
|
||||
className="group flex items-center gap-1 text-xs hover:underline"
|
||||
title="Manage course sections (A/B/C…)"
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="font-medium">{c.section_count ?? c.sections?.length ?? 0}</span>
|
||||
<span className="text-muted-foreground">sections</span>
|
||||
{c.sections && c.sections.length > 0 && (
|
||||
<span className="ml-1 flex flex-wrap gap-0.5">
|
||||
{c.sections.slice(0, 4).map((s) => (
|
||||
<Badge
|
||||
key={s.id}
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-none"
|
||||
>
|
||||
{s.code || s.name}
|
||||
</Badge>
|
||||
))}
|
||||
{c.sections.length > 4 && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
+{c.sections.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
@@ -609,6 +983,14 @@ export default function AdminCourses() {
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Manage sections (A/B/C…)"
|
||||
onClick={() => setSectionsCourse(c)}
|
||||
>
|
||||
<Layers className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -635,7 +1017,7 @@ export default function AdminCourses() {
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground py-8">
|
||||
No courses found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -661,6 +1043,16 @@ export default function AdminCourses() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{sectionsCourse && (
|
||||
<CourseSectionsDialog
|
||||
open={!!sectionsCourse}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSectionsCourse(null);
|
||||
}}
|
||||
course={sectionsCourse}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={enrollOpen} onOpenChange={setEnrollOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function AdminGradebook() {
|
||||
const { data: coursesData } = useCourses({ size: 200 });
|
||||
const { data: subjectsData } = useSubjects();
|
||||
const coursesList = coursesData?.items ?? [];
|
||||
const subjectsList = Array.isArray(subjectsData) ? subjectsData : subjectsData?.data ?? subjectsData?.items ?? [];
|
||||
const subjectsList = Array.isArray(subjectsData) ? subjectsData : [];
|
||||
const createMutation = useCreateGradingAssignment();
|
||||
const updateMutation = useUpdateGradingAssignment();
|
||||
const deleteMutation = useDeleteGradingAssignment();
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function AdminLessons() {
|
||||
const { data: subjectsData } = useSubjects();
|
||||
const courses = coursesData?.items ?? [];
|
||||
const allBatches = batchesData?.items ?? [];
|
||||
const subjects = Array.isArray(subjectsData) ? subjectsData : subjectsData?.data ?? subjectsData?.items ?? [];
|
||||
const subjects = Array.isArray(subjectsData) ? subjectsData : [];
|
||||
const batchesForCourse = useMemo(
|
||||
() => (form.course_id ? allBatches.filter((b) => b.course_id === Number(form.course_id)) : allBatches),
|
||||
[allBatches, form.course_id],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Users, BookOpen, GraduationCap, Layers, Ticket, DollarSign, Plus, BarChart3, FolderOpen } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCourses, useBatches, useStudents, useTeachers } from "@/hooks/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
@@ -32,6 +33,7 @@ interface DashboardStats {
|
||||
}
|
||||
|
||||
export default function AdminLmsDashboard() {
|
||||
const { t } = useTranslation();
|
||||
const { data: coursesData, isLoading: lc } = useCourses();
|
||||
const { data: studentsData, isLoading: ls } = useStudents({ size: 500 });
|
||||
const { data: teachersData, isLoading: lt } = useTeachers({ size: 500 });
|
||||
@@ -39,9 +41,10 @@ export default function AdminLmsDashboard() {
|
||||
|
||||
const { data: dbStats } = useQuery<DashboardStats>({
|
||||
queryKey: ["dashboard", "stats"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<{ data: DashboardStats }>("/stats");
|
||||
return (res as { data: DashboardStats }).data ?? res;
|
||||
queryFn: async (): Promise<DashboardStats> => {
|
||||
const res = await api.get<{ data: DashboardStats } | DashboardStats>("/stats");
|
||||
const wrapped = res as { data?: DashboardStats };
|
||||
return wrapped.data ?? (res as DashboardStats);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
@@ -57,12 +60,12 @@ export default function AdminLmsDashboard() {
|
||||
const openTickets = dbStats?.open_tickets ?? 0;
|
||||
|
||||
const statCards = [
|
||||
{ label: "Total Students", value: String(students.length), icon: Users, color: "text-primary" },
|
||||
{ label: "Active Courses", value: `${courses.filter(c => c.status === "active").length} / ${courses.length}`, icon: BookOpen, color: "text-info" },
|
||||
{ label: "Teachers", value: String(teachers.length), icon: GraduationCap, color: "text-success" },
|
||||
{ label: "Active Batches", value: `${batches.filter(b => b.status === "active").length} / ${batches.length}`, icon: Layers, color: "text-warning" },
|
||||
{ label: "Open Tickets", value: String(openTickets), icon: Ticket, color: "text-destructive" },
|
||||
{ label: "Revenue", value: `$${revenue.toLocaleString()}`, icon: DollarSign, color: "text-success" },
|
||||
{ label: t("adminDash.totalStudents"), value: String(students.length), icon: Users, color: "text-primary" },
|
||||
{ label: t("adminDash.activeCourses"), value: `${courses.filter(c => c.status === "active").length} / ${courses.length}`, icon: BookOpen, color: "text-info" },
|
||||
{ label: t("adminDash.teachers"), value: String(teachers.length), icon: GraduationCap, color: "text-success" },
|
||||
{ label: t("adminDash.activeBatches"), value: `${batches.filter(b => b.status === "active").length} / ${batches.length}`, icon: Layers, color: "text-warning" },
|
||||
{ label: t("adminDash.openTickets"), value: String(openTickets), icon: Ticket, color: "text-destructive" },
|
||||
{ label: t("adminDash.revenue"), value: `$${revenue.toLocaleString()}`, icon: DollarSign, color: "text-success" },
|
||||
];
|
||||
|
||||
const courseChartData = courses.map(c => {
|
||||
@@ -81,12 +84,16 @@ export default function AdminLmsDashboard() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
|
||||
<p className="text-muted-foreground">Platform overview and key metrics.</p>
|
||||
<h1 className="text-2xl font-bold">{t("adminDash.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("adminDash.subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" asChild><Link to="/admin/students"><Plus className="mr-1 h-3 w-3" />Add Student</Link></Button>
|
||||
<Button asChild><Link to="/admin/courses"><Plus className="mr-1 h-3 w-3" />New Course</Link></Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/admin/students"><Plus className="me-1 h-3 w-3" />{t("adminDash.addStudent")}</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/admin/courses"><Plus className="me-1 h-3 w-3" />{t("adminDash.newCourse")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,7 +105,7 @@ export default function AdminLmsDashboard() {
|
||||
<Card key={s.label}>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<s.icon className={`h-5 w-5 ${s.color} mb-1`} />
|
||||
<p className="text-lg font-bold">{s.value}</p>
|
||||
<p className="text-lg font-bold"><bdi>{s.value}</bdi></p>
|
||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -107,16 +114,16 @@ export default function AdminLmsDashboard() {
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Departments", value: dbStats?.total_departments ?? 0, icon: FolderOpen },
|
||||
{ label: "Classrooms", value: dbStats?.total_classrooms ?? 0, icon: BarChart3 },
|
||||
{ label: "Subjects", value: dbStats?.total_subjects ?? 0, icon: BookOpen },
|
||||
{ label: "Resources", value: dbStats?.total_resources ?? 0, icon: Layers },
|
||||
{ label: t("adminDash.departments"), value: dbStats?.total_departments ?? 0, icon: FolderOpen },
|
||||
{ label: t("adminDash.classrooms"), value: dbStats?.total_classrooms ?? 0, icon: BarChart3 },
|
||||
{ label: t("adminDash.subjects"), value: dbStats?.total_subjects ?? 0, icon: BookOpen },
|
||||
{ label: t("adminDash.resources"), value: dbStats?.total_resources ?? 0, icon: Layers },
|
||||
].map(s => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="pt-3 pb-3 flex items-center gap-3">
|
||||
<s.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{s.value}</p>
|
||||
<s.icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold"><bdi>{s.value}</bdi></p>
|
||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -126,7 +133,7 @@ export default function AdminLmsDashboard() {
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Courses Overview</CardTitle></CardHeader>
|
||||
<CardHeader><CardTitle className="text-lg">{t("adminDash.coursesOverview")}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{courseChartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
@@ -135,18 +142,18 @@ export default function AdminLmsDashboard() {
|
||||
<XAxis dataKey="course" className="fill-muted-foreground" tick={{ fontSize: 10 }} />
|
||||
<YAxis className="fill-muted-foreground" tick={{ fontSize: 12 }} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="capacity" name="Capacity" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="enrolled" name="Enrolled" fill="hsl(var(--info))" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="capacity" name={t("adminDash.chartCapacity")} fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="enrolled" name={t("adminDash.chartEnrolled")} fill="hsl(var(--info))" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">No course data available.</p>
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">{t("adminDash.noCourseData")}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Batch Capacity</CardTitle></CardHeader>
|
||||
<CardHeader><CardTitle className="text-lg">{t("adminDash.batchCapacity")}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{batchChartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
@@ -155,11 +162,11 @@ export default function AdminLmsDashboard() {
|
||||
<XAxis dataKey="batch" className="fill-muted-foreground" tick={{ fontSize: 10 }} />
|
||||
<YAxis className="fill-muted-foreground" tick={{ fontSize: 12 }} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="capacity" name="Capacity" fill="hsl(var(--info))" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="capacity" name={t("adminDash.chartCapacity")} fill="hsl(var(--info))" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">No batch data available.</p>
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">{t("adminDash.noBatchData")}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -167,31 +174,43 @@ export default function AdminLmsDashboard() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-lg">Quick Summary</CardTitle>
|
||||
<CardTitle className="text-lg">{t("adminDash.quickSummary")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>Module</TableHead><TableHead className="text-right">Count</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("adminDash.colModule")}</TableHead>
|
||||
<TableHead className="text-end">{t("adminDash.colCount")}</TableHead>
|
||||
<TableHead>{t("adminDash.colStatus")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Exams</TableCell>
|
||||
<TableCell className="text-right">{dbStats?.total_exams ?? 0}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{dbStats?.total_exam_sessions ?? 0} sessions</TableCell>
|
||||
<TableCell className="font-medium">{t("adminDash.exams")}</TableCell>
|
||||
<TableCell className="text-end"><bdi>{dbStats?.total_exams ?? 0}</bdi></TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{t("adminDash.examSessionsCount", { n: dbStats?.total_exam_sessions ?? 0 })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Assignments</TableCell>
|
||||
<TableCell className="text-right">{dbStats?.total_assignments ?? 0}</TableCell>
|
||||
<TableCell className="text-muted-foreground">across courses</TableCell>
|
||||
<TableCell className="font-medium">{t("adminDash.assignments")}</TableCell>
|
||||
<TableCell className="text-end"><bdi>{dbStats?.total_assignments ?? 0}</bdi></TableCell>
|
||||
<TableCell className="text-muted-foreground">{t("adminDash.acrossCourses")}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Support Tickets</TableCell>
|
||||
<TableCell className="text-right">{dbStats?.total_tickets ?? 0}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{openTickets} open</TableCell>
|
||||
<TableCell className="font-medium">{t("adminDash.supportTickets")}</TableCell>
|
||||
<TableCell className="text-end"><bdi>{dbStats?.total_tickets ?? 0}</bdi></TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{t("adminDash.ticketsOpenCount", { n: openTickets })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Payments</TableCell>
|
||||
<TableCell className="text-right">{dbStats?.total_payments ?? 0}</TableCell>
|
||||
<TableCell className="text-muted-foreground">${revenue.toLocaleString()} total</TableCell>
|
||||
<TableCell className="font-medium">{t("adminDash.payments")}</TableCell>
|
||||
<TableCell className="text-end"><bdi>{dbStats?.total_payments ?? 0}</bdi></TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{t("adminDash.revenueTotal", { amount: `$${revenue.toLocaleString()}` })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
165
src/pages/admin/AdminQuickSetup.tsx
Normal file
165
src/pages/admin/AdminQuickSetup.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
BookOpen,
|
||||
Layers,
|
||||
Wand2,
|
||||
GitBranch,
|
||||
ClipboardList,
|
||||
FolderOpen,
|
||||
Users,
|
||||
GraduationCap,
|
||||
FileText,
|
||||
Ticket,
|
||||
Building2,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import { QuickSetupWizard, type WizardStep, type QuickCreate } from "@/components/QuickSetupWizard";
|
||||
import { examsService } from "@/services/exams.service";
|
||||
import { assignmentsService } from "@/services/assignments.service";
|
||||
|
||||
/**
|
||||
* Admin smart-setup wizard.
|
||||
*
|
||||
* Surfaces the recommended order for standing up an exam-ready platform:
|
||||
* rubric → structure → generate → approve → assign. Each step deep-links
|
||||
* into the existing creation page and auto-ticks when the backend confirms
|
||||
* at least one record exists, so coming back here after a sub-task shows
|
||||
* visible progress.
|
||||
*
|
||||
* "Other quick creates" covers the most common ad-hoc actions (new course,
|
||||
* upload resource, add user, etc.) so admins can jump straight in without
|
||||
* hunting through the sidebar.
|
||||
*/
|
||||
|
||||
const steps: WizardStep[] = [
|
||||
{
|
||||
id: "rubric",
|
||||
titleKey: "quickSetup.admin.step1.title",
|
||||
descriptionKey: "quickSetup.admin.step1.description",
|
||||
helpKey: "quickSetup.admin.step1.help",
|
||||
to: "/admin/rubrics",
|
||||
icon: BookOpen,
|
||||
check: async () => {
|
||||
const res = await examsService.listRubrics({ page: 1, size: 1 });
|
||||
return (res.items?.length ?? 0) > 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "structure",
|
||||
titleKey: "quickSetup.admin.step2.title",
|
||||
descriptionKey: "quickSetup.admin.step2.description",
|
||||
helpKey: "quickSetup.admin.step2.help",
|
||||
to: "/admin/exam-structures",
|
||||
icon: Layers,
|
||||
check: async () => {
|
||||
const res = await examsService.listStructures({ page: 1, size: 1 });
|
||||
return (res.items?.length ?? 0) > 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "generate",
|
||||
titleKey: "quickSetup.admin.step3.title",
|
||||
descriptionKey: "quickSetup.admin.step3.description",
|
||||
helpKey: "quickSetup.admin.step3.help",
|
||||
to: "/admin/generation",
|
||||
icon: Wand2,
|
||||
// "Is there at least one exam of any module?" — cheapest existence
|
||||
// check; the writing module is a common default.
|
||||
check: async () => {
|
||||
const res = await examsService.list("writing", { page: 1, size: 1 });
|
||||
return (res.items?.length ?? 0) > 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "approve",
|
||||
titleKey: "quickSetup.admin.step4.title",
|
||||
descriptionKey: "quickSetup.admin.step4.description",
|
||||
helpKey: "quickSetup.admin.step4.help",
|
||||
to: "/admin/approval-workflows",
|
||||
icon: GitBranch,
|
||||
// No cheap "any workflow exists" endpoint — leave this one uncheckable
|
||||
// so it stays neutral and always visible in the flow.
|
||||
},
|
||||
{
|
||||
id: "assign",
|
||||
titleKey: "quickSetup.admin.step5.title",
|
||||
descriptionKey: "quickSetup.admin.step5.description",
|
||||
helpKey: "quickSetup.admin.step5.help",
|
||||
to: "/admin/assignments",
|
||||
icon: ClipboardList,
|
||||
check: async () => {
|
||||
const res = await assignmentsService.listSchedules({ page: 1, size: 1 });
|
||||
return (res.items?.length ?? 0) > 0;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const quickCreates: QuickCreate[] = [
|
||||
{
|
||||
id: "course",
|
||||
titleKey: "quickSetup.admin.quick.course.title",
|
||||
descriptionKey: "quickSetup.admin.quick.course.description",
|
||||
to: "/admin/courses",
|
||||
icon: BookOpen,
|
||||
},
|
||||
{
|
||||
id: "resource",
|
||||
titleKey: "quickSetup.admin.quick.resource.title",
|
||||
descriptionKey: "quickSetup.admin.quick.resource.description",
|
||||
to: "/admin/resources",
|
||||
icon: FolderOpen,
|
||||
},
|
||||
{
|
||||
id: "student",
|
||||
titleKey: "quickSetup.admin.quick.student.title",
|
||||
descriptionKey: "quickSetup.admin.quick.student.description",
|
||||
to: "/admin/students",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
id: "teacher",
|
||||
titleKey: "quickSetup.admin.quick.teacher.title",
|
||||
descriptionKey: "quickSetup.admin.quick.teacher.description",
|
||||
to: "/admin/teachers",
|
||||
icon: GraduationCap,
|
||||
},
|
||||
{
|
||||
id: "classroom",
|
||||
titleKey: "quickSetup.admin.quick.classroom.title",
|
||||
descriptionKey: "quickSetup.admin.quick.classroom.description",
|
||||
to: "/admin/classrooms",
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
id: "exam-session",
|
||||
titleKey: "quickSetup.admin.quick.examSession.title",
|
||||
descriptionKey: "quickSetup.admin.quick.examSession.description",
|
||||
to: "/admin/exam-sessions",
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
id: "custom-exam",
|
||||
titleKey: "quickSetup.admin.quick.customExam.title",
|
||||
descriptionKey: "quickSetup.admin.quick.customExam.description",
|
||||
to: "/admin/exams/new",
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
id: "ticket",
|
||||
titleKey: "quickSetup.admin.quick.ticket.title",
|
||||
descriptionKey: "quickSetup.admin.quick.ticket.description",
|
||||
to: "/admin/tickets",
|
||||
icon: Ticket,
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminQuickSetup() {
|
||||
return (
|
||||
<QuickSetupWizard
|
||||
titleKey="quickSetup.adminTitle"
|
||||
subtitleKey="quickSetup.adminSubtitle"
|
||||
steps={steps}
|
||||
quickCreates={quickCreates}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export default function AdminTeachers() {
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [gender, setGender] = useState("male");
|
||||
const [gender, setGender] = useState<"male" | "female">("male");
|
||||
const [specialization, setSpecialization] = useState("");
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
@@ -157,7 +157,7 @@ export default function AdminTeachers() {
|
||||
<div className="space-y-2"><Label>Email *</Label><Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
|
||||
<div className="space-y-2">
|
||||
<Label>Gender</Label>
|
||||
<Select value={gender} onValueChange={setGender}>
|
||||
<Select value={gender} onValueChange={(v) => setGender(v as "male" | "female")}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="male">Male</SelectItem>
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function AdmissionDetail() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/admissions")}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<ArrowLeft className="h-4 w-4 rtl:rotate-180" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold">{admission.first_name} {admission.last_name}</h1>
|
||||
|
||||
@@ -6,19 +6,12 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Plus, Trash2, Loader2, ChevronRight, Settings2, ArrowRight } from "lucide-react";
|
||||
import { approvalsService, type ApprovalWorkflow } from "@/services/approvals.service";
|
||||
import { Plus, Trash2, Loader2, Settings2, ArrowRight } from "lucide-react";
|
||||
import { approvalsService, type ApprovalWorkflow, type ApprovalStage } from "@/services/approvals.service";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
interface WorkflowStage {
|
||||
order: number;
|
||||
approver_id: number;
|
||||
approver_name: string;
|
||||
max_days?: number;
|
||||
notification_email?: string;
|
||||
auto_escalate?: boolean;
|
||||
}
|
||||
type WorkflowStage = Omit<ApprovalStage, "order"> & { order: number };
|
||||
|
||||
export default function ApprovalWorkflowConfig() {
|
||||
const { toast } = useToast();
|
||||
@@ -28,16 +21,21 @@ export default function ApprovalWorkflowConfig() {
|
||||
queryKey: ["approvals", "list"],
|
||||
queryFn: () => approvalsService.list(),
|
||||
});
|
||||
const workflows = workflowsData?.results ?? [];
|
||||
const workflows: ApprovalWorkflow[] = workflowsData?.results ?? workflowsData?.items ?? [];
|
||||
|
||||
type WorkflowWriteInput = Partial<ApprovalWorkflow> & {
|
||||
steps?: Partial<ApprovalStage>[];
|
||||
bypass_enabled?: boolean;
|
||||
};
|
||||
|
||||
const createWorkflow = useMutation({
|
||||
mutationFn: (data: Partial<ApprovalWorkflow>) => approvalsService.create(data),
|
||||
mutationFn: (data: WorkflowWriteInput) => approvalsService.create(data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["approvals"] }),
|
||||
onError: () => toast({ title: "Error", description: "Failed to create workflow", variant: "destructive" }),
|
||||
});
|
||||
|
||||
const updateWorkflow = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<ApprovalWorkflow> }) => approvalsService.update(id, data),
|
||||
mutationFn: ({ id, data }: { id: number; data: WorkflowWriteInput }) => approvalsService.update(id, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["approvals"] }),
|
||||
onError: () => toast({ title: "Error", description: "Failed to update workflow", variant: "destructive" }),
|
||||
});
|
||||
@@ -72,8 +70,26 @@ export default function ApprovalWorkflowConfig() {
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
const valid = stages.filter(s => s.approver_id > 0);
|
||||
if (valid.length === 0) {
|
||||
toast({ title: "Validation", description: "Add at least one stage with an approver ID", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
createWorkflow.mutate(
|
||||
{ name: wfName, steps: stages.map(s => ({ order: s.order, approver_id: s.approver_id, approver_name: s.approver_name })), status: "draft" } as Partial<ApprovalWorkflow>,
|
||||
{
|
||||
name: wfName,
|
||||
status: "draft",
|
||||
allow_bypass: bypassEnabled,
|
||||
bypass_enabled: bypassEnabled,
|
||||
steps: valid.map(s => ({
|
||||
order: s.order,
|
||||
approver_id: s.approver_id,
|
||||
approver_name: s.approver_name,
|
||||
max_days: s.max_days,
|
||||
auto_escalate: s.auto_escalate,
|
||||
notification_email: s.notification_email,
|
||||
})),
|
||||
},
|
||||
{ onSuccess: () => { toast({ title: "Workflow Created" }); setShowCreate(false); resetForm(); } },
|
||||
);
|
||||
};
|
||||
@@ -200,7 +216,7 @@ export default function ApprovalWorkflowConfig() {
|
||||
<span className="h-5 w-5 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium">{step.order}</span>
|
||||
<span>{step.approver_name}</span>
|
||||
</div>
|
||||
{i < wf.steps.length - 1 && <ArrowRight className="h-4 w-4 text-muted-foreground" />}
|
||||
{i < wf.steps.length - 1 && <ArrowRight className="h-4 w-4 text-muted-foreground rtl:rotate-180" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useCreateCustomExam, useSaveAsTemplate } from "@/hooks/queries/useExamTemplates";
|
||||
import { describeApiError } from "@/lib/api-client";
|
||||
import { useSubjects } from "@/hooks/queries/useAdaptive";
|
||||
import type { CustomExamCreateRequest, CustomScoringMethod } from "@/types";
|
||||
import { toast } from "sonner";
|
||||
@@ -114,7 +115,11 @@ export default function CustomExamCreate() {
|
||||
total_time_min: v.total_time_min,
|
||||
pass_threshold: v.pass_threshold,
|
||||
results_release_mode: v.results_release_mode,
|
||||
// Backend model field is `randomize_questions`; the controller reads both
|
||||
// `randomize` and `randomize_questions`, but prefer the real field name
|
||||
// so future serialisation roundtrips don't silently drop the flag.
|
||||
randomize: v.randomize,
|
||||
randomize_questions: v.randomize,
|
||||
sections: v.sections.map((sec, i) => ({
|
||||
title: sec.title,
|
||||
skill: sec.skill,
|
||||
@@ -124,26 +129,61 @@ export default function CustomExamCreate() {
|
||||
sequence: i,
|
||||
question_ids: sec.question_ids,
|
||||
})),
|
||||
};
|
||||
} as CustomExamCreateRequest;
|
||||
};
|
||||
|
||||
// Extract the id the backend actually returns. `POST /api/exam/custom/create`
|
||||
// returns the serialised exam dict with `id`, not `exam_id`, so the old
|
||||
// `raw.exam_id` read was always undefined (preventing navigation after publish).
|
||||
const pickExamId = (res: unknown): number | undefined => {
|
||||
const raw = res as { id?: number; exam_id?: number; data?: { id?: number; exam_id?: number } };
|
||||
return raw?.id ?? raw?.exam_id ?? raw?.data?.id ?? raw?.data?.exam_id;
|
||||
};
|
||||
|
||||
// Thin wrapper around the shared helper so the existing call-sites below
|
||||
// keep working; the helper lives in api-client so every page gets the same
|
||||
// actionable 413/504/401 messaging.
|
||||
const describeError = (err: unknown, fallback: string): string =>
|
||||
describeApiError(err, fallback, "exam");
|
||||
|
||||
const onSaveDraft = async () => {
|
||||
// Validate everything before firing the request — previously the Save
|
||||
// button POSTed with whatever was in the form and silently swallowed the
|
||||
// 400/500 as a generic toast.
|
||||
const ok = await form.trigger();
|
||||
if (!ok) {
|
||||
toast.error("Please fix the highlighted fields before saving.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
await createExam.mutateAsync(payload);
|
||||
toast.success("Draft saved.");
|
||||
} catch {
|
||||
toast.error("Could not save.");
|
||||
} catch (err) {
|
||||
toast.error(describeError(err, "Could not save draft"));
|
||||
}
|
||||
};
|
||||
|
||||
const onPublish = async () => {
|
||||
const ok = await form.trigger();
|
||||
if (!ok) {
|
||||
toast.error("Please fix the highlighted fields before publishing.");
|
||||
return;
|
||||
}
|
||||
let examId: number | undefined;
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
const res = await createExam.mutateAsync(payload);
|
||||
const raw = res as { exam_id?: number; data?: { exam_id?: number } };
|
||||
const id = raw.exam_id ?? raw.data?.exam_id;
|
||||
if (form.getValues("save_as_template")) {
|
||||
examId = pickExamId(res);
|
||||
} catch (err) {
|
||||
toast.error(describeError(err, "Publish failed"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Template save is additive — if it fails, the exam has already been
|
||||
// created. Don't show a scary "Publish failed" toast for a template issue.
|
||||
if (form.getValues("save_as_template")) {
|
||||
try {
|
||||
const name = form.getValues("template_name")?.trim() || form.getValues("title");
|
||||
await saveTemplate.mutateAsync({
|
||||
name,
|
||||
@@ -151,12 +191,13 @@ export default function CustomExamCreate() {
|
||||
editable: true,
|
||||
type: "custom",
|
||||
});
|
||||
} catch (err) {
|
||||
toast.warning(describeError(err, "Exam saved, but template save failed"));
|
||||
}
|
||||
toast.success("Exam published.");
|
||||
if (id != null) navigate(`/admin/exam`);
|
||||
} catch {
|
||||
toast.error("Publish failed.");
|
||||
}
|
||||
|
||||
toast.success("Exam published.");
|
||||
if (examId != null) navigate(`/admin/exam`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
341
src/pages/admin/ExamReviewDetail.tsx
Normal file
341
src/pages/admin/ExamReviewDetail.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
|
||||
import {
|
||||
useApproveExamReview,
|
||||
useExamReviewDetail,
|
||||
useRejectExamReview,
|
||||
} from "@/hooks/queries/useExamReview";
|
||||
import type { ExamReviewQuestion } from "@/types/exam-review";
|
||||
|
||||
const FAILING_THRESHOLD = 0.7;
|
||||
|
||||
function QuestionCard({ q, index }: { q: ExamReviewQuestion; index: number }) {
|
||||
const failing = (q.quality_score ?? 0) < FAILING_THRESHOLD;
|
||||
return (
|
||||
<Card className={failing ? "border-destructive/40" : undefined}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
|
||||
<span className="font-mono">Q{index + 1}</span>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{q.skill}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{q.question_type}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{q.difficulty}
|
||||
</Badge>
|
||||
{q.ai_generated && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
AI
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-sm leading-snug">
|
||||
{q.stem.length > 220 ? `${q.stem.slice(0, 220)}…` : q.stem}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
(q.quality_score ?? 0) >= 0.85
|
||||
? "default"
|
||||
: failing
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
className="tabular-nums shrink-0"
|
||||
>
|
||||
{((q.quality_score ?? 0) * 100).toFixed(0)}%
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{q.quality_report && Object.keys(q.quality_report).length > 0 && (
|
||||
<CardContent>
|
||||
<Label className="text-xs text-muted-foreground">Quality report</Label>
|
||||
<pre className="mt-1 rounded-md bg-muted/50 p-3 text-xs overflow-x-auto">
|
||||
{JSON.stringify(q.quality_report, null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExamReviewDetail() {
|
||||
const { examId } = useParams<{ examId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const parsedId = examId ? Number(examId) : undefined;
|
||||
|
||||
const { data: exam, isLoading } = useExamReviewDetail(parsedId);
|
||||
const approve = useApproveExamReview();
|
||||
const reject = useRejectExamReview();
|
||||
|
||||
const [approveNotes, setApproveNotes] = useState("");
|
||||
const [rejectNotes, setRejectNotes] = useState("");
|
||||
const [approveOpen, setApproveOpen] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
|
||||
if (isLoading || !exam || !parsedId) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleApprove = async () => {
|
||||
try {
|
||||
await approve.mutateAsync({ examId: parsedId, notes: approveNotes });
|
||||
toast.success("Exam approved and published.");
|
||||
setApproveOpen(false);
|
||||
navigate("/admin/exam/review-queue");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Approve failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!rejectNotes.trim()) {
|
||||
toast.error("Rejection notes are required.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await reject.mutateAsync({ examId: parsedId, notes: rejectNotes });
|
||||
toast.success("Exam rejected and returned to draft.");
|
||||
setRejectOpen(false);
|
||||
navigate("/admin/exam/review-queue");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Reject failed");
|
||||
}
|
||||
};
|
||||
|
||||
const summary = exam.summary;
|
||||
const alreadyReviewed = exam.status !== "pending_review";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Button asChild variant="ghost" size="sm" className="-ml-2">
|
||||
<Link to="/admin/exam/review-queue">
|
||||
<ArrowLeft className="h-4 w-4 me-1 rtl:rotate-180" /> Back to queue
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{exam.title}</h1>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{exam.status.replace("_", " ")}
|
||||
</Badge>
|
||||
{exam.subject_name && <span>· {exam.subject_name}</span>}
|
||||
{exam.teacher_name && <span>· Created by {exam.teacher_name}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!alreadyReviewed && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Dialog open={rejectOpen} onOpenChange={setRejectOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="gap-1">
|
||||
<XCircle className="h-4 w-4" /> Reject
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reject this exam?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reject-notes">
|
||||
Feedback for the author (required)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="reject-notes"
|
||||
value={rejectNotes}
|
||||
onChange={(e) => setRejectNotes(e.target.value)}
|
||||
rows={6}
|
||||
placeholder="Explain what needs to change before this exam can be published."
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setRejectOpen(false)}
|
||||
disabled={reject.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleReject}
|
||||
disabled={reject.isPending || !rejectNotes.trim()}
|
||||
>
|
||||
Reject & return to draft
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={approveOpen} onOpenChange={setApproveOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-1">
|
||||
<CheckCircle2 className="h-4 w-4" /> Approve & publish
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Approve and publish?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="approve-notes">Notes (optional)</Label>
|
||||
<Textarea
|
||||
id="approve-notes"
|
||||
value={approveNotes}
|
||||
onChange={(e) => setApproveNotes(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Any comments or context for the audit log."
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setApproveOpen(false)}
|
||||
disabled={approve.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApprove}
|
||||
disabled={approve.isPending}
|
||||
>
|
||||
Publish exam
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{alreadyReviewed && exam.reviewed_by_name && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Previously reviewed</CardTitle>
|
||||
<CardDescription>
|
||||
{exam.reviewed_by_name}
|
||||
{exam.reviewed_at ? ` · ${new Date(exam.reviewed_at).toLocaleString()}` : ""}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{exam.review_notes && (
|
||||
<CardContent className="text-sm whitespace-pre-wrap">
|
||||
{exam.review_notes}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Quality summary</CardTitle>
|
||||
<CardDescription>
|
||||
Aggregated across every question in this exam.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-muted-foreground">Questions</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">
|
||||
{summary.question_count}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Average quality</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">
|
||||
{(summary.avg_quality_score * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Lowest score</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">
|
||||
{(summary.min_quality_score * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" /> Failing
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tabular-nums text-destructive">
|
||||
{summary.failing_count}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
{exam.sections.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This exam has no sections yet.
|
||||
</p>
|
||||
) : (
|
||||
exam.sections.map((section) => (
|
||||
<section key={section.id} className="space-y-3">
|
||||
<h2 className="text-base font-semibold flex items-center gap-2">
|
||||
<span>{section.title}</span>
|
||||
<Badge variant="outline" className="capitalize text-xs">
|
||||
{section.skill || "general"}
|
||||
</Badge>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
· {section.questions.length} questions
|
||||
</span>
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{section.questions.map((q, idx) => (
|
||||
<QuestionCard key={q.id} q={q} index={idx} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
src/pages/admin/ExamReviewQueue.tsx
Normal file
205
src/pages/admin/ExamReviewQueue.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Search,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
import { useExamReviewQueue } from "@/hooks/queries/useExamReview";
|
||||
|
||||
/**
|
||||
* Admin-facing queue of AI-generated exams that failed the automated quality
|
||||
* gate and need a human to sign off before they can be published to students.
|
||||
*
|
||||
* The list intentionally stays dense — each row shows only the signals an
|
||||
* operator needs to prioritise (quality score, failing-question count, AI
|
||||
* model used). Clicking through to the detail page reveals the full report.
|
||||
*/
|
||||
export default function ExamReviewQueue() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
const { data, isLoading } = useExamReviewQueue({
|
||||
page,
|
||||
size: pageSize,
|
||||
search: search || undefined,
|
||||
status: "pending_review",
|
||||
});
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Exam Review Queue</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
AI-generated exams that failed the automated quality gate. Review
|
||||
each one, then approve to publish or reject back to draft.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{total} awaiting review
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by exam title…"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setSearch(e.target.value);
|
||||
}}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="p-6 space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-10 text-center">
|
||||
<CheckCircle2 className="mx-auto h-10 w-10 text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nothing in the review queue. Any time an AI-generated exam
|
||||
fails the quality gate, it will show up here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[32%]">Exam</TableHead>
|
||||
<TableHead>Subject</TableHead>
|
||||
<TableHead className="text-center">Questions</TableHead>
|
||||
<TableHead className="text-center">Avg. Quality</TableHead>
|
||||
<TableHead className="text-center">Failing</TableHead>
|
||||
<TableHead className="text-right">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((exam) => {
|
||||
const avg = exam.summary.avg_quality_score;
|
||||
const failing = exam.summary.failing_count;
|
||||
return (
|
||||
<TableRow key={exam.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-start gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to={`/admin/exam/review/${exam.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{exam.title}
|
||||
</Link>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{exam.summary.ai_generated_count} AI-generated ·{" "}
|
||||
{exam.teacher_name || "Unassigned"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{exam.subject_name || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{exam.summary.question_count}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant={
|
||||
avg >= 0.85
|
||||
? "default"
|
||||
: avg >= 0.7
|
||||
? "secondary"
|
||||
: "destructive"
|
||||
}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{(avg * 100).toFixed(0)}%
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{failing > 0 ? (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{failing}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link to={`/admin/exam/review/${exam.id}`}>Review</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{pageCount > 1 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Page {page} of {pageCount} · {total} total
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= pageCount}
|
||||
onClick={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useIeltsValidation, usePublishIeltsExam, useAssignIeltsExam } from "@/hooks/queries/useExamTemplates";
|
||||
import { useIeltsExamValidation, usePublishIeltsExam, useAssignIeltsExam } from "@/hooks/queries/useExamTemplates";
|
||||
import { useStudents, useBatches } from "@/hooks/queries/useLms";
|
||||
import { ieltsExamService } from "@/services/ielts-exam.service";
|
||||
import type { ExamValidationReport, ValidationCheck } from "@/types";
|
||||
@@ -40,7 +40,7 @@ export default function IeltsExamValidation() {
|
||||
const navigate = useNavigate();
|
||||
const examId = Number(examIdParam);
|
||||
|
||||
const validationQ = useIeltsValidation(Number.isFinite(examId) ? examId : 0);
|
||||
const validationQ = useIeltsExamValidation(Number.isFinite(examId) ? examId : 0);
|
||||
const publishMut = usePublishIeltsExam();
|
||||
const assignMut = useAssignIeltsExam();
|
||||
|
||||
|
||||
@@ -11,12 +11,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Upload, Search, FileText, Video, Link2, Download, Trash2,
|
||||
CheckCircle2, Clock, XCircle, Loader2, BookOpen, Music, Image,
|
||||
Tag, Plus, Pencil, X, CalendarDays,
|
||||
Tag, Plus, Pencil, X, CalendarDays, Eye,
|
||||
} from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { resourcesService } from "@/services/resources.service";
|
||||
import { coursewareService } from "@/services/courseware.service";
|
||||
import { taxonomyService } from "@/services/taxonomy.service";
|
||||
import { describeApiError, withAuthQuery } from "@/lib/api-client";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { TaxonomyCascade } from "@/components/TaxonomyCascade";
|
||||
import type { Resource, ResourceTag } from "@/types";
|
||||
@@ -164,19 +165,19 @@ function EditResourceDialog({
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pdf">PDF</SelectItem>
|
||||
<SelectItem value="video">Video</SelectItem>
|
||||
<SelectItem value="link">Link</SelectItem>
|
||||
<SelectItem value="document">Document</SelectItem>
|
||||
<SelectItem value="interactive">Interactive</SelectItem>
|
||||
<SelectItem value="audio">Audio</SelectItem>
|
||||
<SelectItem value="image">Image</SelectItem>
|
||||
<SelectItem value="audio">Audio</SelectItem>
|
||||
<SelectItem value="video">Video</SelectItem>
|
||||
<SelectItem value="document">Document</SelectItem>
|
||||
<SelectItem value="article">Article</SelectItem>
|
||||
<SelectItem value="link">Link</SelectItem>
|
||||
<SelectItem value="interactive">Interactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Status</Label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as "pending" | "approved" | "rejected")}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
@@ -274,7 +275,17 @@ export default function ResourceManager() {
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (formData: FormData) => resourcesService.upload(formData),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Resource uploaded" }); setShowUpload(false); setUploadTagIds([]); setUploadSubjectId("none"); setUploadDomainId("none"); setUploadTopicIds([]); setUploadObjectiveIds([]); },
|
||||
onError: () => toast({ title: "Error", description: "Upload failed", variant: "destructive" }),
|
||||
onError: (err) => {
|
||||
// describeApiError surfaces status code + actionable next step so QA /
|
||||
// ops can tell 413 (payload limit) from 504 (timeout) from 401 (session
|
||||
// expired) without opening DevTools. Default "Upload failed" was
|
||||
// reported as non-actionable during the Apr-2026 deployment.
|
||||
toast({
|
||||
title: "Upload failed",
|
||||
description: describeApiError(err, "Upload failed", "file"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const createTagMutation = useMutation({
|
||||
@@ -340,12 +351,19 @@ export default function ResourceManager() {
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pdf">PDF</SelectItem>
|
||||
<SelectItem value="image">Image</SelectItem>
|
||||
<SelectItem value="audio">Audio</SelectItem>
|
||||
<SelectItem value="video">Video</SelectItem>
|
||||
<SelectItem value="link">Link</SelectItem>
|
||||
<SelectItem value="document">Document</SelectItem>
|
||||
<SelectItem value="article">Article</SelectItem>
|
||||
<SelectItem value="link">Link</SelectItem>
|
||||
<SelectItem value="interactive">Interactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Tip: leave this on "PDF" — the server auto-detects the
|
||||
actual type from the uploaded file's MIME and corrects it.
|
||||
</p>
|
||||
</div>
|
||||
<TaxonomyCascade
|
||||
subjectId={uploadSubjectId} onSubjectChange={setUploadSubjectId}
|
||||
@@ -608,12 +626,46 @@ function ContentTable({
|
||||
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">{formatDate(row.createdAt)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{row.source === "resource" && row.resourceId && (
|
||||
{row.source === "resource" && row.resourceId && row.raw && (
|
||||
<>
|
||||
{/* Smart preview — opens the file inline (PDF in
|
||||
iframe, image / audio / video in their native
|
||||
tag) without forcing a download. ``link`` rows
|
||||
jump to the external URL directly. */}
|
||||
{(row.raw.preview_url || row.raw.url) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Preview"
|
||||
onClick={() => {
|
||||
const target =
|
||||
row.raw?.preview_url
|
||||
? withAuthQuery(row.raw.preview_url)
|
||||
: row.raw?.url || "";
|
||||
if (target) window.open(target, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" title="Edit" onClick={() => row.raw && onEditResource(row.raw)}><Pencil className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="icon" title="Download" onClick={async () => {
|
||||
try { const blob = await resourcesService.download(row.resourceId!); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = row.name || "resource"; a.click(); URL.revokeObjectURL(url); } catch { toast({ title: "Download failed", variant: "destructive" }); }
|
||||
}}><Download className="h-4 w-4" /></Button>
|
||||
{row.raw.has_file && (
|
||||
<Button variant="ghost" size="icon" title="Download" onClick={async () => {
|
||||
try {
|
||||
const { blob, filename } = await resourcesService.download(row.resourceId!);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
// Prefer the server-provided filename (which always
|
||||
// carries the extension) over ``row.name``, which
|
||||
// is often just "test" — the previous behaviour
|
||||
// saved files with no extension on disk.
|
||||
a.download = filename || row.raw?.original_filename || row.raw?.file_name || row.name || "resource";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch { toast({ title: "Download failed", variant: "destructive" }); }
|
||||
}}><Download className="h-4 w-4" /></Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" title="Delete" onClick={() => onDeleteResource(row.resourceId!)} disabled={deletePending}><Trash2 className="h-4 w-4 text-destructive" /></Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
215
src/pages/admin/SmartWizardHub.tsx
Normal file
215
src/pages/admin/SmartWizardHub.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BookOpen,
|
||||
Layers,
|
||||
Wand2,
|
||||
GitBranch,
|
||||
ClipboardList,
|
||||
FolderOpen,
|
||||
Users,
|
||||
GraduationCap,
|
||||
Sparkles,
|
||||
Compass,
|
||||
ChevronRight,
|
||||
ArrowRight,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
/**
|
||||
* Smart Wizard Hub.
|
||||
*
|
||||
* Landing page that groups every step-by-step wizard in a single place.
|
||||
* Admins pick a scenario → land in the inline Next/Next/Finish flow. The
|
||||
* page also exposes "Advanced" deep links to the full creation pages for
|
||||
* power users who don't need hand-holding.
|
||||
*/
|
||||
|
||||
interface WizardCard {
|
||||
id: string;
|
||||
to: string;
|
||||
icon: LucideIcon;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
badgeKey?: string;
|
||||
/** When true, deep-links to the full creation page instead of a wizard. */
|
||||
advanced?: boolean;
|
||||
}
|
||||
|
||||
const guidedWizards: WizardCard[] = [
|
||||
{
|
||||
id: "rubric",
|
||||
to: "/admin/smart-wizard/rubric",
|
||||
icon: BookOpen,
|
||||
titleKey: "wizardHub.cards.rubric.title",
|
||||
descriptionKey: "wizardHub.cards.rubric.description",
|
||||
},
|
||||
{
|
||||
id: "exam-structure",
|
||||
to: "/admin/smart-wizard/exam-structure",
|
||||
icon: Layers,
|
||||
titleKey: "wizardHub.cards.examStructure.title",
|
||||
descriptionKey: "wizardHub.cards.examStructure.description",
|
||||
},
|
||||
{
|
||||
id: "course",
|
||||
to: "/admin/smart-wizard/course",
|
||||
icon: GraduationCap,
|
||||
titleKey: "wizardHub.cards.course.title",
|
||||
descriptionKey: "wizardHub.cards.course.description",
|
||||
},
|
||||
{
|
||||
id: "course-plan",
|
||||
to: "/admin/smart-wizard/course-plan",
|
||||
icon: Compass,
|
||||
titleKey: "wizardHub.cards.coursePlan.title",
|
||||
descriptionKey: "wizardHub.cards.coursePlan.description",
|
||||
badgeKey: "wizardHub.aiBadge",
|
||||
},
|
||||
];
|
||||
|
||||
const advancedLinks: WizardCard[] = [
|
||||
{
|
||||
id: "generation",
|
||||
to: "/admin/generation",
|
||||
icon: Wand2,
|
||||
titleKey: "wizardHub.cards.generation.title",
|
||||
descriptionKey: "wizardHub.cards.generation.description",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
id: "approval",
|
||||
to: "/admin/approval-workflows",
|
||||
icon: GitBranch,
|
||||
titleKey: "wizardHub.cards.approval.title",
|
||||
descriptionKey: "wizardHub.cards.approval.description",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
id: "assign",
|
||||
to: "/admin/assignments",
|
||||
icon: ClipboardList,
|
||||
titleKey: "wizardHub.cards.assign.title",
|
||||
descriptionKey: "wizardHub.cards.assign.description",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
id: "resource",
|
||||
to: "/admin/resources",
|
||||
icon: FolderOpen,
|
||||
titleKey: "wizardHub.cards.resource.title",
|
||||
descriptionKey: "wizardHub.cards.resource.description",
|
||||
advanced: true,
|
||||
},
|
||||
{
|
||||
id: "student",
|
||||
to: "/admin/students",
|
||||
icon: Users,
|
||||
titleKey: "wizardHub.cards.student.title",
|
||||
descriptionKey: "wizardHub.cards.student.description",
|
||||
advanced: true,
|
||||
},
|
||||
];
|
||||
|
||||
export default function SmartWizardHub() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t("wizardHub.title")}</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground max-w-3xl">{t("wizardHub.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{/* Recommended order hint */}
|
||||
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||||
<div className="font-medium mb-1">{t("wizardHub.recommendedOrder")}</div>
|
||||
<ol className="list-decimal list-inside text-muted-foreground space-y-0.5">
|
||||
<li>{t("wizardHub.order.rubric")}</li>
|
||||
<li>{t("wizardHub.order.structure")}</li>
|
||||
<li>{t("wizardHub.order.generate")}</li>
|
||||
<li>{t("wizardHub.order.approve")}</li>
|
||||
<li>{t("wizardHub.order.assign")}</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Guided wizards */}
|
||||
<section aria-labelledby="wizard-hub-guided">
|
||||
<h2 id="wizard-hub-guided" className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3">
|
||||
{t("wizardHub.guided")}
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{guidedWizards.map((card) => (
|
||||
<WizardCardView key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Advanced deep links */}
|
||||
<section aria-labelledby="wizard-hub-advanced">
|
||||
<h2 id="wizard-hub-advanced" className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3">
|
||||
{t("wizardHub.advanced")}
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{advancedLinks.map((card) => (
|
||||
<WizardCardView key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WizardCardView({ card }: { card: WizardCard }) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<Card className="transition-all hover:shadow-md hover:-translate-y-0.5">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<CardTitle className="text-base flex-1 min-w-0">{t(card.titleKey)}</CardTitle>
|
||||
{card.badgeKey && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{t(card.badgeKey)}
|
||||
</Badge>
|
||||
)}
|
||||
{card.advanced && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{t("wizardHub.advancedBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="line-clamp-3 min-h-[3em]">
|
||||
{t(card.descriptionKey)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant={card.advanced ? "secondary" : "default"}
|
||||
className="w-full"
|
||||
>
|
||||
<Link to={card.to} className="flex items-center justify-center gap-1">
|
||||
{card.advanced ? t("wizardHub.openPage") : t("wizardHub.startWizard")}
|
||||
{card.advanced ? (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user