feat(frontend): Phase 2/3 hardening release
Roadmap P0
- Ship /logo.svg fallback and rewire asset references (superseded later by
the project-manager PNG in a separate commit).
Roadmap P1
- Response envelope alignment ({items,total,page,size}) across services
and query hooks.
- Approval/ticket UX updates tied to the new backend rollback semantics.
Roadmap P2
- React.lazy + Vite manualChunks to split vendor-radix/charts/icons/forms
from the main bundle and cut initial JS.
- Fix the 181 tsc errors (PaginationParams class + per-service generics).
- Auto-refresh flow for JWT refresh tokens wired into api-client.ts.
Roadmap P3
- i18n: i18next + language detector, en/ar locales, RTL auto-switch,
LanguageToggle component in RoleLayout and AdminLmsLayout.
- GDPR: PrivacyCenter page for data export and right-to-erasure, backed
by gdpr.service.ts; logout via AuthContext after erasure.
- Human-in-the-loop exam review UI: admin ExamReviewQueue + ExamReviewDetail
pages, useExamReview hooks, exam-review.service.ts.
- AI quality loop: AIPromptEditor (versioning + render preview),
AIFeedbackButtons for students, AIFeedbackTriage admin page, dedicated
services, hooks, and types.
- Dark mode: ThemeProvider + ThemeToggle + chart color tokens.
- Accessibility: DialogDescription on every DialogContent; alert-dialog
and sheet updates.
- Retire dead pages: ExamPage marketing, Index, ProfilePage import.
- Playwright e2e scaffolding: playwright.config.ts + e2e/login.spec.ts
smoke tests, npm scripts test:e2e / test:e2e:install.
Made-with: Cursor
This commit is contained in:
27
frontend/e2e/login.spec.ts
Normal file
27
frontend/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 });
|
||||||
|
});
|
||||||
154
frontend/package-lock.json
generated
154
frontend/package-lock.json
generated
@@ -42,6 +42,8 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"i18next": "^26.0.6",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.61.1",
|
"react-hook-form": "^7.61.1",
|
||||||
|
"react-i18next": "^17.0.4",
|
||||||
"react-resizable-panels": "^2.1.9",
|
"react-resizable-panels": "^2.1.9",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
@@ -60,6 +63,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.32.0",
|
"@eslint/js": "^9.32.0",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@testing-library/jest-dom": "^6.6.0",
|
"@testing-library/jest-dom": "^6.6.0",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
@@ -905,6 +909,22 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/number": {
|
"node_modules/@radix-ui/number": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||||
@@ -5480,6 +5500,15 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-parse-stringify": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"void-elements": "3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-proxy-agent": {
|
"node_modules/http-proxy-agent": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
|
||||||
@@ -5509,6 +5538,46 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/i18next": {
|
||||||
|
"version": "26.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.6.tgz",
|
||||||
|
"integrity": "sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.locize.com/i18next"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.locize.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.29.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5 || ^6"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/i18next-browser-languagedetector": {
|
||||||
|
"version": "8.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
|
||||||
|
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.23.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
@@ -6215,6 +6284,53 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.9",
|
"version": "8.5.9",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
|
||||||
@@ -6547,6 +6663,33 @@
|
|||||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-i18next": {
|
||||||
|
"version": "17.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.4.tgz",
|
||||||
|
"integrity": "sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.29.2",
|
||||||
|
"html-parse-stringify": "^3.0.1",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"i18next": ">= 26.0.1",
|
||||||
|
"react": ">= 16.8.0",
|
||||||
|
"typescript": "^5 || ^6"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-native": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
@@ -7369,7 +7512,7 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
@@ -8164,6 +8307,15 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/void-elements": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/w3c-xmlserializer": {
|
"node_modules/w3c-xmlserializer": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:install": "playwright install --with-deps chromium"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
@@ -47,6 +49,8 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"i18next": "^26.0.6",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
@@ -54,6 +58,7 @@
|
|||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.61.1",
|
"react-hook-form": "^7.61.1",
|
||||||
|
"react-i18next": "^17.0.4",
|
||||||
"react-resizable-panels": "^2.1.9",
|
"react-resizable-panels": "^2.1.9",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
@@ -65,9 +70,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.32.0",
|
"@eslint/js": "^9.32.0",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@testing-library/jest-dom": "^6.6.0",
|
"@testing-library/jest-dom": "^6.6.0",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
|
||||||
"@types/node": "^22.16.5",
|
"@types/node": "^22.16.5",
|
||||||
"@types/react": "^18.3.23",
|
"@types/react": "^18.3.23",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
|
|||||||
42
frontend/playwright.config.ts
Normal file
42
frontend/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"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
12
frontend/public/logo.svg
Normal file
12
frontend/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 |
@@ -1,3 +1,5 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { ThemeProvider } from "next-themes";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
@@ -8,146 +10,166 @@ import ProtectedRoute from "@/components/ProtectedRoute";
|
|||||||
import StudentLayout from "@/components/StudentLayout";
|
import StudentLayout from "@/components/StudentLayout";
|
||||||
import TeacherLayout from "@/components/TeacherLayout";
|
import TeacherLayout from "@/components/TeacherLayout";
|
||||||
import AdminLmsLayout from "@/components/AdminLmsLayout";
|
import AdminLmsLayout from "@/components/AdminLmsLayout";
|
||||||
import Login from "@/pages/Login";
|
|
||||||
import Register from "@/pages/Register";
|
|
||||||
import EmailVerification from "@/pages/EmailVerification";
|
|
||||||
import OnboardingWizard from "@/pages/OnboardingWizard";
|
|
||||||
import ForgotPassword from "@/pages/ForgotPassword";
|
|
||||||
import ResetPassword from "@/pages/ResetPassword";
|
|
||||||
import ScoreVerification from "@/pages/ScoreVerification";
|
|
||||||
// Original platform pages
|
|
||||||
import AdminDashboard from "@/pages/AdminDashboard";
|
|
||||||
import UsersPage from "@/pages/UsersPage";
|
|
||||||
import EntitiesPage from "@/pages/EntitiesPage";
|
|
||||||
import AssignmentsPage from "@/pages/AssignmentsPage";
|
|
||||||
import ExamsListPage from "@/pages/ExamsListPage";
|
|
||||||
import ExamStructuresPage from "@/pages/ExamStructuresPage";
|
|
||||||
import RubricsPage from "@/pages/RubricsPage";
|
|
||||||
import GenerationPage from "@/pages/GenerationPage";
|
|
||||||
import ApprovalWorkflowsPage from "@/pages/ApprovalWorkflowsPage";
|
|
||||||
import ClassroomsPage from "@/pages/ClassroomsPage";
|
|
||||||
import StudentPerformancePage from "@/pages/StudentPerformancePage";
|
|
||||||
import StatsCorporatePage from "@/pages/StatsCorporatePage";
|
|
||||||
import RecordPage from "@/pages/RecordPage";
|
|
||||||
import VocabularyPage from "@/pages/VocabularyPage";
|
|
||||||
import GrammarPage from "@/pages/GrammarPage";
|
|
||||||
import PaymentRecordPage from "@/pages/PaymentRecordPage";
|
|
||||||
import TicketsPage from "@/pages/TicketsPage";
|
|
||||||
import SettingsPage from "@/pages/SettingsPage";
|
|
||||||
import ProfilePage from "@/pages/ProfilePage";
|
|
||||||
import ExamPage from "@/pages/ExamPage";
|
|
||||||
// Student pages
|
|
||||||
import StudentDashboard from "@/pages/student/StudentDashboard";
|
|
||||||
import StudentCourses from "@/pages/student/StudentCourses";
|
|
||||||
import StudentCourseDetail from "@/pages/student/StudentCourseDetail";
|
|
||||||
import StudentAssignments from "@/pages/student/StudentAssignments";
|
|
||||||
import StudentGrades from "@/pages/student/StudentGrades";
|
|
||||||
import StudentAttendance from "@/pages/student/StudentAttendance";
|
|
||||||
import StudentTimetable from "@/pages/student/StudentTimetable";
|
|
||||||
import StudentProfile from "@/pages/student/StudentProfile";
|
|
||||||
// Adaptive learning pages
|
|
||||||
import SubjectSelection from "@/pages/student/SubjectSelection";
|
|
||||||
import DiagnosticTest from "@/pages/student/DiagnosticTest";
|
|
||||||
import ProficiencyProfile from "@/pages/student/ProficiencyProfile";
|
|
||||||
import LearningPlanPage from "@/pages/student/LearningPlan";
|
|
||||||
import TopicLearning from "@/pages/student/TopicLearning";
|
|
||||||
// Teacher pages
|
|
||||||
import TeacherDashboard from "@/pages/teacher/TeacherDashboard";
|
|
||||||
import TeacherCourses from "@/pages/teacher/TeacherCourses";
|
|
||||||
import CourseBuilder from "@/pages/teacher/CourseBuilder";
|
|
||||||
import TeacherAssignments from "@/pages/teacher/TeacherAssignments";
|
|
||||||
import TeacherAssignmentDetail from "@/pages/teacher/TeacherAssignmentDetail";
|
|
||||||
import TeacherAttendance from "@/pages/teacher/TeacherAttendance";
|
|
||||||
import TeacherStudents from "@/pages/teacher/TeacherStudents";
|
|
||||||
import TeacherTimetable from "@/pages/teacher/TeacherTimetable";
|
|
||||||
import TeacherProfile from "@/pages/teacher/TeacherProfile";
|
|
||||||
import TeacherLibrary from "@/pages/teacher/TeacherLibrary";
|
|
||||||
import AdaptiveSettings from "@/pages/teacher/AdaptiveSettings";
|
|
||||||
// Admin LMS pages
|
|
||||||
import AdminLmsDashboard from "@/pages/admin/AdminLmsDashboard";
|
|
||||||
import AdminCourses from "@/pages/admin/AdminCourses";
|
|
||||||
import AdminStudents from "@/pages/admin/AdminStudents";
|
|
||||||
import AdminTeachers from "@/pages/admin/AdminTeachers";
|
|
||||||
import AdminBatches from "@/pages/admin/AdminBatches";
|
|
||||||
import AdminBatchDetail from "@/pages/admin/AdminBatchDetail";
|
|
||||||
import AdminTimetable from "@/pages/admin/AdminTimetable";
|
|
||||||
import AdminReports from "@/pages/admin/AdminReports";
|
|
||||||
import AdminSettings from "@/pages/admin/AdminSettings";
|
|
||||||
import AdminProfileLms from "@/pages/admin/AdminProfile";
|
|
||||||
import TaxonomyManager from "@/pages/admin/TaxonomyManager";
|
|
||||||
import ResourceManager from "@/pages/admin/ResourceManager";
|
|
||||||
import AcademicYearManager from "@/pages/admin/AcademicYearManager";
|
|
||||||
import DepartmentManager from "@/pages/admin/DepartmentManager";
|
|
||||||
import AdmissionList from "@/pages/admin/AdmissionList";
|
|
||||||
import AdmissionDetail from "@/pages/admin/AdmissionDetail";
|
|
||||||
import AdmissionRegisterPage from "@/pages/admin/AdmissionRegisterPage";
|
|
||||||
import InstitutionalExamSessions from "@/pages/admin/InstitutionalExamSessions";
|
|
||||||
import MarksheetManager from "@/pages/admin/MarksheetManager";
|
|
||||||
import AdminStudentLeave from "@/pages/admin/AdminStudentLeave";
|
|
||||||
import AdminFees from "@/pages/admin/AdminFees";
|
|
||||||
import AdminLessons from "@/pages/admin/AdminLessons";
|
|
||||||
import AdminGradebook from "@/pages/admin/AdminGradebook";
|
|
||||||
import AdminStudentProgress from "@/pages/admin/AdminStudentProgress";
|
|
||||||
import AdminLibrary from "@/pages/admin/AdminLibrary";
|
|
||||||
import AdminActivities from "@/pages/admin/AdminActivities";
|
|
||||||
import AdminFacilities from "@/pages/admin/AdminFacilities";
|
|
||||||
import AdmissionApplication from "@/pages/AdmissionApplication";
|
|
||||||
import SubjectRegistrationPage from "@/pages/student/SubjectRegistrationPage";
|
|
||||||
// Courseware pages
|
|
||||||
import CourseChapters from "@/pages/teacher/CourseChapters";
|
|
||||||
import ChapterDetail from "@/pages/teacher/ChapterDetail";
|
|
||||||
import AiWorkbench from "@/pages/teacher/AiWorkbench";
|
|
||||||
import TeacherDiscussionBoard from "@/pages/teacher/TeacherDiscussionBoard";
|
|
||||||
import TeacherAnnouncements from "@/pages/teacher/TeacherAnnouncements";
|
|
||||||
import StudentChapterView from "@/pages/student/StudentChapterView";
|
|
||||||
import StudentDiscussionBoard from "@/pages/student/StudentDiscussionBoard";
|
|
||||||
import StudentAnnouncements from "@/pages/student/StudentAnnouncements";
|
|
||||||
import StudentMessages from "@/pages/student/StudentMessages";
|
|
||||||
import StudentJourney from "@/pages/student/StudentJourney";
|
|
||||||
import AiEnglishCourse from "@/pages/student/AiEnglishCourse";
|
|
||||||
import AiIeltsCourse from "@/pages/student/AiIeltsCourse";
|
|
||||||
import ExamSession from "@/pages/student/ExamSession";
|
|
||||||
import ExamStatus from "@/pages/student/ExamStatus";
|
|
||||||
import ExamResults from "@/pages/student/ExamResults";
|
|
||||||
import GapAnalysis from "@/pages/student/GapAnalysis";
|
|
||||||
import CourseDelivery from "@/pages/student/CourseDelivery";
|
|
||||||
import GradingQueue from "@/pages/admin/GradingQueue";
|
|
||||||
import CourseConfig from "@/pages/admin/CourseConfig";
|
|
||||||
import ModuleBuilder from "@/pages/admin/ModuleBuilder";
|
|
||||||
import CourseProgress from "@/pages/teacher/CourseProgress";
|
|
||||||
import PlacementBriefing from "@/pages/student/PlacementBriefing";
|
|
||||||
import PlacementTest from "@/pages/student/PlacementTest";
|
|
||||||
import PlacementResults from "@/pages/student/PlacementResults";
|
|
||||||
import PlacementAccess from "@/pages/student/PlacementAccess";
|
|
||||||
import FaqManager from "@/pages/admin/FaqManager";
|
|
||||||
import NotificationRules from "@/pages/admin/NotificationRules";
|
|
||||||
import ApprovalWorkflowConfig from "@/pages/admin/ApprovalWorkflowConfig";
|
|
||||||
import RolesPermissions from "@/pages/admin/RolesPermissions";
|
|
||||||
import AuthorityMatrix from "@/pages/admin/AuthorityMatrix";
|
|
||||||
import UserRoles from "@/pages/admin/UserRoles";
|
|
||||||
import BulkStudentUpload from "@/pages/admin/BulkStudentUpload";
|
|
||||||
import CredentialDashboard from "@/pages/admin/CredentialDashboard";
|
|
||||||
import AiEnglishQuality from "@/pages/admin/AiEnglishQuality";
|
|
||||||
import AiEnglishTaxonomy from "@/pages/admin/AiEnglishTaxonomy";
|
|
||||||
import AiIeltsValidation from "@/pages/admin/AiIeltsValidation";
|
|
||||||
import AdaptiveDashboard from "@/pages/admin/AdaptiveDashboard";
|
|
||||||
import AdaptiveStudentDetail from "@/pages/admin/AdaptiveStudentDetail";
|
|
||||||
import LevelMappingConfig from "@/pages/admin/LevelMappingConfig";
|
|
||||||
import WhiteLabelBranding from "@/pages/admin/WhiteLabelBranding";
|
|
||||||
import ScoreApprovalQueue from "@/pages/admin/ScoreApprovalQueue";
|
|
||||||
import ExamTemplateSelection from "@/pages/admin/ExamTemplateSelection";
|
|
||||||
import IeltsExamCreate from "@/pages/admin/IeltsExamCreate";
|
|
||||||
import IeltsSkillConfig from "@/pages/admin/IeltsSkillConfig";
|
|
||||||
import IeltsContentPool from "@/pages/admin/IeltsContentPool";
|
|
||||||
import IeltsExamValidation from "@/pages/admin/IeltsExamValidation";
|
|
||||||
import CustomExamCreate from "@/pages/admin/CustomExamCreate";
|
|
||||||
import OfficialExamAccess from "@/pages/OfficialExamAccess";
|
|
||||||
import FaqPage from "@/pages/FaqPage";
|
|
||||||
import NotFound from "@/pages/NotFound";
|
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
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 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 AdminCourses = lazy(() => import("@/pages/admin/AdminCourses"));
|
||||||
|
const AdminStudents = lazy(() => import("@/pages/admin/AdminStudents"));
|
||||||
|
const AdminTeachers = lazy(() => import("@/pages/admin/AdminTeachers"));
|
||||||
|
const AdminBatches = lazy(() => import("@/pages/admin/AdminBatches"));
|
||||||
|
const AdminBatchDetail = lazy(() => import("@/pages/admin/AdminBatchDetail"));
|
||||||
|
const AdminTimetable = lazy(() => import("@/pages/admin/AdminTimetable"));
|
||||||
|
const AdminReports = lazy(() => import("@/pages/admin/AdminReports"));
|
||||||
|
const AdminSettings = lazy(() => import("@/pages/admin/AdminSettings"));
|
||||||
|
const AdminProfileLms = lazy(() => import("@/pages/admin/AdminProfile"));
|
||||||
|
const TaxonomyManager = lazy(() => import("@/pages/admin/TaxonomyManager"));
|
||||||
|
const ResourceManager = lazy(() => import("@/pages/admin/ResourceManager"));
|
||||||
|
const AcademicYearManager = lazy(() => import("@/pages/admin/AcademicYearManager"));
|
||||||
|
const DepartmentManager = lazy(() => import("@/pages/admin/DepartmentManager"));
|
||||||
|
const AdmissionList = lazy(() => import("@/pages/admin/AdmissionList"));
|
||||||
|
const AdmissionDetail = lazy(() => import("@/pages/admin/AdmissionDetail"));
|
||||||
|
const AdmissionRegisterPage = lazy(() => import("@/pages/admin/AdmissionRegisterPage"));
|
||||||
|
const InstitutionalExamSessions = lazy(() => import("@/pages/admin/InstitutionalExamSessions"));
|
||||||
|
const MarksheetManager = lazy(() => import("@/pages/admin/MarksheetManager"));
|
||||||
|
const AdminStudentLeave = lazy(() => import("@/pages/admin/AdminStudentLeave"));
|
||||||
|
const AdminFees = lazy(() => import("@/pages/admin/AdminFees"));
|
||||||
|
const AdminLessons = lazy(() => import("@/pages/admin/AdminLessons"));
|
||||||
|
const AdminGradebook = lazy(() => import("@/pages/admin/AdminGradebook"));
|
||||||
|
const AdminStudentProgress = lazy(() => import("@/pages/admin/AdminStudentProgress"));
|
||||||
|
const AdminLibrary = lazy(() => import("@/pages/admin/AdminLibrary"));
|
||||||
|
const AdminActivities = lazy(() => import("@/pages/admin/AdminActivities"));
|
||||||
|
const AdminFacilities = lazy(() => import("@/pages/admin/AdminFacilities"));
|
||||||
|
const AdmissionApplication = lazy(() => import("@/pages/AdmissionApplication"));
|
||||||
|
const SubjectRegistrationPage = lazy(() => import("@/pages/student/SubjectRegistrationPage"));
|
||||||
|
|
||||||
|
// Courseware pages
|
||||||
|
const CourseChapters = lazy(() => import("@/pages/teacher/CourseChapters"));
|
||||||
|
const ChapterDetail = lazy(() => import("@/pages/teacher/ChapterDetail"));
|
||||||
|
const AiWorkbench = lazy(() => import("@/pages/teacher/AiWorkbench"));
|
||||||
|
const TeacherDiscussionBoard = lazy(() => import("@/pages/teacher/TeacherDiscussionBoard"));
|
||||||
|
const TeacherAnnouncements = lazy(() => import("@/pages/teacher/TeacherAnnouncements"));
|
||||||
|
const StudentChapterView = lazy(() => import("@/pages/student/StudentChapterView"));
|
||||||
|
const StudentDiscussionBoard = lazy(() => import("@/pages/student/StudentDiscussionBoard"));
|
||||||
|
const StudentAnnouncements = lazy(() => import("@/pages/student/StudentAnnouncements"));
|
||||||
|
const StudentMessages = lazy(() => import("@/pages/student/StudentMessages"));
|
||||||
|
const StudentJourney = lazy(() => import("@/pages/student/StudentJourney"));
|
||||||
|
const AiEnglishCourse = lazy(() => import("@/pages/student/AiEnglishCourse"));
|
||||||
|
const AiIeltsCourse = lazy(() => import("@/pages/student/AiIeltsCourse"));
|
||||||
|
const ExamSession = lazy(() => import("@/pages/student/ExamSession"));
|
||||||
|
const ExamStatus = lazy(() => import("@/pages/student/ExamStatus"));
|
||||||
|
const ExamResults = lazy(() => import("@/pages/student/ExamResults"));
|
||||||
|
const GapAnalysis = lazy(() => import("@/pages/student/GapAnalysis"));
|
||||||
|
const CourseDelivery = lazy(() => import("@/pages/student/CourseDelivery"));
|
||||||
|
const GradingQueue = lazy(() => import("@/pages/admin/GradingQueue"));
|
||||||
|
const CourseConfig = lazy(() => import("@/pages/admin/CourseConfig"));
|
||||||
|
const ModuleBuilder = lazy(() => import("@/pages/admin/ModuleBuilder"));
|
||||||
|
const CourseProgress = lazy(() => import("@/pages/teacher/CourseProgress"));
|
||||||
|
const PlacementBriefing = lazy(() => import("@/pages/student/PlacementBriefing"));
|
||||||
|
const PlacementTest = lazy(() => import("@/pages/student/PlacementTest"));
|
||||||
|
const PlacementResults = lazy(() => import("@/pages/student/PlacementResults"));
|
||||||
|
const PlacementAccess = lazy(() => import("@/pages/student/PlacementAccess"));
|
||||||
|
const FaqManager = lazy(() => import("@/pages/admin/FaqManager"));
|
||||||
|
const NotificationRules = lazy(() => import("@/pages/admin/NotificationRules"));
|
||||||
|
const ApprovalWorkflowConfig = lazy(() => import("@/pages/admin/ApprovalWorkflowConfig"));
|
||||||
|
const RolesPermissions = lazy(() => import("@/pages/admin/RolesPermissions"));
|
||||||
|
const AuthorityMatrix = lazy(() => import("@/pages/admin/AuthorityMatrix"));
|
||||||
|
const UserRoles = lazy(() => import("@/pages/admin/UserRoles"));
|
||||||
|
const BulkStudentUpload = lazy(() => import("@/pages/admin/BulkStudentUpload"));
|
||||||
|
const CredentialDashboard = lazy(() => import("@/pages/admin/CredentialDashboard"));
|
||||||
|
const AiEnglishQuality = lazy(() => import("@/pages/admin/AiEnglishQuality"));
|
||||||
|
const AiEnglishTaxonomy = lazy(() => import("@/pages/admin/AiEnglishTaxonomy"));
|
||||||
|
const AiIeltsValidation = lazy(() => import("@/pages/admin/AiIeltsValidation"));
|
||||||
|
const AdaptiveDashboard = lazy(() => import("@/pages/admin/AdaptiveDashboard"));
|
||||||
|
const AdaptiveStudentDetail = lazy(() => import("@/pages/admin/AdaptiveStudentDetail"));
|
||||||
|
const LevelMappingConfig = lazy(() => import("@/pages/admin/LevelMappingConfig"));
|
||||||
|
const WhiteLabelBranding = lazy(() => import("@/pages/admin/WhiteLabelBranding"));
|
||||||
|
const ScoreApprovalQueue = lazy(() => import("@/pages/admin/ScoreApprovalQueue"));
|
||||||
|
const ExamTemplateSelection = lazy(() => import("@/pages/admin/ExamTemplateSelection"));
|
||||||
|
const IeltsExamCreate = lazy(() => import("@/pages/admin/IeltsExamCreate"));
|
||||||
|
const IeltsSkillConfig = lazy(() => import("@/pages/admin/IeltsSkillConfig"));
|
||||||
|
const IeltsContentPool = lazy(() => import("@/pages/admin/IeltsContentPool"));
|
||||||
|
const IeltsExamValidation = lazy(() => import("@/pages/admin/IeltsExamValidation"));
|
||||||
|
const CustomExamCreate = lazy(() => import("@/pages/admin/CustomExamCreate"));
|
||||||
|
const ExamReviewQueue = lazy(() => import("@/pages/admin/ExamReviewQueue"));
|
||||||
|
const ExamReviewDetail = lazy(() => import("@/pages/admin/ExamReviewDetail"));
|
||||||
|
const AIPromptEditor = lazy(() => import("@/pages/admin/AIPromptEditor"));
|
||||||
|
const AIFeedbackTriage = lazy(() => import("@/pages/admin/AIFeedbackTriage"));
|
||||||
|
const PrivacyCenter = lazy(() => import("@/pages/PrivacyCenter"));
|
||||||
|
const OfficialExamAccess = lazy(() => import("@/pages/OfficialExamAccess"));
|
||||||
|
const FaqPage = lazy(() => import("@/pages/FaqPage"));
|
||||||
|
const NotFound = lazy(() => import("@/pages/NotFound"));
|
||||||
|
|
||||||
function StudentSubscriptionPlaceholder() {
|
function StudentSubscriptionPlaceholder() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
@@ -158,14 +180,30 @@ function StudentSubscriptionPlaceholder() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RouteFallback() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const App = () => (
|
const App = () => (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
storageKey="encoach-theme"
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<Sonner />
|
<Sonner />
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<Suspense fallback={<RouteFallback />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Auth routes */}
|
{/* Auth routes */}
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
@@ -200,6 +238,7 @@ const App = () => (
|
|||||||
<Route path="/student/attendance" element={<StudentAttendance />} />
|
<Route path="/student/attendance" element={<StudentAttendance />} />
|
||||||
<Route path="/student/timetable" element={<StudentTimetable />} />
|
<Route path="/student/timetable" element={<StudentTimetable />} />
|
||||||
<Route path="/student/profile" element={<StudentProfile />} />
|
<Route path="/student/profile" element={<StudentProfile />} />
|
||||||
|
<Route path="/student/privacy" element={<PrivacyCenter />} />
|
||||||
<Route path="/student/subjects" element={<SubjectSelection />} />
|
<Route path="/student/subjects" element={<SubjectSelection />} />
|
||||||
<Route path="/student/diagnostic/:subjectId" element={<DiagnosticTest />} />
|
<Route path="/student/diagnostic/:subjectId" element={<DiagnosticTest />} />
|
||||||
<Route path="/student/proficiency/:subjectId" element={<ProficiencyProfile />} />
|
<Route path="/student/proficiency/:subjectId" element={<ProficiencyProfile />} />
|
||||||
@@ -241,6 +280,7 @@ const App = () => (
|
|||||||
<Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} />
|
<Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} />
|
||||||
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
|
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
|
||||||
<Route path="/teacher/profile" element={<TeacherProfile />} />
|
<Route path="/teacher/profile" element={<TeacherProfile />} />
|
||||||
|
<Route path="/teacher/privacy" element={<PrivacyCenter />} />
|
||||||
<Route path="/teacher/course/:courseId/progress" element={<CourseProgress />} />
|
<Route path="/teacher/course/:courseId/progress" element={<CourseProgress />} />
|
||||||
<Route path="/teacher/adaptive/settings" element={<AdaptiveSettings />} />
|
<Route path="/teacher/adaptive/settings" element={<AdaptiveSettings />} />
|
||||||
</Route>
|
</Route>
|
||||||
@@ -263,6 +303,7 @@ const App = () => (
|
|||||||
<Route path="/admin/reports" element={<AdminReports />} />
|
<Route path="/admin/reports" element={<AdminReports />} />
|
||||||
<Route path="/admin/settings" element={<AdminSettings />} />
|
<Route path="/admin/settings" element={<AdminSettings />} />
|
||||||
<Route path="/admin/profile" element={<AdminProfileLms />} />
|
<Route path="/admin/profile" element={<AdminProfileLms />} />
|
||||||
|
<Route path="/admin/privacy" element={<PrivacyCenter />} />
|
||||||
{/* Original academic pages */}
|
{/* Original academic pages */}
|
||||||
<Route path="/admin/assignments" element={<AssignmentsPage />} />
|
<Route path="/admin/assignments" element={<AssignmentsPage />} />
|
||||||
<Route path="/admin/examsList" element={<ExamsListPage />} />
|
<Route path="/admin/examsList" element={<ExamsListPage />} />
|
||||||
@@ -285,13 +326,16 @@ const App = () => (
|
|||||||
<Route path="/admin/payment-record" element={<PaymentRecordPage />} />
|
<Route path="/admin/payment-record" element={<PaymentRecordPage />} />
|
||||||
<Route path="/admin/tickets" element={<TicketsPage />} />
|
<Route path="/admin/tickets" element={<TicketsPage />} />
|
||||||
<Route path="/admin/settings-platform" element={<SettingsPage />} />
|
<Route path="/admin/settings-platform" element={<SettingsPage />} />
|
||||||
<Route path="/admin/exam" element={<ExamPage />} />
|
|
||||||
<Route path="/admin/exam/create" element={<ExamTemplateSelection />} />
|
<Route path="/admin/exam/create" element={<ExamTemplateSelection />} />
|
||||||
<Route path="/admin/exam/ielts/create" element={<IeltsExamCreate />} />
|
<Route path="/admin/exam/ielts/create" element={<IeltsExamCreate />} />
|
||||||
<Route path="/admin/exam/ielts/:examId/skills" element={<IeltsSkillConfig />} />
|
<Route path="/admin/exam/ielts/:examId/skills" element={<IeltsSkillConfig />} />
|
||||||
<Route path="/admin/exam/ielts/:examId/content" element={<IeltsContentPool />} />
|
<Route path="/admin/exam/ielts/:examId/content" element={<IeltsContentPool />} />
|
||||||
<Route path="/admin/exam/ielts/:examId/validate" element={<IeltsExamValidation />} />
|
<Route path="/admin/exam/ielts/:examId/validate" element={<IeltsExamValidation />} />
|
||||||
<Route path="/admin/exam/custom/create" element={<CustomExamCreate />} />
|
<Route path="/admin/exam/custom/create" element={<CustomExamCreate />} />
|
||||||
|
<Route path="/admin/exam/review-queue" element={<ExamReviewQueue />} />
|
||||||
|
<Route path="/admin/exam/review/:examId" element={<ExamReviewDetail />} />
|
||||||
|
<Route path="/admin/ai/prompts" element={<AIPromptEditor />} />
|
||||||
|
<Route path="/admin/ai/feedback" element={<AIFeedbackTriage />} />
|
||||||
<Route path="/admin/taxonomy" element={<TaxonomyManager />} />
|
<Route path="/admin/taxonomy" element={<TaxonomyManager />} />
|
||||||
<Route path="/admin/resources" element={<ResourceManager />} />
|
<Route path="/admin/resources" element={<ResourceManager />} />
|
||||||
{/* Institutional LMS pages */}
|
{/* Institutional LMS pages */}
|
||||||
@@ -340,10 +384,12 @@ const App = () => (
|
|||||||
<Route path="/dashboard/admin" element={<Navigate to="/admin/platform" replace />} />
|
<Route path="/dashboard/admin" element={<Navigate to="/admin/platform" replace />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
164
frontend/src/components/AIFeedbackButtons.tsx
Normal file
164
frontend/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;
|
||||||
43
frontend/src/components/LanguageToggle.tsx
Normal file
43
frontend/src/components/LanguageToggle.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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 } = useTranslation();
|
||||||
|
const current = (i18n.language?.split("-")[0] || "en") as SupportedLang;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Change language">
|
||||||
|
<Languages className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Language</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;
|
||||||
68
frontend/src/components/ThemeToggle.tsx
Normal file
68
frontend/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Moon, Sun, Monitor } from "lucide-react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isDark = mounted && (resolvedTheme ?? theme) === "dark";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
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="mr-2 h-4 w-4" /> Light
|
||||||
|
{theme === "light" && <span className="ml-auto text-xs text-muted-foreground">✓</span>}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||||
|
<Moon className="mr-2 h-4 w-4" /> Dark
|
||||||
|
{theme === "dark" && <span className="ml-auto text-xs text-muted-foreground">✓</span>}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||||
|
<Monitor className="mr-2 h-4 w-4" /> System
|
||||||
|
{theme === "system" && <span className="ml-auto text-xs text-muted-foreground">✓</span>}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThemeToggle;
|
||||||
@@ -25,22 +25,72 @@ const AlertDialogOverlay = React.forwardRef<
|
|||||||
));
|
));
|
||||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
function hasAlertDialogDescription(children: React.ReactNode): boolean {
|
||||||
|
let found = false;
|
||||||
|
React.Children.forEach(children, (child) => {
|
||||||
|
if (found || !React.isValidElement(child)) return;
|
||||||
|
const type = child.type as { displayName?: string } | undefined;
|
||||||
|
if (
|
||||||
|
type === AlertDialogPrimitive.Description ||
|
||||||
|
(type && type.displayName === AlertDialogPrimitive.Description.displayName)
|
||||||
|
) {
|
||||||
|
found = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nested = (child.props as { children?: React.ReactNode } | undefined)
|
||||||
|
?.children;
|
||||||
|
if (nested !== undefined && hasAlertDialogDescription(nested)) {
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertDialogContentProps = React.ComponentPropsWithoutRef<
|
||||||
|
typeof AlertDialogPrimitive.Content
|
||||||
|
> & {
|
||||||
|
/**
|
||||||
|
* Optional accessible description rendered visually-hidden. When omitted and
|
||||||
|
* no `AlertDialogDescription` is found in the subtree, a generic fallback is
|
||||||
|
* emitted so screen readers still have something to announce.
|
||||||
|
*/
|
||||||
|
description?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
const AlertDialogContent = React.forwardRef<
|
const AlertDialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
AlertDialogContentProps
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, children, description, ...props }, ref) => {
|
||||||
<AlertDialogPortal>
|
const needsFallback =
|
||||||
<AlertDialogOverlay />
|
!props["aria-describedby"] &&
|
||||||
<AlertDialogPrimitive.Content
|
description === undefined &&
|
||||||
ref={ref}
|
!hasAlertDialogDescription(children);
|
||||||
className={cn(
|
return (
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
<AlertDialogPortal>
|
||||||
className,
|
<AlertDialogOverlay />
|
||||||
)}
|
<AlertDialogPrimitive.Content
|
||||||
{...props}
|
ref={ref}
|
||||||
/>
|
className={cn(
|
||||||
</AlertDialogPortal>
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
));
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{description !== undefined && (
|
||||||
|
<AlertDialogPrimitive.Description className="sr-only">
|
||||||
|
{description}
|
||||||
|
</AlertDialogPrimitive.Description>
|
||||||
|
)}
|
||||||
|
{needsFallback && (
|
||||||
|
<AlertDialogPrimitive.Description className="sr-only">
|
||||||
|
Alert dialog content
|
||||||
|
</AlertDialogPrimitive.Description>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</AlertDialogPrimitive.Content>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
);
|
||||||
|
});
|
||||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
|||||||
@@ -27,29 +27,86 @@ const DialogOverlay = React.forwardRef<
|
|||||||
));
|
));
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks `children` looking for any element whose component is
|
||||||
|
* `DialogPrimitive.Description` (or our re-exported `DialogDescription`).
|
||||||
|
* Radix emits an a11y warning when a `DialogContent` has neither a
|
||||||
|
* `Description` nor an explicit `aria-describedby`, so we use this to decide
|
||||||
|
* whether to inject a visually-hidden fallback description.
|
||||||
|
*/
|
||||||
|
function hasDialogDescription(children: React.ReactNode): boolean {
|
||||||
|
let found = false;
|
||||||
|
React.Children.forEach(children, (child) => {
|
||||||
|
if (found || !React.isValidElement(child)) return;
|
||||||
|
const type = child.type as { displayName?: string } | undefined;
|
||||||
|
if (
|
||||||
|
type === DialogPrimitive.Description ||
|
||||||
|
(type && type.displayName === DialogPrimitive.Description.displayName)
|
||||||
|
) {
|
||||||
|
found = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nested = (child.props as { children?: React.ReactNode } | undefined)
|
||||||
|
?.children;
|
||||||
|
if (nested !== undefined && hasDialogDescription(nested)) {
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DialogContentProps = React.ComponentPropsWithoutRef<
|
||||||
|
typeof DialogPrimitive.Content
|
||||||
|
> & {
|
||||||
|
/**
|
||||||
|
* Optional accessible description. When supplied, a visually-hidden
|
||||||
|
* `DialogDescription` is rendered so screen readers announce it without
|
||||||
|
* affecting layout. Prefer this for simple dialogs; for richer descriptions
|
||||||
|
* continue to place a `<DialogDescription>` inside `<DialogHeader>`.
|
||||||
|
*/
|
||||||
|
description?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
DialogContentProps
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, description, ...props }, ref) => {
|
||||||
<DialogPortal>
|
const hasExplicitDescribedBy = !!props["aria-describedby"];
|
||||||
<DialogOverlay />
|
const needsFallback =
|
||||||
<DialogPrimitive.Content
|
!hasExplicitDescribedBy &&
|
||||||
ref={ref}
|
description === undefined &&
|
||||||
aria-describedby={props["aria-describedby"] ?? undefined}
|
!hasDialogDescription(children);
|
||||||
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",
|
return (
|
||||||
className,
|
<DialogPortal>
|
||||||
)}
|
<DialogOverlay />
|
||||||
{...props}
|
<DialogPrimitive.Content
|
||||||
>
|
ref={ref}
|
||||||
{children}
|
className={cn(
|
||||||
<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">
|
"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",
|
||||||
<X className="h-4 w-4" />
|
className,
|
||||||
<span className="sr-only">Close</span>
|
)}
|
||||||
</DialogPrimitive.Close>
|
{...props}
|
||||||
</DialogPrimitive.Content>
|
>
|
||||||
</DialogPortal>
|
{description !== undefined && (
|
||||||
));
|
<DialogPrimitive.Description className="sr-only">
|
||||||
|
{description}
|
||||||
|
</DialogPrimitive.Description>
|
||||||
|
)}
|
||||||
|
{needsFallback && (
|
||||||
|
<DialogPrimitive.Description className="sr-only">
|
||||||
|
Dialog content
|
||||||
|
</DialogPrimitive.Description>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
);
|
||||||
|
});
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
|||||||
@@ -49,21 +49,61 @@ const sheetVariants = cva(
|
|||||||
|
|
||||||
interface SheetContentProps
|
interface SheetContentProps
|
||||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||||
VariantProps<typeof sheetVariants> {}
|
VariantProps<typeof sheetVariants> {
|
||||||
|
/**
|
||||||
|
* Optional accessible description rendered visually-hidden. Falls back to a
|
||||||
|
* generic string when no `SheetDescription` is found in the subtree, which
|
||||||
|
* keeps Radix happy without forcing every call-site to add one.
|
||||||
|
*/
|
||||||
|
description?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSheetDescription(children: React.ReactNode): boolean {
|
||||||
|
let found = false;
|
||||||
|
React.Children.forEach(children, (child) => {
|
||||||
|
if (found || !React.isValidElement(child)) return;
|
||||||
|
const type = child.type as { displayName?: string } | undefined;
|
||||||
|
if (
|
||||||
|
type === SheetPrimitive.Description ||
|
||||||
|
(type && type.displayName === SheetPrimitive.Description.displayName)
|
||||||
|
) {
|
||||||
|
found = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nested = (child.props as { children?: React.ReactNode } | undefined)
|
||||||
|
?.children;
|
||||||
|
if (nested !== undefined && hasSheetDescription(nested)) {
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
|
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
|
||||||
({ side = "right", className, children, ...props }, ref) => (
|
({ side = "right", className, children, description, ...props }, ref) => {
|
||||||
<SheetPortal>
|
const needsFallback =
|
||||||
<SheetOverlay />
|
!props["aria-describedby"] &&
|
||||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
description === undefined &&
|
||||||
{children}
|
!hasSheetDescription(children);
|
||||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
return (
|
||||||
<X className="h-4 w-4" />
|
<SheetPortal>
|
||||||
<span className="sr-only">Close</span>
|
<SheetOverlay />
|
||||||
</SheetPrimitive.Close>
|
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||||
</SheetPrimitive.Content>
|
{description !== undefined && (
|
||||||
</SheetPortal>
|
<SheetPrimitive.Description className="sr-only">{description}</SheetPrimitive.Description>
|
||||||
),
|
)}
|
||||||
|
{needsFallback && (
|
||||||
|
<SheetPrimitive.Description className="sr-only">Sheet content</SheetPrimitive.Description>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
|||||||
@@ -4,244 +4,246 @@ export const queryKeys = {
|
|||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
all: ["users"] as const,
|
all: ["users"] as const,
|
||||||
list: (params: Record<string, unknown>) => ["users", "list", params] as const,
|
list: (params: object) => ["users", "list", params] as const,
|
||||||
detail: (id: number) => ["users", id] as const,
|
detail: (id: number) => ["users", id] as const,
|
||||||
},
|
},
|
||||||
entities: {
|
entities: {
|
||||||
all: ["entities"] as const,
|
all: ["entities"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["entities", "list", params] as const,
|
list: (params?: object) => ["entities", "list", params] as const,
|
||||||
detail: (id: number) => ["entities", id] as const,
|
detail: (id: number) => ["entities", id] as const,
|
||||||
roles: (entityId: number) => ["entities", entityId, "roles"] as const,
|
roles: (entityId: number) => ["entities", entityId, "roles"] as const,
|
||||||
permissions: (entityId: number) => ["entities", entityId, "permissions"] as const,
|
permissions: (entityId: number) => ["entities", entityId, "permissions"] as const,
|
||||||
},
|
},
|
||||||
exams: {
|
exams: {
|
||||||
all: ["exams"] as const,
|
all: ["exams"] as const,
|
||||||
list: (module: string, params?: Record<string, unknown>) => ["exams", module, params] as const,
|
list: (module: string, params?: object) => ["exams", module, params] as const,
|
||||||
detail: (module: string, id: number) => ["exams", module, id] as const,
|
detail: (module: string, id: number) => ["exams", module, id] as const,
|
||||||
rubrics: (params?: Record<string, unknown>) => ["exams", "rubrics", params] as const,
|
rubrics: (params?: object) => ["exams", "rubrics", params] as const,
|
||||||
rubricGroups: (params?: Record<string, unknown>) => ["exams", "rubric-groups", params] as const,
|
rubricGroups: (params?: object) => ["exams", "rubric-groups", params] as const,
|
||||||
structures: (params?: Record<string, unknown>) => ["exams", "structures", params] as const,
|
structures: (params?: object) => ["exams", "structures", params] as const,
|
||||||
avatars: ["exams", "avatars"] as const,
|
avatars: ["exams", "avatars"] as const,
|
||||||
|
reviewQueue: (params?: object) => ["exams", "review", "queue", params] as const,
|
||||||
|
reviewDetail: (id: number) => ["exams", "review", "detail", id] as const,
|
||||||
},
|
},
|
||||||
assignments: {
|
assignments: {
|
||||||
all: ["assignments"] as const,
|
all: ["assignments"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["assignments", "list", params] as const,
|
list: (params?: object) => ["assignments", "list", params] as const,
|
||||||
detail: (id: number) => ["assignments", id] as const,
|
detail: (id: number) => ["assignments", id] as const,
|
||||||
},
|
},
|
||||||
classrooms: {
|
classrooms: {
|
||||||
all: ["classrooms"] as const,
|
all: ["classrooms"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["classrooms", "list", params] as const,
|
list: (params?: object) => ["classrooms", "list", params] as const,
|
||||||
detail: (id: number) => ["classrooms", id] as const,
|
detail: (id: number) => ["classrooms", id] as const,
|
||||||
},
|
},
|
||||||
stats: {
|
stats: {
|
||||||
sessions: (params?: Record<string, unknown>) => ["stats", "sessions", params] as const,
|
sessions: (params?: object) => ["stats", "sessions", params] as const,
|
||||||
stats: (params?: Record<string, unknown>) => ["stats", "stats", params] as const,
|
stats: (params?: object) => ["stats", "stats", params] as const,
|
||||||
statistical: (params?: Record<string, unknown>) => ["stats", "statistical", params] as const,
|
statistical: (params?: object) => ["stats", "statistical", params] as const,
|
||||||
performance: (params?: Record<string, unknown>) => ["stats", "performance", params] as const,
|
performance: (params?: object) => ["stats", "performance", params] as const,
|
||||||
},
|
},
|
||||||
training: {
|
training: {
|
||||||
all: ["training"] as const,
|
all: ["training"] as const,
|
||||||
tips: (params?: Record<string, unknown>) => ["training", "tips", params] as const,
|
tips: (params?: object) => ["training", "tips", params] as const,
|
||||||
walkthroughs: (module?: string) => ["training", "walkthroughs", module] as const,
|
walkthroughs: (module?: string) => ["training", "walkthroughs", module] as const,
|
||||||
},
|
},
|
||||||
subscriptions: {
|
subscriptions: {
|
||||||
packages: ["subscriptions", "packages"] as const,
|
packages: ["subscriptions", "packages"] as const,
|
||||||
payments: (params?: Record<string, unknown>) => ["subscriptions", "payments", params] as const,
|
payments: (params?: object) => ["subscriptions", "payments", params] as const,
|
||||||
discounts: (params?: Record<string, unknown>) => ["subscriptions", "discounts", params] as const,
|
discounts: (params?: object) => ["subscriptions", "discounts", params] as const,
|
||||||
},
|
},
|
||||||
tickets: {
|
tickets: {
|
||||||
all: ["tickets"] as const,
|
all: ["tickets"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["tickets", "list", params] as const,
|
list: (params?: object) => ["tickets", "list", params] as const,
|
||||||
assigned: ["tickets", "assigned"] as const,
|
assigned: ["tickets", "assigned"] as const,
|
||||||
},
|
},
|
||||||
approvals: {
|
approvals: {
|
||||||
all: ["approvals"] as const,
|
all: ["approvals"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["approvals", "list", params] as const,
|
list: (params?: object) => ["approvals", "list", params] as const,
|
||||||
},
|
},
|
||||||
taxonomy: {
|
taxonomy: {
|
||||||
subjects: ["taxonomy", "subjects"] as const,
|
subjects: ["taxonomy", "subjects"] as const,
|
||||||
subject: (id: number) => ["taxonomy", "subjects", id] as const,
|
subject: (id: number) => ["taxonomy", "subjects", id] as const,
|
||||||
tree: (subjectId: number) => ["taxonomy", "tree", subjectId] as const,
|
tree: (subjectId: number) => ["taxonomy", "tree", subjectId] as const,
|
||||||
domains: (params?: Record<string, unknown>) => ["taxonomy", "domains", params] as const,
|
domains: (params?: object) => ["taxonomy", "domains", params] as const,
|
||||||
topics: (params?: Record<string, unknown>) => ["taxonomy", "topics", params] as const,
|
topics: (params?: object) => ["taxonomy", "topics", params] as const,
|
||||||
},
|
},
|
||||||
adaptive: {
|
adaptive: {
|
||||||
proficiency: (params?: Record<string, unknown>) => ["adaptive", "proficiency", params] as const,
|
proficiency: (params?: object) => ["adaptive", "proficiency", params] as const,
|
||||||
summary: ["adaptive", "proficiency", "summary"] as const,
|
summary: ["adaptive", "proficiency", "summary"] as const,
|
||||||
plan: (params?: Record<string, unknown>) => ["adaptive", "plan", params] as const,
|
plan: (params?: object) => ["adaptive", "plan", params] as const,
|
||||||
topicContent: (topicId: number) => ["adaptive", "topic", topicId, "content"] as const,
|
topicContent: (topicId: number) => ["adaptive", "topic", topicId, "content"] as const,
|
||||||
diagnostic: (sessionId: number) => ["adaptive", "diagnostic", sessionId] as const,
|
diagnostic: (sessionId: number) => ["adaptive", "diagnostic", sessionId] as const,
|
||||||
},
|
},
|
||||||
resources: {
|
resources: {
|
||||||
all: ["resources"] as const,
|
all: ["resources"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["resources", "list", params] as const,
|
list: (params?: object) => ["resources", "list", params] as const,
|
||||||
},
|
},
|
||||||
lms: {
|
lms: {
|
||||||
courses: (params?: Record<string, unknown>) => ["lms", "courses", params] as const,
|
courses: (params?: object) => ["lms", "courses", params] as const,
|
||||||
course: (id: number) => ["lms", "courses", id] as const,
|
course: (id: number) => ["lms", "courses", id] as const,
|
||||||
myCourses: ["lms", "my-courses"] as const,
|
myCourses: ["lms", "my-courses"] as const,
|
||||||
students: (params?: Record<string, unknown>) => ["lms", "students", params] as const,
|
students: (params?: object) => ["lms", "students", params] as const,
|
||||||
teachers: (params?: Record<string, unknown>) => ["lms", "teachers", params] as const,
|
teachers: (params?: object) => ["lms", "teachers", params] as const,
|
||||||
batches: (params?: Record<string, unknown>) => ["lms", "batches", params] as const,
|
batches: (params?: object) => ["lms", "batches", params] as const,
|
||||||
batch: (id: number) => ["lms", "batches", id] as const,
|
batch: (id: number) => ["lms", "batches", id] as const,
|
||||||
timetable: (params?: Record<string, unknown>) => ["lms", "timetable", params] as const,
|
timetable: (params?: object) => ["lms", "timetable", params] as const,
|
||||||
attendance: (params?: Record<string, unknown>) => ["lms", "attendance", params] as const,
|
attendance: (params?: object) => ["lms", "attendance", params] as const,
|
||||||
grades: (params?: Record<string, unknown>) => ["lms", "grades", params] as const,
|
grades: (params?: object) => ["lms", "grades", params] as const,
|
||||||
},
|
},
|
||||||
analytics: {
|
analytics: {
|
||||||
student: (params?: Record<string, unknown>) => ["analytics", "student", params] as const,
|
student: (params?: object) => ["analytics", "student", params] as const,
|
||||||
class: (params?: Record<string, unknown>) => ["analytics", "class", params] as const,
|
class: (params?: object) => ["analytics", "class", params] as const,
|
||||||
subject: (params?: Record<string, unknown>) => ["analytics", "subject", params] as const,
|
subject: (params?: object) => ["analytics", "subject", params] as const,
|
||||||
contentGaps: (params?: Record<string, unknown>) => ["analytics", "content-gaps", params] as const,
|
contentGaps: (params?: object) => ["analytics", "content-gaps", params] as const,
|
||||||
alerts: ["analytics", "alerts"] as const,
|
alerts: ["analytics", "alerts"] as const,
|
||||||
},
|
},
|
||||||
academicYears: {
|
academicYears: {
|
||||||
all: ["academic-years"] as const,
|
all: ["academic-years"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["academic-years", "list", params] as const,
|
list: (params?: object) => ["academic-years", "list", params] as const,
|
||||||
detail: (id: number) => ["academic-years", id] as const,
|
detail: (id: number) => ["academic-years", id] as const,
|
||||||
},
|
},
|
||||||
academicTerms: {
|
academicTerms: {
|
||||||
all: ["academic-terms"] as const,
|
all: ["academic-terms"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["academic-terms", "list", params] as const,
|
list: (params?: object) => ["academic-terms", "list", params] as const,
|
||||||
detail: (id: number) => ["academic-terms", id] as const,
|
detail: (id: number) => ["academic-terms", id] as const,
|
||||||
},
|
},
|
||||||
departments: {
|
departments: {
|
||||||
all: ["departments"] as const,
|
all: ["departments"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["departments", "list", params] as const,
|
list: (params?: object) => ["departments", "list", params] as const,
|
||||||
detail: (id: number) => ["departments", id] as const,
|
detail: (id: number) => ["departments", id] as const,
|
||||||
},
|
},
|
||||||
admissionRegisters: {
|
admissionRegisters: {
|
||||||
all: ["admission-registers"] as const,
|
all: ["admission-registers"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["admission-registers", "list", params] as const,
|
list: (params?: object) => ["admission-registers", "list", params] as const,
|
||||||
detail: (id: number) => ["admission-registers", id] as const,
|
detail: (id: number) => ["admission-registers", id] as const,
|
||||||
},
|
},
|
||||||
admissions: {
|
admissions: {
|
||||||
all: ["admissions"] as const,
|
all: ["admissions"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["admissions", "list", params] as const,
|
list: (params?: object) => ["admissions", "list", params] as const,
|
||||||
detail: (id: number) => ["admissions", id] as const,
|
detail: (id: number) => ["admissions", id] as const,
|
||||||
},
|
},
|
||||||
instExamSessions: {
|
instExamSessions: {
|
||||||
all: ["inst-exam-sessions"] as const,
|
all: ["inst-exam-sessions"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["inst-exam-sessions", "list", params] as const,
|
list: (params?: object) => ["inst-exam-sessions", "list", params] as const,
|
||||||
detail: (id: number) => ["inst-exam-sessions", id] as const,
|
detail: (id: number) => ["inst-exam-sessions", id] as const,
|
||||||
},
|
},
|
||||||
examTypes: {
|
examTypes: {
|
||||||
all: ["exam-types"] as const,
|
all: ["exam-types"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["exam-types", "list", params] as const,
|
list: (params?: object) => ["exam-types", "list", params] as const,
|
||||||
},
|
},
|
||||||
gradeConfigs: {
|
gradeConfigs: {
|
||||||
all: ["grade-configs"] as const,
|
all: ["grade-configs"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["grade-configs", "list", params] as const,
|
list: (params?: object) => ["grade-configs", "list", params] as const,
|
||||||
},
|
},
|
||||||
resultTemplates: {
|
resultTemplates: {
|
||||||
all: ["result-templates"] as const,
|
all: ["result-templates"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["result-templates", "list", params] as const,
|
list: (params?: object) => ["result-templates", "list", params] as const,
|
||||||
},
|
},
|
||||||
marksheets: {
|
marksheets: {
|
||||||
all: ["marksheets"] as const,
|
all: ["marksheets"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["marksheets", "list", params] as const,
|
list: (params?: object) => ["marksheets", "list", params] as const,
|
||||||
detail: (id: number) => ["marksheets", id] as const,
|
detail: (id: number) => ["marksheets", id] as const,
|
||||||
},
|
},
|
||||||
courseAssignments: {
|
courseAssignments: {
|
||||||
all: ["course-assignments"] as const,
|
all: ["course-assignments"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["course-assignments", "list", params] as const,
|
list: (params?: object) => ["course-assignments", "list", params] as const,
|
||||||
detail: (id: number) => ["course-assignments", id] as const,
|
detail: (id: number) => ["course-assignments", id] as const,
|
||||||
},
|
},
|
||||||
assignmentTypes: {
|
assignmentTypes: {
|
||||||
all: ["assignment-types"] as const,
|
all: ["assignment-types"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["assignment-types", "list", params] as const,
|
list: (params?: object) => ["assignment-types", "list", params] as const,
|
||||||
},
|
},
|
||||||
subjectRegistrations: {
|
subjectRegistrations: {
|
||||||
all: ["subject-registrations"] as const,
|
all: ["subject-registrations"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["subject-registrations", "list", params] as const,
|
list: (params?: object) => ["subject-registrations", "list", params] as const,
|
||||||
available: (params?: Record<string, unknown>) => ["subject-registrations", "available", params] as const,
|
available: (params?: object) => ["subject-registrations", "available", params] as const,
|
||||||
},
|
},
|
||||||
courseCompletion: (courseId: number) => ["course-completion", courseId] as const,
|
courseCompletion: (courseId: number) => ["course-completion", courseId] as const,
|
||||||
chapters: (courseId: number) => ["chapters", "list", courseId] as const,
|
chapters: (courseId: number) => ["chapters", "list", courseId] as const,
|
||||||
chapter: (id: number) => ["chapters", "detail", id] as const,
|
chapter: (id: number) => ["chapters", "detail", id] as const,
|
||||||
chapterMaterials: (chapterId: number) => ["chapter-materials", chapterId] as const,
|
chapterMaterials: (chapterId: number) => ["chapter-materials", chapterId] as const,
|
||||||
chapterProgress: (chapterId: number) => ["chapter-progress", chapterId] as const,
|
chapterProgress: (chapterId: number) => ["chapter-progress", chapterId] as const,
|
||||||
discussionBoards: (params?: Record<string, unknown>) => ["discussion-boards", params] as const,
|
discussionBoards: (params?: object) => ["discussion-boards", params] as const,
|
||||||
posts: (boardId: number, params?: Record<string, unknown>) => ["posts", boardId, params] as const,
|
posts: (boardId: number, params?: object) => ["posts", boardId, params] as const,
|
||||||
announcements: (params?: Record<string, unknown>) => ["announcements", params] as const,
|
announcements: (params?: object) => ["announcements", params] as const,
|
||||||
messages: (params?: Record<string, unknown>) => ["messages", params] as const,
|
messages: (params?: object) => ["messages", params] as const,
|
||||||
sentMessages: (params?: Record<string, unknown>) => ["messages", "sent", params] as const,
|
sentMessages: (params?: object) => ["messages", "sent", params] as const,
|
||||||
unreadMessages: ["messages", "unread-count"] as const,
|
unreadMessages: ["messages", "unread-count"] as const,
|
||||||
notifications: (params?: Record<string, unknown>) => ["notifications", params] as const,
|
notifications: (params?: object) => ["notifications", params] as const,
|
||||||
unreadNotifications: ["notifications", "unread-count"] as const,
|
unreadNotifications: ["notifications", "unread-count"] as const,
|
||||||
notificationRules: ["notification-rules"] as const,
|
notificationRules: ["notification-rules"] as const,
|
||||||
notificationPreferences: ["notification-preferences"] as const,
|
notificationPreferences: ["notification-preferences"] as const,
|
||||||
faqCategories: (audience?: string) => ["faq", "categories", audience] as const,
|
faqCategories: (audience?: string) => ["faq", "categories", audience] as const,
|
||||||
faqItems: (params?: Record<string, unknown>) => ["faq", "items", params] as const,
|
faqItems: (params?: object) => ["faq", "items", params] as const,
|
||||||
studentLeaves: {
|
studentLeaves: {
|
||||||
all: ["student-leaves"] as const,
|
all: ["student-leaves"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["student-leaves", "list", params] as const,
|
list: (params?: object) => ["student-leaves", "list", params] as const,
|
||||||
},
|
},
|
||||||
studentLeaveTypes: {
|
studentLeaveTypes: {
|
||||||
all: ["student-leave-types"] as const,
|
all: ["student-leave-types"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["student-leave-types", "list", params] as const,
|
list: (params?: object) => ["student-leave-types", "list", params] as const,
|
||||||
},
|
},
|
||||||
feesPlans: {
|
feesPlans: {
|
||||||
all: ["fees-plans"] as const,
|
all: ["fees-plans"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["fees-plans", "list", params] as const,
|
list: (params?: object) => ["fees-plans", "list", params] as const,
|
||||||
detail: (id: number) => ["fees-plans", id] as const,
|
detail: (id: number) => ["fees-plans", id] as const,
|
||||||
},
|
},
|
||||||
studentFees: {
|
studentFees: {
|
||||||
all: ["student-fees"] as const,
|
all: ["student-fees"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["student-fees", "list", params] as const,
|
list: (params?: object) => ["student-fees", "list", params] as const,
|
||||||
},
|
},
|
||||||
feesTerms: {
|
feesTerms: {
|
||||||
all: ["fees-terms"] as const,
|
all: ["fees-terms"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["fees-terms", "list", params] as const,
|
list: (params?: object) => ["fees-terms", "list", params] as const,
|
||||||
},
|
},
|
||||||
lessons: {
|
lessons: {
|
||||||
all: ["lessons"] as const,
|
all: ["lessons"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["lessons", "list", params] as const,
|
list: (params?: object) => ["lessons", "list", params] as const,
|
||||||
},
|
},
|
||||||
gradebooks: {
|
gradebooks: {
|
||||||
all: ["gradebooks"] as const,
|
all: ["gradebooks"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["gradebooks", "list", params] as const,
|
list: (params?: object) => ["gradebooks", "list", params] as const,
|
||||||
},
|
},
|
||||||
gradebookLines: {
|
gradebookLines: {
|
||||||
all: ["gradebook-lines"] as const,
|
all: ["gradebook-lines"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["gradebook-lines", "list", params] as const,
|
list: (params?: object) => ["gradebook-lines", "list", params] as const,
|
||||||
},
|
},
|
||||||
gradingAssignments: {
|
gradingAssignments: {
|
||||||
all: ["grading-assignments"] as const,
|
all: ["grading-assignments"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["grading-assignments", "list", params] as const,
|
list: (params?: object) => ["grading-assignments", "list", params] as const,
|
||||||
},
|
},
|
||||||
studentProgress: {
|
studentProgress: {
|
||||||
all: ["student-progress"] as const,
|
all: ["student-progress"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["student-progress", "list", params] as const,
|
list: (params?: object) => ["student-progress", "list", params] as const,
|
||||||
detail: (id: number) => ["student-progress", "detail", id] as const,
|
detail: (id: number) => ["student-progress", "detail", id] as const,
|
||||||
},
|
},
|
||||||
libraryMedia: {
|
libraryMedia: {
|
||||||
all: ["library-media"] as const,
|
all: ["library-media"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["library-media", "list", params] as const,
|
list: (params?: object) => ["library-media", "list", params] as const,
|
||||||
},
|
},
|
||||||
libraryMovements: {
|
libraryMovements: {
|
||||||
all: ["library-movements"] as const,
|
all: ["library-movements"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["library-movements", "list", params] as const,
|
list: (params?: object) => ["library-movements", "list", params] as const,
|
||||||
},
|
},
|
||||||
libraryCards: {
|
libraryCards: {
|
||||||
all: ["library-cards"] as const,
|
all: ["library-cards"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["library-cards", "list", params] as const,
|
list: (params?: object) => ["library-cards", "list", params] as const,
|
||||||
},
|
},
|
||||||
activities: {
|
activities: {
|
||||||
all: ["activities"] as const,
|
all: ["activities"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["activities", "list", params] as const,
|
list: (params?: object) => ["activities", "list", params] as const,
|
||||||
},
|
},
|
||||||
activityTypes: {
|
activityTypes: {
|
||||||
all: ["activity-types"] as const,
|
all: ["activity-types"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["activity-types", "list", params] as const,
|
list: (params?: object) => ["activity-types", "list", params] as const,
|
||||||
},
|
},
|
||||||
facilities: {
|
facilities: {
|
||||||
all: ["facilities"] as const,
|
all: ["facilities"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["facilities", "list", params] as const,
|
list: (params?: object) => ["facilities", "list", params] as const,
|
||||||
},
|
},
|
||||||
assets: {
|
assets: {
|
||||||
all: ["assets"] as const,
|
all: ["assets"] as const,
|
||||||
list: (params?: Record<string, unknown>) => ["assets", "list", params] as const,
|
list: (params?: object) => ["assets", "list", params] as const,
|
||||||
},
|
},
|
||||||
signup: {
|
signup: {
|
||||||
goals: ["signup", "goals"] as const,
|
goals: ["signup", "goals"] as const,
|
||||||
@@ -258,7 +260,7 @@ export const queryKeys = {
|
|||||||
},
|
},
|
||||||
ieltsExam: {
|
ieltsExam: {
|
||||||
skills: (examId: number) => ["ielts-exam", examId, "skills"] as const,
|
skills: (examId: number) => ["ielts-exam", examId, "skills"] as const,
|
||||||
contentPool: (examId: number, filters?: Record<string, unknown>) => ["ielts-exam", examId, "content-pool", filters] as const,
|
contentPool: (examId: number, filters?: object) => ["ielts-exam", examId, "content-pool", filters] as const,
|
||||||
validation: (examId: number) => ["ielts-exam", examId, "validation"] as const,
|
validation: (examId: number) => ["ielts-exam", examId, "validation"] as const,
|
||||||
},
|
},
|
||||||
examSession: {
|
examSession: {
|
||||||
@@ -266,7 +268,7 @@ export const queryKeys = {
|
|||||||
status: (examId: number) => ["exam-session", examId, "status"] as const,
|
status: (examId: number) => ["exam-session", examId, "status"] as const,
|
||||||
},
|
},
|
||||||
grading: {
|
grading: {
|
||||||
queue: (params?: Record<string, unknown>) => ["grading", "queue", params] as const,
|
queue: (params?: object) => ["grading", "queue", params] as const,
|
||||||
response: (attemptId: number, skill: string) => ["grading", attemptId, "response", skill] as const,
|
response: (attemptId: number, skill: string) => ["grading", attemptId, "response", skill] as const,
|
||||||
rubric: (attemptId: number, skill: string) => ["grading", attemptId, "rubric", skill] as const,
|
rubric: (attemptId: number, skill: string) => ["grading", attemptId, "rubric", skill] as const,
|
||||||
},
|
},
|
||||||
@@ -282,11 +284,11 @@ export const queryKeys = {
|
|||||||
englishTaxonomy: ["ai-course", "english", "taxonomy"] as const,
|
englishTaxonomy: ["ai-course", "english", "taxonomy"] as const,
|
||||||
},
|
},
|
||||||
entityOnboarding: {
|
entityOnboarding: {
|
||||||
credentials: (params?: Record<string, unknown>) => ["entity-onboarding", "credentials", params] as const,
|
credentials: (params?: object) => ["entity-onboarding", "credentials", params] as const,
|
||||||
},
|
},
|
||||||
adaptiveEngine: {
|
adaptiveEngine: {
|
||||||
dashboard: ["adaptive-engine", "dashboard"] as const,
|
dashboard: ["adaptive-engine", "dashboard"] as const,
|
||||||
students: (params?: Record<string, unknown>) => ["adaptive-engine", "students", params] as const,
|
students: (params?: object) => ["adaptive-engine", "students", params] as const,
|
||||||
signals: (studentId: number) => ["adaptive-engine", "signals", studentId] as const,
|
signals: (studentId: number) => ["adaptive-engine", "signals", studentId] as const,
|
||||||
ability: (studentId: number) => ["adaptive-engine", "ability", studentId] as const,
|
ability: (studentId: number) => ["adaptive-engine", "ability", studentId] as const,
|
||||||
settings: ["adaptive-engine", "settings"] as const,
|
settings: ["adaptive-engine", "settings"] as const,
|
||||||
@@ -299,7 +301,7 @@ export const queryKeys = {
|
|||||||
current: ["branding", "current"] as const,
|
current: ["branding", "current"] as const,
|
||||||
},
|
},
|
||||||
scoreRelease: {
|
scoreRelease: {
|
||||||
pending: (params?: Record<string, unknown>) => ["score-release", "pending", params] as const,
|
pending: (params?: object) => ["score-release", "pending", params] as const,
|
||||||
},
|
},
|
||||||
verification: {
|
verification: {
|
||||||
verify: (hash: string) => ["verification", hash] as const,
|
verify: (hash: string) => ["verification", hash] as const,
|
||||||
|
|||||||
70
frontend/src/hooks/queries/useAIFeedback.ts
Normal file
70
frontend/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
frontend/src/hooks/queries/useAIPrompts.ts
Normal file
52
frontend/src/hooks/queries/useAIPrompts.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { aiPromptService } from "@/services/ai-prompt.service";
|
||||||
|
import type { AIPromptCreateInput } from "@/types/ai-prompt";
|
||||||
|
|
||||||
|
const KEY_ROOT = ["ai", "prompts"] as const;
|
||||||
|
|
||||||
|
export function useAIPromptKeys(params: { search?: string; page?: number; size?: number } = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [...KEY_ROOT, "keys", params] as const,
|
||||||
|
queryFn: () => aiPromptService.listKeys(params),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAIPromptVersions(key: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
enabled: !!key,
|
||||||
|
queryKey: [...KEY_ROOT, "versions", key] as const,
|
||||||
|
queryFn: () => aiPromptService.listVersions(key as string),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAIPrompt(id: number | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
enabled: !!id,
|
||||||
|
queryKey: [...KEY_ROOT, "detail", id] as const,
|
||||||
|
queryFn: () => aiPromptService.get(id as number),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateAIPrompt() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: AIPromptCreateInput) => aiPromptService.create(input),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActivateAIPrompt() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => aiPromptService.activate(id),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRenderAIPrompt() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, variables }: { id: number; variables: Record<string, string> }) =>
|
||||||
|
aiPromptService.render(id, variables),
|
||||||
|
});
|
||||||
|
}
|
||||||
47
frontend/src/hooks/queries/useExamReview.ts
Normal file
47
frontend/src/hooks/queries/useExamReview.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import {
|
||||||
|
examReviewService,
|
||||||
|
type ExamReviewQueueParams,
|
||||||
|
} from "@/services/exam-review.service";
|
||||||
|
|
||||||
|
import { queryKeys } from "./keys";
|
||||||
|
|
||||||
|
export function useExamReviewQueue(params: ExamReviewQueueParams = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.exams.reviewQueue(params),
|
||||||
|
queryFn: () => examReviewService.queue(params),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExamReviewDetail(examId: number | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
enabled: !!examId,
|
||||||
|
queryKey: queryKeys.exams.reviewDetail(examId ?? -1),
|
||||||
|
queryFn: () => examReviewService.detail(examId as number),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApproveExamReview() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ examId, notes }: { examId: number; notes?: string }) =>
|
||||||
|
examReviewService.approve(examId, notes),
|
||||||
|
onSuccess: (_data, { examId }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["exams", "review"] });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.exams.reviewDetail(examId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRejectExamReview() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ examId, notes }: { examId: number; notes: string }) =>
|
||||||
|
examReviewService.reject(examId, notes),
|
||||||
|
onSuccess: (_data, { examId }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["exams", "review"] });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.exams.reviewDetail(examId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ export function useIeltsAutoAssemble() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useIeltsValidation(examId: number) {
|
export function useIeltsExamValidation(examId: number) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.ieltsExam.validation(examId),
|
queryKey: queryKeys.ieltsExam.validation(examId),
|
||||||
queryFn: () => ieltsExamService.validate(examId),
|
queryFn: () => ieltsExamService.validate(examId),
|
||||||
|
|||||||
67
frontend/src/i18n/index.ts
Normal file
67
frontend/src/i18n/index.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* 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 detection order:
|
||||||
|
* 1. localStorage key ``encoach-lang`` (explicit user pick)
|
||||||
|
* 2. ``navigator.language``
|
||||||
|
* 3. fallback ``en``
|
||||||
|
*
|
||||||
|
* 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"];
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(LanguageDetector)
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
resources: {
|
||||||
|
en: { translation: en },
|
||||||
|
ar: { translation: ar },
|
||||||
|
},
|
||||||
|
fallbackLng: "en",
|
||||||
|
supportedLngs: SUPPORTED_LANGS.map((l) => l.code),
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
detection: {
|
||||||
|
order: ["localStorage", "navigator", "htmlTag"],
|
||||||
|
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;
|
||||||
85
frontend/src/i18n/locales/ar.ts
Normal file
85
frontend/src/i18n/locales/ar.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import type { Translations } from "./en";
|
||||||
|
|
||||||
|
/** Arabic (RTL) translations. */
|
||||||
|
const ar: Translations = {
|
||||||
|
common: {
|
||||||
|
cancel: "إلغاء",
|
||||||
|
save: "حفظ",
|
||||||
|
delete: "حذف",
|
||||||
|
edit: "تعديل",
|
||||||
|
close: "إغلاق",
|
||||||
|
back: "رجوع",
|
||||||
|
next: "التالي",
|
||||||
|
search: "بحث",
|
||||||
|
loading: "جارٍ التحميل…",
|
||||||
|
error: "خطأ",
|
||||||
|
retry: "إعادة المحاولة",
|
||||||
|
yes: "نعم",
|
||||||
|
no: "لا",
|
||||||
|
actions: "إجراءات",
|
||||||
|
status: "الحالة",
|
||||||
|
settings: "الإعدادات",
|
||||||
|
signOut: "تسجيل الخروج",
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
signIn: "تسجيل الدخول",
|
||||||
|
signInTitle: "تسجيل الدخول إلى EnCoach",
|
||||||
|
email: "البريد الإلكتروني",
|
||||||
|
password: "كلمة المرور",
|
||||||
|
forgotPassword: "هل نسيت كلمة المرور؟",
|
||||||
|
needAccount: "ليس لديك حساب؟",
|
||||||
|
signUp: "إنشاء حساب",
|
||||||
|
invalidCredentials: "البريد الإلكتروني أو كلمة المرور غير صحيحة",
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
dashboard: "لوحة التحكم",
|
||||||
|
courses: "الدورات",
|
||||||
|
students: "الطلاب",
|
||||||
|
teachers: "المعلمون",
|
||||||
|
exams: "الاختبارات",
|
||||||
|
reports: "التقارير",
|
||||||
|
settings: "الإعدادات",
|
||||||
|
profile: "الملف الشخصي",
|
||||||
|
privacy: "مركز الخصوصية",
|
||||||
|
reviewQueue: "قائمة المراجعة",
|
||||||
|
aiPrompts: "تعليمات الذكاء الاصطناعي",
|
||||||
|
aiFeedback: "ملاحظات الذكاء الاصطناعي",
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
title: "مركز الخصوصية",
|
||||||
|
description:
|
||||||
|
"إدارة بياناتك الشخصية لدى EnCoach وفقاً للائحة حماية البيانات العامة وما يماثلها.",
|
||||||
|
exportTitle: "تنزيل بياناتك",
|
||||||
|
exportDescription:
|
||||||
|
"سنقوم بتجميع ملفك الشخصي، عضويات الكيانات، محاولات الاختبارات، الإجابات، ملاحظات الذكاء الاصطناعي، والتذاكر في ملف JSON واحد.",
|
||||||
|
exportButton: "تنزيل بياناتي",
|
||||||
|
exportPreparing: "جارٍ تحضير التصدير…",
|
||||||
|
exportSuccess: "تم تنزيل البيانات بنجاح",
|
||||||
|
deleteTitle: "حذف حسابي",
|
||||||
|
deleteDescription:
|
||||||
|
"سيتم إخفاء هوية ملفك الشخصي وإزالة الحقول الشخصية من سجلاتنا. سيتم الاحتفاظ بمحاولات الاختبارات (دون معلومات تعريفية) لسلامة الإحصائيات. لا يمكن التراجع عن هذا الإجراء.",
|
||||||
|
deleteButton: "محو حسابي",
|
||||||
|
deleteErasing: "جارٍ المحو…",
|
||||||
|
confirmTitle: "هل تريد محو حسابك؟",
|
||||||
|
confirmDescription:
|
||||||
|
"سنقوم بإخفاء هوية بياناتك الشخصية وتعطيل تسجيل الدخول. ستبقى التحليلات المجمعة لكنها لن تكون مرتبطة بك.",
|
||||||
|
confirmTypeDelete: "اكتب DELETE للتأكيد",
|
||||||
|
confirmErased: "تم محو حسابك. جارٍ تسجيل الخروج…",
|
||||||
|
},
|
||||||
|
feedback: {
|
||||||
|
thumbsUp: "إعجاب",
|
||||||
|
thumbsDown: "عدم إعجاب",
|
||||||
|
whatWentWrong: "ما الخطأ الذي حدث؟",
|
||||||
|
commentPlaceholder: "مثال: إجابة خاطئة، صياغة مربكة، خارج الموضوع…",
|
||||||
|
commentRequired: "من فضلك أخبرنا بالخطأ.",
|
||||||
|
submit: "إرسال الملاحظات",
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
title: "المظهر",
|
||||||
|
light: "فاتح",
|
||||||
|
dark: "داكن",
|
||||||
|
system: "النظام",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ar;
|
||||||
97
frontend/src/i18n/locales/en.ts
Normal file
97
frontend/src/i18n/locales/en.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/** English translations. Keep keys nested by feature area.
|
||||||
|
*
|
||||||
|
* We deliberately annotate the literal with `Translations` (not `as const`)
|
||||||
|
* so sibling locale files can widen their string values without fighting
|
||||||
|
* literal-string type mismatches.
|
||||||
|
*/
|
||||||
|
export interface Translations {
|
||||||
|
common: Record<string, string>;
|
||||||
|
auth: Record<string, string>;
|
||||||
|
nav: Record<string, string>;
|
||||||
|
privacy: Record<string, string>;
|
||||||
|
feedback: Record<string, string>;
|
||||||
|
theme: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const en: Translations = {
|
||||||
|
common: {
|
||||||
|
cancel: "Cancel",
|
||||||
|
save: "Save",
|
||||||
|
delete: "Delete",
|
||||||
|
edit: "Edit",
|
||||||
|
close: "Close",
|
||||||
|
back: "Back",
|
||||||
|
next: "Next",
|
||||||
|
search: "Search",
|
||||||
|
loading: "Loading…",
|
||||||
|
error: "Error",
|
||||||
|
retry: "Retry",
|
||||||
|
yes: "Yes",
|
||||||
|
no: "No",
|
||||||
|
actions: "Actions",
|
||||||
|
status: "Status",
|
||||||
|
settings: "Settings",
|
||||||
|
signOut: "Sign out",
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
signIn: "Sign in",
|
||||||
|
signInTitle: "Sign in to EnCoach",
|
||||||
|
email: "Email",
|
||||||
|
password: "Password",
|
||||||
|
forgotPassword: "Forgot password?",
|
||||||
|
needAccount: "Don't have an account?",
|
||||||
|
signUp: "Sign up",
|
||||||
|
invalidCredentials: "Invalid email or password",
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
dashboard: "Dashboard",
|
||||||
|
courses: "Courses",
|
||||||
|
students: "Students",
|
||||||
|
teachers: "Teachers",
|
||||||
|
exams: "Exams",
|
||||||
|
reports: "Reports",
|
||||||
|
settings: "Settings",
|
||||||
|
profile: "Profile",
|
||||||
|
privacy: "Privacy Center",
|
||||||
|
reviewQueue: "Review Queue",
|
||||||
|
aiPrompts: "AI Prompts",
|
||||||
|
aiFeedback: "AI Feedback",
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
title: "Privacy Center",
|
||||||
|
description:
|
||||||
|
"Manage your personal data held by EnCoach under GDPR and equivalent regulations.",
|
||||||
|
exportTitle: "Download your data",
|
||||||
|
exportDescription:
|
||||||
|
"We'll package your profile, entity memberships, exam attempts, answers, AI feedback, and tickets into a single JSON file.",
|
||||||
|
exportButton: "Download my data",
|
||||||
|
exportPreparing: "Preparing export…",
|
||||||
|
exportSuccess: "Data export downloaded",
|
||||||
|
deleteTitle: "Delete my account",
|
||||||
|
deleteDescription:
|
||||||
|
"This anonymises your profile and removes personal fields from our records. Exam attempts are retained (without identifying information) for statistical integrity. This action cannot be undone.",
|
||||||
|
deleteButton: "Erase my account",
|
||||||
|
deleteErasing: "Erasing…",
|
||||||
|
confirmTitle: "Erase your account?",
|
||||||
|
confirmDescription:
|
||||||
|
"We'll anonymise your personal data and deactivate your login. Aggregate analytics will remain but will no longer be linked to you.",
|
||||||
|
confirmTypeDelete: "Type DELETE to confirm",
|
||||||
|
confirmErased: "Your account has been erased. Signing out…",
|
||||||
|
},
|
||||||
|
feedback: {
|
||||||
|
thumbsUp: "Thumbs up",
|
||||||
|
thumbsDown: "Thumbs down",
|
||||||
|
whatWentWrong: "What went wrong?",
|
||||||
|
commentPlaceholder: "e.g. Wrong answer, confusing wording, off-topic…",
|
||||||
|
commentRequired: "Please tell us what was wrong.",
|
||||||
|
submit: "Submit feedback",
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
title: "Theme",
|
||||||
|
light: "Light",
|
||||||
|
dark: "Dark",
|
||||||
|
system: "System",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default en;
|
||||||
@@ -51,6 +51,14 @@
|
|||||||
--sidebar-accent-foreground: 8 40% 78%;
|
--sidebar-accent-foreground: 8 40% 78%;
|
||||||
--sidebar-border: 240 18% 20%;
|
--sidebar-border: 240 18% 20%;
|
||||||
--sidebar-ring: 8 50% 58%;
|
--sidebar-ring: 8 50% 58%;
|
||||||
|
|
||||||
|
/* Chart palette — used by Recharts via hsl(var(--chart-N)). Kept on the
|
||||||
|
warm/cool axis so pairs read well together when stacked. */
|
||||||
|
--chart-1: 8 50% 54%;
|
||||||
|
--chart-2: 220 70% 52%;
|
||||||
|
--chart-3: 152 60% 42%;
|
||||||
|
--chart-4: 38 92% 50%;
|
||||||
|
--chart-5: 280 55% 55%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -99,6 +107,12 @@
|
|||||||
--sidebar-accent-foreground: 8 40% 75%;
|
--sidebar-accent-foreground: 8 40% 75%;
|
||||||
--sidebar-border: 240 18% 13%;
|
--sidebar-border: 240 18% 13%;
|
||||||
--sidebar-ring: 8 50% 55%;
|
--sidebar-ring: 8 50% 55%;
|
||||||
|
|
||||||
|
--chart-1: 8 52% 62%;
|
||||||
|
--chart-2: 220 65% 65%;
|
||||||
|
--chart-3: 152 50% 55%;
|
||||||
|
--chart-4: 38 75% 60%;
|
||||||
|
--chart-5: 280 55% 68%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
/** Base path or absolute URL for Odoo JSON API (dev: `/api` + Vite proxy). */
|
/**
|
||||||
|
* Odoo REST client with transparent access-token rotation.
|
||||||
|
*
|
||||||
|
* Tokens live in ``localStorage`` under three keys:
|
||||||
|
* - ``encoach_token`` — short-lived access JWT (1h)
|
||||||
|
* - ``encoach_refresh_token`` — long-lived refresh JWT (7d)
|
||||||
|
* - ``encoach_token_exp`` — epoch seconds for the access token
|
||||||
|
*
|
||||||
|
* When a request receives ``401`` *and* a refresh token is present, the client
|
||||||
|
* silently rotates the pair via ``POST /api/auth/refresh`` and retries the
|
||||||
|
* original request exactly once. Multiple concurrent 401s coalesce onto the
|
||||||
|
* same refresh promise so we never fire more than one rotation in flight.
|
||||||
|
*
|
||||||
|
* Motivation: before this change, every expired access token forced a full
|
||||||
|
* re-login, which interrupted long admin dashboards (stats, reports) multiple
|
||||||
|
* times per hour. With the refresh loop, access tokens can be short-lived
|
||||||
|
* (1h) without hurting UX, giving us the security benefit of short access
|
||||||
|
* windows without an eager logout.
|
||||||
|
*/
|
||||||
|
|
||||||
export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL?.trim() || "/api").replace(/\/$/, "");
|
export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL?.trim() || "/api").replace(/\/$/, "");
|
||||||
const BASE_URL = API_BASE_URL;
|
const BASE_URL = API_BASE_URL;
|
||||||
|
|
||||||
@@ -21,120 +40,229 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getToken(): string | null {
|
const ACCESS_KEY = "encoach_token";
|
||||||
return localStorage.getItem("encoach_token");
|
const REFRESH_KEY = "encoach_refresh_token";
|
||||||
|
const EXP_KEY = "encoach_token_exp";
|
||||||
|
|
||||||
|
function getAccessToken(): string | null {
|
||||||
|
return localStorage.getItem(ACCESS_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRefreshToken(): string | null {
|
||||||
|
return localStorage.getItem(REFRESH_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setToken(token: string): void {
|
export function setToken(token: string): void {
|
||||||
localStorage.setItem("encoach_token", token);
|
localStorage.setItem(ACCESS_KEY, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRefreshToken(token: string | null | undefined): void {
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem(REFRESH_KEY, token);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(REFRESH_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTokenExpiry(epochSeconds: number | null | undefined): void {
|
||||||
|
if (epochSeconds && Number.isFinite(epochSeconds)) {
|
||||||
|
localStorage.setItem(EXP_KEY, String(Math.floor(epochSeconds)));
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(EXP_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the full token bundle returned by /api/login or /api/auth/refresh. */
|
||||||
|
export function persistTokenBundle(bundle: {
|
||||||
|
access_token?: string;
|
||||||
|
token?: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
}): void {
|
||||||
|
const access = bundle.access_token || bundle.token;
|
||||||
|
if (access) setToken(access);
|
||||||
|
setRefreshToken(bundle.refresh_token || null);
|
||||||
|
if (bundle.expires_in) {
|
||||||
|
setTokenExpiry(Math.floor(Date.now() / 1000) + bundle.expires_in);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearToken(): void {
|
export function clearToken(): void {
|
||||||
localStorage.removeItem("encoach_token");
|
localStorage.removeItem(ACCESS_KEY);
|
||||||
|
localStorage.removeItem(REFRESH_KEY);
|
||||||
|
localStorage.removeItem(EXP_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleResponse<T>(response: Response): Promise<T> {
|
// Shared refresh promise — any 401 that arrives while a refresh is in flight
|
||||||
const data = await response.json().catch(() => null);
|
// waits on the existing request instead of firing a duplicate.
|
||||||
|
let refreshPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
if (response.status === 401) {
|
async function performRefresh(): Promise<boolean> {
|
||||||
const hadToken = !!getToken();
|
const refreshToken = getRefreshToken();
|
||||||
clearToken();
|
if (!refreshToken) return false;
|
||||||
// Login failure is also 401 — do not hard-redirect when no session existed.
|
try {
|
||||||
if (hadToken) {
|
const res = await fetch(`${BASE_URL}/auth/refresh`, {
|
||||||
window.location.href = "/login";
|
method: "POST",
|
||||||
}
|
headers: { "Content-Type": "application/json" },
|
||||||
throw new ApiError(401, response.statusText, data);
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const data: {
|
||||||
|
access_token?: string;
|
||||||
|
token?: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
} = await res.json().catch(() => ({} as never));
|
||||||
|
if (!data.access_token && !data.token) return false;
|
||||||
|
persistTokenBundle(data);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new ApiError(response.status, response.statusText, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data as T;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHeaders(extra?: Record<string, string>): Record<string, string> {
|
function refreshOnce(): Promise<boolean> {
|
||||||
const headers: Record<string, string> = {
|
if (!refreshPromise) {
|
||||||
"Content-Type": "application/json",
|
refreshPromise = performRefresh().finally(() => {
|
||||||
...extra,
|
refreshPromise = null;
|
||||||
};
|
});
|
||||||
|
|
||||||
const token = getToken();
|
|
||||||
if (token) {
|
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
|
||||||
}
|
}
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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}`;
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
|
/**
|
||||||
|
* Query param values we know how to serialise into a URL. Arrays are joined
|
||||||
|
* with commas because that's what our Odoo controllers expect for multi-value
|
||||||
|
* filters (e.g. `?state=draft,confirmed`).
|
||||||
|
*/
|
||||||
|
export type QueryParamValue =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
| Array<string | number | boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept any object-shaped bag of query params. We intentionally use `object`
|
||||||
|
* here rather than `Record<string, QueryParamValue>` because typed interfaces
|
||||||
|
* (e.g. `PaginationParams`) don't satisfy a `Record<...>` index signature by
|
||||||
|
* default, which would force every call-site to cast.
|
||||||
|
*/
|
||||||
|
export type QueryParams = object;
|
||||||
|
|
||||||
|
function buildUrl(path: string, params?: QueryParams): string {
|
||||||
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
|
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
|
||||||
if (params) {
|
if (params) {
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
for (const [key, rawValue] of Object.entries(params as Record<string, unknown>)) {
|
||||||
if (value !== undefined) {
|
if (rawValue === undefined || rawValue === null) continue;
|
||||||
url.searchParams.set(key, String(value));
|
if (Array.isArray(rawValue)) {
|
||||||
|
if (rawValue.length === 0) continue;
|
||||||
|
url.searchParams.set(key, rawValue.map(v => String(v)).join(","));
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
});
|
url.searchParams.set(key, String(rawValue));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function parseResponse<T>(response: Response): Promise<T> {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) throw new ApiError(response.status, response.statusText, data);
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestInitWithSkip = RequestInit & { _skipRetry?: boolean };
|
||||||
|
|
||||||
|
async function performRequest<T>(url: string, init: RequestInitWithSkip): Promise<T> {
|
||||||
|
const response = await fetch(url, init);
|
||||||
|
|
||||||
|
// Auth/refresh endpoints opt out of the retry loop — otherwise a bad
|
||||||
|
// refresh token would recurse forever.
|
||||||
|
const isAuthEndpoint = url.includes("/auth/refresh") || url.includes("/login");
|
||||||
|
|
||||||
|
if (response.status === 401 && !init._skipRetry && !isAuthEndpoint) {
|
||||||
|
const hadRefresh = !!getRefreshToken();
|
||||||
|
if (hadRefresh) {
|
||||||
|
const rotated = await refreshOnce();
|
||||||
|
if (rotated) {
|
||||||
|
// Rebuild headers so the new access token is attached.
|
||||||
|
const newInit: RequestInitWithSkip = {
|
||||||
|
...init,
|
||||||
|
_skipRetry: true,
|
||||||
|
headers: {
|
||||||
|
...(init.headers as Record<string, string>),
|
||||||
|
Authorization: `Bearer ${getAccessToken()}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return performRequest<T>(url, newInit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hadAccess = !!getAccessToken();
|
||||||
|
clearToken();
|
||||||
|
if (hadAccess || hadRefresh) {
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
throw new ApiError(401, response.statusText, await response.json().catch(() => null));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseResponse<T>(response);
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
async get<T>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
|
async get<T>(path: string, params?: QueryParams): Promise<T> {
|
||||||
const res = await fetch(buildUrl(path, params), {
|
return performRequest<T>(buildUrl(path, params), {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: buildHeaders(),
|
headers: buildHeaders(),
|
||||||
});
|
});
|
||||||
return handleResponse<T>(res);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async post<T>(path: string, body?: unknown): Promise<T> {
|
async post<T>(path: string, body?: unknown): Promise<T> {
|
||||||
const res = await fetch(buildUrl(path), {
|
return performRequest<T>(buildUrl(path), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildHeaders(),
|
headers: buildHeaders(),
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
return handleResponse<T>(res);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async patch<T>(path: string, body?: unknown): Promise<T> {
|
async patch<T>(path: string, body?: unknown): Promise<T> {
|
||||||
const res = await fetch(buildUrl(path), {
|
return performRequest<T>(buildUrl(path), {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: buildHeaders(),
|
headers: buildHeaders(),
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
return handleResponse<T>(res);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async put<T>(path: string, body?: unknown): Promise<T> {
|
async put<T>(path: string, body?: unknown): Promise<T> {
|
||||||
const res = await fetch(buildUrl(path), {
|
return performRequest<T>(buildUrl(path), {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: buildHeaders(),
|
headers: buildHeaders(),
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
return handleResponse<T>(res);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete<T>(path: string): Promise<T> {
|
async delete<T>(path: string): Promise<T> {
|
||||||
const res = await fetch(buildUrl(path), {
|
return performRequest<T>(buildUrl(path), {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: buildHeaders(),
|
headers: buildHeaders(),
|
||||||
});
|
});
|
||||||
return handleResponse<T>(res);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async upload<T>(path: string, formData: FormData): Promise<T> {
|
async upload<T>(path: string, formData: FormData): Promise<T> {
|
||||||
const token = getToken();
|
return performRequest<T>(buildUrl(path), {
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
if (token) {
|
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(buildUrl(path), {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers: buildHeaders(undefined, false),
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
return handleResponse<T>(res);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
import "./i18n";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(<App />);
|
createRoot(document.getElementById("root")!).render(<App />);
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ interface PlatformStats {
|
|||||||
export default function AdminDashboard() {
|
export default function AdminDashboard() {
|
||||||
const { data: stats } = useQuery<PlatformStats>({
|
const { data: stats } = useQuery<PlatformStats>({
|
||||||
queryKey: ["platform", "stats"],
|
queryKey: ["platform", "stats"],
|
||||||
queryFn: async () => {
|
queryFn: async (): Promise<PlatformStats> => {
|
||||||
const res = await api.get<{ data: PlatformStats }>("/stats");
|
const res = await api.get<{ data: PlatformStats } | PlatformStats>("/stats");
|
||||||
return (res as { data: PlatformStats }).data ?? res;
|
const wrapped = res as { data?: PlatformStats };
|
||||||
|
return wrapped.data ?? (res as PlatformStats);
|
||||||
},
|
},
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,11 +66,11 @@ export default function EntitiesPage() {
|
|||||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const raw = entitiesQ.data;
|
const raw = entitiesQ.data as unknown;
|
||||||
const entities: { id: number; name: string; code: string; type: string; user_count: number; role_count: number; active: boolean }[] = (() => {
|
const entities: { id: number; name: string; code: string; type: string; user_count: number; role_count: number; active: boolean }[] = (() => {
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
if (Array.isArray(raw)) return raw as never[];
|
if (Array.isArray(raw)) return raw as never[];
|
||||||
const r = raw as Record<string, unknown>;
|
const r = raw as { items?: unknown; data?: unknown };
|
||||||
const arr = (r.items ?? r.data ?? []) as never[];
|
const arr = (r.items ?? r.data ?? []) as never[];
|
||||||
return Array.isArray(arr) ? arr : [];
|
return Array.isArray(arr) ? arr : [];
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { GraduationCap, Play, Sparkles } from "lucide-react";
|
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
|
||||||
|
|
||||||
export default function ExamPage() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center min-h-[70vh] gap-4 max-w-md mx-auto">
|
|
||||||
<AiTipBanner context="exam" variant="recommendation" />
|
|
||||||
|
|
||||||
<Card className="border-0 shadow-sm w-full">
|
|
||||||
<CardContent className="p-8 text-center space-y-6">
|
|
||||||
<div className="mx-auto h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
|
|
||||||
<GraduationCap className="h-8 w-8 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-bold mb-1">Ready to Start?</h2>
|
|
||||||
<p className="text-muted-foreground text-sm">IELTS Academic Mock Exam</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Student</span>
|
|
||||||
<span className="font-medium">Sarah Johnson</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Level</span>
|
|
||||||
<Badge variant="outline">B2</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Modules</span>
|
|
||||||
<span className="font-medium">4</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Duration</span>
|
|
||||||
<span className="font-medium">180 min</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Mode</span>
|
|
||||||
<Badge variant="secondary">Practice</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-primary/5 border border-primary/20 p-3 text-left">
|
|
||||||
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-1"><Sparkles className="h-3 w-3" /> AI Pre-Exam Tip</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Your last mock scored 7.5. To target 8.0, focus on time management in Writing Task 2 — you spent 45 min last time vs recommended 40 min.</p>
|
|
||||||
</div>
|
|
||||||
<Button className="w-full" size="lg">
|
|
||||||
<Play className="h-4 w-4 mr-2" /> Begin Exam
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -37,12 +37,14 @@ export default function ExamsListPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const sessionsQ = useInstitutionalExamSessions();
|
const sessionsQ = useInstitutionalExamSessions();
|
||||||
const sessionItems = sessionsQ.data?.data ?? sessionsQ.data?.items ?? [];
|
const sessionItems = sessionsQ.data?.items ?? [];
|
||||||
const sessions = Array.isArray(sessionItems) ? sessionItems : [];
|
const sessions: Record<string, string>[] = Array.isArray(sessionItems)
|
||||||
|
? (sessionItems as unknown as Record<string, string>[])
|
||||||
|
: [];
|
||||||
|
|
||||||
const customExams = customQ.data?.items ?? [];
|
const customExams = customQ.data?.items ?? [];
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
const filteredSessions = sessions.filter((s: Record<string, string>) =>
|
const filteredSessions = sessions.filter((s) =>
|
||||||
s.name?.toLowerCase().includes(q) || s.course_name?.toLowerCase().includes(q),
|
s.name?.toLowerCase().includes(q) || s.course_name?.toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -140,7 +142,7 @@ export default function ExamsListPage() {
|
|||||||
{filteredSessions.length === 0 && (
|
{filteredSessions.length === 0 && (
|
||||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No exam sessions found.</TableCell></TableRow>
|
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No exam sessions found.</TableCell></TableRow>
|
||||||
)}
|
)}
|
||||||
{filteredSessions.map((s: Record<string, string>, i: number) => (
|
{filteredSessions.map((s, i) => (
|
||||||
<TableRow key={s.id}>
|
<TableRow key={s.id}>
|
||||||
<TableCell>{i + 1}</TableCell>
|
<TableCell>{i + 1}</TableCell>
|
||||||
<TableCell className="font-medium">{s.name}</TableCell>
|
<TableCell className="font-medium">{s.name}</TableCell>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ import { mediaService, type Avatar } from "@/services/media.service";
|
|||||||
import { examsService } from "@/services/exams.service";
|
import { examsService } from "@/services/exams.service";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type { ExamStructureConfig } from "@/types";
|
import type { ExamStructure, ExamStructureConfig } from "@/types";
|
||||||
|
|
||||||
type ModuleKey = "reading" | "listening" | "writing" | "speaking" | "level" | "industry";
|
type ModuleKey = "reading" | "listening" | "writing" | "speaking" | "level" | "industry";
|
||||||
|
|
||||||
@@ -398,7 +398,7 @@ export default function GenerationPage() {
|
|||||||
|
|
||||||
const createStructureMut = useMutation({
|
const createStructureMut = useMutation({
|
||||||
mutationFn: (data: { name: string; modules: string[] }) =>
|
mutationFn: (data: { name: string; modules: string[] }) =>
|
||||||
examsService.createStructure(data),
|
examsService.createStructure(data as unknown as Partial<ExamStructure>),
|
||||||
onSuccess: (created) => {
|
onSuccess: (created) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["exam-structures"] });
|
queryClient.invalidateQueries({ queryKey: ["exam-structures"] });
|
||||||
setExamStructure(String(created.id));
|
setExamStructure(String(created.id));
|
||||||
@@ -693,7 +693,7 @@ export default function GenerationPage() {
|
|||||||
try {
|
try {
|
||||||
const status = await mediaService.getVideoStatus(videoId);
|
const status = await mediaService.getVideoStatus(videoId);
|
||||||
if (status.status === "done" || status.status === "completed" || status.video_url) {
|
if (status.status === "done" || status.status === "completed" || status.video_url) {
|
||||||
updatedParts[pp.index] = { ...part, videoUrl: status.video_url || status.url || "" };
|
updatedParts[pp.index] = { ...part, videoUrl: status.video_url || "" };
|
||||||
changed = true;
|
changed = true;
|
||||||
toast({ title: "Video ready!", description: "Avatar video has been generated." });
|
toast({ title: "Video ready!", description: "Avatar video has been generated." });
|
||||||
} else if (status.status === "error" || status.status === "failed") {
|
} else if (status.status === "error" || status.status === "failed") {
|
||||||
@@ -726,7 +726,7 @@ export default function GenerationPage() {
|
|||||||
totalMarks: st.totalMarks,
|
totalMarks: st.totalMarks,
|
||||||
passages: mod === "reading" ? st.passages.map((p) => ({
|
passages: mod === "reading" ? st.passages.map((p) => ({
|
||||||
text: p.text, category: p.category, type: p.type,
|
text: p.text, category: p.category, type: p.type,
|
||||||
exercises: p.exercises?.map((ex: Record<string, unknown>) => ({
|
exercises: p.exercises?.map((ex) => ({
|
||||||
type: ex.type, prompt: ex.prompt, options: ex.options,
|
type: ex.type, prompt: ex.prompt, options: ex.options,
|
||||||
correct_answer: ex.correct_answer, explanation: ex.explanation,
|
correct_answer: ex.correct_answer, explanation: ex.explanation,
|
||||||
instructions: ex.instructions, marks: ex.marks || 1,
|
instructions: ex.instructions, marks: ex.marks || 1,
|
||||||
@@ -735,7 +735,7 @@ export default function GenerationPage() {
|
|||||||
})) : undefined,
|
})) : undefined,
|
||||||
sections: mod === "listening" ? st.listeningSections.map((s) => ({
|
sections: mod === "listening" ? st.listeningSections.map((s) => ({
|
||||||
type: s.type, context: s.context, audioUrl: s.audioUrl,
|
type: s.type, context: s.context, audioUrl: s.audioUrl,
|
||||||
exercises: s.exercises?.map((ex: Record<string, unknown>) => ({
|
exercises: s.exercises?.map((ex) => ({
|
||||||
type: ex.type, prompt: ex.prompt, options: ex.options,
|
type: ex.type, prompt: ex.prompt, options: ex.options,
|
||||||
correct_answer: ex.correct_answer, explanation: ex.explanation,
|
correct_answer: ex.correct_answer, explanation: ex.explanation,
|
||||||
instructions: ex.instructions, marks: ex.marks || 1,
|
instructions: ex.instructions, marks: ex.marks || 1,
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
// Update this page (the content is just a fallback if you fail to update the page)
|
|
||||||
|
|
||||||
const Index = () => {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
|
||||||
<div className="text-center">
|
|
||||||
<h1 className="mb-4 text-4xl font-bold">Welcome to Your Blank App</h1>
|
|
||||||
<p className="text-xl text-muted-foreground">Start building your amazing project here!</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Index;
|
|
||||||
173
frontend/src/pages/PrivacyCenter.tsx
Normal file
173
frontend/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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -165,7 +165,7 @@ export default function RubricsPage() {
|
|||||||
queryKey: ["rubrics"],
|
queryKey: ["rubrics"],
|
||||||
queryFn: () => examsService.listRubrics({}),
|
queryFn: () => examsService.listRubrics({}),
|
||||||
});
|
});
|
||||||
const rubrics = (rubricsQ.data?.items ?? []) as RubricItem[];
|
const rubrics = (rubricsQ.data?.items ?? []) as unknown as RubricItem[];
|
||||||
|
|
||||||
const rubricGroupsQ = useQuery({
|
const rubricGroupsQ = useQuery({
|
||||||
queryKey: ["rubric-groups"],
|
queryKey: ["rubric-groups"],
|
||||||
|
|||||||
348
frontend/src/pages/admin/AIFeedbackTriage.tsx
Normal file
348
frontend/src/pages/admin/AIFeedbackTriage.tsx
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { CheckCircle2, Filter, MessageSquare, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
useAIFeedbackList,
|
||||||
|
useResolveAIFeedback,
|
||||||
|
} from "@/hooks/queries/useAIFeedback";
|
||||||
|
import type {
|
||||||
|
AIFeedback,
|
||||||
|
AIFeedbackStatus,
|
||||||
|
AIFeedbackSubjectType,
|
||||||
|
} from "@/types/ai-feedback";
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<AIFeedbackStatus, string> = {
|
||||||
|
open: "bg-amber-100 text-amber-900 border-amber-300",
|
||||||
|
acknowledged: "bg-sky-100 text-sky-900 border-sky-300",
|
||||||
|
fixed: "bg-emerald-100 text-emerald-900 border-emerald-300",
|
||||||
|
dismissed: "bg-slate-100 text-slate-700 border-slate-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolveChoice = Exclude<AIFeedbackStatus, "open">;
|
||||||
|
|
||||||
|
function ResolveDialog({
|
||||||
|
feedback,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
feedback: AIFeedback | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const resolve = useResolveAIFeedback();
|
||||||
|
const [status, setStatus] = useState<ResolveChoice>("acknowledged");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
|
const open = !!feedback;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => (!v ? onClose() : null)}>
|
||||||
|
<DialogContent
|
||||||
|
className="max-w-lg"
|
||||||
|
description="Triage this feedback by setting its status and leaving a short note for the audit trail."
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Resolve feedback</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-muted-foreground text-xs uppercase">
|
||||||
|
Status
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={status}
|
||||||
|
onValueChange={(v) => setStatus(v as ResolveChoice)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="acknowledged">Acknowledged</SelectItem>
|
||||||
|
<SelectItem value="fixed">Fixed</SelectItem>
|
||||||
|
<SelectItem value="dismissed">Dismissed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground text-xs uppercase">
|
||||||
|
Notes
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder="What did you do? (optional)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!feedback) return;
|
||||||
|
try {
|
||||||
|
await resolve.mutateAsync({
|
||||||
|
id: feedback.id,
|
||||||
|
status,
|
||||||
|
notes,
|
||||||
|
});
|
||||||
|
toast.success(`Marked ${status}`);
|
||||||
|
setNotes("");
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : "Resolve failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={resolve.isPending}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="mr-1 h-4 w-4" />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AIFeedbackTriage() {
|
||||||
|
const [statusFilter, setStatusFilter] = useState<AIFeedbackStatus | "all">("open");
|
||||||
|
const [ratingFilter, setRatingFilter] = useState<"up" | "down" | "all">("down");
|
||||||
|
const [subjectFilter, setSubjectFilter] = useState<
|
||||||
|
AIFeedbackSubjectType | "all"
|
||||||
|
>("all");
|
||||||
|
const [selected, setSelected] = useState<AIFeedback | null>(null);
|
||||||
|
|
||||||
|
const params = useMemo(
|
||||||
|
() => ({
|
||||||
|
status: statusFilter === "all" ? undefined : statusFilter,
|
||||||
|
rating: ratingFilter === "all" ? undefined : ratingFilter,
|
||||||
|
subject_type: subjectFilter === "all" ? undefined : subjectFilter,
|
||||||
|
page: 0,
|
||||||
|
size: 50,
|
||||||
|
}),
|
||||||
|
[statusFilter, ratingFilter, subjectFilter],
|
||||||
|
);
|
||||||
|
const { data, isLoading } = useAIFeedbackList(params);
|
||||||
|
const items: AIFeedback[] = data?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl space-y-6 p-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
<MessageSquare className="mr-1 inline h-5 w-5" />
|
||||||
|
AI feedback triage
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
Student thumbs up/down on AI output — use this to catch broken
|
||||||
|
prompts, bad questions, and low-quality coach replies.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
<Filter className="mr-1 inline h-4 w-4" />
|
||||||
|
Filters
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Default view surfaces open thumbs-down reports, which are usually
|
||||||
|
the most actionable.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-muted-foreground text-xs uppercase">
|
||||||
|
Status
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setStatusFilter(v as AIFeedbackStatus | "all")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All</SelectItem>
|
||||||
|
<SelectItem value="open">Open</SelectItem>
|
||||||
|
<SelectItem value="acknowledged">Acknowledged</SelectItem>
|
||||||
|
<SelectItem value="fixed">Fixed</SelectItem>
|
||||||
|
<SelectItem value="dismissed">Dismissed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-muted-foreground text-xs uppercase">
|
||||||
|
Rating
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={ratingFilter}
|
||||||
|
onValueChange={(v) => setRatingFilter(v as "up" | "down" | "all")}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All</SelectItem>
|
||||||
|
<SelectItem value="down">Thumbs down</SelectItem>
|
||||||
|
<SelectItem value="up">Thumbs up</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-muted-foreground text-xs uppercase">
|
||||||
|
Type
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={subjectFilter}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setSubjectFilter(v as AIFeedbackSubjectType | "all")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-44">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All</SelectItem>
|
||||||
|
<SelectItem value="question">Exam question</SelectItem>
|
||||||
|
<SelectItem value="coach">Coach reply</SelectItem>
|
||||||
|
<SelectItem value="explanation">Explanation</SelectItem>
|
||||||
|
<SelectItem value="translation">Translation</SelectItem>
|
||||||
|
<SelectItem value="narrative">Report narrative</SelectItem>
|
||||||
|
<SelectItem value="other">Other</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Feedback ({data?.total ?? 0})</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
No feedback matches the current filters.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>When</TableHead>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Subject</TableHead>
|
||||||
|
<TableHead>Rating</TableHead>
|
||||||
|
<TableHead>Prompt</TableHead>
|
||||||
|
<TableHead>Comment</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((f) => (
|
||||||
|
<TableRow key={f.id}>
|
||||||
|
<TableCell className="text-muted-foreground text-xs whitespace-nowrap">
|
||||||
|
{f.create_date
|
||||||
|
? new Date(f.create_date).toLocaleString()
|
||||||
|
: "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{f.user_name}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{f.subject_key}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{f.rating === "up" ? (
|
||||||
|
<Badge className="bg-emerald-500 text-white hover:bg-emerald-500">
|
||||||
|
<ThumbsUp className="mr-1 h-3 w-3" />
|
||||||
|
Up
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-rose-500 text-white hover:bg-rose-500">
|
||||||
|
<ThumbsDown className="mr-1 h-3 w-3" />
|
||||||
|
Down
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{f.prompt_key
|
||||||
|
? `${f.prompt_key} v${f.prompt_version ?? "?"}`
|
||||||
|
: "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-md text-sm">
|
||||||
|
{f.comment || <span className="text-muted-foreground">—</span>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={STATUS_COLORS[f.status]}
|
||||||
|
>
|
||||||
|
{f.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{f.status === "open" ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setSelected(f)}
|
||||||
|
>
|
||||||
|
Resolve
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{f.resolution_notes || "—"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ResolveDialog feedback={selected} onClose={() => setSelected(null)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
528
frontend/src/pages/admin/AIPromptEditor.tsx
Normal file
528
frontend/src/pages/admin/AIPromptEditor.tsx
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
useAIPrompt,
|
||||||
|
useAIPromptKeys,
|
||||||
|
useAIPromptVersions,
|
||||||
|
useActivateAIPrompt,
|
||||||
|
useCreateAIPrompt,
|
||||||
|
useRenderAIPrompt,
|
||||||
|
} from "@/hooks/queries/useAIPrompts";
|
||||||
|
import type { AIPromptSummary } from "@/types/ai-prompt";
|
||||||
|
import { CheckCircle2, FileText, History, Play, PlusCircle } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
function SelectedKeyPanel({
|
||||||
|
selectedKey,
|
||||||
|
onSelectVersion,
|
||||||
|
activePromptId,
|
||||||
|
}: {
|
||||||
|
selectedKey: string;
|
||||||
|
onSelectVersion: (id: number) => void;
|
||||||
|
activePromptId: number | null;
|
||||||
|
}) {
|
||||||
|
const { data, isLoading } = useAIPromptVersions(selectedKey);
|
||||||
|
const activate = useActivateAIPrompt();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Skeleton className="h-32 w-full" />;
|
||||||
|
}
|
||||||
|
const versions = data ?? [];
|
||||||
|
if (!versions.length) {
|
||||||
|
return (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
No versions yet for this prompt.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Version</TableHead>
|
||||||
|
<TableHead>Title</TableHead>
|
||||||
|
<TableHead>Author</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{versions.map((v) => (
|
||||||
|
<TableRow
|
||||||
|
key={v.id}
|
||||||
|
className={activePromptId === v.id ? "bg-muted/50" : undefined}
|
||||||
|
>
|
||||||
|
<TableCell className="font-mono">v{v.version}</TableCell>
|
||||||
|
<TableCell>{v.title}</TableCell>
|
||||||
|
<TableCell>{v.author_name || "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{v.is_active ? (
|
||||||
|
<Badge className="bg-emerald-500 text-white hover:bg-emerald-500">
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">Archived</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="space-x-2 text-right">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onSelectVersion(v.id)}
|
||||||
|
>
|
||||||
|
<FileText className="mr-1 h-3 w-3" />
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
{!v.is_active ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await activate.mutateAsync(v.id);
|
||||||
|
toast.success(`Activated v${v.version}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : "Activate failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={activate.isPending}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
|
Activate
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptDetail({ promptId }: { promptId: number }) {
|
||||||
|
const { data, isLoading } = useAIPrompt(promptId);
|
||||||
|
const render = useRenderAIPrompt();
|
||||||
|
|
||||||
|
// Variables detected from the prompt body — keep local state keyed by name
|
||||||
|
// so the editor can show one input per placeholder.
|
||||||
|
const [sampleValues, setSampleValues] = useState<Record<string, string>>({});
|
||||||
|
const [rendered, setRendered] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRendered("");
|
||||||
|
setSampleValues({});
|
||||||
|
}, [promptId]);
|
||||||
|
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return <Skeleton className="h-40 w-full" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variables = data.variables ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{data.title}{" "}
|
||||||
|
<span className="text-muted-foreground font-normal">
|
||||||
|
({data.key} v{data.version})
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
{data.description ? (
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
{data.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs uppercase tracking-wide">Template</Label>
|
||||||
|
<ScrollArea className="bg-muted/30 mt-1 max-h-64 rounded border">
|
||||||
|
<pre className="p-3 font-mono text-sm whitespace-pre-wrap">
|
||||||
|
{data.content}
|
||||||
|
</pre>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{variables.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs uppercase tracking-wide">
|
||||||
|
Sample variables
|
||||||
|
</Label>
|
||||||
|
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||||
|
{variables.map((v) => (
|
||||||
|
<div key={v} className="space-y-1">
|
||||||
|
<Label className="text-xs">{v}</Label>
|
||||||
|
<Input
|
||||||
|
value={sampleValues[v] ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSampleValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[v]: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={`Sample value for {${v}}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const r = await render.mutateAsync({
|
||||||
|
id: data.id,
|
||||||
|
variables: sampleValues,
|
||||||
|
});
|
||||||
|
setRendered(r.rendered);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : "Render failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={render.isPending}
|
||||||
|
>
|
||||||
|
<Play className="mr-1 h-3 w-3" />
|
||||||
|
Render preview
|
||||||
|
</Button>
|
||||||
|
{rendered ? (
|
||||||
|
<ScrollArea className="bg-background mt-2 max-h-48 rounded border">
|
||||||
|
<pre className="p-3 font-mono text-sm whitespace-pre-wrap">
|
||||||
|
{rendered}
|
||||||
|
</pre>
|
||||||
|
</ScrollArea>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewVersionDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
defaultKey,
|
||||||
|
defaultContent,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
defaultKey: string;
|
||||||
|
defaultContent: string;
|
||||||
|
}) {
|
||||||
|
const create = useCreateAIPrompt();
|
||||||
|
const [key, setKey] = useState(defaultKey);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [content, setContent] = useState(defaultContent);
|
||||||
|
const [activate, setActivate] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setKey(defaultKey);
|
||||||
|
setContent(defaultContent);
|
||||||
|
setTitle("");
|
||||||
|
setDescription("");
|
||||||
|
setActivate(true);
|
||||||
|
}
|
||||||
|
}, [open, defaultKey, defaultContent]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className="max-w-2xl"
|
||||||
|
description="Create a new prompt version; the previous active version will be archived."
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New prompt version</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label>Key</Label>
|
||||||
|
<Input
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => setKey(e.target.value.trim())}
|
||||||
|
placeholder="exam.mcq.generate"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
Lowercase dotted identifier. Reuse an existing key to bump its
|
||||||
|
version.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Title</Label>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="MCQ generation — CEFR B2 — v6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Description (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder="What changed? What should editors know?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Template</Label>
|
||||||
|
<Textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
rows={10}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
placeholder="You are an exam author. Generate {count} MCQs for {topic}…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={activate}
|
||||||
|
onChange={(e) => setActivate(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Activate this version immediately
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!key || !title || !content.trim()) {
|
||||||
|
toast.error("Key, title, and content are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await create.mutateAsync({
|
||||||
|
key,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
content,
|
||||||
|
activate,
|
||||||
|
});
|
||||||
|
toast.success("Prompt version created");
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : "Create failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={create.isPending}
|
||||||
|
>
|
||||||
|
Save version
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AIPromptEditor() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const { data, isLoading } = useAIPromptKeys({ search, page: 1, size: 50 });
|
||||||
|
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||||
|
const [selectedPromptId, setSelectedPromptId] = useState<number | null>(null);
|
||||||
|
const [newOpen, setNewOpen] = useState(false);
|
||||||
|
|
||||||
|
const items: AIPromptSummary[] = data?.items ?? [];
|
||||||
|
const activePromptId = useMemo(() => {
|
||||||
|
if (!selectedKey) return null;
|
||||||
|
const match = items.find((it) => it.key === selectedKey);
|
||||||
|
return match?.id ?? null;
|
||||||
|
}, [items, selectedKey]);
|
||||||
|
|
||||||
|
// Autoselect first key on load.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedKey && items.length) {
|
||||||
|
setSelectedKey(items[0].key);
|
||||||
|
setSelectedPromptId(items[0].id);
|
||||||
|
}
|
||||||
|
}, [items, selectedKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl space-y-6 p-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
AI prompt library
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
Versioned, auditable templates that the AI pipelines render at
|
||||||
|
runtime. Non-engineers can iterate here without shipping code.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setNewOpen(true)}>
|
||||||
|
<PlusCircle className="mr-1 h-4 w-4" />
|
||||||
|
New version
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Prompt keys</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Each row shows the latest version of a prompt key. Click a row to
|
||||||
|
inspect all versions.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Input
|
||||||
|
placeholder="Search keys (e.g. exam.mcq)"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="max-w-sm"
|
||||||
|
/>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
No prompts yet. Click <strong>New version</strong> to add one.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Key</TableHead>
|
||||||
|
<TableHead>Latest</TableHead>
|
||||||
|
<TableHead>Title</TableHead>
|
||||||
|
<TableHead>Variables</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((p) => (
|
||||||
|
<TableRow
|
||||||
|
key={p.id}
|
||||||
|
className={
|
||||||
|
selectedKey === p.key
|
||||||
|
? "bg-muted/40 cursor-pointer"
|
||||||
|
: "cursor-pointer"
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedKey(p.key);
|
||||||
|
setSelectedPromptId(p.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableCell className="font-mono text-xs">{p.key}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
v{p.version}{" "}
|
||||||
|
{p.total_versions && p.total_versions > 1 ? (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
(of {p.total_versions})
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{p.title}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{p.variables.slice(0, 4).map((v) => (
|
||||||
|
<Badge key={v} variant="outline" className="text-xs">
|
||||||
|
{v}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{p.variables.length > 4 ? (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
+{p.variables.length - 4}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{p.is_active ? (
|
||||||
|
<Badge className="bg-emerald-500 text-white hover:bg-emerald-500">
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">No active</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{selectedKey ? (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
<History className="mr-1 inline h-4 w-4" />
|
||||||
|
Versions of {selectedKey}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<SelectedKeyPanel
|
||||||
|
selectedKey={selectedKey}
|
||||||
|
onSelectVersion={setSelectedPromptId}
|
||||||
|
activePromptId={selectedPromptId}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Preview</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Inspect template body and try a dry-run render.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{selectedPromptId ? (
|
||||||
|
<PromptDetail promptId={selectedPromptId} />
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Select a version from the list.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<NewVersionDialog
|
||||||
|
open={newOpen}
|
||||||
|
onOpenChange={setNewOpen}
|
||||||
|
defaultKey={selectedKey ?? ""}
|
||||||
|
defaultContent=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ export default function AdminGradebook() {
|
|||||||
const { data: coursesData } = useCourses({ size: 200 });
|
const { data: coursesData } = useCourses({ size: 200 });
|
||||||
const { data: subjectsData } = useSubjects();
|
const { data: subjectsData } = useSubjects();
|
||||||
const coursesList = coursesData?.items ?? [];
|
const coursesList = coursesData?.items ?? [];
|
||||||
const subjectsList = Array.isArray(subjectsData) ? subjectsData : subjectsData?.data ?? subjectsData?.items ?? [];
|
const subjectsList = Array.isArray(subjectsData) ? subjectsData : [];
|
||||||
const createMutation = useCreateGradingAssignment();
|
const createMutation = useCreateGradingAssignment();
|
||||||
const updateMutation = useUpdateGradingAssignment();
|
const updateMutation = useUpdateGradingAssignment();
|
||||||
const deleteMutation = useDeleteGradingAssignment();
|
const deleteMutation = useDeleteGradingAssignment();
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default function AdminLessons() {
|
|||||||
const { data: subjectsData } = useSubjects();
|
const { data: subjectsData } = useSubjects();
|
||||||
const courses = coursesData?.items ?? [];
|
const courses = coursesData?.items ?? [];
|
||||||
const allBatches = batchesData?.items ?? [];
|
const allBatches = batchesData?.items ?? [];
|
||||||
const subjects = Array.isArray(subjectsData) ? subjectsData : subjectsData?.data ?? subjectsData?.items ?? [];
|
const subjects = Array.isArray(subjectsData) ? subjectsData : [];
|
||||||
const batchesForCourse = useMemo(
|
const batchesForCourse = useMemo(
|
||||||
() => (form.course_id ? allBatches.filter((b) => b.course_id === Number(form.course_id)) : allBatches),
|
() => (form.course_id ? allBatches.filter((b) => b.course_id === Number(form.course_id)) : allBatches),
|
||||||
[allBatches, form.course_id],
|
[allBatches, form.course_id],
|
||||||
|
|||||||
@@ -39,9 +39,10 @@ export default function AdminLmsDashboard() {
|
|||||||
|
|
||||||
const { data: dbStats } = useQuery<DashboardStats>({
|
const { data: dbStats } = useQuery<DashboardStats>({
|
||||||
queryKey: ["dashboard", "stats"],
|
queryKey: ["dashboard", "stats"],
|
||||||
queryFn: async () => {
|
queryFn: async (): Promise<DashboardStats> => {
|
||||||
const res = await api.get<{ data: DashboardStats }>("/stats");
|
const res = await api.get<{ data: DashboardStats } | DashboardStats>("/stats");
|
||||||
return (res as { data: DashboardStats }).data ?? res;
|
const wrapped = res as { data?: DashboardStats };
|
||||||
|
return wrapped.data ?? (res as DashboardStats);
|
||||||
},
|
},
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function AdminTeachers() {
|
|||||||
const [firstName, setFirstName] = useState("");
|
const [firstName, setFirstName] = useState("");
|
||||||
const [lastName, setLastName] = useState("");
|
const [lastName, setLastName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [gender, setGender] = useState("male");
|
const [gender, setGender] = useState<"male" | "female">("male");
|
||||||
const [specialization, setSpecialization] = useState("");
|
const [specialization, setSpecialization] = useState("");
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -157,7 +157,7 @@ export default function AdminTeachers() {
|
|||||||
<div className="space-y-2"><Label>Email *</Label><Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
|
<div className="space-y-2"><Label>Email *</Label><Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Gender</Label>
|
<Label>Gender</Label>
|
||||||
<Select value={gender} onValueChange={setGender}>
|
<Select value={gender} onValueChange={(v) => setGender(v as "male" | "female")}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="male">Male</SelectItem>
|
<SelectItem value="male">Male</SelectItem>
|
||||||
|
|||||||
341
frontend/src/pages/admin/ExamReviewDetail.tsx
Normal file
341
frontend/src/pages/admin/ExamReviewDetail.tsx
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle2,
|
||||||
|
Sparkles,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { toast } from "@/components/ui/sonner";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useApproveExamReview,
|
||||||
|
useExamReviewDetail,
|
||||||
|
useRejectExamReview,
|
||||||
|
} from "@/hooks/queries/useExamReview";
|
||||||
|
import type { ExamReviewQuestion } from "@/types/exam-review";
|
||||||
|
|
||||||
|
const FAILING_THRESHOLD = 0.7;
|
||||||
|
|
||||||
|
function QuestionCard({ q, index }: { q: ExamReviewQuestion; index: number }) {
|
||||||
|
const failing = (q.quality_score ?? 0) < FAILING_THRESHOLD;
|
||||||
|
return (
|
||||||
|
<Card className={failing ? "border-destructive/40" : undefined}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
|
||||||
|
<span className="font-mono">Q{index + 1}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<Badge variant="outline" className="capitalize">
|
||||||
|
{q.skill}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="capitalize">
|
||||||
|
{q.question_type}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="capitalize">
|
||||||
|
{q.difficulty}
|
||||||
|
</Badge>
|
||||||
|
{q.ai_generated && (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<Sparkles className="h-3 w-3" />
|
||||||
|
AI
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-sm leading-snug">
|
||||||
|
{q.stem.length > 220 ? `${q.stem.slice(0, 220)}…` : q.stem}
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
(q.quality_score ?? 0) >= 0.85
|
||||||
|
? "default"
|
||||||
|
: failing
|
||||||
|
? "destructive"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
className="tabular-nums shrink-0"
|
||||||
|
>
|
||||||
|
{((q.quality_score ?? 0) * 100).toFixed(0)}%
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
{q.quality_report && Object.keys(q.quality_report).length > 0 && (
|
||||||
|
<CardContent>
|
||||||
|
<Label className="text-xs text-muted-foreground">Quality report</Label>
|
||||||
|
<pre className="mt-1 rounded-md bg-muted/50 p-3 text-xs overflow-x-auto">
|
||||||
|
{JSON.stringify(q.quality_report, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExamReviewDetail() {
|
||||||
|
const { examId } = useParams<{ examId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const parsedId = examId ? Number(examId) : undefined;
|
||||||
|
|
||||||
|
const { data: exam, isLoading } = useExamReviewDetail(parsedId);
|
||||||
|
const approve = useApproveExamReview();
|
||||||
|
const reject = useRejectExamReview();
|
||||||
|
|
||||||
|
const [approveNotes, setApproveNotes] = useState("");
|
||||||
|
const [rejectNotes, setRejectNotes] = useState("");
|
||||||
|
const [approveOpen, setApproveOpen] = useState(false);
|
||||||
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
|
|
||||||
|
if (isLoading || !exam || !parsedId) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApprove = async () => {
|
||||||
|
try {
|
||||||
|
await approve.mutateAsync({ examId: parsedId, notes: approveNotes });
|
||||||
|
toast.success("Exam approved and published.");
|
||||||
|
setApproveOpen(false);
|
||||||
|
navigate("/admin/exam/review-queue");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Approve failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = async () => {
|
||||||
|
if (!rejectNotes.trim()) {
|
||||||
|
toast.error("Rejection notes are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await reject.mutateAsync({ examId: parsedId, notes: rejectNotes });
|
||||||
|
toast.success("Exam rejected and returned to draft.");
|
||||||
|
setRejectOpen(false);
|
||||||
|
navigate("/admin/exam/review-queue");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Reject failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = exam.summary;
|
||||||
|
const alreadyReviewed = exam.status !== "pending_review";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Button asChild variant="ghost" size="sm" className="-ml-2">
|
||||||
|
<Link to="/admin/exam/review-queue">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1" /> Back to queue
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">{exam.title}</h1>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Badge variant="outline" className="capitalize">
|
||||||
|
{exam.status.replace("_", " ")}
|
||||||
|
</Badge>
|
||||||
|
{exam.subject_name && <span>· {exam.subject_name}</span>}
|
||||||
|
{exam.teacher_name && <span>· Created by {exam.teacher_name}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!alreadyReviewed && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Dialog open={rejectOpen} onOpenChange={setRejectOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" className="gap-1">
|
||||||
|
<XCircle className="h-4 w-4" /> Reject
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Reject this exam?</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="reject-notes">
|
||||||
|
Feedback for the author (required)
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="reject-notes"
|
||||||
|
value={rejectNotes}
|
||||||
|
onChange={(e) => setRejectNotes(e.target.value)}
|
||||||
|
rows={6}
|
||||||
|
placeholder="Explain what needs to change before this exam can be published."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setRejectOpen(false)}
|
||||||
|
disabled={reject.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleReject}
|
||||||
|
disabled={reject.isPending || !rejectNotes.trim()}
|
||||||
|
>
|
||||||
|
Reject & return to draft
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={approveOpen} onOpenChange={setApproveOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="gap-1">
|
||||||
|
<CheckCircle2 className="h-4 w-4" /> Approve & publish
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Approve and publish?</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="approve-notes">Notes (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="approve-notes"
|
||||||
|
value={approveNotes}
|
||||||
|
onChange={(e) => setApproveNotes(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Any comments or context for the audit log."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setApproveOpen(false)}
|
||||||
|
disabled={approve.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleApprove}
|
||||||
|
disabled={approve.isPending}
|
||||||
|
>
|
||||||
|
Publish exam
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{alreadyReviewed && exam.reviewed_by_name && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Previously reviewed</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{exam.reviewed_by_name}
|
||||||
|
{exam.reviewed_at ? ` · ${new Date(exam.reviewed_at).toLocaleString()}` : ""}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
{exam.review_notes && (
|
||||||
|
<CardContent className="text-sm whitespace-pre-wrap">
|
||||||
|
{exam.review_notes}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Quality summary</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Aggregated across every question in this exam.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground">Questions</div>
|
||||||
|
<div className="text-2xl font-semibold tabular-nums">
|
||||||
|
{summary.question_count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground">Average quality</div>
|
||||||
|
<div className="text-2xl font-semibold tabular-nums">
|
||||||
|
{(summary.avg_quality_score * 100).toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground">Lowest score</div>
|
||||||
|
<div className="text-2xl font-semibold tabular-nums">
|
||||||
|
{(summary.min_quality_score * 100).toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground flex items-center gap-1">
|
||||||
|
<AlertTriangle className="h-3 w-3" /> Failing
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-semibold tabular-nums text-destructive">
|
||||||
|
{summary.failing_count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{exam.sections.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This exam has no sections yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
exam.sections.map((section) => (
|
||||||
|
<section key={section.id} className="space-y-3">
|
||||||
|
<h2 className="text-base font-semibold flex items-center gap-2">
|
||||||
|
<span>{section.title}</span>
|
||||||
|
<Badge variant="outline" className="capitalize text-xs">
|
||||||
|
{section.skill || "general"}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
|
· {section.questions.length} questions
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{section.questions.map((q, idx) => (
|
||||||
|
<QuestionCard key={q.id} q={q} index={idx} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
205
frontend/src/pages/admin/ExamReviewQueue.tsx
Normal file
205
frontend/src/pages/admin/ExamReviewQueue.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
FileText,
|
||||||
|
Search,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
|
import { useExamReviewQueue } from "@/hooks/queries/useExamReview";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-facing queue of AI-generated exams that failed the automated quality
|
||||||
|
* gate and need a human to sign off before they can be published to students.
|
||||||
|
*
|
||||||
|
* The list intentionally stays dense — each row shows only the signals an
|
||||||
|
* operator needs to prioritise (quality score, failing-question count, AI
|
||||||
|
* model used). Clicking through to the detail page reveals the full report.
|
||||||
|
*/
|
||||||
|
export default function ExamReviewQueue() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
const { data, isLoading } = useExamReviewQueue({
|
||||||
|
page,
|
||||||
|
size: pageSize,
|
||||||
|
search: search || undefined,
|
||||||
|
status: "pending_review",
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Exam Review Queue</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
AI-generated exams that failed the automated quality gate. Review
|
||||||
|
each one, then approve to publish or reject back to draft.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<Sparkles className="h-3 w-3" />
|
||||||
|
{total} awaiting review
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by exam title…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPage(1);
|
||||||
|
setSearch(e.target.value);
|
||||||
|
}}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-6 space-y-2">
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="p-10 text-center">
|
||||||
|
<CheckCircle2 className="mx-auto h-10 w-10 text-muted-foreground mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Nothing in the review queue. Any time an AI-generated exam
|
||||||
|
fails the quality gate, it will show up here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[32%]">Exam</TableHead>
|
||||||
|
<TableHead>Subject</TableHead>
|
||||||
|
<TableHead className="text-center">Questions</TableHead>
|
||||||
|
<TableHead className="text-center">Avg. Quality</TableHead>
|
||||||
|
<TableHead className="text-center">Failing</TableHead>
|
||||||
|
<TableHead className="text-right">Action</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((exam) => {
|
||||||
|
const avg = exam.summary.avg_quality_score;
|
||||||
|
const failing = exam.summary.failing_count;
|
||||||
|
return (
|
||||||
|
<TableRow key={exam.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Link
|
||||||
|
to={`/admin/exam/review/${exam.id}`}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{exam.title}
|
||||||
|
</Link>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{exam.summary.ai_generated_count} AI-generated ·{" "}
|
||||||
|
{exam.teacher_name || "Unassigned"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{exam.subject_name || "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-sm">
|
||||||
|
{exam.summary.question_count}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
avg >= 0.85
|
||||||
|
? "default"
|
||||||
|
: avg >= 0.7
|
||||||
|
? "secondary"
|
||||||
|
: "destructive"
|
||||||
|
}
|
||||||
|
className="tabular-nums"
|
||||||
|
>
|
||||||
|
{(avg * 100).toFixed(0)}%
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{failing > 0 ? (
|
||||||
|
<Badge variant="destructive" className="gap-1">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
{failing}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<Link to={`/admin/exam/review/${exam.id}`}>Review</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{pageCount > 1 && (
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Page {page} of {pageCount} · {total} total
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= pageCount}
|
||||||
|
onClick={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { useIeltsValidation, usePublishIeltsExam, useAssignIeltsExam } from "@/hooks/queries/useExamTemplates";
|
import { useIeltsExamValidation, usePublishIeltsExam, useAssignIeltsExam } from "@/hooks/queries/useExamTemplates";
|
||||||
import { useStudents, useBatches } from "@/hooks/queries/useLms";
|
import { useStudents, useBatches } from "@/hooks/queries/useLms";
|
||||||
import { ieltsExamService } from "@/services/ielts-exam.service";
|
import { ieltsExamService } from "@/services/ielts-exam.service";
|
||||||
import type { ExamValidationReport, ValidationCheck } from "@/types";
|
import type { ExamValidationReport, ValidationCheck } from "@/types";
|
||||||
@@ -40,7 +40,7 @@ export default function IeltsExamValidation() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const examId = Number(examIdParam);
|
const examId = Number(examIdParam);
|
||||||
|
|
||||||
const validationQ = useIeltsValidation(Number.isFinite(examId) ? examId : 0);
|
const validationQ = useIeltsExamValidation(Number.isFinite(examId) ? examId : 0);
|
||||||
const publishMut = usePublishIeltsExam();
|
const publishMut = usePublishIeltsExam();
|
||||||
const assignMut = useAssignIeltsExam();
|
const assignMut = useAssignIeltsExam();
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ function EditResourceDialog({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Status</Label>
|
<Label>Status</Label>
|
||||||
<Select value={status} onValueChange={setStatus}>
|
<Select value={status} onValueChange={(v) => setStatus(v as "pending" | "approved" | "rejected")}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="pending">Pending</SelectItem>
|
<SelectItem value="pending">Pending</SelectItem>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function TaxonomyManager() {
|
|||||||
|
|
||||||
const [showAddTopic, setShowAddTopic] = useState<number | null>(null);
|
const [showAddTopic, setShowAddTopic] = useState<number | null>(null);
|
||||||
const [newTopicName, setNewTopicName] = useState("");
|
const [newTopicName, setNewTopicName] = useState("");
|
||||||
const [newTopicDifficulty, setNewTopicDifficulty] = useState("medium");
|
const [newTopicDifficulty, setNewTopicDifficulty] = useState<"easy" | "medium" | "hard">("medium");
|
||||||
const [newTopicHours, setNewTopicHours] = useState("1");
|
const [newTopicHours, setNewTopicHours] = useState("1");
|
||||||
|
|
||||||
const [editingSubjectId, setEditingSubjectId] = useState<number | null>(null);
|
const [editingSubjectId, setEditingSubjectId] = useState<number | null>(null);
|
||||||
@@ -62,7 +62,7 @@ export default function TaxonomyManager() {
|
|||||||
|
|
||||||
const [editingTopicId, setEditingTopicId] = useState<number | null>(null);
|
const [editingTopicId, setEditingTopicId] = useState<number | null>(null);
|
||||||
const [editTopicName, setEditTopicName] = useState("");
|
const [editTopicName, setEditTopicName] = useState("");
|
||||||
const [editTopicDifficulty, setEditTopicDifficulty] = useState("medium");
|
const [editTopicDifficulty, setEditTopicDifficulty] = useState<"easy" | "medium" | "hard">("medium");
|
||||||
const [editTopicHours, setEditTopicHours] = useState("1");
|
const [editTopicHours, setEditTopicHours] = useState("1");
|
||||||
const [editTopicDesc, setEditTopicDesc] = useState("");
|
const [editTopicDesc, setEditTopicDesc] = useState("");
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ export default function TaxonomyManager() {
|
|||||||
const startEditTopic = (t: { id: number; name: string; difficulty_level?: string; estimated_hours?: number; description?: string }) => {
|
const startEditTopic = (t: { id: number; name: string; difficulty_level?: string; estimated_hours?: number; description?: string }) => {
|
||||||
setEditingTopicId(t.id);
|
setEditingTopicId(t.id);
|
||||||
setEditTopicName(t.name);
|
setEditTopicName(t.name);
|
||||||
setEditTopicDifficulty(t.difficulty_level ?? "medium");
|
setEditTopicDifficulty((t.difficulty_level as "easy" | "medium" | "hard") ?? "medium");
|
||||||
setEditTopicHours(String(t.estimated_hours ?? 1));
|
setEditTopicHours(String(t.estimated_hours ?? 1));
|
||||||
setEditTopicDesc(t.description ?? "");
|
setEditTopicDesc(t.description ?? "");
|
||||||
};
|
};
|
||||||
@@ -333,7 +333,7 @@ export default function TaxonomyManager() {
|
|||||||
{showAddTopic === domain.id && (
|
{showAddTopic === domain.id && (
|
||||||
<div className="flex items-center gap-2 p-2 rounded border bg-muted/50">
|
<div className="flex items-center gap-2 p-2 rounded border bg-muted/50">
|
||||||
<Input placeholder="Topic name" value={newTopicName} onChange={e => setNewTopicName(e.target.value)} className="h-8 text-sm" />
|
<Input placeholder="Topic name" value={newTopicName} onChange={e => setNewTopicName(e.target.value)} className="h-8 text-sm" />
|
||||||
<Select value={newTopicDifficulty} onValueChange={setNewTopicDifficulty}>
|
<Select value={newTopicDifficulty} onValueChange={(v) => setNewTopicDifficulty(v as "easy" | "medium" | "hard")}>
|
||||||
<SelectTrigger className="w-28 h-8"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="w-28 h-8"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="easy">Easy</SelectItem>
|
<SelectItem value="easy">Easy</SelectItem>
|
||||||
@@ -352,7 +352,7 @@ export default function TaxonomyManager() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input value={editTopicName} onChange={e => setEditTopicName(e.target.value)} className="h-7 text-sm flex-1" placeholder="Topic name" />
|
<Input value={editTopicName} onChange={e => setEditTopicName(e.target.value)} className="h-7 text-sm flex-1" placeholder="Topic name" />
|
||||||
<Select value={editTopicDifficulty} onValueChange={setEditTopicDifficulty}>
|
<Select value={editTopicDifficulty} onValueChange={(v) => setEditTopicDifficulty(v as "easy" | "medium" | "hard")}>
|
||||||
<SelectTrigger className="w-28 h-7"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="w-28 h-7"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="easy">Easy</SelectItem>
|
<SelectItem value="easy">Easy</SelectItem>
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export default function AiIeltsCourse() {
|
|||||||
const band = Number(targetBand || course?.target_level || 7);
|
const band = Number(targetBand || course?.target_level || 7);
|
||||||
createIelts.mutate(
|
createIelts.mutate(
|
||||||
{
|
{
|
||||||
skill: skillsRanked[0]?.skill ?? "writing",
|
skill: (skillsRanked[0]?.skill ?? "writing") as "reading" | "listening" | "writing" | "speaking",
|
||||||
target_band: Number.isFinite(band) ? band : 7,
|
target_band: Number.isFinite(band) ? band : 7,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default function ExamResults() {
|
|||||||
|
|
||||||
const { data: results, isLoading, isError } = useQuery<ExamResultsData>({
|
const { data: results, isLoading, isError } = useQuery<ExamResultsData>({
|
||||||
queryKey: ["exam-results", examId],
|
queryKey: ["exam-results", examId],
|
||||||
queryFn: () => examSessionService.getResults(Number(examId)),
|
queryFn: () => examSessionService.getResults<ExamResultsData>(Number(examId)),
|
||||||
enabled: !!examId,
|
enabled: !!examId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export default function StudentAttendance() {
|
|||||||
{attendanceRecords.map(a => (
|
{attendanceRecords.map(a => (
|
||||||
<TableRow key={a.id}>
|
<TableRow key={a.id}>
|
||||||
<TableCell>{a.date}</TableCell>
|
<TableCell>{a.date}</TableCell>
|
||||||
<TableCell>{a.courseName}</TableCell>
|
<TableCell>{a.course_name}</TableCell>
|
||||||
<TableCell><Badge variant={badgeVariant(a.status)} className="capitalize">{a.status}</Badge></TableCell>
|
<TableCell><Badge variant={badgeVariant(a.status)} className="capitalize">{a.status}</Badge></TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export default function StudentDiscussionBoard() {
|
|||||||
const [newTitle, setNewTitle] = useState("");
|
const [newTitle, setNewTitle] = useState("");
|
||||||
const [newContent, setNewContent] = useState("");
|
const [newContent, setNewContent] = useState("");
|
||||||
|
|
||||||
const posts = postsData?.results ?? [];
|
const posts = postsData?.items ?? [];
|
||||||
|
|
||||||
const handleCreatePost = () => {
|
const handleCreatePost = () => {
|
||||||
if (!selectedBoardId) return;
|
if (!selectedBoardId) return;
|
||||||
|
|||||||
@@ -11,15 +11,15 @@ export default function StudentGrades() {
|
|||||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||||
|
|
||||||
const avgGrade = gradeRecords.length
|
const avgGrade = gradeRecords.length
|
||||||
? (gradeRecords.reduce((sum, g) => sum + (g.grade / g.maxGrade) * 100, 0) / gradeRecords.length).toFixed(1)
|
? (gradeRecords.reduce((sum, g) => sum + (g.grade / g.max_grade) * 100, 0) / gradeRecords.length).toFixed(1)
|
||||||
: "0";
|
: "0";
|
||||||
const highest = gradeRecords.length
|
const highest = gradeRecords.length
|
||||||
? Math.max(...gradeRecords.map(g => (g.grade / g.maxGrade) * 100)).toFixed(1)
|
? Math.max(...gradeRecords.map(g => (g.grade / g.max_grade) * 100)).toFixed(1)
|
||||||
: "0";
|
: "0";
|
||||||
|
|
||||||
const chartData = gradeRecords.map(g => ({
|
const chartData = gradeRecords.map(g => ({
|
||||||
name: g.assignmentTitle.length > 15 ? g.assignmentTitle.slice(0, 15) + "…" : g.assignmentTitle,
|
name: g.assignment_title.length > 15 ? g.assignment_title.slice(0, 15) + "…" : g.assignment_title,
|
||||||
grade: Math.round((g.grade / g.maxGrade) * 100),
|
grade: Math.round((g.grade / g.max_grade) * 100),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -69,11 +69,11 @@ export default function StudentGrades() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{gradeRecords.map(g => (
|
{gradeRecords.map(g => (
|
||||||
<TableRow key={g.id}>
|
<TableRow key={g.id}>
|
||||||
<TableCell className="font-medium">{g.assignmentTitle}</TableCell>
|
<TableCell className="font-medium">{g.assignment_title}</TableCell>
|
||||||
<TableCell>{g.courseName}</TableCell>
|
<TableCell>{g.course_name}</TableCell>
|
||||||
<TableCell><Badge variant="outline" className="text-xs capitalize">{g.type}</Badge></TableCell>
|
<TableCell><Badge variant="outline" className="text-xs capitalize">{g.type}</Badge></TableCell>
|
||||||
<TableCell>{g.date}</TableCell>
|
<TableCell>{g.date}</TableCell>
|
||||||
<TableCell className="text-right font-bold">{g.grade}/{g.maxGrade}</TableCell>
|
<TableCell className="text-right font-bold">{g.grade}/{g.max_grade}</TableCell>
|
||||||
<TableCell><AiGradeExplainer studentName="Sarah Johnson" /></TableCell>
|
<TableCell><AiGradeExplainer studentName="Sarah Johnson" /></TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export default function StudentMessages() {
|
|||||||
const { data: unreadData } = useUnreadMessageCount();
|
const { data: unreadData } = useUnreadMessageCount();
|
||||||
const sendMessage = useSendMessage();
|
const sendMessage = useSendMessage();
|
||||||
|
|
||||||
const inbox = inboxData?.results ?? [];
|
const inbox = inboxData?.items ?? [];
|
||||||
const sent = sentData?.results ?? [];
|
const sent = sentData?.items ?? [];
|
||||||
const unreadCount = unreadData?.count ?? 0;
|
const unreadCount = unreadData?.count ?? 0;
|
||||||
|
|
||||||
const [showCompose, setShowCompose] = useState(false);
|
const [showCompose, setShowCompose] = useState(false);
|
||||||
|
|||||||
@@ -8,21 +8,21 @@ export default function StudentTimetable() {
|
|||||||
const { data: timetableSessions = [], isLoading } = useTimetable();
|
const { data: timetableSessions = [], isLoading } = useTimetable();
|
||||||
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
|
||||||
|
|
||||||
const mySessions = timetableSessions.filter(s => ["c1", "c2", "c5", "c8"].includes(s.courseId));
|
const mySessions = timetableSessions.filter(s => ["c1", "c2", "c5", "c8"].includes(String(s.course_id)));
|
||||||
|
|
||||||
function getSessionAt(day: string, hour: string) {
|
function getSessionAt(day: string, hour: string) {
|
||||||
return mySessions.find(s => s.day === day && s.startTime <= hour && s.endTime > hour);
|
return mySessions.find(s => s.day === day && s.start_time <= hour && s.end_time > hour);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isStart(day: string, hour: string) {
|
function isStart(day: string, hour: string) {
|
||||||
return mySessions.find(s => s.day === day && s.startTime === hour);
|
return mySessions.find(s => s.day === day && s.start_time === hour);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSpan(session: (typeof mySessions)[0]) {
|
function getSpan(session: (typeof mySessions)[0]) {
|
||||||
const start = parseInt(session.startTime.split(":")[0]);
|
const start = parseInt(session.start_time.split(":")[0]);
|
||||||
const end = parseInt(session.endTime.split(":")[0]);
|
const end = parseInt(session.end_time.split(":")[0]);
|
||||||
const startMin = parseInt(session.startTime.split(":")[1]);
|
const startMin = parseInt(session.start_time.split(":")[1]);
|
||||||
const endMin = parseInt(session.endTime.split(":")[1]);
|
const endMin = parseInt(session.end_time.split(":")[1]);
|
||||||
return (end - start) + (endMin > 0 ? 0.5 : 0) - (startMin > 0 ? 0.5 : 0);
|
return (end - start) + (endMin > 0 ? 0.5 : 0) - (startMin > 0 ? 0.5 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,8 +55,8 @@ export default function StudentTimetable() {
|
|||||||
className="rounded-md p-2 text-xs text-white"
|
className="rounded-md p-2 text-xs text-white"
|
||||||
style={{ backgroundColor: session.color, minHeight: `${getSpan(session) * 48}px` }}
|
style={{ backgroundColor: session.color, minHeight: `${getSpan(session) * 48}px` }}
|
||||||
>
|
>
|
||||||
<p className="font-semibold">{session.courseName}</p>
|
<p className="font-semibold">{session.course_name}</p>
|
||||||
<p className="opacity-80">{session.startTime}-{session.endTime}</p>
|
<p className="opacity-80">{session.start_time}-{session.end_time}</p>
|
||||||
<p className="opacity-70">{session.room}</p>
|
<p className="opacity-70">{session.room}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default function ChapterDetail() {
|
|||||||
queryFn: () => resourcesService.list({
|
queryFn: () => resourcesService.list({
|
||||||
search: libSearch || undefined,
|
search: libSearch || undefined,
|
||||||
resource_type: libTypeFilter === "all" ? undefined : libTypeFilter,
|
resource_type: libTypeFilter === "all" ? undefined : libTypeFilter,
|
||||||
limit: 50,
|
size: 50,
|
||||||
}),
|
}),
|
||||||
enabled: showUpload,
|
enabled: showUpload,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default function TeacherAnnouncements() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { data: announcements = [], isLoading } = useAnnouncements();
|
const { data: announcements = [], isLoading } = useAnnouncements();
|
||||||
const { data: coursesData } = useCourses();
|
const { data: coursesData } = useCourses();
|
||||||
const courses = coursesData?.results ?? [];
|
const courses = coursesData?.items ?? [];
|
||||||
const createAnnouncement = useCreateAnnouncement();
|
const createAnnouncement = useCreateAnnouncement();
|
||||||
const publishAnnouncement = usePublishAnnouncement();
|
const publishAnnouncement = usePublishAnnouncement();
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ export default function TeacherAnnouncements() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Courses</SelectItem>
|
<SelectItem value="all">All Courses</SelectItem>
|
||||||
{courses.map(c => (
|
{courses.map(c => (
|
||||||
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
|
<SelectItem key={c.id} value={String(c.id)}>{c.title}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default function TeacherDiscussionBoard() {
|
|||||||
const [newTitle, setNewTitle] = useState("");
|
const [newTitle, setNewTitle] = useState("");
|
||||||
const [newContent, setNewContent] = useState("");
|
const [newContent, setNewContent] = useState("");
|
||||||
|
|
||||||
const posts = postsData?.results ?? [];
|
const posts = postsData?.items ?? [];
|
||||||
|
|
||||||
const handleCreatePost = () => {
|
const handleCreatePost = () => {
|
||||||
if (!selectedBoardId) return;
|
if (!selectedBoardId) return;
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
|
import type {
|
||||||
|
AICourseConfig,
|
||||||
|
AICourseTrack,
|
||||||
|
QualityGateResult,
|
||||||
|
IELTSValidationResult,
|
||||||
|
} from "@/types/ai-course";
|
||||||
|
|
||||||
|
export type { QualityGateResult, IELTSValidationResult };
|
||||||
|
|
||||||
export interface AiCourseCreateEnglishRequest {
|
export interface AiCourseCreateEnglishRequest {
|
||||||
cefr_level: string;
|
cefr_level: string;
|
||||||
@@ -18,7 +26,11 @@ export interface AiCourseCreateResponse {
|
|||||||
skill?: string;
|
skill?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QualityGateResult {
|
/**
|
||||||
|
* Legacy flat shape returned by older backends. The canonical type is the
|
||||||
|
* structured {@link QualityGateResult} re-exported above.
|
||||||
|
*/
|
||||||
|
export interface QualityGateResultLegacy {
|
||||||
status: string;
|
status: string;
|
||||||
readability_score: number;
|
readability_score: number;
|
||||||
cefr_alignment: boolean;
|
cefr_alignment: boolean;
|
||||||
@@ -26,7 +38,7 @@ export interface QualityGateResult {
|
|||||||
attempts: number;
|
attempts: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IELTSValidationResult {
|
export interface IELTSValidationResultLegacy {
|
||||||
type: string;
|
type: string;
|
||||||
validation_results: Record<string, unknown>;
|
validation_results: Record<string, unknown>;
|
||||||
overall_passed: boolean;
|
overall_passed: boolean;
|
||||||
@@ -40,10 +52,10 @@ export const aiCourseService = {
|
|||||||
api.post<AiCourseCreateResponse>("/ai-course/ielts/create", data),
|
api.post<AiCourseCreateResponse>("/ai-course/ielts/create", data),
|
||||||
|
|
||||||
getCourse: (courseId: number) =>
|
getCourse: (courseId: number) =>
|
||||||
api.get<Record<string, unknown>>(`/ai-course/${courseId}`),
|
api.get<AICourseConfig>(`/ai-course/${courseId}`),
|
||||||
|
|
||||||
getTracks: (courseId: number) =>
|
getTracks: (courseId: number) =>
|
||||||
api.get<unknown[]>(`/ai-course/${courseId}/tracks`),
|
api.get<AICourseTrack[]>(`/ai-course/${courseId}/tracks`),
|
||||||
|
|
||||||
getQualityGate: (courseId: number) =>
|
getQualityGate: (courseId: number) =>
|
||||||
api.get<QualityGateResult>(`/ai-course/${courseId}/quality`),
|
api.get<QualityGateResult>(`/ai-course/${courseId}/quality`),
|
||||||
|
|||||||
54
frontend/src/services/ai-feedback.service.ts
Normal file
54
frontend/src/services/ai-feedback.service.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import type { PaginatedResponse } from "@/types/common";
|
||||||
|
import type {
|
||||||
|
AIFeedback,
|
||||||
|
AIFeedbackStatus,
|
||||||
|
AIFeedbackSubjectType,
|
||||||
|
AIFeedbackSubmitInput,
|
||||||
|
AIFeedbackSummary,
|
||||||
|
} from "@/types/ai-feedback";
|
||||||
|
|
||||||
|
export const aiFeedbackService = {
|
||||||
|
async submit(input: AIFeedbackSubmitInput): Promise<AIFeedback> {
|
||||||
|
return api.post<AIFeedback>("/ai/feedback", input);
|
||||||
|
},
|
||||||
|
|
||||||
|
async summary(
|
||||||
|
subjectType: AIFeedbackSubjectType,
|
||||||
|
subjectId: number,
|
||||||
|
): Promise<AIFeedbackSummary> {
|
||||||
|
return api.get<AIFeedbackSummary>("/ai/feedback/summary", {
|
||||||
|
subject_type: subjectType,
|
||||||
|
subject_id: subjectId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async list(
|
||||||
|
params: {
|
||||||
|
status?: AIFeedbackStatus;
|
||||||
|
rating?: "up" | "down";
|
||||||
|
subject_type?: AIFeedbackSubjectType;
|
||||||
|
prompt_key?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
} = {},
|
||||||
|
): Promise<PaginatedResponse<AIFeedback>> {
|
||||||
|
const res = await api.get<PaginatedResponse<AIFeedback>>("/ai/feedback", params);
|
||||||
|
const items = res.items ?? res.data ?? [];
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total: res.total ?? items.length,
|
||||||
|
page: res.page ?? 1,
|
||||||
|
size: res.size ?? items.length,
|
||||||
|
pages: res.pages ?? 1,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async resolve(
|
||||||
|
id: number,
|
||||||
|
status: Exclude<AIFeedbackStatus, "open">,
|
||||||
|
notes?: string,
|
||||||
|
): Promise<AIFeedback> {
|
||||||
|
return api.post<AIFeedback>(`/ai/feedback/${id}/resolve`, { status, notes });
|
||||||
|
},
|
||||||
|
};
|
||||||
61
frontend/src/services/ai-prompt.service.ts
Normal file
61
frontend/src/services/ai-prompt.service.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import type { PaginatedResponse } from "@/types/common";
|
||||||
|
import type {
|
||||||
|
AIPrompt,
|
||||||
|
AIPromptCreateInput,
|
||||||
|
AIPromptRenderResponse,
|
||||||
|
AIPromptSummary,
|
||||||
|
} from "@/types/ai-prompt";
|
||||||
|
|
||||||
|
interface PromptListParams {
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiPromptService = {
|
||||||
|
async listKeys(
|
||||||
|
params: PromptListParams = {},
|
||||||
|
): Promise<PaginatedResponse<AIPromptSummary>> {
|
||||||
|
const res = await api.get<PaginatedResponse<AIPromptSummary>>(
|
||||||
|
"/ai/prompts",
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
const items = res.items ?? res.data ?? [];
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total: res.total ?? items.length,
|
||||||
|
page: res.page ?? 1,
|
||||||
|
size: res.size ?? items.length,
|
||||||
|
pages: res.pages ?? 1,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async listVersions(key: string): Promise<AIPromptSummary[]> {
|
||||||
|
const res = await api.get<PaginatedResponse<AIPromptSummary>>(
|
||||||
|
`/ai/prompts/${encodeURIComponent(key)}/versions`,
|
||||||
|
);
|
||||||
|
return res.items ?? res.data ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: number): Promise<AIPrompt> {
|
||||||
|
return api.get<AIPrompt>(`/ai/prompts/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(input: AIPromptCreateInput): Promise<AIPrompt> {
|
||||||
|
return api.post<AIPrompt>("/ai/prompts", input);
|
||||||
|
},
|
||||||
|
|
||||||
|
async activate(id: number): Promise<AIPrompt> {
|
||||||
|
return api.post<AIPrompt>(`/ai/prompts/${id}/activate`, {});
|
||||||
|
},
|
||||||
|
|
||||||
|
async render(
|
||||||
|
id: number,
|
||||||
|
variables: Record<string, string>,
|
||||||
|
): Promise<AIPromptRenderResponse> {
|
||||||
|
return api.post<AIPromptRenderResponse>(`/ai/prompts/${id}/render`, {
|
||||||
|
variables,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,17 +1,36 @@
|
|||||||
import { api, setToken, clearToken } from "@/lib/api-client";
|
import { api, setToken, clearToken, persistTokenBundle } from "@/lib/api-client";
|
||||||
import type { LoginRequest, LoginResponse, User, ResetPasswordRequest, CurrentUserResponse } from "@/types";
|
import type { LoginRequest, LoginResponse, User, ResetPasswordRequest, CurrentUserResponse } from "@/types";
|
||||||
import type { ApiSuccessResponse } from "@/types";
|
import type { ApiSuccessResponse } from "@/types";
|
||||||
|
|
||||||
export const authService = {
|
export const authService = {
|
||||||
async login(data: LoginRequest): Promise<LoginResponse> {
|
async login(data: LoginRequest): Promise<LoginResponse> {
|
||||||
// Odoo accepts `login` or `email` in JSON body
|
// Odoo accepts `login` or `email` in JSON body
|
||||||
const res = await api.post<LoginResponse>("/login", { ...data, email: data.login });
|
const res = await api.post<LoginResponse & {
|
||||||
setToken(res.token);
|
access_token?: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
}>("/login", { ...data, email: data.login });
|
||||||
|
// Persist the full bundle so the transparent refresh loop in api-client
|
||||||
|
// has everything it needs. Falls back to the legacy single-token field
|
||||||
|
// for older backends that only return ``token``.
|
||||||
|
if (res.access_token || res.refresh_token) {
|
||||||
|
persistTokenBundle({
|
||||||
|
access_token: res.access_token,
|
||||||
|
token: res.token,
|
||||||
|
refresh_token: res.refresh_token,
|
||||||
|
expires_in: res.expires_in,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setToken(res.token);
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
|
|
||||||
async logout(): Promise<void> {
|
async logout(): Promise<void> {
|
||||||
await api.post<void>("/logout").catch(() => {});
|
const refresh = localStorage.getItem("encoach_refresh_token") || undefined;
|
||||||
|
await api
|
||||||
|
.post<void>("/logout", refresh ? { refresh_token: refresh } : undefined)
|
||||||
|
.catch(() => {});
|
||||||
clearToken();
|
clearToken();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
57
frontend/src/services/exam-review.service.ts
Normal file
57
frontend/src/services/exam-review.service.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import type { PaginatedResponse } from "@/types/common";
|
||||||
|
import type {
|
||||||
|
ExamReviewDetail,
|
||||||
|
ExamReviewListItem,
|
||||||
|
} from "@/types/exam-review";
|
||||||
|
|
||||||
|
export interface ExamReviewQueueParams {
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
search?: string;
|
||||||
|
status?: "draft" | "pending_review" | "published" | "archived";
|
||||||
|
subject_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-facing review queue. Everything here assumes the caller has an admin
|
||||||
|
* JWT — the server is authoritative; the frontend just surfaces errors.
|
||||||
|
*/
|
||||||
|
export const examReviewService = {
|
||||||
|
async queue(
|
||||||
|
params: ExamReviewQueueParams = {},
|
||||||
|
): Promise<PaginatedResponse<ExamReviewListItem>> {
|
||||||
|
const res = await api.get<PaginatedResponse<ExamReviewListItem>>(
|
||||||
|
"/exam/review/queue",
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
const items = res.items ?? res.data ?? [];
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total: res.total ?? items.length,
|
||||||
|
page: res.page ?? 1,
|
||||||
|
size: res.size ?? items.length,
|
||||||
|
pages: res.pages ?? 1,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async detail(examId: number): Promise<ExamReviewDetail> {
|
||||||
|
return api.get<ExamReviewDetail>(`/exam/review/${examId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async approve(
|
||||||
|
examId: number,
|
||||||
|
notes?: string,
|
||||||
|
): Promise<ExamReviewListItem> {
|
||||||
|
return api.post<ExamReviewListItem>(
|
||||||
|
`/exam/review/${examId}/approve`,
|
||||||
|
notes ? { notes } : {},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async reject(examId: number, notes: string): Promise<ExamReviewListItem> {
|
||||||
|
return api.post<ExamReviewListItem>(`/exam/review/${examId}/reject`, {
|
||||||
|
notes,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -15,6 +15,6 @@ export const examSessionService = {
|
|||||||
getStatus: (examId: number) =>
|
getStatus: (examId: number) =>
|
||||||
api.get<{ status: string; scores_available: boolean }>(`/exam/${examId}/status`),
|
api.get<{ status: string; scores_available: boolean }>(`/exam/${examId}/status`),
|
||||||
|
|
||||||
getResults: (examId: number) =>
|
getResults: <T = unknown>(examId: number) =>
|
||||||
api.get(`/exam/${examId}/results`),
|
api.get<T>(`/exam/${examId}/results`),
|
||||||
};
|
};
|
||||||
|
|||||||
36
frontend/src/services/gdpr.service.ts
Normal file
36
frontend/src/services/gdpr.service.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
|
||||||
|
export interface GdprExportPayload {
|
||||||
|
profile: Record<string, unknown>;
|
||||||
|
partner: Record<string, unknown>;
|
||||||
|
entity_memberships: unknown[];
|
||||||
|
exam_attempts: unknown[];
|
||||||
|
exam_answers: unknown[];
|
||||||
|
ai_feedback: unknown[];
|
||||||
|
ai_calls: unknown[];
|
||||||
|
tickets: unknown[];
|
||||||
|
coaching_sessions: unknown[];
|
||||||
|
exported_at: string;
|
||||||
|
export_format_version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GdprEraseResponse {
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
|
summary: {
|
||||||
|
anonymised_partner_fields: string[];
|
||||||
|
deactivated_user: boolean;
|
||||||
|
deleted_feedback_count: number;
|
||||||
|
deleted_coaching_count: number;
|
||||||
|
retained_attempts_count: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gdprService = {
|
||||||
|
async exportMyData(): Promise<GdprExportPayload> {
|
||||||
|
return api.get<GdprExportPayload>("/gdpr/export");
|
||||||
|
},
|
||||||
|
async eraseMyAccount(): Promise<GdprEraseResponse> {
|
||||||
|
return api.post<GdprEraseResponse>("/gdpr/delete", { confirm: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -130,9 +130,10 @@ export const lmsService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async listCourses(params?: PaginationParams & { status?: string }): Promise<PaginatedResponse<Course>> {
|
async listCourses(params?: PaginationParams & { status?: string }): Promise<PaginatedResponse<Course>> {
|
||||||
const raw = await api.get<unknown>("/courses", params as Record<string, string | number | boolean | undefined>);
|
const raw = await api.get<unknown>("/courses", params);
|
||||||
const p = asPaginated<Record<string, unknown>>(raw);
|
const p = asPaginated<Record<string, unknown>>(raw);
|
||||||
return { ...p, items: p.items.map(mapCourseRaw) };
|
const items = p.items.map(mapCourseRaw);
|
||||||
|
return { items, total: p.total, page: p.page, size: p.size, pages: p.pages };
|
||||||
},
|
},
|
||||||
|
|
||||||
async getCourse(id: number): Promise<Course> {
|
async getCourse(id: number): Promise<Course> {
|
||||||
@@ -183,9 +184,10 @@ export const lmsService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async listBatches(params?: PaginationParams): Promise<PaginatedResponse<Batch>> {
|
async listBatches(params?: PaginationParams): Promise<PaginatedResponse<Batch>> {
|
||||||
const raw = await api.get<unknown>("/batches", params as Record<string, string | number | boolean | undefined>);
|
const raw = await api.get<unknown>("/batches", params);
|
||||||
const p = asPaginated<Record<string, unknown>>(raw);
|
const p = asPaginated<Record<string, unknown>>(raw);
|
||||||
return { ...p, items: p.items.map(mapBatchRaw) };
|
const items = p.items.map(mapBatchRaw);
|
||||||
|
return { items, total: p.total, page: p.page, size: p.size, pages: p.pages };
|
||||||
},
|
},
|
||||||
|
|
||||||
async getBatch(id: number): Promise<Batch> {
|
async getBatch(id: number): Promise<Batch> {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import type { ExamSession, ExamStat, StatisticalData } from "@/types";
|
import type { StatsExamSession as ExamSession, ExamStat, StatisticalData } from "@/types";
|
||||||
|
|
||||||
export const statsService = {
|
export const statsService = {
|
||||||
async getSessions(params?: Record<string, string | number | boolean | undefined>): Promise<ExamSession[]> {
|
async getSessions(params?: Record<string, string | number | boolean | undefined>): Promise<ExamSession[]> {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ export const taxonomyService = {
|
|||||||
|
|
||||||
async getSubject(id: number): Promise<Subject> {
|
async getSubject(id: number): Promise<Subject> {
|
||||||
const res = await api.get<{ data: Subject } | Subject>(`/subjects/${id}`);
|
const res = await api.get<{ data: Subject } | Subject>(`/subjects/${id}`);
|
||||||
return (res as { data: Subject }).data ?? res;
|
const wrapped = res as { data?: Subject };
|
||||||
|
return (wrapped.data ?? (res as Subject));
|
||||||
},
|
},
|
||||||
|
|
||||||
async createSubject(data: Partial<Subject>): Promise<Subject> {
|
async createSubject(data: Partial<Subject>): Promise<Subject> {
|
||||||
@@ -28,7 +29,8 @@ export const taxonomyService = {
|
|||||||
|
|
||||||
async getTaxonomyTree(subjectId: number): Promise<TaxonomyTree> {
|
async getTaxonomyTree(subjectId: number): Promise<TaxonomyTree> {
|
||||||
const res = await api.get<{ data: TaxonomyTree } | TaxonomyTree>(`/subjects/${subjectId}/taxonomy`);
|
const res = await api.get<{ data: TaxonomyTree } | TaxonomyTree>(`/subjects/${subjectId}/taxonomy`);
|
||||||
return (res as { data: TaxonomyTree }).data ?? res;
|
const wrapped = res as { data?: TaxonomyTree };
|
||||||
|
return (wrapped.data ?? (res as TaxonomyTree));
|
||||||
},
|
},
|
||||||
|
|
||||||
async importTaxonomy(subjectId: number, data: FormData): Promise<ApiSuccessResponse> {
|
async importTaxonomy(subjectId: number, data: FormData): Promise<ApiSuccessResponse> {
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ function mapPortalUser(raw: Record<string, unknown>): User {
|
|||||||
|
|
||||||
export const usersService = {
|
export const usersService = {
|
||||||
async list(params: UserListParams): Promise<PaginatedResponse<User>> {
|
async list(params: UserListParams): Promise<PaginatedResponse<User>> {
|
||||||
const raw = await api.get<unknown>("/users/list", params as Record<string, string | number | boolean | undefined>);
|
const raw = await api.get<unknown>("/users/list", params);
|
||||||
const p = asPaginated<Record<string, unknown>>(raw);
|
const p = asPaginated<Record<string, unknown>>(raw);
|
||||||
return { ...p, items: p.items.map(mapPortalUser) };
|
const items = p.items.map(mapPortalUser);
|
||||||
|
return { items, total: p.total, page: p.page, size: p.size, pages: p.pages };
|
||||||
},
|
},
|
||||||
|
|
||||||
async getById(id: number): Promise<User> {
|
async getById(id: number): Promise<User> {
|
||||||
|
|||||||
@@ -179,10 +179,10 @@ export interface StudentAbilityModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AdaptiveThresholdSettings {
|
export interface AdaptiveThresholdSettings {
|
||||||
difficulty_step_up_threshold: number;
|
difficulty_step_up_threshold?: number;
|
||||||
difficulty_step_down_threshold: number;
|
difficulty_step_down_threshold?: number;
|
||||||
micro_lesson_trigger: number;
|
micro_lesson_trigger?: number;
|
||||||
module_skip_threshold: number;
|
module_skip_threshold?: number;
|
||||||
no_progress_alert_days: number;
|
no_progress_alert_days?: number;
|
||||||
max_retries_per_exercise: number;
|
max_retries_per_exercise?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
55
frontend/src/types/ai-feedback.ts
Normal file
55
frontend/src/types/ai-feedback.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
export type AIFeedbackSubjectType =
|
||||||
|
| "question"
|
||||||
|
| "coach"
|
||||||
|
| "explanation"
|
||||||
|
| "translation"
|
||||||
|
| "narrative"
|
||||||
|
| "other";
|
||||||
|
|
||||||
|
export type AIFeedbackRating = "up" | "down";
|
||||||
|
|
||||||
|
export type AIFeedbackStatus = "open" | "acknowledged" | "fixed" | "dismissed";
|
||||||
|
|
||||||
|
export interface AIFeedbackSubmitInput {
|
||||||
|
subject_type: AIFeedbackSubjectType;
|
||||||
|
subject_id: number;
|
||||||
|
rating: AIFeedbackRating;
|
||||||
|
comment?: string;
|
||||||
|
tags?: string[];
|
||||||
|
prompt_key?: string;
|
||||||
|
prompt_version?: number;
|
||||||
|
ai_log_id?: number;
|
||||||
|
entity_id?: number;
|
||||||
|
course_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIFeedback {
|
||||||
|
id: number;
|
||||||
|
subject_type: AIFeedbackSubjectType;
|
||||||
|
subject_id: number;
|
||||||
|
subject_key: string;
|
||||||
|
rating: AIFeedbackRating;
|
||||||
|
comment: string;
|
||||||
|
tags: string[];
|
||||||
|
prompt_key: string | null;
|
||||||
|
prompt_version: number | null;
|
||||||
|
ai_log_id: number | null;
|
||||||
|
user_id: number;
|
||||||
|
user_name: string;
|
||||||
|
entity_id: number | null;
|
||||||
|
course_id: number | null;
|
||||||
|
status: AIFeedbackStatus;
|
||||||
|
create_date: string | null;
|
||||||
|
resolved_at: string | null;
|
||||||
|
resolution_notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIFeedbackSummary {
|
||||||
|
subject_type: AIFeedbackSubjectType;
|
||||||
|
subject_id: number;
|
||||||
|
up: number;
|
||||||
|
down: number;
|
||||||
|
total: number;
|
||||||
|
my_rating: AIFeedbackRating | null;
|
||||||
|
my_comment: string;
|
||||||
|
}
|
||||||
32
frontend/src/types/ai-prompt.ts
Normal file
32
frontend/src/types/ai-prompt.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export interface AIPromptSummary {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
version: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
is_active: boolean;
|
||||||
|
variables: string[];
|
||||||
|
author_id: number | null;
|
||||||
|
author_name: string;
|
||||||
|
activated_at: string | null;
|
||||||
|
create_date: string | null;
|
||||||
|
total_versions?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIPrompt extends AIPromptSummary {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIPromptCreateInput {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
content: string;
|
||||||
|
activate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIPromptRenderResponse {
|
||||||
|
rendered: string;
|
||||||
|
key: string;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
export interface BrandingConfig {
|
export interface BrandingConfig {
|
||||||
logo_url?: string;
|
logo_url?: string;
|
||||||
primary_color: string;
|
primary_color?: string;
|
||||||
secondary_color: string;
|
secondary_color?: string;
|
||||||
background_color: string;
|
background_color?: string;
|
||||||
custom_subdomain?: string;
|
custom_subdomain?: string;
|
||||||
login_title?: string;
|
login_title?: string;
|
||||||
login_description?: string;
|
login_description?: string;
|
||||||
|
|||||||
@@ -55,11 +55,13 @@ export interface ModuleConfig {
|
|||||||
estimated_hours: number;
|
estimated_hours: number;
|
||||||
cefr_target?: string;
|
cefr_target?: string;
|
||||||
resources: ModuleResource[];
|
resources: ModuleResource[];
|
||||||
completion_criteria: "complete_all" | "score_threshold" | "teacher_approval";
|
completion_criteria?: "complete_all" | "score_threshold" | "teacher_approval";
|
||||||
score_threshold?: number;
|
score_threshold?: number;
|
||||||
prerequisite_module_id?: number;
|
prerequisite_module_id?: number;
|
||||||
status: "locked" | "available" | "in_progress" | "completed" | "skipped";
|
status?: "locked" | "available" | "in_progress" | "completed" | "skipped";
|
||||||
progress_percent?: number;
|
progress_percent?: number;
|
||||||
|
/** Optional assessment gate attached to the module (quiz, project, etc.) */
|
||||||
|
assessment_type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModuleResource {
|
export interface ModuleResource {
|
||||||
|
|||||||
64
frontend/src/types/exam-review.ts
Normal file
64
frontend/src/types/exam-review.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Types for the human-in-the-loop exam review workflow.
|
||||||
|
*
|
||||||
|
* Wire format mirrors `encoach_exam_template.controllers.review_workflow`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ExamReviewSummary {
|
||||||
|
question_count: number;
|
||||||
|
avg_quality_score: number;
|
||||||
|
min_quality_score: number;
|
||||||
|
failing_count: number;
|
||||||
|
ai_generated_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamReviewListItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
status: "draft" | "pending_review" | "published" | "archived";
|
||||||
|
subject_id: number | null;
|
||||||
|
subject_name: string;
|
||||||
|
entity_id: number | null;
|
||||||
|
teacher_id: number | null;
|
||||||
|
teacher_name: string;
|
||||||
|
grading_system: string;
|
||||||
|
total_time_min: number;
|
||||||
|
total_marks: number;
|
||||||
|
reviewed_by_id: number | null;
|
||||||
|
reviewed_by_name: string;
|
||||||
|
reviewed_at: string | null;
|
||||||
|
review_notes: string;
|
||||||
|
summary: ExamReviewSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamReviewQuestion {
|
||||||
|
id: number;
|
||||||
|
skill: string;
|
||||||
|
question_type: string;
|
||||||
|
difficulty: string;
|
||||||
|
stem: string;
|
||||||
|
marks: number;
|
||||||
|
ai_generated: boolean;
|
||||||
|
ielts_certified: boolean;
|
||||||
|
format_validated: boolean;
|
||||||
|
ai_model_used: string;
|
||||||
|
ai_prompt_hash: string;
|
||||||
|
quality_score: number;
|
||||||
|
/**
|
||||||
|
* Raw quality report from QualityChecker + IeltsValidator. Shape is loose on
|
||||||
|
* purpose — the review UI renders it as pretty-printed JSON for operators.
|
||||||
|
*/
|
||||||
|
quality_report: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamReviewSection {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
skill: string;
|
||||||
|
sequence: number;
|
||||||
|
questions: ExamReviewQuestion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamReviewDetail extends ExamReviewListItem {
|
||||||
|
sections: ExamReviewSection[];
|
||||||
|
}
|
||||||
@@ -63,7 +63,7 @@ export interface Batch {
|
|||||||
|
|
||||||
export interface TimetableSession {
|
export interface TimetableSession {
|
||||||
id: number;
|
id: number;
|
||||||
course_id: number;
|
course_id: string | number;
|
||||||
course_name: string;
|
course_name: string;
|
||||||
teacher_name: string;
|
teacher_name: string;
|
||||||
batch_name: string;
|
batch_name: string;
|
||||||
@@ -71,6 +71,8 @@ export interface TimetableSession {
|
|||||||
start_time: string;
|
start_time: string;
|
||||||
end_time: string;
|
end_time: string;
|
||||||
room: string;
|
room: string;
|
||||||
|
/** Optional hex/rgb used by the weekly grid renderer. */
|
||||||
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttendanceRecord {
|
export interface AttendanceRecord {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export interface PendingScore {
|
export interface PendingScore {
|
||||||
id: number;
|
id: number;
|
||||||
attempt_id: number;
|
attempt_id: number;
|
||||||
|
exam_id?: number;
|
||||||
student_name: string;
|
student_name: string;
|
||||||
exam_title: string;
|
exam_title: string;
|
||||||
exam_type: "official" | "practice";
|
exam_type: "official" | "practice";
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
export interface ExamSession {
|
/**
|
||||||
|
* Summary record returned by stats/report endpoints.
|
||||||
|
*
|
||||||
|
* Historically exported as `ExamSession` which clashed with the richer live
|
||||||
|
* session model in ``./exam-session.ts``. Keep the legacy name as a deprecated
|
||||||
|
* alias so existing imports compile, but prefer ``StatsExamSession`` for new
|
||||||
|
* code.
|
||||||
|
*/
|
||||||
|
export interface StatsExamSession {
|
||||||
id: number;
|
id: number;
|
||||||
user_id: number;
|
user_id: number;
|
||||||
exam_id: number;
|
exam_id: number;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface Domain {
|
|||||||
export interface Topic {
|
export interface Topic {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
description?: string;
|
||||||
code: string;
|
code: string;
|
||||||
domain_id: number;
|
domain_id: number;
|
||||||
domain_name: string;
|
domain_name: string;
|
||||||
|
|||||||
@@ -25,4 +25,45 @@ export default defineConfig(({ mode }) => ({
|
|||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
// Raise warning threshold slightly — route chunks and the charts vendor
|
||||||
|
// bundle legitimately trend 300–500KB each and we already code-split.
|
||||||
|
chunkSizeWarningLimit: 900,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
// Pin the React core to its own chunk — it's in the critical path on
|
||||||
|
// every route and never changes between deploys, so users cache it.
|
||||||
|
"vendor-react": ["react", "react-dom", "react-router-dom"],
|
||||||
|
// React Query + auth context sit behind every authenticated page.
|
||||||
|
"vendor-query": ["@tanstack/react-query"],
|
||||||
|
// Recharts is large and only used on dashboard/report pages; keeping
|
||||||
|
// it separate lets the login/landing chunks stay small.
|
||||||
|
"vendor-charts": ["recharts"],
|
||||||
|
// Radix/shadcn primitives are shared across most pages.
|
||||||
|
"vendor-radix": [
|
||||||
|
"@radix-ui/react-dialog",
|
||||||
|
"@radix-ui/react-dropdown-menu",
|
||||||
|
"@radix-ui/react-popover",
|
||||||
|
"@radix-ui/react-select",
|
||||||
|
"@radix-ui/react-tabs",
|
||||||
|
"@radix-ui/react-toast",
|
||||||
|
"@radix-ui/react-tooltip",
|
||||||
|
"@radix-ui/react-accordion",
|
||||||
|
"@radix-ui/react-checkbox",
|
||||||
|
"@radix-ui/react-label",
|
||||||
|
"@radix-ui/react-progress",
|
||||||
|
"@radix-ui/react-radio-group",
|
||||||
|
"@radix-ui/react-scroll-area",
|
||||||
|
"@radix-ui/react-separator",
|
||||||
|
"@radix-ui/react-slider",
|
||||||
|
"@radix-ui/react-switch",
|
||||||
|
"@radix-ui/react-avatar",
|
||||||
|
],
|
||||||
|
"vendor-forms": ["react-hook-form", "@hookform/resolvers", "zod"],
|
||||||
|
"vendor-icons": ["lucide-react"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user