Compare commits
8 Commits
000969b0b9
...
45408acd5c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45408acd5c | ||
|
|
bbb2b44ae0 | ||
|
|
dcb7727a4b | ||
|
|
96a2945512 | ||
|
|
a4e92d1f40 | ||
|
|
fbd58fa5a6 | ||
|
|
b02c2e7526 | ||
|
|
9b20d6ce66 |
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 |
345
src/App.tsx
345
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,175 @@ 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 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 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 +189,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 +252,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 />} />
|
||||
@@ -226,6 +279,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 +295,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,6 +306,14 @@ 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 */}
|
||||
@@ -263,6 +326,7 @@ const App = () => (
|
||||
<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 +349,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 +407,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 { Suspense } from "react";
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import {
|
||||
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
||||
@@ -9,6 +10,8 @@ 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";
|
||||
@@ -29,115 +32,128 @@ 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.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.classrooms", url: "/admin/classrooms", icon: GraduationCap },
|
||||
{ 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 +161,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", 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 +222,25 @@ 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 navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
|
||||
|
||||
@@ -213,13 +256,15 @@ 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">
|
||||
<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,36 +277,38 @@ 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"
|
||||
className="fixed bottom-6 end-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>
|
||||
<span className="text-sm font-medium">{t("chrome.needHelp")}</span>
|
||||
</Link>
|
||||
</SidebarProvider>
|
||||
);
|
||||
@@ -273,67 +320,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 +401,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 { Suspense } from "react";
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { AppSidebar } from "@/components/AppSidebar";
|
||||
import {
|
||||
@@ -77,6 +78,16 @@ 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();
|
||||
|
||||
@@ -127,7 +138,9 @@ export default function AppLayout() {
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
@@ -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">
|
||||
|
||||
@@ -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,52 @@ import RoleLayout, { NavGroup } from "./RoleLayout";
|
||||
import ExamPopup from "./student/ExamPopup";
|
||||
import {
|
||||
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
|
||||
CalendarCheck, Calendar, User, Target, GraduationCap, ListChecks,
|
||||
CalendarCheck, Calendar, User, Target, ListChecks,
|
||||
MessageSquare, Megaphone, Mail, TrendingUp,
|
||||
} 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.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 && (
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
876
src/i18n/locales/ar.ts
Normal file
876
src/i18n/locales/ar.ts
Normal file
@@ -0,0 +1,876 @@
|
||||
import type { Translations } from "./en";
|
||||
|
||||
/** Arabic (RTL) translations. Keep key parity with `en.ts`. */
|
||||
const ar: Translations = {
|
||||
common: {
|
||||
cancel: "إلغاء",
|
||||
save: "حفظ",
|
||||
delete: "حذف",
|
||||
edit: "تعديل",
|
||||
close: "إغلاق",
|
||||
back: "رجوع",
|
||||
next: "التالي",
|
||||
search: "بحث",
|
||||
loading: "جارٍ التحميل…",
|
||||
error: "خطأ",
|
||||
retry: "إعادة المحاولة",
|
||||
yes: "نعم",
|
||||
no: "لا",
|
||||
actions: "إجراءات",
|
||||
status: "الحالة",
|
||||
settings: "الإعدادات",
|
||||
signOut: "تسجيل الخروج",
|
||||
viewAll: "عرض الكل",
|
||||
dismiss: "تجاهل",
|
||||
pending: "قيد الانتظار",
|
||||
active: "نشط",
|
||||
inactive: "غير نشط",
|
||||
available: "متاح",
|
||||
unavailable: "غير متاح",
|
||||
name: "الاسم",
|
||||
email: "البريد الإلكتروني",
|
||||
user: "المستخدم",
|
||||
home: "الرئيسية",
|
||||
saving: "جارٍ الحفظ…",
|
||||
disabled: "معطّل",
|
||||
},
|
||||
auth: {
|
||||
signIn: "تسجيل الدخول",
|
||||
signInTitle: "تسجيل الدخول إلى EnCoach",
|
||||
welcomeBack: "مرحباً بعودتك",
|
||||
signInDescription: "سجّل الدخول إلى حسابك للمتابعة",
|
||||
email: "البريد الإلكتروني",
|
||||
emailPlaceholder: "you@example.com",
|
||||
password: "كلمة المرور",
|
||||
rememberMe: "تذكرني",
|
||||
forgotPassword: "هل نسيت كلمة المرور؟",
|
||||
needAccount: "ليس لديك حساب؟",
|
||||
signUp: "إنشاء حساب",
|
||||
invalidCredentials: "البريد الإلكتروني أو كلمة المرور غير صحيحة",
|
||||
missingCredentials: "يرجى إدخال البريد الإلكتروني وكلمة المرور",
|
||||
loginFailedTitle: "فشل تسجيل الدخول",
|
||||
errorTitle: "خطأ",
|
||||
},
|
||||
nav: {
|
||||
smartWizard: "المعالج الذكي",
|
||||
quickSetup: "الإعداد السريع",
|
||||
coursePlans: "خطط المقررات (الذكاء الاصطناعي)",
|
||||
adminDashboard: "لوحة الإدارة",
|
||||
platformDashboard: "لوحة المنصّة",
|
||||
dashboard: "لوحة التحكم",
|
||||
courses: "الدورات",
|
||||
myCourses: "دوراتي",
|
||||
students: "الطلاب",
|
||||
teachers: "المعلمون",
|
||||
batches: "الدفعات",
|
||||
timetable: "الجدول الدراسي",
|
||||
reports: "التقارير",
|
||||
assignments: "الواجبات",
|
||||
examsList: "قائمة الاختبارات",
|
||||
examStructures: "هياكل الاختبارات",
|
||||
rubrics: "معايير التقييم",
|
||||
generation: "التوليد",
|
||||
reviewQueue: "قائمة المراجعة",
|
||||
aiPrompts: "وكلاء الذكاء الاصطناعي والأدوات",
|
||||
aiFeedback: "ملاحظات الذكاء الاصطناعي",
|
||||
approvalWorkflows: "سير عمل الموافقات",
|
||||
taxonomy: "التصنيف",
|
||||
resources: "الموارد",
|
||||
resourceLibrary: "مكتبة الموارد",
|
||||
academicYears: "السنوات الأكاديمية",
|
||||
departments: "الأقسام",
|
||||
admissions: "القبول",
|
||||
admissionRegister: "سجل القبول",
|
||||
examSessions: "جلسات الاختبارات",
|
||||
marksheets: "كشوفات الدرجات",
|
||||
studentLeave: "إجازات الطلاب",
|
||||
fees: "الرسوم والمدفوعات",
|
||||
lessons: "الدروس",
|
||||
gradebook: "سجل الدرجات",
|
||||
studentProgress: "تقدم الطالب",
|
||||
library: "المكتبة",
|
||||
activities: "الأنشطة",
|
||||
facilities: "المرافق",
|
||||
users: "المستخدمون",
|
||||
entities: "الكيانات",
|
||||
classrooms: "الفصول",
|
||||
userRoles: "أدوار المستخدمين",
|
||||
rolesPermissions: "الأدوار والصلاحيات",
|
||||
authorityMatrix: "مصفوفة الصلاحيات",
|
||||
studentPerformance: "أداء الطلاب",
|
||||
statsCorporate: "إحصاءات الشركات",
|
||||
record: "السجل",
|
||||
vocabulary: "المفردات",
|
||||
grammar: "القواعد",
|
||||
faqManager: "إدارة الأسئلة الشائعة",
|
||||
notificationRules: "قواعد الإشعارات",
|
||||
approvalConfig: "إعدادات الموافقات",
|
||||
paymentRecord: "سجل المدفوعات",
|
||||
tickets: "التذاكر",
|
||||
settings: "الإعدادات",
|
||||
profile: "الملف الشخصي",
|
||||
privacy: "مركز الخصوصية",
|
||||
subjectRegistration: "تسجيل المواد",
|
||||
mySubjects: "موادي",
|
||||
grades: "الدرجات",
|
||||
attendance: "الحضور",
|
||||
myJourney: "رحلتي",
|
||||
discussions: "المناقشات",
|
||||
messages: "الرسائل",
|
||||
announcements: "الإعلانات",
|
||||
},
|
||||
sidebarGroup: {
|
||||
overview: "نظرة عامة",
|
||||
lms: "نظام التعلم",
|
||||
adaptiveLearning: "التعلم التكيّفي",
|
||||
institutional: "المؤسسي",
|
||||
academic: "الأكاديمي",
|
||||
management: "الإدارة",
|
||||
reports: "التقارير",
|
||||
configuration: "الإعدادات",
|
||||
training: "التدريب",
|
||||
support: "الدعم",
|
||||
learning: "التعلم",
|
||||
progress: "التقدم",
|
||||
communication: "التواصل",
|
||||
account: "الحساب",
|
||||
teaching: "التدريس",
|
||||
},
|
||||
breadcrumb: {
|
||||
home: "الرئيسية",
|
||||
admin: "الإدارة",
|
||||
dashboard: "لوحة التحكم",
|
||||
platform: "المنصّة",
|
||||
exam: "الاختبار",
|
||||
review: "مراجعة",
|
||||
ai: "الذكاء الاصطناعي",
|
||||
feedback: "ملاحظات",
|
||||
},
|
||||
userMenu: {
|
||||
profile: "الملف الشخصي",
|
||||
settings: "الإعدادات",
|
||||
logout: "تسجيل الخروج",
|
||||
userFallback: "مستخدم",
|
||||
},
|
||||
chrome: {
|
||||
needHelp: "هل تحتاج مساعدة؟",
|
||||
ticketsTooltip: "تذاكر الدعم",
|
||||
aiSearchPlaceholder: "اسأل عن أي شيء… مثال: «اعرض الطلاب ذوي الحضور المنخفض»",
|
||||
aiSearching: "الذكاء الاصطناعي يبحث…",
|
||||
aiRelatedQueries: "استفسارات ذات صلة",
|
||||
aiNoResults: "لا توجد نتائج لـ «{{q}}». جرّب سؤالاً أو كلمة مفتاحية مختلفة.",
|
||||
aiSearchFailedTitle: "فشل البحث",
|
||||
aiSearchFailedDesc: "تعذّر إكمال بحث الذكاء الاصطناعي.",
|
||||
portalSuffix: "البوابة",
|
||||
adminAlt: "EnCoach",
|
||||
adminAltLong: "EnCoach — أطلق إمكاناتك مع منصّة مدعومة بالذكاء الاصطناعي",
|
||||
toggleSidebar: "إظهار/إخفاء الشريط الجانبي",
|
||||
},
|
||||
notifications: {
|
||||
title: "الإشعارات",
|
||||
empty: "ليست لديك إشعارات جديدة.",
|
||||
ariaLabel: "الإشعارات",
|
||||
},
|
||||
theme: {
|
||||
title: "المظهر",
|
||||
light: "فاتح",
|
||||
dark: "داكن",
|
||||
system: "النظام",
|
||||
toggleLabel: "تبديل المظهر",
|
||||
},
|
||||
language: {
|
||||
change: "تغيير اللغة",
|
||||
label: "اللغة",
|
||||
},
|
||||
roles: {
|
||||
student: "طالب",
|
||||
teacher: "معلّم",
|
||||
admin: "مسؤول",
|
||||
developer: "مطوّر",
|
||||
corporate: "شركة",
|
||||
mastercorporate: "شركة رئيسية",
|
||||
agent: "وكيل",
|
||||
},
|
||||
dashboard: {
|
||||
greetingFallback: "الطالب",
|
||||
},
|
||||
studentDash: {
|
||||
welcome: "مرحباً بعودتك، {{name}}!",
|
||||
subtitle: "إليك نظرة عامة على تقدّمك الدراسي.",
|
||||
enrolledCourses: "الدورات المسجّلة",
|
||||
overallProgress: "التقدم العام",
|
||||
averageGrade: "متوسط الدرجات",
|
||||
totalChapters: "إجمالي الفصول",
|
||||
myCourses: "دوراتي",
|
||||
noEnrolledCourses: "لم تسجّل في أي دورة بعد.",
|
||||
chaptersMaterials: "{{chapters}} فصل · {{materials}} مادة",
|
||||
quickActions: "إجراءات سريعة",
|
||||
enrollToStart: "سجّل في دورة للبدء.",
|
||||
continue: "متابعة",
|
||||
start: "ابدأ",
|
||||
chaptersDone: "{{done}}/{{total}} فصل مكتمل",
|
||||
recentGrades: "آخر الدرجات",
|
||||
noGradesYet: "لا توجد درجات بعد.",
|
||||
},
|
||||
teacherDash: {
|
||||
title: "لوحة المعلّم",
|
||||
subtitle: "نظرة عامة على نشاطك التدريسي.",
|
||||
activeCourses: "الدورات النشطة",
|
||||
totalStudents: "إجمالي الطلاب",
|
||||
pendingGrading: "تقييمات قيد الانتظار",
|
||||
avgPassRate: "متوسط نسبة النجاح",
|
||||
myCourses: "دوراتي",
|
||||
colCourse: "الدورة",
|
||||
colStudents: "الطلاب",
|
||||
colStatus: "الحالة",
|
||||
recentActivity: "النشاط الأخير",
|
||||
submitted: "تم الإرسال {{when}}",
|
||||
pending: "قيد الانتظار",
|
||||
},
|
||||
adminDash: {
|
||||
title: "لوحة الإدارة",
|
||||
subtitle: "نظرة عامة على المنصّة والمؤشرات الرئيسية.",
|
||||
addStudent: "إضافة طالب",
|
||||
newCourse: "دورة جديدة",
|
||||
totalStudents: "إجمالي الطلاب",
|
||||
activeCourses: "الدورات النشطة",
|
||||
teachers: "المعلمون",
|
||||
activeBatches: "الدفعات النشطة",
|
||||
openTickets: "تذاكر مفتوحة",
|
||||
revenue: "الإيرادات",
|
||||
departments: "الأقسام",
|
||||
classrooms: "الفصول",
|
||||
subjects: "المواد",
|
||||
resources: "الموارد",
|
||||
coursesOverview: "نظرة عامة على الدورات",
|
||||
batchCapacity: "سعة الدفعات",
|
||||
noCourseData: "لا توجد بيانات دورات متاحة.",
|
||||
noBatchData: "لا توجد بيانات دفعات متاحة.",
|
||||
quickSummary: "ملخّص سريع",
|
||||
colModule: "الوحدة",
|
||||
colCount: "العدد",
|
||||
colStatus: "الحالة",
|
||||
exams: "الاختبارات",
|
||||
examSessionsCount: "{{n}} جلسة",
|
||||
assignments: "الواجبات",
|
||||
acrossCourses: "عبر جميع الدورات",
|
||||
supportTickets: "تذاكر الدعم",
|
||||
ticketsOpenCount: "{{n}} مفتوحة",
|
||||
payments: "المدفوعات",
|
||||
revenueTotal: "{{amount}} إجمالاً",
|
||||
chartCapacity: "السعة",
|
||||
chartEnrolled: "المسجّلون",
|
||||
},
|
||||
examPopup: {
|
||||
title: "اختبارات قادمة ({{n}})",
|
||||
activeNow: "متاح الآن",
|
||||
from: "من:",
|
||||
to: "إلى:",
|
||||
startExam: "ابدأ الاختبار",
|
||||
notAvailableYet: "غير متاح بعد",
|
||||
willBeAvailable: "سيتاح الاختبار عندما يصبح نشطاً",
|
||||
notActive: "الاختبار غير نشط حالياً",
|
||||
},
|
||||
errors: {
|
||||
somethingWrong: "حدث خطأ ما",
|
||||
unexpectedDescription: "حدث خطأ غير متوقع. يرجى تحديث الصفحة والمحاولة مرة أخرى.",
|
||||
errorDetails: "تفاصيل الخطأ",
|
||||
refreshPage: "تحديث الصفحة",
|
||||
notFoundCode: "404",
|
||||
notFoundMessage: "عذراً! الصفحة غير موجودة",
|
||||
returnHome: "العودة إلى الرئيسية",
|
||||
},
|
||||
ai: {
|
||||
assistantLabel: "مساعد الذكاء الاصطناعي",
|
||||
assistantTitle: "مساعد EnCoach الذكي",
|
||||
placeholder: "اسأل عن أي شيء…",
|
||||
send: "إرسال",
|
||||
thinking: "يفكّر…",
|
||||
emptyLine1: "اسألني عن أي شيء يخص المنصّة،",
|
||||
emptyLine2: "أو اختر إجراءً سريعاً من الأعلى.",
|
||||
fallbackReply: "عذراً، تعذّر الوصول إلى المساعد. يرجى المحاولة مرة أخرى بعد قليل.",
|
||||
replyErrorTitle: "تعذّر الحصول على ردّ",
|
||||
replyErrorDesc: "حدث خطأ ما. يرجى المحاولة مرة أخرى.",
|
||||
quickHealth: "ملخّص حالة المنصّة",
|
||||
quickDropouts: "عرض مخاطر التسرّب",
|
||||
quickCompare: "مقارنة أداء الدورات",
|
||||
quickBatch: "اقتراحات لتحسين الدفعات",
|
||||
tipLabel: "نصيحة الذكاء الاصطناعي",
|
||||
insightLabel: "رؤية الذكاء الاصطناعي",
|
||||
recommendationLabel: "توصية الذكاء الاصطناعي",
|
||||
categoryTipLabel: "نصيحة {{category}} من الذكاء الاصطناعي",
|
||||
loadingTip: "جارٍ تحميل النصيحة…",
|
||||
loadingInsights: "جارٍ تحميل الرؤى…",
|
||||
loadingAlerts: "جارٍ تحميل التنبيهات…",
|
||||
couldNotLoadTip: "تعذّر تحميل النصيحة.",
|
||||
couldNotLoadInsights: "تعذّر تحميل الرؤى.",
|
||||
couldNotLoadAlerts: "تعذّر تحميل التنبيهات.",
|
||||
noTipAvailable: "لا توجد نصيحة متاحة.",
|
||||
noTipForContext: "لا توجد نصيحة لهذا السياق بعد.",
|
||||
noInsightsAvailable: "لا توجد رؤى متاحة لهذا العرض.",
|
||||
noAlertsRightNow: "لا توجد تنبيهات ذكاء اصطناعي حالياً.",
|
||||
alertsUnavailable: "التنبيهات غير متاحة",
|
||||
insightsUnavailable: "الرؤى غير متاحة",
|
||||
platformInsightsTitle: "رؤى المنصّة من الذكاء الاصطناعي",
|
||||
},
|
||||
privacy: {
|
||||
title: "مركز الخصوصية",
|
||||
description:
|
||||
"إدارة بياناتك الشخصية لدى EnCoach وفقاً للائحة حماية البيانات العامة وما يماثلها.",
|
||||
exportTitle: "تنزيل بياناتك",
|
||||
exportDescription:
|
||||
"سنقوم بتجميع ملفك الشخصي، عضويات الكيانات، محاولات الاختبارات، الإجابات، ملاحظات الذكاء الاصطناعي، والتذاكر في ملف JSON واحد.",
|
||||
exportButton: "تنزيل بياناتي",
|
||||
exportPreparing: "جارٍ تحضير التصدير…",
|
||||
exportSuccess: "تم تنزيل البيانات بنجاح",
|
||||
deleteTitle: "حذف حسابي",
|
||||
deleteDescription:
|
||||
"سيتم إخفاء هوية ملفك الشخصي وإزالة الحقول الشخصية من سجلاتنا. سيتم الاحتفاظ بمحاولات الاختبارات (دون معلومات تعريفية) لسلامة الإحصائيات. لا يمكن التراجع عن هذا الإجراء.",
|
||||
deleteButton: "محو حسابي",
|
||||
deleteErasing: "جارٍ المحو…",
|
||||
confirmTitle: "هل تريد محو حسابك؟",
|
||||
confirmDescription:
|
||||
"سنقوم بإخفاء هوية بياناتك الشخصية وتعطيل تسجيل الدخول. ستبقى التحليلات المجمعة لكنها لن تكون مرتبطة بك.",
|
||||
confirmTypeDelete: "اكتب DELETE للتأكيد",
|
||||
confirmErased: "تم محو حسابك. جارٍ تسجيل الخروج…",
|
||||
},
|
||||
feedback: {
|
||||
thumbsUp: "إعجاب",
|
||||
thumbsDown: "عدم إعجاب",
|
||||
whatWentWrong: "ما الخطأ الذي حدث؟",
|
||||
commentPlaceholder: "مثال: إجابة خاطئة، صياغة مربكة، خارج الموضوع…",
|
||||
commentRequired: "من فضلك أخبرنا بالخطأ.",
|
||||
submit: "إرسال الملاحظات",
|
||||
},
|
||||
quickSetup: {
|
||||
adminTitle: "الإعداد السريع",
|
||||
adminSubtitle:
|
||||
"كل ما تحتاجه لإطلاق منصّة جاهزة للامتحانات بالترتيب الموصى به. يتم تعليم كل خطوة تلقائياً عند اكتمالها.",
|
||||
teacherTitle: "الإعداد السريع",
|
||||
teacherSubtitle: "ابدأ دورة جديدة من البداية إلى النهاية، ثم انتقل إلى المهام اليومية الشائعة.",
|
||||
progressLabel: "التقدم",
|
||||
recommendedFlow: "المسار الموصى به",
|
||||
otherQuickCreates: "إنشاء سريع آخر",
|
||||
ready: "جاهز",
|
||||
start: "ابدأ",
|
||||
review: "مراجعة",
|
||||
open: "فتح",
|
||||
helpAria: "عرض المساعدة",
|
||||
admin: {
|
||||
step1: {
|
||||
title: "أنشئ معيار تقييم (Rubric)",
|
||||
description: "حدّد معايير التقييم لمهام الكتابة والتحدّث. تُستخدم هذه المعايير لاحقاً عند توليد الامتحانات واعتمادها.",
|
||||
help: "المعيار هو شبكة تقييم (درجات × عناصر). يمكنك البدء من قالب جاهز أو تركيبه من عناصر محدّدة مسبقاً.",
|
||||
},
|
||||
step2: {
|
||||
title: "عرّف بنية الامتحان",
|
||||
description: "حدّد الأقسام والمهام والأجزاء التي يجب أن يحتويها كل امتحان من هذا النوع سواء مولّداً أو مخصّصاً.",
|
||||
help: "البنى تضمن الاتساق. مثلاً: بنية IELTS للكتابة تحتوي على المهمة ١ (١٥٠ كلمة) والمهمة ٢ (٢٥٠ كلمة)؛ سيرفض النظام الإرسال إن نقصت إحداهما.",
|
||||
},
|
||||
step3: {
|
||||
title: "ولّد أو أنشئ امتحاناً",
|
||||
description: "استخدم التوليد بالذكاء الاصطناعي (الأسرع) أو أنشئ امتحاناً مخصّصاً يدوياً. احفظ كمسودة أو أرسله للموافقة.",
|
||||
help: "التوليد يختار بنية ومعيار تقييم ثم يُنتج الأسئلة. المنشئ المخصّص يتيح لك تحكّماً كاملاً للامتحانات التجريبية.",
|
||||
},
|
||||
step4: {
|
||||
title: "راجع واعتمد",
|
||||
description: "يعتمد المصدّقون الامتحانات قبل ظهورها للطلاب. اضبط سير العمل مرة واحدة ليتم توجيه الطلبات تلقائياً.",
|
||||
help: "قائمة الاعتماد تعرض كل امتحان في انتظار المصادقة. الرفض يعيده للمؤلّف، والقبول ينشره.",
|
||||
},
|
||||
step5: {
|
||||
title: "عيّن للطلاب",
|
||||
description: "جدول الامتحان المنشور، اختر الدفعة، وأرسله. يراه الطلاب فوراً في بوّابتهم.",
|
||||
help: "يمكن استهداف طلاب أفراد أو دفعات أو صفوف كاملة مع مراعاة المنطقة الزمنية.",
|
||||
},
|
||||
quick: {
|
||||
course: { title: "دورة جديدة", description: "أنشئ هيكل دورة ليملأها المعلّمون بالفصول." },
|
||||
resource: { title: "رفع مورد", description: "أضف ملفات PDF أو صوت أو فيديو أو روابط إلى المكتبة المشتركة." },
|
||||
student: { title: "إضافة طالب", description: "أنشئ حساب طالب وعيّنه إلى دفعة." },
|
||||
teacher: { title: "إضافة معلّم", description: "ادعُ معلّماً وامنحه صلاحيات التدريس." },
|
||||
classroom: { title: "صف جديد", description: "جمّع الطلاب لأغراض الجدولة والحضور." },
|
||||
examSession: { title: "جدولة جلسة امتحان", description: "أنشئ جلسة امتحان مراقبة لامتحان مؤسسي." },
|
||||
customExam: { title: "امتحان مخصّص", description: "أنشئ امتحاناً يدوياً من الصفر بتحكّم كامل." },
|
||||
ticket: { title: "فتح تذكرة", description: "افتح تذكرة دعم نيابة عن مستخدم." },
|
||||
},
|
||||
},
|
||||
teacher: {
|
||||
step1: {
|
||||
title: "أنشئ دورة",
|
||||
description: "امنح دورتك اسماً، اختر المادّة، وحدّد مستواها. يمكنك تعديل الفصول بعد الإنشاء.",
|
||||
help: "الدورات هي الحاوية للفصول والمواد والواجبات.",
|
||||
},
|
||||
step2: {
|
||||
title: "أضف الفصول والمحتوى",
|
||||
description: "افتح دورتك وأضف فصولاً تحتوي على دروس وفيديوهات واختبارات ومهام تدريب.",
|
||||
help: "الفصول تنظّم أهداف التعلّم. استخدم الورشة الذكية لإنشاء المحتوى تلقائياً.",
|
||||
},
|
||||
step3: {
|
||||
title: "ارفع الموارد",
|
||||
description: "شارك مع طلابك ملفات PDF أو صوت أو فيديو داعمة عبر المكتبة.",
|
||||
help: "الملفات الكبيرة مدعومة — يقبل الخادم حتى ١٢٨ ميغابايت للرفعة الواحدة.",
|
||||
},
|
||||
step4: {
|
||||
title: "أنشئ واجباً",
|
||||
description: "حوّل امتحاناً منشوراً أو مهمّة إلى واجب بتاريخ تسليم ودفعة مستهدفة.",
|
||||
help: "تظهر الواجبات تلقائياً في لوحة كل طالب وفي جدوله.",
|
||||
},
|
||||
step5: {
|
||||
title: "تابع تقدّم الطلاب",
|
||||
description: "راقب التسليمات والحضور ورؤى التعلّم التكيّفي لصفّك.",
|
||||
help: "يحدّد محرك التعلّم التكيّفي الطلاب المعرّضين للخطر لتتدخّل مبكّراً.",
|
||||
},
|
||||
quick: {
|
||||
discussion: { title: "نقاش جديد", description: "ابدأ موضوع نقاش لصفّك." },
|
||||
announcement: { title: "إعلان جديد", description: "أرسل رسالة لجميع طلابك." },
|
||||
attendance: { title: "تسجيل الحضور", description: "سجّل حضور اليوم لإحدى الجلسات." },
|
||||
},
|
||||
},
|
||||
},
|
||||
wizardHub: {
|
||||
title: "المعالج الذكي",
|
||||
subtitle:
|
||||
"اختر أي سيناريو وسيرشدك المعالج خطوة بخطوة. لا حاجة للبحث في الإعدادات — كلّ ضغطة على \"التالي\" تقرّبك أكثر من الإنجاز.",
|
||||
recommendedOrder: "الترتيب الموصى به",
|
||||
order: {
|
||||
rubric: "أنشئ معايير التقييم (الكتابة / المحادثة)",
|
||||
structure: "حدّد هيكل الامتحان (الأقسام والمهام والمدد)",
|
||||
generate: "أنشئ امتحاناً تلقائياً أو يدوياً",
|
||||
approve: "راجع الامتحانات المعلّقة واعتمدها",
|
||||
assign: "أسند الامتحانات إلى الطلاب",
|
||||
},
|
||||
guided: "معالجات موجّهة",
|
||||
advanced: "الصفحات الكاملة (متقدّم)",
|
||||
advancedBadge: "متقدّم",
|
||||
aiBadge: "ذكاء اصطناعي",
|
||||
startWizard: "ابدأ المعالج",
|
||||
openPage: "افتح الصفحة",
|
||||
cards: {
|
||||
rubric: {
|
||||
title: "إنشاء معيار تقييم",
|
||||
description: "الاسم ← المهارة ← المعايير ← الوصف ← المراجعة. للكتابة والمحادثة فقط.",
|
||||
},
|
||||
examStructure: {
|
||||
title: "تعريف هيكل امتحان",
|
||||
description: "الاسم ← الوحدات ← مهام الكتابة ← المراجعة. قالب قابل لإعادة الاستخدام.",
|
||||
},
|
||||
course: {
|
||||
title: "إنشاء مساق",
|
||||
description: "العنوان ← المستوى والسعة ← المراجعة. الفصول تُضاف من صفحة المساق.",
|
||||
},
|
||||
coursePlan: {
|
||||
title: "توليد خطة مقرر (ذكاء اصطناعي)",
|
||||
description: "اوصف المقرر فيكتب الذكاء الاصطناعي الأهداف ونتائج التعلّم لكل مهارة ونطاق القواعد وخطة التسليم أسبوعياً، ثم يولّد المواد التعليمية الجاهزة لكل أسبوع.",
|
||||
},
|
||||
generation: {
|
||||
title: "توليد امتحان",
|
||||
description: "صفحة التوليد الكاملة بخيارات المعايير والهيكل وتحكّم في كل وحدة.",
|
||||
},
|
||||
approval: {
|
||||
title: "مسارات الموافقة",
|
||||
description: "حدّد من يراجع الامتحانات، وتابع الطلبات المعلّقة.",
|
||||
},
|
||||
assign: {
|
||||
title: "إسناد إلى الطلاب",
|
||||
description: "صفحة الجدولة الكاملة مع منتقي الطلاب والنوافذ الزمنية والمنطقة الزمنية.",
|
||||
},
|
||||
resource: {
|
||||
title: "رفع مورد",
|
||||
description: "أضف ملفات PDF أو صوت أو فيديو أو روابط إلى المكتبة.",
|
||||
},
|
||||
student: {
|
||||
title: "إضافة طالب",
|
||||
description: "أنشئ حساب طالب وسجّله في دفعة.",
|
||||
},
|
||||
},
|
||||
},
|
||||
wizard: {
|
||||
back: "السابق",
|
||||
next: "التالي",
|
||||
finish: "إنهاء",
|
||||
backToHub: "العودة إلى المعالجات",
|
||||
stepOf: "الخطوة {{current}} من {{total}}",
|
||||
rubric: {
|
||||
title: "إنشاء معيار تقييم",
|
||||
subtitle: "شبكة تقييم لمهام الكتابة أو المحادثة، يُرجَع إليها عند توليد الامتحانات أو اعتمادها.",
|
||||
finish: "إنشاء المعيار",
|
||||
toastSuccess: "تم إنشاء المعيار",
|
||||
toastError: "تعذّر إنشاء المعيار",
|
||||
addCriterion: "إضافة معيار",
|
||||
removeCriterion: "حذف المعيار",
|
||||
unnamedCriterion: "(معيار بلا اسم)",
|
||||
descriptorHint: "الوصف اختياري. يظهر للمصحّحين كإرشاد لكل معيار.",
|
||||
maxLabel: "الحد الأقصى",
|
||||
moduleHint: "تنطبق المعايير على الكتابة والمحادثة فقط. الاستماع والقراءة يُصحَّحان تلقائياً.",
|
||||
step1: {
|
||||
title: "الأساسيات",
|
||||
description: "امنح المعيار اسماً وحدّد المهارة التي يطبّق عليها.",
|
||||
},
|
||||
step2: {
|
||||
title: "المعايير",
|
||||
description: "أضف المعايير التي سيقوم المصحّحون بتقييمها. لكل معيار حد أقصى (مثل 9 لامتحان IELTS).",
|
||||
},
|
||||
step3: {
|
||||
title: "الأوصاف",
|
||||
description: "اختياري: صف ما يقيسه كل معيار. يراها المصحّحون كتلميحات.",
|
||||
},
|
||||
step4: {
|
||||
title: "المراجعة",
|
||||
description: "راجع وأنشئ.",
|
||||
},
|
||||
labels: {
|
||||
name: "اسم المعيار",
|
||||
module: "المهارة",
|
||||
description: "الوصف",
|
||||
criterionName: "اسم المعيار الفرعي",
|
||||
maxScore: "الحد الأقصى",
|
||||
},
|
||||
placeholders: {
|
||||
name: "مثال: IELTS مهمة كتابة 2",
|
||||
description: "وصف قصير يظهر للمصحّحين.",
|
||||
criterionName: "مثال: الاستجابة للمهمة",
|
||||
descriptor: "ماذا يجب على المصحّح أن يتحقق منه لهذا المعيار.",
|
||||
},
|
||||
modules: {
|
||||
writing: "الكتابة",
|
||||
speaking: "المحادثة",
|
||||
},
|
||||
errors: {
|
||||
nameRequired: "يرجى إدخال اسم للمعيار.",
|
||||
moduleRestricted: "المعايير تنطبق على الكتابة أو المحادثة فقط.",
|
||||
criterionRequired: "أضف معياراً واحداً على الأقل.",
|
||||
criterionName: "كل معيار يحتاج إلى اسم.",
|
||||
criterionScore: "الحد الأقصى يجب أن يكون بين 1 و 100.",
|
||||
},
|
||||
},
|
||||
structure: {
|
||||
title: "تعريف هيكل امتحان",
|
||||
subtitle: "قالب يحدّد الأقسام والمهام التي يجب أن يحتويها كل امتحان من هذا النوع.",
|
||||
finish: "إنشاء الهيكل",
|
||||
toastSuccess: "تم إنشاء هيكل الامتحان",
|
||||
toastError: "تعذّر إنشاء هيكل الامتحان",
|
||||
industryHint: "اختياري: مثل \"الإنجليزية التجارية\"، \"الرعاية الصحية\".",
|
||||
writingSkipped: "الكتابة غير مُحدَّدة ضمن الوحدات — تم تخطّي هذه الخطوة.",
|
||||
wordsSuffix: "كلمة",
|
||||
addTask: "إضافة مهمة",
|
||||
removeTask: "حذف المهمة",
|
||||
step1: {
|
||||
title: "الأساسيات",
|
||||
description: "سمِّ الهيكل وحدّد نوع الامتحان.",
|
||||
},
|
||||
step2: {
|
||||
title: "الوحدات",
|
||||
description: "اختر الوحدات التي يغطّيها هذا الهيكل. سيراها الطلاب كأقسام.",
|
||||
},
|
||||
step3: {
|
||||
title: "مهام الكتابة",
|
||||
description: "للكتابة، حدّد الحد الأدنى لعدد الكلمات لكل مهمة.",
|
||||
},
|
||||
step4: {
|
||||
title: "المراجعة",
|
||||
description: "راجع وأنشئ.",
|
||||
},
|
||||
labels: {
|
||||
name: "اسم الهيكل",
|
||||
industry: "المجال / السياق",
|
||||
examType: "نوع الامتحان",
|
||||
modules: "الوحدات",
|
||||
taskLabel: "اسم المهمة",
|
||||
minWords: "أدنى كلمات",
|
||||
},
|
||||
placeholders: {
|
||||
name: "مثال: IELTS أكاديمي كتابة",
|
||||
industry: "مثال: الإنجليزية التجارية",
|
||||
},
|
||||
examTypes: {
|
||||
academic: "أكاديمي",
|
||||
general: "عام",
|
||||
},
|
||||
modules: {
|
||||
listening: {
|
||||
title: "الاستماع",
|
||||
description: "أسئلة مبنيّة على الصوت (تصحيح تلقائي).",
|
||||
},
|
||||
reading: {
|
||||
title: "القراءة",
|
||||
description: "أسئلة مبنيّة على مقاطع (تصحيح تلقائي).",
|
||||
},
|
||||
writing: {
|
||||
title: "الكتابة",
|
||||
description: "مهام مقاليّة تُصحَّح باستخدام معيار تقييم.",
|
||||
},
|
||||
speaking: {
|
||||
title: "المحادثة",
|
||||
description: "مهام شفهيّة تُصحَّح باستخدام معيار تقييم.",
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
nameRequired: "يرجى إدخال اسم للهيكل.",
|
||||
moduleRequired: "اختر وحدة واحدة على الأقل.",
|
||||
writingTaskRequired: "الكتابة تحتاج إلى مهمة واحدة على الأقل.",
|
||||
writingTaskLabel: "كل مهمة كتابة تحتاج إلى اسم.",
|
||||
writingTaskWords: "الحد الأدنى للكلمات يجب أن يكون 1 على الأقل.",
|
||||
},
|
||||
},
|
||||
course: {
|
||||
title: "إنشاء مساق",
|
||||
subtitle: "هيكل مساق خفيف. يمكنك إضافة الفصول والمواد وتسجيل الطلاب بعد الإنشاء.",
|
||||
finish: "إنشاء المساق",
|
||||
toastSuccess: "تم إنشاء المساق",
|
||||
toastError: "تعذّر إنشاء المساق",
|
||||
codeHint: "اتركه فارغاً لتوليده تلقائياً من العنوان.",
|
||||
difficultyHint: "ما مدى تحدّي هذا المساق إجمالاً؟ يُستخدم للبحث والتصفية.",
|
||||
cefrHint: "إن كان المساق يستهدف مستوى CEFR محدداً، اختره هنا.",
|
||||
step1: {
|
||||
title: "الأساسيات",
|
||||
description: "امنح المساق اسماً ووصفاً قصيراً.",
|
||||
},
|
||||
step2: {
|
||||
title: "المستوى والسعة",
|
||||
description: "حدّد الصعوبة ومستوى CEFR المستهدف والحد الأقصى لعدد الطلاب.",
|
||||
},
|
||||
step3: {
|
||||
title: "المراجعة",
|
||||
description: "راجع وأنشئ.",
|
||||
},
|
||||
labels: {
|
||||
title: "عنوان المساق",
|
||||
code: "رمز المساق",
|
||||
description: "الوصف",
|
||||
difficulty: "الصعوبة",
|
||||
cefrLevel: "مستوى CEFR",
|
||||
capacity: "السعة القصوى",
|
||||
},
|
||||
placeholders: {
|
||||
title: "مثال: إنجليزية تأسيسية المستوى 1",
|
||||
code: "سيتم توليده تلقائياً إذا تُرك فارغاً",
|
||||
description: "لمن هذا المساق؟ ماذا سيتعلّمون؟",
|
||||
difficulty: "اختر مستوى صعوبة",
|
||||
cefr: "اختر مستوى CEFR",
|
||||
},
|
||||
difficulty: {
|
||||
beginner: "مبتدئ",
|
||||
intermediate: "متوسط",
|
||||
advanced: "متقدّم",
|
||||
},
|
||||
errors: {
|
||||
titleRequired: "يرجى إدخال عنوان المساق.",
|
||||
capacityRequired: "السعة القصوى يجب أن تكون 1 على الأقل.",
|
||||
},
|
||||
},
|
||||
},
|
||||
coursePlan: {
|
||||
listTitle: "خطط المقررات",
|
||||
listSubtitle:
|
||||
"خطط مناهج يُنشئها الذكاء الاصطناعي: الأهداف، ونتائج التعلّم لكل مهارة، ونطاق القواعد، وخطة التسليم أسبوعياً، مع توليد المواد التعليمية لكل أسبوع عند الطلب.",
|
||||
generateNew: "توليد خطة جديدة",
|
||||
searchPlaceholder: "ابحث عن خطة بالاسم…",
|
||||
loadFailed: "تعذّر تحميل الخطط.",
|
||||
deleted: "تم حذف الخطة.",
|
||||
deleteFailed: "تعذّر حذف الخطة.",
|
||||
delete: "حذف",
|
||||
confirmDelete: "حذف الخطة \"{{name}}\"؟ سيؤدي ذلك إلى إزالة جميع الأسابيع والمواد.",
|
||||
emptyTitle: "لا توجد خطط بعد",
|
||||
emptySubtitle: "استخدم معالج الذكاء الاصطناعي لتوليد أول خطة — يستغرق الأمر نحو دقيقة.",
|
||||
open: "فتح",
|
||||
backToList: "العودة إلى الخطط",
|
||||
noDescription: "لا يوجد وصف.",
|
||||
weeksCount_one: "{{count}} أسبوع",
|
||||
weeksCount_other: "{{count}} أسابيع",
|
||||
materialsCount_one: "{{count}} مادة",
|
||||
materialsCount_other: "{{count}} مواد",
|
||||
hoursPerWeek: "{{count}} ساعة/أسبوع",
|
||||
weekN: "الأسبوع {{n}}",
|
||||
generateMaterials: "توليد مواد الأسبوع (ذكاء اصطناعي)",
|
||||
regenerateMaterials: "إعادة توليد مواد الأسبوع",
|
||||
generating: "جاري التوليد…",
|
||||
generateHint:
|
||||
"سيُنتج الذكاء الاصطناعي نصّ قراءة وسيناريو استماع ومحفّزات محادثة وسؤال كتابة ودرس قواعد وقائمة مفردات لهذا الأسبوع.",
|
||||
weekMaterialsGenerated: "تم توليد مواد الأسبوع.",
|
||||
weekMaterialsFailed: "تعذّر توليد مواد الأسبوع.",
|
||||
generateSuccess: "تم توليد خطة المقرر.",
|
||||
generateFailed: "تعذّر توليد خطة المقرر.",
|
||||
deliveryHint: "افتح أي أسبوع لعرض النتائج المخطَّطة وتوليد المواد الجاهزة للاستخدام.",
|
||||
status: {
|
||||
draft: "مسودة",
|
||||
generated: "مُولّدة",
|
||||
approved: "معتمدة",
|
||||
archived: "مؤرشفة",
|
||||
},
|
||||
sections: {
|
||||
objectives: "أهداف المقرر",
|
||||
outcomes: "نتائج التعلّم حسب المهارة",
|
||||
grammar: "نطاق القواعد",
|
||||
assessment: "التقييم",
|
||||
resources: "المصادر",
|
||||
delivery: "خطة التسليم الأسبوعية",
|
||||
},
|
||||
skill: {
|
||||
reading: "القراءة",
|
||||
writing: "الكتابة",
|
||||
listening: "الاستماع",
|
||||
speaking: "المحادثة",
|
||||
grammar: "القواعد",
|
||||
vocabulary: "المفردات",
|
||||
integrated: "متكامل",
|
||||
},
|
||||
materialType: {
|
||||
reading_text: "نص قراءة",
|
||||
listening_script: "نص استماع",
|
||||
speaking_prompt: "محفّز محادثة",
|
||||
writing_prompt: "مهمّة كتابة",
|
||||
grammar_lesson: "درس قواعد",
|
||||
vocabulary_list: "قائمة مفردات",
|
||||
practice: "تمرين",
|
||||
other: "مادة",
|
||||
},
|
||||
table: {
|
||||
skill: "المهارة",
|
||||
outcomes: "النتائج",
|
||||
remarks: "ملاحظات",
|
||||
},
|
||||
wizard: {
|
||||
title: "توليد خطة مقرر",
|
||||
subtitle:
|
||||
"اوصف المقرر مرّة واحدة، ويكتب الذكاء الاصطناعي الخطة كاملة — الأهداف والنتائج والقواعد والخطة الأسبوعية — ويمكنك توليد مواد الأسبوع الأول بضغطة واحدة.",
|
||||
finish: "توليد الخطة",
|
||||
reviewHint: "اضغط على \"توليد الخطة\" لبدء الذكاء الاصطناعي. يستغرق عادةً 30–60 ثانية، وستنتقل إلى صفحة الخطة عند الانتهاء.",
|
||||
steps: {
|
||||
basics: "الأساسيات",
|
||||
basicsDesc: "سمِّ المقرر وحدّد المستوى والمدة وعدد الساعات الأسبوعية.",
|
||||
coverage: "التغطية",
|
||||
coverageDesc: "أخبر الذكاء الاصطناعي بتوزيع الساعات على المهارات ومن هم المتعلّمون.",
|
||||
scope: "النطاق",
|
||||
scopeDesc: "اختياري: تركيز القواعد، والمصادر، وملاحظات حرّة.",
|
||||
review: "المراجعة",
|
||||
reviewDesc: "راجع البيانات قبل بدء التوليد.",
|
||||
},
|
||||
fields: {
|
||||
title: "عنوان المقرر",
|
||||
cefr: "مستوى CEFR",
|
||||
cefrPlaceholder: "اختر مستوى",
|
||||
totalWeeks: "إجمالي الأسابيع",
|
||||
contactHours: "عدد الساعات أسبوعياً",
|
||||
skillsDivision: "توزيع المهارات",
|
||||
skillsDivisionHint:
|
||||
"صيغة حرّة — مثال: \"10 س/أسبوع قراءة وكتابة + 8 س/أسبوع استماع ومحادثة\". اتركه فارغاً ليقرّر الذكاء الاصطناعي.",
|
||||
learnerProfile: "ملف المتعلّمين",
|
||||
learnerProfilePlaceholder: "من هم المتعلّمون؟ الفئة العمرية، اللغة الأم، الخلفية الدراسية، الأهداف…",
|
||||
grammarFocus: "تركيز القواعد (Enter للإضافة)",
|
||||
grammarFocusPlaceholder: "مثال: المضارع البسيط",
|
||||
resources: "المصادر (Enter للإضافة)",
|
||||
resourcesPlaceholder: "مثال: Pathways 1 (National Geographic)",
|
||||
notes: "ملاحظات إضافية",
|
||||
notesPlaceholder: "أي شيء آخر يحتاج الذكاء الاصطناعي معرفته.",
|
||||
},
|
||||
errors: {
|
||||
titleRequired: "يرجى إدخال عنوان المقرر.",
|
||||
cefrRequired: "يرجى اختيار مستوى CEFR.",
|
||||
weeksRange: "عدد الأسابيع يجب أن يكون 1 على الأقل.",
|
||||
},
|
||||
},
|
||||
},
|
||||
aiAdmin: {
|
||||
title: "وكلاء الذكاء الاصطناعي والأدوات",
|
||||
subtitle:
|
||||
"هيّئ الوكلاء المبنيين على LangGraph الذين يشغّلون تخطيط المقررات وتوليد الاختبارات والتمارين والمدرّس داخل LMS والتصحيح. الإعدادات الافتراضية جاهزة للاستخدام مباشرة.",
|
||||
tabs: {
|
||||
agents: "الوكلاء",
|
||||
tools: "الأدوات",
|
||||
prompts: "التعليمات",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: {
|
||||
title: "الوكلاء",
|
||||
subtitle: "مهيّأون مسبقاً لكل ركيزة في المنصة — يمكن تعديل الإعدادات الافتراضية أدناه.",
|
||||
search: "ابحث عن وكيل…",
|
||||
empty: "لا توجد وكلاء مطابقون لبحثك.",
|
||||
},
|
||||
detail: {
|
||||
configure: "ضبط الإعدادات",
|
||||
empty: "اختر وكيلاً لعرض إعداداته.",
|
||||
graph: "نوع الرسم البياني",
|
||||
model: "النموذج",
|
||||
temperature: "درجة الإبداع",
|
||||
tokens: "أقصى عدد رموز",
|
||||
format: "صيغة المخرجات",
|
||||
fallback: "الاحتياطي",
|
||||
revisions: "أقصى عدد مراجعات",
|
||||
promptKey: "مفتاح القالب",
|
||||
toolsTitle: "الأدوات المفعّلة",
|
||||
noTools: "لا توجد أدوات مفعّلة لهذا الوكيل.",
|
||||
systemPrompt: "تعليمات النظام",
|
||||
noPrompt: "(فارغ)",
|
||||
},
|
||||
config: {
|
||||
title: "ضبط الإعدادات",
|
||||
saved: "تم حفظ إعدادات الوكيل",
|
||||
name: "اسم العرض",
|
||||
promptKey: "مفتاح القالب (مع نسخ)",
|
||||
description: "الوصف",
|
||||
systemPrompt: "تعليمات النظام",
|
||||
systemPromptHint:
|
||||
"إذا تم تعيين مفتاح قالب أعلاه، فإن النسخة النشطة من ذلك القالب تستبدل هذا الحقل وقت التشغيل.",
|
||||
model: "النموذج",
|
||||
fallbackModel: "النموذج الاحتياطي",
|
||||
responseFormat: "صيغة المخرجات",
|
||||
text: "نص",
|
||||
temperature: "درجة الإبداع",
|
||||
maxTokens: "أقصى عدد رموز",
|
||||
maxRevisions: "أقصى عدد مراجعات",
|
||||
graphType: "بنية الرسم البياني",
|
||||
qualityChecks: "فحوصات الجودة (مفاتيح أدوات مفصولة بفواصل)",
|
||||
tools: "الأدوات المفعّلة",
|
||||
mutates: "كتابة",
|
||||
active: "الوكيل مفعّل",
|
||||
},
|
||||
test: {
|
||||
title: "محرّك الاختبار",
|
||||
subtitle:
|
||||
"أرسل طلباً صغيراً وافحص مخرجات الوكيل واستدعاءات الأدوات وتنبيهات الجودة.",
|
||||
variables: "المتغيّرات (JSON)",
|
||||
payload: "الطلب (نص أو JSON)",
|
||||
payloadPlaceholder:
|
||||
"ماذا يجب أن يفعل الوكيل؟ مثال: «أنشئ 5 أسئلة اختيار من متعدد للقراءة لمستوى B1.»",
|
||||
run: "تشغيل الوكيل",
|
||||
running: "جارٍ التشغيل…",
|
||||
ok: "تم تنفيذ الوكيل بنجاح",
|
||||
output: "المخرجات",
|
||||
toolTrace: "سجلّ استدعاءات الأدوات",
|
||||
iterations: "التكرارات",
|
||||
revisions: "المراجعات",
|
||||
toolCalls: "استدعاءات الأدوات",
|
||||
retrievalHits: "نتائج الاسترجاع",
|
||||
qualityIssues: "تنبيهات الجودة",
|
||||
badVarsJson: "يجب أن تكون المتغيّرات بصيغة JSON صحيحة.",
|
||||
},
|
||||
graph: {
|
||||
simple: "بسيط",
|
||||
planReviewRevise: "تخطيط • مراجعة • تنقيح",
|
||||
rag: "استرجاع وتوليد",
|
||||
react: "ReAct (استدعاء أدوات)",
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
title: "أدوات الوكلاء",
|
||||
subtitle:
|
||||
"القدرات التي يمكن للوكلاء استدعاؤها. أوقف أداة لإخراجها من جميع الوكلاء دون تعديل كل واحد منهم.",
|
||||
search: "ابحث عن أداة…",
|
||||
empty: "لا توجد أدوات مطابقة لبحثك.",
|
||||
writes: "كتابة",
|
||||
toggle: {
|
||||
enabled: "تم تفعيل الأداة",
|
||||
disabled: "تم تعطيل الأداة",
|
||||
},
|
||||
col: {
|
||||
key: "المفتاح",
|
||||
name: "الاسم",
|
||||
category: "الفئة",
|
||||
description: "الوصف",
|
||||
params: "المعاملات",
|
||||
active: "نشط",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default ar;
|
||||
934
src/i18n/locales/en.ts
Normal file
934
src/i18n/locales/en.ts
Normal file
@@ -0,0 +1,934 @@
|
||||
/** English translations. Keep keys nested by feature area.
|
||||
*
|
||||
* We deliberately annotate the literal with `Translations` (not `as const`)
|
||||
* so sibling locale files can widen their string values without fighting
|
||||
* literal-string type mismatches.
|
||||
*
|
||||
* Adding a new key: add it here FIRST (English is the source of truth), then
|
||||
* mirror it in `ar.ts`. The `Translations` interface is intentionally
|
||||
* permissive (`Record<string, string>` per group) so we don't have to update
|
||||
* a giant type every time we add a string.
|
||||
*/
|
||||
export interface Translations {
|
||||
common: Record<string, string>;
|
||||
auth: Record<string, string>;
|
||||
nav: Record<string, string>;
|
||||
sidebarGroup: Record<string, string>;
|
||||
breadcrumb: Record<string, string>;
|
||||
userMenu: Record<string, string>;
|
||||
chrome: Record<string, string>;
|
||||
notifications: Record<string, string>;
|
||||
theme: Record<string, string>;
|
||||
language: Record<string, string>;
|
||||
roles: Record<string, string>;
|
||||
dashboard: Record<string, string>;
|
||||
studentDash: Record<string, string>;
|
||||
teacherDash: Record<string, string>;
|
||||
adminDash: Record<string, string>;
|
||||
examPopup: Record<string, string>;
|
||||
errors: Record<string, string>;
|
||||
ai: Record<string, string>;
|
||||
privacy: Record<string, string>;
|
||||
feedback: Record<string, string>;
|
||||
// quickSetup mixes flat strings (page chrome) and nested blocks
|
||||
// ("admin.step1.title", "teacher.quick.discussion.title") so its values
|
||||
// can be either strings or nested records. We intentionally use a loose
|
||||
// shape here rather than maintain a hand-authored deep type.
|
||||
quickSetup: Record<string, unknown>;
|
||||
/** Smart Wizard Hub + per-scenario step-by-step wizards. */
|
||||
wizardHub: Record<string, unknown>;
|
||||
wizard: Record<string, unknown>;
|
||||
/** AI course-plan generator — list, detail, wizard. */
|
||||
coursePlan: Record<string, unknown>;
|
||||
/** AI Agents & Tools configurator (the /admin/ai/prompts page). */
|
||||
aiAdmin: Record<string, unknown>;
|
||||
agents: Record<string, unknown>;
|
||||
tools: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const en: Translations = {
|
||||
common: {
|
||||
cancel: "Cancel",
|
||||
save: "Save",
|
||||
delete: "Delete",
|
||||
edit: "Edit",
|
||||
close: "Close",
|
||||
back: "Back",
|
||||
next: "Next",
|
||||
search: "Search",
|
||||
loading: "Loading…",
|
||||
error: "Error",
|
||||
retry: "Retry",
|
||||
yes: "Yes",
|
||||
no: "No",
|
||||
actions: "Actions",
|
||||
status: "Status",
|
||||
settings: "Settings",
|
||||
signOut: "Sign out",
|
||||
viewAll: "View All",
|
||||
dismiss: "Dismiss",
|
||||
pending: "Pending",
|
||||
active: "Active",
|
||||
inactive: "Inactive",
|
||||
available: "Available",
|
||||
unavailable: "Unavailable",
|
||||
name: "Name",
|
||||
email: "Email",
|
||||
user: "User",
|
||||
home: "Home",
|
||||
saving: "Saving…",
|
||||
disabled: "Disabled",
|
||||
},
|
||||
auth: {
|
||||
signIn: "Sign in",
|
||||
signInTitle: "Sign in to EnCoach",
|
||||
welcomeBack: "Welcome back",
|
||||
signInDescription: "Sign in to your account to continue",
|
||||
email: "Email",
|
||||
emailPlaceholder: "you@example.com",
|
||||
password: "Password",
|
||||
rememberMe: "Remember me",
|
||||
forgotPassword: "Forgot password?",
|
||||
needAccount: "Don't have an account?",
|
||||
signUp: "Sign up",
|
||||
invalidCredentials: "Invalid email or password",
|
||||
missingCredentials: "Please enter email and password",
|
||||
loginFailedTitle: "Login Failed",
|
||||
errorTitle: "Error",
|
||||
},
|
||||
nav: {
|
||||
smartWizard: "Smart Wizard",
|
||||
quickSetup: "Smart Setup",
|
||||
coursePlans: "Course Plans (AI)",
|
||||
adminDashboard: "Admin Dashboard",
|
||||
platformDashboard: "Platform Dashboard",
|
||||
dashboard: "Dashboard",
|
||||
courses: "Courses",
|
||||
myCourses: "My Courses",
|
||||
students: "Students",
|
||||
teachers: "Teachers",
|
||||
batches: "Batches",
|
||||
timetable: "Timetable",
|
||||
reports: "Reports",
|
||||
assignments: "Assignments",
|
||||
examsList: "Exams List",
|
||||
examStructures: "Exam Structures",
|
||||
rubrics: "Rubrics",
|
||||
generation: "Generation",
|
||||
reviewQueue: "Review Queue",
|
||||
aiPrompts: "AI Agents & Tools",
|
||||
aiFeedback: "AI Feedback",
|
||||
approvalWorkflows: "Approval Workflows",
|
||||
taxonomy: "Taxonomy",
|
||||
resources: "Resources",
|
||||
resourceLibrary: "Resource Library",
|
||||
academicYears: "Academic Years",
|
||||
departments: "Departments",
|
||||
admissions: "Admissions",
|
||||
admissionRegister: "Admission Register",
|
||||
examSessions: "Exam Sessions",
|
||||
marksheets: "Marksheets",
|
||||
studentLeave: "Student Leave",
|
||||
fees: "Fees & Payments",
|
||||
lessons: "Lessons",
|
||||
gradebook: "Gradebook",
|
||||
studentProgress: "Student Progress",
|
||||
library: "Library",
|
||||
activities: "Activities",
|
||||
facilities: "Facilities",
|
||||
users: "Users",
|
||||
entities: "Entities",
|
||||
classrooms: "Classrooms",
|
||||
userRoles: "User Roles",
|
||||
rolesPermissions: "Roles & Permissions",
|
||||
authorityMatrix: "Authority Matrix",
|
||||
studentPerformance: "Student Performance",
|
||||
statsCorporate: "Stats Corporate",
|
||||
record: "Record",
|
||||
vocabulary: "Vocabulary",
|
||||
grammar: "Grammar",
|
||||
faqManager: "FAQ Manager",
|
||||
notificationRules: "Notification Rules",
|
||||
approvalConfig: "Approval Config",
|
||||
paymentRecord: "Payment Record",
|
||||
tickets: "Tickets",
|
||||
settings: "Settings",
|
||||
profile: "Profile",
|
||||
privacy: "Privacy Center",
|
||||
subjectRegistration: "Subject Registration",
|
||||
mySubjects: "My Subjects",
|
||||
grades: "Grades",
|
||||
attendance: "Attendance",
|
||||
myJourney: "My Journey",
|
||||
discussions: "Discussions",
|
||||
messages: "Messages",
|
||||
announcements: "Announcements",
|
||||
},
|
||||
sidebarGroup: {
|
||||
overview: "Overview",
|
||||
lms: "LMS",
|
||||
adaptiveLearning: "Adaptive Learning",
|
||||
institutional: "Institutional",
|
||||
academic: "Academic",
|
||||
management: "Management",
|
||||
reports: "Reports",
|
||||
configuration: "Configuration",
|
||||
training: "Training",
|
||||
support: "Support",
|
||||
learning: "Learning",
|
||||
progress: "Progress",
|
||||
communication: "Communication",
|
||||
account: "Account",
|
||||
teaching: "Teaching",
|
||||
},
|
||||
breadcrumb: {
|
||||
home: "Home",
|
||||
admin: "Admin",
|
||||
dashboard: "Dashboard",
|
||||
platform: "Platform",
|
||||
exam: "Exam",
|
||||
review: "Review",
|
||||
ai: "AI",
|
||||
feedback: "Feedback",
|
||||
},
|
||||
userMenu: {
|
||||
profile: "Profile",
|
||||
settings: "Settings",
|
||||
logout: "Logout",
|
||||
userFallback: "User",
|
||||
},
|
||||
chrome: {
|
||||
needHelp: "Need help?",
|
||||
ticketsTooltip: "Support tickets",
|
||||
aiSearchPlaceholder: "Ask anything... e.g. 'Show students with low attendance'",
|
||||
aiSearching: "AI is searching…",
|
||||
aiRelatedQueries: "Related queries",
|
||||
aiNoResults: "No results for \"{{q}}\". Try a different question or keyword.",
|
||||
aiSearchFailedTitle: "Search failed",
|
||||
aiSearchFailedDesc: "Could not complete AI search.",
|
||||
portalSuffix: "Portal",
|
||||
adminAlt: "EnCoach",
|
||||
adminAltLong: "EnCoach — Unlock your potential with AI powered platform",
|
||||
toggleSidebar: "Toggle sidebar",
|
||||
},
|
||||
notifications: {
|
||||
title: "Notifications",
|
||||
empty: "You're all caught up.",
|
||||
ariaLabel: "Notifications",
|
||||
},
|
||||
theme: {
|
||||
title: "Theme",
|
||||
light: "Light",
|
||||
dark: "Dark",
|
||||
system: "System",
|
||||
toggleLabel: "Toggle theme",
|
||||
},
|
||||
language: {
|
||||
change: "Change language",
|
||||
label: "Language",
|
||||
},
|
||||
roles: {
|
||||
student: "student",
|
||||
teacher: "teacher",
|
||||
admin: "admin",
|
||||
developer: "developer",
|
||||
corporate: "corporate",
|
||||
mastercorporate: "master corporate",
|
||||
agent: "agent",
|
||||
},
|
||||
dashboard: {
|
||||
greetingFallback: "Student",
|
||||
},
|
||||
studentDash: {
|
||||
welcome: "Welcome back, {{name}}!",
|
||||
subtitle: "Here's an overview of your learning progress.",
|
||||
enrolledCourses: "Enrolled Courses",
|
||||
overallProgress: "Overall Progress",
|
||||
averageGrade: "Average Grade",
|
||||
totalChapters: "Total Chapters",
|
||||
myCourses: "My Courses",
|
||||
noEnrolledCourses: "No enrolled courses yet.",
|
||||
chaptersMaterials: "{{chapters}} chapters · {{materials}} materials",
|
||||
quickActions: "Quick Actions",
|
||||
enrollToStart: "Enroll in a course to get started.",
|
||||
continue: "Continue",
|
||||
start: "Start",
|
||||
chaptersDone: "{{done}}/{{total}} chapters done",
|
||||
recentGrades: "Recent Grades",
|
||||
noGradesYet: "No grades yet.",
|
||||
},
|
||||
teacherDash: {
|
||||
title: "Teacher Dashboard",
|
||||
subtitle: "Overview of your teaching activities.",
|
||||
activeCourses: "Active Courses",
|
||||
totalStudents: "Total Students",
|
||||
pendingGrading: "Pending Grading",
|
||||
avgPassRate: "Avg. Pass Rate",
|
||||
myCourses: "My Courses",
|
||||
colCourse: "Course",
|
||||
colStudents: "Students",
|
||||
colStatus: "Status",
|
||||
recentActivity: "Recent Activity",
|
||||
submitted: "Submitted {{when}}",
|
||||
pending: "Pending",
|
||||
},
|
||||
adminDash: {
|
||||
title: "Admin Dashboard",
|
||||
subtitle: "Platform overview and key metrics.",
|
||||
addStudent: "Add Student",
|
||||
newCourse: "New Course",
|
||||
totalStudents: "Total Students",
|
||||
activeCourses: "Active Courses",
|
||||
teachers: "Teachers",
|
||||
activeBatches: "Active Batches",
|
||||
openTickets: "Open Tickets",
|
||||
revenue: "Revenue",
|
||||
departments: "Departments",
|
||||
classrooms: "Classrooms",
|
||||
subjects: "Subjects",
|
||||
resources: "Resources",
|
||||
coursesOverview: "Courses Overview",
|
||||
batchCapacity: "Batch Capacity",
|
||||
noCourseData: "No course data available.",
|
||||
noBatchData: "No batch data available.",
|
||||
quickSummary: "Quick Summary",
|
||||
colModule: "Module",
|
||||
colCount: "Count",
|
||||
colStatus: "Status",
|
||||
exams: "Exams",
|
||||
examSessionsCount: "{{n}} sessions",
|
||||
assignments: "Assignments",
|
||||
acrossCourses: "across courses",
|
||||
supportTickets: "Support Tickets",
|
||||
ticketsOpenCount: "{{n}} open",
|
||||
payments: "Payments",
|
||||
revenueTotal: "{{amount}} total",
|
||||
chartCapacity: "Capacity",
|
||||
chartEnrolled: "Enrolled",
|
||||
},
|
||||
examPopup: {
|
||||
title: "Upcoming Exams ({{n}})",
|
||||
activeNow: "Active Now",
|
||||
from: "From:",
|
||||
to: "To:",
|
||||
startExam: "Start Exam",
|
||||
notAvailableYet: "Not Available Yet",
|
||||
willBeAvailable: "Exam will be available once it becomes active",
|
||||
notActive: "Exam is not currently active",
|
||||
},
|
||||
errors: {
|
||||
somethingWrong: "Something went wrong",
|
||||
unexpectedDescription: "An unexpected error occurred. Please try refreshing the page.",
|
||||
errorDetails: "Error details",
|
||||
refreshPage: "Refresh Page",
|
||||
notFoundCode: "404",
|
||||
notFoundMessage: "Oops! Page not found",
|
||||
returnHome: "Return to Home",
|
||||
},
|
||||
ai: {
|
||||
assistantLabel: "AI Assistant",
|
||||
assistantTitle: "EnCoach AI Assistant",
|
||||
placeholder: "Ask anything...",
|
||||
send: "Send",
|
||||
thinking: "Thinking…",
|
||||
emptyLine1: "Ask me anything about the platform,",
|
||||
emptyLine2: "or click a quick action above.",
|
||||
fallbackReply: "Sorry, I could not reach the assistant. Please try again in a moment.",
|
||||
replyErrorTitle: "Could not get a reply",
|
||||
replyErrorDesc: "Something went wrong. Try again.",
|
||||
quickHealth: "Platform health summary",
|
||||
quickDropouts: "Show dropout risks",
|
||||
quickCompare: "Compare course performance",
|
||||
quickBatch: "Suggest batch improvements",
|
||||
tipLabel: "AI Tip",
|
||||
insightLabel: "AI Insight",
|
||||
recommendationLabel: "AI Recommendation",
|
||||
categoryTipLabel: "AI {{category}} Tip",
|
||||
loadingTip: "Loading AI tip…",
|
||||
loadingInsights: "Loading insights…",
|
||||
loadingAlerts: "Loading alerts…",
|
||||
couldNotLoadTip: "Could not load tip.",
|
||||
couldNotLoadInsights: "Could not load insights.",
|
||||
couldNotLoadAlerts: "Could not load alerts.",
|
||||
noTipAvailable: "No tip available.",
|
||||
noTipForContext: "No tip for this context yet.",
|
||||
noInsightsAvailable: "No insights available for this view.",
|
||||
noAlertsRightNow: "No AI alerts right now.",
|
||||
alertsUnavailable: "Alerts unavailable",
|
||||
insightsUnavailable: "Insights unavailable",
|
||||
platformInsightsTitle: "AI Platform Insights",
|
||||
},
|
||||
privacy: {
|
||||
title: "Privacy Center",
|
||||
description:
|
||||
"Manage your personal data held by EnCoach under GDPR and equivalent regulations.",
|
||||
exportTitle: "Download your data",
|
||||
exportDescription:
|
||||
"We'll package your profile, entity memberships, exam attempts, answers, AI feedback, and tickets into a single JSON file.",
|
||||
exportButton: "Download my data",
|
||||
exportPreparing: "Preparing export…",
|
||||
exportSuccess: "Data export downloaded",
|
||||
deleteTitle: "Delete my account",
|
||||
deleteDescription:
|
||||
"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.",
|
||||
deleteButton: "Erase my account",
|
||||
deleteErasing: "Erasing…",
|
||||
confirmTitle: "Erase your account?",
|
||||
confirmDescription:
|
||||
"We'll anonymise your personal data and deactivate your login. Aggregate analytics will remain but will no longer be linked to you.",
|
||||
confirmTypeDelete: "Type DELETE to confirm",
|
||||
confirmErased: "Your account has been erased. Signing out…",
|
||||
},
|
||||
feedback: {
|
||||
thumbsUp: "Thumbs up",
|
||||
thumbsDown: "Thumbs down",
|
||||
whatWentWrong: "What went wrong?",
|
||||
commentPlaceholder: "e.g. Wrong answer, confusing wording, off-topic…",
|
||||
commentRequired: "Please tell us what was wrong.",
|
||||
submit: "Submit feedback",
|
||||
},
|
||||
// Smart-setup wizard. Nested so i18next's default "." key separator
|
||||
// resolves e.g. t("quickSetup.admin.step1.title").
|
||||
quickSetup: {
|
||||
adminTitle: "Smart Setup",
|
||||
adminSubtitle:
|
||||
"Everything you need to launch an exam-ready platform, in the recommended order. Tick each step off as you go — the wizard auto-detects progress.",
|
||||
teacherTitle: "Smart Setup",
|
||||
teacherSubtitle:
|
||||
"Launch a new course end-to-end, then jump to common day-to-day tasks.",
|
||||
progressLabel: "Progress",
|
||||
recommendedFlow: "Recommended flow",
|
||||
otherQuickCreates: "Other quick creates",
|
||||
ready: "Ready",
|
||||
start: "Start",
|
||||
review: "Review",
|
||||
open: "Open",
|
||||
helpAria: "Show help",
|
||||
admin: {
|
||||
step1: {
|
||||
title: "Create a rubric",
|
||||
description:
|
||||
"Define the grading criteria for Writing and Speaking tasks. Rubrics are referenced later when generating or approving exams.",
|
||||
help: "A rubric is a scoring grid (bands × criteria). You can start from a template or compose one from predefined criteria.",
|
||||
},
|
||||
step2: {
|
||||
title: "Define an exam structure",
|
||||
description:
|
||||
"Blueprint the sections, tasks, and parts that every generated or custom exam of this type must contain.",
|
||||
help: "Structures enforce consistency. E.g. an IELTS Writing structure has Task 1 (150w) + Task 2 (250w); generation will refuse to submit if either is missing.",
|
||||
},
|
||||
step3: {
|
||||
title: "Generate or create an exam",
|
||||
description:
|
||||
"Use AI generation (fastest) or hand-build a custom exam. Save as draft or submit for approval.",
|
||||
help: "Generation picks a structure + rubric and produces questions. The custom builder gives you full control for pilot/test exams.",
|
||||
},
|
||||
step4: {
|
||||
title: "Review & approve",
|
||||
description:
|
||||
"Approvers sign off on exams before students can see them. Configure the workflow once, then route submissions automatically.",
|
||||
help: "The approval queue lists every exam waiting for sign-off. Reject sends it back to the author; approve publishes it.",
|
||||
},
|
||||
step5: {
|
||||
title: "Assign to students",
|
||||
description:
|
||||
"Schedule the published exam, pick a cohort, and send it out. Students see it immediately in their portal.",
|
||||
help: "Assignments can target individual students, batches, or classrooms. Timezone-aware windows are supported.",
|
||||
},
|
||||
quick: {
|
||||
course: { title: "New course", description: "Stand up a course shell for teachers to fill with chapters." },
|
||||
resource: { title: "Upload resource", description: "Add PDFs, audio, video, or links to the shared library." },
|
||||
student: { title: "Add student", description: "Create a student account and assign them to a batch." },
|
||||
teacher: { title: "Add teacher", description: "Invite a teacher and grant teaching permissions." },
|
||||
classroom: { title: "New classroom", description: "Group students for scheduling and attendance." },
|
||||
examSession: { title: "Schedule exam session", description: "Create a proctored sitting for an institutional exam." },
|
||||
customExam: { title: "Custom exam", description: "Hand-build an exam from scratch with full control." },
|
||||
ticket: { title: "Open ticket", description: "Raise a support ticket on behalf of a user." },
|
||||
},
|
||||
},
|
||||
teacher: {
|
||||
step1: {
|
||||
title: "Create a course",
|
||||
description: "Give your course a name, pick a subject, and set its level. You can edit chapters after creation.",
|
||||
help: "Courses are the container for chapters, materials, and assignments.",
|
||||
},
|
||||
step2: {
|
||||
title: "Add chapters & content",
|
||||
description: "Open your course and add chapters with lessons, videos, quizzes, and practice tasks.",
|
||||
help: "Chapters organise learning objectives. Use the AI workbench to auto-draft content.",
|
||||
},
|
||||
step3: {
|
||||
title: "Upload resources",
|
||||
description: "Share supporting PDFs, audio, or video with your students via the library.",
|
||||
help: "Large files are fine — the server accepts up to 128 MB per upload.",
|
||||
},
|
||||
step4: {
|
||||
title: "Create an assignment",
|
||||
description: "Turn a published exam or task into an assignment with a due date and target cohort.",
|
||||
help: "Assignments auto-surface in each student's dashboard and on their timetable.",
|
||||
},
|
||||
step5: {
|
||||
title: "Track student progress",
|
||||
description: "Monitor submissions, attendance, and adaptive learning insights for your class.",
|
||||
help: "The adaptive engine flags at-risk students so you can intervene early.",
|
||||
},
|
||||
quick: {
|
||||
discussion: { title: "New discussion", description: "Start a topic thread for your class." },
|
||||
announcement: { title: "New announcement", description: "Broadcast a message to all your students." },
|
||||
attendance: { title: "Mark attendance", description: "Record today's attendance for a session." },
|
||||
},
|
||||
},
|
||||
},
|
||||
wizardHub: {
|
||||
title: "Smart Wizard",
|
||||
subtitle:
|
||||
"Pick any scenario and the wizard will walk you through it step by step. No need to hunt through settings — every Next button moves you closer to done.",
|
||||
recommendedOrder: "Recommended order",
|
||||
order: {
|
||||
rubric: "Create rubrics (Writing / Speaking)",
|
||||
structure: "Define exam structures (sections, tasks, durations)",
|
||||
generate: "Generate or create exams",
|
||||
approve: "Review & approve pending exams",
|
||||
assign: "Assign exams to students",
|
||||
},
|
||||
guided: "Guided wizards",
|
||||
advanced: "Full pages (advanced)",
|
||||
advancedBadge: "Advanced",
|
||||
aiBadge: "AI",
|
||||
startWizard: "Start wizard",
|
||||
openPage: "Open page",
|
||||
cards: {
|
||||
rubric: {
|
||||
title: "Create a rubric",
|
||||
description: "Name → skill → criteria → descriptors → review. Writing and Speaking only.",
|
||||
},
|
||||
examStructure: {
|
||||
title: "Define an exam structure",
|
||||
description: "Name → modules → writing tasks → review. Reusable blueprint for generation.",
|
||||
},
|
||||
course: {
|
||||
title: "Create a course",
|
||||
description: "Title → level & capacity → review. Chapters can be added from the course page.",
|
||||
},
|
||||
coursePlan: {
|
||||
title: "Generate a course plan (AI)",
|
||||
description: "Describe the course → AI writes objectives, per-skill outcomes, grammar scope and a week-by-week delivery plan, then produces real teaching materials per week.",
|
||||
},
|
||||
generation: {
|
||||
title: "Generate an exam",
|
||||
description: "Full AI generation page with rubric/structure pickers and per-module controls.",
|
||||
},
|
||||
approval: {
|
||||
title: "Approval workflows",
|
||||
description: "Configure who reviews which exams and see pending requests.",
|
||||
},
|
||||
assign: {
|
||||
title: "Assign to students",
|
||||
description: "Full scheduling page with student picker, windows, and timezone support.",
|
||||
},
|
||||
resource: {
|
||||
title: "Upload resource",
|
||||
description: "Add PDFs, audio, video, or links to the shared library.",
|
||||
},
|
||||
student: {
|
||||
title: "Add student",
|
||||
description: "Create a student account and enroll them in a batch.",
|
||||
},
|
||||
},
|
||||
},
|
||||
wizard: {
|
||||
back: "Back",
|
||||
next: "Next",
|
||||
finish: "Finish",
|
||||
backToHub: "Back to wizards",
|
||||
stepOf: "Step {{current}} of {{total}}",
|
||||
rubric: {
|
||||
title: "Create a rubric",
|
||||
subtitle: "Grading grid for Writing or Speaking tasks. Referenced later when generating or approving exams.",
|
||||
finish: "Create rubric",
|
||||
toastSuccess: "Rubric created",
|
||||
toastError: "Could not create rubric",
|
||||
addCriterion: "Add criterion",
|
||||
removeCriterion: "Remove criterion",
|
||||
unnamedCriterion: "(unnamed criterion)",
|
||||
descriptorHint: "Descriptors are optional. They appear to graders as guidance for each criterion.",
|
||||
maxLabel: "Max",
|
||||
moduleHint:
|
||||
"Rubrics only apply to Writing and Speaking. Listening and Reading are auto-graded and don't need one.",
|
||||
step1: {
|
||||
title: "Basics",
|
||||
description: "Give the rubric a name and pick the skill it applies to.",
|
||||
},
|
||||
step2: {
|
||||
title: "Criteria",
|
||||
description: "Add the criteria you want graders to score. Each criterion has a max score (e.g. 9 for IELTS).",
|
||||
},
|
||||
step3: {
|
||||
title: "Descriptors",
|
||||
description: "Optional: describe what each criterion measures. Graders see these as hints.",
|
||||
},
|
||||
step4: {
|
||||
title: "Review",
|
||||
description: "Double-check and create.",
|
||||
},
|
||||
labels: {
|
||||
name: "Rubric name",
|
||||
module: "Skill",
|
||||
description: "Description",
|
||||
criterionName: "Criterion name",
|
||||
maxScore: "Max score",
|
||||
},
|
||||
placeholders: {
|
||||
name: "e.g. IELTS Writing Task 2",
|
||||
description: "Short description shown to graders.",
|
||||
criterionName: "e.g. Task Response",
|
||||
descriptor: "What graders should check for this criterion.",
|
||||
},
|
||||
modules: {
|
||||
writing: "Writing",
|
||||
speaking: "Speaking",
|
||||
},
|
||||
errors: {
|
||||
nameRequired: "Please enter a name for this rubric.",
|
||||
moduleRestricted: "Rubrics apply only to Writing or Speaking.",
|
||||
criterionRequired: "Add at least one criterion.",
|
||||
criterionName: "Every criterion needs a name.",
|
||||
criterionScore: "Max score must be between 1 and 100.",
|
||||
},
|
||||
},
|
||||
structure: {
|
||||
title: "Define an exam structure",
|
||||
subtitle: "Blueprint the sections, tasks, and parts every generated or custom exam of this type must contain.",
|
||||
finish: "Create structure",
|
||||
toastSuccess: "Exam structure created",
|
||||
toastError: "Could not create exam structure",
|
||||
industryHint: "Optional: e.g. 'Business English', 'Healthcare'.",
|
||||
writingSkipped: "Writing is not in the selected modules — this step is skipped.",
|
||||
wordsSuffix: "words",
|
||||
addTask: "Add task",
|
||||
removeTask: "Remove task",
|
||||
step1: {
|
||||
title: "Basics",
|
||||
description: "Name the structure and pick its exam type.",
|
||||
},
|
||||
step2: {
|
||||
title: "Modules",
|
||||
description: "Select which modules this structure covers. Students will see these sections.",
|
||||
},
|
||||
step3: {
|
||||
title: "Writing tasks",
|
||||
description: "For writing, define each task's minimum word count.",
|
||||
},
|
||||
step4: {
|
||||
title: "Review",
|
||||
description: "Double-check and create.",
|
||||
},
|
||||
labels: {
|
||||
name: "Structure name",
|
||||
industry: "Industry / context",
|
||||
examType: "Exam type",
|
||||
modules: "Modules",
|
||||
taskLabel: "Task label",
|
||||
minWords: "Min words",
|
||||
},
|
||||
placeholders: {
|
||||
name: "e.g. IELTS Academic Writing",
|
||||
industry: "e.g. Business English",
|
||||
},
|
||||
examTypes: {
|
||||
academic: "Academic",
|
||||
general: "General",
|
||||
},
|
||||
modules: {
|
||||
listening: {
|
||||
title: "Listening",
|
||||
description: "Audio-based questions (auto-graded).",
|
||||
},
|
||||
reading: {
|
||||
title: "Reading",
|
||||
description: "Passage-based questions (auto-graded).",
|
||||
},
|
||||
writing: {
|
||||
title: "Writing",
|
||||
description: "Essay tasks graded with a rubric.",
|
||||
},
|
||||
speaking: {
|
||||
title: "Speaking",
|
||||
description: "Oral tasks graded with a rubric.",
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
nameRequired: "Please enter a name for this structure.",
|
||||
moduleRequired: "Select at least one module.",
|
||||
writingTaskRequired: "Writing needs at least one task.",
|
||||
writingTaskLabel: "Each writing task needs a label.",
|
||||
writingTaskWords: "Minimum words must be at least 1.",
|
||||
},
|
||||
},
|
||||
course: {
|
||||
title: "Create a course",
|
||||
subtitle: "A lightweight course skeleton. You can add chapters, materials and enroll students after creation.",
|
||||
finish: "Create course",
|
||||
toastSuccess: "Course created",
|
||||
toastError: "Could not create course",
|
||||
codeHint: "Leave blank to auto-generate from the title.",
|
||||
difficultyHint: "How challenging is this course overall? Used for search & filtering.",
|
||||
cefrHint: "If this course targets a specific CEFR band, pick it here.",
|
||||
step1: {
|
||||
title: "Basics",
|
||||
description: "Give your course a name and a short description.",
|
||||
},
|
||||
step2: {
|
||||
title: "Level & capacity",
|
||||
description: "Set difficulty, target CEFR level, and how many students can enroll.",
|
||||
},
|
||||
step3: {
|
||||
title: "Review",
|
||||
description: "Double-check and create.",
|
||||
},
|
||||
labels: {
|
||||
title: "Course title",
|
||||
code: "Course code",
|
||||
description: "Description",
|
||||
difficulty: "Difficulty",
|
||||
cefrLevel: "CEFR level",
|
||||
capacity: "Max capacity",
|
||||
},
|
||||
placeholders: {
|
||||
title: "e.g. Foundation English Level 1",
|
||||
code: "Auto-generated if empty",
|
||||
description: "Who is this course for? What will they learn?",
|
||||
difficulty: "Select a difficulty",
|
||||
cefr: "Select a CEFR level",
|
||||
},
|
||||
difficulty: {
|
||||
beginner: "Beginner",
|
||||
intermediate: "Intermediate",
|
||||
advanced: "Advanced",
|
||||
},
|
||||
errors: {
|
||||
titleRequired: "Please enter a course title.",
|
||||
capacityRequired: "Max capacity must be at least 1.",
|
||||
},
|
||||
},
|
||||
},
|
||||
coursePlan: {
|
||||
listTitle: "Course Plans",
|
||||
listSubtitle:
|
||||
"AI-generated curriculum outlines: objectives, per-skill learning outcomes, grammar scope, a week-by-week delivery plan, and on-demand teaching materials for each week.",
|
||||
generateNew: "Generate new plan",
|
||||
searchPlaceholder: "Search plans by name…",
|
||||
loadFailed: "Couldn't load course plans.",
|
||||
deleted: "Plan deleted.",
|
||||
deleteFailed: "Couldn't delete plan.",
|
||||
delete: "Delete",
|
||||
confirmDelete: "Delete plan \"{{name}}\"? This removes all weeks and materials.",
|
||||
emptyTitle: "No course plans yet",
|
||||
emptySubtitle:
|
||||
"Use the AI wizard to generate your first plan — it only takes about a minute.",
|
||||
open: "Open",
|
||||
backToList: "Back to course plans",
|
||||
noDescription: "No description.",
|
||||
weeksCount_one: "{{count}} week",
|
||||
weeksCount_other: "{{count}} weeks",
|
||||
materialsCount_one: "{{count}} material",
|
||||
materialsCount_other: "{{count}} materials",
|
||||
hoursPerWeek: "{{count}} hrs/week",
|
||||
weekN: "Week {{n}}",
|
||||
generateMaterials: "Generate Week materials (AI)",
|
||||
regenerateMaterials: "Regenerate Week materials",
|
||||
generating: "Generating…",
|
||||
generateHint:
|
||||
"AI will produce a reading text, listening script, speaking prompts, writing prompt, grammar mini-lesson and vocabulary for this week.",
|
||||
weekMaterialsGenerated: "Week materials generated.",
|
||||
weekMaterialsFailed: "Couldn't generate week materials.",
|
||||
generateSuccess: "Course plan generated.",
|
||||
generateFailed: "Couldn't generate course plan.",
|
||||
deliveryHint:
|
||||
"Expand any week to view the planned outcomes and generate ready-to-use teaching materials.",
|
||||
status: {
|
||||
draft: "Draft",
|
||||
generated: "Generated",
|
||||
approved: "Approved",
|
||||
archived: "Archived",
|
||||
},
|
||||
sections: {
|
||||
objectives: "Course objectives",
|
||||
outcomes: "Learning outcomes by skill",
|
||||
grammar: "Grammar scope",
|
||||
assessment: "Assessment",
|
||||
resources: "Resources",
|
||||
delivery: "Weekly delivery plan",
|
||||
},
|
||||
skill: {
|
||||
reading: "Reading",
|
||||
writing: "Writing",
|
||||
listening: "Listening",
|
||||
speaking: "Speaking",
|
||||
grammar: "Grammar",
|
||||
vocabulary: "Vocabulary",
|
||||
integrated: "Integrated",
|
||||
},
|
||||
materialType: {
|
||||
reading_text: "Reading text",
|
||||
listening_script: "Listening script",
|
||||
speaking_prompt: "Speaking prompt",
|
||||
writing_prompt: "Writing prompt",
|
||||
grammar_lesson: "Grammar lesson",
|
||||
vocabulary_list: "Vocabulary list",
|
||||
practice: "Practice",
|
||||
other: "Material",
|
||||
},
|
||||
table: {
|
||||
skill: "Skill",
|
||||
outcomes: "Outcomes",
|
||||
remarks: "Remarks",
|
||||
},
|
||||
wizard: {
|
||||
title: "Generate a course plan",
|
||||
subtitle:
|
||||
"Describe the course once. The AI writes the full outline — objectives, outcomes, grammar, weekly plan — and you can generate Week 1 teaching material in one click afterwards.",
|
||||
finish: "Generate plan",
|
||||
reviewHint:
|
||||
"Click Generate plan to start the AI. This usually takes 30–60 seconds; you'll land on the plan page once it's done.",
|
||||
steps: {
|
||||
basics: "Basics",
|
||||
basicsDesc: "Name the course and set level, duration, and weekly hours.",
|
||||
coverage: "Coverage",
|
||||
coverageDesc: "Tell the AI how hours split across skills and who the learners are.",
|
||||
scope: "Scope",
|
||||
scopeDesc: "Optional: grammar focus, resources to reference, free-form notes.",
|
||||
review: "Review",
|
||||
reviewDesc: "Double-check your brief before kicking off the generation.",
|
||||
},
|
||||
fields: {
|
||||
title: "Course title",
|
||||
cefr: "CEFR level",
|
||||
cefrPlaceholder: "Pick a level",
|
||||
totalWeeks: "Total weeks",
|
||||
contactHours: "Contact hours / week",
|
||||
skillsDivision: "Skills division",
|
||||
skillsDivisionHint:
|
||||
"Free-form — e.g. \"10 hrs/wk Reading & Writing + 8 hrs/wk Listening & Speaking\". Leave blank to let the AI decide.",
|
||||
learnerProfile: "Learner profile",
|
||||
learnerProfilePlaceholder:
|
||||
"Who are the learners? Age range, L1, prior study, goals…",
|
||||
grammarFocus: "Grammar focus (press Enter to add)",
|
||||
grammarFocusPlaceholder: "e.g. present simple",
|
||||
resources: "Resources to reference (press Enter to add)",
|
||||
resourcesPlaceholder: "e.g. Pathways 1 (National Geographic)",
|
||||
notes: "Additional notes",
|
||||
notesPlaceholder: "Anything else the AI should know.",
|
||||
},
|
||||
errors: {
|
||||
titleRequired: "Please enter a course title.",
|
||||
cefrRequired: "Please pick a CEFR level.",
|
||||
weeksRange: "Total weeks must be at least 1.",
|
||||
},
|
||||
},
|
||||
},
|
||||
aiAdmin: {
|
||||
title: "AI Agents & Tools",
|
||||
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.",
|
||||
tabs: {
|
||||
agents: "Agents",
|
||||
tools: "Tools",
|
||||
prompts: "Prompts",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: {
|
||||
title: "Agents",
|
||||
subtitle: "Pre-configured for every platform pillar — edit defaults below.",
|
||||
search: "Search agents…",
|
||||
empty: "No agents match your search.",
|
||||
},
|
||||
detail: {
|
||||
configure: "Configure",
|
||||
empty: "Select an agent to inspect its configuration.",
|
||||
graph: "Graph",
|
||||
model: "Model",
|
||||
temperature: "Temperature",
|
||||
tokens: "Max tokens",
|
||||
format: "Output format",
|
||||
fallback: "Fallback",
|
||||
revisions: "Max revisions",
|
||||
promptKey: "Prompt key",
|
||||
toolsTitle: "Enabled tools",
|
||||
noTools: "This agent has no tools enabled.",
|
||||
systemPrompt: "System prompt",
|
||||
noPrompt: "(empty)",
|
||||
},
|
||||
config: {
|
||||
title: "Configure",
|
||||
saved: "Agent configuration saved",
|
||||
name: "Display name",
|
||||
promptKey: "Prompt key (versioned)",
|
||||
description: "Description",
|
||||
systemPrompt: "System prompt",
|
||||
systemPromptHint:
|
||||
"If a prompt key is set above, the active version of that prompt overrides this field at runtime.",
|
||||
model: "Model",
|
||||
fallbackModel: "Fallback model",
|
||||
responseFormat: "Output format",
|
||||
text: "Text",
|
||||
temperature: "Temperature",
|
||||
maxTokens: "Max tokens",
|
||||
maxRevisions: "Max revisions",
|
||||
graphType: "Graph topology",
|
||||
qualityChecks: "Quality checks (comma-separated tool keys)",
|
||||
tools: "Enabled tools",
|
||||
mutates: "writes",
|
||||
active: "Agent is active",
|
||||
},
|
||||
test: {
|
||||
title: "Test runner",
|
||||
subtitle:
|
||||
"Send a small payload and inspect the agent's output, tool calls, and quality issues.",
|
||||
variables: "Variables (JSON)",
|
||||
payload: "Payload (text or JSON)",
|
||||
payloadPlaceholder:
|
||||
"What should the agent do? e.g. 'Generate 5 MCQ for B1 reading.'",
|
||||
run: "Run agent",
|
||||
running: "Running…",
|
||||
ok: "Agent ran successfully",
|
||||
output: "Output",
|
||||
toolTrace: "Tool trace",
|
||||
iterations: "Iterations",
|
||||
revisions: "Revisions",
|
||||
toolCalls: "Tool calls",
|
||||
retrievalHits: "Retrieval hits",
|
||||
qualityIssues: "Quality issues",
|
||||
badVarsJson: "Variables must be valid JSON.",
|
||||
},
|
||||
graph: {
|
||||
simple: "Simple",
|
||||
planReviewRevise: "Plan • Review • Revise",
|
||||
rag: "RAG",
|
||||
react: "ReAct (tool-calling)",
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
title: "Agent tools",
|
||||
subtitle:
|
||||
"Capabilities your agents can call. Toggle a tool off to take it out of every agent without editing each one.",
|
||||
search: "Search tools…",
|
||||
empty: "No tools match your search.",
|
||||
writes: "writes",
|
||||
toggle: {
|
||||
enabled: "Tool enabled",
|
||||
disabled: "Tool disabled",
|
||||
},
|
||||
col: {
|
||||
key: "Key",
|
||||
name: "Name",
|
||||
category: "Category",
|
||||
description: "Description",
|
||||
params: "Params",
|
||||
active: "Active",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default en;
|
||||
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,316 @@ 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";
|
||||
|
||||
function getAccessToken(): string | null {
|
||||
return localStorage.getItem(ACCESS_KEY);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
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), {
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "POST",
|
||||
headers: buildHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async patch<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "PATCH",
|
||||
headers: buildHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async put<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "PUT",
|
||||
headers: buildHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async delete<T>(path: string): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "DELETE",
|
||||
headers: buildHeaders(),
|
||||
});
|
||||
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>
|
||||
|
||||
@@ -66,11 +66,11 @@ export default function EntitiesPage() {
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const raw = entitiesQ.data;
|
||||
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 : [];
|
||||
})();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
599
src/pages/admin/AIPromptEditor.tsx
Normal file
599
src/pages/admin/AIPromptEditor.tsx
Normal file
@@ -0,0 +1,599 @@
|
||||
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 { AIToolsPanel } from "@/pages/admin/AIToolsPanel";
|
||||
import type { AIPromptSummary } from "@/types/ai-prompt";
|
||||
import {
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
History,
|
||||
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>
|
||||
</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>
|
||||
</Tabs>
|
||||
</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>
|
||||
|
||||
423
src/pages/admin/AdminCoursePlanDetail.tsx
Normal file
423
src/pages/admin/AdminCoursePlanDetail.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import { useMemo } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
Calendar,
|
||||
ClipboardList,
|
||||
Headphones,
|
||||
Library,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
PenSquare,
|
||||
Sparkles,
|
||||
Type,
|
||||
Wand2,
|
||||
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";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { coursePlanService } from "@/services/coursePlan.service";
|
||||
import { describeApiError } from "@/lib/api-client";
|
||||
import type {
|
||||
CoursePlan,
|
||||
CoursePlanMaterial,
|
||||
CoursePlanSkill,
|
||||
CoursePlanWeek,
|
||||
} from "@/types";
|
||||
|
||||
/**
|
||||
* Detail page for a single AI-generated course plan.
|
||||
*
|
||||
* Sections:
|
||||
* - Header with course metadata + CEFR badge
|
||||
* - Course description
|
||||
* - Objectives list
|
||||
* - Learning outcomes grouped by skill
|
||||
* - Grammar scope
|
||||
* - Assessment breakdown
|
||||
* - Resources
|
||||
* - Weekly delivery plan (accordion). Each week shows the items table
|
||||
* from the AI plus a "Generate materials" button. When the user
|
||||
* clicks it, we POST to `/weeks/:n/materials` and re-render the
|
||||
* week content with the returned reading/listening/grammar/…
|
||||
* materials.
|
||||
*/
|
||||
|
||||
const SKILL_ICONS: Record<string, LucideIcon> = {
|
||||
reading: BookOpen,
|
||||
writing: PenSquare,
|
||||
listening: Headphones,
|
||||
speaking: Mic,
|
||||
grammar: Type,
|
||||
vocabulary: Library,
|
||||
integrated: MessageSquare,
|
||||
};
|
||||
|
||||
export default function AdminCoursePlanDetail() {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams<{ planId: string }>();
|
||||
const planId = Number(params.planId);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["course-plan", planId],
|
||||
queryFn: () => coursePlanService.get(planId),
|
||||
enabled: Number.isFinite(planId) && planId > 0,
|
||||
});
|
||||
|
||||
const plan = data?.data as CoursePlan | undefined;
|
||||
|
||||
const generateMut = useMutation({
|
||||
mutationFn: (weekNumber: number) =>
|
||||
coursePlanService.generateWeekMaterials(planId, weekNumber),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["course-plan", planId] });
|
||||
toast.success(t("coursePlan.weekMaterialsGenerated"));
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(describeApiError(err, t("coursePlan.weekMaterialsFailed"))),
|
||||
});
|
||||
|
||||
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">
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 text-muted-foreground">
|
||||
<Link to="/admin/course-plans" className="flex items-center gap-1">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("coursePlan.backToList")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-20 rounded-lg" />
|
||||
<Skeleton className="h-40 rounded-lg" />
|
||||
<Skeleton className="h-60 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>
|
||||
)}
|
||||
|
||||
{plan && (
|
||||
<>
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="space-y-1 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.skills_division && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{plan.skills_division}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* 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((obj, i) => (
|
||||
<li key={i}>{obj}</li>
|
||||
))}
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Outcomes per skill */}
|
||||
{Object.keys(plan.outcomes).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
{t("coursePlan.sections.outcomes")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{(Object.keys(plan.outcomes) as CoursePlanSkill[]).map((skill) => {
|
||||
const list = plan.outcomes[skill];
|
||||
if (!list?.length) return null;
|
||||
const Icon = SKILL_ICONS[skill] ?? ClipboardList;
|
||||
return (
|
||||
<div key={skill}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
<div className="font-medium capitalize">{t(`coursePlan.skill.${skill}`, skill)}</div>
|
||||
</div>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{list.map((o) => (
|
||||
<li key={o.code} className="flex gap-2">
|
||||
<Badge variant="outline" className="font-mono text-[10px] shrink-0">
|
||||
{o.code}
|
||||
</Badge>
|
||||
<span className="flex-1">{o.description}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Grammar scope */}
|
||||
{plan.grammar.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Type className="h-4 w-4 text-primary" />
|
||||
{t("coursePlan.sections.grammar")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{plan.grammar.map((g) => (
|
||||
<li key={g.code}>
|
||||
<div className="font-medium">
|
||||
<Badge variant="outline" className="font-mono text-[10px] mr-2">
|
||||
{g.code}
|
||||
</Badge>
|
||||
{g.label}
|
||||
</div>
|
||||
{g.sub_items?.length ? (
|
||||
<ul className="list-disc list-inside ml-6 mt-1 text-muted-foreground text-xs">
|
||||
{g.sub_items.map((s, i) => (
|
||||
<li key={i}>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Weekly delivery plan */}
|
||||
{plan.weeks && plan.weeks.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Calendar 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((week) => (
|
||||
<WeekAccordionItem
|
||||
key={week.id}
|
||||
week={week}
|
||||
materials={materialsByWeek.get(week.week_number) ?? []}
|
||||
generating={generateMut.isPending && generateMut.variables === week.week_number}
|
||||
onGenerate={() => generateMut.mutate(week.week_number)}
|
||||
/>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WeekAccordionItem({
|
||||
week,
|
||||
materials,
|
||||
generating,
|
||||
onGenerate,
|
||||
}: {
|
||||
week: CoursePlanWeek;
|
||||
materials: CoursePlanMaterial[];
|
||||
generating: boolean;
|
||||
onGenerate: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
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>
|
||||
{materials.length > 0 && (
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{t("coursePlan.materialsCount", { count: materials.length })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
{/* Items table */}
|
||||
{week.items.length > 0 && (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-xs uppercase text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("coursePlan.table.skill")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("coursePlan.table.outcomes")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("coursePlan.table.remarks")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{week.items.map((item, i) => {
|
||||
const Icon = SKILL_ICONS[item.skill] ?? ClipboardList;
|
||||
return (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="px-3 py-2">
|
||||
<span className="flex items-center gap-1.5 capitalize">
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{item.skill}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 flex flex-wrap gap-1">
|
||||
{item.outcome_codes.map((c) => (
|
||||
<Badge
|
||||
key={c}
|
||||
variant="outline"
|
||||
className="font-mono text-[10px]"
|
||||
>
|
||||
{c}
|
||||
</Badge>
|
||||
))}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{item.remarks || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate materials */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" onClick={onGenerate} disabled={generating}>
|
||||
<Wand2 className="mr-1 h-4 w-4" />
|
||||
{generating
|
||||
? t("coursePlan.generating")
|
||||
: materials.length > 0
|
||||
? t("coursePlan.regenerateMaterials")
|
||||
: t("coursePlan.generateMaterials")}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("coursePlan.generateHint")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Generated materials */}
|
||||
{materials.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{materials.map((m) => (
|
||||
<MaterialCard key={m.id} material={m} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
}
|
||||
|
||||
function MaterialCard({ material }: { material: CoursePlanMaterial }) {
|
||||
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>
|
||||
</div>
|
||||
{material.summary && (
|
||||
<CardDescription>{material.summary}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="whitespace-pre-wrap text-xs font-mono bg-muted/40 rounded-md p-3 max-h-80 overflow-auto">
|
||||
{material.body_text || JSON.stringify(material.body, null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -216,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();
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ 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 } from "@/lib/api-client";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { TaxonomyCascade } from "@/components/TaxonomyCascade";
|
||||
import type { Resource, ResourceTag } from "@/types";
|
||||
@@ -176,7 +177,7 @@ function EditResourceDialog({
|
||||
</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({
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export default function TaxonomyManager() {
|
||||
|
||||
const [showAddTopic, setShowAddTopic] = useState<number | null>(null);
|
||||
const [newTopicName, setNewTopicName] = useState("");
|
||||
const [newTopicDifficulty, setNewTopicDifficulty] = useState("medium");
|
||||
const [newTopicDifficulty, setNewTopicDifficulty] = useState<"easy" | "medium" | "hard">("medium");
|
||||
const [newTopicHours, setNewTopicHours] = useState("1");
|
||||
|
||||
const [editingSubjectId, setEditingSubjectId] = useState<number | null>(null);
|
||||
@@ -62,7 +62,7 @@ export default function TaxonomyManager() {
|
||||
|
||||
const [editingTopicId, setEditingTopicId] = useState<number | null>(null);
|
||||
const [editTopicName, setEditTopicName] = useState("");
|
||||
const [editTopicDifficulty, setEditTopicDifficulty] = useState("medium");
|
||||
const [editTopicDifficulty, setEditTopicDifficulty] = useState<"easy" | "medium" | "hard">("medium");
|
||||
const [editTopicHours, setEditTopicHours] = useState("1");
|
||||
const [editTopicDesc, setEditTopicDesc] = useState("");
|
||||
|
||||
@@ -142,7 +142,7 @@ export default function TaxonomyManager() {
|
||||
const startEditTopic = (t: { id: number; name: string; difficulty_level?: string; estimated_hours?: number; description?: string }) => {
|
||||
setEditingTopicId(t.id);
|
||||
setEditTopicName(t.name);
|
||||
setEditTopicDifficulty(t.difficulty_level ?? "medium");
|
||||
setEditTopicDifficulty((t.difficulty_level as "easy" | "medium" | "hard") ?? "medium");
|
||||
setEditTopicHours(String(t.estimated_hours ?? 1));
|
||||
setEditTopicDesc(t.description ?? "");
|
||||
};
|
||||
@@ -333,7 +333,7 @@ export default function TaxonomyManager() {
|
||||
{showAddTopic === domain.id && (
|
||||
<div className="flex items-center gap-2 p-2 rounded border bg-muted/50">
|
||||
<Input placeholder="Topic name" value={newTopicName} onChange={e => setNewTopicName(e.target.value)} className="h-8 text-sm" />
|
||||
<Select value={newTopicDifficulty} onValueChange={setNewTopicDifficulty}>
|
||||
<Select value={newTopicDifficulty} onValueChange={(v) => setNewTopicDifficulty(v as "easy" | "medium" | "hard")}>
|
||||
<SelectTrigger className="w-28 h-8"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="easy">Easy</SelectItem>
|
||||
@@ -352,7 +352,7 @@ export default function TaxonomyManager() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input value={editTopicName} onChange={e => setEditTopicName(e.target.value)} className="h-7 text-sm flex-1" placeholder="Topic name" />
|
||||
<Select value={editTopicDifficulty} onValueChange={setEditTopicDifficulty}>
|
||||
<Select value={editTopicDifficulty} onValueChange={(v) => setEditTopicDifficulty(v as "easy" | "medium" | "hard")}>
|
||||
<SelectTrigger className="w-28 h-7"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="easy">Easy</SelectItem>
|
||||
|
||||
373
src/pages/admin/wizards/CoursePlanWizard.tsx
Normal file
373
src/pages/admin/wizards/CoursePlanWizard.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { coursePlanService } from "@/services/coursePlan.service";
|
||||
import { describeApiError } from "@/lib/api-client";
|
||||
import type { CoursePlanGenerateBrief } from "@/types";
|
||||
|
||||
/**
|
||||
* AI course-plan generation wizard.
|
||||
*
|
||||
* Four steps:
|
||||
* 1. Basics — title, CEFR level, total weeks, contact hours.
|
||||
* 2. Coverage — skills division (e.g. "10 hrs Reading+Writing /
|
||||
* 8 hrs Listening+Speaking"), learner profile.
|
||||
* 3. Scope — grammar focus chips, resource citations chips,
|
||||
* optional free-form notes.
|
||||
* 4. Review — finish → POST /api/ai/course-plan which triggers
|
||||
* the OpenAI call, persists the plan, and returns
|
||||
* the finished record; we navigate to the detail
|
||||
* page so the user can start generating Week 1
|
||||
* materials immediately.
|
||||
*/
|
||||
|
||||
interface CoursePlanWizardState {
|
||||
title: string;
|
||||
cefr_level: string;
|
||||
total_weeks: number;
|
||||
contact_hours_per_week: number;
|
||||
skills_division: string;
|
||||
learner_profile: string;
|
||||
grammar_focus: string[];
|
||||
resources: string[];
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const CEFR_OPTIONS = [
|
||||
{ value: "pre_a1", label: "Pre-A1" },
|
||||
{ value: "a1", label: "A1" },
|
||||
{ value: "a2", label: "A2" },
|
||||
{ value: "b1", label: "B1" },
|
||||
{ value: "b2", label: "B2" },
|
||||
{ value: "c1", label: "C1" },
|
||||
{ value: "c2", label: "C2" },
|
||||
];
|
||||
|
||||
export default function CoursePlanWizard() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (brief: CoursePlanGenerateBrief) =>
|
||||
coursePlanService.generate(brief),
|
||||
onSuccess: (resp) => {
|
||||
qc.invalidateQueries({ queryKey: ["course-plans"] });
|
||||
toast.success(t("coursePlan.generateSuccess"));
|
||||
const id = resp?.data?.id;
|
||||
if (id) navigate(`/admin/course-plans/${id}`);
|
||||
else navigate("/admin/course-plans");
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(describeApiError(err, t("coursePlan.generateFailed")));
|
||||
},
|
||||
});
|
||||
|
||||
const steps: WizardStepDef<CoursePlanWizardState>[] = [
|
||||
{
|
||||
id: "basics",
|
||||
titleKey: "coursePlan.wizard.steps.basics",
|
||||
descriptionKey: "coursePlan.wizard.steps.basicsDesc",
|
||||
validate: (s) => {
|
||||
if (!s.title.trim()) return t("coursePlan.wizard.errors.titleRequired");
|
||||
if (!s.cefr_level) return t("coursePlan.wizard.errors.cefrRequired");
|
||||
if (!s.total_weeks || s.total_weeks < 1)
|
||||
return t("coursePlan.wizard.errors.weeksRange");
|
||||
return null;
|
||||
},
|
||||
render: ({ state, update }) => (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="title">{t("coursePlan.wizard.fields.title")}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="General English 1"
|
||||
value={state.title}
|
||||
onChange={(e) => update({ title: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("coursePlan.wizard.fields.cefr")}</Label>
|
||||
<Select
|
||||
value={state.cefr_level}
|
||||
onValueChange={(v) => update({ cefr_level: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("coursePlan.wizard.fields.cefrPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CEFR_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="weeks">{t("coursePlan.wizard.fields.totalWeeks")}</Label>
|
||||
<Input
|
||||
id="weeks"
|
||||
type="number"
|
||||
min={1}
|
||||
max={30}
|
||||
value={state.total_weeks}
|
||||
onChange={(e) => update({ total_weeks: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="hours">{t("coursePlan.wizard.fields.contactHours")}</Label>
|
||||
<Input
|
||||
id="hours"
|
||||
type="number"
|
||||
min={1}
|
||||
max={40}
|
||||
value={state.contact_hours_per_week}
|
||||
onChange={(e) =>
|
||||
update({ contact_hours_per_week: Number(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "coverage",
|
||||
titleKey: "coursePlan.wizard.steps.coverage",
|
||||
descriptionKey: "coursePlan.wizard.steps.coverageDesc",
|
||||
render: ({ state, update }) => (
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<Label htmlFor="skills">
|
||||
{t("coursePlan.wizard.fields.skillsDivision")}
|
||||
</Label>
|
||||
<Input
|
||||
id="skills"
|
||||
placeholder="10 hrs/wk Reading & Writing + 8 hrs/wk Listening & Speaking"
|
||||
value={state.skills_division}
|
||||
onChange={(e) => update({ skills_division: e.target.value })}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("coursePlan.wizard.fields.skillsDivisionHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="profile">
|
||||
{t("coursePlan.wizard.fields.learnerProfile")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="profile"
|
||||
rows={3}
|
||||
placeholder={t("coursePlan.wizard.fields.learnerProfilePlaceholder")}
|
||||
value={state.learner_profile}
|
||||
onChange={(e) => update({ learner_profile: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "scope",
|
||||
titleKey: "coursePlan.wizard.steps.scope",
|
||||
descriptionKey: "coursePlan.wizard.steps.scopeDesc",
|
||||
render: ({ state, update }) => (
|
||||
<div className="grid gap-4">
|
||||
<ChipInput
|
||||
label={t("coursePlan.wizard.fields.grammarFocus")}
|
||||
placeholder={t("coursePlan.wizard.fields.grammarFocusPlaceholder")}
|
||||
values={state.grammar_focus}
|
||||
onChange={(next) => update({ grammar_focus: next })}
|
||||
/>
|
||||
<ChipInput
|
||||
label={t("coursePlan.wizard.fields.resources")}
|
||||
placeholder={t("coursePlan.wizard.fields.resourcesPlaceholder")}
|
||||
values={state.resources}
|
||||
onChange={(next) => update({ resources: next })}
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="notes">{t("coursePlan.wizard.fields.notes")}</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
rows={3}
|
||||
placeholder={t("coursePlan.wizard.fields.notesPlaceholder")}
|
||||
value={state.notes}
|
||||
onChange={(e) => update({ notes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
titleKey: "coursePlan.wizard.steps.review",
|
||||
descriptionKey: "coursePlan.wizard.steps.reviewDesc",
|
||||
render: ({ state }) => (
|
||||
<div className="space-y-3 text-sm">
|
||||
<ReviewRow label={t("coursePlan.wizard.fields.title")} value={state.title} />
|
||||
<ReviewRow
|
||||
label={t("coursePlan.wizard.fields.cefr")}
|
||||
value={
|
||||
CEFR_OPTIONS.find((o) => o.value === state.cefr_level)?.label ||
|
||||
state.cefr_level ||
|
||||
"—"
|
||||
}
|
||||
/>
|
||||
<ReviewRow
|
||||
label={t("coursePlan.wizard.fields.totalWeeks")}
|
||||
value={String(state.total_weeks || "—")}
|
||||
/>
|
||||
<ReviewRow
|
||||
label={t("coursePlan.wizard.fields.contactHours")}
|
||||
value={String(state.contact_hours_per_week || "—")}
|
||||
/>
|
||||
<ReviewRow
|
||||
label={t("coursePlan.wizard.fields.skillsDivision")}
|
||||
value={state.skills_division || "—"}
|
||||
/>
|
||||
<ReviewRow
|
||||
label={t("coursePlan.wizard.fields.learnerProfile")}
|
||||
value={state.learner_profile || "—"}
|
||||
/>
|
||||
<ReviewRow
|
||||
label={t("coursePlan.wizard.fields.grammarFocus")}
|
||||
value={state.grammar_focus.join(", ") || "—"}
|
||||
/>
|
||||
<ReviewRow
|
||||
label={t("coursePlan.wizard.fields.resources")}
|
||||
value={state.resources.join(", ") || "—"}
|
||||
/>
|
||||
<ReviewRow
|
||||
label={t("coursePlan.wizard.fields.notes")}
|
||||
value={state.notes || "—"}
|
||||
/>
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
{t("coursePlan.wizard.reviewHint")}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<StepWizard<CoursePlanWizardState>
|
||||
titleKey="coursePlan.wizard.title"
|
||||
subtitleKey="coursePlan.wizard.subtitle"
|
||||
backTo="/admin/smart-wizard"
|
||||
steps={steps}
|
||||
submitting={mutation.isPending}
|
||||
finishLabelKey="coursePlan.wizard.finish"
|
||||
initialState={{
|
||||
title: "",
|
||||
cefr_level: "a2",
|
||||
total_weeks: 12,
|
||||
contact_hours_per_week: 18,
|
||||
skills_division: "",
|
||||
learner_profile: "",
|
||||
grammar_focus: [],
|
||||
resources: [],
|
||||
notes: "",
|
||||
}}
|
||||
onFinish={async (state) => {
|
||||
await mutation.mutateAsync({
|
||||
title: state.title.trim(),
|
||||
cefr_level: state.cefr_level,
|
||||
total_weeks: state.total_weeks,
|
||||
contact_hours_per_week: state.contact_hours_per_week,
|
||||
skills_division: state.skills_division.trim() || undefined,
|
||||
learner_profile: state.learner_profile.trim() || undefined,
|
||||
grammar_focus: state.grammar_focus.length
|
||||
? state.grammar_focus
|
||||
: undefined,
|
||||
resources: state.resources.length ? state.resources : undefined,
|
||||
notes: state.notes.trim() || undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="col-span-1 text-muted-foreground">{label}</div>
|
||||
<div className="col-span-2 font-medium break-words">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny chip input: press Enter (or comma) to commit a value. Used for
|
||||
* grammar focus items and resource citations. Kept local because it's
|
||||
* not worth promoting to a reusable component yet.
|
||||
*/
|
||||
function ChipInput({
|
||||
label,
|
||||
placeholder,
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
values: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const commit = () => {
|
||||
const clean = draft.trim();
|
||||
if (!clean) return;
|
||||
if (!values.includes(clean)) onChange([...values, clean]);
|
||||
setDraft("");
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<Label>{label}</Label>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 rounded-md border px-2 py-2">
|
||||
{values.map((v, i) => (
|
||||
<Badge key={`${v}-${i}`} variant="secondary" className="gap-1">
|
||||
{v}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 rounded-sm hover:bg-destructive/20"
|
||||
onClick={() => onChange(values.filter((_, idx) => idx !== i))}
|
||||
aria-label={`Remove ${v}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
} else if (e.key === "Backspace" && !draft && values.length) {
|
||||
onChange(values.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
onBlur={commit}
|
||||
className="flex-1 border-0 shadow-none focus-visible:ring-0 h-8 min-w-[8rem] px-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
271
src/pages/admin/wizards/CourseWizard.tsx
Normal file
271
src/pages/admin/wizards/CourseWizard.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { lmsService } from "@/services/lms.service";
|
||||
import { describeApiError } from "@/lib/api-client";
|
||||
import type { CourseCreateRequest } from "@/types";
|
||||
|
||||
/**
|
||||
* Course creation wizard (3 steps).
|
||||
*
|
||||
* 1. Basics — title, code (auto-derived from title by default), description
|
||||
* 2. Level & capacity — difficulty, CEFR level, max capacity
|
||||
* 3. Review → Finish posts to lmsService.createCourse
|
||||
*
|
||||
* Mirrors the fields of the existing AdminCourses "New course" dialog but
|
||||
* surfaced as a guided flow. Fields the admin normally leaves blank
|
||||
* (topic_ids, tag_ids, subject_id) are omitted here; they can be refined
|
||||
* on the full course page after the course is created.
|
||||
*/
|
||||
|
||||
interface CourseWizardState {
|
||||
title: string;
|
||||
code: string;
|
||||
description: string;
|
||||
difficulty_level: "beginner" | "intermediate" | "advanced" | "";
|
||||
cefr_level: string;
|
||||
max_capacity: number;
|
||||
}
|
||||
|
||||
const INITIAL: CourseWizardState = {
|
||||
title: "",
|
||||
code: "",
|
||||
description: "",
|
||||
difficulty_level: "",
|
||||
cefr_level: "",
|
||||
max_capacity: 30,
|
||||
};
|
||||
|
||||
/**
|
||||
* Odoo `op.course.cefr_level` is a Selection field with lowercase keys
|
||||
* (`pre_a1`, `a1`, `a2`, …). Sending `"A2"` raises
|
||||
* `Wrong value for op.course.cefr_level` (HTTP 500). We display the
|
||||
* uppercase label but always submit the lowercase key.
|
||||
*/
|
||||
const CEFR_LEVELS: { value: string; label: string }[] = [
|
||||
{ value: "pre_a1", label: "Pre-A1" },
|
||||
{ value: "a1", label: "A1" },
|
||||
{ value: "a2", label: "A2" },
|
||||
{ value: "b1", label: "B1" },
|
||||
{ value: "b2", label: "B2" },
|
||||
{ value: "c1", label: "C1" },
|
||||
{ value: "c2", label: "C2" },
|
||||
];
|
||||
|
||||
function deriveCode(title: string): string {
|
||||
return title.trim().toUpperCase().replace(/\s+/g, "-").slice(0, 16);
|
||||
}
|
||||
|
||||
export default function CourseWizard() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (data: CourseCreateRequest) => lmsService.createCourse(data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
|
||||
toast.success(t("wizard.course.toastSuccess"));
|
||||
navigate("/admin/courses");
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(describeApiError(err, t("wizard.course.toastError")));
|
||||
},
|
||||
});
|
||||
|
||||
const steps: WizardStepDef<CourseWizardState>[] = [
|
||||
{
|
||||
id: "basics",
|
||||
titleKey: "wizard.course.step1.title",
|
||||
descriptionKey: "wizard.course.step1.description",
|
||||
validate: (s) => {
|
||||
if (!s.title.trim()) return t("wizard.course.errors.titleRequired");
|
||||
return null;
|
||||
},
|
||||
render: ({ state, update }) => (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="course-title">{t("wizard.course.labels.title")}</Label>
|
||||
<Input
|
||||
id="course-title"
|
||||
value={state.title}
|
||||
onChange={(e) => update({ title: e.target.value })}
|
||||
placeholder={t("wizard.course.placeholders.title")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="course-code">{t("wizard.course.labels.code")}</Label>
|
||||
<Input
|
||||
id="course-code"
|
||||
value={state.code}
|
||||
onChange={(e) => update({ code: e.target.value })}
|
||||
placeholder={deriveCode(state.title) || t("wizard.course.placeholders.code")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("wizard.course.codeHint")}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="course-desc">{t("wizard.course.labels.description")}</Label>
|
||||
<Textarea
|
||||
id="course-desc"
|
||||
value={state.description}
|
||||
onChange={(e) => update({ description: e.target.value })}
|
||||
placeholder={t("wizard.course.placeholders.description")}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
id: "level",
|
||||
titleKey: "wizard.course.step2.title",
|
||||
descriptionKey: "wizard.course.step2.description",
|
||||
validate: (s) => {
|
||||
if (s.max_capacity < 1) return t("wizard.course.errors.capacityRequired");
|
||||
return null;
|
||||
},
|
||||
render: ({ state, update }) => (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("wizard.course.labels.difficulty")}</Label>
|
||||
<Select
|
||||
value={state.difficulty_level || undefined}
|
||||
onValueChange={(v) =>
|
||||
update({ difficulty_level: v as CourseWizardState["difficulty_level"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("wizard.course.placeholders.difficulty")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="beginner">{t("wizard.course.difficulty.beginner")}</SelectItem>
|
||||
<SelectItem value="intermediate">
|
||||
{t("wizard.course.difficulty.intermediate")}
|
||||
</SelectItem>
|
||||
<SelectItem value="advanced">{t("wizard.course.difficulty.advanced")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{t("wizard.course.difficultyHint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("wizard.course.labels.cefrLevel")}</Label>
|
||||
<Select
|
||||
value={state.cefr_level || undefined}
|
||||
onValueChange={(v) => update({ cefr_level: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("wizard.course.placeholders.cefr")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CEFR_LEVELS.map((lvl) => (
|
||||
<SelectItem key={lvl.value} value={lvl.value}>
|
||||
{lvl.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{t("wizard.course.cefrHint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="course-capacity">{t("wizard.course.labels.capacity")}</Label>
|
||||
<Input
|
||||
id="course-capacity"
|
||||
type="number"
|
||||
min={1}
|
||||
value={state.max_capacity}
|
||||
onChange={(e) => update({ max_capacity: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
id: "review",
|
||||
titleKey: "wizard.course.step3.title",
|
||||
descriptionKey: "wizard.course.step3.description",
|
||||
render: ({ state }) => (
|
||||
<div className="space-y-4 text-sm">
|
||||
<ReviewRow label={t("wizard.course.labels.title")} value={state.title} />
|
||||
<ReviewRow
|
||||
label={t("wizard.course.labels.code")}
|
||||
value={state.code || deriveCode(state.title)}
|
||||
/>
|
||||
{state.description && (
|
||||
<ReviewRow
|
||||
label={t("wizard.course.labels.description")}
|
||||
value={state.description}
|
||||
wrap
|
||||
/>
|
||||
)}
|
||||
<ReviewRow
|
||||
label={t("wizard.course.labels.difficulty")}
|
||||
value={
|
||||
state.difficulty_level
|
||||
? t(`wizard.course.difficulty.${state.difficulty_level}`)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<ReviewRow
|
||||
label={t("wizard.course.labels.cefrLevel")}
|
||||
value={
|
||||
CEFR_LEVELS.find((l) => l.value === state.cefr_level)?.label || "—"
|
||||
}
|
||||
/>
|
||||
<ReviewRow
|
||||
label={t("wizard.course.labels.capacity")}
|
||||
value={String(state.max_capacity)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<StepWizard<CourseWizardState>
|
||||
titleKey="wizard.course.title"
|
||||
subtitleKey="wizard.course.subtitle"
|
||||
backTo="/admin/smart-wizard"
|
||||
steps={steps}
|
||||
initialState={INITIAL}
|
||||
submitting={createMut.isPending}
|
||||
finishLabelKey="wizard.course.finish"
|
||||
onFinish={async (state) => {
|
||||
const payload: Partial<CourseCreateRequest> = {
|
||||
title: state.title.trim(),
|
||||
code: state.code.trim() || deriveCode(state.title),
|
||||
description: state.description,
|
||||
max_capacity: state.max_capacity,
|
||||
difficulty_level: state.difficulty_level || undefined,
|
||||
cefr_level: state.cefr_level || undefined,
|
||||
};
|
||||
await createMut.mutateAsync(payload as CourseCreateRequest);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value, wrap }: { label: string; value: string; wrap?: boolean }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<div className="text-muted-foreground">{label}</div>
|
||||
<div className={wrap ? "whitespace-pre-wrap" : ""}>{value || "—"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
348
src/pages/admin/wizards/ExamStructureWizard.tsx
Normal file
348
src/pages/admin/wizards/ExamStructureWizard.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { examsService } from "@/services/exams.service";
|
||||
import { describeApiError } from "@/lib/api-client";
|
||||
import type { ExamModule, ExamStructure } from "@/types";
|
||||
|
||||
/**
|
||||
* Exam Structure creation wizard (4 steps).
|
||||
*
|
||||
* 1. Basics — name, industry, exam type (academic/general)
|
||||
* 2. Modules — which modules this structure covers (multi-select)
|
||||
* 3. Writing tasks — min-words per task (only if writing module selected)
|
||||
* 4. Review — summary → Finish posts to /api/exam-structures
|
||||
*
|
||||
* The backend expects `modules` as a JSON-serialisable array and `config`
|
||||
* as an arbitrary JSON object. We only populate writing-task config from
|
||||
* the wizard; listening/reading/speaking can be refined later on the
|
||||
* full structure edit page.
|
||||
*/
|
||||
|
||||
interface WritingTaskDraft {
|
||||
type: string;
|
||||
min_words: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface StructureWizardState {
|
||||
name: string;
|
||||
industry: string;
|
||||
exam_type: "academic" | "general";
|
||||
modules: ExamModule[];
|
||||
writing_tasks: WritingTaskDraft[];
|
||||
}
|
||||
|
||||
const INITIAL: StructureWizardState = {
|
||||
name: "",
|
||||
industry: "",
|
||||
exam_type: "academic",
|
||||
modules: ["writing"],
|
||||
writing_tasks: [
|
||||
{ type: "task_1", label: "Task 1", min_words: 150 },
|
||||
{ type: "task_2", label: "Task 2", min_words: 250 },
|
||||
],
|
||||
};
|
||||
|
||||
const ALL_MODULES: ExamModule[] = ["listening", "reading", "writing", "speaking"];
|
||||
|
||||
export default function ExamStructureWizard() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (data: Partial<ExamStructure>) =>
|
||||
examsService.createStructure(data as Partial<ExamStructure>),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["exam-structures"] });
|
||||
toast.success(t("wizard.structure.toastSuccess"));
|
||||
navigate("/admin/exam-structures");
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(describeApiError(err, t("wizard.structure.toastError")));
|
||||
},
|
||||
});
|
||||
|
||||
const steps: WizardStepDef<StructureWizardState>[] = [
|
||||
{
|
||||
id: "basics",
|
||||
titleKey: "wizard.structure.step1.title",
|
||||
descriptionKey: "wizard.structure.step1.description",
|
||||
validate: (s) => {
|
||||
if (!s.name.trim()) return t("wizard.structure.errors.nameRequired");
|
||||
return null;
|
||||
},
|
||||
render: ({ state, update }) => (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="str-name">{t("wizard.structure.labels.name")}</Label>
|
||||
<Input
|
||||
id="str-name"
|
||||
value={state.name}
|
||||
onChange={(e) => update({ name: e.target.value })}
|
||||
placeholder={t("wizard.structure.placeholders.name")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="str-industry">{t("wizard.structure.labels.industry")}</Label>
|
||||
<Input
|
||||
id="str-industry"
|
||||
value={state.industry}
|
||||
onChange={(e) => update({ industry: e.target.value })}
|
||||
placeholder={t("wizard.structure.placeholders.industry")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("wizard.structure.industryHint")}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("wizard.structure.labels.examType")}</Label>
|
||||
<Select
|
||||
value={state.exam_type}
|
||||
onValueChange={(v) => update({ exam_type: v as StructureWizardState["exam_type"] })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="academic">{t("wizard.structure.examTypes.academic")}</SelectItem>
|
||||
<SelectItem value="general">{t("wizard.structure.examTypes.general")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
id: "modules",
|
||||
titleKey: "wizard.structure.step2.title",
|
||||
descriptionKey: "wizard.structure.step2.description",
|
||||
validate: (s) => {
|
||||
if (s.modules.length === 0) return t("wizard.structure.errors.moduleRequired");
|
||||
return null;
|
||||
},
|
||||
render: ({ state, update }) => (
|
||||
<div className="space-y-2">
|
||||
{ALL_MODULES.map((m) => {
|
||||
const checked = state.modules.includes(m);
|
||||
return (
|
||||
<label
|
||||
key={m}
|
||||
className="flex items-start gap-3 rounded-md border p-3 cursor-pointer hover:bg-muted/40"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const next = v
|
||||
? [...state.modules, m]
|
||||
: state.modules.filter((x) => x !== m);
|
||||
update({ modules: next });
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{t(`wizard.structure.modules.${m}.title`)}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t(`wizard.structure.modules.${m}.description`)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
id: "writing-tasks",
|
||||
titleKey: "wizard.structure.step3.title",
|
||||
descriptionKey: "wizard.structure.step3.description",
|
||||
validate: (s) => {
|
||||
if (!s.modules.includes("writing")) return null;
|
||||
if (s.writing_tasks.length === 0) {
|
||||
return t("wizard.structure.errors.writingTaskRequired");
|
||||
}
|
||||
for (const task of s.writing_tasks) {
|
||||
if (!task.label.trim()) return t("wizard.structure.errors.writingTaskLabel");
|
||||
if (task.min_words < 1) return t("wizard.structure.errors.writingTaskWords");
|
||||
}
|
||||
return null;
|
||||
},
|
||||
render: ({ state, update }) => {
|
||||
if (!state.modules.includes("writing")) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("wizard.structure.writingSkipped")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{state.writing_tasks.map((task, i) => (
|
||||
<div key={i} className="flex items-end gap-2 rounded-md border p-3">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label htmlFor={`task-label-${i}`}>
|
||||
{t("wizard.structure.labels.taskLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`task-label-${i}`}
|
||||
value={task.label}
|
||||
onChange={(e) => {
|
||||
const next = [...state.writing_tasks];
|
||||
next[i] = { ...task, label: e.target.value };
|
||||
update({ writing_tasks: next });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-28 space-y-1.5">
|
||||
<Label htmlFor={`task-words-${i}`}>
|
||||
{t("wizard.structure.labels.minWords")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`task-words-${i}`}
|
||||
type="number"
|
||||
min={1}
|
||||
value={task.min_words}
|
||||
onChange={(e) => {
|
||||
const next = [...state.writing_tasks];
|
||||
next[i] = { ...task, min_words: Number(e.target.value) || 0 };
|
||||
update({ writing_tasks: next });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("wizard.structure.removeTask")}
|
||||
onClick={() => {
|
||||
update({
|
||||
writing_tasks: state.writing_tasks.filter((_, idx) => idx !== i),
|
||||
});
|
||||
}}
|
||||
disabled={state.writing_tasks.length === 1}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
update({
|
||||
writing_tasks: [
|
||||
...state.writing_tasks,
|
||||
{
|
||||
type: `task_${state.writing_tasks.length + 1}`,
|
||||
label: `Task ${state.writing_tasks.length + 1}`,
|
||||
min_words: 250,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{t("wizard.structure.addTask")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "review",
|
||||
titleKey: "wizard.structure.step4.title",
|
||||
descriptionKey: "wizard.structure.step4.description",
|
||||
render: ({ state }) => (
|
||||
<div className="space-y-4 text-sm">
|
||||
<ReviewRow label={t("wizard.structure.labels.name")} value={state.name} />
|
||||
<ReviewRow
|
||||
label={t("wizard.structure.labels.industry")}
|
||||
value={state.industry || "—"}
|
||||
/>
|
||||
<ReviewRow
|
||||
label={t("wizard.structure.labels.examType")}
|
||||
value={t(`wizard.structure.examTypes.${state.exam_type}`)}
|
||||
/>
|
||||
<ReviewRow
|
||||
label={t("wizard.structure.labels.modules")}
|
||||
value={state.modules
|
||||
.map((m) => t(`wizard.structure.modules.${m}.title`))
|
||||
.join(", ")}
|
||||
/>
|
||||
{state.modules.includes("writing") && (
|
||||
<div>
|
||||
<div className="font-medium mb-1">{t("wizard.structure.step3.title")}</div>
|
||||
<ul className="rounded-md border divide-y">
|
||||
{state.writing_tasks.map((task, i) => (
|
||||
<li key={i} className="flex items-center justify-between p-2">
|
||||
<span>{task.label}</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{task.min_words} {t("wizard.structure.wordsSuffix")}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<StepWizard<StructureWizardState>
|
||||
titleKey="wizard.structure.title"
|
||||
subtitleKey="wizard.structure.subtitle"
|
||||
backTo="/admin/smart-wizard"
|
||||
steps={steps}
|
||||
initialState={INITIAL}
|
||||
submitting={createMut.isPending}
|
||||
finishLabelKey="wizard.structure.finish"
|
||||
onFinish={async (state) => {
|
||||
const config: Record<string, unknown> = {
|
||||
exam_type: state.exam_type,
|
||||
};
|
||||
if (state.modules.includes("writing")) {
|
||||
config.writing = {
|
||||
tasks: state.writing_tasks.map((t) => ({
|
||||
type: t.type,
|
||||
min_words: t.min_words,
|
||||
label: t.label,
|
||||
})),
|
||||
};
|
||||
}
|
||||
await createMut.mutateAsync({
|
||||
name: state.name.trim(),
|
||||
industry: state.industry.trim(),
|
||||
modules: state.modules,
|
||||
config,
|
||||
} as unknown as Partial<ExamStructure>);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value, wrap }: { label: string; value: string; wrap?: boolean }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<div className="text-muted-foreground">{label}</div>
|
||||
<div className={wrap ? "whitespace-pre-wrap" : ""}>{value || "—"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
320
src/pages/admin/wizards/RubricWizard.tsx
Normal file
320
src/pages/admin/wizards/RubricWizard.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { examsService } from "@/services/exams.service";
|
||||
import { describeApiError } from "@/lib/api-client";
|
||||
import type { ExamModule } from "@/types";
|
||||
|
||||
/**
|
||||
* Rubric creation wizard (4 steps).
|
||||
*
|
||||
* 1. Basics — name, skill (module), description
|
||||
* 2. Criteria — rows of name + max score
|
||||
* 3. Descriptors — per-criterion optional descriptors
|
||||
* 4. Review — summary then Finish calls examsService.createRubric
|
||||
*
|
||||
* Only Writing / Speaking make sense as rubric targets because the other
|
||||
* two modules (Listening / Reading) are auto-graded; QA flagged this as an
|
||||
* explicit UX requirement earlier in the project.
|
||||
*/
|
||||
|
||||
interface CriterionDraft {
|
||||
name: string;
|
||||
max_score: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface RubricWizardState {
|
||||
name: string;
|
||||
module: ExamModule;
|
||||
description: string;
|
||||
criteria: CriterionDraft[];
|
||||
}
|
||||
|
||||
const INITIAL: RubricWizardState = {
|
||||
name: "",
|
||||
module: "writing",
|
||||
description: "",
|
||||
criteria: [
|
||||
{ name: "Task Response", max_score: 9, description: "" },
|
||||
{ name: "Coherence & Cohesion", max_score: 9, description: "" },
|
||||
],
|
||||
};
|
||||
|
||||
export default function RubricWizard() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
examsService.createRubric(data as never),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["rubrics"] });
|
||||
toast.success(t("wizard.rubric.toastSuccess"));
|
||||
navigate("/admin/rubrics");
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(describeApiError(err, t("wizard.rubric.toastError")));
|
||||
},
|
||||
});
|
||||
|
||||
const steps: WizardStepDef<RubricWizardState>[] = [
|
||||
{
|
||||
id: "basics",
|
||||
titleKey: "wizard.rubric.step1.title",
|
||||
descriptionKey: "wizard.rubric.step1.description",
|
||||
validate: (s) => {
|
||||
if (!s.name.trim()) return t("wizard.rubric.errors.nameRequired");
|
||||
if (s.module !== "writing" && s.module !== "speaking") {
|
||||
return t("wizard.rubric.errors.moduleRestricted");
|
||||
}
|
||||
return null;
|
||||
},
|
||||
render: ({ state, update }) => (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="rubric-name">{t("wizard.rubric.labels.name")}</Label>
|
||||
<Input
|
||||
id="rubric-name"
|
||||
value={state.name}
|
||||
onChange={(e) => update({ name: e.target.value })}
|
||||
placeholder={t("wizard.rubric.placeholders.name")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("wizard.rubric.labels.module")}</Label>
|
||||
<Select value={state.module} onValueChange={(v) => update({ module: v as ExamModule })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="writing">{t("wizard.rubric.modules.writing")}</SelectItem>
|
||||
<SelectItem value="speaking">{t("wizard.rubric.modules.speaking")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("wizard.rubric.moduleHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="rubric-desc">{t("wizard.rubric.labels.description")}</Label>
|
||||
<Textarea
|
||||
id="rubric-desc"
|
||||
value={state.description}
|
||||
onChange={(e) => update({ description: e.target.value })}
|
||||
placeholder={t("wizard.rubric.placeholders.description")}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
id: "criteria",
|
||||
titleKey: "wizard.rubric.step2.title",
|
||||
descriptionKey: "wizard.rubric.step2.description",
|
||||
validate: (s) => {
|
||||
if (s.criteria.length === 0) return t("wizard.rubric.errors.criterionRequired");
|
||||
for (const c of s.criteria) {
|
||||
if (!c.name.trim()) return t("wizard.rubric.errors.criterionName");
|
||||
if (c.max_score < 1 || c.max_score > 100) {
|
||||
return t("wizard.rubric.errors.criterionScore");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
render: ({ state, update }) => (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{state.criteria.map((c, i) => (
|
||||
<div key={i} className="flex items-end gap-2 rounded-md border p-3">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label htmlFor={`crit-name-${i}`}>{t("wizard.rubric.labels.criterionName")}</Label>
|
||||
<Input
|
||||
id={`crit-name-${i}`}
|
||||
value={c.name}
|
||||
onChange={(e) => {
|
||||
const next = [...state.criteria];
|
||||
next[i] = { ...c, name: e.target.value };
|
||||
update({ criteria: next });
|
||||
}}
|
||||
placeholder={t("wizard.rubric.placeholders.criterionName")}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-24 space-y-1.5">
|
||||
<Label htmlFor={`crit-score-${i}`}>{t("wizard.rubric.labels.maxScore")}</Label>
|
||||
<Input
|
||||
id={`crit-score-${i}`}
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={c.max_score}
|
||||
onChange={(e) => {
|
||||
const next = [...state.criteria];
|
||||
next[i] = { ...c, max_score: Number(e.target.value) || 0 };
|
||||
update({ criteria: next });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("wizard.rubric.removeCriterion")}
|
||||
onClick={() => {
|
||||
const next = state.criteria.filter((_, idx) => idx !== i);
|
||||
update({ criteria: next });
|
||||
}}
|
||||
disabled={state.criteria.length === 1}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
update({
|
||||
criteria: [
|
||||
...state.criteria,
|
||||
{ name: "", max_score: 9, description: "" },
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{t("wizard.rubric.addCriterion")}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
id: "descriptors",
|
||||
titleKey: "wizard.rubric.step3.title",
|
||||
descriptionKey: "wizard.rubric.step3.description",
|
||||
// Descriptors are optional — no validation.
|
||||
render: ({ state, update }) => (
|
||||
<div className="space-y-3">
|
||||
{state.criteria.map((c, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<Label htmlFor={`crit-desc-${i}`}>
|
||||
{c.name || t("wizard.rubric.unnamedCriterion")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={`crit-desc-${i}`}
|
||||
value={c.description}
|
||||
onChange={(e) => {
|
||||
const next = [...state.criteria];
|
||||
next[i] = { ...c, description: e.target.value };
|
||||
update({ criteria: next });
|
||||
}}
|
||||
placeholder={t("wizard.rubric.placeholders.descriptor")}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("wizard.rubric.descriptorHint")}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
id: "review",
|
||||
titleKey: "wizard.rubric.step4.title",
|
||||
descriptionKey: "wizard.rubric.step4.description",
|
||||
render: ({ state }) => (
|
||||
<div className="space-y-4 text-sm">
|
||||
<ReviewRow label={t("wizard.rubric.labels.name")} value={state.name} />
|
||||
<ReviewRow
|
||||
label={t("wizard.rubric.labels.module")}
|
||||
value={t(`wizard.rubric.modules.${state.module}`)}
|
||||
/>
|
||||
{state.description && (
|
||||
<ReviewRow
|
||||
label={t("wizard.rubric.labels.description")}
|
||||
value={state.description}
|
||||
wrap
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium mb-1">
|
||||
{t("wizard.rubric.step2.title")} ({state.criteria.length})
|
||||
</div>
|
||||
<ul className="rounded-md border divide-y">
|
||||
{state.criteria.map((c, i) => (
|
||||
<li key={i} className="flex items-start justify-between gap-3 p-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{c.name}</div>
|
||||
{c.description && (
|
||||
<div className="text-muted-foreground text-xs mt-0.5">
|
||||
{c.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{t("wizard.rubric.maxLabel")}: {c.max_score}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<StepWizard<RubricWizardState>
|
||||
titleKey="wizard.rubric.title"
|
||||
subtitleKey="wizard.rubric.subtitle"
|
||||
backTo="/admin/smart-wizard"
|
||||
steps={steps}
|
||||
initialState={INITIAL}
|
||||
submitting={createMut.isPending}
|
||||
finishLabelKey="wizard.rubric.finish"
|
||||
onFinish={async (state) => {
|
||||
await createMut.mutateAsync({
|
||||
name: state.name.trim(),
|
||||
module: state.module,
|
||||
description: state.description.trim(),
|
||||
criteria: state.criteria.map((c) => ({
|
||||
name: c.name.trim(),
|
||||
description: c.description.trim(),
|
||||
max_score: c.max_score,
|
||||
})),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value, wrap }: { label: string; value: string; wrap?: boolean }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[120px_1fr] gap-2">
|
||||
<div className="text-muted-foreground">{label}</div>
|
||||
<div className={wrap ? "whitespace-pre-wrap" : ""}>{value || "—"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -166,7 +166,7 @@ export default function AiIeltsCourse() {
|
||||
const band = Number(targetBand || course?.target_level || 7);
|
||||
createIelts.mutate(
|
||||
{
|
||||
skill: skillsRanked[0]?.skill ?? "writing",
|
||||
skill: (skillsRanked[0]?.skill ?? "writing") as "reading" | "listening" | "writing" | "speaking",
|
||||
target_band: Number.isFinite(band) ? band : 7,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function DiagnosticTest() {
|
||||
)}
|
||||
<Button className="w-full" onClick={handleAnswer} disabled={!selectedAnswer || answerMutation.isPending}>
|
||||
{answerMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Submit Answer <ArrowRight className="ml-1 h-4 w-4" />
|
||||
Submit Answer <ArrowRight className="ms-1 h-4 w-4 rtl:rotate-180" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function ExamResults() {
|
||||
|
||||
const { data: results, isLoading, isError } = useQuery<ExamResultsData>({
|
||||
queryKey: ["exam-results", examId],
|
||||
queryFn: () => examSessionService.getResults(Number(examId)),
|
||||
queryFn: () => examSessionService.getResults<ExamResultsData>(Number(examId)),
|
||||
enabled: !!examId,
|
||||
});
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ export default function LearningPlanPage() {
|
||||
</div>
|
||||
{(item.status === "available" || item.status === "in_progress") && (
|
||||
<Button size="sm" onClick={() => navigate(`/student/topic/${item.topic_id}`)}>
|
||||
{item.status === "in_progress" ? "Continue" : "Start"} <ArrowRight className="ml-1 h-3 w-3" />
|
||||
{item.status === "in_progress" ? "Continue" : "Start"} <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
|
||||
</Button>
|
||||
)}
|
||||
{item.status === "completed" && (
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function ProficiencyProfile() {
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => navigate(`/student/diagnostic/${subjectId}`)}>Retake Diagnostic</Button>
|
||||
<Button onClick={() => navigate(`/student/plan/${subjectId}`)}>Learning Plan <ArrowRight className="ml-1 h-4 w-4" /></Button>
|
||||
<Button onClick={() => navigate(`/student/plan/${subjectId}`)}>Learning Plan <ArrowRight className="ms-1 h-4 w-4 rtl:rotate-180" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function StudentAttendance() {
|
||||
{attendanceRecords.map(a => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell>{a.date}</TableCell>
|
||||
<TableCell>{a.courseName}</TableCell>
|
||||
<TableCell>{a.course_name}</TableCell>
|
||||
<TableCell><Badge variant={badgeVariant(a.status)} className="capitalize">{a.status}</Badge></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function StudentCourseDetail() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild><Link to="/student/courses"><ArrowLeft className="h-4 w-4" /></Link></Button>
|
||||
<Button variant="ghost" size="icon" asChild><Link to="/student/courses"><ArrowLeft className="h-4 w-4 rtl:rotate-180" /></Link></Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{course.title}</h1>
|
||||
<p className="text-muted-foreground">{course.code} · {chapters.length} chapters</p>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { BookOpen, ClipboardList, BarChart3, Calendar, ArrowRight, Play } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMyEnrolledCourses, useGrades } from "@/hooks/queries";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AiStudyCoach from "@/components/ai/AiStudyCoach";
|
||||
@@ -11,6 +12,7 @@ import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
|
||||
export default function StudentDashboard() {
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const { data: enrolledData, isLoading: lc } = useMyEnrolledCourses();
|
||||
const { data: gradesData, isLoading: lg } = useGrades();
|
||||
const myCourses = enrolledData?.items ?? [];
|
||||
@@ -22,20 +24,20 @@ export default function StudentDashboard() {
|
||||
const avgGrade = gradeRecords.length > 0
|
||||
? Math.round(gradeRecords.reduce((s, g) => s + (g.grade / g.max_grade) * 100, 0) / gradeRecords.length)
|
||||
: 0;
|
||||
const firstName = user?.name?.split(" ")[0] || "Student";
|
||||
const firstName = user?.name?.split(" ")[0] || t("dashboard.greetingFallback");
|
||||
|
||||
const stats = [
|
||||
{ label: "Enrolled Courses", value: String(myCourses.length), icon: BookOpen, color: "text-primary" },
|
||||
{ label: "Overall Progress", value: `${avgProgress}%`, icon: ClipboardList, color: "text-warning" },
|
||||
{ label: "Average Grade", value: gradeRecords.length > 0 ? `${avgGrade}%` : "N/A", icon: BarChart3, color: "text-success" },
|
||||
{ label: "Total Chapters", value: String(myCourses.reduce((s, c) => s + c.chapter_count, 0)), icon: Calendar, color: "text-info" },
|
||||
{ label: t("studentDash.enrolledCourses"), value: String(myCourses.length), icon: BookOpen, color: "text-primary" },
|
||||
{ label: t("studentDash.overallProgress"), value: `${avgProgress}%`, icon: ClipboardList, color: "text-warning" },
|
||||
{ label: t("studentDash.averageGrade"), value: gradeRecords.length > 0 ? `${avgGrade}%` : "N/A", icon: BarChart3, color: "text-success" },
|
||||
{ label: t("studentDash.totalChapters"), value: String(myCourses.reduce((s, c) => s + c.chapter_count, 0)), icon: Calendar, color: "text-info" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Welcome back, {firstName}!</h1>
|
||||
<p className="text-muted-foreground">Here's an overview of your learning progress.</p>
|
||||
<h1 className="text-2xl font-bold">{t("studentDash.welcome", { name: firstName })}</h1>
|
||||
<p className="text-muted-foreground">{t("studentDash.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<AiTipBanner context="student-dashboard" variant="tip" />
|
||||
@@ -46,12 +48,12 @@ export default function StudentDashboard() {
|
||||
{stats.map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
||||
<p className="text-2xl font-bold">{s.value}</p>
|
||||
<p className="text-2xl font-bold"><bdi>{s.value}</bdi></p>
|
||||
</div>
|
||||
<s.icon className={`h-8 w-8 ${s.color} opacity-80`} />
|
||||
<s.icon className={`h-8 w-8 ${s.color} opacity-80 shrink-0`} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -61,22 +63,28 @@ export default function StudentDashboard() {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-lg">My Courses</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild><Link to="/student/courses">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
|
||||
<CardTitle className="text-lg">{t("studentDash.myCourses")}</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to="/student/courses">
|
||||
{t("common.viewAll")} <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{myCourses.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No enrolled courses yet.</p>
|
||||
<p className="text-sm text-muted-foreground text-center py-4">{t("studentDash.noEnrolledCourses")}</p>
|
||||
) : myCourses.map((c) => (
|
||||
<Link to={`/student/courses/${c.id}`} key={c.id} className="block">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-sm truncate">{c.title || c.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{c.chapter_count} chapters · {c.total_materials} materials</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("studentDash.chaptersMaterials", { chapters: c.chapter_count, materials: c.total_materials })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="w-24"><Progress value={c.progress} className="h-2" /></div>
|
||||
<span className="text-xs font-medium w-8 text-right">{c.progress}%</span>
|
||||
<span className="text-xs font-medium w-8 text-end"><bdi>{c.progress}%</bdi></span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -87,35 +95,45 @@ export default function StudentDashboard() {
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-lg">Quick Actions</CardTitle>
|
||||
<CardTitle className="text-lg">{t("studentDash.quickActions")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{myCourses.slice(0, 3).map(c => (
|
||||
<Link key={c.id} to={`/student/courses/${c.id}`} className="flex items-center gap-3 p-3 rounded-lg border hover:bg-muted/50 transition-colors">
|
||||
<Play className="h-4 w-4 text-primary shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{c.progress > 0 ? "Continue" : "Start"} {c.title || c.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{c.completed_chapters}/{c.chapter_count} chapters done</p>
|
||||
<p className="text-sm font-medium truncate">
|
||||
{c.progress > 0 ? t("studentDash.continue") : t("studentDash.start")} {c.title || c.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("studentDash.chaptersDone", { done: c.completed_chapters, total: c.chapter_count })}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{c.progress}%</Badge>
|
||||
<Badge variant="outline"><bdi>{c.progress}%</bdi></Badge>
|
||||
</Link>
|
||||
))}
|
||||
{myCourses.length === 0 && <p className="text-sm text-muted-foreground text-center py-4">Enroll in a course to get started.</p>}
|
||||
{myCourses.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">{t("studentDash.enrollToStart")}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-lg">Recent Grades</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild><Link to="/student/grades">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
|
||||
<CardTitle className="text-lg">{t("studentDash.recentGrades")}</CardTitle>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to="/student/grades">
|
||||
{t("common.viewAll")} <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{recentGrades.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No grades yet.</p>
|
||||
<p className="text-sm text-muted-foreground text-center py-4">{t("studentDash.noGradesYet")}</p>
|
||||
) : recentGrades.map((g) => (
|
||||
<div key={g.id} className="flex items-center justify-between p-2 rounded border">
|
||||
<div><p className="text-sm font-medium">{g.assignment_title}</p><p className="text-xs text-muted-foreground">{g.course_name}</p></div>
|
||||
<span className="text-sm font-bold text-primary">{g.grade}/{g.max_grade}</span>
|
||||
<div key={g.id} className="flex items-center justify-between p-2 rounded border gap-3">
|
||||
<div className="min-w-0"><p className="text-sm font-medium truncate" dir="auto">{g.assignment_title}</p><p className="text-xs text-muted-foreground truncate" dir="auto">{g.course_name}</p></div>
|
||||
<span className="text-sm font-bold text-primary shrink-0"><bdi>{g.grade}/{g.max_grade}</bdi></span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user