Compare commits
17 Commits
v3
...
frontend-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45408acd5c | ||
|
|
bbb2b44ae0 | ||
|
|
dcb7727a4b | ||
|
|
96a2945512 | ||
|
|
a4e92d1f40 | ||
|
|
fbd58fa5a6 | ||
|
|
b02c2e7526 | ||
|
|
9b20d6ce66 | ||
|
|
000969b0b9 | ||
|
|
95938c0079 | ||
|
|
435930a827 | ||
|
|
b78124bb7b | ||
|
|
1d7ebea2af | ||
|
|
2f450a7f26 | ||
|
|
e066c595b9 | ||
|
|
74c2c9f2d2 | ||
|
|
5b2ccbfeec |
14
Dockerfile
@@ -18,13 +18,25 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.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/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8069/api/;
|
proxy_pass http://backend:8069/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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 / {
|
location / {
|
||||||
|
|||||||
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 });
|
||||||
|
});
|
||||||
@@ -6,12 +6,18 @@
|
|||||||
<title>EnCoach — IELTS & English Learning Platform</title>
|
<title>EnCoach — IELTS & English Learning Platform</title>
|
||||||
<meta name="description" content="EnCoach is a comprehensive IELTS and English learning platform for students, teachers, and corporates." />
|
<meta name="description" content="EnCoach is a comprehensive IELTS and English learning platform for students, teachers, and corporates." />
|
||||||
<meta name="author" content="EnCoach" />
|
<meta name="author" content="EnCoach" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="192x192" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="theme-color" content="#4A4068" />
|
||||||
<meta property="og:title" content="EnCoach — IELTS & English Learning Platform" />
|
<meta property="og:title" content="EnCoach — IELTS & English Learning Platform" />
|
||||||
<meta property="og:description" content="Comprehensive IELTS preparation and English learning platform." />
|
<meta property="og:description" content="Comprehensive IELTS preparation and English learning platform." />
|
||||||
|
<meta property="og:image" content="/logo-icon.png" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
162
package-lock.json
generated
@@ -42,6 +42,8 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"i18next": "^26.0.6",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.61.1",
|
"react-hook-form": "^7.61.1",
|
||||||
|
"react-i18next": "^17.0.4",
|
||||||
"react-resizable-panels": "^2.1.9",
|
"react-resizable-panels": "^2.1.9",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
@@ -60,6 +63,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.32.0",
|
"@eslint/js": "^9.32.0",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@testing-library/jest-dom": "^6.6.0",
|
"@testing-library/jest-dom": "^6.6.0",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
@@ -76,6 +80,7 @@
|
|||||||
"lovable-tagger": "^1.1.13",
|
"lovable-tagger": "^1.1.13",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tailwindcss-rtl": "^0.9.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "^8.38.0",
|
"typescript-eslint": "^8.38.0",
|
||||||
"vite": "^5.4.19",
|
"vite": "^5.4.19",
|
||||||
@@ -905,6 +910,22 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/@radix-ui/number": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||||
@@ -5480,6 +5501,15 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/http-proxy-agent": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
|
||||||
@@ -5509,6 +5539,46 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
@@ -6215,6 +6285,53 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.9",
|
"version": "8.5.9",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
|
||||||
@@ -6547,6 +6664,33 @@
|
|||||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
@@ -7157,6 +7301,13 @@
|
|||||||
"tailwindcss": ">=3.0.0 || insiders"
|
"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": {
|
"node_modules/tailwindcss/node_modules/postcss-selector-parser": {
|
||||||
"version": "6.1.2",
|
"version": "6.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||||
@@ -7369,7 +7520,7 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
@@ -8164,6 +8315,15 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/w3c-xmlserializer": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
|
||||||
|
|||||||
11
package.json
@@ -10,7 +10,9 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:install": "playwright install --with-deps chromium"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
@@ -47,6 +49,8 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"i18next": "^26.0.6",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
@@ -54,6 +58,7 @@
|
|||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.61.1",
|
"react-hook-form": "^7.61.1",
|
||||||
|
"react-i18next": "^17.0.4",
|
||||||
"react-resizable-panels": "^2.1.9",
|
"react-resizable-panels": "^2.1.9",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
@@ -65,9 +70,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.32.0",
|
"@eslint/js": "^9.32.0",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@testing-library/jest-dom": "^6.6.0",
|
"@testing-library/jest-dom": "^6.6.0",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
|
||||||
"@types/node": "^22.16.5",
|
"@types/node": "^22.16.5",
|
||||||
"@types/react": "^18.3.23",
|
"@types/react": "^18.3.23",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
@@ -81,6 +87,7 @@
|
|||||||
"lovable-tagger": "^1.1.13",
|
"lovable-tagger": "^1.1.13",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tailwindcss-rtl": "^0.9.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "^8.38.0",
|
"typescript-eslint": "^8.38.0",
|
||||||
"vite": "^5.4.19",
|
"vite": "^5.4.19",
|
||||||
|
|||||||
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"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 532 B |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 888 B |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 554 B |
BIN
public/logo-icon.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
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 |
356
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 } from "@/components/ui/toaster";
|
||||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
@@ -8,143 +10,174 @@ import ProtectedRoute from "@/components/ProtectedRoute";
|
|||||||
import StudentLayout from "@/components/StudentLayout";
|
import StudentLayout from "@/components/StudentLayout";
|
||||||
import TeacherLayout from "@/components/TeacherLayout";
|
import TeacherLayout from "@/components/TeacherLayout";
|
||||||
import AdminLmsLayout from "@/components/AdminLmsLayout";
|
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 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 { queryClient } from "@/lib/query-client";
|
||||||
import { Button } from "@/components/ui/button";
|
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() {
|
function StudentSubscriptionPlaceholder() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -156,13 +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 = () => (
|
const App = () => (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
storageKey="encoach-theme"
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<Sonner />
|
<Sonner />
|
||||||
<BrowserRouter>
|
<BrowserRouter
|
||||||
|
future={{
|
||||||
|
v7_startTransition: true,
|
||||||
|
v7_relativeSplatPath: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<Suspense fallback={<RouteFallback />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Auth routes */}
|
{/* Auth routes */}
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
@@ -197,6 +252,7 @@ const App = () => (
|
|||||||
<Route path="/student/attendance" element={<StudentAttendance />} />
|
<Route path="/student/attendance" element={<StudentAttendance />} />
|
||||||
<Route path="/student/timetable" element={<StudentTimetable />} />
|
<Route path="/student/timetable" element={<StudentTimetable />} />
|
||||||
<Route path="/student/profile" element={<StudentProfile />} />
|
<Route path="/student/profile" element={<StudentProfile />} />
|
||||||
|
<Route path="/student/privacy" element={<PrivacyCenter />} />
|
||||||
<Route path="/student/subjects" element={<SubjectSelection />} />
|
<Route path="/student/subjects" element={<SubjectSelection />} />
|
||||||
<Route path="/student/diagnostic/:subjectId" element={<DiagnosticTest />} />
|
<Route path="/student/diagnostic/:subjectId" element={<DiagnosticTest />} />
|
||||||
<Route path="/student/proficiency/:subjectId" element={<ProficiencyProfile />} />
|
<Route path="/student/proficiency/:subjectId" element={<ProficiencyProfile />} />
|
||||||
@@ -220,10 +276,12 @@ const App = () => (
|
|||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Teacher routes */}
|
{/* Teacher routes */}
|
||||||
<Route element={<ProtectedRoute allowedRoles={["teacher"]} />}>
|
<Route element={<ProtectedRoute allowedRoles={["teacher", "admin", "developer"]} />}>
|
||||||
<Route element={<TeacherLayout />}>
|
<Route element={<TeacherLayout />}>
|
||||||
<Route path="/teacher/dashboard" element={<TeacherDashboard />} />
|
<Route path="/teacher/dashboard" element={<TeacherDashboard />} />
|
||||||
|
<Route path="/teacher/quick-setup" element={<TeacherQuickSetup />} />
|
||||||
<Route path="/teacher/courses" element={<TeacherCourses />} />
|
<Route path="/teacher/courses" element={<TeacherCourses />} />
|
||||||
|
<Route path="/teacher/library" element={<TeacherLibrary />} />
|
||||||
<Route path="/teacher/courses/new" element={<CourseBuilder />} />
|
<Route path="/teacher/courses/new" element={<CourseBuilder />} />
|
||||||
<Route path="/teacher/courses/:id/edit" element={<CourseBuilder />} />
|
<Route path="/teacher/courses/:id/edit" element={<CourseBuilder />} />
|
||||||
<Route path="/teacher/assignments" element={<TeacherAssignments />} />
|
<Route path="/teacher/assignments" element={<TeacherAssignments />} />
|
||||||
@@ -231,12 +289,13 @@ const App = () => (
|
|||||||
<Route path="/teacher/attendance" element={<TeacherAttendance />} />
|
<Route path="/teacher/attendance" element={<TeacherAttendance />} />
|
||||||
<Route path="/teacher/students" element={<TeacherStudents />} />
|
<Route path="/teacher/students" element={<TeacherStudents />} />
|
||||||
<Route path="/teacher/timetable" element={<TeacherTimetable />} />
|
<Route path="/teacher/timetable" element={<TeacherTimetable />} />
|
||||||
<Route path="/teacher/courses/:id/chapters" element={<CourseChapters />} />
|
<Route path="/teacher/courses/:courseId/chapters" element={<CourseChapters />} />
|
||||||
<Route path="/teacher/courses/:id/chapters/:chapterId" element={<ChapterDetail />} />
|
<Route path="/teacher/courses/:courseId/chapters/:chapterId" element={<ChapterDetail />} />
|
||||||
<Route path="/teacher/courses/:id/workbench" element={<AiWorkbench />} />
|
<Route path="/teacher/courses/:courseId/workbench" element={<AiWorkbench />} />
|
||||||
<Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} />
|
<Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} />
|
||||||
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
|
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
|
||||||
<Route path="/teacher/profile" element={<TeacherProfile />} />
|
<Route path="/teacher/profile" element={<TeacherProfile />} />
|
||||||
|
<Route path="/teacher/privacy" element={<PrivacyCenter />} />
|
||||||
<Route path="/teacher/course/:courseId/progress" element={<CourseProgress />} />
|
<Route path="/teacher/course/:courseId/progress" element={<CourseProgress />} />
|
||||||
<Route path="/teacher/adaptive/settings" element={<AdaptiveSettings />} />
|
<Route path="/teacher/adaptive/settings" element={<AdaptiveSettings />} />
|
||||||
</Route>
|
</Route>
|
||||||
@@ -247,6 +306,14 @@ const App = () => (
|
|||||||
<Route element={<AdminLmsLayout />}>
|
<Route element={<AdminLmsLayout />}>
|
||||||
{/* LMS Dashboard */}
|
{/* LMS Dashboard */}
|
||||||
<Route path="/admin/dashboard" element={<AdminLmsDashboard />} />
|
<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 */}
|
{/* Original platform dashboard */}
|
||||||
<Route path="/admin/platform" element={<AdminDashboard />} />
|
<Route path="/admin/platform" element={<AdminDashboard />} />
|
||||||
{/* LMS pages */}
|
{/* LMS pages */}
|
||||||
@@ -259,6 +326,7 @@ const App = () => (
|
|||||||
<Route path="/admin/reports" element={<AdminReports />} />
|
<Route path="/admin/reports" element={<AdminReports />} />
|
||||||
<Route path="/admin/settings" element={<AdminSettings />} />
|
<Route path="/admin/settings" element={<AdminSettings />} />
|
||||||
<Route path="/admin/profile" element={<AdminProfileLms />} />
|
<Route path="/admin/profile" element={<AdminProfileLms />} />
|
||||||
|
<Route path="/admin/privacy" element={<PrivacyCenter />} />
|
||||||
{/* Original academic pages */}
|
{/* Original academic pages */}
|
||||||
<Route path="/admin/assignments" element={<AssignmentsPage />} />
|
<Route path="/admin/assignments" element={<AssignmentsPage />} />
|
||||||
<Route path="/admin/examsList" element={<ExamsListPage />} />
|
<Route path="/admin/examsList" element={<ExamsListPage />} />
|
||||||
@@ -281,13 +349,16 @@ const App = () => (
|
|||||||
<Route path="/admin/payment-record" element={<PaymentRecordPage />} />
|
<Route path="/admin/payment-record" element={<PaymentRecordPage />} />
|
||||||
<Route path="/admin/tickets" element={<TicketsPage />} />
|
<Route path="/admin/tickets" element={<TicketsPage />} />
|
||||||
<Route path="/admin/settings-platform" element={<SettingsPage />} />
|
<Route path="/admin/settings-platform" element={<SettingsPage />} />
|
||||||
<Route path="/admin/exam" element={<ExamPage />} />
|
|
||||||
<Route path="/admin/exam/create" element={<ExamTemplateSelection />} />
|
<Route path="/admin/exam/create" element={<ExamTemplateSelection />} />
|
||||||
<Route path="/admin/exam/ielts/create" element={<IeltsExamCreate />} />
|
<Route path="/admin/exam/ielts/create" element={<IeltsExamCreate />} />
|
||||||
<Route path="/admin/exam/ielts/:examId/skills" element={<IeltsSkillConfig />} />
|
<Route path="/admin/exam/ielts/:examId/skills" element={<IeltsSkillConfig />} />
|
||||||
<Route path="/admin/exam/ielts/:examId/content" element={<IeltsContentPool />} />
|
<Route path="/admin/exam/ielts/:examId/content" element={<IeltsContentPool />} />
|
||||||
<Route path="/admin/exam/ielts/:examId/validate" element={<IeltsExamValidation />} />
|
<Route path="/admin/exam/ielts/:examId/validate" element={<IeltsExamValidation />} />
|
||||||
<Route path="/admin/exam/custom/create" element={<CustomExamCreate />} />
|
<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/taxonomy" element={<TaxonomyManager />} />
|
||||||
<Route path="/admin/resources" element={<ResourceManager />} />
|
<Route path="/admin/resources" element={<ResourceManager />} />
|
||||||
{/* Institutional LMS pages */}
|
{/* Institutional LMS pages */}
|
||||||
@@ -336,10 +407,13 @@ const App = () => (
|
|||||||
<Route path="/dashboard/admin" element={<Navigate to="/admin/platform" replace />} />
|
<Route path="/dashboard/admin" element={<Navigate to="/admin/platform" replace />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
164
src/components/AIFeedbackButtons.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ThumbsDown, ThumbsUp } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
useAIFeedbackSummary,
|
||||||
|
useSubmitAIFeedback,
|
||||||
|
} from "@/hooks/queries/useAIFeedback";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type {
|
||||||
|
AIFeedbackRating,
|
||||||
|
AIFeedbackSubjectType,
|
||||||
|
} from "@/types/ai-feedback";
|
||||||
|
|
||||||
|
export interface AIFeedbackButtonsProps {
|
||||||
|
subjectType: AIFeedbackSubjectType;
|
||||||
|
subjectId: number;
|
||||||
|
promptKey?: string;
|
||||||
|
promptVersion?: number;
|
||||||
|
aiLogId?: number;
|
||||||
|
entityId?: number;
|
||||||
|
courseId?: number;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thumbs up/down widget for any AI-generated artefact.
|
||||||
|
*
|
||||||
|
* Always renders the same UI regardless of whether the user has rated the
|
||||||
|
* artefact before — a previous rating will show as highlighted. Clicking the
|
||||||
|
* same button again is a no-op; clicking the opposite button updates the
|
||||||
|
* server-side row in place. A thumbs-down always prompts the user for a short
|
||||||
|
* comment (required by the backend).
|
||||||
|
*/
|
||||||
|
export function AIFeedbackButtons({
|
||||||
|
subjectType,
|
||||||
|
subjectId,
|
||||||
|
promptKey,
|
||||||
|
promptVersion,
|
||||||
|
aiLogId,
|
||||||
|
entityId,
|
||||||
|
courseId,
|
||||||
|
size = "sm",
|
||||||
|
className,
|
||||||
|
}: AIFeedbackButtonsProps) {
|
||||||
|
const summary = useAIFeedbackSummary(subjectType, subjectId);
|
||||||
|
const submit = useSubmitAIFeedback();
|
||||||
|
|
||||||
|
const [downOpen, setDownOpen] = useState(false);
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (downOpen) {
|
||||||
|
setComment(summary.data?.my_comment ?? "");
|
||||||
|
}
|
||||||
|
}, [downOpen, summary.data?.my_comment]);
|
||||||
|
|
||||||
|
const myRating: AIFeedbackRating | null = summary.data?.my_rating ?? null;
|
||||||
|
|
||||||
|
const submitUp = async () => {
|
||||||
|
if (myRating === "up") return;
|
||||||
|
try {
|
||||||
|
await submit.mutateAsync({
|
||||||
|
subject_type: subjectType,
|
||||||
|
subject_id: subjectId,
|
||||||
|
rating: "up",
|
||||||
|
prompt_key: promptKey,
|
||||||
|
prompt_version: promptVersion,
|
||||||
|
ai_log_id: aiLogId,
|
||||||
|
entity_id: entityId,
|
||||||
|
course_id: courseId,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to submit");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitDown = async () => {
|
||||||
|
if (!comment.trim()) {
|
||||||
|
toast.error("Please tell us what was wrong.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await submit.mutateAsync({
|
||||||
|
subject_type: subjectType,
|
||||||
|
subject_id: subjectId,
|
||||||
|
rating: "down",
|
||||||
|
comment: comment.trim(),
|
||||||
|
prompt_key: promptKey,
|
||||||
|
prompt_version: promptVersion,
|
||||||
|
ai_log_id: aiLogId,
|
||||||
|
entity_id: entityId,
|
||||||
|
course_id: courseId,
|
||||||
|
});
|
||||||
|
setDownOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to submit");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const btnSize = size === "sm" ? "sm" : "default";
|
||||||
|
const iconCls = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center gap-1", className)}>
|
||||||
|
<Button
|
||||||
|
variant={myRating === "up" ? "default" : "outline"}
|
||||||
|
size={btnSize}
|
||||||
|
onClick={submitUp}
|
||||||
|
disabled={submit.isPending}
|
||||||
|
aria-label="Thumbs up"
|
||||||
|
>
|
||||||
|
<ThumbsUp className={iconCls} />
|
||||||
|
<span className="ml-1 text-xs">{summary.data?.up ?? 0}</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={myRating === "down" ? "default" : "outline"}
|
||||||
|
size={btnSize}
|
||||||
|
onClick={() => setDownOpen(true)}
|
||||||
|
disabled={submit.isPending}
|
||||||
|
aria-label="Thumbs down"
|
||||||
|
>
|
||||||
|
<ThumbsDown className={iconCls} />
|
||||||
|
<span className="ml-1 text-xs">{summary.data?.down ?? 0}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={downOpen} onOpenChange={setDownOpen}>
|
||||||
|
<DialogContent
|
||||||
|
className="max-w-md"
|
||||||
|
description="Tell us what was wrong so we can improve this AI output."
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>What went wrong?</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder="e.g. Wrong answer, confusing wording, off-topic…"
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDownOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submitDown} disabled={submit.isPending}>
|
||||||
|
Submit feedback
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AIFeedbackButtons;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Outlet, Link, useNavigate, useLocation } from "react-router-dom";
|
import { Outlet, Link, useNavigate, useLocation } from "react-router-dom";
|
||||||
|
import { Suspense } from "react";
|
||||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import {
|
import {
|
||||||
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
||||||
@@ -9,6 +10,8 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
|
|||||||
import { NavLink } from "@/components/NavLink";
|
import { NavLink } from "@/components/NavLink";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import NotificationDropdown from "@/components/NotificationDropdown";
|
import NotificationDropdown from "@/components/NotificationDropdown";
|
||||||
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||||
|
import { LanguageToggle } from "@/components/LanguageToggle";
|
||||||
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
|
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
|
||||||
import AiSearchBar from "@/components/ai/AiSearchBar";
|
import AiSearchBar from "@/components/ai/AiSearchBar";
|
||||||
import { usePermissions } from "@/hooks/usePermissions";
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
@@ -29,115 +32,128 @@ import {
|
|||||||
CalendarDays, Landmark, UserPlus, ScrollText, Award,
|
CalendarDays, Landmark, UserPlus, ScrollText, Award,
|
||||||
HelpCircle as FaqIcon, Bell, Workflow,
|
HelpCircle as FaqIcon, Bell, Workflow,
|
||||||
CalendarOff, DollarSign, BookMarked, BarChartHorizontal, TrendingUp,
|
CalendarOff, DollarSign, BookMarked, BarChartHorizontal, TrendingUp,
|
||||||
Library, Activity, Warehouse, UserCog,
|
Library, Activity, Warehouse, UserCog, Sparkles, Compass,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
// ============= Navigation Config =============
|
// ============= 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[] = [
|
const overviewItems: NavItem[] = [
|
||||||
{ title: "Admin Dashboard", url: "/admin/dashboard", icon: LayoutDashboard },
|
{ titleKey: "nav.smartWizard", url: "/admin/smart-wizard", icon: Sparkles },
|
||||||
{ title: "Platform Dashboard", url: "/admin/platform", icon: BarChart3 },
|
{ titleKey: "nav.adminDashboard", url: "/admin/dashboard", icon: LayoutDashboard },
|
||||||
|
{ titleKey: "nav.platformDashboard", url: "/admin/platform", icon: BarChart3 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const lmsItems: NavItem[] = [
|
const lmsItems: NavItem[] = [
|
||||||
{ title: "Courses", url: "/admin/courses", icon: BookOpen },
|
{ titleKey: "nav.courses", url: "/admin/courses", icon: BookOpen },
|
||||||
{ title: "Students", url: "/admin/students", icon: Users },
|
{ titleKey: "nav.students", url: "/admin/students", icon: Users },
|
||||||
{ title: "Teachers", url: "/admin/teachers", icon: GraduationCap },
|
{ titleKey: "nav.teachers", url: "/admin/teachers", icon: GraduationCap },
|
||||||
{ title: "Batches", url: "/admin/batches", icon: Layers },
|
{ titleKey: "nav.batches", url: "/admin/batches", icon: Layers },
|
||||||
{ title: "Timetable", url: "/admin/timetable", icon: Calendar },
|
{ titleKey: "nav.timetable", url: "/admin/timetable", icon: Calendar },
|
||||||
{ title: "Reports", url: "/admin/reports", icon: BarChart3 },
|
{ titleKey: "nav.reports", url: "/admin/reports", icon: BarChart3 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const academicItems: NavItem[] = [
|
const academicItems: NavItem[] = [
|
||||||
{ title: "Assignments", url: "/admin/assignments", icon: ClipboardList },
|
{ titleKey: "nav.assignments", url: "/admin/assignments", icon: ClipboardList },
|
||||||
{ title: "Exams List", url: "/admin/examsList", icon: FileText },
|
{ titleKey: "nav.examsList", url: "/admin/examsList", icon: FileText },
|
||||||
{ title: "Exam Structures", url: "/admin/exam-structures", icon: Layers },
|
{ titleKey: "nav.examStructures", url: "/admin/exam-structures", icon: Layers },
|
||||||
{ title: "Rubrics", url: "/admin/rubrics", icon: BookOpen },
|
{ titleKey: "nav.rubrics", url: "/admin/rubrics", icon: BookOpen },
|
||||||
{ title: "Generation", url: "/admin/generation", icon: Wand2 },
|
{ titleKey: "nav.generation", url: "/admin/generation", icon: Wand2 },
|
||||||
{ title: "Approval Workflows", url: "/admin/approval-workflows", icon: GitBranch },
|
{ 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[] = [
|
const adaptiveItems: NavItem[] = [
|
||||||
{ title: "Taxonomy", url: "/admin/taxonomy", icon: Target },
|
{ titleKey: "nav.taxonomy", url: "/admin/taxonomy", icon: Target },
|
||||||
{ title: "Resources", url: "/admin/resources", icon: FolderOpen },
|
{ titleKey: "nav.resources", url: "/admin/resources", icon: FolderOpen },
|
||||||
];
|
];
|
||||||
|
|
||||||
const institutionalItems: NavItem[] = [
|
const institutionalItems: NavItem[] = [
|
||||||
{ title: "Academic Years", url: "/admin/academic-years", icon: CalendarDays },
|
{ titleKey: "nav.academicYears", url: "/admin/academic-years", icon: CalendarDays },
|
||||||
{ title: "Departments", url: "/admin/departments", icon: Landmark },
|
{ titleKey: "nav.departments", url: "/admin/departments", icon: Landmark },
|
||||||
{ title: "Admissions", url: "/admin/admissions", icon: UserPlus },
|
{ titleKey: "nav.admissions", url: "/admin/admissions", icon: UserPlus },
|
||||||
{ title: "Admission Register", url: "/admin/admission-register", icon: ScrollText },
|
{ titleKey: "nav.admissionRegister", url: "/admin/admission-register", icon: ScrollText },
|
||||||
{ title: "Exam Sessions", url: "/admin/exam-sessions", icon: FileText },
|
{ titleKey: "nav.examSessions", url: "/admin/exam-sessions", icon: FileText },
|
||||||
{ title: "Marksheets", url: "/admin/marksheets", icon: Award },
|
{ titleKey: "nav.marksheets", url: "/admin/marksheets", icon: Award },
|
||||||
{ title: "Student Leave", url: "/admin/student-leave", icon: CalendarOff },
|
{ titleKey: "nav.studentLeave", url: "/admin/student-leave", icon: CalendarOff },
|
||||||
{ title: "Fees & Payments", url: "/admin/fees", icon: DollarSign },
|
{ titleKey: "nav.fees", url: "/admin/fees", icon: DollarSign },
|
||||||
{ title: "Lessons", url: "/admin/lessons", icon: BookMarked },
|
{ titleKey: "nav.lessons", url: "/admin/lessons", icon: BookMarked },
|
||||||
{ title: "Gradebook", url: "/admin/gradebook", icon: BarChartHorizontal },
|
{ titleKey: "nav.gradebook", url: "/admin/gradebook", icon: BarChartHorizontal },
|
||||||
{ title: "Student Progress", url: "/admin/student-progress", icon: TrendingUp },
|
{ titleKey: "nav.studentProgress", url: "/admin/student-progress", icon: TrendingUp },
|
||||||
{ title: "Library", url: "/admin/library", icon: Library },
|
{ titleKey: "nav.library", url: "/admin/library", icon: Library },
|
||||||
{ title: "Activities", url: "/admin/activities", icon: Activity },
|
{ titleKey: "nav.activities", url: "/admin/activities", icon: Activity },
|
||||||
{ title: "Facilities", url: "/admin/facilities", icon: Warehouse },
|
{ titleKey: "nav.facilities", url: "/admin/facilities", icon: Warehouse },
|
||||||
];
|
];
|
||||||
|
|
||||||
const managementItems: NavItem[] = [
|
const managementItems: NavItem[] = [
|
||||||
{ title: "Users", url: "/admin/users", icon: Users },
|
{ titleKey: "nav.users", url: "/admin/users", icon: Users },
|
||||||
{ title: "Entities", url: "/admin/entities", icon: Building2 },
|
{ titleKey: "nav.entities", url: "/admin/entities", icon: Building2 },
|
||||||
{ title: "Classrooms", url: "/admin/classrooms", icon: GraduationCap },
|
{ titleKey: "nav.classrooms", url: "/admin/classrooms", icon: GraduationCap },
|
||||||
{ title: "User Roles", url: "/admin/user-roles", icon: UserCog },
|
{ titleKey: "nav.userRoles", url: "/admin/user-roles", icon: UserCog },
|
||||||
{ title: "Roles & Permissions", url: "/admin/roles-permissions", icon: Settings },
|
{ titleKey: "nav.rolesPermissions", url: "/admin/roles-permissions", icon: Settings },
|
||||||
{ title: "Authority Matrix", url: "/admin/authority-matrix", icon: Workflow },
|
{ titleKey: "nav.authorityMatrix", url: "/admin/authority-matrix", icon: Workflow },
|
||||||
];
|
];
|
||||||
|
|
||||||
const reportItems: NavItem[] = [
|
const reportItems: NavItem[] = [
|
||||||
{ title: "Student Performance", url: "/admin/student-performance", icon: BarChart3 },
|
{ titleKey: "nav.studentPerformance", url: "/admin/student-performance", icon: BarChart3 },
|
||||||
{ title: "Stats Corporate", url: "/admin/stats-corporate", icon: Building2 },
|
{ titleKey: "nav.statsCorporate", url: "/admin/stats-corporate", icon: Building2 },
|
||||||
{ title: "Record", url: "/admin/record", icon: History },
|
{ titleKey: "nav.record", url: "/admin/record", icon: History },
|
||||||
];
|
];
|
||||||
|
|
||||||
const trainingItems: NavItem[] = [
|
const trainingItems: NavItem[] = [
|
||||||
{ title: "Vocabulary", url: "/admin/training/vocabulary", icon: BookA },
|
{ titleKey: "nav.vocabulary", url: "/admin/training/vocabulary", icon: BookA },
|
||||||
{ title: "Grammar", url: "/admin/training/grammar", icon: PenTool },
|
{ titleKey: "nav.grammar", url: "/admin/training/grammar", icon: PenTool },
|
||||||
];
|
];
|
||||||
|
|
||||||
const configItems: NavItem[] = [
|
const configItems: NavItem[] = [
|
||||||
{ title: "FAQ Manager", url: "/admin/faq", icon: FaqIcon },
|
{ titleKey: "nav.faqManager", url: "/admin/faq", icon: FaqIcon },
|
||||||
{ title: "Notification Rules", url: "/admin/notification-rules", icon: Bell },
|
{ titleKey: "nav.notificationRules", url: "/admin/notification-rules", icon: Bell },
|
||||||
{ title: "Approval Config", url: "/admin/approval-config", icon: Workflow },
|
{ titleKey: "nav.approvalConfig", url: "/admin/approval-config", icon: Workflow },
|
||||||
];
|
];
|
||||||
|
|
||||||
const supportItems: NavItem[] = [
|
const supportItems: NavItem[] = [
|
||||||
{ title: "Payment Record", url: "/admin/payment-record", icon: CreditCard },
|
{ titleKey: "nav.paymentRecord", url: "/admin/payment-record", icon: CreditCard },
|
||||||
{ title: "Tickets", url: "/admin/tickets", icon: Ticket },
|
{ titleKey: "nav.tickets", url: "/admin/tickets", icon: Ticket },
|
||||||
{ title: "Settings", url: "/admin/settings-platform", icon: Settings },
|
{ titleKey: "nav.settings", url: "/admin/settings-platform", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ============= Reusable sidebar group =============
|
// ============= Reusable sidebar group =============
|
||||||
function SidebarNavGroup({ label, items }: { label: string; items: NavItem[] }) {
|
function SidebarNavGroup({ labelKey, items }: { labelKey: string; items: NavItem[] }) {
|
||||||
const { state } = useSidebar();
|
const { state } = useSidebar();
|
||||||
|
const { t } = useTranslation();
|
||||||
const collapsed = state === "collapsed";
|
const collapsed = state === "collapsed";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
<SidebarGroupLabel>{t(labelKey)}</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{items.map((item) => (
|
{items.map((item) => {
|
||||||
<SidebarMenuItem key={item.url}>
|
const label = t(item.titleKey);
|
||||||
<SidebarMenuButton asChild tooltip={item.title}>
|
return (
|
||||||
<NavLink
|
<SidebarMenuItem key={item.url}>
|
||||||
to={item.url}
|
<SidebarMenuButton asChild tooltip={label}>
|
||||||
end={item.url.endsWith("/dashboard") || item.url.endsWith("/platform")}
|
<NavLink
|
||||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
to={item.url}
|
||||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
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"
|
||||||
<item.icon className="h-4 w-4 shrink-0" />
|
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||||
{!collapsed && <span>{item.title}</span>}
|
>
|
||||||
</NavLink>
|
<item.icon className="h-4 w-4 shrink-0" />
|
||||||
</SidebarMenuButton>
|
{!collapsed && <span>{label}</span>}
|
||||||
</SidebarMenuItem>
|
</NavLink>
|
||||||
))}
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
@@ -145,40 +161,52 @@ function SidebarNavGroup({ label, items }: { label: string; items: NavItem[] })
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============= Breadcrumbs =============
|
// ============= Breadcrumbs =============
|
||||||
const routeLabels: Record<string, string> = {
|
// Map URL segments to i18n keys so breadcrumbs follow the active locale.
|
||||||
admin: "Admin", dashboard: "Dashboard", platform: "Platform", courses: "Courses",
|
// Segments without an entry fall back to a Title-cased version of the slug.
|
||||||
students: "Students", teachers: "Teachers", batches: "Batches", timetable: "Timetable",
|
const routeLabelKeys: Record<string, string> = {
|
||||||
reports: "Reports", assignments: "Assignments", examsList: "Exams List",
|
admin: "breadcrumb.admin", dashboard: "breadcrumb.dashboard",
|
||||||
"exam-structures": "Exam Structures", rubrics: "Rubrics", generation: "Generation",
|
platform: "breadcrumb.platform", courses: "nav.courses",
|
||||||
"approval-workflows": "Approval Workflows", users: "Users", entities: "Entities",
|
students: "nav.students", teachers: "nav.teachers", batches: "nav.batches",
|
||||||
classrooms: "Classrooms", "student-performance": "Student Performance",
|
timetable: "nav.timetable", reports: "nav.reports",
|
||||||
"stats-corporate": "Stats Corporate", record: "Record", training: "Training",
|
assignments: "nav.assignments", examsList: "nav.examsList",
|
||||||
vocabulary: "Vocabulary", grammar: "Grammar", "payment-record": "Payment Record",
|
"exam-structures": "nav.examStructures", rubrics: "nav.rubrics",
|
||||||
tickets: "Tickets", "settings-platform": "Settings", settings: "Settings",
|
generation: "nav.generation", "review-queue": "nav.reviewQueue",
|
||||||
profile: "Profile", exam: "Exam",
|
review: "breadcrumb.review", prompts: "nav.aiPrompts", ai: "breadcrumb.ai",
|
||||||
"academic-years": "Academic Years", departments: "Departments",
|
feedback: "breadcrumb.feedback",
|
||||||
admissions: "Admissions", "admission-register": "Admission Register",
|
"approval-workflows": "nav.approvalWorkflows", users: "nav.users",
|
||||||
"exam-sessions": "Exam Sessions", marksheets: "Marksheets",
|
entities: "nav.entities", classrooms: "nav.classrooms",
|
||||||
faq: "FAQ Manager", "notification-rules": "Notification Rules",
|
"student-performance": "nav.studentPerformance",
|
||||||
"approval-config": "Approval Config",
|
"stats-corporate": "nav.statsCorporate", record: "nav.record",
|
||||||
"student-leave": "Student Leave", fees: "Fees & Payments", lessons: "Lessons",
|
training: "sidebarGroup.training", vocabulary: "nav.vocabulary",
|
||||||
gradebook: "Gradebook", "student-progress": "Student Progress",
|
grammar: "nav.grammar", "payment-record": "nav.paymentRecord",
|
||||||
library: "Library", activities: "Activities", facilities: "Facilities",
|
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() {
|
function AppBreadcrumbs() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { t } = useTranslation();
|
||||||
const segments = location.pathname.split("/").filter(Boolean);
|
const segments = location.pathname.split("/").filter(Boolean);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
<BreadcrumbLink asChild><Link to="/admin/dashboard">Home</Link></BreadcrumbLink>
|
<BreadcrumbLink asChild><Link to="/admin/dashboard">{t("common.home")}</Link></BreadcrumbLink>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
{segments.map((seg, i) => {
|
{segments.map((seg, i) => {
|
||||||
const path = "/" + segments.slice(0, i + 1).join("/");
|
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;
|
const isLast = i === segments.length - 1;
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={path}>
|
<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 =============
|
// ============= Main Layout =============
|
||||||
export default function AdminLmsLayout() {
|
export default function AdminLmsLayout() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
|
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">
|
<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">
|
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<SidebarTrigger />
|
<SidebarTrigger aria-label={t("chrome.toggleSidebar")} />
|
||||||
<AppBreadcrumbs />
|
<AppBreadcrumbs />
|
||||||
</div>
|
</div>
|
||||||
<AiSearchBar />
|
<AiSearchBar />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<LanguageToggle />
|
||||||
|
<ThemeToggle />
|
||||||
<NotificationDropdown />
|
<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" />
|
<Ticket className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -232,36 +277,38 @@ export default function AdminLmsLayout() {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
<div className="px-3 py-2">
|
<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>
|
<p className="text-xs text-muted-foreground">{user?.email ?? ""}</p>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={() => navigate("/admin/profile")}>
|
<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>
|
||||||
<DropdownMenuItem onClick={() => navigate("/admin/settings-platform")}>
|
<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>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleLogout}>
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
<LogOut className="mr-2 h-4 w-4" /> Logout
|
<LogOut className="me-2 h-4 w-4" /> {t("userMenu.logout")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1 overflow-auto p-6">
|
<main className="flex-1 overflow-auto p-6">
|
||||||
<Outlet />
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AiAssistantDrawer />
|
<AiAssistantDrawer />
|
||||||
<Link
|
<Link
|
||||||
to="/admin/tickets"
|
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" />
|
<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>
|
</Link>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
@@ -273,67 +320,78 @@ function AdminSidebar() {
|
|||||||
const collapsed = state === "collapsed";
|
const collapsed = state === "collapsed";
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { hasAnyPermission, isAdmin } = usePermissions();
|
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 showManagement = isAdmin || hasAnyPermission(["view_entities", "view_students", "view_teachers"]);
|
||||||
const showReports = isAdmin || hasAnyPermission(["view_statistics", "view_student_performance", "view_entity_statistics"]);
|
const showReports = isAdmin || hasAnyPermission(["view_statistics", "view_student_performance", "view_entity_statistics"]);
|
||||||
const showPayments = isAdmin || hasAnyPermission(["view_payment_record", "pay_entity"]);
|
const showPayments = isAdmin || hasAnyPermission(["view_payment_record", "pay_entity"]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon">
|
<Sidebar side={sidebarSide} collapsible="icon">
|
||||||
<SidebarHeader className="p-4">
|
<SidebarHeader className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<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 ? (
|
||||||
{!collapsed && (
|
<img
|
||||||
<span className="text-lg font-bold tracking-tight text-sidebar-foreground">
|
src="/logo.png"
|
||||||
<span className="text-[hsl(42,40%,62%)]">En</span>
|
alt={t("chrome.adminAlt")}
|
||||||
<span>Coach</span>
|
className="h-9 w-9 shrink-0 rounded-lg object-contain"
|
||||||
</span>
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src="/logo.png"
|
||||||
|
alt={t("chrome.adminAltLong")}
|
||||||
|
className="h-14 w-auto shrink-0 object-contain"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarSeparator />
|
<SidebarSeparator />
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<SidebarNavGroup label="Overview" items={overviewItems} />
|
<SidebarNavGroup labelKey="sidebarGroup.overview" items={overviewItems} />
|
||||||
<SidebarNavGroup label="LMS" items={lmsItems} />
|
<SidebarNavGroup labelKey="sidebarGroup.lms" items={lmsItems} />
|
||||||
<SidebarNavGroup label="Adaptive Learning" items={adaptiveItems} />
|
<SidebarNavGroup labelKey="sidebarGroup.adaptiveLearning" items={adaptiveItems} />
|
||||||
<SidebarNavGroup label="Institutional" items={institutionalItems} />
|
<SidebarNavGroup labelKey="sidebarGroup.institutional" items={institutionalItems} />
|
||||||
<SidebarNavGroup label="Academic" items={academicItems} />
|
<SidebarNavGroup labelKey="sidebarGroup.academic" items={academicItems} />
|
||||||
{showManagement && <SidebarNavGroup label="Management" items={managementItems} />}
|
{showManagement && <SidebarNavGroup labelKey="sidebarGroup.management" items={managementItems} />}
|
||||||
{showReports && <SidebarNavGroup label="Reports" items={reportItems} />}
|
{showReports && <SidebarNavGroup labelKey="sidebarGroup.reports" items={reportItems} />}
|
||||||
<SidebarNavGroup label="Configuration" items={configItems} />
|
<SidebarNavGroup labelKey="sidebarGroup.configuration" items={configItems} />
|
||||||
|
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<Collapsible defaultOpen className="group/collapsible">
|
<Collapsible defaultOpen className="group/collapsible">
|
||||||
<SidebarGroupLabel asChild>
|
<SidebarGroupLabel asChild>
|
||||||
<CollapsibleTrigger className="flex w-full items-center justify-between">
|
<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" />}
|
{!collapsed && <ChevronDown className="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />}
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
</SidebarGroupLabel>
|
</SidebarGroupLabel>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{trainingItems.map((item) => (
|
{trainingItems.map((item) => {
|
||||||
<SidebarMenuItem key={item.url}>
|
const label = t(item.titleKey);
|
||||||
<SidebarMenuButton asChild tooltip={item.title}>
|
return (
|
||||||
<NavLink
|
<SidebarMenuItem key={item.url}>
|
||||||
to={item.url}
|
<SidebarMenuButton asChild tooltip={label}>
|
||||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
<NavLink
|
||||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
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"
|
||||||
<item.icon className="h-4 w-4 shrink-0" />
|
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||||
{!collapsed && <span>{item.title}</span>}
|
>
|
||||||
</NavLink>
|
<item.icon className="h-4 w-4 shrink-0" />
|
||||||
</SidebarMenuButton>
|
{!collapsed && <span>{label}</span>}
|
||||||
</SidebarMenuItem>
|
</NavLink>
|
||||||
))}
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</SidebarGroup>
|
</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>
|
</SidebarContent>
|
||||||
<SidebarSeparator />
|
<SidebarSeparator />
|
||||||
<SidebarFooter className="p-3">
|
<SidebarFooter className="p-3">
|
||||||
@@ -343,7 +401,7 @@ function AdminSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="flex flex-col min-w-0">
|
<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>
|
<span className="text-xs text-muted-foreground truncate">{user?.email ?? ""}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Outlet, useLocation, Link, useNavigate } from "react-router-dom";
|
import { Outlet, useLocation, Link, useNavigate } from "react-router-dom";
|
||||||
|
import { Suspense } from "react";
|
||||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { AppSidebar } from "@/components/AppSidebar";
|
import { AppSidebar } from "@/components/AppSidebar";
|
||||||
import {
|
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() {
|
export default function AppLayout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -127,7 +138,9 @@ export default function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1 overflow-auto p-6">
|
<main className="flex-1 overflow-auto p-6">
|
||||||
<Outlet />
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { NavLink } from "@/components/NavLink";
|
import { NavLink } from "@/components/NavLink";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
||||||
SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
|
SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
|
||||||
@@ -81,9 +82,11 @@ export function AppSidebar() {
|
|||||||
const { state } = useSidebar();
|
const { state } = useSidebar();
|
||||||
const collapsed = state === "collapsed";
|
const collapsed = state === "collapsed";
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon">
|
<Sidebar side={sidebarSide} collapsible="icon">
|
||||||
<SidebarHeader className="p-4">
|
<SidebarHeader className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center shrink-0">
|
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center shrink-0">
|
||||||
|
|||||||
71
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
import { withTranslation, type WithTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface Props extends WithTranslation {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error("ErrorBoundary caught:", error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="max-w-md w-full text-center space-y-4">
|
||||||
|
<div className="mx-auto h-16 w-16 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||||
|
<svg className="h-8 w-8 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<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">{t("errors.somethingWrong")}</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t("errors.unexpectedDescription")}
|
||||||
|
</p>
|
||||||
|
{this.state.error && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{t("errors.refreshPage")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorBoundary = withTranslation()(ErrorBoundaryInner);
|
||||||
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;
|
||||||
235
src/components/MaterialViewer.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ExternalLink, Download, FileText, Video, Music, Image, Link2 } from "lucide-react";
|
||||||
|
import { API_BASE_URL } from "@/lib/api-client";
|
||||||
|
import type { ChapterMaterial, MaterialType } from "@/types/courseware";
|
||||||
|
|
||||||
|
interface MaterialViewerProps {
|
||||||
|
material: ChapterMaterial | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onDownload?: (materialId: number, name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabels: Record<MaterialType, string> = {
|
||||||
|
pdf: "PDF Document",
|
||||||
|
document: "Document",
|
||||||
|
video: "Video",
|
||||||
|
audio: "Audio",
|
||||||
|
image: "Image",
|
||||||
|
link: "External Link",
|
||||||
|
article: "Article",
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeIcons: Record<MaterialType, React.ReactNode> = {
|
||||||
|
pdf: <FileText className="h-4 w-4" />,
|
||||||
|
document: <FileText className="h-4 w-4" />,
|
||||||
|
video: <Video className="h-4 w-4" />,
|
||||||
|
audio: <Music className="h-4 w-4" />,
|
||||||
|
image: <Image className="h-4 w-4" />,
|
||||||
|
link: <Link2 className="h-4 w-4" />,
|
||||||
|
article: <FileText className="h-4 w-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getContentUrl(materialId: number): string {
|
||||||
|
const token = localStorage.getItem("encoach_token") ?? "";
|
||||||
|
return `${API_BASE_URL}/materials/${materialId}/content?token=${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isYouTubeUrl(url: string): boolean {
|
||||||
|
return /(?:youtube\.com\/(?:watch|embed)|youtu\.be\/)/.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getYouTubeEmbedUrl(url: string): string {
|
||||||
|
const match = url.match(
|
||||||
|
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([\w-]+)/
|
||||||
|
);
|
||||||
|
return match ? `https://www.youtube.com/embed/${match[1]}` : url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PdfViewer({ material }: { material: ChapterMaterial }) {
|
||||||
|
if (material.file_url) {
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src={getContentUrl(material.id)}
|
||||||
|
className="w-full h-full min-h-[600px] rounded-lg border"
|
||||||
|
title={material.name}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (material.url) {
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src={material.url}
|
||||||
|
className="w-full h-full min-h-[600px] rounded-lg border"
|
||||||
|
title={material.name}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <EmptyState message="No PDF file available." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoViewer({ material }: { material: ChapterMaterial }) {
|
||||||
|
if (material.url && isYouTubeUrl(material.url)) {
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src={getYouTubeEmbedUrl(material.url)}
|
||||||
|
className="w-full aspect-video rounded-lg border"
|
||||||
|
title={material.name}
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (material.url) {
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
src={material.url}
|
||||||
|
controls
|
||||||
|
className="w-full rounded-lg border"
|
||||||
|
title={material.name}
|
||||||
|
>
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (material.file_url) {
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
src={getContentUrl(material.id)}
|
||||||
|
controls
|
||||||
|
className="w-full rounded-lg border"
|
||||||
|
title={material.name}
|
||||||
|
>
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <EmptyState message="No video source available." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AudioViewer({ material }: { material: ChapterMaterial }) {
|
||||||
|
const src = material.url || (material.file_url ? getContentUrl(material.id) : null);
|
||||||
|
if (!src) return <EmptyState message="No audio source available." />;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<audio src={src} controls className="w-full max-w-lg">
|
||||||
|
Your browser does not support the audio element.
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageViewer({ material }: { material: ChapterMaterial }) {
|
||||||
|
const src = material.url || (material.file_url ? getContentUrl(material.id) : null);
|
||||||
|
if (!src) return <EmptyState message="No image available." />;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={material.name}
|
||||||
|
className="max-w-full max-h-[70vh] rounded-lg border object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArticleViewer({ material }: { material: ChapterMaterial }) {
|
||||||
|
if (!material.description) return <EmptyState message="No article content available." />;
|
||||||
|
return (
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none p-4 rounded-lg border bg-card">
|
||||||
|
<div className="whitespace-pre-wrap">{material.description}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkViewer({ material }: { material: ChapterMaterial }) {
|
||||||
|
if (!material.url) return <EmptyState message="No URL available." />;
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 p-4 rounded-lg border bg-muted/50">
|
||||||
|
<Link2 className="h-5 w-5 text-primary shrink-0" />
|
||||||
|
<a
|
||||||
|
href={material.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary underline break-all flex-1"
|
||||||
|
>
|
||||||
|
{material.url}
|
||||||
|
</a>
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={material.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="h-4 w-4 mr-1" /> Open
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
src={material.url}
|
||||||
|
className="w-full min-h-[500px] rounded-lg border"
|
||||||
|
title={material.name}
|
||||||
|
sandbox="allow-scripts allow-same-origin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||||
|
<FileText className="h-12 w-12 mb-3 opacity-40" />
|
||||||
|
<p>{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewers: Record<MaterialType, React.FC<{ material: ChapterMaterial }>> = {
|
||||||
|
pdf: PdfViewer,
|
||||||
|
document: PdfViewer,
|
||||||
|
video: VideoViewer,
|
||||||
|
audio: AudioViewer,
|
||||||
|
image: ImageViewer,
|
||||||
|
link: LinkViewer,
|
||||||
|
article: ArticleViewer,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MaterialViewer({ material, open, onOpenChange, onDownload }: MaterialViewerProps) {
|
||||||
|
if (!material) return null;
|
||||||
|
|
||||||
|
const Viewer = viewers[material.type] ?? ArticleViewer;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent side="right" className="w-full sm:max-w-2xl lg:max-w-4xl overflow-y-auto">
|
||||||
|
<SheetHeader className="space-y-3 pb-4 border-b">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{typeIcons[material.type]}
|
||||||
|
<Badge variant="outline">{typeLabels[material.type]}</Badge>
|
||||||
|
</div>
|
||||||
|
<SheetTitle className="text-lg">{material.name}</SheetTitle>
|
||||||
|
{material.description && material.type !== "article" && (
|
||||||
|
<p className="text-sm text-muted-foreground">{material.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{material.allow_download && material.file_url && onDownload && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onDownload(material.id, material.name)}>
|
||||||
|
<Download className="h-4 w-4 mr-1" /> Download
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{material.url && (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={material.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="h-4 w-4 mr-1" /> Open Link
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<Viewer material={material} />
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { forwardRef } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -9,7 +9,14 @@ interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
|
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 (
|
return (
|
||||||
<RouterNavLink
|
<RouterNavLink
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -17,6 +24,31 @@ const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
|
|||||||
className={({ isActive, isPending }) =>
|
className={({ isActive, isPending }) =>
|
||||||
cn(className, isActive && activeClassName, isPending && pendingClassName)
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,35 +1,50 @@
|
|||||||
import { Bell } from "lucide-react";
|
import { Bell } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
export default function NotificationDropdown() {
|
export default function NotificationDropdown() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const notifications: { id: string; title: string; message: string; time: string; read: boolean; type: string }[] = [];
|
const notifications: { id: string; title: string; message: string; time: string; read: boolean; type: string }[] = [];
|
||||||
const unread = notifications.filter((n) => !n.read).length;
|
const unread = notifications.filter((n) => !n.read).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<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" />
|
<Bell className="h-4 w-4" />
|
||||||
{unread > 0 && (
|
{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}
|
{unread}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-80">
|
<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 />
|
<DropdownMenuSeparator />
|
||||||
{notifications.slice(0, 4).map((n) => (
|
{notifications.length === 0 ? (
|
||||||
<DropdownMenuItem key={n.id} className="flex flex-col items-start gap-0.5 py-2.5 cursor-pointer">
|
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||||
<span className={`text-sm font-medium ${!n.read ? "text-foreground" : "text-muted-foreground"}`}>{n.title}</span>
|
{t("notifications.empty")}
|
||||||
<span className="text-xs text-muted-foreground line-clamp-1">{n.message}</span>
|
</div>
|
||||||
<span className="text-[10px] text-muted-foreground/70">{n.time}</span>
|
) : (
|
||||||
</DropdownMenuItem>
|
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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
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 { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import {
|
import {
|
||||||
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
||||||
@@ -8,22 +10,28 @@ import {
|
|||||||
import { NavLink } from "@/components/NavLink";
|
import { NavLink } from "@/components/NavLink";
|
||||||
import { useAuth, UserRole } from "@/contexts/AuthContext";
|
import { useAuth, UserRole } from "@/contexts/AuthContext";
|
||||||
import NotificationDropdown from "@/components/NotificationDropdown";
|
import NotificationDropdown from "@/components/NotificationDropdown";
|
||||||
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||||
|
import { LanguageToggle } from "@/components/LanguageToggle";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { LogOut, User, Settings, LucideIcon } from "lucide-react";
|
import { LogOut, User, LucideIcon } from "lucide-react";
|
||||||
import React from "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 {
|
export interface NavItem {
|
||||||
title: string;
|
titleKey: string;
|
||||||
url: string;
|
url: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavGroup {
|
export interface NavGroup {
|
||||||
label: string;
|
labelKey: string;
|
||||||
items: NavItem[];
|
items: NavItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,30 +42,34 @@ interface RoleLayoutProps {
|
|||||||
|
|
||||||
function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) {
|
function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) {
|
||||||
const { state } = useSidebar();
|
const { state } = useSidebar();
|
||||||
|
const { t } = useTranslation();
|
||||||
const collapsed = state === "collapsed";
|
const collapsed = state === "collapsed";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{navGroups.map((group) => (
|
{navGroups.map((group) => (
|
||||||
<SidebarGroup key={group.label}>
|
<SidebarGroup key={group.labelKey}>
|
||||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
<SidebarGroupLabel>{t(group.labelKey)}</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{group.items.map((item) => (
|
{group.items.map((item) => {
|
||||||
<SidebarMenuItem key={item.url}>
|
const label = t(item.titleKey);
|
||||||
<SidebarMenuButton asChild tooltip={item.title}>
|
return (
|
||||||
<NavLink
|
<SidebarMenuItem key={item.url}>
|
||||||
to={item.url}
|
<SidebarMenuButton asChild tooltip={label}>
|
||||||
end={item.url.endsWith("/dashboard")}
|
<NavLink
|
||||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
to={item.url}
|
||||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
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"
|
||||||
<item.icon className="h-4 w-4 shrink-0" />
|
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||||
{!collapsed && <span>{item.title}</span>}
|
>
|
||||||
</NavLink>
|
<item.icon className="h-4 w-4 shrink-0" />
|
||||||
</SidebarMenuButton>
|
{!collapsed && <span>{label}</span>}
|
||||||
</SidebarMenuItem>
|
</NavLink>
|
||||||
))}
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</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) {
|
export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
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() ?? "??";
|
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");
|
navigate("/login");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const roleLabel = t(`roles.${role}`, { defaultValue: role });
|
||||||
|
const portalTitle = `${roleLabel} ${t("chrome.portalSuffix")}`.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<div className="min-h-screen flex w-full">
|
<div className="min-h-screen flex w-full">
|
||||||
<Sidebar collapsible="icon">
|
<Sidebar side={sidebarSide} collapsible="icon">
|
||||||
<SidebarHeader className="p-4">
|
<SidebarHeader className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img src="/logo.png" alt="EnCoach" className="h-8 w-8 rounded-lg shrink-0 object-contain" />
|
<img
|
||||||
<span className="text-lg font-bold tracking-tight text-sidebar-foreground group-data-[collapsible=icon]:hidden">
|
src="/logo.png"
|
||||||
<span className="text-[hsl(42,40%,62%)]">En</span>
|
alt={t("chrome.adminAlt")}
|
||||||
<span>Coach</span>
|
className="h-9 w-9 shrink-0 rounded-lg object-contain group-data-[collapsible=icon]:block hidden"
|
||||||
</span>
|
/>
|
||||||
|
<img
|
||||||
|
src="/logo.png"
|
||||||
|
alt={t("chrome.adminAltLong")}
|
||||||
|
className="h-14 w-auto shrink-0 object-contain group-data-[collapsible=icon]:hidden"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarSeparator />
|
<SidebarSeparator />
|
||||||
@@ -101,7 +133,9 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
|||||||
<span className="text-xs font-semibold text-primary">{initials}</span>
|
<span className="text-xs font-semibold text-primary">{initials}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0 group-data-[collapsible=icon]:hidden">
|
<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>
|
<span className="text-xs text-muted-foreground truncate">{user?.email}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,10 +145,12 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
|||||||
<div className="flex-1 flex flex-col min-w-0">
|
<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">
|
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<SidebarTrigger />
|
<SidebarTrigger aria-label={t("chrome.toggleSidebar")} />
|
||||||
<span className="text-sm font-medium text-muted-foreground capitalize">{role} Portal</span>
|
<span className="text-sm font-medium text-muted-foreground">{portalTitle}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<LanguageToggle />
|
||||||
|
<ThemeToggle />
|
||||||
<NotificationDropdown />
|
<NotificationDropdown />
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -126,23 +162,25 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
<div className="px-3 py-2">
|
<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>
|
<p className="text-xs text-muted-foreground">{user?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={() => navigate(`/${role}/profile`)}>
|
<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>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleLogout}>
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
<LogOut className="mr-2 h-4 w-4" /> Logout
|
<LogOut className="me-2 h-4 w-4" /> {t("userMenu.logout")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1 overflow-auto p-6">
|
<main className="flex-1 overflow-auto p-6">
|
||||||
<Outlet />
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,51 +1,62 @@
|
|||||||
import RoleLayout, { NavGroup } from "./RoleLayout";
|
import RoleLayout, { NavGroup } from "./RoleLayout";
|
||||||
|
import ExamPopup from "./student/ExamPopup";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
|
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
|
||||||
CalendarCheck, Calendar, User, Target, GraduationCap, ListChecks,
|
CalendarCheck, Calendar, User, Target, ListChecks,
|
||||||
MessageSquare, Megaphone, Mail, TrendingUp,
|
MessageSquare, Megaphone, Mail, TrendingUp,
|
||||||
} from "lucide-react";
|
} 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[] = [
|
const navGroups: NavGroup[] = [
|
||||||
{
|
{
|
||||||
label: "Learning",
|
labelKey: "sidebarGroup.learning",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Dashboard", url: "/student/dashboard", icon: LayoutDashboard },
|
{ titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard },
|
||||||
{ title: "My Courses", url: "/student/courses", icon: BookOpen },
|
{ titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen },
|
||||||
{ title: "Subject Registration", url: "/student/subject-registration", icon: ListChecks },
|
{ titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
|
||||||
{ title: "Assignments", url: "/student/assignments", icon: ClipboardList },
|
{ titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Adaptive Learning",
|
labelKey: "sidebarGroup.adaptiveLearning",
|
||||||
items: [
|
items: [
|
||||||
{ title: "My Subjects", url: "/student/subjects", icon: Target },
|
{ titleKey: "nav.mySubjects", url: "/student/subjects", icon: Target },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Progress",
|
labelKey: "sidebarGroup.progress",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Grades", url: "/student/grades", icon: BarChart3 },
|
{ titleKey: "nav.grades", url: "/student/grades", icon: BarChart3 },
|
||||||
{ title: "Attendance", url: "/student/attendance", icon: CalendarCheck },
|
{ titleKey: "nav.attendance", url: "/student/attendance", icon: CalendarCheck },
|
||||||
{ title: "Timetable", url: "/student/timetable", icon: Calendar },
|
{ titleKey: "nav.timetable", url: "/student/timetable", icon: Calendar },
|
||||||
{ title: "My Journey", url: "/student/journey", icon: TrendingUp },
|
{ titleKey: "nav.myJourney", url: "/student/journey", icon: TrendingUp },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Communication",
|
labelKey: "sidebarGroup.communication",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Discussions", url: "/student/discussions", icon: MessageSquare },
|
{ titleKey: "nav.discussions", url: "/student/discussions", icon: MessageSquare },
|
||||||
{ title: "Messages", url: "/student/messages", icon: Mail },
|
{ titleKey: "nav.messages", url: "/student/messages", icon: Mail },
|
||||||
{ title: "Announcements", url: "/student/announcements", icon: Megaphone },
|
{ titleKey: "nav.announcements", url: "/student/announcements", icon: Megaphone },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Account",
|
labelKey: "sidebarGroup.account",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Profile", url: "/student/profile", icon: User },
|
{ titleKey: "nav.profile", url: "/student/profile", icon: User },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function StudentLayout() {
|
export default function StudentLayout() {
|
||||||
return <RoleLayout navGroups={navGroups} role="student" />;
|
return (
|
||||||
|
<>
|
||||||
|
<RoleLayout navGroups={navGroups} role="student" />
|
||||||
|
<ExamPopup />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
143
src/components/TaxonomyCascade.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { taxonomyService } from "@/services/taxonomy.service";
|
||||||
|
import { lmsService } from "@/services/lms.service";
|
||||||
|
|
||||||
|
interface TaxonomyCascadeProps {
|
||||||
|
subjectId: string;
|
||||||
|
onSubjectChange: (id: string) => void;
|
||||||
|
domainId: string;
|
||||||
|
onDomainChange: (id: string) => void;
|
||||||
|
topicIds: number[];
|
||||||
|
onTopicIdsChange: (ids: number[]) => void;
|
||||||
|
objectiveIds: number[];
|
||||||
|
onObjectiveIdsChange: (ids: number[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaxonomyCascade({
|
||||||
|
subjectId, onSubjectChange,
|
||||||
|
domainId, onDomainChange,
|
||||||
|
topicIds, onTopicIdsChange,
|
||||||
|
objectiveIds, onObjectiveIdsChange,
|
||||||
|
}: TaxonomyCascadeProps) {
|
||||||
|
const { data: subjects } = useQuery({
|
||||||
|
queryKey: ["taxonomy", "subjects"],
|
||||||
|
queryFn: () => taxonomyService.listSubjects(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const numericSubjectId = subjectId !== "none" ? Number(subjectId) : undefined;
|
||||||
|
const numericDomainId = domainId !== "none" ? Number(domainId) : undefined;
|
||||||
|
|
||||||
|
const { data: domains } = useQuery({
|
||||||
|
queryKey: ["taxonomy", "domains", numericSubjectId],
|
||||||
|
queryFn: () => taxonomyService.listDomains({ subject_id: numericSubjectId }),
|
||||||
|
enabled: !!numericSubjectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: topics } = useQuery({
|
||||||
|
queryKey: ["taxonomy", "topics", numericDomainId],
|
||||||
|
queryFn: () => taxonomyService.listTopics({ domain_id: numericDomainId }),
|
||||||
|
enabled: !!numericDomainId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: objectivesData } = useQuery({
|
||||||
|
queryKey: ["learning-objectives", topicIds],
|
||||||
|
queryFn: () => lmsService.listLearningObjectives({ topic_ids: topicIds.join(",") }),
|
||||||
|
enabled: topicIds.length > 0,
|
||||||
|
});
|
||||||
|
const objectives = objectivesData?.items ?? [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (subjectId === "none") {
|
||||||
|
if (domainId !== "none") onDomainChange("none");
|
||||||
|
if (topicIds.length > 0) onTopicIdsChange([]);
|
||||||
|
if (objectiveIds.length > 0) onObjectiveIdsChange([]);
|
||||||
|
}
|
||||||
|
}, [subjectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (domainId === "none") {
|
||||||
|
if (topicIds.length > 0) onTopicIdsChange([]);
|
||||||
|
if (objectiveIds.length > 0) onObjectiveIdsChange([]);
|
||||||
|
}
|
||||||
|
}, [domainId]);
|
||||||
|
|
||||||
|
const toggleTopic = (id: number) => {
|
||||||
|
const next = topicIds.includes(id) ? topicIds.filter(t => t !== id) : [...topicIds, id];
|
||||||
|
onTopicIdsChange(next);
|
||||||
|
if (next.length === 0 && objectiveIds.length > 0) onObjectiveIdsChange([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleObjective = (id: number) => {
|
||||||
|
onObjectiveIdsChange(
|
||||||
|
objectiveIds.includes(id) ? objectiveIds.filter(o => o !== id) : [...objectiveIds, id],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Subject</Label>
|
||||||
|
<Select value={subjectId} onValueChange={(v) => { onSubjectChange(v); if (v === "none") { onDomainChange("none"); } }}>
|
||||||
|
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="Select subject" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
{(subjects ?? []).map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Domain</Label>
|
||||||
|
<Select value={domainId} onValueChange={onDomainChange} disabled={!numericSubjectId}>
|
||||||
|
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder={numericSubjectId ? "Select domain" : "Select subject first"} /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
{(domains ?? []).map(d => <SelectItem key={d.id} value={String(d.id)}>{d.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{numericDomainId && (topics ?? []).length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Topics</Label>
|
||||||
|
<div className="flex flex-wrap gap-1.5 p-2 rounded border bg-muted/30 max-h-[100px] overflow-y-auto">
|
||||||
|
{(topics ?? []).map(t => (
|
||||||
|
<Badge
|
||||||
|
key={t.id}
|
||||||
|
variant={topicIds.includes(t.id) ? "default" : "outline"}
|
||||||
|
className="cursor-pointer select-none text-xs"
|
||||||
|
onClick={() => toggleTopic(t.id)}
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{topicIds.length > 0 && objectives.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Learning Objectives</Label>
|
||||||
|
<div className="space-y-1 p-2 rounded border bg-muted/30 max-h-[100px] overflow-y-auto">
|
||||||
|
{objectives.map(o => (
|
||||||
|
<label key={o.id} className="flex items-center gap-2 text-xs cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={objectiveIds.includes(o.id)}
|
||||||
|
onCheckedChange={() => toggleObjective(o.id)}
|
||||||
|
/>
|
||||||
|
<span>{o.name}</span>
|
||||||
|
{o.bloom_level && <Badge variant="outline" className="text-[10px] h-4">{o.bloom_level}</Badge>}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,38 +1,41 @@
|
|||||||
import RoleLayout, { NavGroup } from "./RoleLayout";
|
import RoleLayout, { NavGroup } from "./RoleLayout";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, BookOpen, ClipboardList, FileText,
|
LayoutDashboard, BookOpen, ClipboardList,
|
||||||
CalendarCheck, Users, Calendar, User, MessageSquare,
|
CalendarCheck, Users, Calendar, User, MessageSquare,
|
||||||
Megaphone, Wand2,
|
Megaphone, Library, Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
/** Teacher portal shell. See `StudentLayout` for the nav-config rationale. */
|
||||||
const navGroups: NavGroup[] = [
|
const navGroups: NavGroup[] = [
|
||||||
{
|
{
|
||||||
label: "Teaching",
|
labelKey: "sidebarGroup.teaching",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Dashboard", url: "/teacher/dashboard", icon: LayoutDashboard },
|
{ titleKey: "nav.quickSetup", url: "/teacher/quick-setup", icon: Sparkles },
|
||||||
{ title: "Courses", url: "/teacher/courses", icon: BookOpen },
|
{ titleKey: "nav.dashboard", url: "/teacher/dashboard", icon: LayoutDashboard },
|
||||||
{ title: "Assignments", url: "/teacher/assignments", icon: ClipboardList },
|
{ 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: [
|
items: [
|
||||||
{ title: "Attendance", url: "/teacher/attendance", icon: CalendarCheck },
|
{ titleKey: "nav.attendance", url: "/teacher/attendance", icon: CalendarCheck },
|
||||||
{ title: "Students", url: "/teacher/students", icon: Users },
|
{ titleKey: "nav.students", url: "/teacher/students", icon: Users },
|
||||||
{ title: "Timetable", url: "/teacher/timetable", icon: Calendar },
|
{ titleKey: "nav.timetable", url: "/teacher/timetable", icon: Calendar },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Communication",
|
labelKey: "sidebarGroup.communication",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Discussions", url: "/teacher/discussions", icon: MessageSquare },
|
{ titleKey: "nav.discussions", url: "/teacher/discussions", icon: MessageSquare },
|
||||||
{ title: "Announcements", url: "/teacher/announcements", icon: Megaphone },
|
{ titleKey: "nav.announcements", url: "/teacher/announcements", icon: Megaphone },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Account",
|
labelKey: "sidebarGroup.account",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Profile", url: "/teacher/profile", icon: User },
|
{ titleKey: "nav.profile", url: "/teacher/profile", icon: User },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
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 { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { AlertTriangle, X, Sparkles, Loader2 } from "lucide-react";
|
import { AlertTriangle, X, Sparkles, Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { analyticsService } from "@/services/analytics.service";
|
import { analyticsService } from "@/services/analytics.service";
|
||||||
@@ -7,32 +8,34 @@ import { analyticsService } from "@/services/analytics.service";
|
|||||||
export default function AiAlertBanner() {
|
export default function AiAlertBanner() {
|
||||||
const [dismissedIds, setDismissedIds] = useState<Set<string>>(() => new Set());
|
const [dismissedIds, setDismissedIds] = useState<Set<string>>(() => new Set());
|
||||||
const [errorDismissed, setErrorDismissed] = useState(false);
|
const [errorDismissed, setErrorDismissed] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data: alerts, isLoading, isError, error } = useQuery({
|
const { data: resp, isLoading, isError, error } = useQuery({
|
||||||
queryKey: ["ai", "alerts"],
|
queryKey: ["ai", "alerts"],
|
||||||
queryFn: () => analyticsService.getAlerts(),
|
queryFn: () => analyticsService.getAlerts(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const visible = alerts?.filter((a) => !dismissedIds.has(a.id)) ?? [];
|
const alerts = resp?.alerts ?? [];
|
||||||
|
const visible = alerts.filter((a, i) => !dismissedIds.has(String(i)));
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-center gap-3">
|
<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" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError && !errorDismissed) {
|
if (isError && !errorDismissed) {
|
||||||
return (
|
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" />
|
<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">
|
<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>
|
||||||
<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>
|
</div>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setErrorDismissed(true)}>
|
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setErrorDismissed(true)}>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
@@ -43,11 +46,11 @@ export default function AiAlertBanner() {
|
|||||||
|
|
||||||
if (isError && errorDismissed) return null;
|
if (isError && errorDismissed) return null;
|
||||||
|
|
||||||
if (!alerts?.length) {
|
if (!alerts.length) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-muted bg-muted/20 p-4 flex items-start gap-3">
|
<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" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -56,12 +59,12 @@ export default function AiAlertBanner() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{visible.map((alert) => (
|
{visible.map((alert, idx) => (
|
||||||
<div key={alert.id} 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" />
|
<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">
|
<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>
|
||||||
<p className="text-sm text-muted-foreground mt-1">{alert.description}</p>
|
<p className="text-sm text-muted-foreground mt-1">{alert.description}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,7 +72,7 @@ export default function AiAlertBanner() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 shrink-0"
|
className="h-7 w-7 shrink-0"
|
||||||
onClick={() => setDismissedIds((prev) => new Set(prev).add(alert.id))}
|
onClick={() => setDismissedIds((prev) => new Set(prev).add(String(idx)))}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -8,38 +9,43 @@ import { Sparkles, Send, Loader2 } from "lucide-react";
|
|||||||
import { coachingService } from "@/services/coaching.service";
|
import { coachingService } from "@/services/coaching.service";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
const quickActions = [
|
|
||||||
"Platform health summary",
|
|
||||||
"Show dropout risks",
|
|
||||||
"Compare course performance",
|
|
||||||
"Suggest batch improvements",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function AiAssistantDrawer() {
|
export default function AiAssistantDrawer() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [messages, setMessages] = useState<{ role: "user" | "ai"; text: string }[]>([]);
|
const [messages, setMessages] = useState<{ role: "user" | "ai"; text: string }[]>([]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { toast } = useToast();
|
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({
|
const chatMutation = useMutation({
|
||||||
mutationFn: (message: string) =>
|
mutationFn: (message: string) =>
|
||||||
coachingService.chat({ message, context: { page: location.pathname } }),
|
coachingService.chat({ message, context: { page: location.pathname } }),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setMessages((prev) => [...prev, { role: "ai", text: data.message }]);
|
setMessages((prev) => [...prev, { role: "ai", text: data.reply }]);
|
||||||
},
|
},
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Could not get a reply",
|
title: t("ai.replyErrorTitle"),
|
||||||
description: err.message || "Something went wrong. Try again.",
|
description: err.message || t("ai.replyErrorDesc"),
|
||||||
});
|
});
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{ role: "ai", text: t("ai.fallbackReply") },
|
||||||
role: "ai",
|
|
||||||
text: "Sorry, I could not reach the assistant. Please try again in a moment.",
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -56,8 +62,9 @@ export default function AiAssistantDrawer() {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(true)}
|
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"
|
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="AI Assistant"
|
aria-label={t("ai.assistantLabel")}
|
||||||
|
title={t("ai.assistantLabel")}
|
||||||
>
|
>
|
||||||
<Sparkles className="h-5 w-5" />
|
<Sparkles className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -67,7 +74,7 @@ export default function AiAssistantDrawer() {
|
|||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle className="flex items-center gap-2">
|
<SheetTitle className="flex items-center gap-2">
|
||||||
<Sparkles className="h-5 w-5 text-primary" />
|
<Sparkles className="h-5 w-5 text-primary" />
|
||||||
EnCoach AI Assistant
|
{t("ai.assistantTitle")}
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
@@ -90,8 +97,8 @@ export default function AiAssistantDrawer() {
|
|||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
<div className="text-center text-muted-foreground text-sm py-8">
|
<div className="text-center text-muted-foreground text-sm py-8">
|
||||||
<Sparkles className="h-8 w-8 mx-auto mb-3 text-primary/40" />
|
<Sparkles className="h-8 w-8 mx-auto mb-3 text-primary/40" />
|
||||||
<p>Ask me anything about the platform,</p>
|
<p>{t("ai.emptyLine1")}</p>
|
||||||
<p>or click a quick action above.</p>
|
<p>{t("ai.emptyLine2")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.map((msg, i) => (
|
{messages.map((msg, i) => (
|
||||||
@@ -99,27 +106,27 @@ export default function AiAssistantDrawer() {
|
|||||||
key={i}
|
key={i}
|
||||||
className={`rounded-lg p-3 text-sm ${
|
className={`rounded-lg p-3 text-sm ${
|
||||||
msg.role === "user"
|
msg.role === "user"
|
||||||
? "bg-primary text-primary-foreground ml-8"
|
? "bg-primary text-primary-foreground ms-8"
|
||||||
: "bg-muted mr-8"
|
: "bg-muted me-8"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{msg.role === "ai" && (
|
{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}
|
{msg.text}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{chatMutation.isPending && (
|
{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" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 pt-3 border-t mt-auto">
|
<div className="flex gap-2 pt-3 border-t mt-auto">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Ask anything..."
|
placeholder={t("ai.placeholder")}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSend(input)}
|
onKeyDown={(e) => e.key === "Enter" && handleSend(input)}
|
||||||
@@ -128,6 +135,8 @@ export default function AiAssistantDrawer() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleSend(input)}
|
onClick={() => handleSend(input)}
|
||||||
disabled={!input.trim() || chatMutation.isPending}
|
disabled={!input.trim() || chatMutation.isPending}
|
||||||
|
aria-label={t("ai.send")}
|
||||||
|
title={t("ai.send")}
|
||||||
>
|
>
|
||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type OptResult = Awaited<ReturnType<typeof analyticsService.getBatchOptimization>>;
|
||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
if (batchId == null) {
|
if (batchId == null) {
|
||||||
toast({
|
toast({
|
||||||
@@ -39,9 +41,23 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
|||||||
mutation.mutate(batchId);
|
mutation.mutate(batchId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyMutation = useMutation({
|
||||||
|
mutationFn: () => analyticsService.applyBatchOptimization(batchId!, mutation.data?.optimized ?? []),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
toast({ title: "Suggestion Applied", description: `${res.applied} optimization(s) saved successfully.` });
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Apply failed",
|
||||||
|
description: err.message || "Could not apply batch optimization.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
toast({ title: "Suggestion Applied", description: "Batch split recommendation has been saved successfully." });
|
applyMutation.mutate();
|
||||||
setOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onOpenChange = (next: boolean) => {
|
const onOpenChange = (next: boolean) => {
|
||||||
@@ -49,9 +65,10 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
|||||||
if (!next) mutation.reset();
|
if (!next) mutation.reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
const suggestions = mutation.data ?? [];
|
const optData = mutation.data as OptResult | undefined;
|
||||||
const showResults = !mutation.isPending && !mutation.isError && suggestions.length > 0;
|
const hasSuggestions = !!optData?.summary;
|
||||||
const showEmpty = !mutation.isPending && !mutation.isError && mutation.isSuccess && suggestions.length === 0;
|
const showResults = !mutation.isPending && !mutation.isError && hasSuggestions;
|
||||||
|
const showEmpty = !mutation.isPending && !mutation.isError && mutation.isSuccess && !hasSuggestions;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -71,20 +88,28 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
) : mutation.isError ? (
|
) : mutation.isError ? (
|
||||||
<p className="text-sm text-muted-foreground py-4 text-center">Something went wrong. Try again.</p>
|
<p className="text-sm text-muted-foreground py-4 text-center">Something went wrong. Try again.</p>
|
||||||
) : showResults ? (
|
) : showResults && optData ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-3 max-h-[50vh] overflow-y-auto">
|
<div className="rounded-lg bg-muted/30 p-4 border border-border/60">
|
||||||
{suggestions.map((s, i) => (
|
<p className="text-xs font-semibold text-primary uppercase tracking-wide mb-1">{optData.impact} impact</p>
|
||||||
<div key={i} className="rounded-lg bg-muted/30 p-4 border border-border/60">
|
<p className="text-sm font-medium">{optData.summary}</p>
|
||||||
<p className="text-xs font-semibold text-primary uppercase tracking-wide mb-1">{s.impact} impact</p>
|
|
||||||
<p className="text-sm font-medium">{s.suggestion}</p>
|
|
||||||
{s.details ? <p className="text-sm text-muted-foreground mt-2 leading-relaxed">{s.details}</p> : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
{Array.isArray(optData.optimized) && optData.optimized.length > 0 && (
|
||||||
|
<div className="space-y-2 max-h-[40vh] overflow-y-auto">
|
||||||
|
{optData.optimized.map((item, i) => (
|
||||||
|
<div key={i} className="rounded-lg bg-muted/20 p-3 border text-sm">
|
||||||
|
{typeof item === "object" && item !== null ? JSON.stringify(item) : String(item)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button className="flex-1" onClick={handleApply}>
|
<Button className="flex-1" onClick={handleApply} disabled={applyMutation.isPending}>
|
||||||
Apply Suggestion
|
{applyMutation.isPending ? (
|
||||||
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Applying...</>
|
||||||
|
) : (
|
||||||
|
"Apply Suggestion"
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
Dismiss
|
Dismiss
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ export default function AiGeneratorModal() {
|
|||||||
difficulty,
|
difficulty,
|
||||||
count,
|
count,
|
||||||
}),
|
}),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res: Record<string, unknown>) => {
|
||||||
setLocalExercises(Array.isArray(res.exercises) ? res.exercises : []);
|
const items = Array.isArray(res.questions) ? res.questions : Array.isArray(res.exercises) ? res.exercises : [];
|
||||||
|
setLocalExercises(items);
|
||||||
},
|
},
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
toast({
|
toast({
|
||||||
@@ -57,6 +58,21 @@ export default function AiGeneratorModal() {
|
|||||||
generateMutation.mutate();
|
generateMutation.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: () => generationService.saveGenerated(moduleType, localExercises ?? []),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
toast({ title: "Saved", description: `${res.saved} assignments saved successfully.` });
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Save failed",
|
||||||
|
description: err.message || "Could not save generated assignments.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const generated = localExercises;
|
const generated = localExercises;
|
||||||
|
|
||||||
const handleRemove = (index: number) => {
|
const handleRemove = (index: number) => {
|
||||||
@@ -188,7 +204,17 @@ export default function AiGeneratorModal() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button className="flex-1">Save All</Button>
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
disabled={saveMutation.isPending || !generated?.length}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? (
|
||||||
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Saving...</>
|
||||||
|
) : (
|
||||||
|
"Save All"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export default function AiGradeExplainer({
|
|||||||
const explainMutation = useMutation({
|
const explainMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
coachingService.explain({
|
coachingService.explain({
|
||||||
context: `IELTS / course grades for student: ${studentName}. Summarize what the scores mean and what to focus on next.`,
|
score_data: scores ?? {},
|
||||||
scores,
|
student_context: `IELTS / course grades for student: ${studentName}. Summarize what the scores mean and what to focus on next.`,
|
||||||
}),
|
}),
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -26,9 +26,8 @@ export default function AiGradingAssistant({
|
|||||||
const gradeMutation = useMutation({
|
const gradeMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
analyticsService.getGradingSuggestion({
|
analyticsService.getGradingSuggestion({
|
||||||
submission_id: submissionId,
|
submission_text: submissionText,
|
||||||
text: submissionText,
|
skill: "writing",
|
||||||
...(rubricId !== undefined ? { rubric_id: rubricId } : {}),
|
|
||||||
}),
|
}),
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
toast({
|
toast({
|
||||||
@@ -45,7 +44,7 @@ export default function AiGradingAssistant({
|
|||||||
}, [submissionId, submissionText, rubricId]);
|
}, [submissionId, submissionText, rubricId]);
|
||||||
|
|
||||||
const data = gradeMutation.data;
|
const data = gradeMutation.data;
|
||||||
const marks = data ? Math.round(data.overall_score) : 0;
|
const marks = data ? Math.round(data.overall_band * 100 / 9) : 0;
|
||||||
const feedbackBlock = data
|
const feedbackBlock = data
|
||||||
? [
|
? [
|
||||||
data.feedback,
|
data.feedback,
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Sparkles, TrendingUp, AlertTriangle, Trophy, Loader2 } from "lucide-react";
|
import { Sparkles, TrendingUp, AlertTriangle, Info, Loader2 } from "lucide-react";
|
||||||
import { analyticsService } from "@/services/analytics.service";
|
import { analyticsService, type AiInsightItem } from "@/services/analytics.service";
|
||||||
import type { AiInsight } from "@/types";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
const EMPTY_PAYLOAD: Record<string, unknown> = {};
|
const EMPTY_PAYLOAD: Record<string, unknown> = {};
|
||||||
|
|
||||||
function insightIcon(type: AiInsight["type"]) {
|
function insightIcon(severity: AiInsightItem["severity"]) {
|
||||||
switch (type) {
|
switch (severity) {
|
||||||
case "positive":
|
case "critical":
|
||||||
return Trophy;
|
|
||||||
case "warning":
|
|
||||||
return AlertTriangle;
|
return AlertTriangle;
|
||||||
default:
|
case "warning":
|
||||||
return TrendingUp;
|
return TrendingUp;
|
||||||
|
default:
|
||||||
|
return Info;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function insightColor(type: AiInsight["type"]) {
|
function insightColor(severity: AiInsightItem["severity"]) {
|
||||||
switch (type) {
|
switch (severity) {
|
||||||
case "positive":
|
case "critical":
|
||||||
return "text-primary";
|
return "text-destructive";
|
||||||
case "warning":
|
case "warning":
|
||||||
return "text-warning";
|
return "text-warning";
|
||||||
default:
|
default:
|
||||||
return "text-success";
|
return "text-primary";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,14 +36,15 @@ interface Props {
|
|||||||
|
|
||||||
export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
|
export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { t } = useTranslation();
|
||||||
const payloadKey = useMemo(() => JSON.stringify(data), [data]);
|
const payloadKey = useMemo(() => JSON.stringify(data), [data]);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (payload: Record<string, unknown>) => analyticsService.getInsights(payload),
|
mutationFn: (payload: Record<string, unknown>) => analyticsService.getInsights(payload),
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
toast({
|
toast({
|
||||||
title: "Insights unavailable",
|
title: t("ai.insightsUnavailable"),
|
||||||
description: err.message || "Could not load AI insights.",
|
description: err.message || t("ai.couldNotLoadInsights"),
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -51,47 +52,47 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mutation.mutate(data);
|
mutation.mutate(data);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- refetch when serialized payload changes
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [payloadKey]);
|
}, [payloadKey]);
|
||||||
|
|
||||||
const items = mutation.data ?? [];
|
const items = mutation.data?.insights ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
<Sparkles className="h-4 w-4 text-primary" />
|
<Sparkles className="h-4 w-4 text-primary" />
|
||||||
AI Platform Insights
|
{t("ai.platformInsightsTitle")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{mutation.isPending && (
|
{mutation.isPending && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-8 justify-center">
|
<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" />
|
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||||
Loading insights…
|
{t("ai.loadingInsights")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{mutation.isError && !mutation.isPending && (
|
{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 && (
|
{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 && (
|
{!mutation.isPending && items.length > 0 && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{items.map((item) => {
|
{items.map((item, idx) => {
|
||||||
const Icon = insightIcon(item.type);
|
const Icon = insightIcon(item.severity);
|
||||||
const color = insightColor(item.type);
|
const color = insightColor(item.severity);
|
||||||
return (
|
return (
|
||||||
<div key={item.id} 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">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Icon className={`h-4 w-4 ${color}`} />
|
<Icon className={`h-4 w-4 shrink-0 ${color}`} />
|
||||||
<span className="text-sm font-semibold">{item.title}</span>
|
<span className="text-sm font-semibold min-w-0">{item.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{item.description}</p>
|
<p className="text-sm text-muted-foreground">{item.description}</p>
|
||||||
{item.metric != null && item.value != null && (
|
{item.recommendation && (
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<p className="text-xs text-muted-foreground mt-2 italic">
|
||||||
{item.metric}: {item.value}
|
{item.recommendation}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Sparkles, Search, Loader2, X } from "lucide-react";
|
import { Sparkles, Search, Loader2, X } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@@ -10,14 +11,15 @@ export default function AiSearchBar() {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const searchMutation = useMutation({
|
const searchMutation = useMutation({
|
||||||
mutationFn: (q: string) => analyticsService.search(q),
|
mutationFn: (q: string) => analyticsService.search(q),
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Search failed",
|
title: t("chrome.aiSearchFailedTitle"),
|
||||||
description: err.message || "Could not complete AI search.",
|
description: err.message || t("chrome.aiSearchFailedDesc"),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -27,16 +29,16 @@ export default function AiSearchBar() {
|
|||||||
searchMutation.mutate(query.trim());
|
searchMutation.mutate(query.trim());
|
||||||
};
|
};
|
||||||
|
|
||||||
const results = searchMutation.data;
|
const result = searchMutation.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative max-w-md w-full">
|
<div className="relative max-w-md w-full">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Sparkles className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary" />
|
<Sparkles className="absolute start-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" />
|
<Search className="absolute start-7 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Ask anything... e.g. 'Show students with low attendance'"
|
placeholder={t("chrome.aiSearchPlaceholder")}
|
||||||
className="pl-12 pr-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1"
|
className="ps-12 pe-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setQuery(e.target.value);
|
setQuery(e.target.value);
|
||||||
@@ -50,50 +52,56 @@ export default function AiSearchBar() {
|
|||||||
setQuery("");
|
setQuery("");
|
||||||
searchMutation.reset();
|
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" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(searchMutation.isPending || results !== undefined) && (
|
{(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 ? (
|
{searchMutation.isPending ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||||
AI is searching...
|
{t("chrome.aiSearching")}
|
||||||
</div>
|
</div>
|
||||||
) : results && results.length > 0 ? (
|
) : result?.answer ? (
|
||||||
<div className="text-sm space-y-2 max-h-64 overflow-y-auto">
|
<div className="text-sm space-y-2 max-h-64 overflow-y-auto">
|
||||||
{results.map((r, i) => (
|
<div className="flex items-start gap-2 pb-2">
|
||||||
<div
|
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||||
key={`${r.title}-${i}`}
|
<p className="text-muted-foreground">{result.answer}</p>
|
||||||
className="flex items-start gap-2 border-b border-border/60 pb-2 last:border-0 last:pb-0"
|
</div>
|
||||||
>
|
{result.suggestions?.length > 0 && (
|
||||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
<div className="border-t pt-2 space-y-1">
|
||||||
<div className="min-w-0">
|
<p className="text-xs font-semibold text-primary">{t("chrome.aiRelatedQueries")}</p>
|
||||||
<p className="font-medium">{r.title}</p>
|
{result.suggestions.map((s, i) => (
|
||||||
<p className="text-muted-foreground text-xs mt-0.5">{r.description}</p>
|
<button
|
||||||
{r.url && (
|
key={i}
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className="block text-xs text-primary hover:underline"
|
||||||
className="text-xs text-primary mt-1 hover:underline"
|
onClick={() => { setQuery(s); searchMutation.mutate(s); }}
|
||||||
onClick={() => navigate(r.url!)}
|
>
|
||||||
>
|
{s}
|
||||||
Go to {r.url}
|
</button>
|
||||||
</button>
|
))}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{result.related_actions?.map((a, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-primary hover:underline"
|
||||||
|
onClick={() => navigate(a.action)}
|
||||||
|
>
|
||||||
|
{a.label}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm flex items-start gap-2">
|
<div className="text-sm flex items-start gap-2">
|
||||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||||
<p>
|
<p>{t("chrome.aiNoResults", { q: query })}</p>
|
||||||
No results for "{query}". Try a different question or keyword.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,8 +29,11 @@ export default function AiStudyCoach() {
|
|||||||
suggestMutation.mutate();
|
suggestMutation.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
const suggestions = suggestMutation.data?.suggestions ?? [];
|
const d = suggestMutation.data;
|
||||||
const planTips = suggestMutation.data?.study_plan_tips ?? [];
|
const suggestions = d ? [d.suggestion, ...(d.focus_areas ?? []).map((a: string) => `Focus area: ${a}`)].filter(Boolean) : [];
|
||||||
|
const planTips = d?.daily_plan?.length
|
||||||
|
? d.daily_plan.map((p: { activity: string; duration_min: number; skill: string }) => `${p.activity} (${p.duration_min}min — ${p.skill})`)
|
||||||
|
: d?.motivation ? [d.motivation] : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-0 shadow-sm bg-primary/5">
|
<Card className="border-0 shadow-sm bg-primary/5">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Sparkles, X, Loader2 } from "lucide-react";
|
import { Sparkles, X, Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { coachingService } from "@/services/coaching.service";
|
import { coachingService } from "@/services/coaching.service";
|
||||||
@@ -12,6 +13,7 @@ interface Props {
|
|||||||
|
|
||||||
export default function AiTipBanner({ context = "dashboard", variant = "tip", dismissible = true }: Props) {
|
export default function AiTipBanner({ context = "dashboard", variant = "tip", dismissible = true }: Props) {
|
||||||
const [dismissed, setDismissed] = useState(false);
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data, isLoading, isError, error } = useQuery({
|
const { data, isLoading, isError, error } = useQuery({
|
||||||
queryKey: ["ai", "tip", context],
|
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 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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-center gap-3`}>
|
<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" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError || !data) {
|
if (isError || !data) {
|
||||||
return (
|
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" />
|
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-xs font-semibold text-primary">AI Tip</span>
|
<span className="block text-xs font-semibold text-primary">{t("ai.tipLabel")}</span>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{dismissible && (
|
{dismissible && (
|
||||||
@@ -50,26 +59,28 @@ export default function AiTipBanner({ context = "dashboard", variant = "tip", di
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.content?.trim() && !data.title?.trim()) {
|
if (!data.tip?.trim()) {
|
||||||
return (
|
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" />
|
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-xs font-semibold text-primary">AI {variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}</span>
|
<span className="block text-xs font-semibold text-primary">{variantLabel}</span>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">No tip for this context yet.</p>
|
<p className="text-sm text-muted-foreground mt-0.5">{t("ai.noTipForContext")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const label = data.category && data.category !== "general"
|
||||||
|
? t("ai.categoryTipLabel", { category: data.category.charAt(0).toUpperCase() + data.category.slice(1) })
|
||||||
|
: variantLabel;
|
||||||
|
|
||||||
return (
|
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" />
|
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-xs font-semibold text-primary">
|
<span className="block text-xs font-semibold text-primary">{label}</span>
|
||||||
{data.title?.trim() || `AI ${variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}`}
|
<p className="text-sm text-muted-foreground mt-0.5">{data.tip}</p>
|
||||||
</span>
|
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">{data.content}</p>
|
|
||||||
</div>
|
</div>
|
||||||
{dismissible && (
|
{dismissible && (
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => setDismissed(true)}>
|
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => setDismissed(true)}>
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (mode: NonNullable<Mode>) =>
|
mutationFn: (mode: NonNullable<Mode>) =>
|
||||||
coachingService.writingHelp({
|
coachingService.writingHelp({
|
||||||
text: text.trim(),
|
task: task_type,
|
||||||
task_type: `${task_type}:${mode}`,
|
draft: text.trim(),
|
||||||
|
help_type: mode,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => setShowResult(true),
|
onSuccess: () => setShowResult(true),
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
@@ -84,20 +85,20 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
|
|||||||
|
|
||||||
{showResult && !loading && mutation.data && activeMode === "improve" && (
|
{showResult && !loading && mutation.data && activeMode === "improve" && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{mutation.data.feedback && (
|
{mutation.data.tips?.length > 0 && (
|
||||||
<div className="rounded-lg border bg-muted/30 p-3">
|
<div className="rounded-lg border bg-muted/30 p-3">
|
||||||
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||||
<Sparkles className="h-3 w-3" /> Feedback
|
<Sparkles className="h-3 w-3" /> Feedback
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">{mutation.data.feedback}</p>
|
<p className="text-sm text-muted-foreground">{mutation.data.tips.join(" ")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{mutation.data.improved && (
|
{mutation.data.improved_text && (
|
||||||
<div className="rounded-lg border bg-muted/30 p-3">
|
<div className="rounded-lg border bg-muted/30 p-3">
|
||||||
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||||
<Sparkles className="h-3 w-3" /> Improved Version
|
<Sparkles className="h-3 w-3" /> Improved Version
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm">{mutation.data.improved}</p>
|
<p className="text-sm">{mutation.data.improved_text}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -108,17 +109,17 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
|
|||||||
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||||
<Sparkles className="h-3 w-3" /> Grammar notes
|
<Sparkles className="h-3 w-3" /> Grammar notes
|
||||||
</p>
|
</p>
|
||||||
{(mutation.data.grammar_notes?.length ?? 0) > 0 ? (
|
{(mutation.data.changes?.length ?? 0) > 0 ? (
|
||||||
mutation.data.grammar_notes!.map((note, i) => (
|
mutation.data.changes.map((c, i) => (
|
||||||
<div key={i} className="text-sm border-l-2 border-warning pl-2">
|
<div key={i} className="text-sm border-l-2 border-warning pl-2">
|
||||||
<p className="text-muted-foreground">{note}</p>
|
<p className="text-muted-foreground"><strong>{c.original}</strong> → {c.revised} — {c.reason}</p>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">No grammar issues flagged.</p>
|
<p className="text-sm text-muted-foreground">No grammar issues flagged.</p>
|
||||||
)}
|
)}
|
||||||
{mutation.data.feedback ? (
|
{mutation.data.tips?.length > 0 ? (
|
||||||
<p className="text-xs text-muted-foreground pt-2 border-t">{mutation.data.feedback}</p>
|
<p className="text-xs text-muted-foreground pt-2 border-t">{mutation.data.tips.join("; ")}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -128,9 +129,9 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
|
|||||||
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||||
<Sparkles className="h-3 w-3" /> Estimated band / assessment
|
<Sparkles className="h-3 w-3" /> Estimated band / assessment
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">{mutation.data.feedback}</p>
|
<p className="text-sm text-muted-foreground">{mutation.data.tips?.join(" ") ?? ""}</p>
|
||||||
{mutation.data.improved ? (
|
{mutation.data.improved_text ? (
|
||||||
<p className="text-sm mt-2 pt-2 border-t">{mutation.data.improved}</p>
|
<p className="text-sm mt-2 pt-2 border-t">{mutation.data.improved_text}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
124
src/components/student/ExamPopup.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
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";
|
||||||
|
import { Clock, Calendar, PlayCircle, AlertCircle } from "lucide-react";
|
||||||
|
import { assignmentsService } from "@/services/assignments.service";
|
||||||
|
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());
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["student-my-exams"],
|
||||||
|
queryFn: () => assignmentsService.getStudentExams(),
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const exams = (data?.items ?? []) as StudentExamAssignment[];
|
||||||
|
const pendingExams = exams.filter((e) => !dismissed.has(e.id));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingExams.length > 0 && !open) {
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
}, [pendingExams.length]);
|
||||||
|
|
||||||
|
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(dateLocale, { month: "short", day: "numeric", year: "numeric" }) +
|
||||||
|
" " + d.toLocaleTimeString(dateLocale, { hour: "2-digit", minute: "2-digit" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStart = (exam: StudentExamAssignment) => {
|
||||||
|
setOpen(false);
|
||||||
|
navigate(`/student/exam/${exam.exam_id}/session`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismiss = (id: number) => {
|
||||||
|
setDismissed((prev) => new Set([...prev, id]));
|
||||||
|
const remaining = pendingExams.filter((e) => e.id !== id);
|
||||||
|
if (remaining.length === 0) setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-5 w-5 text-primary" />
|
||||||
|
{t("examPopup.title", { n: pendingExams.length })}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<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">
|
||||||
|
<h3 className="font-semibold text-sm">{exam.exam_title || exam.schedule_name}</h3>
|
||||||
|
<Badge
|
||||||
|
variant={exam.schedule_state === "active" ? "default" : "secondary"}
|
||||||
|
className="capitalize text-xs"
|
||||||
|
>
|
||||||
|
{exam.schedule_state === "active" ? t("examPopup.activeNow") : exam.schedule_state}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{exam.schedule_name && exam.exam_title && (
|
||||||
|
<p className="text-xs text-muted-foreground">{exam.schedule_name}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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" />
|
||||||
|
{t("examPopup.from")} {formatDate(exam.start_date)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{t("examPopup.to")} {formatDate(exam.end_date)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!exam.can_start}
|
||||||
|
onClick={() => handleStart(exam)}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<PlayCircle className="h-3.5 w-3.5" />
|
||||||
|
{exam.can_start ? t("examPopup.startExam") : t("examPopup.notAvailableYet")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleDismiss(exam.id)}
|
||||||
|
>
|
||||||
|
{t("common.dismiss")}
|
||||||
|
</Button>
|
||||||
|
{!exam.can_start && (
|
||||||
|
<span className="text-[11px] text-muted-foreground italic ms-auto">
|
||||||
|
{exam.schedule_state === "planned"
|
||||||
|
? t("examPopup.willBeAvailable")
|
||||||
|
: t("examPopup.notActive")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,22 +25,72 @@ const AlertDialogOverlay = React.forwardRef<
|
|||||||
));
|
));
|
||||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
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<
|
const AlertDialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
AlertDialogContentProps
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, children, description, ...props }, ref) => {
|
||||||
<AlertDialogPortal>
|
const needsFallback =
|
||||||
<AlertDialogOverlay />
|
!props["aria-describedby"] &&
|
||||||
<AlertDialogPrimitive.Content
|
description === undefined &&
|
||||||
ref={ref}
|
!hasAlertDialogDescription(children);
|
||||||
className={cn(
|
return (
|
||||||
"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",
|
<AlertDialogPortal>
|
||||||
className,
|
<AlertDialogOverlay />
|
||||||
)}
|
<AlertDialogPrimitive.Content
|
||||||
{...props}
|
ref={ref}
|
||||||
/>
|
className={cn(
|
||||||
</AlertDialogPortal>
|
"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;
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
|||||||
@@ -22,8 +22,11 @@ const badgeVariants = cva(
|
|||||||
|
|
||||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
({ className, variant, ...props }, ref) => (
|
||||||
}
|
<div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Badge.displayName = "Badge";
|
||||||
|
|
||||||
export { Badge, badgeVariants };
|
export { Badge, badgeVariants };
|
||||||
|
|||||||
@@ -60,7 +60,12 @@ const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWit
|
|||||||
BreadcrumbPage.displayName = "BreadcrumbPage";
|
BreadcrumbPage.displayName = "BreadcrumbPage";
|
||||||
|
|
||||||
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
|
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 />}
|
{children ?? <ChevronRight />}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
|
|||||||
...classNames,
|
...classNames,
|
||||||
}}
|
}}
|
||||||
components={{
|
components={{
|
||||||
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
|
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4 rtl:rotate-180" />,
|
||||||
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
|
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4 rtl:rotate-180" />,
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProp
|
|||||||
onClick={scrollPrev}
|
onClick={scrollPrev}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4 rtl:rotate-180" />
|
||||||
<span className="sr-only">Previous slide</span>
|
<span className="sr-only">Previous slide</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
@@ -213,7 +213,7 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
|
|||||||
onClick={scrollNext}
|
onClick={scrollNext}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
|
||||||
<span className="sr-only">Next slide</span>
|
<span className="sr-only">Next slide</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const ContextMenuSubTrigger = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRight className="ml-auto h-4 w-4" />
|
<ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
|
||||||
</ContextMenuPrimitive.SubTrigger>
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
));
|
));
|
||||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|||||||
@@ -27,28 +27,86 @@ const DialogOverlay = React.forwardRef<
|
|||||||
));
|
));
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
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<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
DialogContentProps
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, description, ...props }, ref) => {
|
||||||
<DialogPortal>
|
const hasExplicitDescribedBy = !!props["aria-describedby"];
|
||||||
<DialogOverlay />
|
const needsFallback =
|
||||||
<DialogPrimitive.Content
|
!hasExplicitDescribedBy &&
|
||||||
ref={ref}
|
description === undefined &&
|
||||||
className={cn(
|
!hasDialogDescription(children);
|
||||||
"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,
|
return (
|
||||||
)}
|
<DialogPortal>
|
||||||
{...props}
|
<DialogOverlay />
|
||||||
>
|
<DialogPrimitive.Content
|
||||||
{children}
|
ref={ref}
|
||||||
<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">
|
className={cn(
|
||||||
<X className="h-4 w-4" />
|
"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",
|
||||||
<span className="sr-only">Close</span>
|
className,
|
||||||
</DialogPrimitive.Close>
|
)}
|
||||||
</DialogPrimitive.Content>
|
{...props}
|
||||||
</DialogPortal>
|
>
|
||||||
));
|
{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;
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRight className="ml-auto h-4 w-4" />
|
<ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
));
|
));
|
||||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const MenubarSubTrigger = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRight className="ml-auto h-4 w-4" />
|
<ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
|
||||||
</MenubarPrimitive.SubTrigger>
|
</MenubarPrimitive.SubTrigger>
|
||||||
));
|
));
|
||||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
|
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
|
||||||
|
|||||||
@@ -47,17 +47,17 @@ const PaginationLink = ({ className, isActive, size = "icon", ...props }: Pagina
|
|||||||
PaginationLink.displayName = "PaginationLink";
|
PaginationLink.displayName = "PaginationLink";
|
||||||
|
|
||||||
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof 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}>
|
<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 ps-2.5", className)} {...props}>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4 rtl:rotate-180" />
|
||||||
<span>Previous</span>
|
<span>Previous</span>
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
);
|
);
|
||||||
PaginationPrevious.displayName = "PaginationPrevious";
|
PaginationPrevious.displayName = "PaginationPrevious";
|
||||||
|
|
||||||
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
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>
|
<span>Next</span>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4 rtl:rotate-180" />
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
);
|
);
|
||||||
PaginationNext.displayName = "PaginationNext";
|
PaginationNext.displayName = "PaginationNext";
|
||||||
|
|||||||
@@ -49,21 +49,61 @@ const sheetVariants = cva(
|
|||||||
|
|
||||||
interface SheetContentProps
|
interface SheetContentProps
|
||||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
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>(
|
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
|
||||||
({ side = "right", className, children, ...props }, ref) => (
|
({ side = "right", className, children, description, ...props }, ref) => {
|
||||||
<SheetPortal>
|
const needsFallback =
|
||||||
<SheetOverlay />
|
!props["aria-describedby"] &&
|
||||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
description === undefined &&
|
||||||
{children}
|
!hasSheetDescription(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">
|
return (
|
||||||
<X className="h-4 w-4" />
|
<SheetPortal>
|
||||||
<span className="sr-only">Close</span>
|
<SheetOverlay />
|
||||||
</SheetPrimitive.Close>
|
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||||
</SheetPrimitive.Content>
|
{description !== undefined && (
|
||||||
</SheetPortal>
|
<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;
|
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ const Sidebar = React.forwardRef<
|
|||||||
Sidebar.displayName = "Sidebar";
|
Sidebar.displayName = "Sidebar";
|
||||||
|
|
||||||
const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
|
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();
|
const { toggleSidebar } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -226,6 +226,7 @@ const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.C
|
|||||||
data-sidebar="trigger"
|
data-sidebar="trigger"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
aria-label={ariaLabel ?? "Toggle sidebar"}
|
||||||
className={cn("h-7 w-7", className)}
|
className={cn("h-7 w-7", className)}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
onClick?.(event);
|
onClick?.(event);
|
||||||
@@ -233,8 +234,8 @@ const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.C
|
|||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<PanelLeft />
|
<PanelLeft className="rtl:rotate-180" />
|
||||||
<span className="sr-only">Toggle Sidebar</span>
|
<span className="sr-only">{ariaLabel ?? "Toggle sidebar"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
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,241 +4,246 @@ export const queryKeys = {
|
|||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
all: ["users"] as const,
|
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,
|
detail: (id: number) => ["users", id] as const,
|
||||||
},
|
},
|
||||||
entities: {
|
entities: {
|
||||||
all: ["entities"] as const,
|
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,
|
detail: (id: number) => ["entities", id] as const,
|
||||||
roles: (entityId: number) => ["entities", entityId, "roles"] as const,
|
roles: (entityId: number) => ["entities", entityId, "roles"] as const,
|
||||||
permissions: (entityId: number) => ["entities", entityId, "permissions"] as const,
|
permissions: (entityId: number) => ["entities", entityId, "permissions"] as const,
|
||||||
},
|
},
|
||||||
exams: {
|
exams: {
|
||||||
all: ["exams"] as const,
|
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,
|
detail: (module: string, id: number) => ["exams", module, id] as const,
|
||||||
rubrics: (params?: Record<string, unknown>) => ["exams", "rubrics", params] as const,
|
rubrics: (params?: object) => ["exams", "rubrics", params] as const,
|
||||||
rubricGroups: (params?: Record<string, unknown>) => ["exams", "rubric-groups", params] as const,
|
rubricGroups: (params?: object) => ["exams", "rubric-groups", params] as const,
|
||||||
structures: (params?: Record<string, unknown>) => ["exams", "structures", params] as const,
|
structures: (params?: object) => ["exams", "structures", params] as const,
|
||||||
avatars: ["exams", "avatars"] 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: {
|
assignments: {
|
||||||
all: ["assignments"] as const,
|
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,
|
detail: (id: number) => ["assignments", id] as const,
|
||||||
},
|
},
|
||||||
classrooms: {
|
classrooms: {
|
||||||
all: ["classrooms"] as const,
|
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,
|
detail: (id: number) => ["classrooms", id] as const,
|
||||||
},
|
},
|
||||||
stats: {
|
stats: {
|
||||||
sessions: (params?: Record<string, unknown>) => ["stats", "sessions", params] as const,
|
sessions: (params?: object) => ["stats", "sessions", params] as const,
|
||||||
stats: (params?: Record<string, unknown>) => ["stats", "stats", params] as const,
|
stats: (params?: object) => ["stats", "stats", params] as const,
|
||||||
statistical: (params?: Record<string, unknown>) => ["stats", "statistical", params] as const,
|
statistical: (params?: object) => ["stats", "statistical", params] as const,
|
||||||
performance: (params?: Record<string, unknown>) => ["stats", "performance", params] as const,
|
performance: (params?: object) => ["stats", "performance", params] as const,
|
||||||
},
|
},
|
||||||
training: {
|
training: {
|
||||||
all: ["training"] as const,
|
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,
|
walkthroughs: (module?: string) => ["training", "walkthroughs", module] as const,
|
||||||
},
|
},
|
||||||
subscriptions: {
|
subscriptions: {
|
||||||
packages: ["subscriptions", "packages"] as const,
|
packages: ["subscriptions", "packages"] as const,
|
||||||
payments: (params?: Record<string, unknown>) => ["subscriptions", "payments", params] as const,
|
payments: (params?: object) => ["subscriptions", "payments", params] as const,
|
||||||
discounts: (params?: Record<string, unknown>) => ["subscriptions", "discounts", params] as const,
|
discounts: (params?: object) => ["subscriptions", "discounts", params] as const,
|
||||||
},
|
},
|
||||||
tickets: {
|
tickets: {
|
||||||
all: ["tickets"] as const,
|
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,
|
assigned: ["tickets", "assigned"] as const,
|
||||||
},
|
},
|
||||||
approvals: {
|
approvals: {
|
||||||
all: ["approvals"] as const,
|
all: ["approvals"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["approvals", "list", params] as const,
|
list: (params?: object) => ["approvals", "list", params] as const,
|
||||||
},
|
},
|
||||||
taxonomy: {
|
taxonomy: {
|
||||||
subjects: ["taxonomy", "subjects"] as const,
|
subjects: ["taxonomy", "subjects"] as const,
|
||||||
subject: (id: number) => ["taxonomy", "subjects", id] as const,
|
subject: (id: number) => ["taxonomy", "subjects", id] as const,
|
||||||
tree: (subjectId: number) => ["taxonomy", "tree", subjectId] as const,
|
tree: (subjectId: number) => ["taxonomy", "tree", subjectId] as const,
|
||||||
domains: (params?: Record<string, unknown>) => ["taxonomy", "domains", params] as const,
|
domains: (params?: object) => ["taxonomy", "domains", params] as const,
|
||||||
topics: (params?: Record<string, unknown>) => ["taxonomy", "topics", params] as const,
|
topics: (params?: object) => ["taxonomy", "topics", params] as const,
|
||||||
},
|
},
|
||||||
adaptive: {
|
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,
|
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,
|
topicContent: (topicId: number) => ["adaptive", "topic", topicId, "content"] as const,
|
||||||
diagnostic: (sessionId: number) => ["adaptive", "diagnostic", sessionId] as const,
|
diagnostic: (sessionId: number) => ["adaptive", "diagnostic", sessionId] as const,
|
||||||
},
|
},
|
||||||
resources: {
|
resources: {
|
||||||
all: ["resources"] as const,
|
all: ["resources"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["resources", "list", params] as const,
|
list: (params?: object) => ["resources", "list", params] as const,
|
||||||
},
|
},
|
||||||
lms: {
|
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,
|
course: (id: number) => ["lms", "courses", id] as const,
|
||||||
students: (params?: Record<string, unknown>) => ["lms", "students", params] as const,
|
myCourses: ["lms", "my-courses"] as const,
|
||||||
teachers: (params?: Record<string, unknown>) => ["lms", "teachers", params] as const,
|
students: (params?: object) => ["lms", "students", params] as const,
|
||||||
batches: (params?: Record<string, unknown>) => ["lms", "batches", 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,
|
batch: (id: number) => ["lms", "batches", id] as const,
|
||||||
timetable: (params?: Record<string, unknown>) => ["lms", "timetable", params] as const,
|
timetable: (params?: object) => ["lms", "timetable", params] as const,
|
||||||
attendance: (params?: Record<string, unknown>) => ["lms", "attendance", params] as const,
|
attendance: (params?: object) => ["lms", "attendance", params] as const,
|
||||||
grades: (params?: Record<string, unknown>) => ["lms", "grades", params] as const,
|
grades: (params?: object) => ["lms", "grades", params] as const,
|
||||||
},
|
},
|
||||||
analytics: {
|
analytics: {
|
||||||
student: (params?: Record<string, unknown>) => ["analytics", "student", params] as const,
|
student: (params?: object) => ["analytics", "student", params] as const,
|
||||||
class: (params?: Record<string, unknown>) => ["analytics", "class", params] as const,
|
class: (params?: object) => ["analytics", "class", params] as const,
|
||||||
subject: (params?: Record<string, unknown>) => ["analytics", "subject", params] as const,
|
subject: (params?: object) => ["analytics", "subject", params] as const,
|
||||||
contentGaps: (params?: Record<string, unknown>) => ["analytics", "content-gaps", params] as const,
|
contentGaps: (params?: object) => ["analytics", "content-gaps", params] as const,
|
||||||
alerts: ["analytics", "alerts"] as const,
|
alerts: ["analytics", "alerts"] as const,
|
||||||
},
|
},
|
||||||
academicYears: {
|
academicYears: {
|
||||||
all: ["academic-years"] as const,
|
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,
|
detail: (id: number) => ["academic-years", id] as const,
|
||||||
},
|
},
|
||||||
academicTerms: {
|
academicTerms: {
|
||||||
all: ["academic-terms"] as const,
|
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,
|
detail: (id: number) => ["academic-terms", id] as const,
|
||||||
},
|
},
|
||||||
departments: {
|
departments: {
|
||||||
all: ["departments"] as const,
|
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,
|
detail: (id: number) => ["departments", id] as const,
|
||||||
},
|
},
|
||||||
admissionRegisters: {
|
admissionRegisters: {
|
||||||
all: ["admission-registers"] as const,
|
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,
|
detail: (id: number) => ["admission-registers", id] as const,
|
||||||
},
|
},
|
||||||
admissions: {
|
admissions: {
|
||||||
all: ["admissions"] as const,
|
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,
|
detail: (id: number) => ["admissions", id] as const,
|
||||||
},
|
},
|
||||||
instExamSessions: {
|
instExamSessions: {
|
||||||
all: ["inst-exam-sessions"] as const,
|
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,
|
detail: (id: number) => ["inst-exam-sessions", id] as const,
|
||||||
},
|
},
|
||||||
examTypes: {
|
examTypes: {
|
||||||
all: ["exam-types"] as const,
|
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: {
|
gradeConfigs: {
|
||||||
all: ["grade-configs"] as const,
|
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: {
|
resultTemplates: {
|
||||||
all: ["result-templates"] as const,
|
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: {
|
marksheets: {
|
||||||
all: ["marksheets"] as const,
|
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,
|
detail: (id: number) => ["marksheets", id] as const,
|
||||||
},
|
},
|
||||||
courseAssignments: {
|
courseAssignments: {
|
||||||
all: ["course-assignments"] as const,
|
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,
|
detail: (id: number) => ["course-assignments", id] as const,
|
||||||
},
|
},
|
||||||
assignmentTypes: {
|
assignmentTypes: {
|
||||||
all: ["assignment-types"] as const,
|
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: {
|
subjectRegistrations: {
|
||||||
all: ["subject-registrations"] as const,
|
all: ["subject-registrations"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["subject-registrations", "list", params] as const,
|
list: (params?: object) => ["subject-registrations", "list", params] as const,
|
||||||
available: (params?: Record<string, unknown>) => ["subject-registrations", "available", 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,
|
chapters: (courseId: number) => ["chapters", "list", courseId] as const,
|
||||||
chapter: (id: number) => ["chapters", "detail", id] as const,
|
chapter: (id: number) => ["chapters", "detail", id] as const,
|
||||||
chapterMaterials: (chapterId: number) => ["chapter-materials", chapterId] as const,
|
chapterMaterials: (chapterId: number) => ["chapter-materials", chapterId] as const,
|
||||||
chapterProgress: (chapterId: number) => ["chapter-progress", chapterId] as const,
|
chapterProgress: (chapterId: number) => ["chapter-progress", chapterId] as const,
|
||||||
discussionBoards: (params?: Record<string, unknown>) => ["discussion-boards", params] as const,
|
discussionBoards: (params?: object) => ["discussion-boards", params] as const,
|
||||||
posts: (boardId: number, params?: Record<string, unknown>) => ["posts", boardId, params] as const,
|
posts: (boardId: number, params?: object) => ["posts", boardId, params] as const,
|
||||||
announcements: (params?: Record<string, unknown>) => ["announcements", params] as const,
|
announcements: (params?: object) => ["announcements", params] as const,
|
||||||
messages: (params?: Record<string, unknown>) => ["messages", params] as const,
|
messages: (params?: object) => ["messages", params] as const,
|
||||||
sentMessages: (params?: Record<string, unknown>) => ["messages", "sent", params] as const,
|
sentMessages: (params?: object) => ["messages", "sent", params] as const,
|
||||||
unreadMessages: ["messages", "unread-count"] 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,
|
unreadNotifications: ["notifications", "unread-count"] as const,
|
||||||
notificationRules: ["notification-rules"] as const,
|
notificationRules: ["notification-rules"] as const,
|
||||||
notificationPreferences: ["notification-preferences"] as const,
|
notificationPreferences: ["notification-preferences"] as const,
|
||||||
faqCategories: (audience?: string) => ["faq", "categories", audience] 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: {
|
studentLeaves: {
|
||||||
all: ["student-leaves"] as const,
|
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: {
|
studentLeaveTypes: {
|
||||||
all: ["student-leave-types"] as const,
|
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: {
|
feesPlans: {
|
||||||
all: ["fees-plans"] as const,
|
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,
|
detail: (id: number) => ["fees-plans", id] as const,
|
||||||
},
|
},
|
||||||
studentFees: {
|
studentFees: {
|
||||||
all: ["student-fees"] as const,
|
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: {
|
feesTerms: {
|
||||||
all: ["fees-terms"] as const,
|
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: {
|
lessons: {
|
||||||
all: ["lessons"] as const,
|
all: ["lessons"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["lessons", "list", params] as const,
|
list: (params?: object) => ["lessons", "list", params] as const,
|
||||||
},
|
},
|
||||||
gradebooks: {
|
gradebooks: {
|
||||||
all: ["gradebooks"] as const,
|
all: ["gradebooks"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["gradebooks", "list", params] as const,
|
list: (params?: object) => ["gradebooks", "list", params] as const,
|
||||||
},
|
},
|
||||||
gradebookLines: {
|
gradebookLines: {
|
||||||
all: ["gradebook-lines"] as const,
|
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: {
|
gradingAssignments: {
|
||||||
all: ["grading-assignments"] as const,
|
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: {
|
studentProgress: {
|
||||||
all: ["student-progress"] as const,
|
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: {
|
libraryMedia: {
|
||||||
all: ["library-media"] as const,
|
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: {
|
libraryMovements: {
|
||||||
all: ["library-movements"] as const,
|
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: {
|
libraryCards: {
|
||||||
all: ["library-cards"] as const,
|
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: {
|
activities: {
|
||||||
all: ["activities"] as const,
|
all: ["activities"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["activities", "list", params] as const,
|
list: (params?: object) => ["activities", "list", params] as const,
|
||||||
},
|
},
|
||||||
activityTypes: {
|
activityTypes: {
|
||||||
all: ["activity-types"] as const,
|
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: {
|
facilities: {
|
||||||
all: ["facilities"] as const,
|
all: ["facilities"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["facilities", "list", params] as const,
|
list: (params?: object) => ["facilities", "list", params] as const,
|
||||||
},
|
},
|
||||||
assets: {
|
assets: {
|
||||||
all: ["assets"] as const,
|
all: ["assets"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["assets", "list", params] as const,
|
list: (params?: object) => ["assets", "list", params] as const,
|
||||||
},
|
},
|
||||||
signup: {
|
signup: {
|
||||||
goals: ["signup", "goals"] as const,
|
goals: ["signup", "goals"] as const,
|
||||||
@@ -255,7 +260,7 @@ export const queryKeys = {
|
|||||||
},
|
},
|
||||||
ieltsExam: {
|
ieltsExam: {
|
||||||
skills: (examId: number) => ["ielts-exam", examId, "skills"] as const,
|
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,
|
validation: (examId: number) => ["ielts-exam", examId, "validation"] as const,
|
||||||
},
|
},
|
||||||
examSession: {
|
examSession: {
|
||||||
@@ -263,7 +268,7 @@ export const queryKeys = {
|
|||||||
status: (examId: number) => ["exam-session", examId, "status"] as const,
|
status: (examId: number) => ["exam-session", examId, "status"] as const,
|
||||||
},
|
},
|
||||||
grading: {
|
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,
|
response: (attemptId: number, skill: string) => ["grading", attemptId, "response", skill] as const,
|
||||||
rubric: (attemptId: number, skill: string) => ["grading", attemptId, "rubric", skill] as const,
|
rubric: (attemptId: number, skill: string) => ["grading", attemptId, "rubric", skill] as const,
|
||||||
},
|
},
|
||||||
@@ -279,11 +284,11 @@ export const queryKeys = {
|
|||||||
englishTaxonomy: ["ai-course", "english", "taxonomy"] as const,
|
englishTaxonomy: ["ai-course", "english", "taxonomy"] as const,
|
||||||
},
|
},
|
||||||
entityOnboarding: {
|
entityOnboarding: {
|
||||||
credentials: (params?: Record<string, unknown>) => ["entity-onboarding", "credentials", params] as const,
|
credentials: (params?: object) => ["entity-onboarding", "credentials", params] as const,
|
||||||
},
|
},
|
||||||
adaptiveEngine: {
|
adaptiveEngine: {
|
||||||
dashboard: ["adaptive-engine", "dashboard"] as const,
|
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,
|
signals: (studentId: number) => ["adaptive-engine", "signals", studentId] as const,
|
||||||
ability: (studentId: number) => ["adaptive-engine", "ability", studentId] as const,
|
ability: (studentId: number) => ["adaptive-engine", "ability", studentId] as const,
|
||||||
settings: ["adaptive-engine", "settings"] as const,
|
settings: ["adaptive-engine", "settings"] as const,
|
||||||
@@ -296,7 +301,7 @@ export const queryKeys = {
|
|||||||
current: ["branding", "current"] as const,
|
current: ["branding", "current"] as const,
|
||||||
},
|
},
|
||||||
scoreRelease: {
|
scoreRelease: {
|
||||||
pending: (params?: Record<string, unknown>) => ["score-release", "pending", params] as const,
|
pending: (params?: object) => ["score-release", "pending", params] as const,
|
||||||
},
|
},
|
||||||
verification: {
|
verification: {
|
||||||
verify: (hash: string) => ["verification", hash] as const,
|
verify: (hash: string) => ["verification", hash] as const,
|
||||||
|
|||||||
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
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -66,7 +66,10 @@ export function useGenerateTerms() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (yearId: number) => academicService.generateTerms(yearId),
|
mutationFn: (yearId: number) => academicService.generateTerms(yearId),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.academicTerms.all }),
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.academicTerms.all });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.academicYears.all });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { queryKeys } from "./keys";
|
import { queryKeys } from "./keys";
|
||||||
import { aiCourseService } from "@/services/ai-course.service";
|
import {
|
||||||
import type { ExaminerReview } from "@/types";
|
aiCourseService,
|
||||||
|
type AiCourseCreateEnglishRequest,
|
||||||
|
type AiCourseCreateIeltsRequest,
|
||||||
|
} from "@/services/ai-course.service";
|
||||||
|
|
||||||
export function useAiCourse(courseId: number | undefined) {
|
export function useAiCourse(courseId: number | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -22,7 +25,7 @@ export function useAiCourseTracks(courseId: number | undefined) {
|
|||||||
export function useCreateEnglishCourse() {
|
export function useCreateEnglishCourse() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: { current_level: string; target_level: string; learning_style: string[] }) =>
|
mutationFn: (data: AiCourseCreateEnglishRequest) =>
|
||||||
aiCourseService.createEnglish(data),
|
aiCourseService.createEnglish(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
||||||
@@ -33,7 +36,7 @@ export function useCreateEnglishCourse() {
|
|||||||
export function useCreateIeltsCourse() {
|
export function useCreateIeltsCourse() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: { exam_type: string; target_band: number; skills: string[] }) =>
|
mutationFn: (data: AiCourseCreateIeltsRequest) =>
|
||||||
aiCourseService.createIelts(data),
|
aiCourseService.createIelts(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
||||||
@@ -63,8 +66,8 @@ export function useApproveQuality() {
|
|||||||
export function useRejectQuality() {
|
export function useRejectQuality() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ courseId, notes }: { courseId: number; notes: string }) =>
|
mutationFn: ({ courseId, reason }: { courseId: number; reason: string }) =>
|
||||||
aiCourseService.rejectQuality(courseId, notes),
|
aiCourseService.rejectQuality(courseId, reason),
|
||||||
onSuccess: (_d, { courseId }) => {
|
onSuccess: (_d, { courseId }) => {
|
||||||
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.quality(courseId) });
|
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.quality(courseId) });
|
||||||
},
|
},
|
||||||
@@ -89,7 +92,8 @@ export function useIeltsValidation(courseId: number | undefined) {
|
|||||||
export function useSubmitExaminerReview() {
|
export function useSubmitExaminerReview() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: ExaminerReview) => aiCourseService.submitExaminerReview(data),
|
mutationFn: (data: { logId: number; action: string; examiner_notes?: string }) =>
|
||||||
|
aiCourseService.submitExaminerReview(data.logId, { action: data.action, examiner_notes: data.examiner_notes }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -120,6 +120,19 @@ export function useUploadMaterial() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCreateMaterialFromResource() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ chapterId, resourceId, name }: { chapterId: number; resourceId: number; name?: string }) =>
|
||||||
|
coursewareService.createMaterialFromResource(chapterId, resourceId, name),
|
||||||
|
onSuccess: (_, { chapterId }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.chapterMaterials(chapterId) });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.chapterProgress(chapterId) });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.chapter(chapterId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useDeleteMaterial() {
|
export function useDeleteMaterial() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -145,13 +158,24 @@ export function useMarkMaterialViewed() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCourseCompletion(courseId: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.courseCompletion(courseId),
|
||||||
|
queryFn: () => coursewareService.getCourseCompletion(courseId),
|
||||||
|
enabled: !!courseId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useCompleteChapter() {
|
export function useCompleteChapter() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (chapterId: number) => coursewareService.markChapterComplete(chapterId),
|
mutationFn: ({ chapterId, courseId }: { chapterId: number; courseId: number }) =>
|
||||||
onSuccess: (_, chapterId) => {
|
coursewareService.markChapterComplete(chapterId),
|
||||||
|
onSuccess: (_, { chapterId, courseId }) => {
|
||||||
qc.invalidateQueries({ queryKey: queryKeys.chapterProgress(chapterId) });
|
qc.invalidateQueries({ queryKey: queryKeys.chapterProgress(chapterId) });
|
||||||
qc.invalidateQueries({ queryKey: queryKeys.chapter(chapterId) });
|
qc.invalidateQueries({ queryKey: queryKeys.chapter(chapterId) });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.courseCompletion(courseId) });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.lms.myCourses });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
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) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ export function useExamAutoSave() {
|
|||||||
|
|
||||||
export function useExamSubmit() {
|
export function useExamSubmit() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (examId: number) => examSessionService.submit(examId),
|
mutationFn: (data: { examId: number; attempt_id: number; answers: { question_id: number; answer: unknown }[] }) =>
|
||||||
|
examSessionService.submit(data.examId, { attempt_id: data.attempt_id, answers: data.answers }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function useIeltsAutoAssemble() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useIeltsValidation(examId: number) {
|
export function useIeltsExamValidation(examId: number) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.ieltsExam.validation(examId),
|
queryKey: queryKeys.ieltsExam.validation(examId),
|
||||||
queryFn: () => ieltsExamService.validate(examId),
|
queryFn: () => ieltsExamService.validate(examId),
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { queryKeys } from "./keys";
|
import { queryKeys } from "./keys";
|
||||||
import { feesService } from "@/services/fees.service";
|
import { feesService, type FeesPlanFilters } from "@/services/fees.service";
|
||||||
import type { PaginationParams } from "@/types";
|
import type { PaginationParams } from "@/types";
|
||||||
|
|
||||||
export function useFeesPlans(params?: PaginationParams) {
|
export function useFeesPlans(params?: FeesPlanFilters) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.feesPlans.list(params),
|
queryKey: queryKeys.feesPlans.list(params),
|
||||||
queryFn: () => feesService.listFeesPlans(params),
|
queryFn: () => feesService.listFeesPlans(params),
|
||||||
@@ -18,7 +18,7 @@ export function useFeesPlan(id: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStudentFees(params?: PaginationParams) {
|
export function useStudentFees(params?: FeesPlanFilters) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.studentFees.list(params),
|
queryKey: queryKeys.studentFees.list(params),
|
||||||
queryFn: () => feesService.listStudentFees(params),
|
queryFn: () => feesService.listStudentFees(params),
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ export function useGradebooks(params?: PaginationParams) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGradebookLines(params?: PaginationParams) {
|
export function useGradebookLines(params?: PaginationParams & { gradebook_id?: number }) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.gradebookLines.list(params),
|
queryKey: queryKeys.gradebookLines.list(params),
|
||||||
queryFn: () => gradebookService.listGradebookLines(params),
|
queryFn: () => gradebookService.listGradebookLines(params),
|
||||||
|
enabled: params?.gradebook_id ? !!params.gradebook_id : true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { queryKeys } from "./keys";
|
import { queryKeys } from "./keys";
|
||||||
import { lmsService } from "@/services";
|
import { lmsService } from "@/services";
|
||||||
import type { PaginationParams, CourseCreateRequest, LmsStudentCreateRequest, LmsTeacherCreateRequest } from "@/types";
|
import type { PaginationParams, CourseCreateRequest, LmsStudentCreateRequest, LmsTeacherCreateRequest, EnrollStudentRequest, BulkEnrollRequest } from "@/types";
|
||||||
|
|
||||||
export function useCourses(params?: PaginationParams & { status?: string }) {
|
export function useCourses(params?: PaginationParams & { status?: string }) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -26,6 +26,38 @@ export function useCreateCourse() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useMyEnrolledCourses() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.lms.myCourses,
|
||||||
|
queryFn: () => lmsService.getMyEnrolledCourses(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEnrollStudent() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ studentId, data }: { studentId: number; data: EnrollStudentRequest }) =>
|
||||||
|
lmsService.enrollStudent(studentId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms", "students"] });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.lms.myCourses });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBulkEnroll() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ courseId, data }: { courseId: number; data: BulkEnrollRequest }) =>
|
||||||
|
lmsService.bulkEnrollInCourse(courseId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms", "students"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.lms.myCourses });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useStudents(params?: PaginationParams & { search?: string; batch_id?: number }) {
|
export function useStudents(params?: PaginationParams & { search?: string; batch_id?: number }) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.lms.students(params),
|
queryKey: queryKeys.lms.students(params),
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { queryKeys } from "./keys";
|
import { queryKeys } from "./keys";
|
||||||
import { studentProgressService } from "@/services/student-progress.service";
|
import { studentProgressService, type StudentProgressFilters } from "@/services/student-progress.service";
|
||||||
import type { PaginationParams } from "@/types";
|
|
||||||
|
|
||||||
export function useStudentProgressList(params?: PaginationParams) {
|
export function useStudentProgressList(params?: StudentProgressFilters) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.studentProgress.list(params),
|
queryKey: queryKeys.studentProgress.list(params),
|
||||||
queryFn: () => studentProgressService.listProgress(params),
|
queryFn: () => studentProgressService.listProgress(params),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useStudentProgressDetail(id: number | null | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.studentProgress.detail(id ?? 0),
|
||||||
|
queryFn: () => studentProgressService.getProgress(id as number),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
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
@@ -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
@@ -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
@@ -51,6 +51,14 @@
|
|||||||
--sidebar-accent-foreground: 8 40% 78%;
|
--sidebar-accent-foreground: 8 40% 78%;
|
||||||
--sidebar-border: 240 18% 20%;
|
--sidebar-border: 240 18% 20%;
|
||||||
--sidebar-ring: 8 50% 58%;
|
--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 {
|
.dark {
|
||||||
@@ -99,6 +107,12 @@
|
|||||||
--sidebar-accent-foreground: 8 40% 75%;
|
--sidebar-accent-foreground: 8 40% 75%;
|
||||||
--sidebar-border: 240 18% 13%;
|
--sidebar-border: 240 18% 13%;
|
||||||
--sidebar-ring: 8 50% 55%;
|
--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;
|
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(/\/$/, "");
|
export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL?.trim() || "/api").replace(/\/$/, "");
|
||||||
const BASE_URL = API_BASE_URL;
|
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 {
|
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 {
|
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> {
|
// Shared refresh promise — any 401 that arrives while a refresh is in flight
|
||||||
const data = await response.json().catch(() => null);
|
// waits on the existing request instead of firing a duplicate.
|
||||||
|
let refreshPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
if (response.status === 401) {
|
async function performRefresh(): Promise<boolean> {
|
||||||
const hadToken = !!getToken();
|
const refreshToken = getRefreshToken();
|
||||||
clearToken();
|
if (!refreshToken) return false;
|
||||||
// Login failure is also 401 — do not hard-redirect when no session existed.
|
try {
|
||||||
if (hadToken) {
|
const res = await fetch(`${BASE_URL}/auth/refresh`, {
|
||||||
window.location.href = "/login";
|
method: "POST",
|
||||||
}
|
headers: { "Content-Type": "application/json" },
|
||||||
throw new ApiError(401, response.statusText, data);
|
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> {
|
function refreshOnce(): Promise<boolean> {
|
||||||
const headers: Record<string, string> = {
|
if (!refreshPromise) {
|
||||||
"Content-Type": "application/json",
|
refreshPromise = performRefresh().finally(() => {
|
||||||
...extra,
|
refreshPromise = null;
|
||||||
};
|
});
|
||||||
|
|
||||||
const token = getToken();
|
|
||||||
if (token) {
|
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
|
||||||
}
|
}
|
||||||
|
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;
|
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);
|
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
|
||||||
if (params) {
|
if (params) {
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
for (const [key, rawValue] of Object.entries(params as Record<string, unknown>)) {
|
||||||
if (value !== undefined) {
|
if (rawValue === undefined || rawValue === null) continue;
|
||||||
url.searchParams.set(key, String(value));
|
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();
|
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 = {
|
export const api = {
|
||||||
async get<T>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
|
async get<T>(path: string, params?: QueryParams): Promise<T> {
|
||||||
const res = await fetch(buildUrl(path, params), {
|
return performRequest<T>(buildUrl(path, params), {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: buildHeaders(),
|
headers: buildHeaders(),
|
||||||
});
|
});
|
||||||
return handleResponse<T>(res);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async post<T>(path: string, body?: unknown): Promise<T> {
|
async post<T>(path: string, body?: unknown): Promise<T> {
|
||||||
const res = await fetch(buildUrl(path), {
|
return performRequest<T>(buildUrl(path), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildHeaders(),
|
headers: buildHeaders(),
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
return handleResponse<T>(res);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async patch<T>(path: string, body?: unknown): Promise<T> {
|
async patch<T>(path: string, body?: unknown): Promise<T> {
|
||||||
const res = await fetch(buildUrl(path), {
|
return performRequest<T>(buildUrl(path), {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: buildHeaders(),
|
headers: buildHeaders(),
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
return handleResponse<T>(res);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async put<T>(path: string, body?: unknown): Promise<T> {
|
async put<T>(path: string, body?: unknown): Promise<T> {
|
||||||
const res = await fetch(buildUrl(path), {
|
return performRequest<T>(buildUrl(path), {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: buildHeaders(),
|
headers: buildHeaders(),
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
return handleResponse<T>(res);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete<T>(path: string): Promise<T> {
|
async delete<T>(path: string): Promise<T> {
|
||||||
const res = await fetch(buildUrl(path), {
|
return performRequest<T>(buildUrl(path), {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: buildHeaders(),
|
headers: buildHeaders(),
|
||||||
});
|
});
|
||||||
return handleResponse<T>(res);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async upload<T>(path: string, formData: FormData): Promise<T> {
|
async upload<T>(path: string, formData: FormData): Promise<T> {
|
||||||
const token = getToken();
|
return performRequest<T>(buildUrl(path), {
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
if (token) {
|
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(buildUrl(path), {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers: buildHeaders(undefined, false),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
return handleResponse<T>(res);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
import "./i18n";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(<App />);
|
createRoot(document.getElementById("root")!).render(<App />);
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ interface PlatformStats {
|
|||||||
export default function AdminDashboard() {
|
export default function AdminDashboard() {
|
||||||
const { data: stats } = useQuery<PlatformStats>({
|
const { data: stats } = useQuery<PlatformStats>({
|
||||||
queryKey: ["platform", "stats"],
|
queryKey: ["platform", "stats"],
|
||||||
queryFn: async () => {
|
queryFn: async (): Promise<PlatformStats> => {
|
||||||
const res = await api.get<{ data: PlatformStats }>("/stats");
|
const res = await api.get<{ data: PlatformStats } | PlatformStats>("/stats");
|
||||||
return (res as { data: PlatformStats }).data ?? res;
|
const wrapped = res as { data?: PlatformStats };
|
||||||
|
return wrapped.data ?? (res as PlatformStats);
|
||||||
},
|
},
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,119 +1,556 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Plus, CheckCircle, XCircle, Clock } from "lucide-react";
|
import {
|
||||||
import AiGradingAssistant from "@/components/ai/AiGradingAssistant";
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
Loader2,
|
||||||
|
Trash2,
|
||||||
|
ChevronRight,
|
||||||
|
ShieldCheck,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
|
||||||
const workflows = [
|
interface WorkflowStep {
|
||||||
{
|
id: number;
|
||||||
id: 1, name: "Exam Content Review", status: "In Progress",
|
sequence: number;
|
||||||
steps: [
|
approver_id: number | null;
|
||||||
{ name: "Initial Review", assignee: "Dr. Smith", status: "Approved" },
|
approver_name: string;
|
||||||
{ name: "Quality Check", assignee: "Prof. Lee", status: "Pending" },
|
status: string;
|
||||||
{ name: "Final Approval", assignee: "Admin", status: "Waiting" },
|
comment: string;
|
||||||
],
|
auto_escalate: boolean;
|
||||||
},
|
max_days: number;
|
||||||
{
|
acted_at: string | null;
|
||||||
id: 2, name: "Rubric Approval", status: "Completed",
|
}
|
||||||
steps: [
|
|
||||||
{ name: "Draft Review", assignee: "Mr. Kim", status: "Approved" },
|
interface Workflow {
|
||||||
{ name: "Academic Board", assignee: "Dr. Smith", status: "Approved" },
|
id: number;
|
||||||
],
|
name: string;
|
||||||
},
|
type: string;
|
||||||
{
|
entity_id: number | null;
|
||||||
id: 3, name: "New Exam Structure", status: "Rejected",
|
entity_name: string | null;
|
||||||
steps: [
|
allow_bypass: boolean;
|
||||||
{ name: "Content Review", assignee: "Prof. Lee", status: "Approved" },
|
active: boolean;
|
||||||
{ name: "Standards Check", assignee: "Dr. Smith", status: "Rejected" },
|
steps: WorkflowStep[];
|
||||||
],
|
created: string;
|
||||||
},
|
}
|
||||||
];
|
|
||||||
|
interface ApprovalRequest {
|
||||||
|
id: number;
|
||||||
|
workflow_id: number | null;
|
||||||
|
workflow_name: string;
|
||||||
|
res_model: string;
|
||||||
|
res_id: number;
|
||||||
|
state: string;
|
||||||
|
requester_id: number | null;
|
||||||
|
requester_name: string;
|
||||||
|
current_stage_id: number | null;
|
||||||
|
current_stage_sequence: number | null;
|
||||||
|
bypass_reason: string;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
login: string;
|
||||||
|
}
|
||||||
|
|
||||||
const statusIcon = (s: string) => {
|
const statusIcon = (s: string) => {
|
||||||
if (s === "Approved") return <CheckCircle className="h-4 w-4 text-success" />;
|
if (s === "approved") return <CheckCircle className="h-4 w-4 text-green-600" />;
|
||||||
if (s === "Rejected") return <XCircle className="h-4 w-4 text-destructive" />;
|
if (s === "rejected") return <XCircle className="h-4 w-4 text-destructive" />;
|
||||||
return <Clock className="h-4 w-4 text-warning" />;
|
return <Clock className="h-4 w-4 text-amber-500" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateBadge = (state: string) => {
|
||||||
|
const map: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||||
|
approved: "default",
|
||||||
|
in_progress: "secondary",
|
||||||
|
rejected: "destructive",
|
||||||
|
draft: "outline",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Badge variant={map[state] || "outline"} className="capitalize">
|
||||||
|
{state.replace(/_/g, " ")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ApprovalWorkflowsPage() {
|
export default function ApprovalWorkflowsPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [formName, setFormName] = useState("");
|
||||||
|
const [formType, setFormType] = useState("custom");
|
||||||
|
const [formSteps, setFormSteps] = useState<{ approver_id: string }[]>([
|
||||||
|
{ approver_id: "" },
|
||||||
|
{ approver_id: "" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [rejectDialog, setRejectDialog] = useState<number | null>(null);
|
||||||
|
const [rejectComment, setRejectComment] = useState("");
|
||||||
|
|
||||||
|
const workflowsQ = useQuery({
|
||||||
|
queryKey: ["approval-workflows"],
|
||||||
|
queryFn: () => api.get<{ items: Workflow[]; total: number }>("/approval-workflows"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestsQ = useQuery({
|
||||||
|
queryKey: ["approval-requests"],
|
||||||
|
queryFn: () => api.get<{ items: ApprovalRequest[]; total: number }>("/approval-requests"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const usersQ = useQuery({
|
||||||
|
queryKey: ["approval-users"],
|
||||||
|
queryFn: () => api.get<{ items: UserItem[] }>("/approval-users"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflows = workflowsQ.data?.items ?? [];
|
||||||
|
const requests = requestsQ.data?.items ?? [];
|
||||||
|
const users = usersQ.data?.items ?? [];
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => api.post("/approval-workflows", data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
resetForm();
|
||||||
|
toast({ title: "Workflow created" });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast({ variant: "destructive", title: "Create failed", description: e.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMut = useMutation({
|
||||||
|
mutationFn: (id: number) => api.delete(`/approval-workflows/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
|
||||||
|
toast({ title: "Workflow deleted" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const approveMut = useMutation({
|
||||||
|
mutationFn: (id: number) => api.post(`/approval-requests/${id}/approve`, {}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["approval-requests"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
|
||||||
|
toast({ title: "Request approved" });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast({ variant: "destructive", title: "Approve failed", description: e.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rejectMut = useMutation({
|
||||||
|
mutationFn: ({ id, comment }: { id: number; comment: string }) =>
|
||||||
|
api.post(`/approval-requests/${id}/reject`, { comment }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["approval-requests"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
|
||||||
|
setRejectDialog(null);
|
||||||
|
setRejectComment("");
|
||||||
|
toast({ title: "Request rejected" });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast({ variant: "destructive", title: "Reject failed", description: e.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormName("");
|
||||||
|
setFormType("custom");
|
||||||
|
setFormSteps([{ approver_id: "" }, { approver_id: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
createMut.mutate({
|
||||||
|
name: formName.trim(),
|
||||||
|
type: formType,
|
||||||
|
steps: formSteps
|
||||||
|
.filter((s) => s.approver_id)
|
||||||
|
.map((s) => ({ approver_id: Number(s.approver_id) })),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingRequests = requests.filter((r) => r.state === "in_progress");
|
||||||
|
const completedRequests = requests.filter((r) => r.state !== "in_progress");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Approval Workflows</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Approval Workflows</h1>
|
||||||
<p className="text-muted-foreground">Manage multi-step approval processes for exam content.</p>
|
<p className="text-muted-foreground">
|
||||||
|
Manage multi-step approval processes for exam content.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog>
|
<Dialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setCreateOpen(open);
|
||||||
|
if (!open) resetForm();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Workflow</Button>
|
<Button size="sm">
|
||||||
|
<Plus className="h-4 w-4 mr-1" /> Create Workflow
|
||||||
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader><DialogTitle>Create Workflow</DialogTitle></DialogHeader>
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Workflow</DialogTitle>
|
||||||
|
<DialogDescription>Define a multi-step approval process.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2"><Label>Workflow Name</Label><Input placeholder="e.g. Exam Content Review" /></div>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Step 1 — Assignee</Label>
|
<Label>
|
||||||
<Select><SelectTrigger><SelectValue placeholder="Select assignee" /></SelectTrigger>
|
Workflow Name <span className="text-destructive">*</span>
|
||||||
<SelectContent><SelectItem value="smith">Dr. Smith</SelectItem><SelectItem value="lee">Prof. Lee</SelectItem><SelectItem value="kim">Mr. Kim</SelectItem></SelectContent>
|
</Label>
|
||||||
</Select>
|
<Input
|
||||||
|
value={formName}
|
||||||
|
onChange={(e) => setFormName(e.target.value)}
|
||||||
|
placeholder="e.g. Exam Content Review"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Step 2 — Assignee</Label>
|
<Label>Type</Label>
|
||||||
<Select><SelectTrigger><SelectValue placeholder="Select assignee" /></SelectTrigger>
|
<Select value={formType} onValueChange={setFormType}>
|
||||||
<SelectContent><SelectItem value="smith">Dr. Smith</SelectItem><SelectItem value="lee">Prof. Lee</SelectItem><SelectItem value="admin">Admin</SelectItem></SelectContent>
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="custom">Custom</SelectItem>
|
||||||
|
<SelectItem value="exam_publish">Exam Publication</SelectItem>
|
||||||
|
<SelectItem value="assignment_publish">Assignment Publication</SelectItem>
|
||||||
|
<SelectItem value="content_publish">Content Publication</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Button className="w-full">Create</Button>
|
|
||||||
|
{formSteps.map((step, i) => (
|
||||||
|
<div key={i} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Step {i + 1} — Approver</Label>
|
||||||
|
{formSteps.length > 1 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() =>
|
||||||
|
setFormSteps((s) => s.filter((_, idx) => idx !== i))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={step.approver_id}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setFormSteps((s) =>
|
||||||
|
s.map((st, idx) => (idx === i ? { ...st, approver_id: v } : st))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select approver" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{users.map((u) => (
|
||||||
|
<SelectItem key={u.id} value={String(u.id)}>
|
||||||
|
{u.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setFormSteps((s) => [...s, { approver_id: "" }])}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" /> Add Step
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!formName.trim() || createMut.isPending}
|
||||||
|
onClick={handleCreate}
|
||||||
|
>
|
||||||
|
{createMut.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Create"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* Loading */}
|
||||||
|
{(workflowsQ.isLoading || requestsQ.isLoading) && (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pending Requests */}
|
||||||
|
{pendingRequests.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Clock className="h-5 w-5 text-amber-500" />
|
||||||
|
Pending Approval ({pendingRequests.length})
|
||||||
|
</h2>
|
||||||
|
{pendingRequests.map((req) => {
|
||||||
|
const wf = workflows.find((w) => w.id === req.workflow_id);
|
||||||
|
return (
|
||||||
|
<Card key={req.id} className="border-amber-200 bg-amber-50/30">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{req.workflow_name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Requested by {req.requester_name} ·{" "}
|
||||||
|
{req.res_model.replace("encoach.", "")} #{req.res_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{stateBadge(req.state)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{wf && (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mb-3">
|
||||||
|
{wf.steps.map((step, i) => {
|
||||||
|
const isActive = step.id === req.current_stage_id;
|
||||||
|
return (
|
||||||
|
<div key={step.id} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 rounded-lg border p-3 min-w-[160px] transition-all ${
|
||||||
|
isActive
|
||||||
|
? "border-amber-400 bg-amber-50 ring-2 ring-amber-200"
|
||||||
|
: step.status === "approved"
|
||||||
|
? "border-green-200 bg-green-50/50"
|
||||||
|
: step.status === "rejected"
|
||||||
|
? "border-red-200 bg-red-50/50"
|
||||||
|
: "bg-muted/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{statusIcon(step.status)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Step {i + 1}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{step.approver_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{i < wf.steps.length - 1 && (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={approveMut.isPending}
|
||||||
|
onClick={() => approveMut.mutate(req.id)}
|
||||||
|
>
|
||||||
|
{approveMut.isPending ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="h-3.5 w-3.5 mr-1" />
|
||||||
|
)}
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setRejectDialog(req.id)}
|
||||||
|
>
|
||||||
|
<XCircle className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Workflow Templates */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-5 w-5" />
|
||||||
|
Workflow Templates ({workflows.length})
|
||||||
|
</h2>
|
||||||
|
{workflows.length === 0 && !workflowsQ.isLoading && (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="p-8 text-center text-muted-foreground">
|
||||||
|
No workflows defined yet. Create one to get started.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
{workflows.map((wf) => (
|
{workflows.map((wf) => (
|
||||||
<Card key={wf.id} className="border-0 shadow-sm">
|
<Card key={wf.id} className="border-0 shadow-sm">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-base font-semibold">{wf.name}</CardTitle>
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant={wf.status === "Completed" ? "default" : wf.status === "Rejected" ? "destructive" : "secondary"}>{wf.status}</Badge>
|
<CardTitle className="text-base font-semibold">{wf.name}</CardTitle>
|
||||||
|
<Badge variant="outline" className="text-xs capitalize">
|
||||||
|
{wf.type.replace(/_/g, " ")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete workflow "${wf.name}"?`)) deleteMut.mutate(wf.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{wf.steps.map((step, i) => (
|
{wf.steps.map((step, i) => (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={step.id} className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2 rounded-lg border p-3 bg-muted/30 min-w-[160px]">
|
<div className="flex items-center gap-2 rounded-lg border p-3 bg-muted/30 min-w-[160px]">
|
||||||
{statusIcon(step.status)}
|
<div className="h-6 w-6 rounded-full bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary">
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">{step.name}</p>
|
<p className="text-sm font-medium">{step.approver_name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{step.assignee}</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{step.max_days}d limit
|
||||||
|
{step.auto_escalate ? " · auto-escalate" : ""}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{i < wf.steps.length - 1 && <div className="w-8 h-0.5 bg-border" />}
|
{i < wf.steps.length - 1 && (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{wf.status === "In Progress" && (
|
|
||||||
<div className="space-y-4 mt-4">
|
|
||||||
<AiGradingAssistant onAccept={(marks, feedback) => {
|
|
||||||
toast({ title: "AI Grade Accepted", description: `Marks: ${marks}/100 applied with AI feedback.` });
|
|
||||||
}} />
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button size="sm" variant="default">Approve</Button>
|
|
||||||
<Button size="sm" variant="outline">Reject</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Completed Requests */}
|
||||||
|
{completedRequests.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5" />
|
||||||
|
Recent Decisions ({completedRequests.length})
|
||||||
|
</h2>
|
||||||
|
{completedRequests.map((req) => (
|
||||||
|
<Card key={req.id} className="border-0 shadow-sm">
|
||||||
|
<CardContent className="p-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{req.workflow_name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{req.requester_name} · {req.res_model.replace("encoach.", "")} #
|
||||||
|
{req.res_id}
|
||||||
|
{req.created_at && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
· {new Date(req.created_at).toLocaleDateString()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{stateBadge(req.state)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reject Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={rejectDialog !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setRejectDialog(null);
|
||||||
|
setRejectComment("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Reject Request</DialogTitle>
|
||||||
|
<DialogDescription>Provide a reason for rejection.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Comment</Label>
|
||||||
|
<Textarea
|
||||||
|
value={rejectComment}
|
||||||
|
onChange={(e) => setRejectComment(e.target.value)}
|
||||||
|
placeholder="Reason for rejection..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setRejectDialog(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={rejectMut.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (rejectDialog !== null) {
|
||||||
|
rejectMut.mutate({ id: rejectDialog, comment: rejectComment });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rejectMut.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
) : null}
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +1,341 @@
|
|||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Search, Plus, Trash2 } from "lucide-react";
|
import {
|
||||||
import { useAssignments, useCreateAssignment } from "@/hooks/queries";
|
Search, Plus, Trash2, Loader2, Calendar, Users, Clock,
|
||||||
|
CheckCircle2, Archive, AlertTriangle, PlayCircle, Eye
|
||||||
|
} from "lucide-react";
|
||||||
|
import { assignmentsService } from "@/services/assignments.service";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import { useBatches, useStudents } from "@/hooks/queries";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import type { ExamSchedule, ScheduleState, ExamScheduleCreateRequest } from "@/types";
|
||||||
|
|
||||||
|
interface CustomExam {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Entity {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE_TABS: { key: ScheduleState | "all"; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ key: "all", label: "All", icon: <Eye className="h-3.5 w-3.5" /> },
|
||||||
|
{ key: "active", label: "Active", icon: <PlayCircle className="h-3.5 w-3.5" /> },
|
||||||
|
{ key: "planned", label: "Planned", icon: <Clock className="h-3.5 w-3.5" /> },
|
||||||
|
{ key: "past", label: "Past", icon: <CheckCircle2 className="h-3.5 w-3.5" /> },
|
||||||
|
{ key: "start_expired", label: "Start Expired", icon: <AlertTriangle className="h-3.5 w-3.5" /> },
|
||||||
|
{ key: "archived", label: "Archived", icon: <Archive className="h-3.5 w-3.5" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const OPTION_FIELDS: { key: keyof ExamScheduleCreateRequest; label: string }[] = [
|
||||||
|
{ key: "full_length", label: "Full length exams" },
|
||||||
|
{ key: "generate_different", label: "Generate different exams" },
|
||||||
|
{ key: "auto_release_results", label: "Auto release results" },
|
||||||
|
{ key: "auto_start", label: "Auto start exam" },
|
||||||
|
{ key: "official_exam", label: "Official Exam" },
|
||||||
|
{ key: "hide_assignee_details", label: "Hide Assignees Details from Teachers" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const stateBadgeVariant = (s: ScheduleState): "default" | "secondary" | "outline" | "destructive" => {
|
||||||
|
switch (s) {
|
||||||
|
case "active": return "default";
|
||||||
|
case "planned": return "secondary";
|
||||||
|
case "past": return "outline";
|
||||||
|
case "start_expired": return "destructive";
|
||||||
|
case "archived": return "outline";
|
||||||
|
default: return "secondary";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
name: string;
|
||||||
|
exam_id: string;
|
||||||
|
entity_id: string;
|
||||||
|
start_date: string;
|
||||||
|
start_time: string;
|
||||||
|
end_date: string;
|
||||||
|
end_time: string;
|
||||||
|
assign_mode: "entity" | "batch" | "individual";
|
||||||
|
batch_ids: Set<number>;
|
||||||
|
student_ids: Set<number>;
|
||||||
|
full_length: boolean;
|
||||||
|
generate_different: boolean;
|
||||||
|
auto_release_results: boolean;
|
||||||
|
auto_start: boolean;
|
||||||
|
official_exam: boolean;
|
||||||
|
hide_assignee_details: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const _iso = (d: Date) => {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyForm = (): FormState => {
|
||||||
|
const today = new Date();
|
||||||
|
const inOneWeek = new Date();
|
||||||
|
inOneWeek.setDate(today.getDate() + 7);
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
exam_id: "",
|
||||||
|
entity_id: "",
|
||||||
|
start_date: _iso(today),
|
||||||
|
start_time: "09:00",
|
||||||
|
end_date: _iso(inOneWeek),
|
||||||
|
end_time: "17:00",
|
||||||
|
assign_mode: "batch",
|
||||||
|
batch_ids: new Set(),
|
||||||
|
student_ids: new Set(),
|
||||||
|
full_length: true,
|
||||||
|
generate_different: false,
|
||||||
|
auto_release_results: false,
|
||||||
|
auto_start: false,
|
||||||
|
official_exam: false,
|
||||||
|
hide_assignee_details: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default function AssignmentsPage() {
|
export default function AssignmentsPage() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [stateFilter, setStateFilter] = useState<ScheduleState | "all">("all");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [form, setForm] = useState({ title: "", entity_id: "", start_date: "", end_date: "" });
|
const [form, setForm] = useState<FormState>(emptyForm());
|
||||||
|
const [studentQuery, setStudentQuery] = useState("");
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const assignmentsQ = useAssignments({ size: 200 });
|
const schedulesQ = useQuery({
|
||||||
const createMut = useCreateAssignment();
|
queryKey: ["exam-schedules", stateFilter],
|
||||||
|
queryFn: () => assignmentsService.listSchedules(
|
||||||
|
stateFilter === "all" ? {} : { state: stateFilter }
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const schedules = schedulesQ.data?.items ?? [];
|
||||||
|
|
||||||
const items = assignmentsQ.data?.items ?? assignmentsQ.data?.data ?? [];
|
const customExamsQ = useQuery({
|
||||||
const assignments = Array.isArray(items) ? items : [];
|
queryKey: ["custom-exams-for-assign"],
|
||||||
|
queryFn: () => api.get<{ items: CustomExam[]; total: number }>("/exam/custom/list?per_page=200"),
|
||||||
const q = search.toLowerCase();
|
});
|
||||||
const filtered = assignments.filter(a =>
|
const publishedExams = (customExamsQ.data?.items ?? []).filter(
|
||||||
a.title?.toLowerCase().includes(q) || a.entity_name?.toLowerCase().includes(q),
|
(e) => e.status === "published" || e.status === "draft"
|
||||||
);
|
);
|
||||||
|
|
||||||
const loading = assignmentsQ.isLoading;
|
const entitiesQ = useQuery({
|
||||||
|
queryKey: ["entities-for-assign"],
|
||||||
|
queryFn: () => api.get<{ items: Entity[] }>("/entities"),
|
||||||
|
});
|
||||||
|
const entities = entitiesQ.data?.items ?? [];
|
||||||
|
|
||||||
|
const batchesQ = useBatches({ size: 200 });
|
||||||
|
const batches = batchesQ.data?.items ?? [];
|
||||||
|
|
||||||
|
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 ?? [];
|
||||||
|
const counts: Record<string, number> = { all: all.length };
|
||||||
|
for (const s of all) {
|
||||||
|
counts[s.state] = (counts[s.state] || 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [schedulesQ.data]);
|
||||||
|
|
||||||
|
const allSchedulesQ = useQuery({
|
||||||
|
queryKey: ["exam-schedules", "all"],
|
||||||
|
queryFn: () => assignmentsService.listSchedules({}),
|
||||||
|
enabled: stateFilter !== "all",
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCounts = useMemo(() => {
|
||||||
|
const items = stateFilter === "all" ? schedules : (allSchedulesQ.data?.items ?? []);
|
||||||
|
const counts: Record<string, number> = { all: items.length };
|
||||||
|
for (const s of items) {
|
||||||
|
counts[s.state] = (counts[s.state] || 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [stateFilter, schedules, allSchedulesQ.data]);
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: (data: ExamScheduleCreateRequest) => assignmentsService.createSchedule(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
setForm(emptyForm());
|
||||||
|
toast({ title: "Exam scheduled successfully" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Error", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMut = useMutation({
|
||||||
|
mutationFn: (id: number) => assignmentsService.deleteSchedule(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
|
||||||
|
toast({ title: "Schedule deleted" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Error", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const archiveMut = useMutation({
|
||||||
|
mutationFn: (id: number) => assignmentsService.archiveSchedule(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
|
||||||
|
toast({ title: "Schedule archived" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
const filtered = schedules.filter(
|
||||||
|
(s) => s.name.toLowerCase().includes(q) || s.exam_title.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
|
||||||
function handleCreate() {
|
function handleCreate() {
|
||||||
createMut.mutate(
|
const startDt = form.start_date && form.start_time ? `${form.start_date}T${form.start_time}:00` : "";
|
||||||
{ title: form.title, entity_id: Number(form.entity_id) || 0, start_date: form.start_date, end_date: form.end_date },
|
const endDt = form.end_date && form.end_time ? `${form.end_date}T${form.end_time}:00` : "";
|
||||||
{
|
|
||||||
onSuccess: () => { setCreateOpen(false); setForm({ title: "", entity_id: "", start_date: "", end_date: "" }); toast({ title: "Assignment created" }); },
|
if (!form.name || !form.exam_id || !startDt || !endDt) {
|
||||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
toast({ variant: "destructive", title: "Missing fields", description: "Please fill in name, exam, start date/time, and end date/time." });
|
||||||
},
|
return;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
createMut.mutate({
|
||||||
|
name: form.name,
|
||||||
|
exam_id: Number(form.exam_id),
|
||||||
|
entity_id: form.entity_id ? Number(form.entity_id) : undefined,
|
||||||
|
start_date: startDt,
|
||||||
|
end_date: endDt,
|
||||||
|
assign_mode: form.assign_mode,
|
||||||
|
batch_ids: [...form.batch_ids],
|
||||||
|
student_ids: [...form.student_ids],
|
||||||
|
full_length: form.full_length,
|
||||||
|
generate_different: form.generate_different,
|
||||||
|
auto_release_results: form.auto_release_results,
|
||||||
|
auto_start: form.auto_start,
|
||||||
|
official_exam: form.official_exam,
|
||||||
|
hide_assignee_details: form.hide_assignee_details,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateF = (patch: Partial<FormState>) => setForm((p) => ({ ...p, ...patch }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Assignments</h1>
|
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||||
<p className="text-muted-foreground">Create and manage assignments.</p>
|
<Calendar className="h-6 w-6 text-primary" /> Assignments
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">Schedule exams and assign them to entities, classes, or students.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<Button onClick={() => { setForm(emptyForm()); setCreateOpen(true); }}>
|
||||||
<Plus className="h-4 w-4 mr-1" /> Create Assignment
|
<Plus className="h-4 w-4 mr-1" /> Schedule Exam
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* State tabs */}
|
||||||
|
<div className="inline-flex items-center rounded-full border-2 border-primary bg-primary/5 p-1 gap-0">
|
||||||
|
{STATE_TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
className={`flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium transition-all ${
|
||||||
|
stateFilter === tab.key
|
||||||
|
? "bg-white text-primary shadow-sm"
|
||||||
|
: "text-primary/70 hover:text-primary"
|
||||||
|
}`}
|
||||||
|
onClick={() => setStateFilter(tab.key)}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
{tab.label}
|
||||||
|
<span className={`text-xs ml-0.5 ${stateFilter === tab.key ? "text-primary" : "text-primary/50"}`}>
|
||||||
|
({totalCounts[tab.key] ?? 0})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="relative max-w-sm">
|
<div className="relative max-w-sm">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input placeholder="Search assignments..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
<Input placeholder="Search schedules..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{schedulesQ.isLoading ? (
|
||||||
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
<div className="flex justify-center py-12"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="p-12 text-center text-muted-foreground">
|
||||||
|
<Calendar className="h-10 w-10 mx-auto mb-3 opacity-40" />
|
||||||
|
<p className="font-medium">No exam schedules found</p>
|
||||||
|
<p className="text-sm mt-1">Create a new schedule to assign exams to students.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Title</TableHead>
|
<TableHead>Schedule Name</TableHead>
|
||||||
|
<TableHead>Exam</TableHead>
|
||||||
<TableHead>Entity</TableHead>
|
<TableHead>Entity</TableHead>
|
||||||
<TableHead>Start</TableHead>
|
<TableHead>Start</TableHead>
|
||||||
<TableHead>End</TableHead>
|
<TableHead>End</TableHead>
|
||||||
<TableHead>State</TableHead>
|
<TableHead>State</TableHead>
|
||||||
<TableHead>Assignees</TableHead>
|
<TableHead>Assignees</TableHead>
|
||||||
<TableHead>Completed</TableHead>
|
<TableHead>Completed</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filtered.length === 0 && (
|
{filtered.map((s) => (
|
||||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No assignments found.</TableCell></TableRow>
|
<TableRow key={s.id}>
|
||||||
)}
|
<TableCell className="font-medium">{s.name}</TableCell>
|
||||||
{filtered.map((a) => (
|
<TableCell>{s.exam_title}</TableCell>
|
||||||
<TableRow key={a.id}>
|
<TableCell>{s.entity_name || "—"}</TableCell>
|
||||||
<TableCell className="font-medium">{a.title}</TableCell>
|
<TableCell className="text-xs">{s.start_date ? new Date(s.start_date).toLocaleString() : "—"}</TableCell>
|
||||||
<TableCell>{a.entity_name || "—"}</TableCell>
|
<TableCell className="text-xs">{s.end_date ? new Date(s.end_date).toLocaleString() : "—"}</TableCell>
|
||||||
<TableCell>{a.start_date || "—"}</TableCell>
|
<TableCell>
|
||||||
<TableCell>{a.end_date || "—"}</TableCell>
|
<Badge variant={stateBadgeVariant(s.state)} className="capitalize">{s.state.replace("_", " ")}</Badge>
|
||||||
<TableCell><Badge variant={a.state === "active" ? "default" : "secondary"} className="capitalize">{a.state}</Badge></TableCell>
|
</TableCell>
|
||||||
<TableCell>{a.assignee_count ?? 0}</TableCell>
|
<TableCell>
|
||||||
<TableCell>{a.completed_count ?? 0}</TableCell>
|
<div className="flex items-center gap-1"><Users className="h-3.5 w-3.5 text-muted-foreground" />{s.assignee_count}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{s.completed_count}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{s.state !== "archived" && (
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" title="Archive"
|
||||||
|
onClick={() => archiveMut.mutate(s.id)}>
|
||||||
|
<Archive className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" title="Delete"
|
||||||
|
onClick={() => { if (confirm(`Delete schedule "${s.name}"?`)) deleteMut.mutate(s.id); }}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -94,21 +344,188 @@ export default function AssignmentsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Create Schedule Dialog */}
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<DialogContent>
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader><DialogTitle>Create Assignment</DialogTitle></DialogHeader>
|
<DialogHeader>
|
||||||
<div className="space-y-4">
|
<DialogTitle>Schedule Exam</DialogTitle>
|
||||||
<div className="space-y-2"><Label>Title</Label><Input value={form.title} onChange={(e) => setForm(f => ({ ...f, title: e.target.value }))} placeholder="e.g. IELTS Prep Q2" /></div>
|
<DialogDescription>Select an exam, assign it to students, and set scheduling options.</DialogDescription>
|
||||||
<div className="space-y-2"><Label>Entity ID</Label><Input type="number" value={form.entity_id} onChange={(e) => setForm(f => ({ ...f, entity_id: e.target.value }))} /></div>
|
</DialogHeader>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-2"><Label>Start Date</Label><Input type="date" value={form.start_date} onChange={(e) => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
|
<div className="space-y-5 py-2">
|
||||||
<div className="space-y-2"><Label>End Date</Label><Input type="date" value={form.end_date} onChange={(e) => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
|
{/* Basic info */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Schedule Name <span className="text-destructive">*</span></Label>
|
||||||
|
<Input value={form.name} onChange={(e) => updateF({ name: e.target.value })} placeholder="e.g. Q2 IELTS Exam" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Select Exam <span className="text-destructive">*</span></Label>
|
||||||
|
<Select value={form.exam_id} onValueChange={(v) => updateF({ exam_id: v })}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Choose exam..." /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{publishedExams.map((e) => (
|
||||||
|
<SelectItem key={e.id} value={String(e.id)}>
|
||||||
|
{e.title} <span className="text-xs text-muted-foreground ml-1">({e.status})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{publishedExams.length === 0 && (
|
||||||
|
<SelectItem value="_none" disabled>No exams available</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date/Time */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">Schedule Date & Time</Label>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Start Date <span className="text-destructive">*</span></Label>
|
||||||
|
<Input type="date" value={form.start_date} onChange={(e) => updateF({ start_date: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Start Time</Label>
|
||||||
|
<Input type="time" value={form.start_time} onChange={(e) => updateF({ start_time: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">End Date <span className="text-destructive">*</span></Label>
|
||||||
|
<Input type="date" value={form.end_date} onChange={(e) => updateF({ end_date: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">End Time</Label>
|
||||||
|
<Input type="time" value={form.end_time} onChange={(e) => updateF({ end_time: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options checkboxes */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">Exam Options</Label>
|
||||||
|
<div className="flex flex-wrap gap-x-6 gap-y-2">
|
||||||
|
{OPTION_FIELDS.map((opt) => (
|
||||||
|
<label key={opt.key} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={form[opt.key] as boolean}
|
||||||
|
onCheckedChange={(checked) => updateF({ [opt.key]: !!checked } as Partial<FormState>)}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
{opt.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assignment target */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-semibold">Assign To</Label>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Assignment Mode</Label>
|
||||||
|
<Select value={form.assign_mode} onValueChange={(v) => updateF({ assign_mode: v as FormState["assign_mode"] })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="entity">Entire Entity</SelectItem>
|
||||||
|
<SelectItem value="batch">Class / Batch</SelectItem>
|
||||||
|
<SelectItem value="individual">Individual Students</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Entity</Label>
|
||||||
|
<Select value={form.entity_id} onValueChange={(v) => updateF({ entity_id: v })}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select entity..." /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{entities.map((e) => (
|
||||||
|
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
{entities.length === 0 && (
|
||||||
|
<SelectItem value="_none" disabled>No entities</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Batch selection */}
|
||||||
|
{form.assign_mode === "batch" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Select Classes</Label>
|
||||||
|
<div className="max-h-36 overflow-y-auto border rounded-md p-2 space-y-1">
|
||||||
|
{batches.map((b) => (
|
||||||
|
<label key={b.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
||||||
|
<Checkbox
|
||||||
|
checked={form.batch_ids.has(b.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setForm((p) => {
|
||||||
|
const next = new Set(p.batch_ids);
|
||||||
|
checked ? next.add(b.id) : next.delete(b.id);
|
||||||
|
return { ...p, batch_ids: next };
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
{b.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{batches.length === 0 && <p className="text-xs text-muted-foreground italic text-center py-2">No classes available</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Individual student selection */}
|
||||||
|
{form.assign_mode === "individual" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<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)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setForm((p) => {
|
||||||
|
const next = new Set(p.student_ids);
|
||||||
|
checked ? next.add(s.id) : next.delete(s.id);
|
||||||
|
return { ...p, student_ids: next };
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
{s.name}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||||
<Button disabled={createMut.isPending || !form.title} onClick={handleCreate}>
|
<Button disabled={createMut.isPending || !form.name || !form.exam_id || !form.start_date || !form.end_date} onClick={handleCreate}>
|
||||||
{createMut.isPending ? "Creating..." : "Create"}
|
{createMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Schedule Exam"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Search, Plus, Trash2, Users } from "lucide-react";
|
import { Search, Plus, Trash2, Users, UserPlus, UserMinus, Pencil } from "lucide-react";
|
||||||
import { useBatches, useCourses } from "@/hooks/queries";
|
import { useBatches, useCourses, useStudents } from "@/hooks/queries";
|
||||||
import { lmsService } from "@/services/lms.service";
|
import { lmsService } from "@/services/lms.service";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
@@ -17,49 +18,124 @@ export default function ClassroomsPage() {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [form, setForm] = useState({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" });
|
const [form, setForm] = useState({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" });
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [editBatch, setEditBatch] = useState<{ id: number; name: string; course_id: string; start_date: string; end_date: string } | null>(null);
|
||||||
|
const [studentsOpen, setStudentsOpen] = useState(false);
|
||||||
|
const [studentsBatchId, setStudentsBatchId] = useState<number | null>(null);
|
||||||
|
const [addStudentsOpen, setAddStudentsOpen] = useState(false);
|
||||||
|
const [selectedAddIds, setSelectedAddIds] = useState<number[]>([]);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const batchesQ = useBatches({ size: 200 });
|
const batchesQ = useBatches({ size: 200 });
|
||||||
const coursesQ = useCourses({ size: 200 });
|
const coursesQ = useCourses({ size: 200 });
|
||||||
|
const studentsQ = useStudents({ size: 200 });
|
||||||
const batches = batchesQ.data?.items ?? [];
|
const batches = batchesQ.data?.items ?? [];
|
||||||
const courses = coursesQ.data?.items ?? [];
|
const courses = coursesQ.data?.items ?? [];
|
||||||
|
const allStudents = studentsQ.data?.items ?? [];
|
||||||
|
|
||||||
const createMut = useMutation({
|
const createMut = useMutation({
|
||||||
mutationFn: (data: Parameters<typeof lmsService.createBatch>[0]) => lmsService.createBatch(data),
|
mutationFn: (data: Parameters<typeof lmsService.createBatch>[0]) => lmsService.createBatch(data),
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "batches"] }); setCreateOpen(false); setForm({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" }); toast({ title: "Classroom created" }); },
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
setForm({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" });
|
||||||
|
toast({ title: "Classroom created" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMut = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) => lmsService.updateBatch(id, data as never),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
||||||
|
setEditOpen(false);
|
||||||
|
toast({ title: "Classroom updated" });
|
||||||
|
},
|
||||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMut = useMutation({
|
const deleteMut = useMutation({
|
||||||
mutationFn: (id: number) => lmsService.deleteBatch(id),
|
mutationFn: (id: number) => lmsService.deleteBatch(id),
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "batches"] }); toast({ title: "Deleted" }); },
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
||||||
|
toast({ title: "Deleted" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addStudentsMut = useMutation({
|
||||||
|
mutationFn: ({ batchId, studentIds }: { batchId: number; studentIds: number[] }) =>
|
||||||
|
lmsService.addStudentsToBatch(batchId, studentIds),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
||||||
|
setAddStudentsOpen(false);
|
||||||
|
setSelectedAddIds([]);
|
||||||
|
toast({ title: `Added ${res.total} student(s)` });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeStudentMut = useMutation({
|
||||||
|
mutationFn: ({ batchId, studentIds }: { batchId: number; studentIds: number[] }) =>
|
||||||
|
lmsService.removeStudentsFromBatch(batchId, studentIds),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
||||||
|
toast({ title: "Student removed from classroom" });
|
||||||
|
},
|
||||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
const filtered = batches.filter(b => b.name.toLowerCase().includes(q) || b.course_name?.toLowerCase().includes(q));
|
const filtered = batches.filter(
|
||||||
|
(b) => b.name.toLowerCase().includes(q) || b.course_name?.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
const loading = batchesQ.isLoading;
|
const loading = batchesQ.isLoading;
|
||||||
|
|
||||||
|
const currentBatch = batches.find((b) => b.id === studentsBatchId);
|
||||||
|
const batchStudentIds = new Set(currentBatch?.student_ids ?? []);
|
||||||
|
const availableStudents = allStudents.filter((s) => !batchStudentIds.has(s.id));
|
||||||
|
|
||||||
|
function openStudents(batchId: number) {
|
||||||
|
setStudentsBatchId(batchId);
|
||||||
|
setStudentsOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(b: typeof batches[0]) {
|
||||||
|
setEditBatch({
|
||||||
|
id: b.id,
|
||||||
|
name: b.name,
|
||||||
|
course_id: String(b.course_id || ""),
|
||||||
|
start_date: b.start_date,
|
||||||
|
end_date: b.end_date,
|
||||||
|
});
|
||||||
|
setEditOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Classrooms</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Classrooms</h1>
|
||||||
<p className="text-muted-foreground">Create and manage batches (classrooms).</p>
|
<p className="text-muted-foreground">Create and manage classrooms with students.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
<Plus className="h-4 w-4 mr-1" /> Create Classroom
|
<Plus className="h-4 w-4 mr-1" /> Create Classroom
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative max-w-sm">
|
<div className="flex gap-3 items-center">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<div className="relative flex-1 max-w-sm">
|
||||||
<Input placeholder="Search classrooms..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input placeholder="Search classrooms..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">{batches.length} total</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
<div className="flex items-center justify-center min-h-[300px]">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
@@ -69,29 +145,67 @@ export default function ClassroomsPage() {
|
|||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Course</TableHead>
|
<TableHead>Course</TableHead>
|
||||||
<TableHead>Students</TableHead>
|
<TableHead>Students</TableHead>
|
||||||
<TableHead>Capacity</TableHead>
|
|
||||||
<TableHead>Dates</TableHead>
|
<TableHead>Dates</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead className="w-10" />
|
<TableHead className="w-[140px]" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No classrooms found.</TableCell></TableRow>
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||||
|
No classrooms found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{filtered.map((b) => (
|
{filtered.map((b) => (
|
||||||
<TableRow key={b.id}>
|
<TableRow key={b.id}>
|
||||||
<TableCell className="font-medium">{b.name}</TableCell>
|
<TableCell className="font-medium">{b.name}</TableCell>
|
||||||
<TableCell>{b.course_name}</TableCell>
|
<TableCell>{b.course_name || "—"}</TableCell>
|
||||||
<TableCell><Badge variant="secondary"><Users className="h-3 w-3 mr-1" />{b.student_count}</Badge></TableCell>
|
|
||||||
<TableCell>{b.capacity}</TableCell>
|
|
||||||
<TableCell className="text-sm">{b.start_date} — {b.end_date}</TableCell>
|
|
||||||
<TableCell><Badge variant={b.status === "active" ? "default" : "secondary"} className="capitalize">{b.status}</Badge></TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => { if (window.confirm(`Delete "${b.name}"?`)) deleteMut.mutate(b.id); }}>
|
<Button
|
||||||
<Trash2 className="h-4 w-4" />
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5 h-7 px-2"
|
||||||
|
onClick={() => openStudents(b.id)}
|
||||||
|
>
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
{b.student_count} students
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{b.start_date && b.end_date
|
||||||
|
? `${b.start_date} — ${b.end_date}`
|
||||||
|
: "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={b.status === "active" ? "default" : "secondary"}
|
||||||
|
className="capitalize"
|
||||||
|
>
|
||||||
|
{b.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" title="Manage students" onClick={() => openStudents(b.id)}>
|
||||||
|
<UserPlus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" title="Edit" onClick={() => openEdit(b)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Delete "${b.name}"?`)) deleteMut.mutate(b.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -100,34 +214,234 @@ export default function ClassroomsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Create Dialog */}
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader><DialogTitle>Create Classroom</DialogTitle></DialogHeader>
|
<DialogHeader><DialogTitle>Create Classroom</DialogTitle></DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2"><Label>Name</Label><Input value={form.name} onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))} placeholder="e.g. IELTS Prep Group A" /></div>
|
<div className="space-y-2">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. IELTS Prep Group A" />
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Course</Label>
|
<Label>Course</Label>
|
||||||
<Select value={form.course_id} onValueChange={(v) => setForm(f => ({ ...f, course_id: v }))}>
|
<Select value={form.course_id} onValueChange={(v) => setForm((f) => ({ ...f, course_id: v }))}>
|
||||||
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
|
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{courses.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.title || c.code}</SelectItem>)}
|
{courses.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={String(c.id)}>{c.title || c.code}</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-2"><Label>Start Date</Label><Input type="date" value={form.start_date} onChange={(e) => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
|
<div className="space-y-2">
|
||||||
<div className="space-y-2"><Label>End Date</Label><Input type="date" value={form.end_date} onChange={(e) => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
|
<Label>Start Date</Label>
|
||||||
|
<Input type="date" value={form.start_date} onChange={(e) => setForm((f) => ({ ...f, start_date: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>End Date</Label>
|
||||||
|
<Input type="date" value={form.end_date} onChange={(e) => setForm((f) => ({ ...f, end_date: e.target.value }))} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2"><Label>Max Students</Label><Input type="number" value={form.max_students} onChange={(e) => setForm(f => ({ ...f, max_students: e.target.value }))} placeholder="30" /></div>
|
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||||
<Button disabled={createMut.isPending || !form.name} onClick={() => createMut.mutate({ name: form.name, course_id: Number(form.course_id) || undefined, start_date: form.start_date || undefined, end_date: form.end_date || undefined, capacity: Number(form.max_students) || undefined } as never)}>
|
<Button
|
||||||
|
disabled={createMut.isPending || !form.name}
|
||||||
|
onClick={() =>
|
||||||
|
createMut.mutate({
|
||||||
|
name: form.name,
|
||||||
|
course_id: Number(form.course_id) || undefined,
|
||||||
|
start_date: form.start_date || undefined,
|
||||||
|
end_date: form.end_date || undefined,
|
||||||
|
} as never)
|
||||||
|
}
|
||||||
|
>
|
||||||
{createMut.isPending ? "Creating..." : "Create"}
|
{createMut.isPending ? "Creating..." : "Create"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit Dialog */}
|
||||||
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Edit Classroom</DialogTitle></DialogHeader>
|
||||||
|
{editBatch && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input
|
||||||
|
value={editBatch.name}
|
||||||
|
onChange={(e) => setEditBatch((prev) => prev ? { ...prev, name: e.target.value } : prev)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Course</Label>
|
||||||
|
<Select
|
||||||
|
value={editBatch.course_id}
|
||||||
|
onValueChange={(v) => setEditBatch((prev) => prev ? { ...prev, course_id: v } : prev)}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{courses.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={String(c.id)}>{c.title || c.code}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Start Date</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={editBatch.start_date}
|
||||||
|
onChange={(e) => setEditBatch((prev) => prev ? { ...prev, start_date: e.target.value } : prev)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>End Date</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={editBatch.end_date}
|
||||||
|
onChange={(e) => setEditBatch((prev) => prev ? { ...prev, end_date: e.target.value } : prev)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
disabled={updateMut.isPending || !editBatch?.name}
|
||||||
|
onClick={() => {
|
||||||
|
if (!editBatch) return;
|
||||||
|
updateMut.mutate({
|
||||||
|
id: editBatch.id,
|
||||||
|
data: {
|
||||||
|
name: editBatch.name,
|
||||||
|
course_id: Number(editBatch.course_id) || undefined,
|
||||||
|
start_date: editBatch.start_date || undefined,
|
||||||
|
end_date: editBatch.end_date || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{updateMut.isPending ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Students Management Dialog */}
|
||||||
|
<Dialog open={studentsOpen} onOpenChange={setStudentsOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Classroom Students — {currentBatch?.name}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{currentBatch?.course_name && `Course: ${currentBatch.course_name}`}
|
||||||
|
{currentBatch ? ` · ${currentBatch.student_count} student(s)` : ""}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="max-h-[320px] overflow-y-auto space-y-1">
|
||||||
|
{currentBatch?.student_ids?.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-6">
|
||||||
|
No students in this classroom yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
allStudents
|
||||||
|
.filter((s) => batchStudentIds.has(s.id))
|
||||||
|
.map((s) => (
|
||||||
|
<div key={s.id} className="flex items-center justify-between p-2 rounded hover:bg-muted/50">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{s.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{s.email}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (studentsBatchId && window.confirm(`Remove ${s.name} from this classroom?`))
|
||||||
|
removeStudentMut.mutate({ batchId: studentsBatchId, studentIds: [s.id] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserMinus className="h-3.5 w-3.5 mr-1" /> Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setStudentsOpen(false)}>Close</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedAddIds([]);
|
||||||
|
setAddStudentsOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserPlus className="h-4 w-4 mr-1" /> Add Students
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Add Students to Batch Dialog */}
|
||||||
|
<Dialog open={addStudentsOpen} onOpenChange={setAddStudentsOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Students to {currentBatch?.name}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Select students to add to this classroom.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="max-h-[320px] overflow-y-auto space-y-1">
|
||||||
|
{availableStudents.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
All students are already in this classroom.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
availableStudents.map((s) => (
|
||||||
|
<label key={s.id} className="flex items-center gap-3 p-2 rounded hover:bg-muted/50 cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedAddIds.includes(s.id)}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setSelectedAddIds((prev) =>
|
||||||
|
prev.includes(s.id) ? prev.filter((x) => x !== s.id) : [...prev, s.id],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{s.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{s.email}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setAddStudentsOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
disabled={addStudentsMut.isPending || selectedAddIds.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
if (studentsBatchId)
|
||||||
|
addStudentsMut.mutate({ batchId: studentsBatchId, studentIds: selectedAddIds });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{addStudentsMut.isPending
|
||||||
|
? "Adding..."
|
||||||
|
: `Add ${selectedAddIds.length} Student(s)`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,91 +5,280 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Search, Plus, Building2, Trash2 } from "lucide-react";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { useDepartments, useCreateDepartment, useDeleteDepartment } from "@/hooks/queries";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Search, Plus, Building2, Trash2, Pencil, Users, Shield } from "lucide-react";
|
||||||
|
import { entitiesService } from "@/services/entities.service";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
const ENTITY_TYPES = [
|
||||||
|
{ value: "corporate", label: "Corporate" },
|
||||||
|
{ value: "university", label: "University" },
|
||||||
|
{ value: "school", label: "School" },
|
||||||
|
{ value: "government", label: "Government" },
|
||||||
|
{ value: "freelance", label: "Freelance" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function EntitiesPage() {
|
export default function EntitiesPage() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [form, setForm] = useState({ name: "", code: "" });
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [editEntity, setEditEntity] = useState<{ id: number; name: string; code: string; type: string } | null>(null);
|
||||||
|
const [form, setForm] = useState({ name: "", code: "", type: "" });
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const deptsQ = useDepartments({ size: 200 });
|
const entitiesQ = useQuery({
|
||||||
const createMut = useCreateDepartment();
|
queryKey: ["entities"],
|
||||||
const deleteMut = useDeleteDepartment();
|
queryFn: () => entitiesService.list({ size: 200 }),
|
||||||
|
});
|
||||||
|
|
||||||
const depts = deptsQ.data?.items ?? deptsQ.data?.data ?? [];
|
const createMut = useMutation({
|
||||||
const departments = Array.isArray(depts) ? depts : [];
|
mutationFn: (data: { name: string; code?: string; type?: string }) =>
|
||||||
const filtered = departments.filter(d => d.name?.toLowerCase().includes(search.toLowerCase()));
|
entitiesService.create(data as Partial<import("@/types").Entity>),
|
||||||
const loading = deptsQ.isLoading;
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["entities"] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
setForm({ name: "", code: "", type: "" });
|
||||||
|
toast({ title: "Entity created" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
function handleCreate() {
|
const updateMut = useMutation({
|
||||||
createMut.mutate(
|
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) =>
|
||||||
{ name: form.name, code: form.code || undefined },
|
entitiesService.update(id, data as Partial<import("@/types").Entity>),
|
||||||
{
|
onSuccess: () => {
|
||||||
onSuccess: () => { setCreateOpen(false); setForm({ name: "", code: "" }); toast({ title: "Department created" }); },
|
qc.invalidateQueries({ queryKey: ["entities"] });
|
||||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
setEditOpen(false);
|
||||||
},
|
toast({ title: "Entity updated" });
|
||||||
);
|
},
|
||||||
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMut = useMutation({
|
||||||
|
mutationFn: (id: number) => entitiesService.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["entities"] });
|
||||||
|
toast({ title: "Deleted" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const raw = entitiesQ.data as unknown;
|
||||||
|
const entities: { id: number; name: string; code: string; type: string; user_count: number; role_count: number; active: boolean }[] = (() => {
|
||||||
|
if (!raw) return [];
|
||||||
|
if (Array.isArray(raw)) return raw as never[];
|
||||||
|
const r = raw as { items?: unknown; data?: unknown };
|
||||||
|
const arr = (r.items ?? r.data ?? []) as never[];
|
||||||
|
return Array.isArray(arr) ? arr : [];
|
||||||
|
})();
|
||||||
|
|
||||||
|
const filtered = entities.filter(
|
||||||
|
(e) =>
|
||||||
|
e.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
e.code?.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
);
|
||||||
|
const loading = entitiesQ.isLoading;
|
||||||
|
|
||||||
|
function openEdit(e: typeof entities[0]) {
|
||||||
|
setEditEntity({ id: e.id, name: e.name, code: e.code, type: e.type });
|
||||||
|
setEditOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Entities / Departments</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Entities</h1>
|
||||||
<p className="text-muted-foreground">Manage organizational departments.</p>
|
<p className="text-muted-foreground">Manage organizations and entities on the platform.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
<Plus className="h-4 w-4 mr-1" /> Create Department
|
<Plus className="h-4 w-4 mr-1" /> Create Entity
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative max-w-sm">
|
<div className="flex gap-4 items-center">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<div className="relative flex-1 max-w-sm">
|
||||||
<Input placeholder="Search departments..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input placeholder="Search entities..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">{entities.length} total</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
<div className="flex items-center justify-center min-h-[300px]">
|
||||||
) : (
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{filtered.length === 0 && <p className="col-span-full text-center text-muted-foreground py-8">No departments found.</p>}
|
|
||||||
{filtered.map((d) => (
|
|
||||||
<Card key={d.id} className="border-0 shadow-sm">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
|
||||||
<Building2 className="h-4 w-4 text-primary" />
|
|
||||||
{d.name}
|
|
||||||
</CardTitle>
|
|
||||||
<Button size="sm" variant="ghost" className="text-destructive" onClick={() => { if (window.confirm(`Delete "${d.name}"?`)) deleteMut.mutate(d.id, { onSuccess: () => toast({ title: "Deleted" }), onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }) }); }}>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">{d.code || "No code"}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Code</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Users</TableHead>
|
||||||
|
<TableHead>Roles</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="w-24" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
|
||||||
|
No entities found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{filtered.map((e) => (
|
||||||
|
<TableRow key={e.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="h-4 w-4 text-primary" />
|
||||||
|
{e.name}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell><code className="text-xs bg-muted px-1.5 py-0.5 rounded">{e.code}</code></TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="capitalize">{e.type || "—"}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary"><Users className="h-3 w-3 mr-1" />{e.user_count ?? 0}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary"><Shield className="h-3 w-3 mr-1" />{e.role_count ?? 0}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={e.active !== false ? "default" : "secondary"}>
|
||||||
|
{e.active !== false ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(e)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Delete entity "${e.name}"?`))
|
||||||
|
deleteMut.mutate(e.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Create Dialog */}
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader><DialogTitle>Create Department</DialogTitle></DialogHeader>
|
<DialogHeader><DialogTitle>Create Entity</DialogTitle></DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2"><Label>Name</Label><Input value={form.name} onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))} placeholder="Department name" /></div>
|
<div className="space-y-2">
|
||||||
<div className="space-y-2"><Label>Code</Label><Input value={form.code} onChange={(e) => setForm(f => ({ ...f, code: e.target.value }))} placeholder="DEPT01" /></div>
|
<Label>Name</Label>
|
||||||
|
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Organization name" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Code</Label>
|
||||||
|
<Input value={form.code} onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))} placeholder="ORG_CODE (auto-generated if empty)" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Select value={form.type} onValueChange={(v) => setForm((f) => ({ ...f, type: v }))}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ENTITY_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||||
<Button disabled={createMut.isPending || !form.name} onClick={handleCreate}>
|
<Button
|
||||||
|
disabled={createMut.isPending || !form.name.trim()}
|
||||||
|
onClick={() =>
|
||||||
|
createMut.mutate({
|
||||||
|
name: form.name.trim(),
|
||||||
|
code: form.code.trim() || undefined,
|
||||||
|
type: form.type || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
{createMut.isPending ? "Creating..." : "Create"}
|
{createMut.isPending ? "Creating..." : "Create"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit Dialog */}
|
||||||
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Edit Entity</DialogTitle></DialogHeader>
|
||||||
|
{editEntity && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input
|
||||||
|
value={editEntity.name}
|
||||||
|
onChange={(e) => setEditEntity((prev) => prev ? { ...prev, name: e.target.value } : prev)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Code</Label>
|
||||||
|
<Input
|
||||||
|
value={editEntity.code}
|
||||||
|
onChange={(e) => setEditEntity((prev) => prev ? { ...prev, code: e.target.value } : prev)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Select
|
||||||
|
value={editEntity.type}
|
||||||
|
onValueChange={(v) => setEditEntity((prev) => prev ? { ...prev, type: v } : prev)}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ENTITY_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
disabled={updateMut.isPending || !editEntity?.name.trim()}
|
||||||
|
onClick={() => {
|
||||||
|
if (!editEntity) return;
|
||||||
|
updateMut.mutate({
|
||||||
|
id: editEntity.id,
|
||||||
|
data: { name: editEntity.name, code: editEntity.code, type: editEntity.type },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{updateMut.isPending ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { GraduationCap, Play, Sparkles } from "lucide-react";
|
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
|
||||||
|
|
||||||
export default function ExamPage() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center min-h-[70vh] gap-4 max-w-md mx-auto">
|
|
||||||
<AiTipBanner tip="Based on your practice history, focus on Reading Part 3 (sentence completion) — your accuracy there is 58% vs 82% average. Budget 20 min for the writing section." 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,50 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Search } from "lucide-react";
|
import { Search, Plus, FileText, Clock } from "lucide-react";
|
||||||
import { useInstitutionalExamSessions } from "@/hooks/queries";
|
import { useInstitutionalExamSessions } from "@/hooks/queries";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
interface CustomExam {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
total_time_min: number;
|
||||||
|
sections: { id: number; title: string; skill: string }[];
|
||||||
|
teacher_id: number | null;
|
||||||
|
template_id: number | null;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
|
||||||
|
published: "default",
|
||||||
|
draft: "secondary",
|
||||||
|
archived: "outline",
|
||||||
|
};
|
||||||
|
|
||||||
export default function ExamsListPage() {
|
export default function ExamsListPage() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const sessionsQ = useInstitutionalExamSessions();
|
const [tab, setTab] = useState<"custom" | "sessions">("custom");
|
||||||
const items = sessionsQ.data?.data ?? sessionsQ.data?.items ?? [];
|
|
||||||
const sessions = Array.isArray(items) ? items : [];
|
|
||||||
|
|
||||||
|
const customQ = useQuery({
|
||||||
|
queryKey: ["custom-exams", search],
|
||||||
|
queryFn: () => api.get<{ items: CustomExam[]; total: number }>(`/exam/custom/list?search=${encodeURIComponent(search)}&per_page=50`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionsQ = useInstitutionalExamSessions();
|
||||||
|
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 q = search.toLowerCase();
|
||||||
const filtered = sessions.filter(s =>
|
const filteredSessions = sessions.filter((s) =>
|
||||||
s.name?.toLowerCase().includes(q) || s.course_name?.toLowerCase().includes(q),
|
s.name?.toLowerCase().includes(q) || s.course_name?.toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -23,8 +53,20 @@ export default function ExamsListPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Exams List</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Exams List</h1>
|
||||||
<p className="text-muted-foreground">Browse and manage all exam sessions.</p>
|
<p className="text-muted-foreground">Browse and manage all exams and exam sessions.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Link to="/admin/exam/create">
|
||||||
|
<Button><Plus className="h-4 w-4 mr-2" /> Create Exam</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant={tab === "custom" ? "default" : "outline"} size="sm" onClick={() => setTab("custom")}>
|
||||||
|
<FileText className="h-4 w-4 mr-1" /> Custom Exams ({customExams.length})
|
||||||
|
</Button>
|
||||||
|
<Button variant={tab === "sessions" ? "default" : "outline"} size="sm" onClick={() => setTab("sessions")}>
|
||||||
|
<Clock className="h-4 w-4 mr-1" /> Exam Sessions ({filteredSessions.length})
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative max-w-sm">
|
<div className="relative max-w-sm">
|
||||||
@@ -32,44 +74,92 @@ export default function ExamsListPage() {
|
|||||||
<Input placeholder="Search exams..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
<Input placeholder="Search exams..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sessionsQ.isLoading ? (
|
{tab === "custom" && (
|
||||||
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
customQ.isLoading ? (
|
||||||
) : (
|
<div className="flex items-center justify-center min-h-[200px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
||||||
<Card className="border-0 shadow-sm">
|
) : (
|
||||||
<CardContent className="p-0">
|
<Card className="border-0 shadow-sm">
|
||||||
<Table>
|
<CardContent className="p-0">
|
||||||
<TableHeader>
|
<Table>
|
||||||
<TableRow>
|
<TableHeader>
|
||||||
<TableHead>#</TableHead>
|
<TableRow>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead className="w-12">#</TableHead>
|
||||||
<TableHead>Course</TableHead>
|
<TableHead>Title</TableHead>
|
||||||
<TableHead>Batch</TableHead>
|
<TableHead>Modules</TableHead>
|
||||||
<TableHead>Start</TableHead>
|
<TableHead>Duration</TableHead>
|
||||||
<TableHead>End</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead>State</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No exam sessions found.</TableCell></TableRow>
|
|
||||||
)}
|
|
||||||
{filtered.map((s, i) => (
|
|
||||||
<TableRow key={s.id}>
|
|
||||||
<TableCell>{i + 1}</TableCell>
|
|
||||||
<TableCell className="font-medium">{s.name}</TableCell>
|
|
||||||
<TableCell>{s.course_name || "—"}</TableCell>
|
|
||||||
<TableCell>{s.batch_name || "—"}</TableCell>
|
|
||||||
<TableCell>{s.start_date || "—"}</TableCell>
|
|
||||||
<TableCell>{s.end_date || "—"}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={s.state === "done" ? "default" : s.state === "schedule" ? "secondary" : "outline"} className="capitalize">{s.state}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{customExams.length === 0 && (
|
||||||
</CardContent>
|
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">No custom exams found. Submit one from the Generation page.</TableCell></TableRow>
|
||||||
</Card>
|
)}
|
||||||
|
{customExams.map((exam, i) => (
|
||||||
|
<TableRow key={exam.id}>
|
||||||
|
<TableCell className="text-muted-foreground">{exam.id}</TableCell>
|
||||||
|
<TableCell className="font-medium">{exam.title}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{exam.sections.length > 0
|
||||||
|
? exam.sections.map((s) => (
|
||||||
|
<Badge key={s.id} variant="outline" className="text-xs capitalize">{s.skill || s.title}</Badge>
|
||||||
|
))
|
||||||
|
: <span className="text-muted-foreground text-xs">—</span>}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{exam.total_time_min ? `${exam.total_time_min} min` : "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusColors[exam.status] ?? "outline"} className="capitalize">{exam.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "sessions" && (
|
||||||
|
sessionsQ.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center min-h-[200px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
||||||
|
) : (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>#</TableHead>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Course</TableHead>
|
||||||
|
<TableHead>Batch</TableHead>
|
||||||
|
<TableHead>Start</TableHead>
|
||||||
|
<TableHead>End</TableHead>
|
||||||
|
<TableHead>State</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredSessions.length === 0 && (
|
||||||
|
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No exam sessions found.</TableCell></TableRow>
|
||||||
|
)}
|
||||||
|
{filteredSessions.map((s, i) => (
|
||||||
|
<TableRow key={s.id}>
|
||||||
|
<TableCell>{i + 1}</TableCell>
|
||||||
|
<TableCell className="font-medium">{s.name}</TableCell>
|
||||||
|
<TableCell>{s.course_name || "—"}</TableCell>
|
||||||
|
<TableCell>{s.batch_name || "—"}</TableCell>
|
||||||
|
<TableCell>{s.start_date || "—"}</TableCell>
|
||||||
|
<TableCell>{s.end_date || "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={s.state === "done" ? "default" : s.state === "schedule" ? "secondary" : "outline"} className="capitalize">{s.state}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function ForgotPassword() {
|
|||||||
) : null}
|
) : null}
|
||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
<Link to="/login" className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
|
<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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,34 +1,122 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { CheckCircle, PenTool, Sparkles } from "lucide-react";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
Sparkles,
|
||||||
|
Circle,
|
||||||
|
} from "lucide-react";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { trainingService, type CefrLevel } from "@/services/training.service";
|
||||||
|
|
||||||
const grammarItems = [
|
const LEVELS: (CefrLevel | "all")[] = ["all", "A1", "A2", "B1", "B2", "C1", "C2"];
|
||||||
{ rule: "Conditional Sentences (Type 2 & 3)", description: "If + past simple / past perfect for hypothetical situations", level: "B2", completed: true },
|
|
||||||
{ rule: "Passive Voice in Academic Writing", description: "Using passive constructions in formal reports", level: "B2", completed: false },
|
|
||||||
{ rule: "Relative Clauses", description: "Defining vs non-defining relative clauses", level: "B1", completed: true },
|
|
||||||
{ rule: "Subject-Verb Agreement", description: "Complex subjects with prepositional phrases", level: "B1", completed: false },
|
|
||||||
{ rule: "Modal Verbs for Speculation", description: "Must have, could have, might have + past participle", level: "C1", completed: false },
|
|
||||||
{ rule: "Reported Speech", description: "Tense changes and time references in indirect speech", level: "B2", completed: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function GrammarPage() {
|
export default function GrammarPage() {
|
||||||
const [showCompleted, setShowCompleted] = useState(false);
|
const { toast } = useToast();
|
||||||
const completed = grammarItems.filter(g => g.completed).length;
|
const qc = useQueryClient();
|
||||||
const filtered = showCompleted ? grammarItems : grammarItems.filter(g => !g.completed);
|
const [showCompleted, setShowCompleted] = useState(true);
|
||||||
|
const [levelFilter, setLevelFilter] = useState<string>("all");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
example: "",
|
||||||
|
level: "B1" as CefrLevel,
|
||||||
|
category: "general",
|
||||||
|
});
|
||||||
|
|
||||||
|
const listQ = useQuery({
|
||||||
|
queryKey: ["training-grammar", levelFilter, search],
|
||||||
|
queryFn: () =>
|
||||||
|
trainingService.listGrammar({
|
||||||
|
...(levelFilter !== "all" ? { level: levelFilter } : {}),
|
||||||
|
...(search ? { search } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = listQ.data?.items ?? [];
|
||||||
|
const summary = listQ.data?.summary;
|
||||||
|
const filtered = showCompleted ? items : items.filter((g) => !g.completed);
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: trainingService.createGrammar,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["training-grammar"] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
setForm({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
example: "",
|
||||||
|
level: "B1",
|
||||||
|
category: "general",
|
||||||
|
});
|
||||||
|
toast({ title: "Rule added" });
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const msg =
|
||||||
|
err && typeof err === "object" && "message" in err
|
||||||
|
? String((err as { message: unknown }).message)
|
||||||
|
: "Failed to add rule";
|
||||||
|
toast({ title: msg, variant: "destructive" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const delMut = useMutation({
|
||||||
|
mutationFn: trainingService.deleteGrammar,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["training-grammar"] });
|
||||||
|
toast({ title: "Rule deleted" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const progressMut = useMutation({
|
||||||
|
mutationFn: ({ id, completed }: { id: number; completed: boolean }) =>
|
||||||
|
trainingService.setGrammarProgress(id, { completed }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["training-grammar"] }),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Grammar Training</h1>
|
<div>
|
||||||
<p className="text-muted-foreground">Master grammar rules essential for IELTS.</p>
|
<h1 className="text-2xl font-bold tracking-tight">Grammar Training</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage the grammar-rule library and track mastery by CEFR level.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" /> Add Rule
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AiTipBanner tip="You've completed 50% of grammar topics. Focus on Passive Voice next — it appears in 73% of IELTS Writing Task 1 questions and will boost your band score." variant="recommendation" />
|
<AiTipBanner context="grammar" variant="recommendation" />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="lg:col-span-2 space-y-4">
|
<div className="lg:col-span-2 space-y-4">
|
||||||
@@ -36,31 +124,104 @@ export default function GrammarPage() {
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-base">Progress</CardTitle>
|
<CardTitle className="text-base">Progress</CardTitle>
|
||||||
<span className="text-sm text-muted-foreground">{completed}/{grammarItems.length} completed</span>
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{summary
|
||||||
|
? `${summary.completed}/${summary.total} completed (${summary.completion_rate}%)`
|
||||||
|
: "—"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Progress value={(completed / grammarItems.length) * 100} className="h-3" />
|
<Progress value={summary?.completion_rate ?? 0} className="h-3" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
<Switch id="show" checked={showCompleted} onCheckedChange={setShowCompleted} />
|
<div className="relative flex-1 max-w-sm">
|
||||||
<Label htmlFor="show" className="text-sm">Show completed</Label>
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search rules…"
|
||||||
|
className="pl-9"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={levelFilter} onValueChange={setLevelFilter}>
|
||||||
|
<SelectTrigger className="w-[110px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{LEVELS.map((l) => (
|
||||||
|
<SelectItem key={l} value={l}>
|
||||||
|
{l === "all" ? "All levels" : l}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="show-g"
|
||||||
|
checked={showCompleted}
|
||||||
|
onCheckedChange={setShowCompleted}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="show-g" className="text-sm">
|
||||||
|
Show completed
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
{listQ.isLoading && (
|
||||||
|
<div className="py-8 text-center text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!listQ.isLoading && filtered.length === 0 && (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="text-center text-muted-foreground py-8">
|
||||||
|
No grammar rules match.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
{filtered.map((g) => (
|
{filtered.map((g) => (
|
||||||
<Card key={g.rule} className="border-0 shadow-sm">
|
<Card key={g.id} className="border-0 shadow-sm">
|
||||||
<CardContent className="p-4 flex items-center justify-between">
|
<CardContent className="p-4 flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<button
|
||||||
{g.completed && <CheckCircle className="h-4 w-4 text-success shrink-0" />}
|
onClick={() =>
|
||||||
<div>
|
progressMut.mutate({ id: g.id, completed: !g.completed })
|
||||||
<p className="font-semibold text-sm">{g.rule}</p>
|
}
|
||||||
<p className="text-xs text-muted-foreground">{g.description}</p>
|
className="shrink-0 rounded-full p-0.5 hover:bg-muted transition"
|
||||||
</div>
|
aria-label={g.completed ? "Mark incomplete" : "Mark complete"}
|
||||||
|
>
|
||||||
|
{g.completed ? (
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Circle className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-sm">{g.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{g.description}</p>
|
||||||
|
{g.example && (
|
||||||
|
<p className="text-xs italic text-muted-foreground mt-0.5">
|
||||||
|
e.g. {g.example}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Badge variant="outline">{g.level}</Badge>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{g.category}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
onClick={() => delMut.mutate(g.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">{g.level}</Badge>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -70,25 +231,133 @@ export default function GrammarPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-base flex items-center gap-2"><Sparkles className="h-4 w-4 text-primary" /> AI Recommendations</CardTitle>
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-primary" /> AI Recommendations
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Practice passive voice transformations — high exam frequency</li>
|
<li className="text-sm text-muted-foreground flex items-start gap-2">
|
||||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Review modal verbs for IELTS Writing Task 1</li>
|
<span className="text-primary font-bold">•</span>
|
||||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Focus on complex sentence structures for C1 readiness</li>
|
Practice passive-voice transformations — high exam frequency.
|
||||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Your conditional sentences are strong — try advanced mixed conditionals</li>
|
</li>
|
||||||
|
<li className="text-sm text-muted-foreground flex items-start gap-2">
|
||||||
|
<span className="text-primary font-bold">•</span>
|
||||||
|
Review modal verbs for IELTS Writing Task 1.
|
||||||
|
</li>
|
||||||
|
<li className="text-sm text-muted-foreground flex items-start gap-2">
|
||||||
|
<span className="text-primary font-bold">•</span>
|
||||||
|
Focus on complex sentences for C1 readiness.
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
|
{summary && (
|
||||||
<CardContent className="p-4">
|
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
|
||||||
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-2"><Sparkles className="h-3 w-3" /> AI Study Plan</p>
|
<CardContent className="p-4">
|
||||||
<p className="text-sm text-muted-foreground">Based on your progress, complete Passive Voice and Subject-Verb Agreement this week. At your current pace, you'll finish all B2 grammar by April 15.</p>
|
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-2">
|
||||||
</CardContent>
|
<Sparkles className="h-3 w-3" /> AI Study Plan
|
||||||
</Card>
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{summary.remaining > 0
|
||||||
|
? `Finish the remaining ${summary.remaining} rule(s) at one per day to stay on track.`
|
||||||
|
: "You've mastered every active rule in the library."}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Grammar Rule</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Rule name</Label>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
placeholder="e.g. Present Perfect vs Past Simple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, description: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="When and how this rule applies"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Example (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={form.example}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, example: e.target.value }))}
|
||||||
|
placeholder="A sample sentence"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>CEFR level</Label>
|
||||||
|
<Select
|
||||||
|
value={form.level}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setForm((f) => ({ ...f, level: v as CefrLevel }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(LEVELS.filter((l) => l !== "all") as CefrLevel[]).map(
|
||||||
|
(l) => (
|
||||||
|
<SelectItem key={l} value={l}>
|
||||||
|
{l}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Category</Label>
|
||||||
|
<Input
|
||||||
|
value={form.category}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, category: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
!form.name.trim() ||
|
||||||
|
!form.description.trim() ||
|
||||||
|
createMut.isPending
|
||||||
|
}
|
||||||
|
onClick={() => createMut.mutate(form)}
|
||||||
|
>
|
||||||
|
{createMut.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> Adding…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Add"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -10,6 +11,7 @@ import { useAuth } from "@/contexts/AuthContext";
|
|||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { ApiError } from "@/lib/api-client";
|
import { ApiError } from "@/lib/api-client";
|
||||||
import type { UserRole } from "@/types/auth";
|
import type { UserRole } from "@/types/auth";
|
||||||
|
import { LanguageToggle } from "@/components/LanguageToggle";
|
||||||
|
|
||||||
/** Keep in sync with `ProtectedRoute` post-login targets */
|
/** Keep in sync with `ProtectedRoute` post-login targets */
|
||||||
function getRoleDashboard(role: string): string {
|
function getRoleDashboard(role: string): string {
|
||||||
@@ -48,11 +50,16 @@ export default function Login() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!email || !password) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +68,11 @@ export default function Login() {
|
|||||||
const user = await login(email, password);
|
const user = await login(email, password);
|
||||||
navigate(getRoleDashboard(user.user_type));
|
navigate(getRoleDashboard(user.user_type));
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast({ title: "Login Failed", description: loginErrorMessage(err), variant: "destructive" });
|
toast({
|
||||||
|
title: t("auth.loginFailedTitle"),
|
||||||
|
description: loginErrorMessage(err),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -69,35 +80,39 @@ export default function Login() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
<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="w-full max-w-md">
|
||||||
<div className="flex items-center justify-center gap-3 mb-8">
|
<div className="flex items-center justify-center mb-8">
|
||||||
<img src="/logo.png" alt="EnCoach" className="h-12 w-12 rounded-xl object-contain" />
|
<img
|
||||||
<h1 className="text-2xl font-bold tracking-tight">
|
src="/logo.png"
|
||||||
<span className="text-[hsl(42,40%,62%)]">En</span>
|
alt="EnCoach — Unlock your potential with AI powered platform"
|
||||||
<span className="text-[hsl(240,20%,30%)]">Coach</span>
|
className="h-36 w-auto object-contain"
|
||||||
</h1>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="shadow-lg border-0 bg-card">
|
<Card className="shadow-lg border-0 bg-card">
|
||||||
<CardHeader className="text-center pb-4">
|
<CardHeader className="text-center pb-4">
|
||||||
<CardTitle className="text-xl">Welcome back</CardTitle>
|
<CardTitle className="text-xl">{t("auth.welcomeBack")}</CardTitle>
|
||||||
<CardDescription>Sign in to your account to continue</CardDescription>
|
<CardDescription>{t("auth.signInDescription")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">{t("auth.email")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="text"
|
||||||
placeholder="you@example.com"
|
placeholder={t("auth.emailPlaceholder")}
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
dir="ltr"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
@@ -106,11 +121,13 @@ export default function Login() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
dir="ltr"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
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" />}
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -119,19 +136,25 @@ export default function Login() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox id="remember" />
|
<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>
|
</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>
|
</div>
|
||||||
<Button type="submit" className="w-full" disabled={loading}>
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
|
||||||
Sign in
|
{t("auth.signIn")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||||
Don't have an account?{" "}
|
{t("auth.needAccount")}{" "}
|
||||||
<Link to="/register" className="text-primary font-medium hover:underline">Sign up</Link>
|
<Link to="/register" className="text-primary font-medium hover:underline">
|
||||||
|
{t("auth.signUp")}
|
||||||
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const NotFound = () => {
|
const NotFound = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
|
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
|
||||||
@@ -11,10 +13,10 @@ const NotFound = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-muted">
|
<div className="flex min-h-screen items-center justify-center bg-muted">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="mb-4 text-4xl font-bold">404</h1>
|
<h1 className="mb-4 text-4xl font-bold">{t("errors.notFoundCode")}</h1>
|
||||||
<p className="mb-4 text-xl text-muted-foreground">Oops! Page not found</p>
|
<p className="mb-4 text-xl text-muted-foreground">{t("errors.notFoundMessage")}</p>
|
||||||
<a href="/" className="text-primary underline hover:text-primary/90">
|
<a href="/" className="text-primary underline hover:text-primary/90">
|
||||||
Return to Home
|
{t("errors.returnHome")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,58 +1,105 @@
|
|||||||
import { useState } from "react";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import {
|
||||||
import { Label } from "@/components/ui/label";
|
Table,
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
TableBody,
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
TableCell,
|
||||||
import { Plus, Download } from "lucide-react";
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Download, Loader2 } from "lucide-react";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
import AiReportNarrative from "@/components/ai/AiReportNarrative";
|
import AiReportNarrative from "@/components/ai/AiReportNarrative";
|
||||||
|
import { paymentsService } from "@/services/payments.service";
|
||||||
const payments = [
|
|
||||||
{ id: "PAY-001", corporate: "Acme Corp", manager: "John Admin", amount: 5000, currency: "USD", paid: true, date: "2025-01-15", type: "Corporate" },
|
|
||||||
{ id: "PAY-002", corporate: "Global Ltd", manager: "HR Global", amount: 8500, currency: "USD", paid: true, date: "2025-02-01", type: "Corporate" },
|
|
||||||
{ id: "PAY-003", corporate: "Tech Co", manager: "Tech Admin", amount: 2000, currency: "USD", paid: false, date: "2025-03-01", type: "Commission" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const paymobOrders = [
|
|
||||||
{ id: "PMB-1001", status: "Paid", user: "Sarah Johnson", email: "sarah.j@email.com", amount: 199.00 },
|
|
||||||
{ id: "PMB-1002", status: "Pending", user: "Ahmed Hassan", email: "ahmed.h@email.com", amount: 149.00 },
|
|
||||||
{ id: "PMB-1003", status: "Paid", user: "Li Wei", email: "li.w@email.com", amount: 199.00 },
|
|
||||||
{ id: "PMB-1004", status: "Failed", user: "Emma Brown", email: "emma.b@email.com", amount: 99.00 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function PaymentRecordPage() {
|
export default function PaymentRecordPage() {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["payment-records"],
|
||||||
|
queryFn: () => paymentsService.list(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: paymobData } = useQuery({
|
||||||
|
queryKey: ["paymob-orders"],
|
||||||
|
queryFn: () => paymentsService.paymobOrders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payments = data?.items ?? [];
|
||||||
|
const totals = data?.totals;
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (!payments.length) return;
|
||||||
|
const header = ["Ref", "Student", "Course", "Product", "Amount", "Currency", "State", "Paid", "Date"];
|
||||||
|
const rows = payments.map((p) => [
|
||||||
|
p.ref,
|
||||||
|
p.student_name,
|
||||||
|
p.course_name,
|
||||||
|
p.product_name,
|
||||||
|
p.amount.toFixed(2),
|
||||||
|
p.currency,
|
||||||
|
p.state,
|
||||||
|
p.paid ? "yes" : "no",
|
||||||
|
p.date,
|
||||||
|
]);
|
||||||
|
const csv = [header, ...rows].map((r) => r.join(",")).join("\n");
|
||||||
|
const blob = new Blob([csv], { type: "text/csv" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `payment-records-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Payment Record</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Payment Record</h1>
|
||||||
<p className="text-muted-foreground">Manage payments, commissions, and Paymob orders.</p>
|
<p className="text-muted-foreground">
|
||||||
|
Live student fees, invoices, and Paymob orders.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm"><Download className="h-4 w-4 mr-1" /> Export CSV</Button>
|
<Button variant="outline" size="sm" onClick={handleExport} disabled={!payments.length}>
|
||||||
<Dialog>
|
<Download className="h-4 w-4 mr-1" /> Export CSV
|
||||||
<DialogTrigger asChild>
|
</Button>
|
||||||
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> New Payment</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader><DialogTitle>Create Payment Record</DialogTitle></DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2"><Label>Corporate</Label><Select><SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger><SelectContent><SelectItem value="acme">Acme Corp</SelectItem></SelectContent></Select></div>
|
|
||||||
<div className="space-y-2"><Label>Amount (USD)</Label><Input type="number" placeholder="5000" /></div>
|
|
||||||
<div className="space-y-2"><Label>Type</Label><Select><SelectTrigger><SelectValue placeholder="Type" /></SelectTrigger><SelectContent><SelectItem value="corporate">Corporate</SelectItem><SelectItem value="commission">Commission</SelectItem></SelectContent></Select></div>
|
|
||||||
<Button className="w-full">Create</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AiTipBanner tip="PAY-003 (Tech Co) is unpaid and overdue. PMB-1004 failed — AI recommends sending an automated retry notification to Emma Brown." variant="recommendation" />
|
{totals && (
|
||||||
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-xs text-muted-foreground">Total records</p>
|
||||||
|
<p className="text-2xl font-bold">{totals.count}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-xs text-muted-foreground">Paid</p>
|
||||||
|
<p className="text-2xl font-bold text-green-600">{totals.paid}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-xs text-muted-foreground">Unpaid</p>
|
||||||
|
<p className="text-2xl font-bold text-red-600">{totals.unpaid}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-xs text-muted-foreground">Total amount</p>
|
||||||
|
<p className="text-2xl font-bold">${totals.amount.toLocaleString()}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AiTipBanner context="payment-record" variant="recommendation" />
|
||||||
|
|
||||||
<Tabs defaultValue="payments">
|
<Tabs defaultValue="payments">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
@@ -61,26 +108,57 @@ export default function PaymentRecordPage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="payments" className="mt-4 space-y-4">
|
<TabsContent value="payments" className="mt-4 space-y-4">
|
||||||
<AiReportNarrative narrative="Total revenue collected: $13,500 from 2 corporate payments. One commission of $2,000 remains unpaid. Collection rate: 67%. Trend: Q1 payments are on track but Tech Co requires follow-up." />
|
<AiReportNarrative report_type="payments" data={{ payments }} />
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>ID</TableHead><TableHead>Corporate</TableHead><TableHead>Manager</TableHead>
|
<TableHead>Ref</TableHead>
|
||||||
<TableHead>Amount</TableHead><TableHead>Type</TableHead><TableHead>Paid</TableHead><TableHead>Date</TableHead>
|
<TableHead>Student</TableHead>
|
||||||
|
<TableHead>Course</TableHead>
|
||||||
|
<TableHead>Product</TableHead>
|
||||||
|
<TableHead>Amount</TableHead>
|
||||||
|
<TableHead>State</TableHead>
|
||||||
|
<TableHead>Paid</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
{isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{!isLoading && payments.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center text-muted-foreground py-8">
|
||||||
|
No payment records yet.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
{payments.map((p) => (
|
{payments.map((p) => (
|
||||||
<TableRow key={p.id}>
|
<TableRow key={p.id}>
|
||||||
<TableCell className="font-mono text-xs">{p.id}</TableCell>
|
<TableCell className="font-mono text-xs">{p.ref}</TableCell>
|
||||||
<TableCell>{p.corporate}</TableCell>
|
<TableCell>{p.student_name || "—"}</TableCell>
|
||||||
<TableCell>{p.manager}</TableCell>
|
<TableCell>{p.course_name || "—"}</TableCell>
|
||||||
<TableCell className="font-semibold">${p.amount.toLocaleString()}</TableCell>
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
<TableCell><Badge variant="outline">{p.type}</Badge></TableCell>
|
{p.product_name || "—"}
|
||||||
<TableCell><Badge variant={p.paid ? "default" : "destructive"}>{p.paid ? "Paid" : "Unpaid"}</Badge></TableCell>
|
</TableCell>
|
||||||
<TableCell>{p.date}</TableCell>
|
<TableCell className="font-semibold">
|
||||||
|
${p.amount.toLocaleString()} {p.currency}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{p.state}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={p.paid ? "default" : "destructive"}>
|
||||||
|
{p.paid ? "Paid" : "Unpaid"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{p.date || "—"}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -91,26 +169,50 @@ export default function PaymentRecordPage() {
|
|||||||
|
|
||||||
<TabsContent value="paymob" className="mt-4">
|
<TabsContent value="paymob" className="mt-4">
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-6">
|
||||||
<Table>
|
{(paymobData?.items?.length ?? 0) === 0 ? (
|
||||||
<TableHeader>
|
<div className="text-center text-muted-foreground py-8">
|
||||||
<TableRow>
|
{paymobData?.message ??
|
||||||
<TableHead>Order ID</TableHead><TableHead>Status</TableHead><TableHead>User</TableHead>
|
"Paymob integration is not yet wired. Orders will appear here once configured."}
|
||||||
<TableHead>Email</TableHead><TableHead>Amount</TableHead>
|
</div>
|
||||||
</TableRow>
|
) : (
|
||||||
</TableHeader>
|
<Table>
|
||||||
<TableBody>
|
<TableHeader>
|
||||||
{paymobOrders.map((o) => (
|
<TableRow>
|
||||||
<TableRow key={o.id}>
|
<TableHead>Order ID</TableHead>
|
||||||
<TableCell className="font-mono text-xs">{o.id}</TableCell>
|
<TableHead>Status</TableHead>
|
||||||
<TableCell><Badge variant={o.status === "Paid" ? "default" : o.status === "Pending" ? "secondary" : "destructive"}>{o.status}</Badge></TableCell>
|
<TableHead>User</TableHead>
|
||||||
<TableCell>{o.user}</TableCell>
|
<TableHead>Email</TableHead>
|
||||||
<TableCell>{o.email}</TableCell>
|
<TableHead>Amount</TableHead>
|
||||||
<TableCell className="font-semibold">${o.amount.toFixed(2)}</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{paymobData!.items.map((o) => (
|
||||||
|
<TableRow key={o.id}>
|
||||||
|
<TableCell className="font-mono text-xs">{o.id}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
o.status === "Paid"
|
||||||
|
? "default"
|
||||||
|
: o.status === "Pending"
|
||||||
|
? "secondary"
|
||||||
|
: "destructive"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{o.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{o.user}</TableCell>
|
||||||
|
<TableCell>{o.email}</TableCell>
|
||||||
|
<TableCell className="font-semibold">
|
||||||
|
${o.amount.toFixed(2)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
173
src/pages/PrivacyCenter.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Download, ShieldAlert, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { gdprService } from "@/services/gdpr.service";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
|
||||||
|
export default function PrivacyCenter() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { logout } = useAuth();
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [erasing, setErasing] = useState(false);
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [confirmText, setConfirmText] = useState("");
|
||||||
|
|
||||||
|
const downloadExport = async () => {
|
||||||
|
try {
|
||||||
|
setExporting(true);
|
||||||
|
const payload = await gdprService.exportMyData();
|
||||||
|
const blob = new Blob([JSON.stringify(payload, null, 2)], {
|
||||||
|
type: "application/json",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `encoach-data-export-${new Date()
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10)}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast.success("Data export downloaded");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Export failed");
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const eraseAccount = async () => {
|
||||||
|
try {
|
||||||
|
setErasing(true);
|
||||||
|
await gdprService.eraseMyAccount();
|
||||||
|
toast.success("Your account has been erased. Signing out…");
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
} catch {
|
||||||
|
// ignore — the server may have already invalidated our tokens
|
||||||
|
}
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
}, 1200);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Erasure failed");
|
||||||
|
setErasing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
<ShieldAlert className="mr-1 inline h-5 w-5" />
|
||||||
|
Privacy Center
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
Manage your personal data held by EnCoach under GDPR and equivalent
|
||||||
|
regulations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Download your data</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
We'll package your profile, entity memberships, exam attempts,
|
||||||
|
answers, AI feedback, and tickets into a single JSON file.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button onClick={downloadExport} disabled={exporting}>
|
||||||
|
<Download className="mr-1 h-4 w-4" />
|
||||||
|
{exporting ? "Preparing export…" : "Download my data"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-destructive/40">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-destructive">Delete my account</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
This anonymises your profile and removes personal fields from our
|
||||||
|
records. Exam attempts are retained (without identifying
|
||||||
|
information) for statistical integrity. This action cannot be
|
||||||
|
undone.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setConfirmOpen(true)}
|
||||||
|
disabled={erasing}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1 h-4 w-4" />
|
||||||
|
{erasing ? "Erasing…" : "Erase my account"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||||
|
<AlertDialogContent description="Erasure is permanent — type DELETE to confirm.">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Erase your account?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
We'll anonymise your personal data and deactivate your login.
|
||||||
|
Aggregate analytics (exam scores, attempt counts) will remain
|
||||||
|
but will no longer be linked to you.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="confirm-delete">
|
||||||
|
Type <strong>DELETE</strong> to confirm:
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm-delete"
|
||||||
|
value={confirmText}
|
||||||
|
onChange={(e) => setConfirmText(e.target.value)}
|
||||||
|
placeholder="DELETE"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => setConfirmText("")}>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={confirmText !== "DELETE" || erasing}
|
||||||
|
onClick={async () => {
|
||||||
|
setConfirmOpen(false);
|
||||||
|
await eraseAccount();
|
||||||
|
setConfirmText("");
|
||||||
|
}}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Erase account
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { User } from "lucide-react";
|
|
||||||
import { api } from "@/lib/api-client";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
|
||||||
|
|
||||||
export default function ProfilePage() {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const nameParts = (user?.name || "Admin User").split(" ");
|
|
||||||
const [first, setFirst] = useState(nameParts[0] || "");
|
|
||||||
const [last, setLast] = useState(nameParts.slice(1).join(" ") || "");
|
|
||||||
const [email, setEmail] = useState(user?.email || "");
|
|
||||||
const [curPw, setCurPw] = useState("");
|
|
||||||
const [newPw, setNewPw] = useState("");
|
|
||||||
const [confirmPw, setConfirmPw] = useState("");
|
|
||||||
|
|
||||||
const saveMut = useMutation({
|
|
||||||
mutationFn: () => api.patch("/user", { first_name: first, last_name: last, email }),
|
|
||||||
onSuccess: () => toast({ title: "Profile saved" }),
|
|
||||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const pwMut = useMutation({
|
|
||||||
mutationFn: () => api.post("/user/change-password", { current_password: curPw, new_password: newPw }),
|
|
||||||
onSuccess: () => { setCurPw(""); setNewPw(""); setConfirmPw(""); toast({ title: "Password updated" }); },
|
|
||||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 max-w-2xl">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Profile</h1>
|
|
||||||
<p className="text-muted-foreground">Manage your account information.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="border-0 shadow-sm">
|
|
||||||
<CardHeader><CardTitle className="text-base">Personal Information</CardTitle></CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center gap-4 mb-4">
|
|
||||||
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
|
|
||||||
<User className="h-8 w-8 text-primary" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2"><Label>First Name</Label><Input value={first} onChange={(e) => setFirst(e.target.value)} /></div>
|
|
||||||
<div className="space-y-2"><Label>Last Name</Label><Input value={last} onChange={(e) => setLast(e.target.value)} /></div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2"><Label>Email</Label><Input value={email} onChange={(e) => setEmail(e.target.value)} type="email" /></div>
|
|
||||||
<Button onClick={() => saveMut.mutate()} disabled={saveMut.isPending}>
|
|
||||||
{saveMut.isPending ? "Saving..." : "Save Changes"}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-0 shadow-sm">
|
|
||||||
<CardHeader><CardTitle className="text-base">Change Password</CardTitle></CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2"><Label>Current Password</Label><Input type="password" value={curPw} onChange={(e) => setCurPw(e.target.value)} placeholder="••••••••" /></div>
|
|
||||||
<div className="space-y-2"><Label>New Password</Label><Input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} placeholder="••••••••" /></div>
|
|
||||||
<div className="space-y-2"><Label>Confirm New Password</Label><Input type="password" value={confirmPw} onChange={(e) => setConfirmPw(e.target.value)} placeholder="••••••••" /></div>
|
|
||||||
{newPw && confirmPw && newPw !== confirmPw && <p className="text-sm text-destructive">Passwords do not match.</p>}
|
|
||||||
<Button onClick={() => pwMut.mutate()} disabled={pwMut.isPending || !curPw || !newPw || newPw !== confirmPw}>
|
|
||||||
{pwMut.isPending ? "Updating..." : "Update Password"}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,66 +1,212 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import {
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Loader2, Download } from "lucide-react";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
import AiReportNarrative from "@/components/ai/AiReportNarrative";
|
import AiReportNarrative from "@/components/ai/AiReportNarrative";
|
||||||
|
import { reportsService, RecordRow } from "@/services/reports.service";
|
||||||
|
|
||||||
const records = [
|
type Period = "all" | "day" | "week" | "month";
|
||||||
{ id: 1, assignment: "IELTS Prep Q1", exam: "EX-001", date: "2025-03-01", score: 7.5, duration: "175 min", status: "Completed" },
|
|
||||||
{ id: 2, assignment: "Speaking Workshop", exam: "EX-005", date: "2025-03-05", score: 6.0, duration: "14 min", status: "Completed" },
|
|
||||||
{ id: 3, assignment: "Full Mock Exam", exam: "EX-006", date: "2025-03-10", score: null, duration: "120 min", status: "In Progress" },
|
|
||||||
{ id: 4, assignment: "Listening Bootcamp", exam: "EX-003", date: "2025-02-20", score: 5.5, duration: "28 min", status: "Completed" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function RecordPage() {
|
export default function RecordPage() {
|
||||||
|
const [entityId, setEntityId] = useState("all");
|
||||||
|
const [userId, setUserId] = useState("all");
|
||||||
|
const [period, setPeriod] = useState<Period>("all");
|
||||||
|
|
||||||
|
const { data: filters } = useQuery({
|
||||||
|
queryKey: ["reports-filters"],
|
||||||
|
queryFn: () => reportsService.filters(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["record", entityId, userId, period],
|
||||||
|
queryFn: () =>
|
||||||
|
reportsService.record({
|
||||||
|
entity_id: entityId === "all" ? undefined : Number(entityId),
|
||||||
|
user_id: userId === "all" ? undefined : Number(userId),
|
||||||
|
period: period === "all" ? undefined : period,
|
||||||
|
size: 100,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const records: RecordRow[] = data?.items ?? [];
|
||||||
|
|
||||||
|
const exportCsv = () => {
|
||||||
|
const header = [
|
||||||
|
"Student",
|
||||||
|
"Assignment",
|
||||||
|
"Exam",
|
||||||
|
"Date",
|
||||||
|
"Score",
|
||||||
|
"Duration",
|
||||||
|
"Status",
|
||||||
|
];
|
||||||
|
const lines = records.map((r) =>
|
||||||
|
[
|
||||||
|
r.student_name,
|
||||||
|
r.assignment,
|
||||||
|
r.exam_code || r.exam,
|
||||||
|
r.date ?? "",
|
||||||
|
r.score ?? "",
|
||||||
|
r.duration,
|
||||||
|
r.status_label,
|
||||||
|
]
|
||||||
|
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
|
||||||
|
.join(","),
|
||||||
|
);
|
||||||
|
const blob = new Blob([[header.join(","), ...lines].join("\n")], {
|
||||||
|
type: "text/csv",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `records-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusVariant = (status: string) => {
|
||||||
|
if (status === "completed" || status === "released" || status === "scored")
|
||||||
|
return "default";
|
||||||
|
if (status === "in_progress" || status === "scoring") return "secondary";
|
||||||
|
return "outline";
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex items-start justify-between gap-4">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Record</h1>
|
<div>
|
||||||
<p className="text-muted-foreground">Browse assignment and exam attempt history.</p>
|
<h1 className="text-2xl font-bold tracking-tight">Record</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Browse assignment and exam attempt history.
|
||||||
|
{data && (
|
||||||
|
<span className="ml-2 text-xs">({data.total} attempts)</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={exportCsv}
|
||||||
|
disabled={!records.length}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-1" /> Export CSV
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AiTipBanner tip="The student's scores show an upward trend from 5.5 → 6.0 → 7.5 over the last 3 completed exams. Listening remains the weakest module — recommend targeted practice." variant="insight" />
|
<AiTipBanner context="record" variant="insight" />
|
||||||
|
|
||||||
<AiReportNarrative narrative="3 of 4 attempts completed with an average score of 6.3. Time management is good — all exams finished within allocated time. The Full Mock Exam is still in progress (67% time used). Strongest area: Reading (7.5), weakest: Listening (5.5)." />
|
{records.length > 0 && (
|
||||||
|
<AiReportNarrative report_type="record" data={{ records }} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="Entity" /></SelectTrigger>
|
<Select value={entityId} onValueChange={setEntityId}>
|
||||||
<SelectContent><SelectItem value="acme">Acme Corp</SelectItem><SelectItem value="global">Global Ltd</SelectItem></SelectContent>
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="All Entities" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Entities</SelectItem>
|
||||||
|
{filters?.entities.map((e) => (
|
||||||
|
<SelectItem key={e.id} value={String(e.id)}>
|
||||||
|
{e.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select><SelectTrigger className="w-[180px]"><SelectValue placeholder="Select User" /></SelectTrigger>
|
<Select value={userId} onValueChange={setUserId}>
|
||||||
<SelectContent><SelectItem value="sarah">Sarah Johnson</SelectItem><SelectItem value="ahmed">Ahmed Hassan</SelectItem></SelectContent>
|
<SelectTrigger className="w-[220px]">
|
||||||
|
<SelectValue placeholder="All Users" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Users</SelectItem>
|
||||||
|
{filters?.students.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={String(s.id)}>
|
||||||
|
{s.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{["Day", "Week", "Month"].map(t => (
|
{(["all", "day", "week", "month"] as Period[]).map((p) => (
|
||||||
<Button key={t} variant="outline" size="sm">{t}</Button>
|
<Button
|
||||||
|
key={p}
|
||||||
|
variant={period === p ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPeriod(p)}
|
||||||
|
>
|
||||||
|
{p === "all" ? "All" : p.charAt(0).toUpperCase() + p.slice(1)}
|
||||||
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<Table>
|
{isLoading ? (
|
||||||
<TableHeader>
|
<div className="flex items-center justify-center p-12 text-muted-foreground">
|
||||||
<TableRow>
|
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Loading
|
||||||
<TableHead>Assignment</TableHead><TableHead>Exam</TableHead><TableHead>Date</TableHead>
|
attempts...
|
||||||
<TableHead>Score</TableHead><TableHead>Duration</TableHead><TableHead>Status</TableHead>
|
</div>
|
||||||
</TableRow>
|
) : records.length === 0 ? (
|
||||||
</TableHeader>
|
<div className="p-8 text-center text-muted-foreground">
|
||||||
<TableBody>
|
No attempts match these filters.
|
||||||
{records.map((r) => (
|
</div>
|
||||||
<TableRow key={r.id}>
|
) : (
|
||||||
<TableCell className="font-medium">{r.assignment}</TableCell>
|
<Table>
|
||||||
<TableCell className="font-mono text-xs">{r.exam}</TableCell>
|
<TableHeader>
|
||||||
<TableCell>{r.date}</TableCell>
|
<TableRow>
|
||||||
<TableCell>{r.score ? r.score.toFixed(1) : "—"}</TableCell>
|
<TableHead>Student</TableHead>
|
||||||
<TableCell>{r.duration}</TableCell>
|
<TableHead>Assignment</TableHead>
|
||||||
<TableCell><Badge variant={r.status === "Completed" ? "default" : "secondary"}>{r.status}</Badge></TableCell>
|
<TableHead>Exam</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Score</TableHead>
|
||||||
|
<TableHead>Duration</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{records.map((r) => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{r.student_name || "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{r.assignment || "—"}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{r.exam_code}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{r.date || "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{r.score ? r.score.toFixed(1) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{r.duration}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusVariant(r.status)}>
|
||||||
|
{r.status_label}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { GraduationCap, Eye, EyeOff, Loader2 } from "lucide-react";
|
import { GraduationCap, Eye, EyeOff, Loader2, ShieldCheck } from "lucide-react";
|
||||||
import { useRegister, useCheckEmail } from "@/hooks/queries/useSignup";
|
import { useRegister, useCheckEmail } from "@/hooks/queries/useSignup";
|
||||||
import { ApiError } from "@/lib/api-client";
|
import { ApiError, api } from "@/lib/api-client";
|
||||||
import type { RegisterRequest } from "@/types";
|
import type { RegisterRequest } from "@/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -47,6 +48,41 @@ export default function Register() {
|
|||||||
const checkEmail = useCheckEmail();
|
const checkEmail = useCheckEmail();
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const [captchaToken, setCaptchaToken] = useState<string>("");
|
||||||
|
const captchaContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { data: captchaConfig } = useQuery({
|
||||||
|
queryKey: ["captcha-config"],
|
||||||
|
queryFn: () => api.get<{ provider: string; site_key: string }>("/config/captcha"),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadCaptchaScript = useCallback((provider: string, siteKey: string) => {
|
||||||
|
if (!siteKey || !provider) return;
|
||||||
|
const existingScript = document.querySelector(`script[data-captcha-provider="${provider}"]`);
|
||||||
|
if (existingScript) return;
|
||||||
|
|
||||||
|
const scriptUrls: Record<string, string> = {
|
||||||
|
recaptcha: `https://www.google.com/recaptcha/api.js?render=${siteKey}`,
|
||||||
|
hcaptcha: "https://js.hcaptcha.com/1/api.js",
|
||||||
|
turnstile: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
||||||
|
};
|
||||||
|
const url = scriptUrls[provider];
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = url;
|
||||||
|
script.async = true;
|
||||||
|
script.defer = true;
|
||||||
|
script.setAttribute("data-captcha-provider", provider);
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (captchaConfig?.site_key && captchaConfig?.provider) {
|
||||||
|
loadCaptchaScript(captchaConfig.provider, captchaConfig.site_key);
|
||||||
|
}
|
||||||
|
}, [captchaConfig, loadCaptchaScript]);
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
@@ -69,7 +105,7 @@ export default function Register() {
|
|||||||
email: values.email.trim(),
|
email: values.email.trim(),
|
||||||
password: values.password,
|
password: values.password,
|
||||||
role: values.role,
|
role: values.role,
|
||||||
captcha_token: "demo-placeholder",
|
captcha_token: captchaToken || undefined,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await register.mutateAsync(payload);
|
await register.mutateAsync(payload);
|
||||||
@@ -251,10 +287,26 @@ export default function Register() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="rounded-lg border border-dashed bg-muted/30 p-4 space-y-2">
|
<div className="rounded-lg border border-dashed bg-muted/30 p-4 space-y-2">
|
||||||
<p className="text-sm font-medium">CAPTCHA</p>
|
<p className="text-sm font-medium flex items-center gap-2">
|
||||||
<div className="h-16 rounded-md bg-muted flex items-center justify-center text-xs text-muted-foreground">
|
<ShieldCheck className="h-4 w-4" /> Verification
|
||||||
Verification widget placeholder
|
</p>
|
||||||
|
<div ref={captchaContainerRef} className="min-h-[65px] flex items-center justify-center">
|
||||||
|
{captchaConfig?.site_key ? (
|
||||||
|
<div
|
||||||
|
className={captchaConfig.provider === "hcaptcha" ? "h-captcha" :
|
||||||
|
captchaConfig.provider === "turnstile" ? "cf-turnstile" : "g-recaptcha"}
|
||||||
|
data-sitekey={captchaConfig.site_key}
|
||||||
|
data-callback="onCaptchaSuccess"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">CAPTCHA not configured — registration allowed</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{captchaToken && (
|
||||||
|
<p className="text-xs text-green-600 flex items-center gap-1">
|
||||||
|
<ShieldCheck className="h-3 w-3" /> Verified
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{form.formState.errors.root && (
|
{form.formState.errors.root && (
|
||||||
|
|||||||
@@ -1,31 +1,644 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import {
|
||||||
import { Search, Plus } from "lucide-react";
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
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 AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
||||||
|
import { examsService } from "@/services/exams.service";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
const rubrics = [
|
interface RubricItem {
|
||||||
{ id: 1, name: "IELTS Writing Task 2", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 4, created: "2025-01-05" },
|
id: number;
|
||||||
{ id: 2, name: "Speaking Fluency", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 3, created: "2025-01-10" },
|
name: string;
|
||||||
{ id: 3, name: "Reading Comprehension", levels: ["A1","A2","B1","B2","C1"], criteria: 5, created: "2025-02-01" },
|
skill: string;
|
||||||
|
exam_type: string;
|
||||||
|
criteria: number;
|
||||||
|
criteria_text: string;
|
||||||
|
levels: string[];
|
||||||
|
created: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CriterionEntry {
|
||||||
|
name: string;
|
||||||
|
weight: number;
|
||||||
|
descriptors: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKILL_OPTIONS = [
|
||||||
|
{ value: "writing", label: "Writing" },
|
||||||
|
{ value: "speaking", label: "Speaking" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const rubricGroups = [
|
const EXAM_TYPE_OPTIONS = [
|
||||||
{ id: 1, name: "Academic IELTS Full", rubrics: ["IELTS Writing Task 2", "Speaking Fluency", "Reading Comprehension"], created: "2025-02-15" },
|
{ value: "academic", label: "Academic" },
|
||||||
{ id: 2, name: "Business English", rubrics: ["Speaking Fluency"], created: "2025-03-01" },
|
{ value: "general_training", label: "General Training" },
|
||||||
|
{ value: "general_english", label: "General English" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ALL_CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"];
|
||||||
|
|
||||||
|
interface RubricFormState {
|
||||||
|
name: string;
|
||||||
|
skill: string;
|
||||||
|
exam_type: string;
|
||||||
|
criteria: CriterionEntry[];
|
||||||
|
levels: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
exam_type: "academic",
|
||||||
|
criteria: [],
|
||||||
|
levels: new Set(ALL_CEFR_LEVELS),
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseCriteriaText(text: string, levels: string[]): CriterionEntry[] {
|
||||||
|
if (!text) return [];
|
||||||
|
|
||||||
|
const mapStructured = (arr: unknown[]): CriterionEntry[] =>
|
||||||
|
arr.map((c) => {
|
||||||
|
const obj = c as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
name: String(obj.name || ""),
|
||||||
|
weight: Number(obj.weight || 0),
|
||||||
|
descriptors:
|
||||||
|
typeof obj.descriptors === "object" && obj.descriptors !== null
|
||||||
|
? Object.fromEntries(levels.map((l) => [l, String((obj.descriptors as Record<string, unknown>)[l] || "")]))
|
||||||
|
: Object.fromEntries(levels.map((l) => [l, ""])),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
// Handle {"criteria": [...]} wrapper (from AI or previous saves)
|
||||||
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && Array.isArray(parsed.criteria)) {
|
||||||
|
return mapStructured(parsed.criteria);
|
||||||
|
}
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
if (parsed.length > 0 && typeof parsed[0] === "object" && parsed[0] !== null && "name" in parsed[0]) {
|
||||||
|
return mapStructured(parsed);
|
||||||
|
}
|
||||||
|
return parsed.filter(Boolean).map((name) => ({
|
||||||
|
name: String(name),
|
||||||
|
weight: Math.round(100 / parsed.length),
|
||||||
|
descriptors: Object.fromEntries(levels.map((l) => [l, ""])),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch { /* not JSON */ }
|
||||||
|
|
||||||
|
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||||
|
if (lines.length === 0) return [];
|
||||||
|
return lines.map((name) => ({
|
||||||
|
name,
|
||||||
|
weight: Math.round(100 / lines.length),
|
||||||
|
descriptors: Object.fromEntries(levels.map((l) => [l, ""])),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupFormState {
|
||||||
|
name: string;
|
||||||
|
rubric_ids: Set<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyGroupForm = (): GroupFormState => ({ name: "", rubric_ids: new Set() });
|
||||||
|
|
||||||
export default function RubricsPage() {
|
export default function RubricsPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createForm, setCreateForm] = useState<RubricFormState>(emptyForm());
|
||||||
|
|
||||||
|
const [editTarget, setEditTarget] = useState<RubricItem | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState<RubricFormState>(emptyForm());
|
||||||
|
|
||||||
|
const [groupCreateOpen, setGroupCreateOpen] = useState(false);
|
||||||
|
const [groupCreateForm, setGroupCreateForm] = useState<GroupFormState>(emptyGroupForm());
|
||||||
|
const [groupEditTarget, setGroupEditTarget] = useState<{ id: number; name: string; rubric_ids: number[] } | null>(null);
|
||||||
|
const [groupEditForm, setGroupEditForm] = useState<GroupFormState>(emptyGroupForm());
|
||||||
|
|
||||||
|
const rubricsQ = useQuery({
|
||||||
|
queryKey: ["rubrics"],
|
||||||
|
queryFn: () => examsService.listRubrics({}),
|
||||||
|
});
|
||||||
|
const rubrics = (rubricsQ.data?.items ?? []) as unknown as RubricItem[];
|
||||||
|
|
||||||
|
const rubricGroupsQ = useQuery({
|
||||||
|
queryKey: ["rubric-groups"],
|
||||||
|
queryFn: () => examsService.listRubricGroups({}),
|
||||||
|
});
|
||||||
|
const rubricGroups = rubricGroupsQ.data?.items ?? [];
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => examsService.createRubric(data as never),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["rubrics"] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
setCreateForm(emptyForm());
|
||||||
|
toast({ title: "Rubric created" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Create failed", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMut = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) =>
|
||||||
|
examsService.updateRubric(id, data as never),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["rubrics"] });
|
||||||
|
setEditTarget(null);
|
||||||
|
toast({ title: "Rubric updated" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Update failed", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMut = useMutation({
|
||||||
|
mutationFn: (id: number) => examsService.deleteRubric(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["rubrics"] });
|
||||||
|
toast({ title: "Rubric deleted" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Delete failed", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createGroupMut = useMutation({
|
||||||
|
mutationFn: (data: { name: string; rubric_ids: number[] }) => examsService.createRubricGroup(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["rubric-groups"] });
|
||||||
|
setGroupCreateOpen(false);
|
||||||
|
setGroupCreateForm(emptyGroupForm());
|
||||||
|
toast({ title: "Rubric group created" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Create group failed", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateGroupMut = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: { name?: string; rubric_ids?: number[] } }) =>
|
||||||
|
examsService.updateRubricGroup(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["rubric-groups"] });
|
||||||
|
setGroupEditTarget(null);
|
||||||
|
toast({ title: "Rubric group updated" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Update group failed", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteGroupMut = useMutation({
|
||||||
|
mutationFn: (id: number) => examsService.deleteRubricGroup(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["rubric-groups"] });
|
||||||
|
toast({ title: "Rubric group deleted" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Delete group failed", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [suggesting, setSuggesting] = useState(false);
|
||||||
|
|
||||||
|
const suggestCriteria = async (
|
||||||
|
form: RubricFormState,
|
||||||
|
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
|
||||||
|
) => {
|
||||||
|
setSuggesting(true);
|
||||||
|
try {
|
||||||
|
const result = await examsService.suggestRubricCriteria({
|
||||||
|
name: form.name,
|
||||||
|
skill: form.skill,
|
||||||
|
exam_type: form.exam_type,
|
||||||
|
levels: [...form.levels],
|
||||||
|
});
|
||||||
|
if (result.criteria && Array.isArray(result.criteria) && result.criteria.length > 0) {
|
||||||
|
const levelsArr = [...form.levels];
|
||||||
|
const entries: CriterionEntry[] = result.criteria.map((c: unknown) => {
|
||||||
|
if (typeof c === "object" && c !== null) {
|
||||||
|
const obj = c as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
name: String(obj.name || ""),
|
||||||
|
weight: Number(obj.weight || 0),
|
||||||
|
descriptors: typeof obj.descriptors === "object" && obj.descriptors !== null
|
||||||
|
? Object.fromEntries(
|
||||||
|
levelsArr.map((l) => [l, String((obj.descriptors as Record<string, unknown>)[l] || "")])
|
||||||
|
)
|
||||||
|
: Object.fromEntries(levelsArr.map((l) => [l, ""])),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { name: String(c), weight: 0, descriptors: Object.fromEntries(levelsArr.map((l) => [l, ""])) };
|
||||||
|
});
|
||||||
|
setForm((f) => ({ ...f, criteria: entries }));
|
||||||
|
toast({ title: "Criteria generated", description: `${entries.length} criteria with band descriptors created by AI.` });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast({ variant: "destructive", title: "AI suggestion failed", description: (err as Error).message });
|
||||||
|
} finally {
|
||||||
|
setSuggesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (r: RubricItem) => {
|
||||||
|
const levelsArr = Array.isArray(r.levels) && r.levels.length > 0 ? r.levels : ALL_CEFR_LEVELS;
|
||||||
|
setEditTarget(r);
|
||||||
|
setEditForm({
|
||||||
|
name: r.name,
|
||||||
|
skill: r.skill || "writing",
|
||||||
|
exam_type: r.exam_type || "academic",
|
||||||
|
criteria: parseCriteriaText(r.criteria_text, levelsArr),
|
||||||
|
levels: new Set(levelsArr),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formToPayload = (form: RubricFormState) => ({
|
||||||
|
name: form.name.trim(),
|
||||||
|
skill: form.skill,
|
||||||
|
exam_type: form.exam_type,
|
||||||
|
criteria: JSON.stringify(form.criteria.filter((c) => c.name.trim())),
|
||||||
|
levels: [...form.levels],
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = rubrics.filter((r) => {
|
||||||
|
if (!search) return true;
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return r.name?.toLowerCase().includes(q) || r.skill?.toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCriterion = (
|
||||||
|
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
|
||||||
|
index: number,
|
||||||
|
patch: Partial<CriterionEntry>,
|
||||||
|
) => {
|
||||||
|
setForm((f) => {
|
||||||
|
const criteria = [...f.criteria];
|
||||||
|
criteria[index] = { ...criteria[index], ...patch };
|
||||||
|
return { ...f, criteria };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDescriptor = (
|
||||||
|
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
|
||||||
|
cIndex: number,
|
||||||
|
level: string,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
setForm((f) => {
|
||||||
|
const criteria = [...f.criteria];
|
||||||
|
criteria[cIndex] = {
|
||||||
|
...criteria[cIndex],
|
||||||
|
descriptors: { ...criteria[cIndex].descriptors, [level]: value },
|
||||||
|
};
|
||||||
|
return { ...f, criteria };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderForm = (
|
||||||
|
form: RubricFormState,
|
||||||
|
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
|
||||||
|
) => {
|
||||||
|
const levelsArr = [...form.levels].sort(
|
||||||
|
(a, b) => ALL_CEFR_LEVELS.indexOf(a) - ALL_CEFR_LEVELS.indexOf(b)
|
||||||
|
);
|
||||||
|
const totalWeight = form.criteria.reduce((s, c) => s + (c.weight || 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Rubric Name <span className="text-destructive">*</span></Label>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
placeholder="e.g. IELTS Writing Task 1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Skill</Label>
|
||||||
|
<Select value={form.skill} onValueChange={(v) => setForm((f) => ({ ...f, skill: v }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SKILL_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Exam Type</Label>
|
||||||
|
<Select value={form.exam_type} onValueChange={(v) => setForm((f) => ({ ...f, exam_type: v }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{EXAM_TYPE_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>CEFR Levels</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ALL_CEFR_LEVELS.map((level) => {
|
||||||
|
const checked = form.levels.has(level);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={level}
|
||||||
|
className={`flex items-center gap-1.5 rounded-md border px-3 py-1.5 cursor-pointer transition-all text-sm font-medium ${
|
||||||
|
checked ? "bg-primary/10 border-primary text-primary" : "hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(val) => {
|
||||||
|
setForm((f) => {
|
||||||
|
const next = new Set(f.levels);
|
||||||
|
if (val) next.add(level); else next.delete(level);
|
||||||
|
return { ...f, levels: next };
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{level}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>Criteria</Label>
|
||||||
|
{form.criteria.length > 0 && (
|
||||||
|
<span className={`ml-2 text-xs ${totalWeight === 100 ? "text-green-600" : "text-amber-600"}`}>
|
||||||
|
(Total weight: {totalWeight}%)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 bg-gradient-to-r from-violet-500 to-indigo-500 text-white hover:from-violet-600 hover:to-indigo-600"
|
||||||
|
disabled={suggesting}
|
||||||
|
onClick={() => suggestCriteria(form, setForm)}
|
||||||
|
>
|
||||||
|
{suggesting
|
||||||
|
? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> Generating...</>
|
||||||
|
: <><Sparkles className="h-3.5 w-3.5 mr-1.5" /> Generate with AI</>
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{suggesting ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 gap-2 text-muted-foreground rounded-lg border border-dashed border-violet-300 bg-violet-50/50">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-violet-500" />
|
||||||
|
<p className="text-sm font-medium">AI is generating criteria with band descriptors...</p>
|
||||||
|
<p className="text-xs">Based on {form.skill} / {form.exam_type.replace(/_/g, " ")}</p>
|
||||||
|
</div>
|
||||||
|
) : form.criteria.length === 0 ? (
|
||||||
|
<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 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">
|
||||||
|
{form.criteria.map((c, ci) => (
|
||||||
|
<Collapsible key={ci} defaultOpen={form.criteria.length <= 3}>
|
||||||
|
<div className="rounded-lg border bg-card">
|
||||||
|
<div className="flex items-center gap-2 p-3">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<Input
|
||||||
|
value={c.name}
|
||||||
|
onChange={(e) => updateCriterion(setForm, ci, { name: e.target.value })}
|
||||||
|
placeholder="Criterion name"
|
||||||
|
className="flex-1 h-8 font-medium"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={c.weight}
|
||||||
|
onChange={(e) => updateCriterion(setForm, ci, { weight: Number(e.target.value) })}
|
||||||
|
className="w-16 h-8 text-center text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">%</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => setForm((f) => ({ ...f, criteria: f.criteria.filter((_, i) => i !== ci) }))}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="px-3 pb-3 pt-0 border-t">
|
||||||
|
<p className="text-xs text-muted-foreground my-2">Band level descriptors:</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{levelsArr.map((level) => (
|
||||||
|
<div key={level} className="flex gap-2 items-start">
|
||||||
|
<Badge variant="outline" className="mt-1.5 shrink-0 w-9 justify-center text-xs">{level}</Badge>
|
||||||
|
<Textarea
|
||||||
|
value={c.descriptors[level] || ""}
|
||||||
|
onChange={(e) => updateDescriptor(setForm, ci, level, e.target.value)}
|
||||||
|
placeholder={`Performance descriptor for ${level}...`}
|
||||||
|
className="min-h-[40px] h-10 text-sm resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
))}
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -35,25 +648,25 @@ export default function RubricsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<AiCreationAssistant type="rubric" />
|
<AiCreationAssistant type="rubric" />
|
||||||
<Dialog>
|
<Dialog open={createOpen} onOpenChange={(open) => { setCreateOpen(open); if (!open) setCreateForm(emptyForm()); }}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Rubric</Button>
|
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Rubric</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||||
<DialogHeader><DialogTitle>Create Rubric</DialogTitle></DialogHeader>
|
<DialogHeader>
|
||||||
<div className="space-y-4">
|
<DialogTitle>Create Rubric</DialogTitle>
|
||||||
<div className="space-y-2"><Label>Rubric Name</Label><Input placeholder="e.g. IELTS Writing Task 1" /></div>
|
<DialogDescription>Define a scoring rubric. Use AI to generate criteria or add them manually.</DialogDescription>
|
||||||
<div className="space-y-3">
|
</DialogHeader>
|
||||||
<Label>Level Descriptors</Label>
|
{renderForm(createForm, setCreateForm)}
|
||||||
{["A1","A2","B1","B2","C1","C2"].map(level => (
|
<DialogFooter>
|
||||||
<div key={level} className="space-y-1">
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||||
<Label className="text-xs text-muted-foreground">{level}</Label>
|
<Button
|
||||||
<Textarea placeholder={`Descriptor for level ${level}...`} className="h-16" />
|
disabled={!createForm.name.trim() || createForm.criteria.filter((c) => c.name.trim()).length === 0 || createMut.isPending}
|
||||||
</div>
|
onClick={() => createMut.mutate(formToPayload(createForm))}
|
||||||
))}
|
>
|
||||||
</div>
|
{createMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Create"}
|
||||||
<Button className="w-full">Create Rubric</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,51 +685,248 @@ export default function RubricsPage() {
|
|||||||
<TabsTrigger value="groups">Rubric Groups</TabsTrigger>
|
<TabsTrigger value="groups">Rubric Groups</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="rubrics" className="mt-4">
|
<TabsContent value="rubrics" className="mt-4">
|
||||||
<Card className="border-0 shadow-sm">
|
{rubricsQ.isLoading && (
|
||||||
<CardContent className="p-0">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Table>
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
<TableHeader>
|
</div>
|
||||||
<TableRow>
|
)}
|
||||||
<TableHead>Name</TableHead><TableHead>Levels</TableHead><TableHead>Criteria</TableHead><TableHead>Created</TableHead>
|
{rubricsQ.error && (
|
||||||
</TableRow>
|
<Card className="border-destructive">
|
||||||
</TableHeader>
|
<CardContent className="p-4 text-sm text-destructive">
|
||||||
<TableBody>
|
Failed to load rubrics. The backend endpoint may not be available yet.
|
||||||
{rubrics.map((r) => (
|
</CardContent>
|
||||||
<TableRow key={r.id}>
|
</Card>
|
||||||
<TableCell className="font-medium">{r.name}</TableCell>
|
)}
|
||||||
<TableCell><div className="flex gap-1">{r.levels.map(l => <Badge key={l} variant="outline" className="text-xs">{l}</Badge>)}</div></TableCell>
|
{!rubricsQ.isLoading && !rubricsQ.error && filtered.length === 0 && (
|
||||||
<TableCell>{r.criteria}</TableCell>
|
<Card className="border-dashed">
|
||||||
<TableCell>{r.created}</TableCell>
|
<CardContent className="p-8 text-center text-muted-foreground">
|
||||||
|
No rubrics found. Create one to get started.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{!rubricsQ.isLoading && filtered.length > 0 && (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Skill</TableHead>
|
||||||
|
<TableHead>Exam Type</TableHead>
|
||||||
|
<TableHead>Levels</TableHead>
|
||||||
|
<TableHead>Criteria</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-[90px]">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{filtered.map((r) => (
|
||||||
</CardContent>
|
<TableRow key={r.id} className="cursor-pointer hover:bg-muted/50" onClick={() => openEdit(r)}>
|
||||||
</Card>
|
<TableCell className="font-medium">{r.name}</TableCell>
|
||||||
|
<TableCell><Badge variant="secondary" className="capitalize">{r.skill || "—"}</Badge></TableCell>
|
||||||
|
<TableCell className="capitalize text-sm">{r.exam_type?.replace(/_/g, " ") || "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(r.levels || ALL_CEFR_LEVELS).map((l) => (
|
||||||
|
<Badge key={l} variant="outline" className="text-xs">{l}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{r.criteria}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{r.created}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8"
|
||||||
|
onClick={(e) => { e.stopPropagation(); openEdit(r); }}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"
|
||||||
|
onClick={(e) => { e.stopPropagation(); deleteMut.mutate(r.id); }}
|
||||||
|
disabled={deleteMut.isPending}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="groups" className="mt-4">
|
<TabsContent value="groups" className="mt-4">
|
||||||
<Card className="border-0 shadow-sm">
|
<div className="flex justify-end mb-3">
|
||||||
<CardContent className="p-0">
|
<Button size="sm" onClick={() => { setGroupCreateForm(emptyGroupForm()); setGroupCreateOpen(true); }}>
|
||||||
<Table>
|
<Plus className="h-4 w-4 mr-1" /> Create Group
|
||||||
<TableHeader>
|
</Button>
|
||||||
<TableRow>
|
</div>
|
||||||
<TableHead>Group Name</TableHead><TableHead>Rubrics</TableHead><TableHead>Created</TableHead>
|
{rubricGroupsQ.isLoading && (
|
||||||
</TableRow>
|
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
|
||||||
</TableHeader>
|
)}
|
||||||
<TableBody>
|
{!rubricGroupsQ.isLoading && rubricGroups.length === 0 && (
|
||||||
{rubricGroups.map((g) => (
|
<Card className="border-dashed"><CardContent className="p-8 text-center text-muted-foreground text-sm">No rubric groups yet. Create one to bundle rubrics together.</CardContent></Card>
|
||||||
<TableRow key={g.id}>
|
)}
|
||||||
<TableCell className="font-medium">{g.name}</TableCell>
|
{!rubricGroupsQ.isLoading && rubricGroups.length > 0 && (
|
||||||
<TableCell><div className="flex gap-1 flex-wrap">{g.rubrics.map(r => <Badge key={r} variant="secondary" className="text-xs">{r}</Badge>)}</div></TableCell>
|
<Card className="border-0 shadow-sm">
|
||||||
<TableCell>{g.created}</TableCell>
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Group Name</TableHead>
|
||||||
|
<TableHead>Rubrics</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-24 text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{rubricGroups.map((g) => (
|
||||||
</CardContent>
|
<TableRow key={g.id}>
|
||||||
</Card>
|
<TableCell className="font-medium">{g.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{(g.rubric_names ?? []).map((rb) => (
|
||||||
|
<Badge key={rb} variant="secondary" className="text-xs">{rb}</Badge>
|
||||||
|
))}
|
||||||
|
{(!g.rubric_names || g.rubric_names.length === 0) && (
|
||||||
|
<span className="text-xs text-muted-foreground italic">No rubrics</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{g.created}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => {
|
||||||
|
setGroupEditTarget({ id: g.id, name: g.name, rubric_ids: g.rubric_ids });
|
||||||
|
setGroupEditForm({ name: g.name, rubric_ids: new Set(g.rubric_ids) });
|
||||||
|
}}>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => { if (confirm(`Delete group "${g.name}"?`)) deleteGroupMut.mutate(g.id); }}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Group Dialog */}
|
||||||
|
<Dialog open={groupCreateOpen} onOpenChange={setGroupCreateOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Rubric Group</DialogTitle>
|
||||||
|
<DialogDescription>Bundle multiple rubrics into a group for easy selection.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Group Name <span className="text-destructive">*</span></Label>
|
||||||
|
<Input value={groupCreateForm.name} onChange={(e) => setGroupCreateForm((p) => ({ ...p, name: e.target.value }))} placeholder="e.g. Academic IELTS Full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Select Rubrics</Label>
|
||||||
|
<div className="max-h-48 overflow-y-auto border rounded-md p-2 space-y-1">
|
||||||
|
{rubrics.map((r) => (
|
||||||
|
<label key={r.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
||||||
|
<Checkbox checked={groupCreateForm.rubric_ids.has(r.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setGroupCreateForm((p) => {
|
||||||
|
const next = new Set(p.rubric_ids);
|
||||||
|
checked ? next.add(r.id) : next.delete(r.id);
|
||||||
|
return { ...p, rubric_ids: next };
|
||||||
|
});
|
||||||
|
}} className="h-3.5 w-3.5" />
|
||||||
|
{r.name}
|
||||||
|
<Badge variant="outline" className="text-[10px] ml-auto">{r.skill}</Badge>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{rubrics.length === 0 && <p className="text-xs text-muted-foreground italic py-2 text-center">No rubrics available</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setGroupCreateOpen(false)}>Cancel</Button>
|
||||||
|
<Button disabled={!groupCreateForm.name.trim() || createGroupMut.isPending}
|
||||||
|
onClick={() => createGroupMut.mutate({ name: groupCreateForm.name.trim(), rubric_ids: [...groupCreateForm.rubric_ids] })}>
|
||||||
|
{createGroupMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Create Group"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit Group Dialog */}
|
||||||
|
<Dialog open={!!groupEditTarget} onOpenChange={(open) => { if (!open) setGroupEditTarget(null); }}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Rubric Group</DialogTitle>
|
||||||
|
<DialogDescription>Update the group name and selected rubrics.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Group Name <span className="text-destructive">*</span></Label>
|
||||||
|
<Input value={groupEditForm.name} onChange={(e) => setGroupEditForm((p) => ({ ...p, name: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Select Rubrics</Label>
|
||||||
|
<div className="max-h-48 overflow-y-auto border rounded-md p-2 space-y-1">
|
||||||
|
{rubrics.map((r) => (
|
||||||
|
<label key={r.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
||||||
|
<Checkbox checked={groupEditForm.rubric_ids.has(r.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setGroupEditForm((p) => {
|
||||||
|
const next = new Set(p.rubric_ids);
|
||||||
|
checked ? next.add(r.id) : next.delete(r.id);
|
||||||
|
return { ...p, rubric_ids: next };
|
||||||
|
});
|
||||||
|
}} className="h-3.5 w-3.5" />
|
||||||
|
{r.name}
|
||||||
|
<Badge variant="outline" className="text-[10px] ml-auto">{r.skill}</Badge>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setGroupEditTarget(null)}>Cancel</Button>
|
||||||
|
<Button disabled={!groupEditForm.name.trim() || updateGroupMut.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!groupEditTarget) return;
|
||||||
|
updateGroupMut.mutate({ id: groupEditTarget.id, data: { name: groupEditForm.name.trim(), rubric_ids: [...groupEditForm.rubric_ids] } });
|
||||||
|
}}>
|
||||||
|
{updateGroupMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Saving...</> : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
<Dialog open={!!editTarget} onOpenChange={(open) => { if (!open) setEditTarget(null); }}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Rubric</DialogTitle>
|
||||||
|
<DialogDescription>Update the rubric details below. Use AI to regenerate criteria.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{renderForm(editForm, setEditForm)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditTarget(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!editForm.name.trim() || editForm.criteria.filter((c) => c.name.trim()).length === 0 || updateMut.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!editTarget) return;
|
||||||
|
updateMut.mutate({ id: editTarget.id, data: formToPayload(editForm) });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{updateMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Saving...</> : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,142 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import {
|
||||||
import { Plus, Trash2, Copy } from "lucide-react";
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Plus, Trash2, Copy, Loader2 } from "lucide-react";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
const codes = [
|
import {
|
||||||
{ code: "ENCOACH-2025-A1B2", type: "Single", used: false, created: "2025-01-10" },
|
platformSettingsService,
|
||||||
{ code: "ENCOACH-2025-C3D4", type: "Single", used: true, created: "2025-01-12" },
|
type GradingConfig,
|
||||||
{ code: "BATCH-2025-E5F6", type: "Batch", used: false, created: "2025-02-01" },
|
type PlatformPackage,
|
||||||
{ code: "BATCH-2025-G7H8", type: "Batch", used: false, created: "2025-02-01" },
|
type RegistrationCode,
|
||||||
];
|
} from "@/services/platformSettings.service";
|
||||||
|
|
||||||
const packages = [
|
|
||||||
{ id: 1, name: "IELTS Starter", price: 99, duration: "1 month", discount: 0 },
|
|
||||||
{ id: 2, name: "IELTS Pro", price: 249, duration: "3 months", discount: 15 },
|
|
||||||
{ id: 3, name: "Corporate Bundle", price: 1999, duration: "12 months", discount: 25 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
// ── Codes ──────────────────────────────────────────────────────────
|
||||||
|
const codesQ = useQuery({
|
||||||
|
queryKey: ["codes"],
|
||||||
|
queryFn: () => platformSettingsService.listCodes(),
|
||||||
|
});
|
||||||
|
const codes: RegistrationCode[] = codesQ.data?.items ?? [];
|
||||||
|
|
||||||
|
const genMut = useMutation({
|
||||||
|
mutationFn: platformSettingsService.generateCodes,
|
||||||
|
onSuccess: (r) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["codes"] });
|
||||||
|
toast({ title: `Generated ${r.count} code(s)` });
|
||||||
|
},
|
||||||
|
onError: () =>
|
||||||
|
toast({ title: "Failed to generate codes", variant: "destructive" }),
|
||||||
|
});
|
||||||
|
const delCodeMut = useMutation({
|
||||||
|
mutationFn: platformSettingsService.deleteCode,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["codes"] });
|
||||||
|
toast({ title: "Code deleted" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Packages ───────────────────────────────────────────────────────
|
||||||
|
const pkgQ = useQuery({
|
||||||
|
queryKey: ["packages"],
|
||||||
|
queryFn: () => platformSettingsService.listPackages(),
|
||||||
|
});
|
||||||
|
const packages: PlatformPackage[] = pkgQ.data?.items ?? [];
|
||||||
|
const [pkgOpen, setPkgOpen] = useState(false);
|
||||||
|
const [pkgForm, setPkgForm] = useState<Omit<PlatformPackage, "id">>({
|
||||||
|
name: "",
|
||||||
|
price: 0,
|
||||||
|
duration: "1 month",
|
||||||
|
discount: 0,
|
||||||
|
});
|
||||||
|
const createPkgMut = useMutation({
|
||||||
|
mutationFn: platformSettingsService.createPackage,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["packages"] });
|
||||||
|
setPkgOpen(false);
|
||||||
|
setPkgForm({ name: "", price: 0, duration: "1 month", discount: 0 });
|
||||||
|
toast({ title: "Package created" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const delPkgMut = useMutation({
|
||||||
|
mutationFn: platformSettingsService.deletePackage,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["packages"] });
|
||||||
|
toast({ title: "Package deleted" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Grading config ─────────────────────────────────────────────────
|
||||||
|
const gradingQ = useQuery({
|
||||||
|
queryKey: ["grading-config"],
|
||||||
|
queryFn: () => platformSettingsService.getGrading(),
|
||||||
|
});
|
||||||
|
const [grading, setGrading] = useState<GradingConfig>({
|
||||||
|
min_score: 0,
|
||||||
|
max_score: 9,
|
||||||
|
increment: 0.5,
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (gradingQ.data) {
|
||||||
|
setGrading({
|
||||||
|
min_score: gradingQ.data.min_score,
|
||||||
|
max_score: gradingQ.data.max_score,
|
||||||
|
increment: gradingQ.data.increment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [gradingQ.data]);
|
||||||
|
const saveGradingMut = useMutation({
|
||||||
|
mutationFn: platformSettingsService.setGrading,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["grading-config"] });
|
||||||
|
toast({ title: "Grading configuration saved" });
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const msg =
|
||||||
|
err && typeof err === "object" && "message" in err
|
||||||
|
? String((err as { message: unknown }).message)
|
||||||
|
: "Failed to save";
|
||||||
|
toast({ title: msg, variant: "destructive" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
||||||
<p className="text-muted-foreground">Manage codes, packages, discounts, and grading system.</p>
|
<p className="text-muted-foreground">
|
||||||
|
Manage registration codes, packages, and the grading scale.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="codes">
|
<Tabs defaultValue="codes">
|
||||||
@@ -37,26 +146,107 @@ export default function SettingsPage() {
|
|||||||
<TabsTrigger value="grading">Grading System</TabsTrigger>
|
<TabsTrigger value="grading">Grading System</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
{/* CODES */}
|
||||||
<TabsContent value="codes" className="mt-4 space-y-4">
|
<TabsContent value="codes" className="mt-4 space-y-4">
|
||||||
<AiTipBanner tip="2 batch codes have been unused for over 30 days. Consider sending reminder emails to the assigned entities or recycling unused codes." variant="insight" />
|
<AiTipBanner context="settings-codes" variant="insight" />
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Generate Single</Button>
|
<Button
|
||||||
<Button size="sm" variant="outline"><Copy className="h-4 w-4 mr-1" /> Generate Batch</Button>
|
size="sm"
|
||||||
|
disabled={genMut.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
genMut.mutate({
|
||||||
|
count: 1,
|
||||||
|
code_type: "individual",
|
||||||
|
user_type: "student",
|
||||||
|
max_uses: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{genMut.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
)}{" "}
|
||||||
|
Generate Single
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={genMut.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
genMut.mutate({
|
||||||
|
count: 10,
|
||||||
|
code_type: "corporate",
|
||||||
|
user_type: "corporate",
|
||||||
|
max_uses: 50,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4 mr-1" /> Generate Batch (10)
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow><TableHead>Code</TableHead><TableHead>Type</TableHead><TableHead>Used</TableHead><TableHead>Created</TableHead><TableHead className="w-10"></TableHead></TableRow>
|
<TableRow>
|
||||||
|
<TableHead>Code</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Uses</TableHead>
|
||||||
|
<TableHead>State</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-10"></TableHead>
|
||||||
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
{codesQ.isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{!codesQ.isLoading && codes.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={7}
|
||||||
|
className="text-center text-muted-foreground py-8"
|
||||||
|
>
|
||||||
|
No codes yet. Click Generate Single to create one.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
{codes.map((c) => (
|
{codes.map((c) => (
|
||||||
<TableRow key={c.code}>
|
<TableRow key={c.id}>
|
||||||
<TableCell className="font-mono text-xs">{c.code}</TableCell>
|
<TableCell className="font-mono text-xs">{c.code}</TableCell>
|
||||||
<TableCell><Badge variant="outline">{c.type}</Badge></TableCell>
|
<TableCell>
|
||||||
<TableCell><Badge variant={c.used ? "secondary" : "default"}>{c.used ? "Used" : "Available"}</Badge></TableCell>
|
<Badge variant="outline">{c.code_type}</Badge>
|
||||||
<TableCell>{c.created}</TableCell>
|
</TableCell>
|
||||||
<TableCell><Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"><Trash2 className="h-4 w-4" /></Button></TableCell>
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{c.user_type}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{c.uses}/{c.max_uses}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={c.used ? "secondary" : "default"}>
|
||||||
|
{c.used ? "Used" : "Available"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{c.created ? new Date(c.created).toLocaleDateString() : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive"
|
||||||
|
onClick={() => delCodeMut.mutate(c.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -65,35 +255,186 @@ export default function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* PACKAGES */}
|
||||||
<TabsContent value="packages" className="mt-4 space-y-4">
|
<TabsContent value="packages" className="mt-4 space-y-4">
|
||||||
<AiTipBanner tip="Based on conversion data, the IELTS Pro package has the highest ROI. Consider increasing the Corporate Bundle discount to 30% to boost enterprise sign-ups." variant="recommendation" />
|
<AiTipBanner context="settings-packages" variant="recommendation" />
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" onClick={() => setPkgOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" /> Add Package
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{pkgQ.isLoading && <Loader2 className="h-5 w-5 animate-spin" />}
|
||||||
{packages.map((p) => (
|
{packages.map((p) => (
|
||||||
<Card key={p.id} className="border-0 shadow-sm">
|
<Card key={p.id} className="border-0 shadow-sm">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3 flex-row items-start justify-between">
|
||||||
<CardTitle className="text-base">{p.name}</CardTitle>
|
<CardTitle className="text-base">{p.name}</CardTitle>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
onClick={() => delPkgMut.mutate(p.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
<p className="text-2xl font-bold">${p.price}</p>
|
<p className="text-2xl font-bold">${p.price}</p>
|
||||||
<p className="text-sm text-muted-foreground">{p.duration}</p>
|
<p className="text-sm text-muted-foreground">{p.duration}</p>
|
||||||
{p.discount > 0 && <Badge variant="default">{p.discount}% OFF</Badge>}
|
{p.discount > 0 && (
|
||||||
|
<Badge variant="default">{p.discount}% OFF</Badge>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={pkgOpen} onOpenChange={setPkgOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New Package</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input
|
||||||
|
value={pkgForm.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPkgForm((f) => ({ ...f, name: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Price (USD)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={pkgForm.price}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPkgForm((f) => ({
|
||||||
|
...f,
|
||||||
|
price: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Discount %</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={pkgForm.discount}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPkgForm((f) => ({
|
||||||
|
...f,
|
||||||
|
discount: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Duration</Label>
|
||||||
|
<Select
|
||||||
|
value={pkgForm.duration}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setPkgForm((f) => ({ ...f, duration: v }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1 month">1 month</SelectItem>
|
||||||
|
<SelectItem value="3 months">3 months</SelectItem>
|
||||||
|
<SelectItem value="6 months">6 months</SelectItem>
|
||||||
|
<SelectItem value="12 months">12 months</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setPkgOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!pkgForm.name || createPkgMut.isPending}
|
||||||
|
onClick={() => createPkgMut.mutate(pkgForm)}
|
||||||
|
>
|
||||||
|
{createPkgMut.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> Creating…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Create"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* GRADING */}
|
||||||
<TabsContent value="grading" className="mt-4 space-y-4">
|
<TabsContent value="grading" className="mt-4 space-y-4">
|
||||||
<AiTipBanner tip="Current 0.5 increment scoring aligns with official IELTS band scoring. AI recommends keeping this configuration for standardised assessment." variant="tip" />
|
<AiTipBanner context="settings-grading" variant="tip" />
|
||||||
<Card className="border-0 shadow-sm max-w-lg">
|
<Card className="border-0 shadow-sm max-w-lg">
|
||||||
<CardHeader><CardTitle className="text-base">Scoring Scale</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Scoring Scale</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-2"><Label>Min Score</Label><Input type="number" defaultValue="0" /></div>
|
<div className="space-y-2">
|
||||||
<div className="space-y-2"><Label>Max Score</Label><Input type="number" defaultValue="9" /></div>
|
<Label>Min Score</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={grading.min_score}
|
||||||
|
onChange={(e) =>
|
||||||
|
setGrading((g) => ({
|
||||||
|
...g,
|
||||||
|
min_score: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Max Score</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={grading.max_score}
|
||||||
|
onChange={(e) =>
|
||||||
|
setGrading((g) => ({
|
||||||
|
...g,
|
||||||
|
max_score: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2"><Label>Score Increment</Label><Input type="number" defaultValue="0.5" step="0.5" /></div>
|
<div className="space-y-2">
|
||||||
<Button>Save Grading Configuration</Button>
|
<Label>Score Increment</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
value={grading.increment}
|
||||||
|
onChange={(e) =>
|
||||||
|
setGrading((g) => ({
|
||||||
|
...g,
|
||||||
|
increment: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
disabled={saveGradingMut.isPending}
|
||||||
|
onClick={() => saveGradingMut.mutate(grading)}
|
||||||
|
>
|
||||||
|
{saveGradingMut.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> Saving…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save Grading Configuration"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -1,133 +1,266 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, PieChart, Pie, Cell } from "recharts";
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
} from "recharts";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
import AiReportNarrative from "@/components/ai/AiReportNarrative";
|
import AiReportNarrative from "@/components/ai/AiReportNarrative";
|
||||||
|
import { reportsService } from "@/services/reports.service";
|
||||||
const tabNarratives: Record<string, string> = {
|
|
||||||
overview: "Writing scores (61%) are significantly lower than other modules. Consider allocating more teaching resources to writing workshops. Reading leads at 72%, suggesting current materials are effective.",
|
|
||||||
trends: "Scores have shown a consistent upward trend of +14 points over 6 months. The plateau in April correlates with mid-term exam stress. June's 72% is the highest recorded average this year.",
|
|
||||||
distribution: "B1 is the largest cohort at 30%, indicating most students are at intermediate level. Only 3% reach C2 — consider creating more advanced pathways to support progression from C1.",
|
|
||||||
comparison: "Attendance dropped 8% in the second week of March, correlating with the mid-term assignment deadline. Consider spacing deadlines more evenly across the term.",
|
|
||||||
};
|
|
||||||
|
|
||||||
const thresholds = ["0%", "50%", "70%", "90%"];
|
const thresholds = ["0%", "50%", "70%", "90%"];
|
||||||
|
|
||||||
const barData = [
|
|
||||||
{ module: "Reading", score: 72 },
|
|
||||||
{ module: "Listening", score: 68 },
|
|
||||||
{ module: "Writing", score: 61 },
|
|
||||||
{ module: "Speaking", score: 65 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const trendData = [
|
|
||||||
{ month: "Jan", avg: 58 }, { month: "Feb", avg: 62 }, { month: "Mar", avg: 65 },
|
|
||||||
{ month: "Apr", avg: 64 }, { month: "May", avg: 69 }, { month: "Jun", avg: 72 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const distData = [
|
|
||||||
{ name: "A1", value: 15, color: "hsl(0, 72%, 51%)" },
|
|
||||||
{ name: "A2", value: 22, color: "hsl(38, 92%, 50%)" },
|
|
||||||
{ name: "B1", value: 30, color: "hsl(199, 89%, 48%)" },
|
|
||||||
{ name: "B2", value: 20, color: "hsl(243, 75%, 59%)" },
|
|
||||||
{ name: "C1", value: 10, color: "hsl(142, 71%, 45%)" },
|
|
||||||
{ name: "C2", value: 3, color: "hsl(280, 65%, 50%)" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function StatsCorporatePage() {
|
export default function StatsCorporatePage() {
|
||||||
const [threshold, setThreshold] = useState("0%");
|
const [threshold, setThreshold] = useState("0%");
|
||||||
|
const [entityId, setEntityId] = useState("all");
|
||||||
|
|
||||||
|
const { data: filters } = useQuery({
|
||||||
|
queryKey: ["reports-filters"],
|
||||||
|
queryFn: () => reportsService.filters(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["stats-corporate", threshold, entityId],
|
||||||
|
queryFn: () =>
|
||||||
|
reportsService.statsCorporate({
|
||||||
|
entity_id: entityId === "all" ? undefined : Number(entityId),
|
||||||
|
threshold: Number(threshold.replace("%", "")),
|
||||||
|
months: 6,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const barData = data?.by_module ?? [];
|
||||||
|
const trendData = data?.trend ?? [];
|
||||||
|
const distData = data?.distribution ?? [];
|
||||||
|
const comparison = data?.comparison ?? [];
|
||||||
|
const attemptsConsidered = data?.meta.attempts_considered ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Corporate Statistics</h1>
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
<p className="text-muted-foreground">Entity-level performance analytics and reports.</p>
|
Corporate Statistics
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Entity-level performance analytics and reports.
|
||||||
|
{data && (
|
||||||
|
<span className="ml-2 text-xs">
|
||||||
|
({attemptsConsidered} scored attempts)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{thresholds.map(t => (
|
{thresholds.map((t) => (
|
||||||
<Button key={t} variant={threshold === t ? "default" : "outline"} size="sm" onClick={() => setThreshold(t)}>{t}</Button>
|
<Button
|
||||||
|
key={t}
|
||||||
|
variant={threshold === t ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setThreshold(t)}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="All Entities" /></SelectTrigger>
|
<Select value={entityId} onValueChange={setEntityId}>
|
||||||
<SelectContent><SelectItem value="all">All</SelectItem><SelectItem value="acme">Acme Corp</SelectItem></SelectContent>
|
<SelectTrigger className="w-[180px]">
|
||||||
</Select>
|
<SelectValue placeholder="All Entities" />
|
||||||
<Select><SelectTrigger className="w-[180px]"><SelectValue placeholder="All Assignments" /></SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent><SelectItem value="all">All Assignments</SelectItem></SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Entities</SelectItem>
|
||||||
|
{filters?.entities.map((e) => (
|
||||||
|
<SelectItem key={e.id} value={String(e.id)}>
|
||||||
|
{e.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="overview">
|
{isLoading ? (
|
||||||
<TabsList>
|
<div className="flex items-center justify-center p-12 text-muted-foreground">
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Aggregating
|
||||||
<TabsTrigger value="trends">Trends</TabsTrigger>
|
attempts...
|
||||||
<TabsTrigger value="distribution">Distribution</TabsTrigger>
|
</div>
|
||||||
<TabsTrigger value="comparison">Comparison</TabsTrigger>
|
) : attemptsConsidered === 0 ? (
|
||||||
</TabsList>
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="p-8 text-center text-muted-foreground">
|
||||||
|
No scored attempts match this threshold / entity combination yet.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Tabs defaultValue="overview">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
|
<TabsTrigger value="trends">Trends</TabsTrigger>
|
||||||
|
<TabsTrigger value="distribution">Distribution</TabsTrigger>
|
||||||
|
<TabsTrigger value="comparison">Comparison</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="overview" className="mt-4">
|
<TabsContent value="overview" className="mt-4">
|
||||||
<AiReportNarrative narrative={tabNarratives.overview} />
|
<AiReportNarrative
|
||||||
<Card className="border-0 shadow-sm">
|
report_type="corporate-overview"
|
||||||
<CardHeader><CardTitle className="text-base">Average Score by Module</CardTitle></CardHeader>
|
data={{ modules: barData }}
|
||||||
<CardContent>
|
/>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<Card className="border-0 shadow-sm">
|
||||||
<BarChart data={barData}>
|
<CardHeader>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(220, 16%, 90%)" />
|
<CardTitle className="text-base">
|
||||||
<XAxis dataKey="module" />
|
Average Score by Module
|
||||||
<YAxis domain={[0, 100]} />
|
</CardTitle>
|
||||||
<Tooltip />
|
</CardHeader>
|
||||||
<Bar dataKey="score" fill="hsl(243, 75%, 59%)" radius={[4, 4, 0, 0]} />
|
<CardContent>
|
||||||
</BarChart>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
</ResponsiveContainer>
|
<BarChart data={barData}>
|
||||||
</CardContent>
|
<CartesianGrid
|
||||||
</Card>
|
strokeDasharray="3 3"
|
||||||
</TabsContent>
|
stroke="hsl(220, 16%, 90%)"
|
||||||
|
/>
|
||||||
|
<XAxis dataKey="module" />
|
||||||
|
<YAxis domain={[0, 100]} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar
|
||||||
|
dataKey="score"
|
||||||
|
fill="hsl(243, 75%, 59%)"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="trends" className="mt-4">
|
<TabsContent value="trends" className="mt-4">
|
||||||
<AiReportNarrative narrative={tabNarratives.trends} />
|
<AiReportNarrative
|
||||||
<Card className="border-0 shadow-sm">
|
report_type="corporate-trends"
|
||||||
<CardHeader><CardTitle className="text-base">Score Trend Over Time</CardTitle></CardHeader>
|
data={{ trends: trendData }}
|
||||||
<CardContent>
|
/>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<Card className="border-0 shadow-sm">
|
||||||
<LineChart data={trendData}>
|
<CardHeader>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(220, 16%, 90%)" />
|
<CardTitle className="text-base">
|
||||||
<XAxis dataKey="month" />
|
Score Trend Over Time
|
||||||
<YAxis domain={[0, 100]} />
|
</CardTitle>
|
||||||
<Tooltip />
|
</CardHeader>
|
||||||
<Line type="monotone" dataKey="avg" stroke="hsl(243, 75%, 59%)" strokeWidth={2} dot={{ r: 4 }} />
|
<CardContent>
|
||||||
</LineChart>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
</ResponsiveContainer>
|
<LineChart data={trendData}>
|
||||||
</CardContent>
|
<CartesianGrid
|
||||||
</Card>
|
strokeDasharray="3 3"
|
||||||
</TabsContent>
|
stroke="hsl(220, 16%, 90%)"
|
||||||
|
/>
|
||||||
|
<XAxis dataKey="month" />
|
||||||
|
<YAxis domain={[0, 100]} />
|
||||||
|
<Tooltip />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="avg"
|
||||||
|
stroke="hsl(243, 75%, 59%)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 4 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="distribution" className="mt-4">
|
<TabsContent value="distribution" className="mt-4">
|
||||||
<AiReportNarrative narrative={tabNarratives.distribution} />
|
<AiReportNarrative
|
||||||
<Card className="border-0 shadow-sm">
|
report_type="corporate-distribution"
|
||||||
<CardHeader><CardTitle className="text-base">Level Distribution</CardTitle></CardHeader>
|
data={{ distribution: distData }}
|
||||||
<CardContent className="flex justify-center">
|
/>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<Card className="border-0 shadow-sm">
|
||||||
<PieChart>
|
<CardHeader>
|
||||||
<Pie data={distData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={100} label>
|
<CardTitle className="text-base">Level Distribution</CardTitle>
|
||||||
{distData.map((entry, i) => <Cell key={i} fill={entry.color} />)}
|
</CardHeader>
|
||||||
</Pie>
|
<CardContent className="flex justify-center">
|
||||||
<Tooltip />
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
</PieChart>
|
<PieChart>
|
||||||
</ResponsiveContainer>
|
<Pie
|
||||||
</CardContent>
|
data={distData}
|
||||||
</Card>
|
dataKey="value"
|
||||||
</TabsContent>
|
nameKey="name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={100}
|
||||||
|
label
|
||||||
|
>
|
||||||
|
{distData.map((entry, i) => (
|
||||||
|
<Cell key={i} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="comparison" className="mt-4">
|
<TabsContent value="comparison" className="mt-4">
|
||||||
<AiReportNarrative narrative={tabNarratives.comparison} />
|
<AiReportNarrative
|
||||||
<Card className="border-0 shadow-sm">
|
report_type="corporate-comparison"
|
||||||
<CardContent className="p-8 text-center text-muted-foreground">Entity comparison charts will appear here based on selected filters.</CardContent>
|
data={{ threshold, entities: comparison }}
|
||||||
</Card>
|
/>
|
||||||
</TabsContent>
|
<Card className="border-0 shadow-sm">
|
||||||
</Tabs>
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
Entity Comparison (average band × 10)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{comparison.length === 0 ? (
|
||||||
|
<p className="text-center text-muted-foreground p-8">
|
||||||
|
No entities have attempts above this threshold.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={comparison}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
stroke="hsl(220, 16%, 90%)"
|
||||||
|
/>
|
||||||
|
<XAxis dataKey="entity_name" />
|
||||||
|
<YAxis domain={[0, 100]} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar
|
||||||
|
dataKey="avg"
|
||||||
|
fill="hsl(142, 71%, 45%)"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,270 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import {
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
Select,
|
||||||
import { Switch } from "@/components/ui/switch";
|
SelectContent,
|
||||||
import { Label } from "@/components/ui/label";
|
SelectItem,
|
||||||
import { useState } from "react";
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Loader2, Download } from "lucide-react";
|
||||||
import AiStudyCoach from "@/components/ai/AiStudyCoach";
|
import AiStudyCoach from "@/components/ai/AiStudyCoach";
|
||||||
import AiGradeExplainer from "@/components/ai/AiGradeExplainer";
|
import AiGradeExplainer from "@/components/ai/AiGradeExplainer";
|
||||||
|
import { reportsService } from "@/services/reports.service";
|
||||||
|
|
||||||
const students = [
|
const LEVELS = ["all", "A1", "A2", "B1", "B2", "C1", "C2"] as const;
|
||||||
{ name: "Sarah Johnson", entity: "Acme Corp", reading: 7.5, listening: 8.0, writing: 7.0, speaking: 7.5, overall: 7.5, level: "B2" },
|
|
||||||
{ name: "Ahmed Hassan", entity: "Global Ltd", reading: 5.5, listening: 6.0, writing: 5.0, speaking: 5.5, overall: 5.5, level: "A2" },
|
|
||||||
{ name: "Maria Garcia", entity: "Acme Corp", reading: 8.5, listening: 8.0, writing: 8.0, speaking: 8.5, overall: 8.25, level: "C1" },
|
|
||||||
{ name: "Li Wei", entity: "Tech Co", reading: 6.5, listening: 6.0, writing: 6.0, speaking: 6.5, overall: 6.25, level: "B1" },
|
|
||||||
{ name: "Emma Brown", entity: "Acme Corp", reading: 4.5, listening: 5.0, writing: 4.0, speaking: 4.5, overall: 4.5, level: "A1" },
|
|
||||||
{ name: "John Park", entity: "Global Ltd", reading: 7.0, listening: 7.5, writing: 6.5, speaking: 7.0, overall: 7.0, level: "B2" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function ScoreBadge({ score }: { score: number }) {
|
function ScoreBadge({ score }: { score: number | null }) {
|
||||||
const color = score >= 7.5 ? "bg-success/10 text-success" : score >= 6.0 ? "bg-warning/10 text-warning" : "bg-destructive/10 text-destructive";
|
if (score === null || score === undefined) {
|
||||||
return <span className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold ${color}`}>{score.toFixed(1)}</span>;
|
return <span className="text-muted-foreground">—</span>;
|
||||||
|
}
|
||||||
|
const color =
|
||||||
|
score >= 7.5
|
||||||
|
? "bg-success/10 text-success"
|
||||||
|
: score >= 6.0
|
||||||
|
? "bg-warning/10 text-warning"
|
||||||
|
: "bg-destructive/10 text-destructive";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold ${color}`}
|
||||||
|
>
|
||||||
|
{score.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StudentPerformancePage() {
|
export default function StudentPerformancePage() {
|
||||||
const [showUtilisation, setShowUtilisation] = useState(false);
|
const [entityId, setEntityId] = useState<string>("all");
|
||||||
|
const [level, setLevel] = useState<(typeof LEVELS)[number]>("all");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const { data: filters } = useQuery({
|
||||||
|
queryKey: ["reports-filters"],
|
||||||
|
queryFn: () => reportsService.filters(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["student-performance", entityId, level, search],
|
||||||
|
queryFn: () =>
|
||||||
|
reportsService.studentPerformance({
|
||||||
|
entity_id: entityId === "all" ? undefined : Number(entityId),
|
||||||
|
level: level === "all" ? undefined : level,
|
||||||
|
search: search || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = data?.items ?? [];
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
if (!rows.length) return null;
|
||||||
|
const overall = rows
|
||||||
|
.map((r) => r.overall)
|
||||||
|
.filter((v): v is number => v !== null);
|
||||||
|
const avg = overall.length
|
||||||
|
? overall.reduce((a, b) => a + b, 0) / overall.length
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
total: rows.length,
|
||||||
|
avg: avg !== null ? avg.toFixed(1) : "—",
|
||||||
|
topLevel: rows[0]?.level ?? "—",
|
||||||
|
};
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const exportCsv = () => {
|
||||||
|
const header = [
|
||||||
|
"Student",
|
||||||
|
"Entity",
|
||||||
|
"Level",
|
||||||
|
"Reading",
|
||||||
|
"Listening",
|
||||||
|
"Writing",
|
||||||
|
"Speaking",
|
||||||
|
"Overall",
|
||||||
|
"Attempts",
|
||||||
|
];
|
||||||
|
const lines = rows.map((r) =>
|
||||||
|
[
|
||||||
|
r.student_name,
|
||||||
|
r.entity_name,
|
||||||
|
r.level ?? "",
|
||||||
|
r.reading ?? "",
|
||||||
|
r.listening ?? "",
|
||||||
|
r.writing ?? "",
|
||||||
|
r.speaking ?? "",
|
||||||
|
r.overall ?? "",
|
||||||
|
r.attempts_count,
|
||||||
|
]
|
||||||
|
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
|
||||||
|
.join(","),
|
||||||
|
);
|
||||||
|
const blob = new Blob([[header.join(","), ...lines].join("\n")], {
|
||||||
|
type: "text/csv",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `student-performance-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex items-start justify-between gap-4">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Student Performance</h1>
|
<div>
|
||||||
<p className="text-muted-foreground">Track student scores across all IELTS modules.</p>
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
|
Student Performance
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Track student scores across all IELTS modules.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={exportCsv} disabled={!rows.length}>
|
||||||
|
<Download className="h-4 w-4 mr-1" /> Export CSV
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{summary && (
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Students tracked</p>
|
||||||
|
<p className="text-2xl font-bold">{summary.total}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Average overall band</p>
|
||||||
|
<p className="text-2xl font-bold">{summary.avg}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Top performer level</p>
|
||||||
|
<p className="text-2xl font-bold">{summary.topLevel}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<AiStudyCoach />
|
<AiStudyCoach />
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="All Entities" /></SelectTrigger>
|
<Input
|
||||||
<SelectContent><SelectItem value="all">All Entities</SelectItem><SelectItem value="acme">Acme Corp</SelectItem><SelectItem value="global">Global Ltd</SelectItem></SelectContent>
|
placeholder="Search student..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-[220px]"
|
||||||
|
/>
|
||||||
|
<Select value={entityId} onValueChange={setEntityId}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="All Entities" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Entities</SelectItem>
|
||||||
|
{filters?.entities.map((e) => (
|
||||||
|
<SelectItem key={e.id} value={String(e.id)}>
|
||||||
|
{e.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={level}
|
||||||
|
onValueChange={(v) => setLevel(v as (typeof LEVELS)[number])}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="Level" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{LEVELS.map((l) => (
|
||||||
|
<SelectItem key={l} value={l}>
|
||||||
|
{l === "all" ? "All Levels" : l}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<div className="flex items-center gap-2 ml-auto">
|
|
||||||
<Switch id="util" checked={showUtilisation} onCheckedChange={setShowUtilisation} />
|
|
||||||
<Label htmlFor="util" className="text-sm">Show Utilisation</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<Table>
|
{isLoading ? (
|
||||||
<TableHeader>
|
<div className="flex items-center justify-center p-12 text-muted-foreground">
|
||||||
<TableRow>
|
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Loading
|
||||||
<TableHead>Student</TableHead><TableHead>Entity</TableHead><TableHead>Level</TableHead>
|
performance data...
|
||||||
<TableHead className="text-center">Reading</TableHead><TableHead className="text-center">Listening</TableHead>
|
</div>
|
||||||
<TableHead className="text-center">Writing</TableHead><TableHead className="text-center">Speaking</TableHead>
|
) : rows.length === 0 ? (
|
||||||
<TableHead className="text-center">Overall</TableHead>
|
<div className="p-8 text-center text-muted-foreground">
|
||||||
<TableHead className="w-10">AI</TableHead>
|
No completed attempts match these filters yet.
|
||||||
</TableRow>
|
</div>
|
||||||
</TableHeader>
|
) : (
|
||||||
<TableBody>
|
<Table>
|
||||||
{students.map((s) => (
|
<TableHeader>
|
||||||
<TableRow key={s.name}>
|
<TableRow>
|
||||||
<TableCell className="font-medium">{s.name}</TableCell>
|
<TableHead>Student</TableHead>
|
||||||
<TableCell>{s.entity}</TableCell>
|
<TableHead>Entity</TableHead>
|
||||||
<TableCell><Badge variant="outline">{s.level}</Badge></TableCell>
|
<TableHead>Level</TableHead>
|
||||||
<TableCell className="text-center"><ScoreBadge score={s.reading} /></TableCell>
|
<TableHead className="text-center">Reading</TableHead>
|
||||||
<TableCell className="text-center"><ScoreBadge score={s.listening} /></TableCell>
|
<TableHead className="text-center">Listening</TableHead>
|
||||||
<TableCell className="text-center"><ScoreBadge score={s.writing} /></TableCell>
|
<TableHead className="text-center">Writing</TableHead>
|
||||||
<TableCell className="text-center"><ScoreBadge score={s.speaking} /></TableCell>
|
<TableHead className="text-center">Speaking</TableHead>
|
||||||
<TableCell className="text-center"><ScoreBadge score={s.overall} /></TableCell>
|
<TableHead className="text-center">Overall</TableHead>
|
||||||
<TableCell><AiGradeExplainer studentName={s.name} /></TableCell>
|
<TableHead className="text-center">Attempts</TableHead>
|
||||||
|
<TableHead className="w-10">AI</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{rows.map((r) => (
|
||||||
|
<TableRow key={r.student_id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{r.student_name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{r.entity_name || "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{r.level ? (
|
||||||
|
<Badge variant="outline">{r.level}</Badge>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<ScoreBadge score={r.reading} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<ScoreBadge score={r.listening} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<ScoreBadge score={r.writing} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<ScoreBadge score={r.speaking} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<ScoreBadge score={r.overall} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-sm text-muted-foreground">
|
||||||
|
{r.attempts_count}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<AiGradeExplainer studentName={r.student_name} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default function TicketsPage() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const tickets: Ticket[] = data?.data ?? [];
|
const tickets: Ticket[] = data?.items ?? data?.data ?? [];
|
||||||
|
|
||||||
const createMut = useMutation({
|
const createMut = useMutation({
|
||||||
mutationFn: ticketsService.create,
|
mutationFn: ticketsService.create,
|
||||||
|
|||||||
@@ -3,151 +3,153 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Search, Plus, Download, MoreHorizontal } from "lucide-react";
|
import { Search, Plus, Download, Shield, Pencil, UserCog } from "lucide-react";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useStudents, useTeachers, useCreateStudent, useCreateTeacher } from "@/hooks/queries";
|
import { api } from "@/lib/api-client";
|
||||||
import { lmsService } from "@/services/lms.service";
|
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { ApiError } from "@/lib/api-client";
|
|
||||||
|
|
||||||
function messageFromCreateError(err: unknown): string {
|
interface PlatformUser {
|
||||||
if (err instanceof ApiError && err.data && typeof err.data === "object" && err.data !== null && "error" in err.data) {
|
id: number;
|
||||||
const e = (err.data as { error: unknown }).error;
|
name: string;
|
||||||
if (e != null && String(e).trim()) return String(e);
|
email: string;
|
||||||
}
|
login: string;
|
||||||
if (err instanceof Error) return err.message;
|
user_type: string;
|
||||||
return "Something went wrong.";
|
active: boolean;
|
||||||
|
phone: string;
|
||||||
|
role_ids: number[];
|
||||||
|
roles: { id: number; name: string }[];
|
||||||
|
effective_permission_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toastForCreateUserError(err: unknown): { title: string; description: string; variant?: "default" | "destructive" } {
|
interface RoleInfo {
|
||||||
const msg = messageFromCreateError(err).toLowerCase();
|
id: number;
|
||||||
const isDuplicate =
|
name: string;
|
||||||
msg.includes("already exists") ||
|
description: string;
|
||||||
msg.includes("duplicate") ||
|
assigned: boolean;
|
||||||
msg.includes("unique") ||
|
permission_count: number;
|
||||||
msg.includes("already registered");
|
|
||||||
if (isDuplicate) {
|
|
||||||
return {
|
|
||||||
title: "Email already in use",
|
|
||||||
description:
|
|
||||||
messageFromCreateError(err) +
|
|
||||||
" Try another address, or find the existing user in the Students or Teachers tab.",
|
|
||||||
variant: "destructive",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { title: "Could not create user", description: messageFromCreateError(err), variant: "destructive" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
|
admin: "bg-red-100 text-red-800 border-red-200",
|
||||||
|
teacher: "bg-blue-100 text-blue-800 border-blue-200",
|
||||||
|
student: "bg-green-100 text-green-800 border-green-200",
|
||||||
|
user: "bg-gray-100 text-gray-800 border-gray-200",
|
||||||
|
};
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [form, setForm] = useState({ first_name: "", last_name: "", email: "", role: "student", phone: "", gender: "" });
|
const [editUser, setEditUser] = useState<PlatformUser | null>(null);
|
||||||
|
const [rolesUser, setRolesUser] = useState<PlatformUser | null>(null);
|
||||||
|
const [form, setForm] = useState({ name: "", email: "", password: "", phone: "" });
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const studentsQ = useStudents({ search: search || undefined, size: 200 });
|
const usersQ = useQuery({
|
||||||
const teachersQ = useTeachers({ search: search || undefined, size: 200 });
|
queryKey: ["platform-users", search],
|
||||||
const createStudentMut = useCreateStudent();
|
queryFn: async () => {
|
||||||
const createTeacherMut = useCreateTeacher();
|
const params: Record<string, string | number> = { size: 200 };
|
||||||
|
if (search) params.search = search;
|
||||||
const students = studentsQ.data?.items ?? [];
|
const res = await api.get<{ items?: PlatformUser[]; data?: PlatformUser[]; total: number }>("/users/list", params);
|
||||||
const teachers = teachersQ.data?.items ?? [];
|
return (res.items ?? res.data ?? []) as PlatformUser[];
|
||||||
|
|
||||||
const deleteMut = useMutation({
|
|
||||||
mutationFn: async ({ type, id }: { type: "student" | "teacher"; id: number }) => {
|
|
||||||
if (type === "student") return lmsService.deleteStudent?.(id) ?? lmsService.updateStudent(id, { status: "inactive" });
|
|
||||||
return lmsService.updateTeacher?.(id, {}) ?? Promise.resolve();
|
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rolesQ = useQuery({
|
||||||
|
queryKey: ["user-roles-detail", rolesUser?.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!rolesUser) return null;
|
||||||
|
return api.get<{ roles: RoleInfo[]; effective_permissions: unknown[] }>(`/users/${rolesUser.id}/roles`);
|
||||||
|
},
|
||||||
|
enabled: !!rolesUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: (data: { name: string; email: string; password: string; phone?: string }) =>
|
||||||
|
api.post<{ data: PlatformUser }>("/users/create", data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["lms"] });
|
qc.invalidateQueries({ queryKey: ["platform-users"] });
|
||||||
toast({ title: "Done" });
|
setCreateOpen(false);
|
||||||
|
setForm({ name: "", email: "", password: "", phone: "" });
|
||||||
|
toast({ title: "User created" });
|
||||||
},
|
},
|
||||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleCreate() {
|
const updateMut = useMutation({
|
||||||
const email = form.email.trim();
|
mutationFn: (data: { id: number; name?: string; email?: string; phone?: string }) =>
|
||||||
const first_name = form.first_name.trim();
|
api.patch<{ data: PlatformUser }>("/users/update", data),
|
||||||
const last_name = form.last_name.trim();
|
onSuccess: () => {
|
||||||
const emailKey = email.toLowerCase();
|
qc.invalidateQueries({ queryKey: ["platform-users"] });
|
||||||
const dupStudent = students.some((s) => (s.email || "").trim().toLowerCase() === emailKey);
|
setEditUser(null);
|
||||||
const dupTeacher = teachers.some((t) => (t.email || "").trim().toLowerCase() === emailKey);
|
toast({ title: "User updated" });
|
||||||
if (dupStudent || dupTeacher) {
|
},
|
||||||
toast({
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
title: "Email already in use",
|
});
|
||||||
description:
|
|
||||||
"That address matches someone already listed on this page (search may hide them). Use another email or clear search to find the user.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cb = {
|
|
||||||
onSuccess: () => {
|
|
||||||
setCreateOpen(false);
|
|
||||||
resetForm();
|
|
||||||
toast({ title: "User created successfully" });
|
|
||||||
},
|
|
||||||
onError: (err: unknown) => toast(toastForCreateUserError(err)),
|
|
||||||
};
|
|
||||||
if (form.role === "teacher") {
|
|
||||||
createTeacherMut.mutate(
|
|
||||||
{
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
email,
|
|
||||||
phone: form.phone?.trim() || undefined,
|
|
||||||
gender: (form.gender as "male" | "female") || undefined,
|
|
||||||
},
|
|
||||||
cb,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
createStudentMut.mutate(
|
|
||||||
{
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
email,
|
|
||||||
phone: form.phone?.trim() || undefined,
|
|
||||||
gender: form.gender || undefined,
|
|
||||||
create_portal_user: true,
|
|
||||||
},
|
|
||||||
cb,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetForm() {
|
const toggleRoleMut = useMutation({
|
||||||
setForm({ first_name: "", last_name: "", email: "", role: "student", phone: "", gender: "" });
|
mutationFn: ({ userId, roleId }: { userId: number; roleId: number }) =>
|
||||||
}
|
api.post<{ assigned: boolean }>(`/users/${userId}/roles/toggle`, { role_id: roleId }),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["platform-users"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["user-roles-detail"] });
|
||||||
|
const verb = (res as { assigned: boolean }).assigned ? "assigned" : "removed";
|
||||||
|
toast({ title: `Role ${verb}` });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const users = usersQ.data ?? [];
|
||||||
|
const filtered = users.filter((u) => {
|
||||||
|
if (typeFilter !== "all" && u.user_type !== typeFilter) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeCounts = {
|
||||||
|
all: users.length,
|
||||||
|
admin: users.filter((u) => u.user_type === "admin").length,
|
||||||
|
teacher: users.filter((u) => u.user_type === "teacher").length,
|
||||||
|
student: users.filter((u) => u.user_type === "student").length,
|
||||||
|
};
|
||||||
|
|
||||||
function exportCsv() {
|
function exportCsv() {
|
||||||
const rows = [["Name", "Email", "Role", "Status"]];
|
const rows = [["Name", "Email", "Type", "Roles", "Permissions"]];
|
||||||
students.forEach(s => rows.push([s.name, s.email, "Student", s.status || "active"]));
|
users.forEach((u) =>
|
||||||
teachers.forEach(t => rows.push([t.name, t.email, "Teacher", "active"]));
|
rows.push([
|
||||||
const csv = rows.map(r => r.join(",")).join("\n");
|
u.name,
|
||||||
|
u.email,
|
||||||
|
u.user_type,
|
||||||
|
u.roles.map((r) => r.name).join("; "),
|
||||||
|
String(u.effective_permission_count),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const csv = rows.map((r) => r.map((c) => `"${c}"`).join(",")).join("\n");
|
||||||
const blob = new Blob([csv], { type: "text/csv" });
|
const blob = new Blob([csv], { type: "text/csv" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = "users_export.csv";
|
a.download = "platform_users.csv";
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPending = createStudentMut.isPending || createTeacherMut.isPending;
|
const rolesData = rolesQ.data as { roles: RoleInfo[]; effective_permissions: unknown[] } | null;
|
||||||
const loading = studentsQ.isLoading || teachersQ.isLoading;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Users</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Users</h1>
|
||||||
<p className="text-muted-foreground">Manage platform users across all roles.</p>
|
<p className="text-muted-foreground">
|
||||||
|
Manage all platform accounts — admins, teachers, and students.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={exportCsv}>
|
<Button variant="outline" size="sm" onClick={exportCsv}>
|
||||||
@@ -159,177 +161,254 @@ export default function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 items-center">
|
<div className="flex gap-3 items-center flex-wrap">
|
||||||
<div className="relative flex-1 max-w-sm">
|
<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" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input placeholder="Search users..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
<Input placeholder="Search by name..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
<Tabs value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="all">All ({typeCounts.all})</TabsTrigger>
|
||||||
|
<TabsTrigger value="admin">Admins ({typeCounts.admin})</TabsTrigger>
|
||||||
|
<TabsTrigger value="teacher">Teachers ({typeCounts.teacher})</TabsTrigger>
|
||||||
|
<TabsTrigger value="student">Students ({typeCounts.student})</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{usersQ.isLoading ? (
|
||||||
<div className="flex items-center justify-center min-h-[300px]">
|
<div className="flex items-center justify-center min-h-[300px]">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Tabs defaultValue="students">
|
<Card className="border-0 shadow-sm">
|
||||||
<TabsList>
|
<CardContent className="p-0">
|
||||||
<TabsTrigger value="students">Students ({students.length})</TabsTrigger>
|
<Table>
|
||||||
<TabsTrigger value="teachers">Teachers ({teachers.length})</TabsTrigger>
|
<TableHeader>
|
||||||
</TabsList>
|
<TableRow>
|
||||||
<TabsContent value="students" className="mt-4">
|
<TableHead>User</TableHead>
|
||||||
<Card className="border-0 shadow-sm">
|
<TableHead>Type</TableHead>
|
||||||
<CardContent className="p-0">
|
<TableHead>Roles</TableHead>
|
||||||
<Table>
|
<TableHead>Permissions</TableHead>
|
||||||
<TableHeader>
|
<TableHead>Status</TableHead>
|
||||||
<TableRow>
|
<TableHead className="w-[120px]" />
|
||||||
<TableHead>Name</TableHead>
|
</TableRow>
|
||||||
<TableHead>Email</TableHead>
|
</TableHeader>
|
||||||
<TableHead>Phone</TableHead>
|
<TableBody>
|
||||||
<TableHead>Batch</TableHead>
|
{filtered.length === 0 && (
|
||||||
<TableHead>Status</TableHead>
|
<TableRow>
|
||||||
<TableHead>Enrollment</TableHead>
|
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||||
<TableHead className="w-10" />
|
No users found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{filtered.map((u) => {
|
||||||
|
const initials = u.name
|
||||||
|
.split(" ")
|
||||||
|
.map((w) => w[0])
|
||||||
|
.join("")
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase();
|
||||||
|
return (
|
||||||
|
<TableRow key={u.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{u.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{u.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className={`capitalize text-xs ${TYPE_COLORS[u.user_type] || TYPE_COLORS.user}`}>
|
||||||
|
{u.user_type}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{u.roles.length > 0
|
||||||
|
? u.roles.map((r) => (
|
||||||
|
<Badge key={r.id} variant="secondary" className="text-xs">
|
||||||
|
{r.name}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
: <span className="text-xs text-muted-foreground">No roles</span>}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm">{u.effective_permission_count}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={u.active ? "default" : "secondary"}>
|
||||||
|
{u.active ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" title="Edit user" onClick={() => setEditUser(u)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" title="Manage roles" onClick={() => setRolesUser(u)}>
|
||||||
|
<UserCog className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
);
|
||||||
<TableBody>
|
})}
|
||||||
{students.length === 0 && (
|
</TableBody>
|
||||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No students found.</TableCell></TableRow>
|
</Table>
|
||||||
)}
|
</CardContent>
|
||||||
{students.map((s) => (
|
</Card>
|
||||||
<TableRow key={s.id}>
|
|
||||||
<TableCell className="font-medium">{s.name}</TableCell>
|
|
||||||
<TableCell>{s.email}</TableCell>
|
|
||||||
<TableCell>{s.phone || "—"}</TableCell>
|
|
||||||
<TableCell>{s.batch_name || "—"}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={s.status === "active" ? "default" : "secondary"} className="capitalize">{s.status || "active"}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{s.enrollment_date || "—"}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem className="text-destructive" onClick={() => { if (window.confirm(`Deactivate student "${s.name}"?`)) deleteMut.mutate({ type: "student", id: s.id }); }}>
|
|
||||||
Deactivate
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="teachers" className="mt-4">
|
|
||||||
<Card className="border-0 shadow-sm">
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Name</TableHead>
|
|
||||||
<TableHead>Email</TableHead>
|
|
||||||
<TableHead>Phone</TableHead>
|
|
||||||
<TableHead>Department</TableHead>
|
|
||||||
<TableHead>Specialization</TableHead>
|
|
||||||
<TableHead className="w-10" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{teachers.length === 0 && (
|
|
||||||
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">No teachers found.</TableCell></TableRow>
|
|
||||||
)}
|
|
||||||
{teachers.map((t) => (
|
|
||||||
<TableRow key={t.id}>
|
|
||||||
<TableCell className="font-medium">{t.name}</TableCell>
|
|
||||||
<TableCell>{t.email}</TableCell>
|
|
||||||
<TableCell>{t.phone || "—"}</TableCell>
|
|
||||||
<TableCell>{t.department_name || "—"}</TableCell>
|
|
||||||
<TableCell>{t.specialization || "—"}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem className="text-destructive" onClick={() => { if (window.confirm(`Remove teacher "${t.name}"?`)) deleteMut.mutate({ type: "teacher", id: t.id }); }}>
|
|
||||||
Remove
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={createOpen} onOpenChange={(v) => { setCreateOpen(v); if (!v) resetForm(); }}>
|
{/* Create User Dialog */}
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader><DialogTitle>Create New User</DialogTitle></DialogHeader>
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Platform User</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Creates an internal Odoo user (res.users) with login access.
|
||||||
|
For students/teachers, use the LMS pages instead.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<Label>Full Name</Label>
|
||||||
<Label>First Name</Label>
|
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. John Admin" />
|
||||||
<Input value={form.first_name} onChange={(e) => setForm(f => ({ ...f, first_name: e.target.value }))} placeholder="John" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Last Name</Label>
|
|
||||||
<Input value={form.last_name} onChange={(e) => setForm(f => ({ ...f, last_name: e.target.value }))} placeholder="Doe" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Email</Label>
|
<Label>Email (login)</Label>
|
||||||
<Input type="email" value={form.email} onChange={(e) => setForm(f => ({ ...f, email: e.target.value }))} placeholder="john@email.com" />
|
<Input type="email" value={form.email} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} placeholder="admin@encoach.com" />
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Must be unique in Odoo for students and teachers. If create fails, the address may already exist outside the current list.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Phone</Label>
|
<Label>Password</Label>
|
||||||
<Input value={form.phone} onChange={(e) => setForm(f => ({ ...f, phone: e.target.value }))} placeholder="+1234567890" />
|
<Input type="password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} placeholder="Minimum 6 characters" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<Label>Phone (optional)</Label>
|
||||||
<Label>Role</Label>
|
<Input value={form.phone} onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))} placeholder="+1234567890" />
|
||||||
<Select value={form.role} onValueChange={(v) => setForm(f => ({ ...f, role: v }))}>
|
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="student">Student</SelectItem>
|
|
||||||
<SelectItem value="teacher">Teacher</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Gender</Label>
|
|
||||||
<Select value={form.gender} onValueChange={(v) => setForm(f => ({ ...f, gender: v }))}>
|
|
||||||
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="male">Male</SelectItem>
|
|
||||||
<SelectItem value="female">Female</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => { setCreateOpen(false); resetForm(); }}>Cancel</Button>
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={isPending || !form.first_name.trim() || !form.last_name.trim() || !form.email.trim()}
|
disabled={createMut.isPending || !form.name.trim() || !form.email.trim()}
|
||||||
onClick={handleCreate}
|
onClick={() => createMut.mutate({ name: form.name.trim(), email: form.email.trim(), password: form.password || "admin123", phone: form.phone.trim() || undefined })}
|
||||||
>
|
>
|
||||||
{isPending ? "Creating..." : "Create"}
|
{createMut.isPending ? "Creating..." : "Create User"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit User Dialog */}
|
||||||
|
<Dialog open={!!editUser} onOpenChange={(v) => { if (!v) setEditUser(null); }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit User</DialogTitle>
|
||||||
|
<DialogDescription>Update profile for {editUser?.name}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{editUser && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input
|
||||||
|
value={editUser.name}
|
||||||
|
onChange={(e) => setEditUser((u) => u ? { ...u, name: e.target.value } : null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Email</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={editUser.email}
|
||||||
|
onChange={(e) => setEditUser((u) => u ? { ...u, email: e.target.value } : null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Phone</Label>
|
||||||
|
<Input
|
||||||
|
value={editUser.phone || ""}
|
||||||
|
onChange={(e) => setEditUser((u) => u ? { ...u, phone: e.target.value } : null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditUser(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
disabled={updateMut.isPending || !editUser?.name.trim()}
|
||||||
|
onClick={() => {
|
||||||
|
if (!editUser) return;
|
||||||
|
updateMut.mutate({
|
||||||
|
id: editUser.id,
|
||||||
|
name: editUser.name.trim(),
|
||||||
|
email: editUser.email.trim(),
|
||||||
|
phone: editUser.phone?.trim() || undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{updateMut.isPending ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Manage Roles Dialog */}
|
||||||
|
<Dialog open={!!rolesUser} onOpenChange={(v) => { if (!v) setRolesUser(null); }}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="h-5 w-5" />
|
||||||
|
Manage Roles — {rolesUser?.name}
|
||||||
|
</div>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>Toggle roles on/off for this user.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{rolesQ.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
) : rolesData?.roles ? (
|
||||||
|
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||||
|
{rolesData.roles.map((r) => (
|
||||||
|
<label
|
||||||
|
key={r.id}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||||
|
r.assigned ? "border-primary bg-primary/5" : "border-border hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={r.assigned}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
if (rolesUser) toggleRoleMut.mutate({ userId: rolesUser.id, roleId: r.id });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{r.name}</p>
|
||||||
|
{r.description && <p className="text-xs text-muted-foreground">{r.description}</p>}
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-xs shrink-0">
|
||||||
|
{r.permission_count} perms
|
||||||
|
</Badge>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">No roles available.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setRolesUser(null)}>Close</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,96 +1,389 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { CheckCircle, BookA, Sparkles } from "lucide-react";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
Sparkles,
|
||||||
|
Circle,
|
||||||
|
} from "lucide-react";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import {
|
||||||
|
trainingService,
|
||||||
|
type VocabItem,
|
||||||
|
type CefrLevel,
|
||||||
|
} from "@/services/training.service";
|
||||||
|
|
||||||
const vocabItems = [
|
const LEVELS: (CefrLevel | "all")[] = ["all", "A1", "A2", "B1", "B2", "C1", "C2"];
|
||||||
{ word: "Ubiquitous", meaning: "Present, appearing, or found everywhere", level: "C1", completed: true },
|
|
||||||
{ word: "Pragmatic", meaning: "Dealing with things sensibly and realistically", level: "B2", completed: true },
|
|
||||||
{ word: "Eloquent", meaning: "Fluent or persuasive in speaking or writing", level: "C1", completed: false },
|
|
||||||
{ word: "Meticulous", meaning: "Showing great attention to detail", level: "B2", completed: false },
|
|
||||||
{ word: "Ambiguous", meaning: "Open to more than one interpretation", level: "B2", completed: false },
|
|
||||||
{ word: "Coherent", meaning: "Logical and consistent", level: "B1", completed: false },
|
|
||||||
{ word: "Versatile", meaning: "Able to adapt to many different functions", level: "B2", completed: true },
|
|
||||||
{ word: "Concise", meaning: "Giving a lot of information in few words", level: "B1", completed: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function VocabularyPage() {
|
export default function VocabularyPage() {
|
||||||
const [showCompleted, setShowCompleted] = useState(false);
|
const { toast } = useToast();
|
||||||
const completed = vocabItems.filter(v => v.completed).length;
|
const qc = useQueryClient();
|
||||||
const filtered = showCompleted ? vocabItems : vocabItems.filter(v => !v.completed);
|
const [showCompleted, setShowCompleted] = useState(true);
|
||||||
|
const [levelFilter, setLevelFilter] = useState<string>("all");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
word: "",
|
||||||
|
meaning: "",
|
||||||
|
example_sentence: "",
|
||||||
|
level: "B1" as CefrLevel,
|
||||||
|
part_of_speech: "noun" as VocabItem["part_of_speech"],
|
||||||
|
category: "general",
|
||||||
|
});
|
||||||
|
|
||||||
|
const listQ = useQuery({
|
||||||
|
queryKey: ["training-vocab", levelFilter, search],
|
||||||
|
queryFn: () =>
|
||||||
|
trainingService.listVocab({
|
||||||
|
...(levelFilter !== "all" ? { level: levelFilter } : {}),
|
||||||
|
...(search ? { search } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = listQ.data?.items ?? [];
|
||||||
|
const summary = listQ.data?.summary;
|
||||||
|
const filtered = showCompleted ? items : items.filter((v) => !v.completed);
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: trainingService.createVocab,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["training-vocab"] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
setForm({
|
||||||
|
word: "",
|
||||||
|
meaning: "",
|
||||||
|
example_sentence: "",
|
||||||
|
level: "B1",
|
||||||
|
part_of_speech: "noun",
|
||||||
|
category: "general",
|
||||||
|
});
|
||||||
|
toast({ title: "Word added" });
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const msg =
|
||||||
|
err && typeof err === "object" && "message" in err
|
||||||
|
? String((err as { message: unknown }).message)
|
||||||
|
: "Failed to add word";
|
||||||
|
toast({ title: msg, variant: "destructive" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const delMut = useMutation({
|
||||||
|
mutationFn: trainingService.deleteVocab,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["training-vocab"] });
|
||||||
|
toast({ title: "Word deleted" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const progressMut = useMutation({
|
||||||
|
mutationFn: ({ id, completed }: { id: number; completed: boolean }) =>
|
||||||
|
trainingService.setVocabProgress(id, { completed }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["training-vocab"] }),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Vocabulary Training</h1>
|
<div>
|
||||||
<p className="text-muted-foreground">Build your vocabulary for IELTS success.</p>
|
<h1 className="text-2xl font-bold tracking-tight">Vocabulary Training</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Build and manage the vocabulary library — track completion by CEFR level.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" /> Add Word
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AiTipBanner context="vocabulary" variant="recommendation" />
|
<AiTipBanner context="vocabulary" variant="recommendation" />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="lg:col-span-2 space-y-4">
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
{/* Summary */}
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-base">Progress</CardTitle>
|
<CardTitle className="text-base">Progress</CardTitle>
|
||||||
<span className="text-sm text-muted-foreground">{completed}/{vocabItems.length} completed</span>
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{summary
|
||||||
|
? `${summary.completed}/${summary.total} completed (${summary.completion_rate}%)`
|
||||||
|
: "—"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Progress value={(completed / vocabItems.length) * 100} className="h-3" />
|
<Progress value={summary?.completion_rate ?? 0} className="h-3" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
{/* Filters */}
|
||||||
<Switch id="show" checked={showCompleted} onCheckedChange={setShowCompleted} />
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
<Label htmlFor="show" className="text-sm">Show completed</Label>
|
<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 word or meaning…"
|
||||||
|
className="pl-9"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={levelFilter} onValueChange={setLevelFilter}>
|
||||||
|
<SelectTrigger className="w-[110px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{LEVELS.map((l) => (
|
||||||
|
<SelectItem key={l} value={l}>
|
||||||
|
{l === "all" ? "All levels" : l}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch id="show" checked={showCompleted} onCheckedChange={setShowCompleted} />
|
||||||
|
<Label htmlFor="show" className="text-sm">
|
||||||
|
Show completed
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
{listQ.isLoading && (
|
||||||
|
<div className="py-8 text-center text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!listQ.isLoading && filtered.length === 0 && (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="text-center text-muted-foreground py-8">
|
||||||
|
No vocabulary items match.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
{filtered.map((v) => (
|
{filtered.map((v) => (
|
||||||
<Card key={v.word} className="border-0 shadow-sm">
|
<Card key={v.id} className="border-0 shadow-sm">
|
||||||
<CardContent className="p-4 flex items-center justify-between">
|
<CardContent className="p-4 flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<button
|
||||||
{v.completed && <CheckCircle className="h-4 w-4 text-success shrink-0" />}
|
onClick={() =>
|
||||||
<div>
|
progressMut.mutate({ id: v.id, completed: !v.completed })
|
||||||
<p className="font-semibold text-sm">{v.word}</p>
|
}
|
||||||
<p className="text-xs text-muted-foreground">{v.meaning}</p>
|
className="shrink-0 rounded-full p-0.5 hover:bg-muted transition"
|
||||||
</div>
|
aria-label={v.completed ? "Mark incomplete" : "Mark complete"}
|
||||||
|
>
|
||||||
|
{v.completed ? (
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Circle className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-sm">{v.word}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{v.meaning}</p>
|
||||||
|
{v.example_sentence && (
|
||||||
|
<p className="text-xs italic text-muted-foreground mt-0.5 truncate">
|
||||||
|
“{v.example_sentence}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Badge variant="outline">{v.level}</Badge>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{v.part_of_speech}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
onClick={() => delMut.mutate(v.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">{v.level}</Badge>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* AI panel */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-base flex items-center gap-2"><Sparkles className="h-4 w-4 text-primary" /> AI Recommendations</CardTitle>
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-primary" /> AI Recommendations
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Learn "coherent" + "concise" together — they share academic writing context</li>
|
<li className="text-sm text-muted-foreground flex items-start gap-2">
|
||||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Review C1-level vocabulary for IELTS Task 2</li>
|
<span className="text-primary font-bold">•</span>
|
||||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Focus on collocations with 'make' and 'do'</li>
|
Pair <b>coherent</b> + <b>concise</b> — they share academic-writing
|
||||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Try using "meticulous" in your next writing practice</li>
|
contexts.
|
||||||
|
</li>
|
||||||
|
<li className="text-sm text-muted-foreground flex items-start gap-2">
|
||||||
|
<span className="text-primary font-bold">•</span>
|
||||||
|
Review C1 words for IELTS Writing Task 2.
|
||||||
|
</li>
|
||||||
|
<li className="text-sm text-muted-foreground flex items-start gap-2">
|
||||||
|
<span className="text-primary font-bold">•</span>
|
||||||
|
Try using <b>meticulous</b> in your next essay.
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
|
{summary && (
|
||||||
<CardContent className="p-4">
|
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
|
||||||
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-2"><Sparkles className="h-3 w-3" /> AI Vocabulary Goal</p>
|
<CardContent className="p-4">
|
||||||
<p className="text-sm text-muted-foreground">At 2 words/day, you'll complete this set by March 15. AI recommends adding 10 more domain-specific words from your upcoming exam topics.</p>
|
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-2">
|
||||||
</CardContent>
|
<Sparkles className="h-3 w-3" /> AI Vocabulary Goal
|
||||||
</Card>
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{summary.remaining > 0
|
||||||
|
? `At 2 words/day, you'll complete the remaining ${summary.remaining} items in about ${Math.ceil(summary.remaining / 2)} days.`
|
||||||
|
: "You've completed every active word — try adding more to keep challenging yourself."}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Create dialog */}
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Vocabulary Word</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Word</Label>
|
||||||
|
<Input
|
||||||
|
value={form.word}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, word: e.target.value }))}
|
||||||
|
placeholder="e.g. Ubiquitous"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Meaning</Label>
|
||||||
|
<Textarea
|
||||||
|
value={form.meaning}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, meaning: e.target.value }))}
|
||||||
|
placeholder="Plain-English definition"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Example sentence (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={form.example_sentence}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, example_sentence: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Use the word in context"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>CEFR level</Label>
|
||||||
|
<Select
|
||||||
|
value={form.level}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setForm((f) => ({ ...f, level: v as CefrLevel }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(LEVELS.filter((l) => l !== "all") as CefrLevel[]).map(
|
||||||
|
(l) => (
|
||||||
|
<SelectItem key={l} value={l}>
|
||||||
|
{l}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Part of speech</Label>
|
||||||
|
<Select
|
||||||
|
value={form.part_of_speech}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
part_of_speech: v as VocabItem["part_of_speech"],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="noun">noun</SelectItem>
|
||||||
|
<SelectItem value="verb">verb</SelectItem>
|
||||||
|
<SelectItem value="adjective">adjective</SelectItem>
|
||||||
|
<SelectItem value="adverb">adverb</SelectItem>
|
||||||
|
<SelectItem value="phrase">phrase</SelectItem>
|
||||||
|
<SelectItem value="other">other</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Category</Label>
|
||||||
|
<Input
|
||||||
|
value={form.category}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, category: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!form.word.trim() || !form.meaning.trim() || createMut.isPending}
|
||||||
|
onClick={() => createMut.mutate(form)}
|
||||||
|
>
|
||||||
|
{createMut.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> Adding…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Add"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||