Compare commits
33 Commits
v3
...
release-v4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdc5097ce8 | ||
|
|
a0cee77628 | ||
| c9bf12ad8e | |||
|
|
8df6804dcf | ||
|
|
b4b5868223 | ||
|
|
c2a07a6e5c | ||
|
|
01a32e7785 | ||
|
|
724edea349 | ||
|
|
45408acd5c | ||
|
|
bbb2b44ae0 | ||
|
|
dcb7727a4b | ||
|
|
96a2945512 | ||
|
|
a4e92d1f40 | ||
|
|
fbd58fa5a6 | ||
|
|
b02c2e7526 | ||
|
|
9b20d6ce66 | ||
|
|
000969b0b9 | ||
|
|
95938c0079 | ||
|
|
435930a827 | ||
|
|
b78124bb7b | ||
|
|
1d7ebea2af | ||
|
|
2f450a7f26 | ||
|
|
e066c595b9 | ||
|
|
74c2c9f2d2 | ||
|
|
5b2ccbfeec | ||
| d6413a0496 | |||
| 4383db7fa1 | |||
| 6543081011 | |||
|
|
b66a623141 | ||
| 41846a6eb6 | |||
|
|
110a0b7105 | ||
| b04db0b06c | |||
| 3ebcec2b43 |
57
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Deploy Frontend to Staging
|
||||
|
||||
# Triggered on every push to main.
|
||||
# Syncs this repo's source into the backend monorepo's frontend/
|
||||
# directory and rebuilds the encoach-frontend Docker container.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: deploy-frontend
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy frontend to staging
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Sync frontend source to backend repo
|
||||
run: |
|
||||
FRONTEND_SRC="/opt/encoach/encoach_frontend_new_v2"
|
||||
FRONTEND_DEST="/opt/encoach/encoach_backend_new_v2/frontend"
|
||||
|
||||
# Keep a clean clone of this repo on the server
|
||||
if [ -d "$FRONTEND_SRC/.git" ]; then
|
||||
git -C "$FRONTEND_SRC" fetch origin
|
||||
git -C "$FRONTEND_SRC" reset --hard origin/main
|
||||
else
|
||||
git clone http://localhost:3003/devops/encoach_frontend_new_v2.git "$FRONTEND_SRC"
|
||||
fi
|
||||
|
||||
echo "Syncing to backend frontend/ dir..."
|
||||
rsync -a --delete \
|
||||
--exclude '.git' \
|
||||
--exclude 'node_modules' \
|
||||
--exclude 'dist' \
|
||||
--exclude 'docs' \
|
||||
"$FRONTEND_SRC/" "$FRONTEND_DEST/"
|
||||
echo "Latest commit: $(git -C $FRONTEND_SRC log -1 --oneline)"
|
||||
|
||||
- name: Rebuild frontend container
|
||||
run: |
|
||||
cd /opt/encoach/encoach_backend_new_v2
|
||||
docker compose \
|
||||
-f docker-compose.yml \
|
||||
-f /opt/encoach/overrides/encoach.override.yml \
|
||||
up -d --build frontend
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}" | grep encoach-frontend
|
||||
|
||||
- name: Smoke test
|
||||
run: |
|
||||
sleep 5
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/)
|
||||
echo "frontend / => HTTP $STATUS"
|
||||
test "$STATUS" = "200" || exit 1
|
||||
echo "Deploy successful!"
|
||||
14
Dockerfile
@@ -18,13 +18,25 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Default nginx body cap is 1 MB which causes HTTP 413 on:
|
||||
# * resource uploads (PDF / audio / video)
|
||||
# * /api/exam/generation/submit (whole exam with generated questions)
|
||||
# * /api/approval-requests (attachments)
|
||||
# Match Odoo's raised limit in odoo-docker.conf (128 MB).
|
||||
client_max_body_size 128m;
|
||||
client_body_buffer_size 1m;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8069/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
# OpenAI streaming calls + multi-module AI generation can run >3 min.
|
||||
# Keep read/send timeouts above Odoo's limit_time_real (900s).
|
||||
proxy_read_timeout 900s;
|
||||
proxy_send_timeout 900s;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
||||
27
e2e/login.spec.ts
Normal file
@@ -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>
|
||||
<meta name="description" content="EnCoach is a comprehensive IELTS and English learning platform for students, teachers, and corporates." />
|
||||
<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:description" content="Comprehensive IELTS preparation and English learning platform." />
|
||||
<meta property="og:image" content="/logo-icon.png" />
|
||||
<meta property="og:type" content="website" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@300;400;500;600;700;800&family=DM+Sans:wght@400;500;600;700&family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
162
package-lock.json
generated
@@ -42,6 +42,8 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"i18next": "^26.0.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next-themes": "^0.3.0",
|
||||
@@ -49,6 +51,7 @@
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.61.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-resizable-panels": "^2.1.9",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"recharts": "^2.15.4",
|
||||
@@ -60,6 +63,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
@@ -76,6 +80,7 @@
|
||||
"lovable-tagger": "^1.1.13",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-rtl": "^0.9.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.38.0",
|
||||
"vite": "^5.4.19",
|
||||
@@ -905,6 +910,22 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||
@@ -5480,6 +5501,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
|
||||
@@ -5509,6 +5539,46 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.0.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.6.tgz",
|
||||
"integrity": "sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-browser-languagedetector": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
|
||||
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
@@ -6215,6 +6285,53 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.9",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
|
||||
@@ -6547,6 +6664,33 @@
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.4.tgz",
|
||||
"integrity": "sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.0.1",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
@@ -7157,6 +7301,13 @@
|
||||
"tailwindcss": ">=3.0.0 || insiders"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss-rtl": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss-rtl/-/tailwindcss-rtl-0.9.0.tgz",
|
||||
"integrity": "sha512-y7yC8QXjluDBEFMSX33tV6xMYrf0B3sa+tOB5JSQb6/G6laBU313a+Z+qxu55M1Qyn8tDMttjomsA8IsJD+k+w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss/node_modules/postcss-selector-parser": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||
@@ -7369,7 +7520,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -8164,6 +8315,15 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
|
||||
|
||||
11
package.json
@@ -10,7 +10,9 @@
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:install": "playwright install --with-deps chromium"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
@@ -47,6 +49,8 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"i18next": "^26.0.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next-themes": "^0.3.0",
|
||||
@@ -54,6 +58,7 @@
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.61.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-resizable-panels": "^2.1.9",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"recharts": "^2.15.4",
|
||||
@@ -65,9 +70,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/node": "^22.16.5",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
@@ -81,6 +87,7 @@
|
||||
"lovable-tagger": "^1.1.13",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-rtl": "^0.9.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.38.0",
|
||||
"vite": "^5.4.19",
|
||||
|
||||
42
playwright.config.ts
Normal file
@@ -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 |
364
src/App.tsx
@@ -1,3 +1,5 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
@@ -8,143 +10,177 @@ import ProtectedRoute from "@/components/ProtectedRoute";
|
||||
import StudentLayout from "@/components/StudentLayout";
|
||||
import TeacherLayout from "@/components/TeacherLayout";
|
||||
import AdminLmsLayout from "@/components/AdminLmsLayout";
|
||||
import Login from "@/pages/Login";
|
||||
import Register from "@/pages/Register";
|
||||
import EmailVerification from "@/pages/EmailVerification";
|
||||
import OnboardingWizard from "@/pages/OnboardingWizard";
|
||||
import ForgotPassword from "@/pages/ForgotPassword";
|
||||
import ResetPassword from "@/pages/ResetPassword";
|
||||
import ScoreVerification from "@/pages/ScoreVerification";
|
||||
// Original platform pages
|
||||
import AdminDashboard from "@/pages/AdminDashboard";
|
||||
import UsersPage from "@/pages/UsersPage";
|
||||
import EntitiesPage from "@/pages/EntitiesPage";
|
||||
import AssignmentsPage from "@/pages/AssignmentsPage";
|
||||
import ExamsListPage from "@/pages/ExamsListPage";
|
||||
import ExamStructuresPage from "@/pages/ExamStructuresPage";
|
||||
import RubricsPage from "@/pages/RubricsPage";
|
||||
import GenerationPage from "@/pages/GenerationPage";
|
||||
import ApprovalWorkflowsPage from "@/pages/ApprovalWorkflowsPage";
|
||||
import ClassroomsPage from "@/pages/ClassroomsPage";
|
||||
import StudentPerformancePage from "@/pages/StudentPerformancePage";
|
||||
import StatsCorporatePage from "@/pages/StatsCorporatePage";
|
||||
import RecordPage from "@/pages/RecordPage";
|
||||
import VocabularyPage from "@/pages/VocabularyPage";
|
||||
import GrammarPage from "@/pages/GrammarPage";
|
||||
import PaymentRecordPage from "@/pages/PaymentRecordPage";
|
||||
import TicketsPage from "@/pages/TicketsPage";
|
||||
import SettingsPage from "@/pages/SettingsPage";
|
||||
import ProfilePage from "@/pages/ProfilePage";
|
||||
import ExamPage from "@/pages/ExamPage";
|
||||
// Student pages
|
||||
import StudentDashboard from "@/pages/student/StudentDashboard";
|
||||
import StudentCourses from "@/pages/student/StudentCourses";
|
||||
import StudentCourseDetail from "@/pages/student/StudentCourseDetail";
|
||||
import StudentAssignments from "@/pages/student/StudentAssignments";
|
||||
import StudentGrades from "@/pages/student/StudentGrades";
|
||||
import StudentAttendance from "@/pages/student/StudentAttendance";
|
||||
import StudentTimetable from "@/pages/student/StudentTimetable";
|
||||
import StudentProfile from "@/pages/student/StudentProfile";
|
||||
// Adaptive learning pages
|
||||
import SubjectSelection from "@/pages/student/SubjectSelection";
|
||||
import DiagnosticTest from "@/pages/student/DiagnosticTest";
|
||||
import ProficiencyProfile from "@/pages/student/ProficiencyProfile";
|
||||
import LearningPlanPage from "@/pages/student/LearningPlan";
|
||||
import TopicLearning from "@/pages/student/TopicLearning";
|
||||
// Teacher pages
|
||||
import TeacherDashboard from "@/pages/teacher/TeacherDashboard";
|
||||
import TeacherCourses from "@/pages/teacher/TeacherCourses";
|
||||
import CourseBuilder from "@/pages/teacher/CourseBuilder";
|
||||
import TeacherAssignments from "@/pages/teacher/TeacherAssignments";
|
||||
import TeacherAssignmentDetail from "@/pages/teacher/TeacherAssignmentDetail";
|
||||
import TeacherAttendance from "@/pages/teacher/TeacherAttendance";
|
||||
import TeacherStudents from "@/pages/teacher/TeacherStudents";
|
||||
import TeacherTimetable from "@/pages/teacher/TeacherTimetable";
|
||||
import TeacherProfile from "@/pages/teacher/TeacherProfile";
|
||||
import AdaptiveSettings from "@/pages/teacher/AdaptiveSettings";
|
||||
// Admin LMS pages
|
||||
import AdminLmsDashboard from "@/pages/admin/AdminLmsDashboard";
|
||||
import AdminCourses from "@/pages/admin/AdminCourses";
|
||||
import AdminStudents from "@/pages/admin/AdminStudents";
|
||||
import AdminTeachers from "@/pages/admin/AdminTeachers";
|
||||
import AdminBatches from "@/pages/admin/AdminBatches";
|
||||
import AdminBatchDetail from "@/pages/admin/AdminBatchDetail";
|
||||
import AdminTimetable from "@/pages/admin/AdminTimetable";
|
||||
import AdminReports from "@/pages/admin/AdminReports";
|
||||
import AdminSettings from "@/pages/admin/AdminSettings";
|
||||
import AdminProfileLms from "@/pages/admin/AdminProfile";
|
||||
import TaxonomyManager from "@/pages/admin/TaxonomyManager";
|
||||
import ResourceManager from "@/pages/admin/ResourceManager";
|
||||
import AcademicYearManager from "@/pages/admin/AcademicYearManager";
|
||||
import DepartmentManager from "@/pages/admin/DepartmentManager";
|
||||
import AdmissionList from "@/pages/admin/AdmissionList";
|
||||
import AdmissionDetail from "@/pages/admin/AdmissionDetail";
|
||||
import AdmissionRegisterPage from "@/pages/admin/AdmissionRegisterPage";
|
||||
import InstitutionalExamSessions from "@/pages/admin/InstitutionalExamSessions";
|
||||
import MarksheetManager from "@/pages/admin/MarksheetManager";
|
||||
import AdminStudentLeave from "@/pages/admin/AdminStudentLeave";
|
||||
import AdminFees from "@/pages/admin/AdminFees";
|
||||
import AdminLessons from "@/pages/admin/AdminLessons";
|
||||
import AdminGradebook from "@/pages/admin/AdminGradebook";
|
||||
import AdminStudentProgress from "@/pages/admin/AdminStudentProgress";
|
||||
import AdminLibrary from "@/pages/admin/AdminLibrary";
|
||||
import AdminActivities from "@/pages/admin/AdminActivities";
|
||||
import AdminFacilities from "@/pages/admin/AdminFacilities";
|
||||
import AdmissionApplication from "@/pages/AdmissionApplication";
|
||||
import SubjectRegistrationPage from "@/pages/student/SubjectRegistrationPage";
|
||||
// Courseware pages
|
||||
import CourseChapters from "@/pages/teacher/CourseChapters";
|
||||
import ChapterDetail from "@/pages/teacher/ChapterDetail";
|
||||
import AiWorkbench from "@/pages/teacher/AiWorkbench";
|
||||
import TeacherDiscussionBoard from "@/pages/teacher/TeacherDiscussionBoard";
|
||||
import TeacherAnnouncements from "@/pages/teacher/TeacherAnnouncements";
|
||||
import StudentChapterView from "@/pages/student/StudentChapterView";
|
||||
import StudentDiscussionBoard from "@/pages/student/StudentDiscussionBoard";
|
||||
import StudentAnnouncements from "@/pages/student/StudentAnnouncements";
|
||||
import StudentMessages from "@/pages/student/StudentMessages";
|
||||
import StudentJourney from "@/pages/student/StudentJourney";
|
||||
import AiEnglishCourse from "@/pages/student/AiEnglishCourse";
|
||||
import AiIeltsCourse from "@/pages/student/AiIeltsCourse";
|
||||
import ExamSession from "@/pages/student/ExamSession";
|
||||
import ExamStatus from "@/pages/student/ExamStatus";
|
||||
import ExamResults from "@/pages/student/ExamResults";
|
||||
import GapAnalysis from "@/pages/student/GapAnalysis";
|
||||
import CourseDelivery from "@/pages/student/CourseDelivery";
|
||||
import GradingQueue from "@/pages/admin/GradingQueue";
|
||||
import CourseConfig from "@/pages/admin/CourseConfig";
|
||||
import ModuleBuilder from "@/pages/admin/ModuleBuilder";
|
||||
import CourseProgress from "@/pages/teacher/CourseProgress";
|
||||
import PlacementBriefing from "@/pages/student/PlacementBriefing";
|
||||
import PlacementTest from "@/pages/student/PlacementTest";
|
||||
import PlacementResults from "@/pages/student/PlacementResults";
|
||||
import PlacementAccess from "@/pages/student/PlacementAccess";
|
||||
import FaqManager from "@/pages/admin/FaqManager";
|
||||
import NotificationRules from "@/pages/admin/NotificationRules";
|
||||
import ApprovalWorkflowConfig from "@/pages/admin/ApprovalWorkflowConfig";
|
||||
import RolesPermissions from "@/pages/admin/RolesPermissions";
|
||||
import AuthorityMatrix from "@/pages/admin/AuthorityMatrix";
|
||||
import UserRoles from "@/pages/admin/UserRoles";
|
||||
import BulkStudentUpload from "@/pages/admin/BulkStudentUpload";
|
||||
import CredentialDashboard from "@/pages/admin/CredentialDashboard";
|
||||
import AiEnglishQuality from "@/pages/admin/AiEnglishQuality";
|
||||
import AiEnglishTaxonomy from "@/pages/admin/AiEnglishTaxonomy";
|
||||
import AiIeltsValidation from "@/pages/admin/AiIeltsValidation";
|
||||
import AdaptiveDashboard from "@/pages/admin/AdaptiveDashboard";
|
||||
import AdaptiveStudentDetail from "@/pages/admin/AdaptiveStudentDetail";
|
||||
import LevelMappingConfig from "@/pages/admin/LevelMappingConfig";
|
||||
import WhiteLabelBranding from "@/pages/admin/WhiteLabelBranding";
|
||||
import ScoreApprovalQueue from "@/pages/admin/ScoreApprovalQueue";
|
||||
import ExamTemplateSelection from "@/pages/admin/ExamTemplateSelection";
|
||||
import IeltsExamCreate from "@/pages/admin/IeltsExamCreate";
|
||||
import IeltsSkillConfig from "@/pages/admin/IeltsSkillConfig";
|
||||
import IeltsContentPool from "@/pages/admin/IeltsContentPool";
|
||||
import IeltsExamValidation from "@/pages/admin/IeltsExamValidation";
|
||||
import CustomExamCreate from "@/pages/admin/CustomExamCreate";
|
||||
import OfficialExamAccess from "@/pages/OfficialExamAccess";
|
||||
import FaqPage from "@/pages/FaqPage";
|
||||
import NotFound from "@/pages/NotFound";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Lazy-loaded route pages
|
||||
// -----------------------------------------------------------------------------
|
||||
// Keeping the initial bundle small matters on slow networks / modest hardware
|
||||
// (many teachers/students open the app from school Wi-Fi). Each page below is
|
||||
// split into its own chunk and only fetched when the user hits the matching
|
||||
// route. The shell (layouts, providers, router) is still eagerly imported so
|
||||
// the first paint stays snappy.
|
||||
|
||||
// Auth
|
||||
const Login = lazy(() => import("@/pages/Login"));
|
||||
const Register = lazy(() => import("@/pages/Register"));
|
||||
const EmailVerification = lazy(() => import("@/pages/EmailVerification"));
|
||||
const OnboardingWizard = lazy(() => import("@/pages/OnboardingWizard"));
|
||||
const ForgotPassword = lazy(() => import("@/pages/ForgotPassword"));
|
||||
const ResetPassword = lazy(() => import("@/pages/ResetPassword"));
|
||||
const ScoreVerification = lazy(() => import("@/pages/ScoreVerification"));
|
||||
|
||||
// Original platform pages
|
||||
const AdminDashboard = lazy(() => import("@/pages/AdminDashboard"));
|
||||
const UsersPage = lazy(() => import("@/pages/UsersPage"));
|
||||
const EntitiesPage = lazy(() => import("@/pages/EntitiesPage"));
|
||||
const AssignmentsPage = lazy(() => import("@/pages/AssignmentsPage"));
|
||||
const ExamsListPage = lazy(() => import("@/pages/ExamsListPage"));
|
||||
const ExamStructuresPage = lazy(() => import("@/pages/ExamStructuresPage"));
|
||||
const RubricsPage = lazy(() => import("@/pages/RubricsPage"));
|
||||
const GenerationPage = lazy(() => import("@/pages/GenerationPage"));
|
||||
const ApprovalWorkflowsPage = lazy(() => import("@/pages/ApprovalWorkflowsPage"));
|
||||
const ClassroomsPage = lazy(() => import("@/pages/ClassroomsPage"));
|
||||
const StudentPerformancePage = lazy(() => import("@/pages/StudentPerformancePage"));
|
||||
const StatsCorporatePage = lazy(() => import("@/pages/StatsCorporatePage"));
|
||||
const RecordPage = lazy(() => import("@/pages/RecordPage"));
|
||||
const VocabularyPage = lazy(() => import("@/pages/VocabularyPage"));
|
||||
const GrammarPage = lazy(() => import("@/pages/GrammarPage"));
|
||||
const PaymentRecordPage = lazy(() => import("@/pages/PaymentRecordPage"));
|
||||
const TicketsPage = lazy(() => import("@/pages/TicketsPage"));
|
||||
const SettingsPage = lazy(() => import("@/pages/SettingsPage"));
|
||||
|
||||
// Student pages
|
||||
const StudentDashboard = lazy(() => import("@/pages/student/StudentDashboard"));
|
||||
const StudentCourses = lazy(() => import("@/pages/student/StudentCourses"));
|
||||
const StudentCourseDetail = lazy(() => import("@/pages/student/StudentCourseDetail"));
|
||||
const StudentAssignments = lazy(() => import("@/pages/student/StudentAssignments"));
|
||||
const StudentGrades = lazy(() => import("@/pages/student/StudentGrades"));
|
||||
const StudentAttendance = lazy(() => import("@/pages/student/StudentAttendance"));
|
||||
const StudentTimetable = lazy(() => import("@/pages/student/StudentTimetable"));
|
||||
const StudentProfile = lazy(() => import("@/pages/student/StudentProfile"));
|
||||
|
||||
// Adaptive learning pages
|
||||
const SubjectSelection = lazy(() => import("@/pages/student/SubjectSelection"));
|
||||
const DiagnosticTest = lazy(() => import("@/pages/student/DiagnosticTest"));
|
||||
const ProficiencyProfile = lazy(() => import("@/pages/student/ProficiencyProfile"));
|
||||
const LearningPlanPage = lazy(() => import("@/pages/student/LearningPlan"));
|
||||
const TopicLearning = lazy(() => import("@/pages/student/TopicLearning"));
|
||||
|
||||
// Teacher pages
|
||||
const TeacherDashboard = lazy(() => import("@/pages/teacher/TeacherDashboard"));
|
||||
const TeacherQuickSetup = lazy(() => import("@/pages/teacher/TeacherQuickSetup"));
|
||||
const TeacherCourses = lazy(() => import("@/pages/teacher/TeacherCourses"));
|
||||
const CourseBuilder = lazy(() => import("@/pages/teacher/CourseBuilder"));
|
||||
const TeacherAssignments = lazy(() => import("@/pages/teacher/TeacherAssignments"));
|
||||
const TeacherAssignmentDetail = lazy(() => import("@/pages/teacher/TeacherAssignmentDetail"));
|
||||
const TeacherAttendance = lazy(() => import("@/pages/teacher/TeacherAttendance"));
|
||||
const TeacherStudents = lazy(() => import("@/pages/teacher/TeacherStudents"));
|
||||
const TeacherTimetable = lazy(() => import("@/pages/teacher/TeacherTimetable"));
|
||||
const TeacherProfile = lazy(() => import("@/pages/teacher/TeacherProfile"));
|
||||
const TeacherLibrary = lazy(() => import("@/pages/teacher/TeacherLibrary"));
|
||||
const AdaptiveSettings = lazy(() => import("@/pages/teacher/AdaptiveSettings"));
|
||||
|
||||
// Admin LMS pages
|
||||
const AdminLmsDashboard = lazy(() => import("@/pages/admin/AdminLmsDashboard"));
|
||||
const AdminQuickSetup = lazy(() => import("@/pages/admin/AdminQuickSetup"));
|
||||
const SmartWizardHub = lazy(() => import("@/pages/admin/SmartWizardHub"));
|
||||
const RubricWizard = lazy(() => import("@/pages/admin/wizards/RubricWizard"));
|
||||
const ExamStructureWizard = lazy(() => import("@/pages/admin/wizards/ExamStructureWizard"));
|
||||
const CourseWizard = lazy(() => import("@/pages/admin/wizards/CourseWizard"));
|
||||
const CoursePlanWizard = lazy(() => import("@/pages/admin/wizards/CoursePlanWizard"));
|
||||
const AdminCoursePlans = lazy(() => import("@/pages/admin/AdminCoursePlans"));
|
||||
const AdminCoursePlanDetail = lazy(() => import("@/pages/admin/AdminCoursePlanDetail"));
|
||||
const AdminCourses = lazy(() => import("@/pages/admin/AdminCourses"));
|
||||
const AdminStudents = lazy(() => import("@/pages/admin/AdminStudents"));
|
||||
const AdminTeachers = lazy(() => import("@/pages/admin/AdminTeachers"));
|
||||
const AdminBranches = lazy(() => import("@/pages/admin/AdminBranches"));
|
||||
const AdminBatches = lazy(() => import("@/pages/admin/AdminBatches"));
|
||||
const AdminBatchDetail = lazy(() => import("@/pages/admin/AdminBatchDetail"));
|
||||
const AdminTimetable = lazy(() => import("@/pages/admin/AdminTimetable"));
|
||||
const AdminReports = lazy(() => import("@/pages/admin/AdminReports"));
|
||||
const AdminSettings = lazy(() => import("@/pages/admin/AdminSettings"));
|
||||
const AdminProfileLms = lazy(() => import("@/pages/admin/AdminProfile"));
|
||||
const TaxonomyManager = lazy(() => import("@/pages/admin/TaxonomyManager"));
|
||||
const ResourceManager = lazy(() => import("@/pages/admin/ResourceManager"));
|
||||
const AcademicYearManager = lazy(() => import("@/pages/admin/AcademicYearManager"));
|
||||
const DepartmentManager = lazy(() => import("@/pages/admin/DepartmentManager"));
|
||||
const AdmissionList = lazy(() => import("@/pages/admin/AdmissionList"));
|
||||
const AdmissionDetail = lazy(() => import("@/pages/admin/AdmissionDetail"));
|
||||
const AdmissionRegisterPage = lazy(() => import("@/pages/admin/AdmissionRegisterPage"));
|
||||
const InstitutionalExamSessions = lazy(() => import("@/pages/admin/InstitutionalExamSessions"));
|
||||
const MarksheetManager = lazy(() => import("@/pages/admin/MarksheetManager"));
|
||||
const AdminStudentLeave = lazy(() => import("@/pages/admin/AdminStudentLeave"));
|
||||
const AdminFees = lazy(() => import("@/pages/admin/AdminFees"));
|
||||
const AdminLessons = lazy(() => import("@/pages/admin/AdminLessons"));
|
||||
const AdminGradebook = lazy(() => import("@/pages/admin/AdminGradebook"));
|
||||
const AdminStudentProgress = lazy(() => import("@/pages/admin/AdminStudentProgress"));
|
||||
const AdminLibrary = lazy(() => import("@/pages/admin/AdminLibrary"));
|
||||
const AdminActivities = lazy(() => import("@/pages/admin/AdminActivities"));
|
||||
const AdminFacilities = lazy(() => import("@/pages/admin/AdminFacilities"));
|
||||
const AdmissionApplication = lazy(() => import("@/pages/AdmissionApplication"));
|
||||
const SubjectRegistrationPage = lazy(() => import("@/pages/student/SubjectRegistrationPage"));
|
||||
|
||||
// Courseware pages
|
||||
const CourseChapters = lazy(() => import("@/pages/teacher/CourseChapters"));
|
||||
const ChapterDetail = lazy(() => import("@/pages/teacher/ChapterDetail"));
|
||||
const AiWorkbench = lazy(() => import("@/pages/teacher/AiWorkbench"));
|
||||
const TeacherDiscussionBoard = lazy(() => import("@/pages/teacher/TeacherDiscussionBoard"));
|
||||
const TeacherAnnouncements = lazy(() => import("@/pages/teacher/TeacherAnnouncements"));
|
||||
const StudentChapterView = lazy(() => import("@/pages/student/StudentChapterView"));
|
||||
const StudentDiscussionBoard = lazy(() => import("@/pages/student/StudentDiscussionBoard"));
|
||||
const StudentAnnouncements = lazy(() => import("@/pages/student/StudentAnnouncements"));
|
||||
const StudentMessages = lazy(() => import("@/pages/student/StudentMessages"));
|
||||
const StudentJourney = lazy(() => import("@/pages/student/StudentJourney"));
|
||||
const StudentCoursePlans = lazy(() => import("@/pages/student/StudentCoursePlans"));
|
||||
const StudentCoursePlanDetail = lazy(() => import("@/pages/student/StudentCoursePlanDetail"));
|
||||
const AiEnglishCourse = lazy(() => import("@/pages/student/AiEnglishCourse"));
|
||||
const AiIeltsCourse = lazy(() => import("@/pages/student/AiIeltsCourse"));
|
||||
const ExamSession = lazy(() => import("@/pages/student/ExamSession"));
|
||||
const ExamStatus = lazy(() => import("@/pages/student/ExamStatus"));
|
||||
const ExamResults = lazy(() => import("@/pages/student/ExamResults"));
|
||||
const GapAnalysis = lazy(() => import("@/pages/student/GapAnalysis"));
|
||||
const CourseDelivery = lazy(() => import("@/pages/student/CourseDelivery"));
|
||||
const GradingQueue = lazy(() => import("@/pages/admin/GradingQueue"));
|
||||
const CourseConfig = lazy(() => import("@/pages/admin/CourseConfig"));
|
||||
const ModuleBuilder = lazy(() => import("@/pages/admin/ModuleBuilder"));
|
||||
const CourseProgress = lazy(() => import("@/pages/teacher/CourseProgress"));
|
||||
const PlacementBriefing = lazy(() => import("@/pages/student/PlacementBriefing"));
|
||||
const PlacementTest = lazy(() => import("@/pages/student/PlacementTest"));
|
||||
const PlacementResults = lazy(() => import("@/pages/student/PlacementResults"));
|
||||
const PlacementAccess = lazy(() => import("@/pages/student/PlacementAccess"));
|
||||
const FaqManager = lazy(() => import("@/pages/admin/FaqManager"));
|
||||
const NotificationRules = lazy(() => import("@/pages/admin/NotificationRules"));
|
||||
const ApprovalWorkflowConfig = lazy(() => import("@/pages/admin/ApprovalWorkflowConfig"));
|
||||
const RolesPermissions = lazy(() => import("@/pages/admin/RolesPermissions"));
|
||||
const AuthorityMatrix = lazy(() => import("@/pages/admin/AuthorityMatrix"));
|
||||
const UserRoles = lazy(() => import("@/pages/admin/UserRoles"));
|
||||
const BulkStudentUpload = lazy(() => import("@/pages/admin/BulkStudentUpload"));
|
||||
const CredentialDashboard = lazy(() => import("@/pages/admin/CredentialDashboard"));
|
||||
const AiEnglishQuality = lazy(() => import("@/pages/admin/AiEnglishQuality"));
|
||||
const AiEnglishTaxonomy = lazy(() => import("@/pages/admin/AiEnglishTaxonomy"));
|
||||
const AiIeltsValidation = lazy(() => import("@/pages/admin/AiIeltsValidation"));
|
||||
const AdaptiveDashboard = lazy(() => import("@/pages/admin/AdaptiveDashboard"));
|
||||
const AdaptiveStudentDetail = lazy(() => import("@/pages/admin/AdaptiveStudentDetail"));
|
||||
const LevelMappingConfig = lazy(() => import("@/pages/admin/LevelMappingConfig"));
|
||||
const WhiteLabelBranding = lazy(() => import("@/pages/admin/WhiteLabelBranding"));
|
||||
const ScoreApprovalQueue = lazy(() => import("@/pages/admin/ScoreApprovalQueue"));
|
||||
const ExamTemplateSelection = lazy(() => import("@/pages/admin/ExamTemplateSelection"));
|
||||
const IeltsExamCreate = lazy(() => import("@/pages/admin/IeltsExamCreate"));
|
||||
const IeltsSkillConfig = lazy(() => import("@/pages/admin/IeltsSkillConfig"));
|
||||
const IeltsContentPool = lazy(() => import("@/pages/admin/IeltsContentPool"));
|
||||
const IeltsExamValidation = lazy(() => import("@/pages/admin/IeltsExamValidation"));
|
||||
const CustomExamCreate = lazy(() => import("@/pages/admin/CustomExamCreate"));
|
||||
const ExamReviewQueue = lazy(() => import("@/pages/admin/ExamReviewQueue"));
|
||||
const ExamReviewDetail = lazy(() => import("@/pages/admin/ExamReviewDetail"));
|
||||
const AIPromptEditor = lazy(() => import("@/pages/admin/AIPromptEditor"));
|
||||
const AIFeedbackTriage = lazy(() => import("@/pages/admin/AIFeedbackTriage"));
|
||||
const PrivacyCenter = lazy(() => import("@/pages/PrivacyCenter"));
|
||||
const OfficialExamAccess = lazy(() => import("@/pages/OfficialExamAccess"));
|
||||
const FaqPage = lazy(() => import("@/pages/FaqPage"));
|
||||
const NotFound = lazy(() => import("@/pages/NotFound"));
|
||||
|
||||
function StudentSubscriptionPlaceholder() {
|
||||
const navigate = useNavigate();
|
||||
@@ -156,13 +192,35 @@ function StudentSubscriptionPlaceholder() {
|
||||
);
|
||||
}
|
||||
|
||||
function RouteFallback() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const App = () => (
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
storageKey="encoach-theme"
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
<BrowserRouter>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
<AuthProvider>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Routes>
|
||||
{/* Auth routes */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
@@ -197,6 +255,7 @@ const App = () => (
|
||||
<Route path="/student/attendance" element={<StudentAttendance />} />
|
||||
<Route path="/student/timetable" element={<StudentTimetable />} />
|
||||
<Route path="/student/profile" element={<StudentProfile />} />
|
||||
<Route path="/student/privacy" element={<PrivacyCenter />} />
|
||||
<Route path="/student/subjects" element={<SubjectSelection />} />
|
||||
<Route path="/student/diagnostic/:subjectId" element={<DiagnosticTest />} />
|
||||
<Route path="/student/proficiency/:subjectId" element={<ProficiencyProfile />} />
|
||||
@@ -213,6 +272,8 @@ const App = () => (
|
||||
<Route path="/student/placement/access" element={<PlacementAccess />} />
|
||||
<Route path="/student/exam/:examId/results" element={<ExamResults />} />
|
||||
<Route path="/student/course/generate" element={<GapAnalysis />} />
|
||||
<Route path="/student/course-plans" element={<StudentCoursePlans />} />
|
||||
<Route path="/student/course-plans/:planId" element={<StudentCoursePlanDetail />} />
|
||||
<Route path="/student/course/ai-english/:courseId" element={<AiEnglishCourse />} />
|
||||
<Route path="/student/course/ai-ielts/:courseId" element={<AiIeltsCourse />} />
|
||||
<Route path="/student/course/:courseId" element={<CourseDelivery />} />
|
||||
@@ -220,10 +281,12 @@ const App = () => (
|
||||
</Route>
|
||||
|
||||
{/* Teacher routes */}
|
||||
<Route element={<ProtectedRoute allowedRoles={["teacher"]} />}>
|
||||
<Route element={<ProtectedRoute allowedRoles={["teacher", "admin", "developer"]} />}>
|
||||
<Route element={<TeacherLayout />}>
|
||||
<Route path="/teacher/dashboard" element={<TeacherDashboard />} />
|
||||
<Route path="/teacher/quick-setup" element={<TeacherQuickSetup />} />
|
||||
<Route path="/teacher/courses" element={<TeacherCourses />} />
|
||||
<Route path="/teacher/library" element={<TeacherLibrary />} />
|
||||
<Route path="/teacher/courses/new" element={<CourseBuilder />} />
|
||||
<Route path="/teacher/courses/:id/edit" element={<CourseBuilder />} />
|
||||
<Route path="/teacher/assignments" element={<TeacherAssignments />} />
|
||||
@@ -231,12 +294,13 @@ const App = () => (
|
||||
<Route path="/teacher/attendance" element={<TeacherAttendance />} />
|
||||
<Route path="/teacher/students" element={<TeacherStudents />} />
|
||||
<Route path="/teacher/timetable" element={<TeacherTimetable />} />
|
||||
<Route path="/teacher/courses/:id/chapters" element={<CourseChapters />} />
|
||||
<Route path="/teacher/courses/:id/chapters/:chapterId" element={<ChapterDetail />} />
|
||||
<Route path="/teacher/courses/:id/workbench" element={<AiWorkbench />} />
|
||||
<Route path="/teacher/courses/:courseId/chapters" element={<CourseChapters />} />
|
||||
<Route path="/teacher/courses/:courseId/chapters/:chapterId" element={<ChapterDetail />} />
|
||||
<Route path="/teacher/courses/:courseId/workbench" element={<AiWorkbench />} />
|
||||
<Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} />
|
||||
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
|
||||
<Route path="/teacher/profile" element={<TeacherProfile />} />
|
||||
<Route path="/teacher/privacy" element={<PrivacyCenter />} />
|
||||
<Route path="/teacher/course/:courseId/progress" element={<CourseProgress />} />
|
||||
<Route path="/teacher/adaptive/settings" element={<AdaptiveSettings />} />
|
||||
</Route>
|
||||
@@ -247,18 +311,28 @@ const App = () => (
|
||||
<Route element={<AdminLmsLayout />}>
|
||||
{/* LMS Dashboard */}
|
||||
<Route path="/admin/dashboard" element={<AdminLmsDashboard />} />
|
||||
<Route path="/admin/quick-setup" element={<AdminQuickSetup />} />
|
||||
<Route path="/admin/smart-wizard" element={<SmartWizardHub />} />
|
||||
<Route path="/admin/smart-wizard/rubric" element={<RubricWizard />} />
|
||||
<Route path="/admin/smart-wizard/exam-structure" element={<ExamStructureWizard />} />
|
||||
<Route path="/admin/smart-wizard/course" element={<CourseWizard />} />
|
||||
<Route path="/admin/smart-wizard/course-plan" element={<CoursePlanWizard />} />
|
||||
<Route path="/admin/course-plans" element={<AdminCoursePlans />} />
|
||||
<Route path="/admin/course-plans/:planId" element={<AdminCoursePlanDetail />} />
|
||||
{/* Original platform dashboard */}
|
||||
<Route path="/admin/platform" element={<AdminDashboard />} />
|
||||
{/* LMS pages */}
|
||||
{/* LMS pages (entity-scoped, includes branch management) */}
|
||||
<Route path="/admin/courses" element={<AdminCourses />} />
|
||||
<Route path="/admin/students" element={<AdminStudents />} />
|
||||
<Route path="/admin/teachers" element={<AdminTeachers />} />
|
||||
<Route path="/admin/branches" element={<AdminBranches />} />
|
||||
<Route path="/admin/batches" element={<AdminBatches />} />
|
||||
<Route path="/admin/batches/:id" element={<AdminBatchDetail />} />
|
||||
<Route path="/admin/timetable" element={<AdminTimetable />} />
|
||||
<Route path="/admin/reports" element={<AdminReports />} />
|
||||
<Route path="/admin/settings" element={<AdminSettings />} />
|
||||
<Route path="/admin/profile" element={<AdminProfileLms />} />
|
||||
<Route path="/admin/privacy" element={<PrivacyCenter />} />
|
||||
{/* Original academic pages */}
|
||||
<Route path="/admin/assignments" element={<AssignmentsPage />} />
|
||||
<Route path="/admin/examsList" element={<ExamsListPage />} />
|
||||
@@ -281,13 +355,16 @@ const App = () => (
|
||||
<Route path="/admin/payment-record" element={<PaymentRecordPage />} />
|
||||
<Route path="/admin/tickets" element={<TicketsPage />} />
|
||||
<Route path="/admin/settings-platform" element={<SettingsPage />} />
|
||||
<Route path="/admin/exam" element={<ExamPage />} />
|
||||
<Route path="/admin/exam/create" element={<ExamTemplateSelection />} />
|
||||
<Route path="/admin/exam/ielts/create" element={<IeltsExamCreate />} />
|
||||
<Route path="/admin/exam/ielts/:examId/skills" element={<IeltsSkillConfig />} />
|
||||
<Route path="/admin/exam/ielts/:examId/content" element={<IeltsContentPool />} />
|
||||
<Route path="/admin/exam/ielts/:examId/validate" element={<IeltsExamValidation />} />
|
||||
<Route path="/admin/exam/custom/create" element={<CustomExamCreate />} />
|
||||
<Route path="/admin/exam/review-queue" element={<ExamReviewQueue />} />
|
||||
<Route path="/admin/exam/review/:examId" element={<ExamReviewDetail />} />
|
||||
<Route path="/admin/ai/prompts" element={<AIPromptEditor />} />
|
||||
<Route path="/admin/ai/feedback" element={<AIFeedbackTriage />} />
|
||||
<Route path="/admin/taxonomy" element={<TaxonomyManager />} />
|
||||
<Route path="/admin/resources" element={<ResourceManager />} />
|
||||
{/* Institutional LMS pages */}
|
||||
@@ -336,10 +413,13 @@ const App = () => (
|
||||
<Route path="/dashboard/admin" element={<Navigate to="/admin/platform" replace />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
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, useMatch } from "react-router-dom";
|
||||
import { Suspense } from "react";
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import {
|
||||
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
||||
@@ -9,10 +10,13 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
|
||||
import { NavLink } from "@/components/NavLink";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import NotificationDropdown from "@/components/NotificationDropdown";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { LanguageToggle } from "@/components/LanguageToggle";
|
||||
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
|
||||
import AiSearchBar from "@/components/ai/AiSearchBar";
|
||||
import { usePermissions } from "@/hooks/usePermissions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
@@ -29,115 +33,129 @@ import {
|
||||
CalendarDays, Landmark, UserPlus, ScrollText, Award,
|
||||
HelpCircle as FaqIcon, Bell, Workflow,
|
||||
CalendarOff, DollarSign, BookMarked, BarChartHorizontal, TrendingUp,
|
||||
Library, Activity, Warehouse, UserCog,
|
||||
Library, Activity, Warehouse, UserCog, Sparkles, Compass,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// ============= Navigation Config =============
|
||||
interface NavItem { title: string; url: string; icon: LucideIcon }
|
||||
// Items store i18n keys (`titleKey`) rather than literal strings so Arabic
|
||||
// language switches update the sidebar without the component having to
|
||||
// re-mount.
|
||||
interface NavItem { titleKey: string; url: string; icon: LucideIcon }
|
||||
|
||||
const overviewItems: NavItem[] = [
|
||||
{ title: "Admin Dashboard", url: "/admin/dashboard", icon: LayoutDashboard },
|
||||
{ title: "Platform Dashboard", url: "/admin/platform", icon: BarChart3 },
|
||||
{ titleKey: "nav.smartWizard", url: "/admin/smart-wizard", icon: Sparkles },
|
||||
{ titleKey: "nav.adminDashboard", url: "/admin/dashboard", icon: LayoutDashboard },
|
||||
{ titleKey: "nav.platformDashboard", url: "/admin/platform", icon: BarChart3 },
|
||||
];
|
||||
|
||||
const lmsItems: NavItem[] = [
|
||||
{ title: "Courses", url: "/admin/courses", icon: BookOpen },
|
||||
{ title: "Students", url: "/admin/students", icon: Users },
|
||||
{ title: "Teachers", url: "/admin/teachers", icon: GraduationCap },
|
||||
{ title: "Batches", url: "/admin/batches", icon: Layers },
|
||||
{ title: "Timetable", url: "/admin/timetable", icon: Calendar },
|
||||
{ title: "Reports", url: "/admin/reports", icon: BarChart3 },
|
||||
{ titleKey: "nav.courses", url: "/admin/courses", icon: BookOpen },
|
||||
{ titleKey: "nav.students", url: "/admin/students", icon: Users },
|
||||
{ titleKey: "nav.teachers", url: "/admin/teachers", icon: GraduationCap },
|
||||
{ titleKey: "nav.branches", url: "/admin/branches", icon: GitBranch },
|
||||
{ titleKey: "nav.classrooms", url: "/admin/classrooms", icon: GraduationCap },
|
||||
{ titleKey: "nav.batches", url: "/admin/batches", icon: Layers },
|
||||
{ titleKey: "nav.timetable", url: "/admin/timetable", icon: Calendar },
|
||||
{ titleKey: "nav.reports", url: "/admin/reports", icon: BarChart3 },
|
||||
];
|
||||
|
||||
const academicItems: NavItem[] = [
|
||||
{ title: "Assignments", url: "/admin/assignments", icon: ClipboardList },
|
||||
{ title: "Exams List", url: "/admin/examsList", icon: FileText },
|
||||
{ title: "Exam Structures", url: "/admin/exam-structures", icon: Layers },
|
||||
{ title: "Rubrics", url: "/admin/rubrics", icon: BookOpen },
|
||||
{ title: "Generation", url: "/admin/generation", icon: Wand2 },
|
||||
{ title: "Approval Workflows", url: "/admin/approval-workflows", icon: GitBranch },
|
||||
{ titleKey: "nav.assignments", url: "/admin/assignments", icon: ClipboardList },
|
||||
{ titleKey: "nav.examsList", url: "/admin/examsList", icon: FileText },
|
||||
{ titleKey: "nav.examStructures", url: "/admin/exam-structures", icon: Layers },
|
||||
{ titleKey: "nav.rubrics", url: "/admin/rubrics", icon: BookOpen },
|
||||
{ titleKey: "nav.generation", url: "/admin/generation", icon: Wand2 },
|
||||
{ titleKey: "nav.reviewQueue", url: "/admin/exam/review-queue", icon: Sparkles },
|
||||
{ titleKey: "nav.aiPrompts", url: "/admin/ai/prompts", icon: Wand2 },
|
||||
{ titleKey: "nav.aiFeedback", url: "/admin/ai/feedback", icon: Sparkles },
|
||||
{ titleKey: "nav.coursePlans", url: "/admin/course-plans", icon: Compass },
|
||||
{ titleKey: "nav.approvalWorkflows", url: "/admin/approval-workflows", icon: GitBranch },
|
||||
];
|
||||
|
||||
const adaptiveItems: NavItem[] = [
|
||||
{ title: "Taxonomy", url: "/admin/taxonomy", icon: Target },
|
||||
{ title: "Resources", url: "/admin/resources", icon: FolderOpen },
|
||||
{ titleKey: "nav.taxonomy", url: "/admin/taxonomy", icon: Target },
|
||||
{ titleKey: "nav.resources", url: "/admin/resources", icon: FolderOpen },
|
||||
];
|
||||
|
||||
const institutionalItems: NavItem[] = [
|
||||
{ title: "Academic Years", url: "/admin/academic-years", icon: CalendarDays },
|
||||
{ title: "Departments", url: "/admin/departments", icon: Landmark },
|
||||
{ title: "Admissions", url: "/admin/admissions", icon: UserPlus },
|
||||
{ title: "Admission Register", url: "/admin/admission-register", icon: ScrollText },
|
||||
{ title: "Exam Sessions", url: "/admin/exam-sessions", icon: FileText },
|
||||
{ title: "Marksheets", url: "/admin/marksheets", icon: Award },
|
||||
{ title: "Student Leave", url: "/admin/student-leave", icon: CalendarOff },
|
||||
{ title: "Fees & Payments", url: "/admin/fees", icon: DollarSign },
|
||||
{ title: "Lessons", url: "/admin/lessons", icon: BookMarked },
|
||||
{ title: "Gradebook", url: "/admin/gradebook", icon: BarChartHorizontal },
|
||||
{ title: "Student Progress", url: "/admin/student-progress", icon: TrendingUp },
|
||||
{ title: "Library", url: "/admin/library", icon: Library },
|
||||
{ title: "Activities", url: "/admin/activities", icon: Activity },
|
||||
{ title: "Facilities", url: "/admin/facilities", icon: Warehouse },
|
||||
{ titleKey: "nav.academicYears", url: "/admin/academic-years", icon: CalendarDays },
|
||||
{ titleKey: "nav.departments", url: "/admin/departments", icon: Landmark },
|
||||
{ titleKey: "nav.admissions", url: "/admin/admissions", icon: UserPlus },
|
||||
{ titleKey: "nav.admissionRegister", url: "/admin/admission-register", icon: ScrollText },
|
||||
{ titleKey: "nav.examSessions", url: "/admin/exam-sessions", icon: FileText },
|
||||
{ titleKey: "nav.marksheets", url: "/admin/marksheets", icon: Award },
|
||||
{ titleKey: "nav.studentLeave", url: "/admin/student-leave", icon: CalendarOff },
|
||||
{ titleKey: "nav.fees", url: "/admin/fees", icon: DollarSign },
|
||||
{ titleKey: "nav.lessons", url: "/admin/lessons", icon: BookMarked },
|
||||
{ titleKey: "nav.gradebook", url: "/admin/gradebook", icon: BarChartHorizontal },
|
||||
{ titleKey: "nav.studentProgress", url: "/admin/student-progress", icon: TrendingUp },
|
||||
{ titleKey: "nav.library", url: "/admin/library", icon: Library },
|
||||
{ titleKey: "nav.activities", url: "/admin/activities", icon: Activity },
|
||||
{ titleKey: "nav.facilities", url: "/admin/facilities", icon: Warehouse },
|
||||
];
|
||||
|
||||
const managementItems: NavItem[] = [
|
||||
{ title: "Users", url: "/admin/users", icon: Users },
|
||||
{ title: "Entities", url: "/admin/entities", icon: Building2 },
|
||||
{ title: "Classrooms", url: "/admin/classrooms", icon: GraduationCap },
|
||||
{ title: "User Roles", url: "/admin/user-roles", icon: UserCog },
|
||||
{ title: "Roles & Permissions", url: "/admin/roles-permissions", icon: Settings },
|
||||
{ title: "Authority Matrix", url: "/admin/authority-matrix", icon: Workflow },
|
||||
{ titleKey: "nav.users", url: "/admin/users", icon: Users },
|
||||
{ titleKey: "nav.entities", url: "/admin/entities", icon: Building2 },
|
||||
{ titleKey: "nav.userRoles", url: "/admin/user-roles", icon: UserCog },
|
||||
{ titleKey: "nav.rolesPermissions", url: "/admin/roles-permissions", icon: Settings },
|
||||
{ titleKey: "nav.authorityMatrix", url: "/admin/authority-matrix", icon: Workflow },
|
||||
];
|
||||
|
||||
const reportItems: NavItem[] = [
|
||||
{ title: "Student Performance", url: "/admin/student-performance", icon: BarChart3 },
|
||||
{ title: "Stats Corporate", url: "/admin/stats-corporate", icon: Building2 },
|
||||
{ title: "Record", url: "/admin/record", icon: History },
|
||||
{ titleKey: "nav.studentPerformance", url: "/admin/student-performance", icon: BarChart3 },
|
||||
{ titleKey: "nav.statsCorporate", url: "/admin/stats-corporate", icon: Building2 },
|
||||
{ titleKey: "nav.record", url: "/admin/record", icon: History },
|
||||
];
|
||||
|
||||
const trainingItems: NavItem[] = [
|
||||
{ title: "Vocabulary", url: "/admin/training/vocabulary", icon: BookA },
|
||||
{ title: "Grammar", url: "/admin/training/grammar", icon: PenTool },
|
||||
{ titleKey: "nav.vocabulary", url: "/admin/training/vocabulary", icon: BookA },
|
||||
{ titleKey: "nav.grammar", url: "/admin/training/grammar", icon: PenTool },
|
||||
];
|
||||
|
||||
const configItems: NavItem[] = [
|
||||
{ title: "FAQ Manager", url: "/admin/faq", icon: FaqIcon },
|
||||
{ title: "Notification Rules", url: "/admin/notification-rules", icon: Bell },
|
||||
{ title: "Approval Config", url: "/admin/approval-config", icon: Workflow },
|
||||
{ titleKey: "nav.faqManager", url: "/admin/faq", icon: FaqIcon },
|
||||
{ titleKey: "nav.notificationRules", url: "/admin/notification-rules", icon: Bell },
|
||||
{ titleKey: "nav.approvalConfig", url: "/admin/approval-config", icon: Workflow },
|
||||
];
|
||||
|
||||
const supportItems: NavItem[] = [
|
||||
{ title: "Payment Record", url: "/admin/payment-record", icon: CreditCard },
|
||||
{ title: "Tickets", url: "/admin/tickets", icon: Ticket },
|
||||
{ title: "Settings", url: "/admin/settings-platform", icon: Settings },
|
||||
{ titleKey: "nav.paymentRecord", url: "/admin/payment-record", icon: CreditCard },
|
||||
{ titleKey: "nav.tickets", url: "/admin/tickets", icon: Ticket },
|
||||
{ titleKey: "nav.settings", url: "/admin/settings-platform", icon: Settings },
|
||||
];
|
||||
|
||||
// ============= Reusable sidebar group =============
|
||||
function SidebarNavGroup({ label, items }: { label: string; items: NavItem[] }) {
|
||||
function SidebarNavGroup({ labelKey, items }: { labelKey: string; items: NavItem[] }) {
|
||||
const { state } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
const collapsed = state === "collapsed";
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
||||
<SidebarGroupLabel>{t(labelKey)}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton asChild tooltip={item.title}>
|
||||
<NavLink
|
||||
to={item.url}
|
||||
end={item.url.endsWith("/dashboard") || item.url.endsWith("/platform")}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{item.title}</span>}
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{items.map((item) => {
|
||||
const label = t(item.titleKey);
|
||||
return (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton asChild tooltip={label}>
|
||||
<NavLink
|
||||
to={item.url}
|
||||
end={item.url.endsWith("/dashboard") || item.url.endsWith("/platform")}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
@@ -145,40 +163,52 @@ function SidebarNavGroup({ label, items }: { label: string; items: NavItem[] })
|
||||
}
|
||||
|
||||
// ============= Breadcrumbs =============
|
||||
const routeLabels: Record<string, string> = {
|
||||
admin: "Admin", dashboard: "Dashboard", platform: "Platform", courses: "Courses",
|
||||
students: "Students", teachers: "Teachers", batches: "Batches", timetable: "Timetable",
|
||||
reports: "Reports", assignments: "Assignments", examsList: "Exams List",
|
||||
"exam-structures": "Exam Structures", rubrics: "Rubrics", generation: "Generation",
|
||||
"approval-workflows": "Approval Workflows", users: "Users", entities: "Entities",
|
||||
classrooms: "Classrooms", "student-performance": "Student Performance",
|
||||
"stats-corporate": "Stats Corporate", record: "Record", training: "Training",
|
||||
vocabulary: "Vocabulary", grammar: "Grammar", "payment-record": "Payment Record",
|
||||
tickets: "Tickets", "settings-platform": "Settings", settings: "Settings",
|
||||
profile: "Profile", exam: "Exam",
|
||||
"academic-years": "Academic Years", departments: "Departments",
|
||||
admissions: "Admissions", "admission-register": "Admission Register",
|
||||
"exam-sessions": "Exam Sessions", marksheets: "Marksheets",
|
||||
faq: "FAQ Manager", "notification-rules": "Notification Rules",
|
||||
"approval-config": "Approval Config",
|
||||
"student-leave": "Student Leave", fees: "Fees & Payments", lessons: "Lessons",
|
||||
gradebook: "Gradebook", "student-progress": "Student Progress",
|
||||
library: "Library", activities: "Activities", facilities: "Facilities",
|
||||
// Map URL segments to i18n keys so breadcrumbs follow the active locale.
|
||||
// Segments without an entry fall back to a Title-cased version of the slug.
|
||||
const routeLabelKeys: Record<string, string> = {
|
||||
admin: "breadcrumb.admin", dashboard: "breadcrumb.dashboard",
|
||||
platform: "breadcrumb.platform", courses: "nav.courses",
|
||||
students: "nav.students", teachers: "nav.teachers", branches: "nav.branches", batches: "nav.batches",
|
||||
timetable: "nav.timetable", reports: "nav.reports",
|
||||
assignments: "nav.assignments", examsList: "nav.examsList",
|
||||
"exam-structures": "nav.examStructures", rubrics: "nav.rubrics",
|
||||
generation: "nav.generation", "review-queue": "nav.reviewQueue",
|
||||
review: "breadcrumb.review", prompts: "nav.aiPrompts", ai: "breadcrumb.ai",
|
||||
feedback: "breadcrumb.feedback",
|
||||
"approval-workflows": "nav.approvalWorkflows", users: "nav.users",
|
||||
entities: "nav.entities", classrooms: "nav.classrooms",
|
||||
"student-performance": "nav.studentPerformance",
|
||||
"stats-corporate": "nav.statsCorporate", record: "nav.record",
|
||||
training: "sidebarGroup.training", vocabulary: "nav.vocabulary",
|
||||
grammar: "nav.grammar", "payment-record": "nav.paymentRecord",
|
||||
tickets: "nav.tickets", "settings-platform": "nav.settings",
|
||||
settings: "nav.settings", profile: "nav.profile", exam: "breadcrumb.exam",
|
||||
"academic-years": "nav.academicYears", departments: "nav.departments",
|
||||
admissions: "nav.admissions", "admission-register": "nav.admissionRegister",
|
||||
"exam-sessions": "nav.examSessions", marksheets: "nav.marksheets",
|
||||
faq: "nav.faqManager", "notification-rules": "nav.notificationRules",
|
||||
"approval-config": "nav.approvalConfig",
|
||||
"student-leave": "nav.studentLeave", fees: "nav.fees",
|
||||
lessons: "nav.lessons", gradebook: "nav.gradebook",
|
||||
"student-progress": "nav.studentProgress", library: "nav.library",
|
||||
activities: "nav.activities", facilities: "nav.facilities",
|
||||
};
|
||||
|
||||
function AppBreadcrumbs() {
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const segments = location.pathname.split("/").filter(Boolean);
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild><Link to="/admin/dashboard">Home</Link></BreadcrumbLink>
|
||||
<BreadcrumbLink asChild><Link to="/admin/dashboard">{t("common.home")}</Link></BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
{segments.map((seg, i) => {
|
||||
const path = "/" + segments.slice(0, i + 1).join("/");
|
||||
const label = routeLabels[seg] || seg.charAt(0).toUpperCase() + seg.slice(1);
|
||||
const key = routeLabelKeys[seg];
|
||||
const label = key ? t(key) : seg.charAt(0).toUpperCase() + seg.slice(1);
|
||||
const isLast = i === segments.length - 1;
|
||||
return (
|
||||
<React.Fragment key={path}>
|
||||
@@ -194,10 +224,32 @@ function AppBreadcrumbs() {
|
||||
);
|
||||
}
|
||||
|
||||
// ============= Route content fallback =============
|
||||
// Shown only inside the main content area while a lazy-loaded route chunk
|
||||
// is fetching. Keeping the fallback local means the sidebar and header
|
||||
// stay mounted during navigation — previously the page felt like a full
|
||||
// browser reload because the outer App-level Suspense replaced everything
|
||||
// with a full-viewport spinner.
|
||||
function RouteContentFallback() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============= Main Layout =============
|
||||
export default function AdminLmsLayout() {
|
||||
const { user, logout } = useAuth();
|
||||
const { user, logout, selectedEntity, setSelectedEntityId } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
// Hide the floating "Need help?" pill on multi-step wizard routes.
|
||||
// It sits at `fixed bottom-6 end-6` z-50 and was intercepting clicks
|
||||
// on the wizard's own Next/Finish footer (real bug repro: trying to
|
||||
// click "Next" in the Course-plan wizard at the standard 1024×768
|
||||
// viewport hits the pill instead). The AI Assistant orb also at the
|
||||
// bottom-right is preserved — it's smaller and useful in-wizard.
|
||||
const isWizardRoute = !!useMatch("/admin/smart-wizard/*");
|
||||
|
||||
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
|
||||
|
||||
@@ -213,13 +265,53 @@ export default function AdminLmsLayout() {
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<SidebarTrigger />
|
||||
<SidebarTrigger aria-label={t("chrome.toggleSidebar")} />
|
||||
<AppBreadcrumbs />
|
||||
</div>
|
||||
<AiSearchBar />
|
||||
<div className="flex items-center gap-2">
|
||||
{user?.entities?.length ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="max-w-40 truncate">
|
||||
{selectedEntity?.name ?? user.entities[0]?.name ?? t("nav.entities")}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{t("nav.entities")}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{user.entities.map((entity) => (
|
||||
<DropdownMenuItem
|
||||
key={entity.id}
|
||||
onClick={() => {
|
||||
setSelectedEntityId(entity.id);
|
||||
// Force page data to refresh against the newly
|
||||
// selected entity scope.
|
||||
window.location.reload();
|
||||
}}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<span className="truncate">{entity.name}</span>
|
||||
{selectedEntity?.id === entity.id && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{t("common.active", "Active")}
|
||||
</Badge>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<LanguageToggle />
|
||||
<ThemeToggle />
|
||||
<NotificationDropdown />
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/tickets")} className="text-muted-foreground hover:text-foreground">
|
||||
<Button variant="ghost" size="icon" aria-label={t("chrome.ticketsTooltip")} onClick={() => navigate("/admin/tickets")} className="text-muted-foreground hover:text-foreground">
|
||||
<Ticket className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
@@ -232,37 +324,41 @@ export default function AdminLmsLayout() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-sm font-medium">{user?.name ?? "User"}</p>
|
||||
<p className="text-sm font-medium">{user?.name ?? t("userMenu.userFallback")}</p>
|
||||
<p className="text-xs text-muted-foreground">{user?.email ?? ""}</p>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => navigate("/admin/profile")}>
|
||||
<User className="mr-2 h-4 w-4" /> Profile
|
||||
<User className="me-2 h-4 w-4" /> {t("userMenu.profile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate("/admin/settings-platform")}>
|
||||
<Settings className="mr-2 h-4 w-4" /> Settings
|
||||
<Settings className="me-2 h-4 w-4" /> {t("userMenu.settings")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" /> Logout
|
||||
<LogOut className="me-2 h-4 w-4" /> {t("userMenu.logout")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<AiAssistantDrawer />
|
||||
<Link
|
||||
to="/admin/tickets"
|
||||
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Need help?</span>
|
||||
</Link>
|
||||
{!isWizardRoute && (
|
||||
<Link
|
||||
to="/admin/tickets"
|
||||
className="fixed bottom-6 end-6 z-40 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">{t("chrome.needHelp")}</span>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -273,67 +369,78 @@ function AdminSidebar() {
|
||||
const collapsed = state === "collapsed";
|
||||
const { user } = useAuth();
|
||||
const { hasAnyPermission, isAdmin } = usePermissions();
|
||||
const { t, i18n } = useTranslation();
|
||||
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
|
||||
|
||||
const showManagement = isAdmin || hasAnyPermission(["view_entities", "view_students", "view_teachers"]);
|
||||
const showReports = isAdmin || hasAnyPermission(["view_statistics", "view_student_performance", "view_entity_statistics"]);
|
||||
const showPayments = isAdmin || hasAnyPermission(["view_payment_record", "pay_entity"]);
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<Sidebar side={sidebarSide} collapsible="icon">
|
||||
<SidebarHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/logo.png" alt="EnCoach" className="h-8 w-8 rounded-lg shrink-0 object-contain" />
|
||||
{!collapsed && (
|
||||
<span className="text-lg font-bold tracking-tight text-sidebar-foreground">
|
||||
<span className="text-[hsl(42,40%,62%)]">En</span>
|
||||
<span>Coach</span>
|
||||
</span>
|
||||
{collapsed ? (
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt={t("chrome.adminAlt")}
|
||||
className="h-9 w-9 shrink-0 rounded-lg object-contain"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt={t("chrome.adminAltLong")}
|
||||
className="h-14 w-auto shrink-0 object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarSeparator />
|
||||
<SidebarContent>
|
||||
<SidebarNavGroup label="Overview" items={overviewItems} />
|
||||
<SidebarNavGroup label="LMS" items={lmsItems} />
|
||||
<SidebarNavGroup label="Adaptive Learning" items={adaptiveItems} />
|
||||
<SidebarNavGroup label="Institutional" items={institutionalItems} />
|
||||
<SidebarNavGroup label="Academic" items={academicItems} />
|
||||
{showManagement && <SidebarNavGroup label="Management" items={managementItems} />}
|
||||
{showReports && <SidebarNavGroup label="Reports" items={reportItems} />}
|
||||
<SidebarNavGroup label="Configuration" items={configItems} />
|
||||
<SidebarNavGroup labelKey="sidebarGroup.overview" items={overviewItems} />
|
||||
<SidebarNavGroup labelKey="sidebarGroup.lms" items={lmsItems} />
|
||||
<SidebarNavGroup labelKey="sidebarGroup.adaptiveLearning" items={adaptiveItems} />
|
||||
<SidebarNavGroup labelKey="sidebarGroup.institutional" items={institutionalItems} />
|
||||
<SidebarNavGroup labelKey="sidebarGroup.academic" items={academicItems} />
|
||||
{showManagement && <SidebarNavGroup labelKey="sidebarGroup.management" items={managementItems} />}
|
||||
{showReports && <SidebarNavGroup labelKey="sidebarGroup.reports" items={reportItems} />}
|
||||
<SidebarNavGroup labelKey="sidebarGroup.configuration" items={configItems} />
|
||||
|
||||
<SidebarGroup>
|
||||
<Collapsible defaultOpen className="group/collapsible">
|
||||
<SidebarGroupLabel asChild>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between">
|
||||
Training
|
||||
{t("sidebarGroup.training")}
|
||||
{!collapsed && <ChevronDown className="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />}
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{trainingItems.map((item) => (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton asChild tooltip={item.title}>
|
||||
<NavLink
|
||||
to={item.url}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{item.title}</span>}
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{trainingItems.map((item) => {
|
||||
const label = t(item.titleKey);
|
||||
return (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton asChild tooltip={label}>
|
||||
<NavLink
|
||||
to={item.url}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarNavGroup label="Support" items={showPayments ? supportItems : supportItems.filter(i => i.url !== "/admin/payment-record")} />
|
||||
<SidebarNavGroup labelKey="sidebarGroup.support" items={showPayments ? supportItems : supportItems.filter(i => i.url !== "/admin/payment-record")} />
|
||||
</SidebarContent>
|
||||
<SidebarSeparator />
|
||||
<SidebarFooter className="p-3">
|
||||
@@ -343,7 +450,7 @@ function AdminSidebar() {
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name ?? "User"}</span>
|
||||
<span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name ?? t("userMenu.userFallback")}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{user?.email ?? ""}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Outlet, useLocation, Link, useNavigate } from "react-router-dom";
|
||||
import { Outlet, useLocation, Link, useNavigate, useMatch } from "react-router-dom";
|
||||
import { Suspense } from "react";
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { AppSidebar } from "@/components/AppSidebar";
|
||||
import {
|
||||
@@ -6,14 +7,16 @@ import {
|
||||
BreadcrumbPage, BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Ticket, Settings, User, LogOut, HelpCircle } from "lucide-react";
|
||||
import { Ticket, Settings, User, LogOut, HelpCircle, Building2, ChevronDown } from "lucide-react";
|
||||
import React from "react";
|
||||
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
|
||||
import AiSearchBar from "@/components/ai/AiSearchBar";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
const routeLabels: Record<string, string> = {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -77,8 +80,26 @@ function AppBreadcrumbs() {
|
||||
);
|
||||
}
|
||||
|
||||
// Local Suspense fallback so the sidebar/header keep rendering while a
|
||||
// lazy route chunk loads. See AdminLmsLayout for the full rationale.
|
||||
function RouteContentFallback() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppLayout() {
|
||||
const navigate = useNavigate();
|
||||
const { user, selectedEntity, setSelectedEntityId } = useAuth();
|
||||
// Hide the floating "Need help?" pill on multi-step wizard routes.
|
||||
// The pill sits at `fixed bottom-6 right-6` with z-50 and was
|
||||
// intercepting clicks on the wizard's own Next/Finish footer at most
|
||||
// viewport heights, blocking users from finishing the flow. The AI
|
||||
// Assistant orb (also bottom-right) stays — it's smaller and
|
||||
// genuinely useful inside the wizard.
|
||||
const isWizardRoute = !!useMatch("/admin/smart-wizard/*");
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
@@ -92,6 +113,34 @@ export default function AppLayout() {
|
||||
</div>
|
||||
<AiSearchBar />
|
||||
<div className="flex items-center gap-2">
|
||||
{user?.entities?.length ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="max-w-40 truncate">
|
||||
{selectedEntity?.name ?? user.entities[0]?.name ?? "Entity"}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
{user.entities.map((entity) => {
|
||||
const active = selectedEntity?.id === entity.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={entity.id}
|
||||
onClick={() => setSelectedEntityId(entity.id)}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="truncate">{entity.name}</span>
|
||||
{active ? <Badge variant="secondary">Current</Badge> : null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/tickets")} className="text-muted-foreground hover:text-foreground">
|
||||
<Ticket className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -127,19 +176,22 @@ export default function AppLayout() {
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<AiAssistantDrawer />
|
||||
{/* Floating help button */}
|
||||
<Link
|
||||
to="/tickets"
|
||||
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Need help?</span>
|
||||
</Link>
|
||||
{!isWizardRoute && (
|
||||
<Link
|
||||
to="/tickets"
|
||||
className="fixed bottom-6 right-6 z-40 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Need help?</span>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { NavLink } from "@/components/NavLink";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
||||
SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
|
||||
@@ -21,12 +22,12 @@ const mainItems = [
|
||||
{ title: "Rubrics", url: "/rubrics", icon: BookOpen },
|
||||
{ title: "Generation", url: "/generation", icon: Wand2 },
|
||||
{ title: "Approval Workflows", url: "/approval-workflows", icon: GitBranch },
|
||||
{ title: "Classrooms", url: "/classrooms", icon: GraduationCap },
|
||||
];
|
||||
|
||||
const managementItems = [
|
||||
{ title: "Users", url: "/users", icon: Users },
|
||||
{ title: "Entities", url: "/entities", icon: Building2 },
|
||||
{ title: "Classrooms", url: "/classrooms", icon: GraduationCap },
|
||||
];
|
||||
|
||||
const reportItems = [
|
||||
@@ -81,9 +82,11 @@ export function AppSidebar() {
|
||||
const { state } = useSidebar();
|
||||
const collapsed = state === "collapsed";
|
||||
const location = useLocation();
|
||||
const { i18n } = useTranslation();
|
||||
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<Sidebar side={sidebarSide} collapsible="icon">
|
||||
<SidebarHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center shrink-0">
|
||||
@@ -100,7 +103,7 @@ export function AppSidebar() {
|
||||
<SidebarSeparator />
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarNavGroup label="Academic" items={mainItems} />
|
||||
<SidebarNavGroup label="LMS" items={mainItems} />
|
||||
<SidebarNavGroup label="Management" items={managementItems} />
|
||||
<SidebarNavGroup label="Reports" items={reportItems} />
|
||||
|
||||
|
||||
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 { cn } from "@/lib/utils";
|
||||
|
||||
@@ -9,7 +9,14 @@ interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
|
||||
}
|
||||
|
||||
const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
|
||||
({ className, activeClassName, pendingClassName, to, ...props }, ref) => {
|
||||
({ className, activeClassName, pendingClassName, to, onClick, ...props }, ref) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isExternalTo = (value: NavLinkProps["to"]): boolean => {
|
||||
if (typeof value !== "string") return false;
|
||||
return /^(https?:)?\/\//.test(value) || value.startsWith("mailto:") || value.startsWith("tel:");
|
||||
};
|
||||
|
||||
return (
|
||||
<RouterNavLink
|
||||
ref={ref}
|
||||
@@ -17,6 +24,31 @@ const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
|
||||
className={({ isActive, isPending }) =>
|
||||
cn(className, isActive && activeClassName, isPending && pendingClassName)
|
||||
}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.altKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
props.target === "_blank" ||
|
||||
isExternalTo(to)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Force client-side route transitions for app menu links.
|
||||
event.preventDefault();
|
||||
navigate(to, {
|
||||
replace: props.replace,
|
||||
state: props.state,
|
||||
relative: props.relative,
|
||||
preventScrollReset: props.preventScrollReset,
|
||||
viewTransition: props.viewTransition,
|
||||
});
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,35 +1,50 @@
|
||||
import { Bell } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export default function NotificationDropdown() {
|
||||
const { t } = useTranslation();
|
||||
const notifications: { id: string; title: string; message: string; time: string; read: boolean; type: string }[] = [];
|
||||
const unread = notifications.filter((n) => !n.read).length;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative text-muted-foreground hover:text-foreground">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("notifications.ariaLabel")}
|
||||
title={t("notifications.ariaLabel")}
|
||||
className="relative text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
{unread > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 h-4 w-4 rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground flex items-center justify-center">
|
||||
<span className="absolute -top-0.5 -end-0.5 h-4 w-4 rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground flex items-center justify-center">
|
||||
{unread}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
<div className="px-3 py-2 font-semibold text-sm">Notifications</div>
|
||||
<div className="px-3 py-2 font-semibold text-sm">{t("notifications.title")}</div>
|
||||
<DropdownMenuSeparator />
|
||||
{notifications.slice(0, 4).map((n) => (
|
||||
<DropdownMenuItem key={n.id} className="flex flex-col items-start gap-0.5 py-2.5 cursor-pointer">
|
||||
<span className={`text-sm font-medium ${!n.read ? "text-foreground" : "text-muted-foreground"}`}>{n.title}</span>
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">{n.message}</span>
|
||||
<span className="text-[10px] text-muted-foreground/70">{n.time}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{t("notifications.empty")}
|
||||
</div>
|
||||
) : (
|
||||
notifications.slice(0, 4).map((n) => (
|
||||
<DropdownMenuItem key={n.id} className="flex flex-col items-start gap-0.5 py-2.5 cursor-pointer">
|
||||
<span className={`text-sm font-medium ${!n.read ? "text-foreground" : "text-muted-foreground"}`}>{n.title}</span>
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">{n.message}</span>
|
||||
<span className="text-[10px] text-muted-foreground/70">{n.time}</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
250
src/components/QuickSetupWizard.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LucideIcon, Check, ChevronRight, Sparkles } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Smart-setup wizard primitives.
|
||||
*
|
||||
* The wizard is purposefully thin: each step / quick-create card is a deep
|
||||
* link into an existing creation page that already knows how to talk to the
|
||||
* backend. We don't re-implement forms here — the value of this screen is
|
||||
* the guided sequence, the visual "what's next" hint, and the completion
|
||||
* ticks that show which parts of the setup still need attention.
|
||||
*
|
||||
* Completion detection is optional and client-only. A caller can supply a
|
||||
* `check` function that returns a Promise<boolean>; the wizard runs it via
|
||||
* react-query so the UI refreshes automatically when the user returns from
|
||||
* a sub-page without any extra plumbing.
|
||||
*/
|
||||
|
||||
export interface WizardStep {
|
||||
/** Stable key used for react-query cache. */
|
||||
id: string;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
/** Destination page that performs the actual creation. */
|
||||
to: string;
|
||||
icon: LucideIcon;
|
||||
/** Optional completion check. When omitted the step is never auto-ticked. */
|
||||
check?: () => Promise<boolean>;
|
||||
/** Optional i18n key for a longer help tooltip. */
|
||||
helpKey?: string;
|
||||
}
|
||||
|
||||
export interface QuickCreate {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
to: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export interface QuickSetupWizardProps {
|
||||
/** Page heading i18n key, e.g. "quickSetup.adminTitle". */
|
||||
titleKey: string;
|
||||
/** Short lead paragraph i18n key. */
|
||||
subtitleKey: string;
|
||||
/** Ordered "Recommended flow" steps. */
|
||||
steps: WizardStep[];
|
||||
/** Side-grid of one-click creates that don't belong to the main flow. */
|
||||
quickCreates: QuickCreate[];
|
||||
}
|
||||
|
||||
export function QuickSetupWizard({
|
||||
titleKey,
|
||||
subtitleKey,
|
||||
steps,
|
||||
quickCreates,
|
||||
}: QuickSetupWizardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Run all completion checks in parallel. Each step with a `check` gets its
|
||||
// own cached query keyed by the step id. Steps without a `check` just
|
||||
// resolve to `undefined` and render as neutral.
|
||||
const queries = useQueries({
|
||||
queries: steps.map((step) => ({
|
||||
queryKey: ["quick-setup-check", step.id],
|
||||
queryFn: step.check ?? (async () => undefined),
|
||||
enabled: Boolean(step.check),
|
||||
// Re-check when the user comes back from a sub-page — that's the most
|
||||
// common path to "un-greying" a step after they've created a rubric /
|
||||
// structure / exam.
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 30_000,
|
||||
})),
|
||||
});
|
||||
|
||||
const completedCount = queries.filter((q) => q.data === true).length;
|
||||
const totalCheckable = steps.filter((s) => s.check).length;
|
||||
const progress = totalCheckable > 0 ? Math.round((completedCount / totalCheckable) * 100) : 0;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="space-y-6">
|
||||
{/* Heading + progress */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t(titleKey)}</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground max-w-2xl">{t(subtitleKey)}</p>
|
||||
</div>
|
||||
{totalCheckable > 0 && (
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("quickSetup.progressLabel")}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">
|
||||
{completedCount}/{totalCheckable}
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 w-32 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recommended flow */}
|
||||
<section aria-labelledby="quick-setup-flow">
|
||||
<h2
|
||||
id="quick-setup-flow"
|
||||
className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3"
|
||||
>
|
||||
{t("quickSetup.recommendedFlow")}
|
||||
</h2>
|
||||
|
||||
<ol className="space-y-3">
|
||||
{steps.map((step, index) => {
|
||||
const query = queries[index];
|
||||
const Icon = step.icon;
|
||||
const isDone = query?.data === true;
|
||||
|
||||
return (
|
||||
<li key={step.id}>
|
||||
<Card
|
||||
className={cn(
|
||||
"transition-shadow hover:shadow-md",
|
||||
isDone && "border-green-500/40 bg-green-500/5",
|
||||
)}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
{/* Step number / done tick */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full font-semibold",
|
||||
isDone
|
||||
? "bg-green-500 text-white"
|
||||
: "bg-primary/10 text-primary",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{isDone ? <Check className="h-5 w-5" /> : index + 1}
|
||||
</div>
|
||||
|
||||
{/* Title + description */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<h3 className="font-medium">{t(step.titleKey)}</h3>
|
||||
{isDone && (
|
||||
<Badge variant="outline" className="border-green-500/50 text-green-600 text-[10px]">
|
||||
{t("quickSetup.ready")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{t(step.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{step.helpKey && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground"
|
||||
aria-label={t("quickSetup.helpAria")}
|
||||
>
|
||||
?
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-xs">
|
||||
{t(step.helpKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button asChild size="sm" variant={isDone ? "outline" : "default"}>
|
||||
<Link to={step.to} className="flex items-center gap-1">
|
||||
{isDone ? t("quickSetup.review") : t("quickSetup.start")}
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Quick creates */}
|
||||
{quickCreates.length > 0 && (
|
||||
<section aria-labelledby="quick-setup-other" className="pt-2">
|
||||
<h2
|
||||
id="quick-setup-other"
|
||||
className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3"
|
||||
>
|
||||
{t("quickSetup.otherQuickCreates")}
|
||||
</h2>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{quickCreates.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Card key={item.id} className="transition-all hover:shadow-md hover:-translate-y-0.5">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<CardTitle className="text-base">{t(item.titleKey)}</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="line-clamp-2">
|
||||
{t(item.descriptionKey)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild variant="secondary" size="sm" className="w-full">
|
||||
<Link to={item.to} className="flex items-center justify-center gap-1">
|
||||
{t("quickSetup.open")}
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuickSetupWizard;
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Outlet, Link, useNavigate, useLocation } from "react-router-dom";
|
||||
import { Outlet, useNavigate } from "react-router-dom";
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import {
|
||||
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
||||
@@ -8,22 +10,28 @@ import {
|
||||
import { NavLink } from "@/components/NavLink";
|
||||
import { useAuth, UserRole } from "@/contexts/AuthContext";
|
||||
import NotificationDropdown from "@/components/NotificationDropdown";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { LanguageToggle } from "@/components/LanguageToggle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { LogOut, User, Settings, LucideIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { LogOut, User, LucideIcon } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Non-admin portal shell (student / teacher / etc.). Nav items hold i18n
|
||||
* keys (`titleKey` / `labelKey`) rather than literal strings so they react
|
||||
* to language changes without re-mounting the layout.
|
||||
*/
|
||||
export interface NavItem {
|
||||
title: string;
|
||||
titleKey: string;
|
||||
url: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export interface NavGroup {
|
||||
label: string;
|
||||
labelKey: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
@@ -34,30 +42,34 @@ interface RoleLayoutProps {
|
||||
|
||||
function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) {
|
||||
const { state } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
const collapsed = state === "collapsed";
|
||||
|
||||
return (
|
||||
<>
|
||||
{navGroups.map((group) => (
|
||||
<SidebarGroup key={group.label}>
|
||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||||
<SidebarGroup key={group.labelKey}>
|
||||
<SidebarGroupLabel>{t(group.labelKey)}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton asChild tooltip={item.title}>
|
||||
<NavLink
|
||||
to={item.url}
|
||||
end={item.url.endsWith("/dashboard")}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{item.title}</span>}
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{group.items.map((item) => {
|
||||
const label = t(item.titleKey);
|
||||
return (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton asChild tooltip={label}>
|
||||
<NavLink
|
||||
to={item.url}
|
||||
end={item.url.endsWith("/dashboard")}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
@@ -66,9 +78,21 @@ function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Keeps the student/teacher sidebar + header mounted while the lazy route
|
||||
// chunk is fetching. See AdminLmsLayout for the rationale.
|
||||
function RouteContentFallback() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation();
|
||||
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
|
||||
|
||||
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
|
||||
|
||||
@@ -77,17 +101,25 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
const roleLabel = t(`roles.${role}`, { defaultValue: role });
|
||||
const portalTitle = `${roleLabel} ${t("chrome.portalSuffix")}`.trim();
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="min-h-screen flex w-full">
|
||||
<Sidebar collapsible="icon">
|
||||
<Sidebar side={sidebarSide} collapsible="icon">
|
||||
<SidebarHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/logo.png" alt="EnCoach" className="h-8 w-8 rounded-lg shrink-0 object-contain" />
|
||||
<span className="text-lg font-bold tracking-tight text-sidebar-foreground group-data-[collapsible=icon]:hidden">
|
||||
<span className="text-[hsl(42,40%,62%)]">En</span>
|
||||
<span>Coach</span>
|
||||
</span>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt={t("chrome.adminAlt")}
|
||||
className="h-9 w-9 shrink-0 rounded-lg object-contain group-data-[collapsible=icon]:block hidden"
|
||||
/>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt={t("chrome.adminAltLong")}
|
||||
className="h-14 w-auto shrink-0 object-contain group-data-[collapsible=icon]:hidden"
|
||||
/>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarSeparator />
|
||||
@@ -101,7 +133,9 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
||||
<span className="text-xs font-semibold text-primary">{initials}</span>
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 group-data-[collapsible=icon]:hidden">
|
||||
<span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name}</span>
|
||||
<span className="text-sm font-medium truncate text-sidebar-foreground">
|
||||
{user?.name ?? t("userMenu.userFallback")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{user?.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,10 +145,12 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<SidebarTrigger />
|
||||
<span className="text-sm font-medium text-muted-foreground capitalize">{role} Portal</span>
|
||||
<SidebarTrigger aria-label={t("chrome.toggleSidebar")} />
|
||||
<span className="text-sm font-medium text-muted-foreground">{portalTitle}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LanguageToggle />
|
||||
<ThemeToggle />
|
||||
<NotificationDropdown />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -126,23 +162,25 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-sm font-medium">{user?.name}</p>
|
||||
<p className="text-sm font-medium">{user?.name ?? t("userMenu.userFallback")}</p>
|
||||
<p className="text-xs text-muted-foreground">{user?.email}</p>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => navigate(`/${role}/profile`)}>
|
||||
<User className="mr-2 h-4 w-4" /> Profile
|
||||
<User className="me-2 h-4 w-4" /> {t("userMenu.profile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" /> Logout
|
||||
<LogOut className="me-2 h-4 w-4" /> {t("userMenu.logout")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,51 +1,63 @@
|
||||
import RoleLayout, { NavGroup } from "./RoleLayout";
|
||||
import ExamPopup from "./student/ExamPopup";
|
||||
import {
|
||||
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
|
||||
CalendarCheck, Calendar, User, Target, GraduationCap, ListChecks,
|
||||
MessageSquare, Megaphone, Mail, TrendingUp,
|
||||
CalendarCheck, Calendar, User, Target, ListChecks,
|
||||
MessageSquare, Megaphone, Mail, TrendingUp, Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* Student portal shell. Nav items hold i18n keys which `RoleLayout`
|
||||
* resolves via `useTranslation`, so switching language does not require
|
||||
* re-mounting the layout.
|
||||
*/
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
label: "Learning",
|
||||
labelKey: "sidebarGroup.learning",
|
||||
items: [
|
||||
{ title: "Dashboard", url: "/student/dashboard", icon: LayoutDashboard },
|
||||
{ title: "My Courses", url: "/student/courses", icon: BookOpen },
|
||||
{ title: "Subject Registration", url: "/student/subject-registration", icon: ListChecks },
|
||||
{ title: "Assignments", url: "/student/assignments", icon: ClipboardList },
|
||||
{ titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard },
|
||||
{ titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen },
|
||||
{ titleKey: "nav.myCoursePlans", url: "/student/course-plans", icon: Sparkles },
|
||||
{ titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
|
||||
{ titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Adaptive Learning",
|
||||
labelKey: "sidebarGroup.adaptiveLearning",
|
||||
items: [
|
||||
{ title: "My Subjects", url: "/student/subjects", icon: Target },
|
||||
{ titleKey: "nav.mySubjects", url: "/student/subjects", icon: Target },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Progress",
|
||||
labelKey: "sidebarGroup.progress",
|
||||
items: [
|
||||
{ title: "Grades", url: "/student/grades", icon: BarChart3 },
|
||||
{ title: "Attendance", url: "/student/attendance", icon: CalendarCheck },
|
||||
{ title: "Timetable", url: "/student/timetable", icon: Calendar },
|
||||
{ title: "My Journey", url: "/student/journey", icon: TrendingUp },
|
||||
{ titleKey: "nav.grades", url: "/student/grades", icon: BarChart3 },
|
||||
{ titleKey: "nav.attendance", url: "/student/attendance", icon: CalendarCheck },
|
||||
{ titleKey: "nav.timetable", url: "/student/timetable", icon: Calendar },
|
||||
{ titleKey: "nav.myJourney", url: "/student/journey", icon: TrendingUp },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Communication",
|
||||
labelKey: "sidebarGroup.communication",
|
||||
items: [
|
||||
{ title: "Discussions", url: "/student/discussions", icon: MessageSquare },
|
||||
{ title: "Messages", url: "/student/messages", icon: Mail },
|
||||
{ title: "Announcements", url: "/student/announcements", icon: Megaphone },
|
||||
{ titleKey: "nav.discussions", url: "/student/discussions", icon: MessageSquare },
|
||||
{ titleKey: "nav.messages", url: "/student/messages", icon: Mail },
|
||||
{ titleKey: "nav.announcements", url: "/student/announcements", icon: Megaphone },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Account",
|
||||
labelKey: "sidebarGroup.account",
|
||||
items: [
|
||||
{ title: "Profile", url: "/student/profile", icon: User },
|
||||
{ titleKey: "nav.profile", url: "/student/profile", icon: User },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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 {
|
||||
LayoutDashboard, BookOpen, ClipboardList, FileText,
|
||||
LayoutDashboard, BookOpen, ClipboardList,
|
||||
CalendarCheck, Users, Calendar, User, MessageSquare,
|
||||
Megaphone, Wand2,
|
||||
Megaphone, Library, Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
/** Teacher portal shell. See `StudentLayout` for the nav-config rationale. */
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
label: "Teaching",
|
||||
labelKey: "sidebarGroup.teaching",
|
||||
items: [
|
||||
{ title: "Dashboard", url: "/teacher/dashboard", icon: LayoutDashboard },
|
||||
{ title: "Courses", url: "/teacher/courses", icon: BookOpen },
|
||||
{ title: "Assignments", url: "/teacher/assignments", icon: ClipboardList },
|
||||
{ titleKey: "nav.quickSetup", url: "/teacher/quick-setup", icon: Sparkles },
|
||||
{ titleKey: "nav.dashboard", url: "/teacher/dashboard", icon: LayoutDashboard },
|
||||
{ titleKey: "nav.courses", url: "/teacher/courses", icon: BookOpen },
|
||||
{ titleKey: "nav.resourceLibrary", url: "/teacher/library", icon: Library },
|
||||
{ titleKey: "nav.assignments", url: "/teacher/assignments", icon: ClipboardList },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Management",
|
||||
labelKey: "sidebarGroup.management",
|
||||
items: [
|
||||
{ title: "Attendance", url: "/teacher/attendance", icon: CalendarCheck },
|
||||
{ title: "Students", url: "/teacher/students", icon: Users },
|
||||
{ title: "Timetable", url: "/teacher/timetable", icon: Calendar },
|
||||
{ titleKey: "nav.attendance", url: "/teacher/attendance", icon: CalendarCheck },
|
||||
{ titleKey: "nav.students", url: "/teacher/students", icon: Users },
|
||||
{ titleKey: "nav.timetable", url: "/teacher/timetable", icon: Calendar },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Communication",
|
||||
labelKey: "sidebarGroup.communication",
|
||||
items: [
|
||||
{ title: "Discussions", url: "/teacher/discussions", icon: MessageSquare },
|
||||
{ title: "Announcements", url: "/teacher/announcements", icon: Megaphone },
|
||||
{ titleKey: "nav.discussions", url: "/teacher/discussions", icon: MessageSquare },
|
||||
{ titleKey: "nav.announcements", url: "/teacher/announcements", icon: Megaphone },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Account",
|
||||
labelKey: "sidebarGroup.account",
|
||||
items: [
|
||||
{ title: "Profile", url: "/teacher/profile", icon: User },
|
||||
{ titleKey: "nav.profile", url: "/teacher/profile", icon: User },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
71
src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Moon, Sun, Monitor } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
/**
|
||||
* Light/dark/system theme toggle.
|
||||
*
|
||||
* We intentionally render a neutral placeholder until `next-themes` has
|
||||
* hydrated — otherwise the first client render disagrees with the server-less
|
||||
* pre-render and the icon briefly flashes the wrong state.
|
||||
*/
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const isDark = mounted && (resolvedTheme ?? theme) === "dark";
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("theme.toggleLabel")}
|
||||
title={t("theme.toggleLabel")}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{mounted ? (
|
||||
isDark ? (
|
||||
<Moon className="h-4 w-4" />
|
||||
) : (
|
||||
<Sun className="h-4 w-4" />
|
||||
)
|
||||
) : (
|
||||
<Sun className="h-4 w-4 opacity-0" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
<Sun className="me-2 h-4 w-4" /> {t("theme.light")}
|
||||
{theme === "light" && <span className="ms-auto text-xs text-muted-foreground">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
<Moon className="me-2 h-4 w-4" /> {t("theme.dark")}
|
||||
{theme === "dark" && <span className="ms-auto text-xs text-muted-foreground">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
<Monitor className="me-2 h-4 w-4" /> {t("theme.system")}
|
||||
{theme === "system" && <span className="ms-auto text-xs text-muted-foreground">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThemeToggle;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, X, Sparkles, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { analyticsService } from "@/services/analytics.service";
|
||||
@@ -7,32 +8,34 @@ import { analyticsService } from "@/services/analytics.service";
|
||||
export default function AiAlertBanner() {
|
||||
const [dismissedIds, setDismissedIds] = useState<Set<string>>(() => new Set());
|
||||
const [errorDismissed, setErrorDismissed] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: alerts, isLoading, isError, error } = useQuery({
|
||||
const { data: resp, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["ai", "alerts"],
|
||||
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) {
|
||||
return (
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-center gap-3">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-warning shrink-0" />
|
||||
<p className="text-sm text-muted-foreground">Loading alerts…</p>
|
||||
<p className="text-sm text-muted-foreground">{t("ai.loadingAlerts")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError && !errorDismissed) {
|
||||
return (
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3">
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3" dir="auto">
|
||||
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3 text-primary" /> Alerts unavailable
|
||||
<Sparkles className="h-3 w-3 text-primary shrink-0" /> {t("ai.alertsUnavailable")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{error instanceof Error ? error.message : "Could not load alerts."}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{error instanceof Error ? error.message : t("ai.couldNotLoadAlerts")}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setErrorDismissed(true)}>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -43,11 +46,11 @@ export default function AiAlertBanner() {
|
||||
|
||||
if (isError && errorDismissed) return null;
|
||||
|
||||
if (!alerts?.length) {
|
||||
if (!alerts.length) {
|
||||
return (
|
||||
<div className="rounded-lg border border-muted bg-muted/20 p-4 flex items-start gap-3">
|
||||
<Sparkles className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-muted-foreground">No AI alerts right now.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("ai.noAlertsRightNow")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,12 +59,12 @@ export default function AiAlertBanner() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{visible.map((alert) => (
|
||||
<div key={alert.id} className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3">
|
||||
{visible.map((alert, idx) => (
|
||||
<div key={idx} className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3" dir="auto">
|
||||
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3 text-primary" /> {alert.title}
|
||||
<Sparkles className="h-3 w-3 text-primary shrink-0" /> {alert.title}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{alert.description}</p>
|
||||
</div>
|
||||
@@ -69,7 +72,7 @@ export default function AiAlertBanner() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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" />
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -8,38 +9,43 @@ import { Sparkles, Send, Loader2 } from "lucide-react";
|
||||
import { coachingService } from "@/services/coaching.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const quickActions = [
|
||||
"Platform health summary",
|
||||
"Show dropout risks",
|
||||
"Compare course performance",
|
||||
"Suggest batch improvements",
|
||||
];
|
||||
|
||||
export default function AiAssistantDrawer() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<{ role: "user" | "ai"; text: string }[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const location = useLocation();
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Re-compute when language switches so quick-action chips show translated
|
||||
// labels immediately. Chips are stored by label (what we send to the
|
||||
// backend), so localizing the label is safe here — the backend treats
|
||||
// them as free-form prompts.
|
||||
const quickActions = useMemo(
|
||||
() => [
|
||||
t("ai.quickHealth"),
|
||||
t("ai.quickDropouts"),
|
||||
t("ai.quickCompare"),
|
||||
t("ai.quickBatch"),
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const chatMutation = useMutation({
|
||||
mutationFn: (message: string) =>
|
||||
coachingService.chat({ message, context: { page: location.pathname } }),
|
||||
onSuccess: (data) => {
|
||||
setMessages((prev) => [...prev, { role: "ai", text: data.message }]);
|
||||
setMessages((prev) => [...prev, { role: "ai", text: data.reply }]);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not get a reply",
|
||||
description: err.message || "Something went wrong. Try again.",
|
||||
title: t("ai.replyErrorTitle"),
|
||||
description: err.message || t("ai.replyErrorDesc"),
|
||||
});
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: "ai",
|
||||
text: "Sorry, I could not reach the assistant. Please try again in a moment.",
|
||||
},
|
||||
{ role: "ai", text: t("ai.fallbackReply") },
|
||||
]);
|
||||
},
|
||||
});
|
||||
@@ -56,8 +62,9 @@ export default function AiAssistantDrawer() {
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed bottom-20 right-6 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
aria-label="AI Assistant"
|
||||
className="fixed bottom-20 end-6 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
aria-label={t("ai.assistantLabel")}
|
||||
title={t("ai.assistantLabel")}
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -67,7 +74,7 @@ export default function AiAssistantDrawer() {
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
EnCoach AI Assistant
|
||||
{t("ai.assistantTitle")}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -90,8 +97,8 @@ export default function AiAssistantDrawer() {
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center text-muted-foreground text-sm py-8">
|
||||
<Sparkles className="h-8 w-8 mx-auto mb-3 text-primary/40" />
|
||||
<p>Ask me anything about the platform,</p>
|
||||
<p>or click a quick action above.</p>
|
||||
<p>{t("ai.emptyLine1")}</p>
|
||||
<p>{t("ai.emptyLine2")}</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
@@ -99,27 +106,27 @@ export default function AiAssistantDrawer() {
|
||||
key={i}
|
||||
className={`rounded-lg p-3 text-sm ${
|
||||
msg.role === "user"
|
||||
? "bg-primary text-primary-foreground ml-8"
|
||||
: "bg-muted mr-8"
|
||||
? "bg-primary text-primary-foreground ms-8"
|
||||
: "bg-muted me-8"
|
||||
}`}
|
||||
>
|
||||
{msg.role === "ai" && (
|
||||
<Sparkles className="h-3 w-3 text-primary inline mr-1.5 -mt-0.5" />
|
||||
<Sparkles className="h-3 w-3 text-primary inline me-1.5 -mt-0.5" />
|
||||
)}
|
||||
{msg.text}
|
||||
</div>
|
||||
))}
|
||||
{chatMutation.isPending && (
|
||||
<div className="bg-muted rounded-lg p-3 text-sm mr-8 flex items-center gap-2">
|
||||
<div className="bg-muted rounded-lg p-3 text-sm me-8 flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<span className="text-muted-foreground">Thinking...</span>
|
||||
<span className="text-muted-foreground">{t("ai.thinking")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-3 border-t mt-auto">
|
||||
<Input
|
||||
placeholder="Ask anything..."
|
||||
placeholder={t("ai.placeholder")}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSend(input)}
|
||||
@@ -128,6 +135,8 @@ export default function AiAssistantDrawer() {
|
||||
size="icon"
|
||||
onClick={() => handleSend(input)}
|
||||
disabled={!input.trim() || chatMutation.isPending}
|
||||
aria-label={t("ai.send")}
|
||||
title={t("ai.send")}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -25,6 +25,8 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
||||
},
|
||||
});
|
||||
|
||||
type OptResult = Awaited<ReturnType<typeof analyticsService.getBatchOptimization>>;
|
||||
|
||||
const handleOpen = () => {
|
||||
if (batchId == null) {
|
||||
toast({
|
||||
@@ -39,9 +41,23 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
||||
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 = () => {
|
||||
toast({ title: "Suggestion Applied", description: "Batch split recommendation has been saved successfully." });
|
||||
setOpen(false);
|
||||
applyMutation.mutate();
|
||||
};
|
||||
|
||||
const onOpenChange = (next: boolean) => {
|
||||
@@ -49,9 +65,10 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
||||
if (!next) mutation.reset();
|
||||
};
|
||||
|
||||
const suggestions = mutation.data ?? [];
|
||||
const showResults = !mutation.isPending && !mutation.isError && suggestions.length > 0;
|
||||
const showEmpty = !mutation.isPending && !mutation.isError && mutation.isSuccess && suggestions.length === 0;
|
||||
const optData = mutation.data as OptResult | undefined;
|
||||
const hasSuggestions = !!optData?.summary;
|
||||
const showResults = !mutation.isPending && !mutation.isError && hasSuggestions;
|
||||
const showEmpty = !mutation.isPending && !mutation.isError && mutation.isSuccess && !hasSuggestions;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -71,20 +88,28 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
||||
</div>
|
||||
) : mutation.isError ? (
|
||||
<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-3 max-h-[50vh] overflow-y-auto">
|
||||
{suggestions.map((s, i) => (
|
||||
<div key={i} className="rounded-lg bg-muted/30 p-4 border border-border/60">
|
||||
<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 className="rounded-lg bg-muted/30 p-4 border border-border/60">
|
||||
<p className="text-xs font-semibold text-primary uppercase tracking-wide mb-1">{optData.impact} impact</p>
|
||||
<p className="text-sm font-medium">{optData.summary}</p>
|
||||
</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">
|
||||
<Button className="flex-1" onClick={handleApply}>
|
||||
Apply Suggestion
|
||||
<Button className="flex-1" onClick={handleApply} disabled={applyMutation.isPending}>
|
||||
{applyMutation.isPending ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Applying...</>
|
||||
) : (
|
||||
"Apply Suggestion"
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Dismiss
|
||||
|
||||
@@ -40,8 +40,9 @@ export default function AiGeneratorModal() {
|
||||
difficulty,
|
||||
count,
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
setLocalExercises(Array.isArray(res.exercises) ? res.exercises : []);
|
||||
onSuccess: (res: Record<string, unknown>) => {
|
||||
const items = Array.isArray(res.questions) ? res.questions : Array.isArray(res.exercises) ? res.exercises : [];
|
||||
setLocalExercises(items);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
@@ -57,6 +58,21 @@ export default function AiGeneratorModal() {
|
||||
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 handleRemove = (index: number) => {
|
||||
@@ -188,7 +204,17 @@ export default function AiGeneratorModal() {
|
||||
);
|
||||
})}
|
||||
<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
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
|
||||
@@ -19,8 +19,8 @@ export default function AiGradeExplainer({
|
||||
const explainMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
coachingService.explain({
|
||||
context: `IELTS / course grades for student: ${studentName}. Summarize what the scores mean and what to focus on next.`,
|
||||
scores,
|
||||
score_data: scores ?? {},
|
||||
student_context: `IELTS / course grades for student: ${studentName}. Summarize what the scores mean and what to focus on next.`,
|
||||
}),
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
|
||||
@@ -26,9 +26,8 @@ export default function AiGradingAssistant({
|
||||
const gradeMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
analyticsService.getGradingSuggestion({
|
||||
submission_id: submissionId,
|
||||
text: submissionText,
|
||||
...(rubricId !== undefined ? { rubric_id: rubricId } : {}),
|
||||
submission_text: submissionText,
|
||||
skill: "writing",
|
||||
}),
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
@@ -45,7 +44,7 @@ export default function AiGradingAssistant({
|
||||
}, [submissionId, submissionText, rubricId]);
|
||||
|
||||
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
|
||||
? [
|
||||
data.feedback,
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Sparkles, TrendingUp, AlertTriangle, Trophy, Loader2 } from "lucide-react";
|
||||
import { analyticsService } from "@/services/analytics.service";
|
||||
import type { AiInsight } from "@/types";
|
||||
import { Sparkles, TrendingUp, AlertTriangle, Info, Loader2 } from "lucide-react";
|
||||
import { analyticsService, type AiInsightItem } from "@/services/analytics.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const EMPTY_PAYLOAD: Record<string, unknown> = {};
|
||||
|
||||
function insightIcon(type: AiInsight["type"]) {
|
||||
switch (type) {
|
||||
case "positive":
|
||||
return Trophy;
|
||||
case "warning":
|
||||
function insightIcon(severity: AiInsightItem["severity"]) {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return AlertTriangle;
|
||||
default:
|
||||
case "warning":
|
||||
return TrendingUp;
|
||||
default:
|
||||
return Info;
|
||||
}
|
||||
}
|
||||
|
||||
function insightColor(type: AiInsight["type"]) {
|
||||
switch (type) {
|
||||
case "positive":
|
||||
return "text-primary";
|
||||
function insightColor(severity: AiInsightItem["severity"]) {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return "text-destructive";
|
||||
case "warning":
|
||||
return "text-warning";
|
||||
default:
|
||||
return "text-success";
|
||||
return "text-primary";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,14 +36,15 @@ interface Props {
|
||||
|
||||
export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
const payloadKey = useMemo(() => JSON.stringify(data), [data]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (payload: Record<string, unknown>) => analyticsService.getInsights(payload),
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
title: "Insights unavailable",
|
||||
description: err.message || "Could not load AI insights.",
|
||||
title: t("ai.insightsUnavailable"),
|
||||
description: err.message || t("ai.couldNotLoadInsights"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
@@ -51,47 +52,47 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
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]);
|
||||
|
||||
const items = mutation.data ?? [];
|
||||
const items = mutation.data?.insights ?? [];
|
||||
|
||||
return (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
AI Platform Insights
|
||||
{t("ai.platformInsightsTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mutation.isPending && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-8 justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
Loading insights…
|
||||
{t("ai.loadingInsights")}
|
||||
</div>
|
||||
)}
|
||||
{mutation.isError && !mutation.isPending && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">Could not load insights.</p>
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">{t("ai.couldNotLoadInsights")}</p>
|
||||
)}
|
||||
{mutation.isSuccess && items.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">No insights available for this view.</p>
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">{t("ai.noInsightsAvailable")}</p>
|
||||
)}
|
||||
{!mutation.isPending && items.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{items.map((item) => {
|
||||
const Icon = insightIcon(item.type);
|
||||
const color = insightColor(item.type);
|
||||
{items.map((item, idx) => {
|
||||
const Icon = insightIcon(item.severity);
|
||||
const color = insightColor(item.severity);
|
||||
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">
|
||||
<Icon className={`h-4 w-4 ${color}`} />
|
||||
<span className="text-sm font-semibold">{item.title}</span>
|
||||
<Icon className={`h-4 w-4 shrink-0 ${color}`} />
|
||||
<span className="text-sm font-semibold min-w-0">{item.title}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{item.description}</p>
|
||||
{item.metric != null && item.value != null && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{item.metric}: {item.value}
|
||||
{item.recommendation && (
|
||||
<p className="text-xs text-muted-foreground mt-2 italic">
|
||||
{item.recommendation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Sparkles, Search, Loader2, X } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -10,14 +11,15 @@ export default function AiSearchBar() {
|
||||
const [query, setQuery] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const searchMutation = useMutation({
|
||||
mutationFn: (q: string) => analyticsService.search(q),
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Search failed",
|
||||
description: err.message || "Could not complete AI search.",
|
||||
title: t("chrome.aiSearchFailedTitle"),
|
||||
description: err.message || t("chrome.aiSearchFailedDesc"),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -27,16 +29,16 @@ export default function AiSearchBar() {
|
||||
searchMutation.mutate(query.trim());
|
||||
};
|
||||
|
||||
const results = searchMutation.data;
|
||||
const result = searchMutation.data;
|
||||
|
||||
return (
|
||||
<div className="relative max-w-md w-full">
|
||||
<div className="relative">
|
||||
<Sparkles className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary" />
|
||||
<Search className="absolute left-7 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Sparkles className="absolute start-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary" />
|
||||
<Search className="absolute start-7 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Ask anything... e.g. 'Show students with low attendance'"
|
||||
className="pl-12 pr-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1"
|
||||
placeholder={t("chrome.aiSearchPlaceholder")}
|
||||
className="ps-12 pe-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
@@ -50,50 +52,56 @@ export default function AiSearchBar() {
|
||||
setQuery("");
|
||||
searchMutation.reset();
|
||||
}}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
className="absolute end-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(searchMutation.isPending || results !== undefined) && (
|
||||
<div className="absolute top-full mt-1 left-0 right-0 z-50 rounded-lg border bg-popover p-3 shadow-md">
|
||||
{(searchMutation.isPending || result !== undefined) && (
|
||||
<div className="absolute top-full mt-1 start-0 end-0 z-50 rounded-lg border bg-popover p-3 shadow-md">
|
||||
{searchMutation.isPending ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
AI is searching...
|
||||
{t("chrome.aiSearching")}
|
||||
</div>
|
||||
) : results && results.length > 0 ? (
|
||||
) : result?.answer ? (
|
||||
<div className="text-sm space-y-2 max-h-64 overflow-y-auto">
|
||||
{results.map((r, i) => (
|
||||
<div
|
||||
key={`${r.title}-${i}`}
|
||||
className="flex items-start gap-2 border-b border-border/60 pb-2 last:border-0 last:pb-0"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{r.title}</p>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">{r.description}</p>
|
||||
{r.url && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary mt-1 hover:underline"
|
||||
onClick={() => navigate(r.url!)}
|
||||
>
|
||||
Go to {r.url}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-2 pb-2">
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
<p className="text-muted-foreground">{result.answer}</p>
|
||||
</div>
|
||||
{result.suggestions?.length > 0 && (
|
||||
<div className="border-t pt-2 space-y-1">
|
||||
<p className="text-xs font-semibold text-primary">{t("chrome.aiRelatedQueries")}</p>
|
||||
{result.suggestions.map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="block text-xs text-primary hover:underline"
|
||||
onClick={() => { setQuery(s); searchMutation.mutate(s); }}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</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 className="text-sm flex items-start gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
<p>
|
||||
No results for "{query}". Try a different question or keyword.
|
||||
</p>
|
||||
<p>{t("chrome.aiNoResults", { q: query })}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -29,8 +29,11 @@ export default function AiStudyCoach() {
|
||||
suggestMutation.mutate();
|
||||
};
|
||||
|
||||
const suggestions = suggestMutation.data?.suggestions ?? [];
|
||||
const planTips = suggestMutation.data?.study_plan_tips ?? [];
|
||||
const d = suggestMutation.data;
|
||||
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 (
|
||||
<Card className="border-0 shadow-sm bg-primary/5">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sparkles, X, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { coachingService } from "@/services/coaching.service";
|
||||
@@ -12,6 +13,7 @@ interface Props {
|
||||
|
||||
export default function AiTipBanner({ context = "dashboard", variant = "tip", dismissible = true }: Props) {
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["ai", "tip", context],
|
||||
@@ -22,23 +24,30 @@ export default function AiTipBanner({ context = "dashboard", variant = "tip", di
|
||||
|
||||
const bgClass = variant === "recommendation" ? "bg-accent/50 border-accent" : variant === "insight" ? "bg-primary/5 border-primary/20" : "bg-muted/50 border-muted-foreground/10";
|
||||
|
||||
const variantLabel =
|
||||
variant === "insight"
|
||||
? t("ai.insightLabel")
|
||||
: variant === "recommendation"
|
||||
? t("ai.recommendationLabel")
|
||||
: t("ai.tipLabel");
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-center gap-3`}>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">Loading AI tip…</span>
|
||||
<span className="text-sm text-muted-foreground">{t("ai.loadingTip")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`}>
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`} dir="auto">
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<span className="text-xs font-semibold text-primary">AI Tip</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="block text-xs font-semibold text-primary">{t("ai.tipLabel")}</span>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{isError ? (error instanceof Error ? error.message : "Could not load tip.") : "No tip available."}
|
||||
{isError ? (error instanceof Error ? error.message : t("ai.couldNotLoadTip")) : t("ai.noTipAvailable")}
|
||||
</p>
|
||||
</div>
|
||||
{dismissible && (
|
||||
@@ -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 (
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`}>
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`} dir="auto">
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<span className="text-xs font-semibold text-primary">AI {variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}</span>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">No tip for this context yet.</p>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="block text-xs font-semibold text-primary">{variantLabel}</span>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{t("ai.noTipForContext")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const label = data.category && data.category !== "general"
|
||||
? t("ai.categoryTipLabel", { category: data.category.charAt(0).toUpperCase() + data.category.slice(1) })
|
||||
: variantLabel;
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3 animate-in fade-in slide-in-from-top-2 duration-300`}>
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3 animate-in fade-in slide-in-from-top-2 duration-300`} dir="auto">
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<span className="text-xs font-semibold text-primary">
|
||||
{data.title?.trim() || `AI ${variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}`}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{data.content}</p>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="block text-xs font-semibold text-primary">{label}</span>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{data.tip}</p>
|
||||
</div>
|
||||
{dismissible && (
|
||||
<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({
|
||||
mutationFn: (mode: NonNullable<Mode>) =>
|
||||
coachingService.writingHelp({
|
||||
text: text.trim(),
|
||||
task_type: `${task_type}:${mode}`,
|
||||
task: task_type,
|
||||
draft: text.trim(),
|
||||
help_type: mode,
|
||||
}),
|
||||
onSuccess: () => setShowResult(true),
|
||||
onError: (err: Error) => {
|
||||
@@ -84,20 +85,20 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
|
||||
|
||||
{showResult && !loading && mutation.data && activeMode === "improve" && (
|
||||
<div className="space-y-3">
|
||||
{mutation.data.feedback && (
|
||||
{mutation.data.tips?.length > 0 && (
|
||||
<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">
|
||||
<Sparkles className="h-3 w-3" /> Feedback
|
||||
</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>
|
||||
)}
|
||||
{mutation.data.improved && (
|
||||
{mutation.data.improved_text && (
|
||||
<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">
|
||||
<Sparkles className="h-3 w-3" /> Improved Version
|
||||
</p>
|
||||
<p className="text-sm">{mutation.data.improved}</p>
|
||||
<p className="text-sm">{mutation.data.improved_text}</p>
|
||||
</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">
|
||||
<Sparkles className="h-3 w-3" /> Grammar notes
|
||||
</p>
|
||||
{(mutation.data.grammar_notes?.length ?? 0) > 0 ? (
|
||||
mutation.data.grammar_notes!.map((note, i) => (
|
||||
{(mutation.data.changes?.length ?? 0) > 0 ? (
|
||||
mutation.data.changes.map((c, i) => (
|
||||
<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>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No grammar issues flagged.</p>
|
||||
)}
|
||||
{mutation.data.feedback ? (
|
||||
<p className="text-xs text-muted-foreground pt-2 border-t">{mutation.data.feedback}</p>
|
||||
{mutation.data.tips?.length > 0 ? (
|
||||
<p className="text-xs text-muted-foreground pt-2 border-t">{mutation.data.tips.join("; ")}</p>
|
||||
) : null}
|
||||
</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">
|
||||
<Sparkles className="h-3 w-3" /> Estimated band / assessment
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{mutation.data.feedback}</p>
|
||||
{mutation.data.improved ? (
|
||||
<p className="text-sm mt-2 pt-2 border-t">{mutation.data.improved}</p>
|
||||
<p className="text-sm text-muted-foreground">{mutation.data.tips?.join(" ") ?? ""}</p>
|
||||
{mutation.data.improved_text ? (
|
||||
<p className="text-sm mt-2 pt-2 border-t">{mutation.data.improved_text}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
867
src/components/coursePlan/InteractiveWorkbook.tsx
Normal file
@@ -0,0 +1,867 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Award,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
GripVertical,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Save,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { coursePlanService } from "@/services/coursePlan.service";
|
||||
import type {
|
||||
CoursePlanMaterial,
|
||||
GapFillExercise,
|
||||
MatchPairsExercise,
|
||||
MultipleChoiceExercise,
|
||||
ReorderWordsExercise,
|
||||
ShortAnswerExercise,
|
||||
TransformationExercise,
|
||||
WorkbookAttempt,
|
||||
WorkbookAttemptScoreItem,
|
||||
WorkbookBody,
|
||||
WorkbookExercise,
|
||||
} from "@/types";
|
||||
|
||||
/**
|
||||
* Workbook renderer for `material_type === "interactive_workbook"`.
|
||||
*
|
||||
* Modes:
|
||||
* - `student` → answers persist server-side (graded, scored, saved).
|
||||
* - `preview` → no persistence, no Submit; "Show answers" toggle reveals
|
||||
* the key so the admin can verify the v2 generator's output.
|
||||
*
|
||||
* The renderer also accepts a `WorkbookBody` directly (for skill bodies
|
||||
* that embed `interactive_workbook: { exercises: [...] }`), so it can be
|
||||
* reused inside other material renderers without duplicating the logic.
|
||||
*/
|
||||
export type WorkbookMode = "student" | "preview";
|
||||
|
||||
interface InteractiveWorkbookProps {
|
||||
material: Pick<
|
||||
CoursePlanMaterial,
|
||||
"id" | "plan_id" | "body" | "title" | "summary" | "material_type"
|
||||
>;
|
||||
/** Optional override — when present we read exercises from here instead
|
||||
* of `material.body`. Used by skill bodies that embed a workbook one
|
||||
* level deep. */
|
||||
bodyOverride?: WorkbookBody;
|
||||
mode?: WorkbookMode;
|
||||
}
|
||||
|
||||
type AnswerValue = string | string[] | number[][] | undefined;
|
||||
|
||||
function toExercises(body: unknown): WorkbookExercise[] {
|
||||
if (!body || typeof body !== "object") return [];
|
||||
const b = body as Record<string, unknown>;
|
||||
if (Array.isArray(b.exercises)) {
|
||||
return b.exercises as WorkbookExercise[];
|
||||
}
|
||||
// Skill bodies sometimes nest the workbook under `interactive_workbook`.
|
||||
const nested = b.interactive_workbook as { exercises?: WorkbookExercise[] } | undefined;
|
||||
if (nested && Array.isArray(nested.exercises)) {
|
||||
return nested.exercises;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function toLessonPlan(body: unknown): WorkbookBody["lesson_plan"] | undefined {
|
||||
if (!body || typeof body !== "object") return undefined;
|
||||
const b = body as Record<string, unknown>;
|
||||
if (b.lesson_plan && typeof b.lesson_plan === "object") {
|
||||
return b.lesson_plan as WorkbookBody["lesson_plan"];
|
||||
}
|
||||
const nested = b.interactive_workbook as { lesson_plan?: WorkbookBody["lesson_plan"] } | undefined;
|
||||
return nested?.lesson_plan;
|
||||
}
|
||||
|
||||
// Local copies of the backend grading functions for instant per-exercise
|
||||
// feedback while typing. The authoritative score still comes from the
|
||||
// server when we POST — these are only used for the "Check" button.
|
||||
function normText(s: unknown): string {
|
||||
if (s == null) return "";
|
||||
return String(s).replace(/\s+/g, " ").trim().toLowerCase();
|
||||
}
|
||||
function normPunct(s: unknown): string {
|
||||
return normText(s).replace(/[\s.?!,]+$/u, "");
|
||||
}
|
||||
function globMatch(pattern: string, value: string): boolean {
|
||||
const pat = normText(pattern);
|
||||
const val = normText(value);
|
||||
if (!pat) return false;
|
||||
if (!pat.includes("*")) return pat === val;
|
||||
const rx = new RegExp(
|
||||
"^" + pat.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*") + "$",
|
||||
);
|
||||
return rx.test(val);
|
||||
}
|
||||
|
||||
function gradeOne(ex: WorkbookExercise, given: AnswerValue): boolean {
|
||||
if (given === undefined) return false;
|
||||
switch (ex.type) {
|
||||
case "gap_fill": {
|
||||
const accepted = [ex.answer, ...(ex.alt ?? [])];
|
||||
const n = normText(given as string);
|
||||
return accepted.some((a) => normText(a) === n);
|
||||
}
|
||||
case "multiple_choice": {
|
||||
const opts = ex.options ?? [];
|
||||
const ans = ex.answer;
|
||||
const g = normText(given as string);
|
||||
if (g === normText(ans)) return true;
|
||||
if (typeof ans === "string" && ans.length === 1 && /[a-z]/i.test(ans)) {
|
||||
const idx = ans.toUpperCase().charCodeAt(0) - 65;
|
||||
if (opts[idx] && normText(opts[idx]) === g) return true;
|
||||
}
|
||||
if (typeof given === "string" && given.length === 1 && /[a-z]/i.test(given)) {
|
||||
const idx = given.toUpperCase().charCodeAt(0) - 65;
|
||||
if (opts[idx] && normText(opts[idx]) === normText(ans)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "match_pairs": {
|
||||
const pairs = (given as number[][]) ?? [];
|
||||
const expected = ex.answer ?? [];
|
||||
const setOf = (rows: number[][]) =>
|
||||
new Set(rows.filter((p) => p.length === 2).map((p) => `${p[0]},${p[1]}`));
|
||||
const a = setOf(expected);
|
||||
const b = setOf(pairs);
|
||||
if (a.size !== b.size) return false;
|
||||
for (const v of a) if (!b.has(v)) return false;
|
||||
return true;
|
||||
}
|
||||
case "reorder_words": {
|
||||
const value = Array.isArray(given) ? (given as string[]).join(" ") : String(given ?? "");
|
||||
return normText(value) === normText(ex.answer);
|
||||
}
|
||||
case "transformation": {
|
||||
const accepted = [ex.answer, ...(ex.alt ?? [])];
|
||||
const n = normPunct(given as string);
|
||||
return accepted.some((a) => normPunct(a) === n);
|
||||
}
|
||||
case "short_answer": {
|
||||
const accepted = [...(ex.accepted ?? []), ex.answer].filter(Boolean) as string[];
|
||||
return accepted.some((p) => globMatch(p, given as string));
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function InteractiveWorkbook({
|
||||
material,
|
||||
bodyOverride,
|
||||
mode = "student",
|
||||
}: InteractiveWorkbookProps) {
|
||||
const exercises = useMemo<WorkbookExercise[]>(
|
||||
() => toExercises(bodyOverride ?? material.body),
|
||||
[bodyOverride, material.body],
|
||||
);
|
||||
const lessonPlan = useMemo(
|
||||
() => toLessonPlan(bodyOverride ?? material.body),
|
||||
[bodyOverride, material.body],
|
||||
);
|
||||
|
||||
const { toast } = useToast();
|
||||
const [answers, setAnswers] = useState<Record<string, AnswerValue>>({});
|
||||
const [revealedIds, setRevealedIds] = useState<Set<string>>(new Set());
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [serverScore, setServerScore] = useState<{
|
||||
correct: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
items?: WorkbookAttemptScoreItem[];
|
||||
is_final?: boolean;
|
||||
} | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(mode === "student");
|
||||
|
||||
// Load existing attempt (student mode only).
|
||||
useEffect(() => {
|
||||
if (mode !== "student") return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await coursePlanService.myWorkbookAttempt(
|
||||
material.plan_id,
|
||||
material.id,
|
||||
);
|
||||
if (cancelled) return;
|
||||
const attempt = res.data;
|
||||
if (attempt) {
|
||||
setAnswers((attempt.answers ?? {}) as Record<string, AnswerValue>);
|
||||
if (attempt.score && "items" in attempt.score) {
|
||||
setServerScore({
|
||||
correct: attempt.correct_count,
|
||||
total: attempt.total_count,
|
||||
percent: attempt.percent,
|
||||
items: attempt.score.items,
|
||||
is_final: attempt.is_final,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — empty workbook on first open.
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [material.id, material.plan_id, mode]);
|
||||
|
||||
const isFinal = !!serverScore?.is_final;
|
||||
const liveCorrect = useMemo(
|
||||
() => exercises.filter((ex) => gradeOne(ex, answers[ex.id])).length,
|
||||
[answers, exercises],
|
||||
);
|
||||
const livePercent = exercises.length
|
||||
? Math.round((liveCorrect / exercises.length) * 100)
|
||||
: 0;
|
||||
|
||||
const onChange = (id: string, value: AnswerValue) => {
|
||||
setAnswers((prev) => ({ ...prev, [id]: value }));
|
||||
};
|
||||
|
||||
const onCheck = (id: string) => {
|
||||
setRevealedIds((prev) => new Set(prev).add(id));
|
||||
if (mode === "student") void persist(false);
|
||||
};
|
||||
|
||||
const persist = async (finalize: boolean) => {
|
||||
if (mode !== "student") return;
|
||||
if (finalize) setSubmitting(true);
|
||||
else setSaving(true);
|
||||
try {
|
||||
const res = await coursePlanService.saveWorkbookAttempt(
|
||||
material.plan_id,
|
||||
material.id,
|
||||
{ answers, finalize },
|
||||
);
|
||||
const attempt: WorkbookAttempt = res.data;
|
||||
setServerScore({
|
||||
correct: attempt.correct_count,
|
||||
total: attempt.total_count,
|
||||
percent: attempt.percent,
|
||||
items: attempt.score && "items" in attempt.score ? attempt.score.items : [],
|
||||
is_final: attempt.is_final,
|
||||
});
|
||||
if (finalize) {
|
||||
toast({
|
||||
title: "Submitted",
|
||||
description: `${attempt.correct_count} / ${attempt.total_count} correct (${attempt.percent.toFixed(0)}%)`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: finalize ? "Submit failed" : "Save failed",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Debounce per-keystroke saves: every 1.5s after the last change.
|
||||
const saveTimer = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (mode !== "student" || isFinal || loading) return;
|
||||
if (saveTimer.current) window.clearTimeout(saveTimer.current);
|
||||
saveTimer.current = window.setTimeout(() => {
|
||||
void persist(false);
|
||||
}, 1500);
|
||||
return () => {
|
||||
if (saveTimer.current) window.clearTimeout(saveTimer.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [answers]);
|
||||
|
||||
const reset = () => {
|
||||
setAnswers({});
|
||||
setRevealedIds(new Set());
|
||||
};
|
||||
|
||||
if (!exercises.length) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/30 p-6 text-sm text-muted-foreground">
|
||||
This workbook has no exercises yet — generate or extract first.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{mode === "preview" && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-900 flex items-center justify-between">
|
||||
<span>
|
||||
<strong>Preview</strong> — answers won't be saved. Toggle the
|
||||
answer key to verify the generated content.
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={showKey ? "default" : "outline"}
|
||||
onClick={() => setShowKey((v) => !v)}
|
||||
>
|
||||
<Eye className="mr-1 h-4 w-4" />
|
||||
{showKey ? "Hide answers" : "Show answers"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lessonPlan && Object.values(lessonPlan).some(Boolean) && (
|
||||
<details className="rounded-lg border bg-background/70 p-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Teacher's lesson plan
|
||||
</summary>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2 text-sm">
|
||||
{(["warmup", "presentation", "controlled_practice", "freer_practice", "exit_ticket"] as const).map(
|
||||
(k) =>
|
||||
lessonPlan[k] ? (
|
||||
<div key={k}>
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{k.replace(/_/g, " ")}
|
||||
</div>
|
||||
<div className="leading-6">{lessonPlan[k]}</div>
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-background/70 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Award className="h-4 w-4 text-primary" />
|
||||
<strong>{liveCorrect}</strong>
|
||||
<span className="text-muted-foreground">/ {exercises.length} correct (live)</span>
|
||||
{serverScore && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
Server: {serverScore.correct}/{serverScore.total} ({serverScore.percent.toFixed(0)}%)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{mode === "student" && (
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Saving…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-3 w-3" />{" "}
|
||||
{serverScore ? "Auto-saved" : "Auto-save on"}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Progress value={livePercent} className="h-2" />
|
||||
</div>
|
||||
|
||||
<ol className="space-y-3 list-none p-0">
|
||||
{exercises.map((ex, idx) => {
|
||||
const value = answers[ex.id];
|
||||
const checked = revealedIds.has(ex.id) || isFinal || showKey;
|
||||
const correct = checked ? gradeOne(ex, value) : null;
|
||||
return (
|
||||
<li
|
||||
key={ex.id || idx}
|
||||
className={cn(
|
||||
"rounded-lg border p-4 bg-background/70 space-y-3",
|
||||
checked && correct === true && "border-emerald-400 bg-emerald-50/40",
|
||||
checked && correct === false && "border-rose-400 bg-rose-50/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Exercise {idx + 1} · {ex.type.replace(/_/g, " ")}
|
||||
</div>
|
||||
{checked && (
|
||||
<Badge variant={correct ? "default" : "destructive"} className="capitalize">
|
||||
{correct ? (
|
||||
<>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" /> Correct
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="mr-1 h-3 w-3" /> Try again
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderExercise(ex, value, (v) => onChange(ex.id, v), {
|
||||
disabled: mode === "preview" || isFinal,
|
||||
showKey: showKey,
|
||||
})}
|
||||
|
||||
{ex.hint && !checked && (
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Lightbulb className="h-3 w-3" /> {ex.hint}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checked && (
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
{!correct && (
|
||||
<div>
|
||||
Expected: <span className="font-medium">{formatAnswer(ex)}</span>
|
||||
</div>
|
||||
)}
|
||||
{ex.rationale && <div>{ex.rationale}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isFinal && mode !== "preview" && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onCheck(ex.id)}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-4 w-4" /> Check
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{mode === "student" && (
|
||||
<div className="flex items-center justify-between rounded-lg border bg-background/70 p-3">
|
||||
<Button type="button" variant="ghost" onClick={reset} disabled={isFinal}>
|
||||
<RotateCcw className="mr-1 h-4 w-4" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={submitting || isFinal}
|
||||
onClick={() => persist(true)}
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{isFinal ? "Submitted" : "Submit final"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-type renderers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderExercise(
|
||||
ex: WorkbookExercise,
|
||||
value: AnswerValue,
|
||||
onChange: (v: AnswerValue) => void,
|
||||
opts: { disabled?: boolean; showKey?: boolean },
|
||||
): React.ReactNode {
|
||||
switch (ex.type) {
|
||||
case "gap_fill":
|
||||
return <GapFill ex={ex} value={value as string} onChange={onChange} disabled={opts.disabled} />;
|
||||
case "multiple_choice":
|
||||
return (
|
||||
<MultipleChoice
|
||||
ex={ex}
|
||||
value={value as string}
|
||||
onChange={onChange}
|
||||
disabled={opts.disabled}
|
||||
/>
|
||||
);
|
||||
case "match_pairs":
|
||||
return (
|
||||
<MatchPairs
|
||||
ex={ex}
|
||||
value={value as number[][]}
|
||||
onChange={onChange}
|
||||
disabled={opts.disabled}
|
||||
/>
|
||||
);
|
||||
case "reorder_words":
|
||||
return (
|
||||
<ReorderWords
|
||||
ex={ex}
|
||||
value={value as string[]}
|
||||
onChange={onChange}
|
||||
disabled={opts.disabled}
|
||||
/>
|
||||
);
|
||||
case "transformation":
|
||||
return (
|
||||
<Transformation
|
||||
ex={ex}
|
||||
value={value as string}
|
||||
onChange={onChange}
|
||||
disabled={opts.disabled}
|
||||
/>
|
||||
);
|
||||
case "short_answer":
|
||||
return (
|
||||
<ShortAnswer
|
||||
ex={ex}
|
||||
value={value as string}
|
||||
onChange={onChange}
|
||||
disabled={opts.disabled}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <p className="text-sm text-muted-foreground">Unsupported exercise type.</p>;
|
||||
}
|
||||
}
|
||||
|
||||
function formatAnswer(ex: WorkbookExercise): string {
|
||||
switch (ex.type) {
|
||||
case "match_pairs":
|
||||
return (ex.answer ?? [])
|
||||
.map((p) => `${ex.left[p[0]] ?? "?"} ↔ ${ex.right[p[1]] ?? "?"}`)
|
||||
.join(", ");
|
||||
case "reorder_words":
|
||||
return ex.answer;
|
||||
default:
|
||||
return String((ex as { answer?: unknown }).answer ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type-specific input subcomponents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function GapFill({
|
||||
ex,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
ex: GapFillExercise;
|
||||
value: string | undefined;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
// Replace ___ in the stem with an inline input. Falls back to a stem
|
||||
// label + standalone input when there's no underscore marker.
|
||||
const parts = ex.stem.split(/_{2,}/);
|
||||
if (parts.length === 1) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="leading-7">{ex.stem}</p>
|
||||
<Input
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder="Type your answer"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="leading-8 text-base flex flex-wrap items-center gap-1">
|
||||
{parts.map((part, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1">
|
||||
<span>{part}</span>
|
||||
{i < parts.length - 1 && (
|
||||
<Input
|
||||
type="text"
|
||||
className="inline-block w-32 align-baseline"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder="…"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function MultipleChoice({
|
||||
ex,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
ex: MultipleChoiceExercise;
|
||||
value: string | undefined;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="leading-7">{ex.stem}</p>
|
||||
<RadioGroup
|
||||
value={value ?? ""}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled}
|
||||
className="grid gap-2 sm:grid-cols-2"
|
||||
>
|
||||
{ex.options.map((opt, i) => {
|
||||
const id = `${ex.id}_opt_${i}`;
|
||||
return (
|
||||
<Label
|
||||
key={id}
|
||||
htmlFor={id}
|
||||
className="flex items-start gap-2 rounded-md border p-2 cursor-pointer hover:bg-muted"
|
||||
>
|
||||
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
|
||||
<span className="text-sm">{opt}</span>
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchPairs({
|
||||
ex,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
ex: MatchPairsExercise;
|
||||
value: number[][] | undefined;
|
||||
onChange: (v: number[][]) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [selectedLeft, setSelectedLeft] = useState<number | null>(null);
|
||||
const pairs = value ?? [];
|
||||
const pairedLeft = new Set(pairs.map((p) => p[0]));
|
||||
const pairedRight = new Set(pairs.map((p) => p[1]));
|
||||
|
||||
const tapLeft = (idx: number) => {
|
||||
if (disabled) return;
|
||||
setSelectedLeft(idx);
|
||||
};
|
||||
const tapRight = (idx: number) => {
|
||||
if (disabled || selectedLeft == null) return;
|
||||
const next = pairs.filter((p) => p[0] !== selectedLeft && p[1] !== idx);
|
||||
next.push([selectedLeft, idx]);
|
||||
onChange(next);
|
||||
setSelectedLeft(null);
|
||||
};
|
||||
const clear = (li: number) => {
|
||||
if (disabled) return;
|
||||
onChange(pairs.filter((p) => p[0] !== li));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">Match these…</div>
|
||||
{ex.left.map((item, i) => {
|
||||
const paired = pairs.find((p) => p[0] === i);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`L${i}`}
|
||||
onClick={() => (paired ? clear(i) : tapLeft(i))}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"block w-full rounded border px-2 py-1.5 text-left",
|
||||
selectedLeft === i && "border-primary ring-1 ring-primary",
|
||||
paired && "bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground mr-1">{i + 1}.</span>
|
||||
{item}
|
||||
{paired ? (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
→ {ex.right[paired[1]]}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">…with these</div>
|
||||
{ex.right.map((item, j) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`R${j}`}
|
||||
onClick={() => tapRight(j)}
|
||||
disabled={disabled || selectedLeft == null}
|
||||
className={cn(
|
||||
"block w-full rounded border px-2 py-1.5 text-left",
|
||||
pairedRight.has(j) && "bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground mr-1">{String.fromCharCode(65 + j)}.</span>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="col-span-2 text-xs text-muted-foreground">
|
||||
Tap a left item, then tap its match on the right. Tap a paired left
|
||||
item to clear it.
|
||||
</div>
|
||||
{!pairedLeft.size && (
|
||||
<div className="col-span-2 text-xs text-muted-foreground">
|
||||
{ex.left.length} pair(s) to match.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReorderWords({
|
||||
ex,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
ex: ReorderWordsExercise;
|
||||
value: string[] | undefined;
|
||||
onChange: (v: string[]) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
// Stateful tap-to-insert: tap a token in the bank to append; tap a
|
||||
// token in the answer strip to remove. Keeps it touch-friendly.
|
||||
const order = value ?? [];
|
||||
const remaining: { tok: string; bankIdx: number }[] = [];
|
||||
const used: number[] = [];
|
||||
// Build maps so duplicate tokens stay independently selectable.
|
||||
const usedCounts = new Map<string, number>();
|
||||
order.forEach((t) => usedCounts.set(t, (usedCounts.get(t) ?? 0) + 1));
|
||||
const seenWhileBuilding = new Map<string, number>();
|
||||
ex.tokens.forEach((tok, idx) => {
|
||||
const seen = seenWhileBuilding.get(tok) ?? 0;
|
||||
const usesLeft = (usedCounts.get(tok) ?? 0) - seen;
|
||||
if (usesLeft > 0) {
|
||||
used.push(idx);
|
||||
seenWhileBuilding.set(tok, seen + 1);
|
||||
} else {
|
||||
remaining.push({ tok, bankIdx: idx });
|
||||
}
|
||||
});
|
||||
|
||||
const append = (tok: string) => {
|
||||
if (disabled) return;
|
||||
onChange([...order, tok]);
|
||||
};
|
||||
const removeAt = (i: number) => {
|
||||
if (disabled) return;
|
||||
onChange(order.filter((_, idx) => idx !== i));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="rounded border bg-muted/30 p-2 min-h-10 flex flex-wrap gap-1">
|
||||
{order.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">Tap tokens below to build the sentence…</span>
|
||||
) : (
|
||||
order.map((tok, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`a${i}`}
|
||||
onClick={() => removeAt(i)}
|
||||
disabled={disabled}
|
||||
className="rounded bg-background border px-2 py-1"
|
||||
>
|
||||
<GripVertical className="mr-1 inline h-3 w-3 text-muted-foreground" />
|
||||
{tok}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{remaining.map(({ tok, bankIdx }) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`b${bankIdx}`}
|
||||
onClick={() => append(tok)}
|
||||
disabled={disabled}
|
||||
className="rounded border px-2 py-1 hover:bg-muted"
|
||||
>
|
||||
{tok}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Transformation({
|
||||
ex,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
ex: TransformationExercise;
|
||||
value: string | undefined;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border bg-muted/30 px-3 py-2 text-sm">
|
||||
<Badge variant="outline" className="mr-2">
|
||||
{ex.instruction}
|
||||
</Badge>
|
||||
<span className="font-medium">{ex.from}</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
rows={2}
|
||||
placeholder="Transformed sentence"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortAnswer({
|
||||
ex,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
ex: ShortAnswerExercise;
|
||||
value: string | undefined;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="leading-7">{ex.stem}</p>
|
||||
<Textarea
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
rows={3}
|
||||
placeholder="Type your answer"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
214
src/components/coursePlan/LibraryPickerDialog.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Library, Loader2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
import { resourcesService } from "@/services/resources.service";
|
||||
import type { Resource } from "@/types";
|
||||
|
||||
/**
|
||||
* Generic, reusable picker over `/api/resources`.
|
||||
*
|
||||
* Two callers wire this up today:
|
||||
*
|
||||
* 1. **AdminCoursePlanDetail** — for an *existing* plan, so it forwards
|
||||
* the picked resources to `coursePlanService.attachResources` in the
|
||||
* `onConfirm` handler and invalidates the sources query.
|
||||
*
|
||||
* 2. **CoursePlanWizard** — the plan does not exist yet at pick time,
|
||||
* so the wizard simply pushes the picks into its draft state and
|
||||
* attaches them in one batch after the plan is created.
|
||||
*
|
||||
* The dialog itself is purposefully agnostic: it only reports the
|
||||
* selected `Resource` rows; the parent decides what to do.
|
||||
*/
|
||||
export function LibraryPickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
alreadyLinkedIds,
|
||||
onConfirm,
|
||||
isPending,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Resources whose ids are in this set render disabled + checked. */
|
||||
alreadyLinkedIds?: Set<number>;
|
||||
/** Called when the admin clicks "Attach selected". */
|
||||
onConfirm: (resources: Resource[]) => void | Promise<void>;
|
||||
/** Optional spinner state from the parent's mutation. */
|
||||
isPending?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
|
||||
// Reset every time the dialog re-opens so a previous session's
|
||||
// selection doesn't bleed into the next attach.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelected(new Set());
|
||||
setSearch("");
|
||||
setTypeFilter("all");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["library-resources", { search, type: typeFilter }],
|
||||
queryFn: () =>
|
||||
resourcesService.list({
|
||||
search: search || undefined,
|
||||
resource_type: typeFilter === "all" ? undefined : typeFilter,
|
||||
review_status: "approved",
|
||||
}),
|
||||
enabled: open,
|
||||
});
|
||||
const resources: Resource[] = data?.items ?? [];
|
||||
|
||||
const toggle = (id: number) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const picked = resources.filter((r) => selected.has(r.id));
|
||||
if (!picked.length) return;
|
||||
await onConfirm(picked);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[640px] max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Library className="h-5 w-5 text-primary" />
|
||||
{t("coursePlan.sources.libraryTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("coursePlan.sources.libraryDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Input
|
||||
placeholder={t("coursePlan.sources.librarySearchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("coursePlan.sources.libraryTypeAll")}
|
||||
</SelectItem>
|
||||
<SelectItem value="pdf">PDF</SelectItem>
|
||||
<SelectItem value="document">DOCX</SelectItem>
|
||||
<SelectItem value="link">URL</SelectItem>
|
||||
<SelectItem value="article">Article</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto -mx-2 px-2 space-y-1">
|
||||
{isLoading && <Skeleton className="h-32 w-full" />}
|
||||
{!isLoading && resources.length === 0 && (
|
||||
<p className="text-center text-sm text-muted-foreground py-8">
|
||||
{t("coursePlan.sources.libraryEmpty")}
|
||||
</p>
|
||||
)}
|
||||
{!isLoading &&
|
||||
resources.map((r) => {
|
||||
const isLinked = alreadyLinkedIds?.has(r.id) ?? false;
|
||||
const isSelected = selected.has(r.id);
|
||||
return (
|
||||
<label
|
||||
key={r.id}
|
||||
className={`flex items-start gap-3 rounded-md border p-2 text-sm transition-colors ${
|
||||
isLinked
|
||||
? "opacity-60 cursor-not-allowed bg-muted/40"
|
||||
: "cursor-pointer hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isLinked || isSelected}
|
||||
disabled={isLinked}
|
||||
onCheckedChange={() => !isLinked && toggle(r.id)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium truncate">{r.name}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] capitalize"
|
||||
>
|
||||
{r.resource_type || r.type || "resource"}
|
||||
</Badge>
|
||||
{isLinked && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{t("coursePlan.sources.libraryAlreadyLinked")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{(r.subject_name || (r.topic_names ?? []).length > 0) && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{[r.subject_name, ...(r.topic_names?.slice(0, 2) ?? [])]
|
||||
.filter(Boolean)
|
||||
.join(" › ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t pt-3">
|
||||
<span className="mr-auto text-xs text-muted-foreground self-center">
|
||||
{t("coursePlan.sources.librarySelectedCount", {
|
||||
count: selected.size,
|
||||
})}
|
||||
</span>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selected.size || isPending}
|
||||
>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t("coursePlan.sources.libraryAttach")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
139
src/components/coursePlan/MaterialBookView.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CoursePlanMaterial, WorkbookBody } from "@/types";
|
||||
|
||||
import InteractiveWorkbook, { type WorkbookMode } from "./InteractiveWorkbook";
|
||||
|
||||
export const SKILL_STYLE: Record<string, string> = {
|
||||
reading: "bg-blue-100 text-blue-800 border-blue-200",
|
||||
writing: "bg-purple-100 text-purple-800 border-purple-200",
|
||||
listening: "bg-emerald-100 text-emerald-800 border-emerald-200",
|
||||
speaking: "bg-amber-100 text-amber-800 border-amber-200",
|
||||
grammar: "bg-rose-100 text-rose-800 border-rose-200",
|
||||
vocabulary: "bg-cyan-100 text-cyan-800 border-cyan-200",
|
||||
integrated: "bg-slate-100 text-slate-800 border-slate-200",
|
||||
};
|
||||
|
||||
export function SkillBadge({ skill }: { skill: string }) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("capitalize border", SKILL_STYLE[skill] ?? SKILL_STYLE.integrated)}
|
||||
>
|
||||
{skill || "integrated"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function renderAny(value: unknown): React.ReactNode {
|
||||
if (value == null) return null;
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return <p className="leading-7">{String(value)}</p>;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return null;
|
||||
return (
|
||||
<ul className="list-disc ps-5 space-y-1">
|
||||
{value.map((item, idx) => (
|
||||
<li key={idx}>{renderAny(item)}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{entries.map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<h5 className="font-medium capitalize text-sm text-muted-foreground mb-1">
|
||||
{k.replace(/_/g, " ")}
|
||||
</h5>
|
||||
<div className="text-sm">{renderAny(v)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type MaterialForBook = Pick<
|
||||
CoursePlanMaterial,
|
||||
"id" | "plan_id" | "body" | "body_text" | "material_type" | "title" | "summary"
|
||||
>;
|
||||
|
||||
export default function MaterialBookView({
|
||||
material,
|
||||
mode = "student",
|
||||
}: {
|
||||
// The legacy callsites only pass body/body_text/material_type, so we
|
||||
// tolerate that by making id/plan_id optional in the prop type even
|
||||
// though the types/index file marks them as required. Required props
|
||||
// are validated at runtime when interactive_workbook is dispatched.
|
||||
material: Partial<MaterialForBook> &
|
||||
Pick<CoursePlanMaterial, "body" | "body_text" | "material_type">;
|
||||
mode?: WorkbookMode;
|
||||
}) {
|
||||
const body = material.body ?? {};
|
||||
const hasStructured = Object.keys(body).length > 0;
|
||||
|
||||
// Dispatch to the interactive renderer for workbook materials. The
|
||||
// skill-bodied workbooks (grammar/vocabulary that embed their own
|
||||
// `interactive_workbook` block) render via the generic walker AND
|
||||
// the embedded workbook below for the exercises portion.
|
||||
if (
|
||||
material.material_type === "interactive_workbook" &&
|
||||
typeof material.id === "number" &&
|
||||
typeof material.plan_id === "number"
|
||||
) {
|
||||
return (
|
||||
<InteractiveWorkbook
|
||||
material={material as MaterialForBook}
|
||||
mode={mode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// For grammar / vocabulary that embed an interactive workbook inside
|
||||
// their body, we render the prose first and then the workbook UI so
|
||||
// students can practise without leaving the lesson.
|
||||
const embeddedWorkbook = (body as { interactive_workbook?: WorkbookBody })
|
||||
.interactive_workbook;
|
||||
const hasEmbedded =
|
||||
embeddedWorkbook &&
|
||||
Array.isArray(embeddedWorkbook.exercises) &&
|
||||
embeddedWorkbook.exercises.length > 0 &&
|
||||
typeof material.id === "number" &&
|
||||
typeof material.plan_id === "number";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background/70 p-4 space-y-4">
|
||||
{hasStructured ? (
|
||||
renderAny(stripEmbedded(body))
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap leading-7">
|
||||
{material.body_text || "No content available yet."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasEmbedded && (
|
||||
<div className="border-t pt-3">
|
||||
<div className="text-sm font-medium mb-2">Practice</div>
|
||||
<InteractiveWorkbook
|
||||
material={material as MaterialForBook}
|
||||
bodyOverride={embeddedWorkbook!}
|
||||
mode={mode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function stripEmbedded(body: Record<string, unknown>): Record<string, unknown> {
|
||||
if (!body || typeof body !== "object") return body;
|
||||
if (!("interactive_workbook" in body)) return body;
|
||||
const { interactive_workbook: _drop, ...rest } = body;
|
||||
return rest;
|
||||
}
|
||||
390
src/components/coursePlan/PlanReader.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BookOpen,
|
||||
Calendar,
|
||||
ClipboardList,
|
||||
Headphones,
|
||||
Image as ImageIcon,
|
||||
Library,
|
||||
Link2,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
Music,
|
||||
PenSquare,
|
||||
Sparkles,
|
||||
Type,
|
||||
Video,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { withAuthQuery } from "@/lib/api-client";
|
||||
import MaterialBookView, {
|
||||
SkillBadge,
|
||||
} from "@/components/coursePlan/MaterialBookView";
|
||||
import type { WorkbookMode } from "@/components/coursePlan/InteractiveWorkbook";
|
||||
import type {
|
||||
CoursePlan,
|
||||
CoursePlanMaterial,
|
||||
CoursePlanMedia,
|
||||
CoursePlanWeek,
|
||||
} from "@/types";
|
||||
|
||||
const SKILL_ICONS: Record<string, LucideIcon> = {
|
||||
reading: BookOpen,
|
||||
writing: PenSquare,
|
||||
listening: Headphones,
|
||||
speaking: Mic,
|
||||
grammar: Type,
|
||||
vocabulary: Library,
|
||||
integrated: MessageSquare,
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-only renderer for a course plan that BOTH the student page and
|
||||
* the admin "View as Student" preview share. The only difference is
|
||||
* the workbook `mode`:
|
||||
*
|
||||
* - `mode="student"` → answers persist server-side (real attempts).
|
||||
* - `mode="preview"` → InteractiveWorkbook runs in preview mode (no
|
||||
* persistence) and surfaces an "Show answer key" toggle so admins
|
||||
* can verify the v2 generator's output.
|
||||
*
|
||||
* Materials are rendered through `MaterialBookView`, which dispatches
|
||||
* `interactive_workbook` materials into the live workbook component.
|
||||
*/
|
||||
export interface PlanReaderProps {
|
||||
plan: CoursePlan;
|
||||
mode?: WorkbookMode;
|
||||
/** Heading level for accessibility — admins use this inside their own
|
||||
* Card; students use it as the page's main grid block. Defaults to 'h3'. */
|
||||
headingClassName?: string;
|
||||
}
|
||||
|
||||
export default function PlanReader({ plan, mode = "student" }: PlanReaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const materialsByWeek = useMemo(() => {
|
||||
const map = new Map<number, CoursePlanMaterial[]>();
|
||||
if (!plan.materials) return map;
|
||||
for (const m of plan.materials) {
|
||||
const list = map.get(m.week_number) ?? [];
|
||||
list.push(m);
|
||||
map.set(m.week_number, list);
|
||||
}
|
||||
return map;
|
||||
}, [plan.materials]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-2xl">{plan.name}</CardTitle>
|
||||
{plan.description && (
|
||||
<CardDescription className="max-w-3xl">
|
||||
{plan.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Badge variant="secondary" className="uppercase">
|
||||
{plan.cefr_level}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{t("coursePlan.hoursPerWeek", {
|
||||
count: plan.contact_hours_per_week,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{plan.assignment && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{plan.assignment.due_date
|
||||
? t("coursePlan.student.due", { date: plan.assignment.due_date })
|
||||
: t("coursePlan.student.noDue")}
|
||||
{plan.assignment.assigned_by_name && (
|
||||
<span>
|
||||
·{" "}
|
||||
{t("coursePlan.student.assignedBy", {
|
||||
name: plan.assignment.assigned_by_name,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{plan.assignment?.message && (
|
||||
<p className="text-sm bg-muted/40 rounded-md px-3 py-2 mt-2">
|
||||
{plan.assignment.message}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{plan.objectives && plan.objectives.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-primary" />
|
||||
{t("coursePlan.sections.objectives")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ol className="list-decimal list-inside space-y-1 text-sm">
|
||||
{plan.objectives.map((o, i) => (
|
||||
<li key={i}>{o}</li>
|
||||
))}
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{plan.weeks && plan.weeks.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
{t("coursePlan.sections.delivery")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("coursePlan.deliveryHint")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{plan.weeks.map((w) => (
|
||||
<ReaderWeek
|
||||
key={w.id}
|
||||
week={w}
|
||||
materials={materialsByWeek.get(w.week_number) ?? []}
|
||||
mode={mode}
|
||||
/>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReaderWeek({
|
||||
week,
|
||||
materials,
|
||||
mode,
|
||||
}: {
|
||||
week: CoursePlanWeek;
|
||||
materials: CoursePlanMaterial[];
|
||||
mode: WorkbookMode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [skillFilter, setSkillFilter] = useState<string>("all");
|
||||
const skills = useMemo(
|
||||
() => Array.from(new Set(materials.map((m) => m.skill))).sort(),
|
||||
[materials],
|
||||
);
|
||||
const filteredMaterials = useMemo(
|
||||
() =>
|
||||
materials.filter(
|
||||
(m) => skillFilter === "all" || m.skill === skillFilter,
|
||||
),
|
||||
[materials, skillFilter],
|
||||
);
|
||||
return (
|
||||
<AccordionItem value={String(week.week_number)}>
|
||||
<AccordionTrigger className="text-left">
|
||||
<div className="flex-1 flex items-center gap-3 min-w-0">
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{t("coursePlan.weekN", { n: week.week_number })}
|
||||
</Badge>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{week.focus || week.unit || "—"}
|
||||
</div>
|
||||
{week.date_label && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{week.date_label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3">
|
||||
{skills.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={skillFilter === "all" ? "default" : "outline"}
|
||||
onClick={() => setSkillFilter("all")}
|
||||
>
|
||||
{t("common.all", "All")}
|
||||
</Button>
|
||||
{skills.map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
size="sm"
|
||||
variant={skillFilter === s ? "default" : "outline"}
|
||||
onClick={() => setSkillFilter(s)}
|
||||
className="capitalize"
|
||||
>
|
||||
{s}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{materials.length === 0 && (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
{t("coursePlan.media.noMedia")}
|
||||
</p>
|
||||
)}
|
||||
{filteredMaterials.map((m) => (
|
||||
<ReaderMaterial key={m.id} material={m} mode={mode} />
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ReaderMaterial({
|
||||
material,
|
||||
mode,
|
||||
}: {
|
||||
material: CoursePlanMaterial;
|
||||
mode: WorkbookMode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Icon className="h-4 w-4 text-primary" />
|
||||
<CardTitle className="text-base flex-1 min-w-0">
|
||||
{material.title}
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{t(
|
||||
`coursePlan.materialType.${material.material_type}`,
|
||||
material.material_type,
|
||||
)}
|
||||
</Badge>
|
||||
<SkillBadge skill={material.skill} />
|
||||
<GroundingBadge material={material} />
|
||||
</div>
|
||||
{material.summary && (
|
||||
<CardDescription>{material.summary}</CardDescription>
|
||||
)}
|
||||
{material.share_date && (
|
||||
<CardDescription>
|
||||
{t("coursePlan.shareDate", "Share date")}: {material.share_date}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(material.media ?? []).map((m) => (
|
||||
<ReaderMediaTile key={m.id} media={m} />
|
||||
))}
|
||||
<MaterialBookView material={material} mode={mode} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ReaderMediaTile({ media }: { media: CoursePlanMedia }) {
|
||||
const url = withAuthQuery(media.preview_url || media.download_url || "");
|
||||
if (!url) return null;
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/20 p-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{media.kind === "audio" && <Music className="h-4 w-4" />}
|
||||
{media.kind === "image" && <ImageIcon className="h-4 w-4" />}
|
||||
{media.kind === "video" && <Video className="h-4 w-4" />}
|
||||
<span className="capitalize text-xs text-muted-foreground">
|
||||
{media.kind}
|
||||
</span>
|
||||
</div>
|
||||
{media.kind === "audio" && <audio src={url} controls className="w-full" />}
|
||||
{media.kind === "image" && (
|
||||
<img
|
||||
src={url}
|
||||
alt={media.title}
|
||||
className="w-full rounded-md max-h-72 object-contain bg-muted/30"
|
||||
/>
|
||||
)}
|
||||
{media.kind === "video" && (
|
||||
<video src={url} controls className="w-full rounded-md max-h-72" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pill that surfaces RAG / extraction provenance for one material. */
|
||||
export function GroundingBadge({ material }: { material: CoursePlanMaterial }) {
|
||||
const grounded = material.grounded_on ?? [];
|
||||
const extracted = material.extracted_from ?? null;
|
||||
if (!grounded.length && !extracted) return null;
|
||||
const total =
|
||||
grounded.reduce((acc, g) => acc + (g.chunks_used || 0), 0) +
|
||||
(extracted ? 1 : 0);
|
||||
return (
|
||||
<HoverCard openDelay={150}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Badge variant="secondary" className="cursor-help">
|
||||
<Link2 className="mr-1 h-3 w-3" />
|
||||
Grounded on {total} reference{total === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-72 text-xs">
|
||||
{grounded.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-sm">RAG sources</div>
|
||||
<ul className="space-y-1">
|
||||
{grounded.map((g) => (
|
||||
<li key={g.source_id} className="flex justify-between gap-2">
|
||||
<span className="truncate">{g.title || `Source #${g.source_id}`}</span>
|
||||
<span className="text-muted-foreground">{g.chunks_used} chunk(s)</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{extracted && (
|
||||
<div className={grounded.length ? "mt-3 border-t pt-2" : ""}>
|
||||
<div className="font-medium text-sm">Extracted from</div>
|
||||
<div className="text-muted-foreground">
|
||||
{extracted.source_title || `Source #${extracted.source_id}`}
|
||||
{extracted.page_hint ? ` · ${extracted.page_hint}` : ""}
|
||||
</div>
|
||||
{extracted.topic_keywords && extracted.topic_keywords.length > 0 && (
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
Topics: {extracted.topic_keywords.slice(0, 5).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
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;
|
||||
|
||||
function hasAlertDialogDescription(children: React.ReactNode): boolean {
|
||||
let found = false;
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (found || !React.isValidElement(child)) return;
|
||||
const type = child.type as { displayName?: string } | undefined;
|
||||
if (
|
||||
type === AlertDialogPrimitive.Description ||
|
||||
(type && type.displayName === AlertDialogPrimitive.Description.displayName)
|
||||
) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
const nested = (child.props as { children?: React.ReactNode } | undefined)
|
||||
?.children;
|
||||
if (nested !== undefined && hasAlertDialogDescription(nested)) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
type AlertDialogContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof AlertDialogPrimitive.Content
|
||||
> & {
|
||||
/**
|
||||
* Optional accessible description rendered visually-hidden. When omitted and
|
||||
* no `AlertDialogDescription` is found in the subtree, a generic fallback is
|
||||
* emitted so screen readers still have something to announce.
|
||||
*/
|
||||
description?: React.ReactNode;
|
||||
};
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContentProps
|
||||
>(({ className, children, description, ...props }, ref) => {
|
||||
const needsFallback =
|
||||
!props["aria-describedby"] &&
|
||||
description === undefined &&
|
||||
!hasAlertDialogDescription(children);
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{description !== undefined && (
|
||||
<AlertDialogPrimitive.Description className="sr-only">
|
||||
{description}
|
||||
</AlertDialogPrimitive.Description>
|
||||
)}
|
||||
{needsFallback && (
|
||||
<AlertDialogPrimitive.Description className="sr-only">
|
||||
Alert dialog content
|
||||
</AlertDialogPrimitive.Description>
|
||||
)}
|
||||
{children}
|
||||
</AlertDialogPrimitive.Content>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
});
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
|
||||
@@ -22,8 +22,11 @@ const badgeVariants = cva(
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||
({ className, variant, ...props }, ref) => (
|
||||
<div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
),
|
||||
);
|
||||
Badge.displayName = "Badge";
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -60,7 +60,12 @@ const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWit
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage";
|
||||
|
||||
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
|
||||
<li role="presentation" aria-hidden="true" className={cn("[&>svg]:size-3.5", className)} {...props}>
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5 rtl:[&>svg]:rotate-180", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -42,8 +42,8 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
|
||||
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4 rtl:rotate-180" />,
|
||||
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4 rtl:rotate-180" />,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -185,7 +185,7 @@ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProp
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<ArrowLeft className="h-4 w-4 rtl:rotate-180" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
@@ -213,7 +213,7 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ const ContextMenuSubTrigger = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
<ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
));
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
@@ -27,28 +27,86 @@ const DialogOverlay = React.forwardRef<
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
/**
|
||||
* Walks `children` looking for any element whose component is
|
||||
* `DialogPrimitive.Description` (or our re-exported `DialogDescription`).
|
||||
* Radix emits an a11y warning when a `DialogContent` has neither a
|
||||
* `Description` nor an explicit `aria-describedby`, so we use this to decide
|
||||
* whether to inject a visually-hidden fallback description.
|
||||
*/
|
||||
function hasDialogDescription(children: React.ReactNode): boolean {
|
||||
let found = false;
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (found || !React.isValidElement(child)) return;
|
||||
const type = child.type as { displayName?: string } | undefined;
|
||||
if (
|
||||
type === DialogPrimitive.Description ||
|
||||
(type && type.displayName === DialogPrimitive.Description.displayName)
|
||||
) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
const nested = (child.props as { children?: React.ReactNode } | undefined)
|
||||
?.children;
|
||||
if (nested !== undefined && hasDialogDescription(nested)) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
type DialogContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof DialogPrimitive.Content
|
||||
> & {
|
||||
/**
|
||||
* Optional accessible description. When supplied, a visually-hidden
|
||||
* `DialogDescription` is rendered so screen readers announce it without
|
||||
* affecting layout. Prefer this for simple dialogs; for richer descriptions
|
||||
* continue to place a `<DialogDescription>` inside `<DialogHeader>`.
|
||||
*/
|
||||
description?: React.ReactNode;
|
||||
};
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContentProps
|
||||
>(({ className, children, description, ...props }, ref) => {
|
||||
const hasExplicitDescribedBy = !!props["aria-describedby"];
|
||||
const needsFallback =
|
||||
!hasExplicitDescribedBy &&
|
||||
description === undefined &&
|
||||
!hasDialogDescription(children);
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{description !== undefined && (
|
||||
<DialogPrimitive.Description className="sr-only">
|
||||
{description}
|
||||
</DialogPrimitive.Description>
|
||||
)}
|
||||
{needsFallback && (
|
||||
<DialogPrimitive.Description className="sr-only">
|
||||
Dialog content
|
||||
</DialogPrimitive.Description>
|
||||
)}
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
|
||||
@@ -32,7 +32,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
<ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
@@ -57,7 +57,7 @@ const MenubarSubTrigger = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
<ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
));
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
|
||||
|
||||
@@ -47,17 +47,17 @@ const PaginationLink = ({ className, isActive, size = "icon", ...props }: Pagina
|
||||
PaginationLink.displayName = "PaginationLink";
|
||||
|
||||
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 pl-2.5", className)} {...props}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 ps-2.5", className)} {...props}>
|
||||
<ChevronLeft className="h-4 w-4 rtl:rotate-180" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationPrevious.displayName = "PaginationPrevious";
|
||||
|
||||
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 pr-2.5", className)} {...props}>
|
||||
<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 pe-2.5", className)} {...props}>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<ChevronRight className="h-4 w-4 rtl:rotate-180" />
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationNext.displayName = "PaginationNext";
|
||||
|
||||
@@ -49,21 +49,61 @@ const sheetVariants = cva(
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
VariantProps<typeof sheetVariants> {
|
||||
/**
|
||||
* Optional accessible description rendered visually-hidden. Falls back to a
|
||||
* generic string when no `SheetDescription` is found in the subtree, which
|
||||
* keeps Radix happy without forcing every call-site to add one.
|
||||
*/
|
||||
description?: React.ReactNode;
|
||||
}
|
||||
|
||||
function hasSheetDescription(children: React.ReactNode): boolean {
|
||||
let found = false;
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (found || !React.isValidElement(child)) return;
|
||||
const type = child.type as { displayName?: string } | undefined;
|
||||
if (
|
||||
type === SheetPrimitive.Description ||
|
||||
(type && type.displayName === SheetPrimitive.Description.displayName)
|
||||
) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
const nested = (child.props as { children?: React.ReactNode } | undefined)
|
||||
?.children;
|
||||
if (nested !== undefined && hasSheetDescription(nested)) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
|
||||
({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
),
|
||||
({ side = "right", className, children, description, ...props }, ref) => {
|
||||
const needsFallback =
|
||||
!props["aria-describedby"] &&
|
||||
description === undefined &&
|
||||
!hasSheetDescription(children);
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{description !== undefined && (
|
||||
<SheetPrimitive.Description className="sr-only">{description}</SheetPrimitive.Description>
|
||||
)}
|
||||
{needsFallback && (
|
||||
<SheetPrimitive.Description className="sr-only">Sheet content</SheetPrimitive.Description>
|
||||
)}
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
},
|
||||
);
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ const Sidebar = React.forwardRef<
|
||||
Sidebar.displayName = "Sidebar";
|
||||
|
||||
const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
|
||||
({ className, onClick, ...props }, ref) => {
|
||||
({ className, onClick, "aria-label": ariaLabel, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
@@ -226,6 +226,7 @@ const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.C
|
||||
data-sidebar="trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={ariaLabel ?? "Toggle sidebar"}
|
||||
className={cn("h-7 w-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
@@ -233,8 +234,8 @@ const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.C
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeft />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
<PanelLeft className="rtl:rotate-180" />
|
||||
<span className="sr-only">{ariaLabel ?? "Toggle sidebar"}</span>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
|
||||
234
src/components/wizard/StepWizard.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { ReactNode, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowLeft, ArrowRight, Check, ChevronLeft } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Generic multi-step wizard shell.
|
||||
*
|
||||
* A wizard is defined as an ordered array of {@link WizardStepDef} items.
|
||||
* Each step owns its own piece of the accumulated state and returns a node
|
||||
* that renders the form. The shell handles:
|
||||
*
|
||||
* - rendering the numbered stepper (with `completed` / `active` styles)
|
||||
* - Back / Next navigation
|
||||
* - a final "Finish" button that calls `onFinish(state)`
|
||||
* - per-step validation via `step.validate(state)` → return an error
|
||||
* message or `null` when valid
|
||||
* - a persistent progress bar
|
||||
*
|
||||
* Keeping this shell free of domain logic means every scenario wizard
|
||||
* (rubric, course, structure, …) is just a tiny file that defines steps
|
||||
* and wires the final submit to the right service.
|
||||
*/
|
||||
|
||||
export interface WizardStepDef<TState> {
|
||||
id: string;
|
||||
/** i18n key for the step title. */
|
||||
titleKey: string;
|
||||
/** Optional i18n key for a short description shown under the title. */
|
||||
descriptionKey?: string;
|
||||
/**
|
||||
* Render the step's form. Call `update(patch)` to merge fields into the
|
||||
* shared state. Do **not** call `onNext` directly — the shell handles
|
||||
* Next/Back; the render function should just update local fields.
|
||||
*/
|
||||
render: (props: StepRenderProps<TState>) => ReactNode;
|
||||
/**
|
||||
* Synchronous validator. Return a human-readable error message that will
|
||||
* be displayed under the form and block Next, or `null` when valid.
|
||||
*/
|
||||
validate?: (state: TState) => string | null;
|
||||
}
|
||||
|
||||
export interface StepRenderProps<TState> {
|
||||
state: TState;
|
||||
update: (patch: Partial<TState>) => void;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface StepWizardProps<TState> {
|
||||
/** i18n key for the wizard heading. */
|
||||
titleKey: string;
|
||||
/** i18n key for the short lead paragraph shown under the heading. */
|
||||
subtitleKey?: string;
|
||||
/** Optional back-link to the hub. */
|
||||
backTo?: string;
|
||||
backLabelKey?: string;
|
||||
steps: WizardStepDef<TState>[];
|
||||
initialState: TState;
|
||||
/** Called once the user clicks Finish on the last step. */
|
||||
onFinish: (state: TState) => Promise<void> | void;
|
||||
/** i18n key for the Finish button label. Defaults to "wizard.finish". */
|
||||
finishLabelKey?: string;
|
||||
/** Disable all form controls (used by consumers while submitting). */
|
||||
submitting?: boolean;
|
||||
}
|
||||
|
||||
export function StepWizard<TState>({
|
||||
titleKey,
|
||||
subtitleKey,
|
||||
backTo,
|
||||
backLabelKey = "wizard.backToHub",
|
||||
steps,
|
||||
initialState,
|
||||
onFinish,
|
||||
finishLabelKey = "wizard.finish",
|
||||
submitting = false,
|
||||
}: StepWizardProps<TState>) {
|
||||
const { t } = useTranslation();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [state, setState] = useState<TState>(initialState);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submittingInternal, setSubmittingInternal] = useState(false);
|
||||
|
||||
const busy = submitting || submittingInternal;
|
||||
const current = steps[index];
|
||||
const isLast = index === steps.length - 1;
|
||||
const progress = Math.round(((index + 1) / steps.length) * 100);
|
||||
|
||||
const update = (patch: Partial<TState>) => {
|
||||
setState((prev) => ({ ...prev, ...patch }));
|
||||
// Clear validation error as soon as the user starts typing again.
|
||||
if (error) setError(null);
|
||||
};
|
||||
|
||||
const validateAndAdvance = async () => {
|
||||
const msg = current.validate?.(state) ?? null;
|
||||
if (msg) {
|
||||
setError(msg);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
|
||||
if (!isLast) {
|
||||
setIndex((i) => i + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Last step: submit.
|
||||
try {
|
||||
setSubmittingInternal(true);
|
||||
await onFinish(state);
|
||||
} finally {
|
||||
setSubmittingInternal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stepStatus = useMemo(
|
||||
() =>
|
||||
steps.map((_, i) => {
|
||||
if (i < index) return "done" as const;
|
||||
if (i === index) return "active" as const;
|
||||
return "upcoming" as const;
|
||||
}),
|
||||
[steps, index],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
{/* Heading + back link */}
|
||||
<div className="space-y-2">
|
||||
{backTo && (
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 text-muted-foreground">
|
||||
<Link to={backTo} className="flex items-center gap-1">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t(backLabelKey)}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t(titleKey)}</h1>
|
||||
{subtitleKey && <p className="text-muted-foreground">{t(subtitleKey)}</p>}
|
||||
</div>
|
||||
|
||||
{/* Stepper */}
|
||||
<ol className="flex items-center gap-2 overflow-x-auto pb-2" role="list">
|
||||
{steps.map((step, i) => {
|
||||
const s = stepStatus[i];
|
||||
return (
|
||||
<li key={step.id} className="flex items-center gap-2 shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold",
|
||||
s === "done" && "bg-primary text-primary-foreground",
|
||||
s === "active" && "bg-primary/10 text-primary ring-2 ring-primary",
|
||||
s === "upcoming" && "bg-muted text-muted-foreground",
|
||||
)}
|
||||
aria-current={s === "active" ? "step" : undefined}
|
||||
>
|
||||
{s === "done" ? <Check className="h-4 w-4" /> : i + 1}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm whitespace-nowrap",
|
||||
s === "active" ? "font-medium" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t(step.titleKey)}
|
||||
</span>
|
||||
{i < steps.length - 1 && <div className="mx-1 h-px w-8 bg-border" />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Active step */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<h2 className="text-lg font-semibold">{t(current.titleKey)}</h2>
|
||||
{current.descriptionKey && (
|
||||
<p className="text-sm text-muted-foreground">{t(current.descriptionKey)}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{current.render({ state, update, error })}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIndex((i) => Math.max(0, i - 1))}
|
||||
disabled={busy || index === 0}
|
||||
>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
{t("wizard.back")}
|
||||
</Button>
|
||||
|
||||
<div className="text-xs text-muted-foreground tabular-nums">
|
||||
{t("wizard.stepOf", { current: index + 1, total: steps.length })}
|
||||
</div>
|
||||
|
||||
<Button onClick={validateAndAdvance} disabled={busy}>
|
||||
{isLast ? t(finishLabelKey) : t("wizard.next")}
|
||||
{!isLast && <ArrowRight className="ml-1 h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StepWizard;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
|
||||
import { authService } from "@/services/auth.service";
|
||||
import { clearToken } from "@/lib/api-client";
|
||||
import { clearToken, getActiveEntityId, setActiveEntityId } from "@/lib/api-client";
|
||||
import type { User, UserRole } from "@/types/auth";
|
||||
|
||||
export type { UserRole };
|
||||
@@ -9,6 +9,9 @@ interface AuthContextType {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
selectedEntityId: number | null;
|
||||
selectedEntity: User["entities"][number] | null;
|
||||
setSelectedEntityId: (entityId: number | null) => void;
|
||||
login: (email: string, password: string) => Promise<User>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
@@ -17,8 +20,23 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [selectedEntityId, setSelectedEntityIdState] = useState<number | null>(getActiveEntityId());
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const syncEntitySelection = useCallback((nextUser: User | null) => {
|
||||
const available = nextUser?.entities ?? [];
|
||||
if (available.length === 0) {
|
||||
setSelectedEntityIdState(null);
|
||||
setActiveEntityId(null);
|
||||
return;
|
||||
}
|
||||
const stored = getActiveEntityId();
|
||||
const validStored = stored && available.some((e) => e.id === stored) ? stored : null;
|
||||
const next = validStored ?? available[0].id;
|
||||
setSelectedEntityIdState(next);
|
||||
setActiveEntityId(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("encoach_token");
|
||||
if (!token) {
|
||||
@@ -28,30 +46,61 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
authService
|
||||
.getUser()
|
||||
.then(setUser)
|
||||
.then((u) => {
|
||||
setUser(u);
|
||||
syncEntitySelection(u);
|
||||
})
|
||||
.catch(() => {
|
||||
clearToken();
|
||||
setActiveEntityId(null);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
}, [syncEntitySelection]);
|
||||
|
||||
const login = useCallback(async (email: string, password: string): Promise<User> => {
|
||||
const res = await authService.login({ login: email, password });
|
||||
setUser(res.user);
|
||||
syncEntitySelection(res.user);
|
||||
return res.user;
|
||||
}, []);
|
||||
}, [syncEntitySelection]);
|
||||
|
||||
const setSelectedEntityId = useCallback((entityId: number | null) => {
|
||||
if (!user) {
|
||||
setSelectedEntityIdState(null);
|
||||
setActiveEntityId(null);
|
||||
return;
|
||||
}
|
||||
if (entityId == null) {
|
||||
setSelectedEntityIdState(null);
|
||||
setActiveEntityId(null);
|
||||
return;
|
||||
}
|
||||
if (!user.entities.some((e) => e.id === entityId)) return;
|
||||
setSelectedEntityIdState(entityId);
|
||||
setActiveEntityId(entityId);
|
||||
}, [user]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await authService.logout();
|
||||
setUser(null);
|
||||
setSelectedEntityIdState(null);
|
||||
setActiveEntityId(null);
|
||||
}, []);
|
||||
|
||||
const selectedEntity =
|
||||
user?.entities.find((e) => e.id === selectedEntityId) ??
|
||||
user?.entities[0] ??
|
||||
null;
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
isLoading,
|
||||
selectedEntityId,
|
||||
selectedEntity,
|
||||
setSelectedEntityId,
|
||||
login,
|
||||
logout,
|
||||
}}
|
||||
|
||||
@@ -4,241 +4,246 @@ export const queryKeys = {
|
||||
},
|
||||
users: {
|
||||
all: ["users"] as const,
|
||||
list: (params: Record<string, unknown>) => ["users", "list", params] as const,
|
||||
list: (params: object) => ["users", "list", params] as const,
|
||||
detail: (id: number) => ["users", id] as const,
|
||||
},
|
||||
entities: {
|
||||
all: ["entities"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["entities", "list", params] as const,
|
||||
list: (params?: object) => ["entities", "list", params] as const,
|
||||
detail: (id: number) => ["entities", id] as const,
|
||||
roles: (entityId: number) => ["entities", entityId, "roles"] as const,
|
||||
permissions: (entityId: number) => ["entities", entityId, "permissions"] as const,
|
||||
},
|
||||
exams: {
|
||||
all: ["exams"] as const,
|
||||
list: (module: string, params?: Record<string, unknown>) => ["exams", module, params] as const,
|
||||
list: (module: string, params?: object) => ["exams", module, params] as const,
|
||||
detail: (module: string, id: number) => ["exams", module, id] as const,
|
||||
rubrics: (params?: Record<string, unknown>) => ["exams", "rubrics", params] as const,
|
||||
rubricGroups: (params?: Record<string, unknown>) => ["exams", "rubric-groups", params] as const,
|
||||
structures: (params?: Record<string, unknown>) => ["exams", "structures", params] as const,
|
||||
rubrics: (params?: object) => ["exams", "rubrics", params] as const,
|
||||
rubricGroups: (params?: object) => ["exams", "rubric-groups", params] as const,
|
||||
structures: (params?: object) => ["exams", "structures", params] as const,
|
||||
avatars: ["exams", "avatars"] as const,
|
||||
reviewQueue: (params?: object) => ["exams", "review", "queue", params] as const,
|
||||
reviewDetail: (id: number) => ["exams", "review", "detail", id] as const,
|
||||
},
|
||||
assignments: {
|
||||
all: ["assignments"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["assignments", "list", params] as const,
|
||||
list: (params?: object) => ["assignments", "list", params] as const,
|
||||
detail: (id: number) => ["assignments", id] as const,
|
||||
},
|
||||
classrooms: {
|
||||
all: ["classrooms"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["classrooms", "list", params] as const,
|
||||
list: (params?: object) => ["classrooms", "list", params] as const,
|
||||
detail: (id: number) => ["classrooms", id] as const,
|
||||
},
|
||||
stats: {
|
||||
sessions: (params?: Record<string, unknown>) => ["stats", "sessions", params] as const,
|
||||
stats: (params?: Record<string, unknown>) => ["stats", "stats", params] as const,
|
||||
statistical: (params?: Record<string, unknown>) => ["stats", "statistical", params] as const,
|
||||
performance: (params?: Record<string, unknown>) => ["stats", "performance", params] as const,
|
||||
sessions: (params?: object) => ["stats", "sessions", params] as const,
|
||||
stats: (params?: object) => ["stats", "stats", params] as const,
|
||||
statistical: (params?: object) => ["stats", "statistical", params] as const,
|
||||
performance: (params?: object) => ["stats", "performance", params] as const,
|
||||
},
|
||||
training: {
|
||||
all: ["training"] as const,
|
||||
tips: (params?: Record<string, unknown>) => ["training", "tips", params] as const,
|
||||
tips: (params?: object) => ["training", "tips", params] as const,
|
||||
walkthroughs: (module?: string) => ["training", "walkthroughs", module] as const,
|
||||
},
|
||||
subscriptions: {
|
||||
packages: ["subscriptions", "packages"] as const,
|
||||
payments: (params?: Record<string, unknown>) => ["subscriptions", "payments", params] as const,
|
||||
discounts: (params?: Record<string, unknown>) => ["subscriptions", "discounts", params] as const,
|
||||
payments: (params?: object) => ["subscriptions", "payments", params] as const,
|
||||
discounts: (params?: object) => ["subscriptions", "discounts", params] as const,
|
||||
},
|
||||
tickets: {
|
||||
all: ["tickets"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["tickets", "list", params] as const,
|
||||
list: (params?: object) => ["tickets", "list", params] as const,
|
||||
assigned: ["tickets", "assigned"] as const,
|
||||
},
|
||||
approvals: {
|
||||
all: ["approvals"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["approvals", "list", params] as const,
|
||||
list: (params?: object) => ["approvals", "list", params] as const,
|
||||
},
|
||||
taxonomy: {
|
||||
subjects: ["taxonomy", "subjects"] as const,
|
||||
subject: (id: number) => ["taxonomy", "subjects", id] as const,
|
||||
tree: (subjectId: number) => ["taxonomy", "tree", subjectId] as const,
|
||||
domains: (params?: Record<string, unknown>) => ["taxonomy", "domains", params] as const,
|
||||
topics: (params?: Record<string, unknown>) => ["taxonomy", "topics", params] as const,
|
||||
domains: (params?: object) => ["taxonomy", "domains", params] as const,
|
||||
topics: (params?: object) => ["taxonomy", "topics", params] as const,
|
||||
},
|
||||
adaptive: {
|
||||
proficiency: (params?: Record<string, unknown>) => ["adaptive", "proficiency", params] as const,
|
||||
proficiency: (params?: object) => ["adaptive", "proficiency", params] as const,
|
||||
summary: ["adaptive", "proficiency", "summary"] as const,
|
||||
plan: (params?: Record<string, unknown>) => ["adaptive", "plan", params] as const,
|
||||
plan: (params?: object) => ["adaptive", "plan", params] as const,
|
||||
topicContent: (topicId: number) => ["adaptive", "topic", topicId, "content"] as const,
|
||||
diagnostic: (sessionId: number) => ["adaptive", "diagnostic", sessionId] as const,
|
||||
},
|
||||
resources: {
|
||||
all: ["resources"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["resources", "list", params] as const,
|
||||
list: (params?: object) => ["resources", "list", params] as const,
|
||||
},
|
||||
lms: {
|
||||
courses: (params?: Record<string, unknown>) => ["lms", "courses", params] as const,
|
||||
courses: (params?: object) => ["lms", "courses", params] as const,
|
||||
course: (id: number) => ["lms", "courses", id] as const,
|
||||
students: (params?: Record<string, unknown>) => ["lms", "students", params] as const,
|
||||
teachers: (params?: Record<string, unknown>) => ["lms", "teachers", params] as const,
|
||||
batches: (params?: Record<string, unknown>) => ["lms", "batches", params] as const,
|
||||
myCourses: ["lms", "my-courses"] as const,
|
||||
students: (params?: object) => ["lms", "students", params] as const,
|
||||
teachers: (params?: object) => ["lms", "teachers", params] as const,
|
||||
batches: (params?: object) => ["lms", "batches", params] as const,
|
||||
batch: (id: number) => ["lms", "batches", id] as const,
|
||||
timetable: (params?: Record<string, unknown>) => ["lms", "timetable", params] as const,
|
||||
attendance: (params?: Record<string, unknown>) => ["lms", "attendance", params] as const,
|
||||
grades: (params?: Record<string, unknown>) => ["lms", "grades", params] as const,
|
||||
timetable: (params?: object) => ["lms", "timetable", params] as const,
|
||||
attendance: (params?: object) => ["lms", "attendance", params] as const,
|
||||
grades: (params?: object) => ["lms", "grades", params] as const,
|
||||
},
|
||||
analytics: {
|
||||
student: (params?: Record<string, unknown>) => ["analytics", "student", params] as const,
|
||||
class: (params?: Record<string, unknown>) => ["analytics", "class", params] as const,
|
||||
subject: (params?: Record<string, unknown>) => ["analytics", "subject", params] as const,
|
||||
contentGaps: (params?: Record<string, unknown>) => ["analytics", "content-gaps", params] as const,
|
||||
student: (params?: object) => ["analytics", "student", params] as const,
|
||||
class: (params?: object) => ["analytics", "class", params] as const,
|
||||
subject: (params?: object) => ["analytics", "subject", params] as const,
|
||||
contentGaps: (params?: object) => ["analytics", "content-gaps", params] as const,
|
||||
alerts: ["analytics", "alerts"] as const,
|
||||
},
|
||||
academicYears: {
|
||||
all: ["academic-years"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["academic-years", "list", params] as const,
|
||||
list: (params?: object) => ["academic-years", "list", params] as const,
|
||||
detail: (id: number) => ["academic-years", id] as const,
|
||||
},
|
||||
academicTerms: {
|
||||
all: ["academic-terms"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["academic-terms", "list", params] as const,
|
||||
list: (params?: object) => ["academic-terms", "list", params] as const,
|
||||
detail: (id: number) => ["academic-terms", id] as const,
|
||||
},
|
||||
departments: {
|
||||
all: ["departments"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["departments", "list", params] as const,
|
||||
list: (params?: object) => ["departments", "list", params] as const,
|
||||
detail: (id: number) => ["departments", id] as const,
|
||||
},
|
||||
admissionRegisters: {
|
||||
all: ["admission-registers"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["admission-registers", "list", params] as const,
|
||||
list: (params?: object) => ["admission-registers", "list", params] as const,
|
||||
detail: (id: number) => ["admission-registers", id] as const,
|
||||
},
|
||||
admissions: {
|
||||
all: ["admissions"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["admissions", "list", params] as const,
|
||||
list: (params?: object) => ["admissions", "list", params] as const,
|
||||
detail: (id: number) => ["admissions", id] as const,
|
||||
},
|
||||
instExamSessions: {
|
||||
all: ["inst-exam-sessions"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["inst-exam-sessions", "list", params] as const,
|
||||
list: (params?: object) => ["inst-exam-sessions", "list", params] as const,
|
||||
detail: (id: number) => ["inst-exam-sessions", id] as const,
|
||||
},
|
||||
examTypes: {
|
||||
all: ["exam-types"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["exam-types", "list", params] as const,
|
||||
list: (params?: object) => ["exam-types", "list", params] as const,
|
||||
},
|
||||
gradeConfigs: {
|
||||
all: ["grade-configs"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["grade-configs", "list", params] as const,
|
||||
list: (params?: object) => ["grade-configs", "list", params] as const,
|
||||
},
|
||||
resultTemplates: {
|
||||
all: ["result-templates"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["result-templates", "list", params] as const,
|
||||
list: (params?: object) => ["result-templates", "list", params] as const,
|
||||
},
|
||||
marksheets: {
|
||||
all: ["marksheets"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["marksheets", "list", params] as const,
|
||||
list: (params?: object) => ["marksheets", "list", params] as const,
|
||||
detail: (id: number) => ["marksheets", id] as const,
|
||||
},
|
||||
courseAssignments: {
|
||||
all: ["course-assignments"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["course-assignments", "list", params] as const,
|
||||
list: (params?: object) => ["course-assignments", "list", params] as const,
|
||||
detail: (id: number) => ["course-assignments", id] as const,
|
||||
},
|
||||
assignmentTypes: {
|
||||
all: ["assignment-types"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["assignment-types", "list", params] as const,
|
||||
list: (params?: object) => ["assignment-types", "list", params] as const,
|
||||
},
|
||||
subjectRegistrations: {
|
||||
all: ["subject-registrations"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["subject-registrations", "list", params] as const,
|
||||
available: (params?: Record<string, unknown>) => ["subject-registrations", "available", params] as const,
|
||||
list: (params?: object) => ["subject-registrations", "list", params] as const,
|
||||
available: (params?: object) => ["subject-registrations", "available", params] as const,
|
||||
},
|
||||
courseCompletion: (courseId: number) => ["course-completion", courseId] as const,
|
||||
chapters: (courseId: number) => ["chapters", "list", courseId] as const,
|
||||
chapter: (id: number) => ["chapters", "detail", id] as const,
|
||||
chapterMaterials: (chapterId: number) => ["chapter-materials", chapterId] as const,
|
||||
chapterProgress: (chapterId: number) => ["chapter-progress", chapterId] as const,
|
||||
discussionBoards: (params?: Record<string, unknown>) => ["discussion-boards", params] as const,
|
||||
posts: (boardId: number, params?: Record<string, unknown>) => ["posts", boardId, params] as const,
|
||||
announcements: (params?: Record<string, unknown>) => ["announcements", params] as const,
|
||||
messages: (params?: Record<string, unknown>) => ["messages", params] as const,
|
||||
sentMessages: (params?: Record<string, unknown>) => ["messages", "sent", params] as const,
|
||||
discussionBoards: (params?: object) => ["discussion-boards", params] as const,
|
||||
posts: (boardId: number, params?: object) => ["posts", boardId, params] as const,
|
||||
announcements: (params?: object) => ["announcements", params] as const,
|
||||
messages: (params?: object) => ["messages", params] as const,
|
||||
sentMessages: (params?: object) => ["messages", "sent", params] as const,
|
||||
unreadMessages: ["messages", "unread-count"] as const,
|
||||
notifications: (params?: Record<string, unknown>) => ["notifications", params] as const,
|
||||
notifications: (params?: object) => ["notifications", params] as const,
|
||||
unreadNotifications: ["notifications", "unread-count"] as const,
|
||||
notificationRules: ["notification-rules"] as const,
|
||||
notificationPreferences: ["notification-preferences"] as const,
|
||||
faqCategories: (audience?: string) => ["faq", "categories", audience] as const,
|
||||
faqItems: (params?: Record<string, unknown>) => ["faq", "items", params] as const,
|
||||
faqItems: (params?: object) => ["faq", "items", params] as const,
|
||||
studentLeaves: {
|
||||
all: ["student-leaves"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["student-leaves", "list", params] as const,
|
||||
list: (params?: object) => ["student-leaves", "list", params] as const,
|
||||
},
|
||||
studentLeaveTypes: {
|
||||
all: ["student-leave-types"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["student-leave-types", "list", params] as const,
|
||||
list: (params?: object) => ["student-leave-types", "list", params] as const,
|
||||
},
|
||||
feesPlans: {
|
||||
all: ["fees-plans"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["fees-plans", "list", params] as const,
|
||||
list: (params?: object) => ["fees-plans", "list", params] as const,
|
||||
detail: (id: number) => ["fees-plans", id] as const,
|
||||
},
|
||||
studentFees: {
|
||||
all: ["student-fees"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["student-fees", "list", params] as const,
|
||||
list: (params?: object) => ["student-fees", "list", params] as const,
|
||||
},
|
||||
feesTerms: {
|
||||
all: ["fees-terms"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["fees-terms", "list", params] as const,
|
||||
list: (params?: object) => ["fees-terms", "list", params] as const,
|
||||
},
|
||||
lessons: {
|
||||
all: ["lessons"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["lessons", "list", params] as const,
|
||||
list: (params?: object) => ["lessons", "list", params] as const,
|
||||
},
|
||||
gradebooks: {
|
||||
all: ["gradebooks"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["gradebooks", "list", params] as const,
|
||||
list: (params?: object) => ["gradebooks", "list", params] as const,
|
||||
},
|
||||
gradebookLines: {
|
||||
all: ["gradebook-lines"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["gradebook-lines", "list", params] as const,
|
||||
list: (params?: object) => ["gradebook-lines", "list", params] as const,
|
||||
},
|
||||
gradingAssignments: {
|
||||
all: ["grading-assignments"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["grading-assignments", "list", params] as const,
|
||||
list: (params?: object) => ["grading-assignments", "list", params] as const,
|
||||
},
|
||||
studentProgress: {
|
||||
all: ["student-progress"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["student-progress", "list", params] as const,
|
||||
list: (params?: object) => ["student-progress", "list", params] as const,
|
||||
detail: (id: number) => ["student-progress", "detail", id] as const,
|
||||
},
|
||||
libraryMedia: {
|
||||
all: ["library-media"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["library-media", "list", params] as const,
|
||||
list: (params?: object) => ["library-media", "list", params] as const,
|
||||
},
|
||||
libraryMovements: {
|
||||
all: ["library-movements"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["library-movements", "list", params] as const,
|
||||
list: (params?: object) => ["library-movements", "list", params] as const,
|
||||
},
|
||||
libraryCards: {
|
||||
all: ["library-cards"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["library-cards", "list", params] as const,
|
||||
list: (params?: object) => ["library-cards", "list", params] as const,
|
||||
},
|
||||
activities: {
|
||||
all: ["activities"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["activities", "list", params] as const,
|
||||
list: (params?: object) => ["activities", "list", params] as const,
|
||||
},
|
||||
activityTypes: {
|
||||
all: ["activity-types"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["activity-types", "list", params] as const,
|
||||
list: (params?: object) => ["activity-types", "list", params] as const,
|
||||
},
|
||||
facilities: {
|
||||
all: ["facilities"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["facilities", "list", params] as const,
|
||||
list: (params?: object) => ["facilities", "list", params] as const,
|
||||
},
|
||||
assets: {
|
||||
all: ["assets"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["assets", "list", params] as const,
|
||||
list: (params?: object) => ["assets", "list", params] as const,
|
||||
},
|
||||
signup: {
|
||||
goals: ["signup", "goals"] as const,
|
||||
@@ -255,7 +260,7 @@ export const queryKeys = {
|
||||
},
|
||||
ieltsExam: {
|
||||
skills: (examId: number) => ["ielts-exam", examId, "skills"] as const,
|
||||
contentPool: (examId: number, filters?: Record<string, unknown>) => ["ielts-exam", examId, "content-pool", filters] as const,
|
||||
contentPool: (examId: number, filters?: object) => ["ielts-exam", examId, "content-pool", filters] as const,
|
||||
validation: (examId: number) => ["ielts-exam", examId, "validation"] as const,
|
||||
},
|
||||
examSession: {
|
||||
@@ -263,7 +268,7 @@ export const queryKeys = {
|
||||
status: (examId: number) => ["exam-session", examId, "status"] as const,
|
||||
},
|
||||
grading: {
|
||||
queue: (params?: Record<string, unknown>) => ["grading", "queue", params] as const,
|
||||
queue: (params?: object) => ["grading", "queue", params] as const,
|
||||
response: (attemptId: number, skill: string) => ["grading", attemptId, "response", skill] as const,
|
||||
rubric: (attemptId: number, skill: string) => ["grading", attemptId, "rubric", skill] as const,
|
||||
},
|
||||
@@ -279,11 +284,11 @@ export const queryKeys = {
|
||||
englishTaxonomy: ["ai-course", "english", "taxonomy"] as const,
|
||||
},
|
||||
entityOnboarding: {
|
||||
credentials: (params?: Record<string, unknown>) => ["entity-onboarding", "credentials", params] as const,
|
||||
credentials: (params?: object) => ["entity-onboarding", "credentials", params] as const,
|
||||
},
|
||||
adaptiveEngine: {
|
||||
dashboard: ["adaptive-engine", "dashboard"] as const,
|
||||
students: (params?: Record<string, unknown>) => ["adaptive-engine", "students", params] as const,
|
||||
students: (params?: object) => ["adaptive-engine", "students", params] as const,
|
||||
signals: (studentId: number) => ["adaptive-engine", "signals", studentId] as const,
|
||||
ability: (studentId: number) => ["adaptive-engine", "ability", studentId] as const,
|
||||
settings: ["adaptive-engine", "settings"] as const,
|
||||
@@ -296,7 +301,7 @@ export const queryKeys = {
|
||||
current: ["branding", "current"] as const,
|
||||
},
|
||||
scoreRelease: {
|
||||
pending: (params?: Record<string, unknown>) => ["score-release", "pending", params] as const,
|
||||
pending: (params?: object) => ["score-release", "pending", params] as const,
|
||||
},
|
||||
verification: {
|
||||
verify: (hash: string) => ["verification", hash] as const,
|
||||
|
||||
70
src/hooks/queries/useAIFeedback.ts
Normal file
@@ -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();
|
||||
return useMutation({
|
||||
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 { queryKeys } from "./keys";
|
||||
import { aiCourseService } from "@/services/ai-course.service";
|
||||
import type { ExaminerReview } from "@/types";
|
||||
import {
|
||||
aiCourseService,
|
||||
type AiCourseCreateEnglishRequest,
|
||||
type AiCourseCreateIeltsRequest,
|
||||
} from "@/services/ai-course.service";
|
||||
|
||||
export function useAiCourse(courseId: number | undefined) {
|
||||
return useQuery({
|
||||
@@ -22,7 +25,7 @@ export function useAiCourseTracks(courseId: number | undefined) {
|
||||
export function useCreateEnglishCourse() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { current_level: string; target_level: string; learning_style: string[] }) =>
|
||||
mutationFn: (data: AiCourseCreateEnglishRequest) =>
|
||||
aiCourseService.createEnglish(data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
||||
@@ -33,7 +36,7 @@ export function useCreateEnglishCourse() {
|
||||
export function useCreateIeltsCourse() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { exam_type: string; target_band: number; skills: string[] }) =>
|
||||
mutationFn: (data: AiCourseCreateIeltsRequest) =>
|
||||
aiCourseService.createIelts(data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
||||
@@ -63,8 +66,8 @@ export function useApproveQuality() {
|
||||
export function useRejectQuality() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ courseId, notes }: { courseId: number; notes: string }) =>
|
||||
aiCourseService.rejectQuality(courseId, notes),
|
||||
mutationFn: ({ courseId, reason }: { courseId: number; reason: string }) =>
|
||||
aiCourseService.rejectQuality(courseId, reason),
|
||||
onSuccess: (_d, { courseId }) => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.quality(courseId) });
|
||||
},
|
||||
@@ -89,7 +92,8 @@ export function useIeltsValidation(courseId: number | undefined) {
|
||||
export function useSubmitExaminerReview() {
|
||||
const qc = useQueryClient();
|
||||
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: () => {
|
||||
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() {
|
||||
const qc = useQueryClient();
|
||||
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() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (chapterId: number) => coursewareService.markChapterComplete(chapterId),
|
||||
onSuccess: (_, chapterId) => {
|
||||
mutationFn: ({ chapterId, courseId }: { chapterId: number; courseId: number }) =>
|
||||
coursewareService.markChapterComplete(chapterId),
|
||||
onSuccess: (_, { chapterId, courseId }) => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.chapterProgress(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() {
|
||||
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({
|
||||
queryKey: queryKeys.ieltsExam.validation(examId),
|
||||
queryFn: () => ieltsExamService.validate(examId),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { queryKeys } from "./keys";
|
||||
import { feesService } from "@/services/fees.service";
|
||||
import { feesService, type FeesPlanFilters } from "@/services/fees.service";
|
||||
import type { PaginationParams } from "@/types";
|
||||
|
||||
export function useFeesPlans(params?: PaginationParams) {
|
||||
export function useFeesPlans(params?: FeesPlanFilters) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.feesPlans.list(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({
|
||||
queryKey: queryKeys.studentFees.list(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({
|
||||
queryKey: queryKeys.gradebookLines.list(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 { queryKeys } from "./keys";
|
||||
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 }) {
|
||||
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 }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.lms.students(params),
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { queryKeys } from "./keys";
|
||||
import { studentProgressService } from "@/services/student-progress.service";
|
||||
import type { PaginationParams } from "@/types";
|
||||
import { studentProgressService, type StudentProgressFilters } from "@/services/student-progress.service";
|
||||
|
||||
export function useStudentProgressList(params?: PaginationParams) {
|
||||
export function useStudentProgressList(params?: StudentProgressFilters) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.studentProgress.list(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;
|
||||
1134
src/i18n/locales/ar.ts
Normal file
1196
src/i18n/locales/en.ts
Normal file
114
src/index.css
@@ -51,6 +51,14 @@
|
||||
--sidebar-accent-foreground: 8 40% 78%;
|
||||
--sidebar-border: 240 18% 20%;
|
||||
--sidebar-ring: 8 50% 58%;
|
||||
|
||||
/* Chart palette — used by Recharts via hsl(var(--chart-N)). Kept on the
|
||||
warm/cool axis so pairs read well together when stacked. */
|
||||
--chart-1: 8 50% 54%;
|
||||
--chart-2: 220 70% 52%;
|
||||
--chart-3: 152 60% 42%;
|
||||
--chart-4: 38 92% 50%;
|
||||
--chart-5: 280 55% 55%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -99,6 +107,12 @@
|
||||
--sidebar-accent-foreground: 8 40% 75%;
|
||||
--sidebar-border: 240 18% 13%;
|
||||
--sidebar-ring: 8 50% 55%;
|
||||
|
||||
--chart-1: 8 52% 62%;
|
||||
--chart-2: 220 65% 65%;
|
||||
--chart-3: 152 50% 55%;
|
||||
--chart-4: 38 75% 60%;
|
||||
--chart-5: 280 55% 68%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,3 +134,103 @@
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* RTL / Arabic support
|
||||
*
|
||||
* `tailwindcss-rtl` handles the bulk of the work: physical margin / padding /
|
||||
* position / border / rounded / text-align / space-x utilities are mirrored
|
||||
* automatically when `html[dir="rtl"]` is active.
|
||||
*
|
||||
* Below we cover the things Tailwind can't: the webfont (Inter has weak
|
||||
* Arabic glyphs, so swap in Cairo), recharts tooltips/axes which have their
|
||||
* own DOM, and a few icons that should NOT mirror (arrows used as pure
|
||||
* visual affordance e.g. ChevronRight in a dropdown).
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
html[dir="rtl"] body,
|
||||
html[dir="rtl"] h1,
|
||||
html[dir="rtl"] h2,
|
||||
html[dir="rtl"] h3,
|
||||
html[dir="rtl"] h4,
|
||||
html[dir="rtl"] h5,
|
||||
html[dir="rtl"] h6,
|
||||
html[dir="rtl"] input,
|
||||
html[dir="rtl"] textarea,
|
||||
html[dir="rtl"] button,
|
||||
html[dir="rtl"] select {
|
||||
font-family: 'Cairo', 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html[dir="rtl"] code,
|
||||
html[dir="rtl"] pre {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .recharts-wrapper,
|
||||
html[dir="rtl"] .recharts-legend-wrapper {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
/* Breadcrumb and submenu chevrons: always pointing "forward" in reading
|
||||
direction. Lucide's ChevronRight is ">" which is forward in LTR, but in
|
||||
RTL the same arrow must become "<". We flip via CSS so no component has
|
||||
to know about direction. */
|
||||
html[dir="rtl"] nav[aria-label="breadcrumb"] li[role="presentation"] > svg,
|
||||
html[dir="rtl"] [data-radix-menu-content] [role="menuitem"] > svg:last-child,
|
||||
html[dir="rtl"] [data-radix-popper-content-wrapper] [role="menuitem"] > svg:last-child {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Numbers, percentages, emails, URLs, dates should stay LTR-isolated even
|
||||
when embedded in Arabic paragraphs, otherwise slashes / percent signs /
|
||||
dashes drift to the wrong side and the value becomes unreadable.
|
||||
<bdi> already gives us this per-element; the helper classes below are
|
||||
for places where adding a wrapper element is awkward. */
|
||||
.ltr-nums,
|
||||
.dir-ltr {
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
/* Pure-number cells & badges align to the end (right in LTR, left in RTL)
|
||||
but the digits themselves still read left-to-right. */
|
||||
html[dir="rtl"] .numeric {
|
||||
text-align: end;
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
/* Some lucide icons are pure affordances (play, external-link, send) and
|
||||
should NOT be mirrored even if tailwindcss-rtl would otherwise flip the
|
||||
button that contains them. Authors opt-in with data-no-flip. */
|
||||
html[dir="rtl"] [data-no-flip] {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* Keep code snippets, identifiers, and file paths readable in RTL. */
|
||||
html[dir="rtl"] code,
|
||||
html[dir="rtl"] pre,
|
||||
html[dir="rtl"] kbd,
|
||||
html[dir="rtl"] samp,
|
||||
html[dir="rtl"] [data-monospace] {
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
/* Scrollbars inside horizontally-scrollable tables already flip naturally
|
||||
in RTL, but the shadow Radix portals (dropdowns, tooltips, dialogs) use
|
||||
`right-0` / `left-0` positioning computed in JS. For safety, make sure
|
||||
they inherit the document direction. */
|
||||
html[dir="rtl"] [data-radix-popper-content-wrapper] {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
/* Inputs with dir="ltr" (email, password, URL) override but keep the
|
||||
placeholder aligned to the visual start (right in RTL, left in LTR). */
|
||||
html[dir="rtl"] input[dir="ltr"]::placeholder,
|
||||
html[dir="rtl"] textarea[dir="ltr"]::placeholder {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
/** Base path or absolute URL for Odoo JSON API (dev: `/api` + Vite proxy). */
|
||||
/**
|
||||
* Odoo REST client with transparent access-token rotation.
|
||||
*
|
||||
* Tokens live in ``localStorage`` under three keys:
|
||||
* - ``encoach_token`` — short-lived access JWT (1h)
|
||||
* - ``encoach_refresh_token`` — long-lived refresh JWT (7d)
|
||||
* - ``encoach_token_exp`` — epoch seconds for the access token
|
||||
*
|
||||
* When a request receives ``401`` *and* a refresh token is present, the client
|
||||
* silently rotates the pair via ``POST /api/auth/refresh`` and retries the
|
||||
* original request exactly once. Multiple concurrent 401s coalesce onto the
|
||||
* same refresh promise so we never fire more than one rotation in flight.
|
||||
*
|
||||
* Motivation: before this change, every expired access token forced a full
|
||||
* re-login, which interrupted long admin dashboards (stats, reports) multiple
|
||||
* times per hour. With the refresh loop, access tokens can be short-lived
|
||||
* (1h) without hurting UX, giving us the security benefit of short access
|
||||
* windows without an eager logout.
|
||||
*/
|
||||
|
||||
export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL?.trim() || "/api").replace(/\/$/, "");
|
||||
const BASE_URL = API_BASE_URL;
|
||||
|
||||
@@ -21,120 +40,388 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem("encoach_token");
|
||||
/**
|
||||
* Turn an arbitrary thrown value from an API mutation into a human-readable,
|
||||
* actionable toast description. Deployment QA kept reporting generic
|
||||
* "Submit failed" / "Upload failed" toasts — this helper surfaces the HTTP
|
||||
* status and, when we recognise the code, a hint about what to do next
|
||||
* (payload too big → ask ops to raise nginx, timeout → retry, etc.).
|
||||
*
|
||||
* @param err the error thrown by an `api.*` call (usually an ApiError)
|
||||
* @param fallback text to show if we can't classify the error
|
||||
* @param context short context word used in the 413/504 hints
|
||||
* ("file", "request", "payload" …)
|
||||
*/
|
||||
export function describeApiError(
|
||||
err: unknown,
|
||||
fallback = "Something went wrong",
|
||||
context = "request",
|
||||
): string {
|
||||
const e = err as { status?: number; message?: string; data?: unknown } | null;
|
||||
const status = e?.status;
|
||||
// Try to extract a server-side error body even if the generic ApiError
|
||||
// message wasn't populated (nginx 413/504 respond with an HTML page so
|
||||
// `data.error` is empty and `message` looks like "413 ").
|
||||
const serverMsg =
|
||||
e?.data && typeof e.data === "object" && "error" in (e.data as object)
|
||||
? String((e.data as { error: unknown }).error || "").trim()
|
||||
: "";
|
||||
|
||||
if (status === 413) {
|
||||
return `The ${context} is too large for the server to accept (HTTP 413). Try a smaller file / fewer tasks, or ask an admin to raise the nginx \`client_max_body_size\` and Odoo \`limit_request\`.`;
|
||||
}
|
||||
if (status === 504) {
|
||||
return `The server took too long to respond (HTTP 504). The ${context} may still be processing — wait a minute and reload before retrying.`;
|
||||
}
|
||||
if (status === 502) {
|
||||
return "The backend is unreachable (HTTP 502). Odoo may be restarting — try again in a moment.";
|
||||
}
|
||||
if (status === 401) {
|
||||
return "Your session expired — please sign in again.";
|
||||
}
|
||||
if (status === 403) {
|
||||
return serverMsg || "You don't have permission to do that (HTTP 403).";
|
||||
}
|
||||
if (status === 404) {
|
||||
return serverMsg || "The requested item no longer exists (HTTP 404).";
|
||||
}
|
||||
if (status === 422 || status === 400) {
|
||||
return serverMsg || `The server rejected the ${context} as invalid (HTTP ${status}).`;
|
||||
}
|
||||
if (status && status >= 500) {
|
||||
return `${serverMsg || "The server returned an error"} (HTTP ${status}). Check the Odoo logs for a stack trace.`;
|
||||
}
|
||||
|
||||
// Network failure / fetch aborted / CORS — ApiError not thrown at all.
|
||||
if (!status && e?.message) return e.message;
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const ACCESS_KEY = "encoach_token";
|
||||
const REFRESH_KEY = "encoach_refresh_token";
|
||||
const EXP_KEY = "encoach_token_exp";
|
||||
const ENTITY_KEY = "encoach_entity_id";
|
||||
|
||||
function getAccessToken(): string | null {
|
||||
return localStorage.getItem(ACCESS_KEY);
|
||||
}
|
||||
|
||||
export function getActiveEntityId(): number | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(ENTITY_KEY);
|
||||
if (!raw) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setActiveEntityId(entityId: number | null | undefined): void {
|
||||
try {
|
||||
if (entityId && Number.isFinite(entityId)) {
|
||||
localStorage.setItem(ENTITY_KEY, String(Math.floor(entityId)));
|
||||
} else {
|
||||
localStorage.removeItem(ENTITY_KEY);
|
||||
}
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a URL to a media-streaming endpoint with the JWT attached as a
|
||||
* query parameter. Used by ``<img>`` / ``<audio>`` / ``<video>`` tags
|
||||
* (which can't attach custom Authorization headers) and by ``<a download>``
|
||||
* tags so the browser can fetch the binary directly without going through
|
||||
* a fetch + blob URL dance.
|
||||
*
|
||||
* Returns the original path unchanged when no token is stored, which lets
|
||||
* callers render a placeholder rather than crashing on ``null``.
|
||||
*/
|
||||
export function withAuthQuery(path: string): string {
|
||||
if (!path) return path;
|
||||
const token = getAccessToken();
|
||||
if (!token) return path;
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
return `${path}${sep}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
function getRefreshToken(): string | null {
|
||||
return localStorage.getItem(REFRESH_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem("encoach_token", token);
|
||||
localStorage.setItem(ACCESS_KEY, token);
|
||||
}
|
||||
|
||||
export function setRefreshToken(token: string | null | undefined): void {
|
||||
if (token) {
|
||||
localStorage.setItem(REFRESH_KEY, token);
|
||||
} else {
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function setTokenExpiry(epochSeconds: number | null | undefined): void {
|
||||
if (epochSeconds && Number.isFinite(epochSeconds)) {
|
||||
localStorage.setItem(EXP_KEY, String(Math.floor(epochSeconds)));
|
||||
} else {
|
||||
localStorage.removeItem(EXP_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the full token bundle returned by /api/login or /api/auth/refresh. */
|
||||
export function persistTokenBundle(bundle: {
|
||||
access_token?: string;
|
||||
token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
}): void {
|
||||
const access = bundle.access_token || bundle.token;
|
||||
if (access) setToken(access);
|
||||
setRefreshToken(bundle.refresh_token || null);
|
||||
if (bundle.expires_in) {
|
||||
setTokenExpiry(Math.floor(Date.now() / 1000) + bundle.expires_in);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
localStorage.removeItem("encoach_token");
|
||||
localStorage.removeItem(ACCESS_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
localStorage.removeItem(EXP_KEY);
|
||||
}
|
||||
|
||||
async function handleResponse<T>(response: Response): Promise<T> {
|
||||
const data = await response.json().catch(() => null);
|
||||
// Shared refresh promise — any 401 that arrives while a refresh is in flight
|
||||
// waits on the existing request instead of firing a duplicate.
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
if (response.status === 401) {
|
||||
const hadToken = !!getToken();
|
||||
clearToken();
|
||||
// Login failure is also 401 — do not hard-redirect when no session existed.
|
||||
if (hadToken) {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
throw new ApiError(401, response.statusText, data);
|
||||
async function performRefresh(): Promise<boolean> {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) return false;
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data: {
|
||||
access_token?: string;
|
||||
token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
} = await res.json().catch(() => ({} as never));
|
||||
if (!data.access_token && !data.token) return false;
|
||||
persistTokenBundle(data);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, response.statusText, data);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
function buildHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...extra,
|
||||
};
|
||||
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
function refreshOnce(): Promise<boolean> {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = performRefresh().finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
function getCurrentLanguage(): string {
|
||||
// Mirrors the i18n bootstrap in src/i18n/index.ts: the user's explicit
|
||||
// pick wins; everything else falls back to English. We intentionally do
|
||||
// not consult navigator.language here so AI-generated content stays in
|
||||
// English on first visit (matching the UI default) until the user flips
|
||||
// the language toggle.
|
||||
try {
|
||||
const stored = localStorage.getItem("encoach-lang");
|
||||
if (stored) return stored;
|
||||
} catch {
|
||||
// localStorage unavailable (SSR, sandboxing, etc.)
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
function buildHeaders(extra?: Record<string, string>, withJson = true): Record<string, string> {
|
||||
const headers: Record<string, string> = { ...extra };
|
||||
if (withJson) headers["Content-Type"] = headers["Content-Type"] || "application/json";
|
||||
|
||||
const token = getAccessToken();
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
// Tell the backend which UI language the user is on so AI-generated content
|
||||
// (coaching tips, insights, alerts, narratives) can be returned in the same
|
||||
// language instead of always defaulting to English.
|
||||
if (!headers["Accept-Language"]) {
|
||||
headers["Accept-Language"] = getCurrentLanguage();
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
|
||||
/**
|
||||
* Query param values we know how to serialise into a URL. Arrays are joined
|
||||
* with commas because that's what our Odoo controllers expect for multi-value
|
||||
* filters (e.g. `?state=draft,confirmed`).
|
||||
*/
|
||||
export type QueryParamValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| undefined
|
||||
| Array<string | number | boolean>;
|
||||
|
||||
/**
|
||||
* Accept any object-shaped bag of query params. We intentionally use `object`
|
||||
* here rather than `Record<string, QueryParamValue>` because typed interfaces
|
||||
* (e.g. `PaginationParams`) don't satisfy a `Record<...>` index signature by
|
||||
* default, which would force every call-site to cast.
|
||||
*/
|
||||
export type QueryParams = object;
|
||||
|
||||
const ENTITY_QUERY_SCOPE_RE =
|
||||
/^\/(courses|students|teachers|batches|branches|classrooms|student\/my-courses|ai\/course-plan)(\/|$)/;
|
||||
const ENTITY_BODY_SCOPE_RE =
|
||||
/^\/(courses|students|teachers|batches|branches|classrooms|ai\/course-plan)(\/|$)/;
|
||||
|
||||
function buildUrl(path: string, params?: QueryParams): string {
|
||||
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
url.searchParams.set(key, String(value));
|
||||
for (const [key, rawValue] of Object.entries(params as Record<string, unknown>)) {
|
||||
if (rawValue === undefined || rawValue === null) continue;
|
||||
if (Array.isArray(rawValue)) {
|
||||
if (rawValue.length === 0) continue;
|
||||
url.searchParams.set(key, rawValue.map(v => String(v)).join(","));
|
||||
continue;
|
||||
}
|
||||
});
|
||||
url.searchParams.set(key, String(rawValue));
|
||||
}
|
||||
}
|
||||
// Multi-entity LMS scope: when the user has selected an active entity
|
||||
// in the UI, append it to entity-scoped endpoints unless the caller
|
||||
// already provided an explicit entity_id.
|
||||
const activeEntityId = getActiveEntityId();
|
||||
if (
|
||||
activeEntityId &&
|
||||
!url.searchParams.has("entity_id") &&
|
||||
ENTITY_QUERY_SCOPE_RE.test(path)
|
||||
) {
|
||||
url.searchParams.set("entity_id", String(activeEntityId));
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function maybeInjectEntityIntoBody(path: string, body: unknown): unknown {
|
||||
const activeEntityId = getActiveEntityId();
|
||||
if (!activeEntityId) return body;
|
||||
if (!ENTITY_BODY_SCOPE_RE.test(path)) return body;
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
|
||||
const rec = body as Record<string, unknown>;
|
||||
if (rec.entity_id !== undefined && rec.entity_id !== null) return body;
|
||||
return { ...rec, entity_id: activeEntityId };
|
||||
}
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new ApiError(response.status, response.statusText, data);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
type RequestInitWithSkip = RequestInit & { _skipRetry?: boolean };
|
||||
|
||||
async function performRequest<T>(url: string, init: RequestInitWithSkip): Promise<T> {
|
||||
const response = await fetch(url, init);
|
||||
|
||||
// Auth/refresh endpoints opt out of the retry loop — otherwise a bad
|
||||
// refresh token would recurse forever.
|
||||
const isAuthEndpoint = url.includes("/auth/refresh") || url.includes("/login");
|
||||
|
||||
if (response.status === 401 && !init._skipRetry && !isAuthEndpoint) {
|
||||
const hadRefresh = !!getRefreshToken();
|
||||
if (hadRefresh) {
|
||||
const rotated = await refreshOnce();
|
||||
if (rotated) {
|
||||
// Rebuild headers so the new access token is attached.
|
||||
const newInit: RequestInitWithSkip = {
|
||||
...init,
|
||||
_skipRetry: true,
|
||||
headers: {
|
||||
...(init.headers as Record<string, string>),
|
||||
Authorization: `Bearer ${getAccessToken()}`,
|
||||
},
|
||||
};
|
||||
return performRequest<T>(url, newInit);
|
||||
}
|
||||
}
|
||||
const hadAccess = !!getAccessToken();
|
||||
clearToken();
|
||||
if (hadAccess || hadRefresh) {
|
||||
// Use SPA-style navigation when possible; fall back to a hard nav only
|
||||
// when we're inside a worker / non-browser context. A full document
|
||||
// reload here used to feel like "the browser refreshes on every click"
|
||||
// whenever an access token silently expired.
|
||||
if (typeof window !== "undefined" && !window.location.pathname.startsWith("/login")) {
|
||||
window.history.pushState({}, "", "/login");
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
}
|
||||
}
|
||||
throw new ApiError(401, response.statusText, await response.json().catch(() => null));
|
||||
}
|
||||
|
||||
return parseResponse<T>(response);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async get<T>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
|
||||
const res = await fetch(buildUrl(path, params), {
|
||||
async get<T>(path: string, params?: QueryParams): Promise<T> {
|
||||
return performRequest<T>(buildUrl(path, params), {
|
||||
method: "GET",
|
||||
headers: buildHeaders(),
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async post<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
const payload = maybeInjectEntityIntoBody(path, body);
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "POST",
|
||||
headers: buildHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async patch<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
const payload = maybeInjectEntityIntoBody(path, body);
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "PATCH",
|
||||
headers: buildHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async put<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
const payload = maybeInjectEntityIntoBody(path, body);
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "PUT",
|
||||
headers: buildHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async delete<T>(path: string): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
async delete<T>(path: string, payload?: Record<string, unknown>): Promise<T> {
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "DELETE",
|
||||
headers: buildHeaders(),
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async upload<T>(path: string, formData: FormData): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(buildUrl(path), {
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "POST",
|
||||
headers,
|
||||
headers: buildHeaders(undefined, false),
|
||||
body: formData,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
import "./i18n";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
@@ -31,9 +31,10 @@ interface PlatformStats {
|
||||
export default function AdminDashboard() {
|
||||
const { data: stats } = useQuery<PlatformStats>({
|
||||
queryKey: ["platform", "stats"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<{ data: PlatformStats }>("/stats");
|
||||
return (res as { data: PlatformStats }).data ?? res;
|
||||
queryFn: async (): Promise<PlatformStats> => {
|
||||
const res = await api.get<{ data: PlatformStats } | PlatformStats>("/stats");
|
||||
const wrapped = res as { data?: PlatformStats };
|
||||
return wrapped.data ?? (res as PlatformStats);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -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 { Button } from "@/components/ui/button";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, CheckCircle, XCircle, Clock } from "lucide-react";
|
||||
import AiGradingAssistant from "@/components/ai/AiGradingAssistant";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
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 { api } from "@/lib/api-client";
|
||||
|
||||
const workflows = [
|
||||
{
|
||||
id: 1, name: "Exam Content Review", status: "In Progress",
|
||||
steps: [
|
||||
{ name: "Initial Review", assignee: "Dr. Smith", status: "Approved" },
|
||||
{ name: "Quality Check", assignee: "Prof. Lee", status: "Pending" },
|
||||
{ name: "Final Approval", assignee: "Admin", status: "Waiting" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2, name: "Rubric Approval", status: "Completed",
|
||||
steps: [
|
||||
{ name: "Draft Review", assignee: "Mr. Kim", status: "Approved" },
|
||||
{ name: "Academic Board", assignee: "Dr. Smith", status: "Approved" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3, name: "New Exam Structure", status: "Rejected",
|
||||
steps: [
|
||||
{ name: "Content Review", assignee: "Prof. Lee", status: "Approved" },
|
||||
{ name: "Standards Check", assignee: "Dr. Smith", status: "Rejected" },
|
||||
],
|
||||
},
|
||||
];
|
||||
interface WorkflowStep {
|
||||
id: number;
|
||||
sequence: number;
|
||||
approver_id: number | null;
|
||||
approver_name: string;
|
||||
status: string;
|
||||
comment: string;
|
||||
auto_escalate: boolean;
|
||||
max_days: number;
|
||||
acted_at: string | null;
|
||||
}
|
||||
|
||||
interface Workflow {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
entity_id: number | null;
|
||||
entity_name: string | null;
|
||||
allow_bypass: boolean;
|
||||
active: boolean;
|
||||
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) => {
|
||||
if (s === "Approved") return <CheckCircle className="h-4 w-4 text-success" />;
|
||||
if (s === "Rejected") return <XCircle className="h-4 w-4 text-destructive" />;
|
||||
return <Clock className="h-4 w-4 text-warning" />;
|
||||
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" />;
|
||||
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() {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
<Dialog>
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
if (!open) resetForm();
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<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-2"><Label>Workflow Name</Label><Input placeholder="e.g. Exam Content Review" /></div>
|
||||
<div className="space-y-2">
|
||||
<Label>Step 1 — Assignee</Label>
|
||||
<Select><SelectTrigger><SelectValue placeholder="Select assignee" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="smith">Dr. Smith</SelectItem><SelectItem value="lee">Prof. Lee</SelectItem><SelectItem value="kim">Mr. Kim</SelectItem></SelectContent>
|
||||
</Select>
|
||||
<Label>
|
||||
Workflow Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder="e.g. Exam Content Review"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Step 2 — Assignee</Label>
|
||||
<Select><SelectTrigger><SelectValue placeholder="Select assignee" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="smith">Dr. Smith</SelectItem><SelectItem value="lee">Prof. Lee</SelectItem><SelectItem value="admin">Admin</SelectItem></SelectContent>
|
||||
<Label>Type</Label>
|
||||
<Select value={formType} onValueChange={setFormType}>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
</Dialog>
|
||||
</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) => (
|
||||
<Card key={wf.id} className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">{wf.name}</CardTitle>
|
||||
<Badge variant={wf.status === "Completed" ? "default" : wf.status === "Rejected" ? "destructive" : "secondary"}>{wf.status}</Badge>
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{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]">
|
||||
{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>
|
||||
<p className="text-sm font-medium">{step.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{step.assignee}</p>
|
||||
<p className="text-sm font-medium">{step.approver_name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{step.max_days}d limit
|
||||
{step.auto_escalate ? " · auto-escalate" : ""}
|
||||
</p>
|
||||
</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>
|
||||
{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>
|
||||
</Card>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Search, Plus, Trash2 } from "lucide-react";
|
||||
import { useAssignments, useCreateAssignment } from "@/hooks/queries";
|
||||
import {
|
||||
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 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() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [stateFilter, setStateFilter] = useState<ScheduleState | "all">("all");
|
||||
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 qc = useQueryClient();
|
||||
|
||||
const assignmentsQ = useAssignments({ size: 200 });
|
||||
const createMut = useCreateAssignment();
|
||||
const schedulesQ = useQuery({
|
||||
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 assignments = Array.isArray(items) ? items : [];
|
||||
|
||||
const q = search.toLowerCase();
|
||||
const filtered = assignments.filter(a =>
|
||||
a.title?.toLowerCase().includes(q) || a.entity_name?.toLowerCase().includes(q),
|
||||
const customExamsQ = useQuery({
|
||||
queryKey: ["custom-exams-for-assign"],
|
||||
queryFn: () => api.get<{ items: CustomExam[]; total: number }>("/exam/custom/list?per_page=200"),
|
||||
});
|
||||
const publishedExams = (customExamsQ.data?.items ?? []).filter(
|
||||
(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() {
|
||||
createMut.mutate(
|
||||
{ title: form.title, entity_id: Number(form.entity_id) || 0, start_date: form.start_date, end_date: form.end_date },
|
||||
{
|
||||
onSuccess: () => { setCreateOpen(false); setForm({ title: "", entity_id: "", start_date: "", end_date: "" }); toast({ title: "Assignment created" }); },
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
},
|
||||
);
|
||||
const startDt = form.start_date && form.start_time ? `${form.start_date}T${form.start_time}:00` : "";
|
||||
const endDt = form.end_date && form.end_time ? `${form.end_date}T${form.end_time}:00` : "";
|
||||
|
||||
if (!form.name || !form.exam_id || !startDt || !endDt) {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Assignments</h1>
|
||||
<p className="text-muted-foreground">Create and manage assignments.</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<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>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Create Assignment
|
||||
<Button onClick={() => { setForm(emptyForm()); setCreateOpen(true); }}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Schedule Exam
|
||||
</Button>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
{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>
|
||||
{schedulesQ.isLoading ? (
|
||||
<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">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Schedule Name</TableHead>
|
||||
<TableHead>Exam</TableHead>
|
||||
<TableHead>Entity</TableHead>
|
||||
<TableHead>Start</TableHead>
|
||||
<TableHead>End</TableHead>
|
||||
<TableHead>State</TableHead>
|
||||
<TableHead>Assignees</TableHead>
|
||||
<TableHead>Completed</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.length === 0 && (
|
||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No assignments found.</TableCell></TableRow>
|
||||
)}
|
||||
{filtered.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-medium">{a.title}</TableCell>
|
||||
<TableCell>{a.entity_name || "—"}</TableCell>
|
||||
<TableCell>{a.start_date || "—"}</TableCell>
|
||||
<TableCell>{a.end_date || "—"}</TableCell>
|
||||
<TableCell><Badge variant={a.state === "active" ? "default" : "secondary"} className="capitalize">{a.state}</Badge></TableCell>
|
||||
<TableCell>{a.assignee_count ?? 0}</TableCell>
|
||||
<TableCell>{a.completed_count ?? 0}</TableCell>
|
||||
{filtered.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="font-medium">{s.name}</TableCell>
|
||||
<TableCell>{s.exam_title}</TableCell>
|
||||
<TableCell>{s.entity_name || "—"}</TableCell>
|
||||
<TableCell className="text-xs">{s.start_date ? new Date(s.start_date).toLocaleString() : "—"}</TableCell>
|
||||
<TableCell className="text-xs">{s.end_date ? new Date(s.end_date).toLocaleString() : "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={stateBadgeVariant(s.state)} className="capitalize">{s.state.replace("_", " ")}</Badge>
|
||||
</TableCell>
|
||||
<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>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -94,21 +344,188 @@ export default function AssignmentsPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create Schedule Dialog */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Create Assignment</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
<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>
|
||||
<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"><Label>End Date</Label><Input type="date" value={form.end_date} onChange={(e) => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schedule Exam</DialogTitle>
|
||||
<DialogDescription>Select an exam, assign it to students, and set scheduling options.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 py-2">
|
||||
{/* 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>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button disabled={createMut.isPending || !form.title} onClick={handleCreate}>
|
||||
{createMut.isPending ? "Creating..." : "Create"}
|
||||
<Button disabled={createMut.isPending || !form.name || !form.exam_id || !form.start_date || !form.end_date} onClick={handleCreate}>
|
||||
{createMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Schedule Exam"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,95 +1,429 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Search, Plus, Building2, Trash2 } from "lucide-react";
|
||||
import { useDepartments, useCreateDepartment, useDeleteDepartment } from "@/hooks/queries";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Search, Plus, Building2, Trash2, Pencil, Users, Shield, UserCog } from "lucide-react";
|
||||
import { entitiesService } from "@/services/entities.service";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
const ENTITY_TYPES = [
|
||||
{ value: "corporate", label: "Corporate" },
|
||||
{ value: "university", label: "University" },
|
||||
{ value: "school", label: "School" },
|
||||
{ value: "government", label: "Government" },
|
||||
{ value: "freelance", label: "Freelance" },
|
||||
];
|
||||
|
||||
export default function EntitiesPage() {
|
||||
const { user } = useAuth();
|
||||
const isAdminUser = user?.user_type === "admin";
|
||||
const [search, setSearch] = useState("");
|
||||
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 [manageOpen, setManageOpen] = useState(false);
|
||||
const [manageEntity, setManageEntity] = useState<{ id: number; name: string } | null>(null);
|
||||
const [usersSearch, setUsersSearch] = useState("");
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<Set<number>>(new Set());
|
||||
const [form, setForm] = useState({ name: "", code: "", type: "" });
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const deptsQ = useDepartments({ size: 200 });
|
||||
const createMut = useCreateDepartment();
|
||||
const deleteMut = useDeleteDepartment();
|
||||
const entitiesQ = useQuery({
|
||||
queryKey: ["entities"],
|
||||
queryFn: () => entitiesService.list({ size: 200 }),
|
||||
});
|
||||
|
||||
const depts = deptsQ.data?.items ?? deptsQ.data?.data ?? [];
|
||||
const departments = Array.isArray(depts) ? depts : [];
|
||||
const filtered = departments.filter(d => d.name?.toLowerCase().includes(search.toLowerCase()));
|
||||
const loading = deptsQ.isLoading;
|
||||
const platformUsersQ = useQuery({
|
||||
queryKey: ["platform-users-for-entities"],
|
||||
queryFn: () => entitiesService.listPlatformUsers({ size: 500 }),
|
||||
enabled: isAdminUser && manageOpen,
|
||||
});
|
||||
|
||||
function handleCreate() {
|
||||
createMut.mutate(
|
||||
{ name: form.name, code: form.code || undefined },
|
||||
{
|
||||
onSuccess: () => { setCreateOpen(false); setForm({ name: "", code: "" }); toast({ title: "Department created" }); },
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
},
|
||||
const entityUsersQ = useQuery({
|
||||
queryKey: ["entity-users", manageEntity?.id],
|
||||
queryFn: () => entitiesService.listEntityUsers(manageEntity!.id),
|
||||
enabled: isAdminUser && !!manageEntity?.id,
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (data: { name: string; code?: string; type?: string }) =>
|
||||
entitiesService.create(data as Partial<import("@/types").Entity>),
|
||||
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" }),
|
||||
});
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) =>
|
||||
entitiesService.update(id, data as Partial<import("@/types").Entity>),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["entities"] });
|
||||
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 saveEntityUsersMut = useMutation({
|
||||
mutationFn: ({ entityId, userIds }: { entityId: number; userIds: number[] }) =>
|
||||
entitiesService.updateEntityUsers(entityId, userIds),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["entities"] });
|
||||
qc.invalidateQueries({ queryKey: ["entity-users"] });
|
||||
setManageOpen(false);
|
||||
setManageEntity(null);
|
||||
setUsersSearch("");
|
||||
setSelectedUserIds(new Set());
|
||||
toast({ title: "Entity users updated" });
|
||||
},
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const raw = entitiesQ.data as unknown;
|
||||
const entities: { id: number; name: string; code: string; type: string; user_count: number; role_count: number; active: boolean }[] = (() => {
|
||||
if (!raw) return [];
|
||||
if (Array.isArray(raw)) return raw as never[];
|
||||
const r = raw as { 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;
|
||||
|
||||
useEffect(() => {
|
||||
if (!entityUsersQ.data) return;
|
||||
setSelectedUserIds(new Set(entityUsersQ.data.map((u) => u.id)));
|
||||
}, [entityUsersQ.data]);
|
||||
|
||||
const filteredPlatformUsers = useMemo(() => {
|
||||
const all = platformUsersQ.data ?? [];
|
||||
const q = usersSearch.trim().toLowerCase();
|
||||
if (!q) return all;
|
||||
return all.filter((u) =>
|
||||
(u.name || "").toLowerCase().includes(q)
|
||||
|| (u.email || "").toLowerCase().includes(q)
|
||||
|| (u.login || "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [platformUsersQ.data, usersSearch]);
|
||||
|
||||
function openEdit(e: typeof entities[0]) {
|
||||
setEditEntity({ id: e.id, name: e.name, code: e.code, type: e.type });
|
||||
setEditOpen(true);
|
||||
}
|
||||
|
||||
function openManageUsers(e: typeof entities[0]) {
|
||||
setManageEntity({ id: e.id, name: e.name });
|
||||
setUsersSearch("");
|
||||
setSelectedUserIds(new Set());
|
||||
setManageOpen(true);
|
||||
}
|
||||
|
||||
function toggleUser(userId: number) {
|
||||
setSelectedUserIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(userId)) next.delete(userId);
|
||||
else next.add(userId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Entities / Departments</h1>
|
||||
<p className="text-muted-foreground">Manage organizational departments.</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Entities</h1>
|
||||
<p className="text-muted-foreground">Manage organizations and entities on the platform.</p>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder="Search departments..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<div className="flex gap-4 items-center">
|
||||
<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 entities..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<Badge variant="secondary">{entities.length} total</Badge>
|
||||
</div>
|
||||
|
||||
{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="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 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">
|
||||
<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">
|
||||
{isAdminUser ? (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openManageUsers(e)}>
|
||||
<UserCog className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(e)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<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}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Create Department</DialogTitle></DialogHeader>
|
||||
<DialogHeader><DialogTitle>Create Entity</DialogTitle></DialogHeader>
|
||||
<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"><Label>Code</Label><Input value={form.code} onChange={(e) => setForm(f => ({ ...f, code: e.target.value }))} placeholder="DEPT01" /></div>
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
<DialogFooter>
|
||||
<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"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</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>
|
||||
|
||||
{/* Manage Users Dialog (admin only) */}
|
||||
<Dialog open={manageOpen} onOpenChange={(open) => {
|
||||
setManageOpen(open);
|
||||
if (!open) {
|
||||
setManageEntity(null);
|
||||
setUsersSearch("");
|
||||
setSelectedUserIds(new Set());
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Users — {manageEntity?.name ?? ""}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{!isAdminUser ? (
|
||||
<div className="text-sm text-muted-foreground py-4">Only admin users can manage entity members.</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search users by name/email/login..."
|
||||
className="pl-9"
|
||||
value={usersSearch}
|
||||
onChange={(e) => setUsersSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border max-h-[420px] overflow-y-auto">
|
||||
{platformUsersQ.isLoading || entityUsersQ.isLoading ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">Loading users...</div>
|
||||
) : filteredPlatformUsers.length === 0 ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">No users found.</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{filteredPlatformUsers.map((u) => (
|
||||
<label key={u.id} className="flex items-center gap-3 p-3 hover:bg-muted/40 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={selectedUserIds.has(u.id)}
|
||||
onCheckedChange={() => toggleUser(u.id)}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{u.name || u.login || u.email}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{u.email || u.login}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<div className="text-xs text-muted-foreground mr-auto">
|
||||
{selectedUserIds.size} user(s) selected
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setManageOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={!manageEntity || saveEntityUsersMut.isPending || !isAdminUser}
|
||||
onClick={() => {
|
||||
if (!manageEntity) return;
|
||||
saveEntityUsersMut.mutate({
|
||||
entityId: manageEntity.id,
|
||||
userIds: Array.from(selectedUserIds),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{saveEntityUsersMut.isPending ? "Saving..." : "Save Users"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GraduationCap, Play, Sparkles } from "lucide-react";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
|
||||
export default function ExamPage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh] gap-4 max-w-md mx-auto">
|
||||
<AiTipBanner 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 { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 { 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() {
|
||||
const [search, setSearch] = useState("");
|
||||
const sessionsQ = useInstitutionalExamSessions();
|
||||
const items = sessionsQ.data?.data ?? sessionsQ.data?.items ?? [];
|
||||
const sessions = Array.isArray(items) ? items : [];
|
||||
const [tab, setTab] = useState<"custom" | "sessions">("custom");
|
||||
|
||||
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 filtered = sessions.filter(s =>
|
||||
const filteredSessions = sessions.filter((s) =>
|
||||
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>
|
||||
<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>
|
||||
<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 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)} />
|
||||
</div>
|
||||
|
||||
{sessionsQ.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>
|
||||
) : (
|
||||
<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>
|
||||
{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>
|
||||
{tab === "custom" && (
|
||||
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">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Modules</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{customExams.length === 0 && (
|
||||
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">No custom exams found. Submit one from the Generation page.</TableCell></TableRow>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function ForgotPassword() {
|
||||
) : null}
|
||||
<div className="mt-6 text-center">
|
||||
<Link to="/login" className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
|
||||
<ArrowLeft className="h-3 w-3" /> Back to sign in
|
||||
<ArrowLeft className="h-3 w-3 rtl:rotate-180" /> Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,34 +1,122 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
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 { useToast } from "@/hooks/use-toast";
|
||||
import { trainingService, type CefrLevel } from "@/services/training.service";
|
||||
|
||||
const grammarItems = [
|
||||
{ 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 },
|
||||
];
|
||||
const LEVELS: (CefrLevel | "all")[] = ["all", "A1", "A2", "B1", "B2", "C1", "C2"];
|
||||
|
||||
export default function GrammarPage() {
|
||||
const [showCompleted, setShowCompleted] = useState(false);
|
||||
const completed = grammarItems.filter(g => g.completed).length;
|
||||
const filtered = showCompleted ? grammarItems : grammarItems.filter(g => !g.completed);
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Grammar Training</h1>
|
||||
<p className="text-muted-foreground">Master grammar rules essential for IELTS.</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<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="lg:col-span-2 space-y-4">
|
||||
@@ -36,31 +124,104 @@ export default function GrammarPage() {
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Progress value={(completed / grammarItems.length) * 100} className="h-3" />
|
||||
<Progress value={summary?.completion_rate ?? 0} className="h-3" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch id="show" checked={showCompleted} onCheckedChange={setShowCompleted} />
|
||||
<Label htmlFor="show" className="text-sm">Show completed</Label>
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<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 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 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) => (
|
||||
<Card key={g.rule} className="border-0 shadow-sm">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{g.completed && <CheckCircle className="h-4 w-4 text-success shrink-0" />}
|
||||
<div>
|
||||
<p className="font-semibold text-sm">{g.rule}</p>
|
||||
<p className="text-xs text-muted-foreground">{g.description}</p>
|
||||
</div>
|
||||
<Card key={g.id} className="border-0 shadow-sm">
|
||||
<CardContent className="p-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
onClick={() =>
|
||||
progressMut.mutate({ id: g.id, completed: !g.completed })
|
||||
}
|
||||
className="shrink-0 rounded-full p-0.5 hover:bg-muted transition"
|
||||
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>
|
||||
<Badge variant="outline">{g.level}</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
@@ -70,25 +231,133 @@ export default function GrammarPage() {
|
||||
<div className="space-y-4">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<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>
|
||||
<CardContent>
|
||||
<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"><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 sentence structures for C1 readiness</li>
|
||||
<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 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">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
|
||||
<CardContent className="p-4">
|
||||
<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>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{summary && (
|
||||
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
|
||||
<CardContent className="p-4">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
// Update this page (the content is just a fallback if you fail to update the page)
|
||||
|
||||
const Index = () => {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<h1 className="mb-4 text-4xl font-bold">Welcome to Your Blank App</h1>
|
||||
<p className="text-xl text-muted-foreground">Start building your amazing project here!</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -10,6 +11,7 @@ import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { ApiError } from "@/lib/api-client";
|
||||
import type { UserRole } from "@/types/auth";
|
||||
import { LanguageToggle } from "@/components/LanguageToggle";
|
||||
|
||||
/** Keep in sync with `ProtectedRoute` post-login targets */
|
||||
function getRoleDashboard(role: string): string {
|
||||
@@ -48,11 +50,16 @@ export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email || !password) {
|
||||
toast({ title: "Error", description: "Please enter email and password", variant: "destructive" });
|
||||
toast({
|
||||
title: t("auth.errorTitle"),
|
||||
description: t("auth.missingCredentials"),
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,7 +68,11 @@ export default function Login() {
|
||||
const user = await login(email, password);
|
||||
navigate(getRoleDashboard(user.user_type));
|
||||
} catch (err: unknown) {
|
||||
toast({ title: "Login Failed", description: loginErrorMessage(err), variant: "destructive" });
|
||||
toast({
|
||||
title: t("auth.loginFailedTitle"),
|
||||
description: loginErrorMessage(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -69,35 +80,39 @@ export default function Login() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<div className="absolute top-4 right-4">
|
||||
<LanguageToggle />
|
||||
</div>
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center gap-3 mb-8">
|
||||
<img src="/logo.png" alt="EnCoach" className="h-12 w-12 rounded-xl object-contain" />
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<span className="text-[hsl(42,40%,62%)]">En</span>
|
||||
<span className="text-[hsl(240,20%,30%)]">Coach</span>
|
||||
</h1>
|
||||
<div className="flex items-center justify-center mb-8">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="EnCoach — Unlock your potential with AI powered platform"
|
||||
className="h-36 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-lg border-0 bg-card">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<CardTitle className="text-xl">Welcome back</CardTitle>
|
||||
<CardDescription>Sign in to your account to continue</CardDescription>
|
||||
<CardTitle className="text-xl">{t("auth.welcomeBack")}</CardTitle>
|
||||
<CardDescription>{t("auth.signInDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Label htmlFor="email">{t("auth.email")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
type="text"
|
||||
placeholder={t("auth.emailPlaceholder")}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={loading}
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
@@ -106,11 +121,13 @@ export default function Login() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
dir="ltr"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showPassword ? t("auth.password") : t("auth.password")}
|
||||
className="absolute end-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
@@ -119,19 +136,25 @@ export default function Login() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox id="remember" />
|
||||
<Label htmlFor="remember" className="text-sm font-normal cursor-pointer">Remember me</Label>
|
||||
<Label htmlFor="remember" className="text-sm font-normal cursor-pointer">
|
||||
{t("auth.rememberMe")}
|
||||
</Label>
|
||||
</div>
|
||||
<Link to="/forgot-password" className="text-sm text-primary hover:underline">Forgot password?</Link>
|
||||
<Link to="/forgot-password" className="text-sm text-primary hover:underline">
|
||||
{t("auth.forgotPassword")}
|
||||
</Link>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Sign in
|
||||
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
|
||||
{t("auth.signIn")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{" "}
|
||||
<Link to="/register" className="text-primary font-medium hover:underline">Sign up</Link>
|
||||
{t("auth.needAccount")}{" "}
|
||||
<Link to="/register" className="text-primary font-medium hover:underline">
|
||||
{t("auth.signUp")}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const NotFound = () => {
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
|
||||
@@ -11,10 +13,10 @@ const NotFound = () => {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted">
|
||||
<div className="text-center">
|
||||
<h1 className="mb-4 text-4xl font-bold">404</h1>
|
||||
<p className="mb-4 text-xl text-muted-foreground">Oops! Page not found</p>
|
||||
<h1 className="mb-4 text-4xl font-bold">{t("errors.notFoundCode")}</h1>
|
||||
<p className="mb-4 text-xl text-muted-foreground">{t("errors.notFoundMessage")}</p>
|
||||
<a href="/" className="text-primary underline hover:text-primary/90">
|
||||
Return to Home
|
||||
{t("errors.returnHome")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,58 +1,105 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Plus, Download } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
import AiReportNarrative from "@/components/ai/AiReportNarrative";
|
||||
|
||||
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 },
|
||||
];
|
||||
import { paymentsService } from "@/services/payments.service";
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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 className="flex gap-2">
|
||||
<Button variant="outline" size="sm"><Download className="h-4 w-4 mr-1" /> Export CSV</Button>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<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>
|
||||
<Button variant="outline" size="sm" onClick={handleExport} disabled={!payments.length}>
|
||||
<Download className="h-4 w-4 mr-1" /> Export CSV
|
||||
</Button>
|
||||
</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">
|
||||
<TabsList>
|
||||
@@ -61,26 +108,57 @@ export default function PaymentRecordPage() {
|
||||
</TabsList>
|
||||
|
||||
<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">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead><TableHead>Corporate</TableHead><TableHead>Manager</TableHead>
|
||||
<TableHead>Amount</TableHead><TableHead>Type</TableHead><TableHead>Paid</TableHead><TableHead>Date</TableHead>
|
||||
<TableHead>Ref</TableHead>
|
||||
<TableHead>Student</TableHead>
|
||||
<TableHead>Course</TableHead>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>State</TableHead>
|
||||
<TableHead>Paid</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<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) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-mono text-xs">{p.id}</TableCell>
|
||||
<TableCell>{p.corporate}</TableCell>
|
||||
<TableCell>{p.manager}</TableCell>
|
||||
<TableCell className="font-semibold">${p.amount.toLocaleString()}</TableCell>
|
||||
<TableCell><Badge variant="outline">{p.type}</Badge></TableCell>
|
||||
<TableCell><Badge variant={p.paid ? "default" : "destructive"}>{p.paid ? "Paid" : "Unpaid"}</Badge></TableCell>
|
||||
<TableCell>{p.date}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{p.ref}</TableCell>
|
||||
<TableCell>{p.student_name || "—"}</TableCell>
|
||||
<TableCell>{p.course_name || "—"}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{p.product_name || "—"}
|
||||
</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>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -91,26 +169,50 @@ export default function PaymentRecordPage() {
|
||||
|
||||
<TabsContent value="paymob" className="mt-4">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Order ID</TableHead><TableHead>Status</TableHead><TableHead>User</TableHead>
|
||||
<TableHead>Email</TableHead><TableHead>Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paymobOrders.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>
|
||||
<CardContent className="p-6">
|
||||
{(paymobData?.items?.length ?? 0) === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
{paymobData?.message ??
|
||||
"Paymob integration is not yet wired. Orders will appear here once configured."}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Order ID</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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>
|
||||
</Card>
|
||||
</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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, Download } from "lucide-react";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
import AiReportNarrative from "@/components/ai/AiReportNarrative";
|
||||
import { reportsService, RecordRow } from "@/services/reports.service";
|
||||
|
||||
const records = [
|
||||
{ id: 1, assignment: "IELTS Prep Q1", exam: "EX-001", date: "2025-03-01", score: 7.5, duration: "175 min", status: "Completed" },
|
||||
{ id: 2, assignment: "Speaking Workshop", exam: "EX-005", date: "2025-03-05", score: 6.0, duration: "14 min", status: "Completed" },
|
||||
{ id: 3, assignment: "Full Mock Exam", exam: "EX-006", date: "2025-03-10", score: null, duration: "120 min", status: "In Progress" },
|
||||
{ id: 4, assignment: "Listening Bootcamp", exam: "EX-003", date: "2025-02-20", score: 5.5, duration: "28 min", status: "Completed" },
|
||||
];
|
||||
type Period = "all" | "day" | "week" | "month";
|
||||
|
||||
export default function RecordPage() {
|
||||
const [entityId, setEntityId] = useState("all");
|
||||
const [userId, setUserId] = useState("all");
|
||||
const [period, setPeriod] = useState<Period>("all");
|
||||
|
||||
const { data: filters } = useQuery({
|
||||
queryKey: ["reports-filters"],
|
||||
queryFn: () => reportsService.filters(),
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["record", entityId, userId, period],
|
||||
queryFn: () =>
|
||||
reportsService.record({
|
||||
entity_id: entityId === "all" ? undefined : Number(entityId),
|
||||
user_id: userId === "all" ? undefined : Number(userId),
|
||||
period: period === "all" ? undefined : period,
|
||||
size: 100,
|
||||
}),
|
||||
});
|
||||
|
||||
const records: RecordRow[] = data?.items ?? [];
|
||||
|
||||
const exportCsv = () => {
|
||||
const header = [
|
||||
"Student",
|
||||
"Assignment",
|
||||
"Exam",
|
||||
"Date",
|
||||
"Score",
|
||||
"Duration",
|
||||
"Status",
|
||||
];
|
||||
const lines = records.map((r) =>
|
||||
[
|
||||
r.student_name,
|
||||
r.assignment,
|
||||
r.exam_code || r.exam,
|
||||
r.date ?? "",
|
||||
r.score ?? "",
|
||||
r.duration,
|
||||
r.status_label,
|
||||
]
|
||||
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
|
||||
.join(","),
|
||||
);
|
||||
const blob = new Blob([[header.join(","), ...lines].join("\n")], {
|
||||
type: "text/csv",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `records-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const statusVariant = (status: string) => {
|
||||
if (status === "completed" || status === "released" || status === "scored")
|
||||
return "default";
|
||||
if (status === "in_progress" || status === "scoring") return "secondary";
|
||||
return "outline";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Record</h1>
|
||||
<p className="text-muted-foreground">Browse assignment and exam attempt history.</p>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Record</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Browse assignment and exam attempt history.
|
||||
{data && (
|
||||
<span className="ml-2 text-xs">({data.total} attempts)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={exportCsv}
|
||||
disabled={!records.length}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" /> Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AiTipBanner 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">
|
||||
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="Entity" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="acme">Acme Corp</SelectItem><SelectItem value="global">Global Ltd</SelectItem></SelectContent>
|
||||
<Select value={entityId} onValueChange={setEntityId}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All Entities" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Entities</SelectItem>
|
||||
{filters?.entities.map((e) => (
|
||||
<SelectItem key={e.id} value={String(e.id)}>
|
||||
{e.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select><SelectTrigger className="w-[180px]"><SelectValue placeholder="Select User" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="sarah">Sarah Johnson</SelectItem><SelectItem value="ahmed">Ahmed Hassan</SelectItem></SelectContent>
|
||||
<Select value={userId} onValueChange={setUserId}>
|
||||
<SelectTrigger className="w-[220px]">
|
||||
<SelectValue placeholder="All Users" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Users</SelectItem>
|
||||
{filters?.students.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex gap-1">
|
||||
{["Day", "Week", "Month"].map(t => (
|
||||
<Button key={t} variant="outline" size="sm">{t}</Button>
|
||||
{(["all", "day", "week", "month"] as Period[]).map((p) => (
|
||||
<Button
|
||||
key={p}
|
||||
variant={period === p ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setPeriod(p)}
|
||||
>
|
||||
{p === "all" ? "All" : p.charAt(0).toUpperCase() + p.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Assignment</TableHead><TableHead>Exam</TableHead><TableHead>Date</TableHead>
|
||||
<TableHead>Score</TableHead><TableHead>Duration</TableHead><TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-medium">{r.assignment}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{r.exam}</TableCell>
|
||||
<TableCell>{r.date}</TableCell>
|
||||
<TableCell>{r.score ? r.score.toFixed(1) : "—"}</TableCell>
|
||||
<TableCell>{r.duration}</TableCell>
|
||||
<TableCell><Badge variant={r.status === "Completed" ? "default" : "secondary"}>{r.status}</Badge></TableCell>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-12 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Loading
|
||||
attempts...
|
||||
</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
No attempts match these filters.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Student</TableHead>
|
||||
<TableHead>Assignment</TableHead>
|
||||
<TableHead>Exam</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Score</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-medium">
|
||||
{r.student_name || "—"}
|
||||
</TableCell>
|
||||
<TableCell>{r.assignment || "—"}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{r.exam_code}
|
||||
</TableCell>
|
||||
<TableCell>{r.date || "—"}</TableCell>
|
||||
<TableCell>
|
||||
{r.score ? r.score.toFixed(1) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>{r.duration}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(r.status)}>
|
||||
{r.status_label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
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 { ApiError } from "@/lib/api-client";
|
||||
import { ApiError, api } from "@/lib/api-client";
|
||||
import type { RegisterRequest } from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -47,6 +48,41 @@ export default function Register() {
|
||||
const checkEmail = useCheckEmail();
|
||||
const [showPassword, setShowPassword] = 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>({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -69,7 +105,7 @@ export default function Register() {
|
||||
email: values.email.trim(),
|
||||
password: values.password,
|
||||
role: values.role,
|
||||
captcha_token: "demo-placeholder",
|
||||
captcha_token: captchaToken || undefined,
|
||||
};
|
||||
try {
|
||||
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">
|
||||
<p className="text-sm font-medium">CAPTCHA</p>
|
||||
<div className="h-16 rounded-md bg-muted flex items-center justify-center text-xs text-muted-foreground">
|
||||
Verification widget placeholder
|
||||
<p className="text-sm font-medium flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4" /> Verification
|
||||
</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>
|
||||
{captchaToken && (
|
||||
<p className="text-xs text-green-600 flex items-center gap-1">
|
||||
<ShieldCheck className="h-3 w-3" /> Verified
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{form.formState.errors.root && (
|
||||
|
||||
@@ -1,31 +1,644 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Search, Plus } from "lucide-react";
|
||||
import {
|
||||
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 AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
||||
import { examsService } from "@/services/exams.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const rubrics = [
|
||||
{ id: 1, name: "IELTS Writing Task 2", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 4, created: "2025-01-05" },
|
||||
{ id: 2, name: "Speaking Fluency", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 3, created: "2025-01-10" },
|
||||
{ id: 3, name: "Reading Comprehension", levels: ["A1","A2","B1","B2","C1"], criteria: 5, created: "2025-02-01" },
|
||||
interface RubricItem {
|
||||
id: number;
|
||||
name: string;
|
||||
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 = [
|
||||
{ id: 1, name: "Academic IELTS Full", rubrics: ["IELTS Writing Task 2", "Speaking Fluency", "Reading Comprehension"], created: "2025-02-15" },
|
||||
{ id: 2, name: "Business English", rubrics: ["Speaking Fluency"], created: "2025-03-01" },
|
||||
const EXAM_TYPE_OPTIONS = [
|
||||
{ value: "academic", label: "Academic" },
|
||||
{ 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() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -35,25 +648,25 @@ export default function RubricsPage() {
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<AiCreationAssistant type="rubric" />
|
||||
<Dialog>
|
||||
<Dialog open={createOpen} onOpenChange={(open) => { setCreateOpen(open); if (!open) setCreateForm(emptyForm()); }}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Rubric</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader><DialogTitle>Create Rubric</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2"><Label>Rubric Name</Label><Input placeholder="e.g. IELTS Writing Task 1" /></div>
|
||||
<div className="space-y-3">
|
||||
<Label>Level Descriptors</Label>
|
||||
{["A1","A2","B1","B2","C1","C2"].map(level => (
|
||||
<div key={level} className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{level}</Label>
|
||||
<Textarea placeholder={`Descriptor for level ${level}...`} className="h-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button className="w-full">Create Rubric</Button>
|
||||
</div>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Rubric</DialogTitle>
|
||||
<DialogDescription>Define a scoring rubric. Use AI to generate criteria or add them manually.</DialogDescription>
|
||||
</DialogHeader>
|
||||
{renderForm(createForm, setCreateForm)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={!createForm.name.trim() || createForm.criteria.filter((c) => c.name.trim()).length === 0 || createMut.isPending}
|
||||
onClick={() => createMut.mutate(formToPayload(createForm))}
|
||||
>
|
||||
{createMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
@@ -72,51 +685,248 @@ export default function RubricsPage() {
|
||||
<TabsTrigger value="groups">Rubric Groups</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="rubrics" className="mt-4">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead><TableHead>Levels</TableHead><TableHead>Criteria</TableHead><TableHead>Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rubrics.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<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>
|
||||
<TableCell>{r.criteria}</TableCell>
|
||||
<TableCell>{r.created}</TableCell>
|
||||
{rubricsQ.isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{rubricsQ.error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="p-4 text-sm text-destructive">
|
||||
Failed to load rubrics. The backend endpoint may not be available yet.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{!rubricsQ.isLoading && !rubricsQ.error && filtered.length === 0 && (
|
||||
<Card className="border-dashed">
|
||||
<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>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((r) => (
|
||||
<TableRow key={r.id} className="cursor-pointer hover:bg-muted/50" onClick={() => openEdit(r)}>
|
||||
<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 value="groups" className="mt-4">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Group Name</TableHead><TableHead>Rubrics</TableHead><TableHead>Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rubricGroups.map((g) => (
|
||||
<TableRow key={g.id}>
|
||||
<TableCell className="font-medium">{g.name}</TableCell>
|
||||
<TableCell><div className="flex gap-1 flex-wrap">{g.rubrics.map(r => <Badge key={r} variant="secondary" className="text-xs">{r}</Badge>)}</div></TableCell>
|
||||
<TableCell>{g.created}</TableCell>
|
||||
<div className="flex justify-end mb-3">
|
||||
<Button size="sm" onClick={() => { setGroupCreateForm(emptyGroupForm()); setGroupCreateOpen(true); }}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Create Group
|
||||
</Button>
|
||||
</div>
|
||||
{rubricGroupsQ.isLoading && (
|
||||
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
|
||||
)}
|
||||
{!rubricGroupsQ.isLoading && rubricGroups.length === 0 && (
|
||||
<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>
|
||||
)}
|
||||
{!rubricGroupsQ.isLoading && rubricGroups.length > 0 && (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<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>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rubricGroups.map((g) => (
|
||||
<TableRow key={g.id}>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Plus, Trash2, Copy } from "lucide-react";
|
||||
import {
|
||||
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";
|
||||
|
||||
const codes = [
|
||||
{ code: "ENCOACH-2025-A1B2", type: "Single", used: false, created: "2025-01-10" },
|
||||
{ code: "ENCOACH-2025-C3D4", type: "Single", used: true, created: "2025-01-12" },
|
||||
{ code: "BATCH-2025-E5F6", type: "Batch", used: false, created: "2025-02-01" },
|
||||
{ code: "BATCH-2025-G7H8", type: "Batch", used: false, created: "2025-02-01" },
|
||||
];
|
||||
|
||||
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 },
|
||||
];
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
platformSettingsService,
|
||||
type GradingConfig,
|
||||
type PlatformPackage,
|
||||
type RegistrationCode,
|
||||
} from "@/services/platformSettings.service";
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<Tabs defaultValue="codes">
|
||||
@@ -37,26 +146,107 @@ export default function SettingsPage() {
|
||||
<TabsTrigger value="grading">Grading System</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* CODES */}
|
||||
<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">
|
||||
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Generate Single</Button>
|
||||
<Button size="sm" variant="outline"><Copy className="h-4 w-4 mr-1" /> Generate Batch</Button>
|
||||
<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>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<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>
|
||||
<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) => (
|
||||
<TableRow key={c.code}>
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-mono text-xs">{c.code}</TableCell>
|
||||
<TableCell><Badge variant="outline">{c.type}</Badge></TableCell>
|
||||
<TableCell><Badge variant={c.used ? "secondary" : "default"}>{c.used ? "Used" : "Available"}</Badge></TableCell>
|
||||
<TableCell>{c.created}</TableCell>
|
||||
<TableCell><Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"><Trash2 className="h-4 w-4" /></Button></TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{c.code_type}</Badge>
|
||||
</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>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -65,35 +255,186 @@ export default function SettingsPage() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* PACKAGES */}
|
||||
<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">
|
||||
{pkgQ.isLoading && <Loader2 className="h-5 w-5 animate-spin" />}
|
||||
{packages.map((p) => (
|
||||
<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>
|
||||
<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>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-2xl font-bold">${p.price}</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>
|
||||
</Card>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* GRADING */}
|
||||
<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">
|
||||
<CardHeader><CardTitle className="text-base">Scoring Scale</CardTitle></CardHeader>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Scoring Scale</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<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"><Label>Max Score</Label><Input type="number" defaultValue="9" /></div>
|
||||
<div className="space-y-2">
|
||||
<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 className="space-y-2"><Label>Score Increment</Label><Input type="number" defaultValue="0.5" step="0.5" /></div>
|
||||
<Button>Save Grading Configuration</Button>
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
@@ -1,133 +1,266 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, PieChart, Pie, Cell } from "recharts";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import AiReportNarrative from "@/components/ai/AiReportNarrative";
|
||||
|
||||
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.",
|
||||
};
|
||||
import { reportsService } from "@/services/reports.service";
|
||||
|
||||
const thresholds = ["0%", "50%", "70%", "90%"];
|
||||
|
||||
const barData = [
|
||||
{ module: "Reading", score: 72 },
|
||||
{ module: "Listening", score: 68 },
|
||||
{ module: "Writing", score: 61 },
|
||||
{ module: "Speaking", score: 65 },
|
||||
];
|
||||
|
||||
const trendData = [
|
||||
{ month: "Jan", avg: 58 }, { month: "Feb", avg: 62 }, { month: "Mar", avg: 65 },
|
||||
{ month: "Apr", avg: 64 }, { month: "May", avg: 69 }, { month: "Jun", avg: 72 },
|
||||
];
|
||||
|
||||
const distData = [
|
||||
{ name: "A1", value: 15, color: "hsl(0, 72%, 51%)" },
|
||||
{ name: "A2", value: 22, color: "hsl(38, 92%, 50%)" },
|
||||
{ name: "B1", value: 30, color: "hsl(199, 89%, 48%)" },
|
||||
{ name: "B2", value: 20, color: "hsl(243, 75%, 59%)" },
|
||||
{ name: "C1", value: 10, color: "hsl(142, 71%, 45%)" },
|
||||
{ name: "C2", value: 3, color: "hsl(280, 65%, 50%)" },
|
||||
];
|
||||
|
||||
export default function StatsCorporatePage() {
|
||||
const [threshold, setThreshold] = useState("0%");
|
||||
const [entityId, setEntityId] = useState("all");
|
||||
|
||||
const { data: filters } = useQuery({
|
||||
queryKey: ["reports-filters"],
|
||||
queryFn: () => reportsService.filters(),
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["stats-corporate", threshold, entityId],
|
||||
queryFn: () =>
|
||||
reportsService.statsCorporate({
|
||||
entity_id: entityId === "all" ? undefined : Number(entityId),
|
||||
threshold: Number(threshold.replace("%", "")),
|
||||
months: 6,
|
||||
}),
|
||||
});
|
||||
|
||||
const barData = data?.by_module ?? [];
|
||||
const trendData = data?.trend ?? [];
|
||||
const distData = data?.distribution ?? [];
|
||||
const comparison = data?.comparison ?? [];
|
||||
const attemptsConsidered = data?.meta.attempts_considered ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Corporate Statistics</h1>
|
||||
<p className="text-muted-foreground">Entity-level performance analytics and reports.</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
Corporate Statistics
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Entity-level performance analytics and reports.
|
||||
{data && (
|
||||
<span className="ml-2 text-xs">
|
||||
({attemptsConsidered} scored attempts)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<div className="flex gap-1">
|
||||
{thresholds.map(t => (
|
||||
<Button key={t} variant={threshold === t ? "default" : "outline"} size="sm" onClick={() => setThreshold(t)}>{t}</Button>
|
||||
{thresholds.map((t) => (
|
||||
<Button
|
||||
key={t}
|
||||
variant={threshold === t ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setThreshold(t)}
|
||||
>
|
||||
{t}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="All Entities" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="all">All</SelectItem><SelectItem value="acme">Acme Corp</SelectItem></SelectContent>
|
||||
</Select>
|
||||
<Select><SelectTrigger className="w-[180px]"><SelectValue placeholder="All Assignments" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="all">All Assignments</SelectItem></SelectContent>
|
||||
<Select value={entityId} onValueChange={setEntityId}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All Entities" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Entities</SelectItem>
|
||||
{filters?.entities.map((e) => (
|
||||
<SelectItem key={e.id} value={String(e.id)}>
|
||||
{e.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="trends">Trends</TabsTrigger>
|
||||
<TabsTrigger value="distribution">Distribution</TabsTrigger>
|
||||
<TabsTrigger value="comparison">Comparison</TabsTrigger>
|
||||
</TabsList>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-12 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Aggregating
|
||||
attempts...
|
||||
</div>
|
||||
) : attemptsConsidered === 0 ? (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
No scored attempts match this threshold / entity combination yet.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="trends">Trends</TabsTrigger>
|
||||
<TabsTrigger value="distribution">Distribution</TabsTrigger>
|
||||
<TabsTrigger value="comparison">Comparison</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-4">
|
||||
<AiReportNarrative narrative={tabNarratives.overview} />
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader><CardTitle className="text-base">Average Score by Module</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={barData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(220, 16%, 90%)" />
|
||||
<XAxis dataKey="module" />
|
||||
<YAxis domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="score" fill="hsl(243, 75%, 59%)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="overview" className="mt-4">
|
||||
<AiReportNarrative
|
||||
report_type="corporate-overview"
|
||||
data={{ modules: barData }}
|
||||
/>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Average Score by Module
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={barData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(220, 16%, 90%)"
|
||||
/>
|
||||
<XAxis dataKey="module" />
|
||||
<YAxis domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Bar
|
||||
dataKey="score"
|
||||
fill="hsl(243, 75%, 59%)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="trends" className="mt-4">
|
||||
<AiReportNarrative narrative={tabNarratives.trends} />
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader><CardTitle className="text-base">Score Trend Over Time</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={trendData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(220, 16%, 90%)" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="avg" stroke="hsl(243, 75%, 59%)" strokeWidth={2} dot={{ r: 4 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="trends" className="mt-4">
|
||||
<AiReportNarrative
|
||||
report_type="corporate-trends"
|
||||
data={{ trends: trendData }}
|
||||
/>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Score Trend Over Time
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={trendData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(220, 16%, 90%)"
|
||||
/>
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="avg"
|
||||
stroke="hsl(243, 75%, 59%)"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="distribution" className="mt-4">
|
||||
<AiReportNarrative narrative={tabNarratives.distribution} />
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader><CardTitle className="text-base">Level Distribution</CardTitle></CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie data={distData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={100} label>
|
||||
{distData.map((entry, i) => <Cell key={i} fill={entry.color} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="distribution" className="mt-4">
|
||||
<AiReportNarrative
|
||||
report_type="corporate-distribution"
|
||||
data={{ distribution: distData }}
|
||||
/>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Level Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={distData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={100}
|
||||
label
|
||||
>
|
||||
{distData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="comparison" className="mt-4">
|
||||
<AiReportNarrative narrative={tabNarratives.comparison} />
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-8 text-center text-muted-foreground">Entity comparison charts will appear here based on selected filters.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value="comparison" className="mt-4">
|
||||
<AiReportNarrative
|
||||
report_type="corporate-comparison"
|
||||
data={{ threshold, entities: comparison }}
|
||||
/>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Entity Comparison (average band × 10)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{comparison.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground p-8">
|
||||
No entities have attempts above this threshold.
|
||||
</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={comparison}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(220, 16%, 90%)"
|
||||
/>
|
||||
<XAxis dataKey="entity_name" />
|
||||
<YAxis domain={[0, 100]} />
|
||||
<Tooltip />
|
||||
<Bar
|
||||
dataKey="avg"
|
||||
fill="hsl(142, 71%, 45%)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||