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
e2e/login.spec.ts
Normal file
27
e2e/login.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Smoke test: the app loads, serves the login page, and the main form
|
||||
* controls are present and reachable. We deliberately avoid hitting the
|
||||
* real backend — this is a lightweight sanity check that catches broken
|
||||
* routing / bundle / asset problems before they reach production.
|
||||
*/
|
||||
test("login page renders the sign-in form", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /sign in|login|welcome/i }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const email = page.getByLabel(/email/i);
|
||||
const password = page.getByLabel(/password/i);
|
||||
await expect(email).toBeVisible();
|
||||
await expect(password).toBeVisible();
|
||||
|
||||
await expect(page.getByRole("button", { name: /sign in|log in/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test("root redirects to login for anonymous users", async ({ page }) => {
|
||||
const response = await page.goto("/");
|
||||
expect(response?.ok()).toBeTruthy();
|
||||
await page.waitForURL(/\/login/i, { timeout: 10_000 });
|
||||
});
|
||||
154
package-lock.json
generated
154
package-lock.json
generated
@@ -42,6 +42,8 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"i18next": "^26.0.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next-themes": "^0.3.0",
|
||||
@@ -49,6 +51,7 @@
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.61.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-resizable-panels": "^2.1.9",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"recharts": "^2.15.4",
|
||||
@@ -60,6 +63,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
@@ -905,6 +909,22 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||
@@ -5480,6 +5500,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
|
||||
@@ -5509,6 +5538,46 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.0.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.6.tgz",
|
||||
"integrity": "sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-browser-languagedetector": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
|
||||
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
@@ -6215,6 +6284,53 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.9",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
|
||||
@@ -6547,6 +6663,33 @@
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.4.tgz",
|
||||
"integrity": "sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.0.1",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
@@ -7369,7 +7512,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -8164,6 +8307,15 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
|
||||
|
||||
10
package.json
10
package.json
@@ -10,7 +10,9 @@
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:install": "playwright install --with-deps chromium"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
@@ -47,6 +49,8 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"i18next": "^26.0.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next-themes": "^0.3.0",
|
||||
@@ -54,6 +58,7 @@
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.61.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-resizable-panels": "^2.1.9",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"recharts": "^2.15.4",
|
||||
@@ -65,9 +70,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/node": "^22.16.5",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
|
||||
42
playwright.config.ts
Normal file
42
playwright.config.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Playwright configuration for EnCoach frontend smoke tests.
|
||||
*
|
||||
* By default we run against a Vite preview server on localhost:4173. In CI
|
||||
* this is started by the `playwright` step in .github/workflows/ci.yml after
|
||||
* `npm run build`.
|
||||
*
|
||||
* Override the base URL with `PLAYWRIGHT_BASE_URL=https://... npx playwright
|
||||
* test` to run against a deployed environment.
|
||||
*/
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:4173";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 5_000 },
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: process.env.CI ? [["github"], ["list"]] : [["list"]],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
webServer: process.env.PLAYWRIGHT_SKIP_WEBSERVER
|
||||
? undefined
|
||||
: {
|
||||
command: "npm run preview -- --host 127.0.0.1 --port 4173",
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60_000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
12
public/logo.svg
Normal file
12
public/logo.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="EnCoach">
|
||||
<defs>
|
||||
<linearGradient id="eg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#4f46e5"/>
|
||||
<stop offset="50%" stop-color="#7c3aed"/>
|
||||
<stop offset="100%" stop-color="#2563eb"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#eg)"/>
|
||||
<path d="M20 20h22a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H26v6h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H26v6h16a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H20a2 2 0 0 1-2-2V22a2 2 0 0 1 2-2Z" fill="#ffffff"/>
|
||||
<circle cx="48" cy="48" r="5" fill="#facc15" stroke="#ffffff" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 697 B |
320
src/App.tsx
320
src/App.tsx
@@ -1,3 +1,5 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
@@ -8,146 +10,166 @@ import ProtectedRoute from "@/components/ProtectedRoute";
|
||||
import StudentLayout from "@/components/StudentLayout";
|
||||
import TeacherLayout from "@/components/TeacherLayout";
|
||||
import AdminLmsLayout from "@/components/AdminLmsLayout";
|
||||
import Login from "@/pages/Login";
|
||||
import Register from "@/pages/Register";
|
||||
import EmailVerification from "@/pages/EmailVerification";
|
||||
import OnboardingWizard from "@/pages/OnboardingWizard";
|
||||
import ForgotPassword from "@/pages/ForgotPassword";
|
||||
import ResetPassword from "@/pages/ResetPassword";
|
||||
import ScoreVerification from "@/pages/ScoreVerification";
|
||||
// Original platform pages
|
||||
import AdminDashboard from "@/pages/AdminDashboard";
|
||||
import UsersPage from "@/pages/UsersPage";
|
||||
import EntitiesPage from "@/pages/EntitiesPage";
|
||||
import AssignmentsPage from "@/pages/AssignmentsPage";
|
||||
import ExamsListPage from "@/pages/ExamsListPage";
|
||||
import ExamStructuresPage from "@/pages/ExamStructuresPage";
|
||||
import RubricsPage from "@/pages/RubricsPage";
|
||||
import GenerationPage from "@/pages/GenerationPage";
|
||||
import ApprovalWorkflowsPage from "@/pages/ApprovalWorkflowsPage";
|
||||
import ClassroomsPage from "@/pages/ClassroomsPage";
|
||||
import StudentPerformancePage from "@/pages/StudentPerformancePage";
|
||||
import StatsCorporatePage from "@/pages/StatsCorporatePage";
|
||||
import RecordPage from "@/pages/RecordPage";
|
||||
import VocabularyPage from "@/pages/VocabularyPage";
|
||||
import GrammarPage from "@/pages/GrammarPage";
|
||||
import PaymentRecordPage from "@/pages/PaymentRecordPage";
|
||||
import TicketsPage from "@/pages/TicketsPage";
|
||||
import SettingsPage from "@/pages/SettingsPage";
|
||||
import ProfilePage from "@/pages/ProfilePage";
|
||||
import ExamPage from "@/pages/ExamPage";
|
||||
// Student pages
|
||||
import StudentDashboard from "@/pages/student/StudentDashboard";
|
||||
import StudentCourses from "@/pages/student/StudentCourses";
|
||||
import StudentCourseDetail from "@/pages/student/StudentCourseDetail";
|
||||
import StudentAssignments from "@/pages/student/StudentAssignments";
|
||||
import StudentGrades from "@/pages/student/StudentGrades";
|
||||
import StudentAttendance from "@/pages/student/StudentAttendance";
|
||||
import StudentTimetable from "@/pages/student/StudentTimetable";
|
||||
import StudentProfile from "@/pages/student/StudentProfile";
|
||||
// Adaptive learning pages
|
||||
import SubjectSelection from "@/pages/student/SubjectSelection";
|
||||
import DiagnosticTest from "@/pages/student/DiagnosticTest";
|
||||
import ProficiencyProfile from "@/pages/student/ProficiencyProfile";
|
||||
import LearningPlanPage from "@/pages/student/LearningPlan";
|
||||
import TopicLearning from "@/pages/student/TopicLearning";
|
||||
// Teacher pages
|
||||
import TeacherDashboard from "@/pages/teacher/TeacherDashboard";
|
||||
import TeacherCourses from "@/pages/teacher/TeacherCourses";
|
||||
import CourseBuilder from "@/pages/teacher/CourseBuilder";
|
||||
import TeacherAssignments from "@/pages/teacher/TeacherAssignments";
|
||||
import TeacherAssignmentDetail from "@/pages/teacher/TeacherAssignmentDetail";
|
||||
import TeacherAttendance from "@/pages/teacher/TeacherAttendance";
|
||||
import TeacherStudents from "@/pages/teacher/TeacherStudents";
|
||||
import TeacherTimetable from "@/pages/teacher/TeacherTimetable";
|
||||
import TeacherProfile from "@/pages/teacher/TeacherProfile";
|
||||
import TeacherLibrary from "@/pages/teacher/TeacherLibrary";
|
||||
import AdaptiveSettings from "@/pages/teacher/AdaptiveSettings";
|
||||
// Admin LMS pages
|
||||
import AdminLmsDashboard from "@/pages/admin/AdminLmsDashboard";
|
||||
import AdminCourses from "@/pages/admin/AdminCourses";
|
||||
import AdminStudents from "@/pages/admin/AdminStudents";
|
||||
import AdminTeachers from "@/pages/admin/AdminTeachers";
|
||||
import AdminBatches from "@/pages/admin/AdminBatches";
|
||||
import AdminBatchDetail from "@/pages/admin/AdminBatchDetail";
|
||||
import AdminTimetable from "@/pages/admin/AdminTimetable";
|
||||
import AdminReports from "@/pages/admin/AdminReports";
|
||||
import AdminSettings from "@/pages/admin/AdminSettings";
|
||||
import AdminProfileLms from "@/pages/admin/AdminProfile";
|
||||
import TaxonomyManager from "@/pages/admin/TaxonomyManager";
|
||||
import ResourceManager from "@/pages/admin/ResourceManager";
|
||||
import AcademicYearManager from "@/pages/admin/AcademicYearManager";
|
||||
import DepartmentManager from "@/pages/admin/DepartmentManager";
|
||||
import AdmissionList from "@/pages/admin/AdmissionList";
|
||||
import AdmissionDetail from "@/pages/admin/AdmissionDetail";
|
||||
import AdmissionRegisterPage from "@/pages/admin/AdmissionRegisterPage";
|
||||
import InstitutionalExamSessions from "@/pages/admin/InstitutionalExamSessions";
|
||||
import MarksheetManager from "@/pages/admin/MarksheetManager";
|
||||
import AdminStudentLeave from "@/pages/admin/AdminStudentLeave";
|
||||
import AdminFees from "@/pages/admin/AdminFees";
|
||||
import AdminLessons from "@/pages/admin/AdminLessons";
|
||||
import AdminGradebook from "@/pages/admin/AdminGradebook";
|
||||
import AdminStudentProgress from "@/pages/admin/AdminStudentProgress";
|
||||
import AdminLibrary from "@/pages/admin/AdminLibrary";
|
||||
import AdminActivities from "@/pages/admin/AdminActivities";
|
||||
import AdminFacilities from "@/pages/admin/AdminFacilities";
|
||||
import AdmissionApplication from "@/pages/AdmissionApplication";
|
||||
import SubjectRegistrationPage from "@/pages/student/SubjectRegistrationPage";
|
||||
// Courseware pages
|
||||
import CourseChapters from "@/pages/teacher/CourseChapters";
|
||||
import ChapterDetail from "@/pages/teacher/ChapterDetail";
|
||||
import AiWorkbench from "@/pages/teacher/AiWorkbench";
|
||||
import TeacherDiscussionBoard from "@/pages/teacher/TeacherDiscussionBoard";
|
||||
import TeacherAnnouncements from "@/pages/teacher/TeacherAnnouncements";
|
||||
import StudentChapterView from "@/pages/student/StudentChapterView";
|
||||
import StudentDiscussionBoard from "@/pages/student/StudentDiscussionBoard";
|
||||
import StudentAnnouncements from "@/pages/student/StudentAnnouncements";
|
||||
import StudentMessages from "@/pages/student/StudentMessages";
|
||||
import StudentJourney from "@/pages/student/StudentJourney";
|
||||
import AiEnglishCourse from "@/pages/student/AiEnglishCourse";
|
||||
import AiIeltsCourse from "@/pages/student/AiIeltsCourse";
|
||||
import ExamSession from "@/pages/student/ExamSession";
|
||||
import ExamStatus from "@/pages/student/ExamStatus";
|
||||
import ExamResults from "@/pages/student/ExamResults";
|
||||
import GapAnalysis from "@/pages/student/GapAnalysis";
|
||||
import CourseDelivery from "@/pages/student/CourseDelivery";
|
||||
import GradingQueue from "@/pages/admin/GradingQueue";
|
||||
import CourseConfig from "@/pages/admin/CourseConfig";
|
||||
import ModuleBuilder from "@/pages/admin/ModuleBuilder";
|
||||
import CourseProgress from "@/pages/teacher/CourseProgress";
|
||||
import PlacementBriefing from "@/pages/student/PlacementBriefing";
|
||||
import PlacementTest from "@/pages/student/PlacementTest";
|
||||
import PlacementResults from "@/pages/student/PlacementResults";
|
||||
import PlacementAccess from "@/pages/student/PlacementAccess";
|
||||
import FaqManager from "@/pages/admin/FaqManager";
|
||||
import NotificationRules from "@/pages/admin/NotificationRules";
|
||||
import ApprovalWorkflowConfig from "@/pages/admin/ApprovalWorkflowConfig";
|
||||
import RolesPermissions from "@/pages/admin/RolesPermissions";
|
||||
import AuthorityMatrix from "@/pages/admin/AuthorityMatrix";
|
||||
import UserRoles from "@/pages/admin/UserRoles";
|
||||
import BulkStudentUpload from "@/pages/admin/BulkStudentUpload";
|
||||
import CredentialDashboard from "@/pages/admin/CredentialDashboard";
|
||||
import AiEnglishQuality from "@/pages/admin/AiEnglishQuality";
|
||||
import AiEnglishTaxonomy from "@/pages/admin/AiEnglishTaxonomy";
|
||||
import AiIeltsValidation from "@/pages/admin/AiIeltsValidation";
|
||||
import AdaptiveDashboard from "@/pages/admin/AdaptiveDashboard";
|
||||
import AdaptiveStudentDetail from "@/pages/admin/AdaptiveStudentDetail";
|
||||
import LevelMappingConfig from "@/pages/admin/LevelMappingConfig";
|
||||
import WhiteLabelBranding from "@/pages/admin/WhiteLabelBranding";
|
||||
import ScoreApprovalQueue from "@/pages/admin/ScoreApprovalQueue";
|
||||
import ExamTemplateSelection from "@/pages/admin/ExamTemplateSelection";
|
||||
import IeltsExamCreate from "@/pages/admin/IeltsExamCreate";
|
||||
import IeltsSkillConfig from "@/pages/admin/IeltsSkillConfig";
|
||||
import IeltsContentPool from "@/pages/admin/IeltsContentPool";
|
||||
import IeltsExamValidation from "@/pages/admin/IeltsExamValidation";
|
||||
import CustomExamCreate from "@/pages/admin/CustomExamCreate";
|
||||
import OfficialExamAccess from "@/pages/OfficialExamAccess";
|
||||
import FaqPage from "@/pages/FaqPage";
|
||||
import NotFound from "@/pages/NotFound";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Lazy-loaded route pages
|
||||
// -----------------------------------------------------------------------------
|
||||
// Keeping the initial bundle small matters on slow networks / modest hardware
|
||||
// (many teachers/students open the app from school Wi-Fi). Each page below is
|
||||
// split into its own chunk and only fetched when the user hits the matching
|
||||
// route. The shell (layouts, providers, router) is still eagerly imported so
|
||||
// the first paint stays snappy.
|
||||
|
||||
// Auth
|
||||
const Login = lazy(() => import("@/pages/Login"));
|
||||
const Register = lazy(() => import("@/pages/Register"));
|
||||
const EmailVerification = lazy(() => import("@/pages/EmailVerification"));
|
||||
const OnboardingWizard = lazy(() => import("@/pages/OnboardingWizard"));
|
||||
const ForgotPassword = lazy(() => import("@/pages/ForgotPassword"));
|
||||
const ResetPassword = lazy(() => import("@/pages/ResetPassword"));
|
||||
const ScoreVerification = lazy(() => import("@/pages/ScoreVerification"));
|
||||
|
||||
// Original platform pages
|
||||
const AdminDashboard = lazy(() => import("@/pages/AdminDashboard"));
|
||||
const UsersPage = lazy(() => import("@/pages/UsersPage"));
|
||||
const EntitiesPage = lazy(() => import("@/pages/EntitiesPage"));
|
||||
const AssignmentsPage = lazy(() => import("@/pages/AssignmentsPage"));
|
||||
const ExamsListPage = lazy(() => import("@/pages/ExamsListPage"));
|
||||
const ExamStructuresPage = lazy(() => import("@/pages/ExamStructuresPage"));
|
||||
const RubricsPage = lazy(() => import("@/pages/RubricsPage"));
|
||||
const GenerationPage = lazy(() => import("@/pages/GenerationPage"));
|
||||
const ApprovalWorkflowsPage = lazy(() => import("@/pages/ApprovalWorkflowsPage"));
|
||||
const ClassroomsPage = lazy(() => import("@/pages/ClassroomsPage"));
|
||||
const StudentPerformancePage = lazy(() => import("@/pages/StudentPerformancePage"));
|
||||
const StatsCorporatePage = lazy(() => import("@/pages/StatsCorporatePage"));
|
||||
const RecordPage = lazy(() => import("@/pages/RecordPage"));
|
||||
const VocabularyPage = lazy(() => import("@/pages/VocabularyPage"));
|
||||
const GrammarPage = lazy(() => import("@/pages/GrammarPage"));
|
||||
const PaymentRecordPage = lazy(() => import("@/pages/PaymentRecordPage"));
|
||||
const TicketsPage = lazy(() => import("@/pages/TicketsPage"));
|
||||
const SettingsPage = lazy(() => import("@/pages/SettingsPage"));
|
||||
|
||||
// Student pages
|
||||
const StudentDashboard = lazy(() => import("@/pages/student/StudentDashboard"));
|
||||
const StudentCourses = lazy(() => import("@/pages/student/StudentCourses"));
|
||||
const StudentCourseDetail = lazy(() => import("@/pages/student/StudentCourseDetail"));
|
||||
const StudentAssignments = lazy(() => import("@/pages/student/StudentAssignments"));
|
||||
const StudentGrades = lazy(() => import("@/pages/student/StudentGrades"));
|
||||
const StudentAttendance = lazy(() => import("@/pages/student/StudentAttendance"));
|
||||
const StudentTimetable = lazy(() => import("@/pages/student/StudentTimetable"));
|
||||
const StudentProfile = lazy(() => import("@/pages/student/StudentProfile"));
|
||||
|
||||
// Adaptive learning pages
|
||||
const SubjectSelection = lazy(() => import("@/pages/student/SubjectSelection"));
|
||||
const DiagnosticTest = lazy(() => import("@/pages/student/DiagnosticTest"));
|
||||
const ProficiencyProfile = lazy(() => import("@/pages/student/ProficiencyProfile"));
|
||||
const LearningPlanPage = lazy(() => import("@/pages/student/LearningPlan"));
|
||||
const TopicLearning = lazy(() => import("@/pages/student/TopicLearning"));
|
||||
|
||||
// Teacher pages
|
||||
const TeacherDashboard = lazy(() => import("@/pages/teacher/TeacherDashboard"));
|
||||
const 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() {
|
||||
const navigate = useNavigate();
|
||||
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 = () => (
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
storageKey="encoach-theme"
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Routes>
|
||||
{/* Auth routes */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
@@ -200,6 +238,7 @@ const App = () => (
|
||||
<Route path="/student/attendance" element={<StudentAttendance />} />
|
||||
<Route path="/student/timetable" element={<StudentTimetable />} />
|
||||
<Route path="/student/profile" element={<StudentProfile />} />
|
||||
<Route path="/student/privacy" element={<PrivacyCenter />} />
|
||||
<Route path="/student/subjects" element={<SubjectSelection />} />
|
||||
<Route path="/student/diagnostic/:subjectId" element={<DiagnosticTest />} />
|
||||
<Route path="/student/proficiency/:subjectId" element={<ProficiencyProfile />} />
|
||||
@@ -241,6 +280,7 @@ const App = () => (
|
||||
<Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} />
|
||||
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
|
||||
<Route path="/teacher/profile" element={<TeacherProfile />} />
|
||||
<Route path="/teacher/privacy" element={<PrivacyCenter />} />
|
||||
<Route path="/teacher/course/:courseId/progress" element={<CourseProgress />} />
|
||||
<Route path="/teacher/adaptive/settings" element={<AdaptiveSettings />} />
|
||||
</Route>
|
||||
@@ -263,6 +303,7 @@ const App = () => (
|
||||
<Route path="/admin/reports" element={<AdminReports />} />
|
||||
<Route path="/admin/settings" element={<AdminSettings />} />
|
||||
<Route path="/admin/profile" element={<AdminProfileLms />} />
|
||||
<Route path="/admin/privacy" element={<PrivacyCenter />} />
|
||||
{/* Original academic pages */}
|
||||
<Route path="/admin/assignments" element={<AssignmentsPage />} />
|
||||
<Route path="/admin/examsList" element={<ExamsListPage />} />
|
||||
@@ -285,13 +326,16 @@ const App = () => (
|
||||
<Route path="/admin/payment-record" element={<PaymentRecordPage />} />
|
||||
<Route path="/admin/tickets" element={<TicketsPage />} />
|
||||
<Route path="/admin/settings-platform" element={<SettingsPage />} />
|
||||
<Route path="/admin/exam" element={<ExamPage />} />
|
||||
<Route path="/admin/exam/create" element={<ExamTemplateSelection />} />
|
||||
<Route path="/admin/exam/ielts/create" element={<IeltsExamCreate />} />
|
||||
<Route path="/admin/exam/ielts/:examId/skills" element={<IeltsSkillConfig />} />
|
||||
<Route path="/admin/exam/ielts/:examId/content" element={<IeltsContentPool />} />
|
||||
<Route path="/admin/exam/ielts/:examId/validate" element={<IeltsExamValidation />} />
|
||||
<Route path="/admin/exam/custom/create" element={<CustomExamCreate />} />
|
||||
<Route path="/admin/exam/review-queue" element={<ExamReviewQueue />} />
|
||||
<Route path="/admin/exam/review/:examId" element={<ExamReviewDetail />} />
|
||||
<Route path="/admin/ai/prompts" element={<AIPromptEditor />} />
|
||||
<Route path="/admin/ai/feedback" element={<AIFeedbackTriage />} />
|
||||
<Route path="/admin/taxonomy" element={<TaxonomyManager />} />
|
||||
<Route path="/admin/resources" element={<ResourceManager />} />
|
||||
{/* Institutional LMS pages */}
|
||||
@@ -340,10 +384,12 @@ const App = () => (
|
||||
<Route path="/dashboard/admin" element={<Navigate to="/admin/platform" replace />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
|
||||
164
src/components/AIFeedbackButtons.tsx
Normal file
164
src/components/AIFeedbackButtons.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useAIFeedbackSummary,
|
||||
useSubmitAIFeedback,
|
||||
} from "@/hooks/queries/useAIFeedback";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
AIFeedbackRating,
|
||||
AIFeedbackSubjectType,
|
||||
} from "@/types/ai-feedback";
|
||||
|
||||
export interface AIFeedbackButtonsProps {
|
||||
subjectType: AIFeedbackSubjectType;
|
||||
subjectId: number;
|
||||
promptKey?: string;
|
||||
promptVersion?: number;
|
||||
aiLogId?: number;
|
||||
entityId?: number;
|
||||
courseId?: number;
|
||||
size?: "sm" | "md";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Thumbs up/down widget for any AI-generated artefact.
|
||||
*
|
||||
* Always renders the same UI regardless of whether the user has rated the
|
||||
* artefact before — a previous rating will show as highlighted. Clicking the
|
||||
* same button again is a no-op; clicking the opposite button updates the
|
||||
* server-side row in place. A thumbs-down always prompts the user for a short
|
||||
* comment (required by the backend).
|
||||
*/
|
||||
export function AIFeedbackButtons({
|
||||
subjectType,
|
||||
subjectId,
|
||||
promptKey,
|
||||
promptVersion,
|
||||
aiLogId,
|
||||
entityId,
|
||||
courseId,
|
||||
size = "sm",
|
||||
className,
|
||||
}: AIFeedbackButtonsProps) {
|
||||
const summary = useAIFeedbackSummary(subjectType, subjectId);
|
||||
const submit = useSubmitAIFeedback();
|
||||
|
||||
const [downOpen, setDownOpen] = useState(false);
|
||||
const [comment, setComment] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (downOpen) {
|
||||
setComment(summary.data?.my_comment ?? "");
|
||||
}
|
||||
}, [downOpen, summary.data?.my_comment]);
|
||||
|
||||
const myRating: AIFeedbackRating | null = summary.data?.my_rating ?? null;
|
||||
|
||||
const submitUp = async () => {
|
||||
if (myRating === "up") return;
|
||||
try {
|
||||
await submit.mutateAsync({
|
||||
subject_type: subjectType,
|
||||
subject_id: subjectId,
|
||||
rating: "up",
|
||||
prompt_key: promptKey,
|
||||
prompt_version: promptVersion,
|
||||
ai_log_id: aiLogId,
|
||||
entity_id: entityId,
|
||||
course_id: courseId,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to submit");
|
||||
}
|
||||
};
|
||||
|
||||
const submitDown = async () => {
|
||||
if (!comment.trim()) {
|
||||
toast.error("Please tell us what was wrong.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await submit.mutateAsync({
|
||||
subject_type: subjectType,
|
||||
subject_id: subjectId,
|
||||
rating: "down",
|
||||
comment: comment.trim(),
|
||||
prompt_key: promptKey,
|
||||
prompt_version: promptVersion,
|
||||
ai_log_id: aiLogId,
|
||||
entity_id: entityId,
|
||||
course_id: courseId,
|
||||
});
|
||||
setDownOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to submit");
|
||||
}
|
||||
};
|
||||
|
||||
const btnSize = size === "sm" ? "sm" : "default";
|
||||
const iconCls = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", className)}>
|
||||
<Button
|
||||
variant={myRating === "up" ? "default" : "outline"}
|
||||
size={btnSize}
|
||||
onClick={submitUp}
|
||||
disabled={submit.isPending}
|
||||
aria-label="Thumbs up"
|
||||
>
|
||||
<ThumbsUp className={iconCls} />
|
||||
<span className="ml-1 text-xs">{summary.data?.up ?? 0}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={myRating === "down" ? "default" : "outline"}
|
||||
size={btnSize}
|
||||
onClick={() => setDownOpen(true)}
|
||||
disabled={submit.isPending}
|
||||
aria-label="Thumbs down"
|
||||
>
|
||||
<ThumbsDown className={iconCls} />
|
||||
<span className="ml-1 text-xs">{summary.data?.down ?? 0}</span>
|
||||
</Button>
|
||||
|
||||
<Dialog open={downOpen} onOpenChange={setDownOpen}>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
description="Tell us what was wrong so we can improve this AI output."
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>What went wrong?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="e.g. Wrong answer, confusing wording, off-topic…"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDownOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submitDown} disabled={submit.isPending}>
|
||||
Submit feedback
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AIFeedbackButtons;
|
||||
43
src/components/LanguageToggle.tsx
Normal file
43
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
src/components/ThemeToggle.tsx
Normal file
68
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,10 +25,47 @@ const AlertDialogOverlay = React.forwardRef<
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
function hasAlertDialogDescription(children: React.ReactNode): boolean {
|
||||
let found = false;
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (found || !React.isValidElement(child)) return;
|
||||
const type = child.type as { displayName?: string } | undefined;
|
||||
if (
|
||||
type === AlertDialogPrimitive.Description ||
|
||||
(type && type.displayName === AlertDialogPrimitive.Description.displayName)
|
||||
) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
const nested = (child.props as { children?: React.ReactNode } | undefined)
|
||||
?.children;
|
||||
if (nested !== undefined && hasAlertDialogDescription(nested)) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
type AlertDialogContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof AlertDialogPrimitive.Content
|
||||
> & {
|
||||
/**
|
||||
* Optional accessible description rendered visually-hidden. When omitted and
|
||||
* no `AlertDialogDescription` is found in the subtree, a generic fallback is
|
||||
* emitted so screen readers still have something to announce.
|
||||
*/
|
||||
description?: React.ReactNode;
|
||||
};
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
AlertDialogContentProps
|
||||
>(({ className, children, description, ...props }, ref) => {
|
||||
const needsFallback =
|
||||
!props["aria-describedby"] &&
|
||||
description === undefined &&
|
||||
!hasAlertDialogDescription(children);
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
@@ -38,9 +75,22 @@ const AlertDialogContent = React.forwardRef<
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{description !== undefined && (
|
||||
<AlertDialogPrimitive.Description className="sr-only">
|
||||
{description}
|
||||
</AlertDialogPrimitive.Description>
|
||||
)}
|
||||
{needsFallback && (
|
||||
<AlertDialogPrimitive.Description className="sr-only">
|
||||
Alert dialog content
|
||||
</AlertDialogPrimitive.Description>
|
||||
)}
|
||||
{children}
|
||||
</AlertDialogPrimitive.Content>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
);
|
||||
});
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
|
||||
@@ -27,21 +27,77 @@ const DialogOverlay = React.forwardRef<
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
/**
|
||||
* Walks `children` looking for any element whose component is
|
||||
* `DialogPrimitive.Description` (or our re-exported `DialogDescription`).
|
||||
* Radix emits an a11y warning when a `DialogContent` has neither a
|
||||
* `Description` nor an explicit `aria-describedby`, so we use this to decide
|
||||
* whether to inject a visually-hidden fallback description.
|
||||
*/
|
||||
function hasDialogDescription(children: React.ReactNode): boolean {
|
||||
let found = false;
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (found || !React.isValidElement(child)) return;
|
||||
const type = child.type as { displayName?: string } | undefined;
|
||||
if (
|
||||
type === DialogPrimitive.Description ||
|
||||
(type && type.displayName === DialogPrimitive.Description.displayName)
|
||||
) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
const nested = (child.props as { children?: React.ReactNode } | undefined)
|
||||
?.children;
|
||||
if (nested !== undefined && hasDialogDescription(nested)) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
type DialogContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof DialogPrimitive.Content
|
||||
> & {
|
||||
/**
|
||||
* Optional accessible description. When supplied, a visually-hidden
|
||||
* `DialogDescription` is rendered so screen readers announce it without
|
||||
* affecting layout. Prefer this for simple dialogs; for richer descriptions
|
||||
* continue to place a `<DialogDescription>` inside `<DialogHeader>`.
|
||||
*/
|
||||
description?: React.ReactNode;
|
||||
};
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
DialogContentProps
|
||||
>(({ className, children, description, ...props }, ref) => {
|
||||
const hasExplicitDescribedBy = !!props["aria-describedby"];
|
||||
const needsFallback =
|
||||
!hasExplicitDescribedBy &&
|
||||
description === undefined &&
|
||||
!hasDialogDescription(children);
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
aria-describedby={props["aria-describedby"] ?? undefined}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{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" />
|
||||
@@ -49,7 +105,8 @@ const DialogContent = React.forwardRef<
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
|
||||
@@ -49,13 +49,52 @@ const sheetVariants = cva(
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
VariantProps<typeof sheetVariants> {
|
||||
/**
|
||||
* Optional accessible description rendered visually-hidden. Falls back to a
|
||||
* generic string when no `SheetDescription` is found in the subtree, which
|
||||
* keeps Radix happy without forcing every call-site to add one.
|
||||
*/
|
||||
description?: React.ReactNode;
|
||||
}
|
||||
|
||||
function hasSheetDescription(children: React.ReactNode): boolean {
|
||||
let found = false;
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (found || !React.isValidElement(child)) return;
|
||||
const type = child.type as { displayName?: string } | undefined;
|
||||
if (
|
||||
type === SheetPrimitive.Description ||
|
||||
(type && type.displayName === SheetPrimitive.Description.displayName)
|
||||
) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
const nested = (child.props as { children?: React.ReactNode } | undefined)
|
||||
?.children;
|
||||
if (nested !== undefined && hasSheetDescription(nested)) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
|
||||
({ side = "right", className, children, ...props }, ref) => (
|
||||
({ side = "right", className, children, description, ...props }, ref) => {
|
||||
const needsFallback =
|
||||
!props["aria-describedby"] &&
|
||||
description === undefined &&
|
||||
!hasSheetDescription(children);
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{description !== undefined && (
|
||||
<SheetPrimitive.Description className="sr-only">{description}</SheetPrimitive.Description>
|
||||
)}
|
||||
{needsFallback && (
|
||||
<SheetPrimitive.Description className="sr-only">Sheet content</SheetPrimitive.Description>
|
||||
)}
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
@@ -63,7 +102,8 @@ const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Con
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
|
||||
@@ -4,244 +4,246 @@ export const queryKeys = {
|
||||
},
|
||||
users: {
|
||||
all: ["users"] as const,
|
||||
list: (params: Record<string, unknown>) => ["users", "list", params] as const,
|
||||
list: (params: object) => ["users", "list", params] as const,
|
||||
detail: (id: number) => ["users", id] as const,
|
||||
},
|
||||
entities: {
|
||||
all: ["entities"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["entities", "list", params] as const,
|
||||
list: (params?: object) => ["entities", "list", params] as const,
|
||||
detail: (id: number) => ["entities", id] as const,
|
||||
roles: (entityId: number) => ["entities", entityId, "roles"] as const,
|
||||
permissions: (entityId: number) => ["entities", entityId, "permissions"] as const,
|
||||
},
|
||||
exams: {
|
||||
all: ["exams"] as const,
|
||||
list: (module: string, params?: Record<string, unknown>) => ["exams", module, params] as const,
|
||||
list: (module: string, params?: object) => ["exams", module, params] as const,
|
||||
detail: (module: string, id: number) => ["exams", module, id] as const,
|
||||
rubrics: (params?: Record<string, unknown>) => ["exams", "rubrics", params] as const,
|
||||
rubricGroups: (params?: Record<string, unknown>) => ["exams", "rubric-groups", params] as const,
|
||||
structures: (params?: Record<string, unknown>) => ["exams", "structures", params] as const,
|
||||
rubrics: (params?: object) => ["exams", "rubrics", params] as const,
|
||||
rubricGroups: (params?: object) => ["exams", "rubric-groups", params] as const,
|
||||
structures: (params?: object) => ["exams", "structures", params] as const,
|
||||
avatars: ["exams", "avatars"] as const,
|
||||
reviewQueue: (params?: object) => ["exams", "review", "queue", params] as const,
|
||||
reviewDetail: (id: number) => ["exams", "review", "detail", id] as const,
|
||||
},
|
||||
assignments: {
|
||||
all: ["assignments"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["assignments", "list", params] as const,
|
||||
list: (params?: object) => ["assignments", "list", params] as const,
|
||||
detail: (id: number) => ["assignments", id] as const,
|
||||
},
|
||||
classrooms: {
|
||||
all: ["classrooms"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["classrooms", "list", params] as const,
|
||||
list: (params?: object) => ["classrooms", "list", params] as const,
|
||||
detail: (id: number) => ["classrooms", id] as const,
|
||||
},
|
||||
stats: {
|
||||
sessions: (params?: Record<string, unknown>) => ["stats", "sessions", params] as const,
|
||||
stats: (params?: Record<string, unknown>) => ["stats", "stats", params] as const,
|
||||
statistical: (params?: Record<string, unknown>) => ["stats", "statistical", params] as const,
|
||||
performance: (params?: Record<string, unknown>) => ["stats", "performance", params] as const,
|
||||
sessions: (params?: object) => ["stats", "sessions", params] as const,
|
||||
stats: (params?: object) => ["stats", "stats", params] as const,
|
||||
statistical: (params?: object) => ["stats", "statistical", params] as const,
|
||||
performance: (params?: object) => ["stats", "performance", params] as const,
|
||||
},
|
||||
training: {
|
||||
all: ["training"] as const,
|
||||
tips: (params?: Record<string, unknown>) => ["training", "tips", params] as const,
|
||||
tips: (params?: object) => ["training", "tips", params] as const,
|
||||
walkthroughs: (module?: string) => ["training", "walkthroughs", module] as const,
|
||||
},
|
||||
subscriptions: {
|
||||
packages: ["subscriptions", "packages"] as const,
|
||||
payments: (params?: Record<string, unknown>) => ["subscriptions", "payments", params] as const,
|
||||
discounts: (params?: Record<string, unknown>) => ["subscriptions", "discounts", params] as const,
|
||||
payments: (params?: object) => ["subscriptions", "payments", params] as const,
|
||||
discounts: (params?: object) => ["subscriptions", "discounts", params] as const,
|
||||
},
|
||||
tickets: {
|
||||
all: ["tickets"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["tickets", "list", params] as const,
|
||||
list: (params?: object) => ["tickets", "list", params] as const,
|
||||
assigned: ["tickets", "assigned"] as const,
|
||||
},
|
||||
approvals: {
|
||||
all: ["approvals"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["approvals", "list", params] as const,
|
||||
list: (params?: object) => ["approvals", "list", params] as const,
|
||||
},
|
||||
taxonomy: {
|
||||
subjects: ["taxonomy", "subjects"] as const,
|
||||
subject: (id: number) => ["taxonomy", "subjects", id] as const,
|
||||
tree: (subjectId: number) => ["taxonomy", "tree", subjectId] as const,
|
||||
domains: (params?: Record<string, unknown>) => ["taxonomy", "domains", params] as const,
|
||||
topics: (params?: Record<string, unknown>) => ["taxonomy", "topics", params] as const,
|
||||
domains: (params?: object) => ["taxonomy", "domains", params] as const,
|
||||
topics: (params?: object) => ["taxonomy", "topics", params] as const,
|
||||
},
|
||||
adaptive: {
|
||||
proficiency: (params?: Record<string, unknown>) => ["adaptive", "proficiency", params] as const,
|
||||
proficiency: (params?: object) => ["adaptive", "proficiency", params] as const,
|
||||
summary: ["adaptive", "proficiency", "summary"] as const,
|
||||
plan: (params?: Record<string, unknown>) => ["adaptive", "plan", params] as const,
|
||||
plan: (params?: object) => ["adaptive", "plan", params] as const,
|
||||
topicContent: (topicId: number) => ["adaptive", "topic", topicId, "content"] as const,
|
||||
diagnostic: (sessionId: number) => ["adaptive", "diagnostic", sessionId] as const,
|
||||
},
|
||||
resources: {
|
||||
all: ["resources"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["resources", "list", params] as const,
|
||||
list: (params?: object) => ["resources", "list", params] as const,
|
||||
},
|
||||
lms: {
|
||||
courses: (params?: Record<string, unknown>) => ["lms", "courses", params] as const,
|
||||
courses: (params?: object) => ["lms", "courses", params] as const,
|
||||
course: (id: number) => ["lms", "courses", id] as const,
|
||||
myCourses: ["lms", "my-courses"] as const,
|
||||
students: (params?: Record<string, unknown>) => ["lms", "students", params] as const,
|
||||
teachers: (params?: Record<string, unknown>) => ["lms", "teachers", params] as const,
|
||||
batches: (params?: Record<string, unknown>) => ["lms", "batches", params] as const,
|
||||
students: (params?: object) => ["lms", "students", params] as const,
|
||||
teachers: (params?: object) => ["lms", "teachers", params] as const,
|
||||
batches: (params?: object) => ["lms", "batches", params] as const,
|
||||
batch: (id: number) => ["lms", "batches", id] as const,
|
||||
timetable: (params?: Record<string, unknown>) => ["lms", "timetable", params] as const,
|
||||
attendance: (params?: Record<string, unknown>) => ["lms", "attendance", params] as const,
|
||||
grades: (params?: Record<string, unknown>) => ["lms", "grades", params] as const,
|
||||
timetable: (params?: object) => ["lms", "timetable", params] as const,
|
||||
attendance: (params?: object) => ["lms", "attendance", params] as const,
|
||||
grades: (params?: object) => ["lms", "grades", params] as const,
|
||||
},
|
||||
analytics: {
|
||||
student: (params?: Record<string, unknown>) => ["analytics", "student", params] as const,
|
||||
class: (params?: Record<string, unknown>) => ["analytics", "class", params] as const,
|
||||
subject: (params?: Record<string, unknown>) => ["analytics", "subject", params] as const,
|
||||
contentGaps: (params?: Record<string, unknown>) => ["analytics", "content-gaps", params] as const,
|
||||
student: (params?: object) => ["analytics", "student", params] as const,
|
||||
class: (params?: object) => ["analytics", "class", params] as const,
|
||||
subject: (params?: object) => ["analytics", "subject", params] as const,
|
||||
contentGaps: (params?: object) => ["analytics", "content-gaps", params] as const,
|
||||
alerts: ["analytics", "alerts"] as const,
|
||||
},
|
||||
academicYears: {
|
||||
all: ["academic-years"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["academic-years", "list", params] as const,
|
||||
list: (params?: object) => ["academic-years", "list", params] as const,
|
||||
detail: (id: number) => ["academic-years", id] as const,
|
||||
},
|
||||
academicTerms: {
|
||||
all: ["academic-terms"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["academic-terms", "list", params] as const,
|
||||
list: (params?: object) => ["academic-terms", "list", params] as const,
|
||||
detail: (id: number) => ["academic-terms", id] as const,
|
||||
},
|
||||
departments: {
|
||||
all: ["departments"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["departments", "list", params] as const,
|
||||
list: (params?: object) => ["departments", "list", params] as const,
|
||||
detail: (id: number) => ["departments", id] as const,
|
||||
},
|
||||
admissionRegisters: {
|
||||
all: ["admission-registers"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["admission-registers", "list", params] as const,
|
||||
list: (params?: object) => ["admission-registers", "list", params] as const,
|
||||
detail: (id: number) => ["admission-registers", id] as const,
|
||||
},
|
||||
admissions: {
|
||||
all: ["admissions"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["admissions", "list", params] as const,
|
||||
list: (params?: object) => ["admissions", "list", params] as const,
|
||||
detail: (id: number) => ["admissions", id] as const,
|
||||
},
|
||||
instExamSessions: {
|
||||
all: ["inst-exam-sessions"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["inst-exam-sessions", "list", params] as const,
|
||||
list: (params?: object) => ["inst-exam-sessions", "list", params] as const,
|
||||
detail: (id: number) => ["inst-exam-sessions", id] as const,
|
||||
},
|
||||
examTypes: {
|
||||
all: ["exam-types"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["exam-types", "list", params] as const,
|
||||
list: (params?: object) => ["exam-types", "list", params] as const,
|
||||
},
|
||||
gradeConfigs: {
|
||||
all: ["grade-configs"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["grade-configs", "list", params] as const,
|
||||
list: (params?: object) => ["grade-configs", "list", params] as const,
|
||||
},
|
||||
resultTemplates: {
|
||||
all: ["result-templates"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["result-templates", "list", params] as const,
|
||||
list: (params?: object) => ["result-templates", "list", params] as const,
|
||||
},
|
||||
marksheets: {
|
||||
all: ["marksheets"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["marksheets", "list", params] as const,
|
||||
list: (params?: object) => ["marksheets", "list", params] as const,
|
||||
detail: (id: number) => ["marksheets", id] as const,
|
||||
},
|
||||
courseAssignments: {
|
||||
all: ["course-assignments"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["course-assignments", "list", params] as const,
|
||||
list: (params?: object) => ["course-assignments", "list", params] as const,
|
||||
detail: (id: number) => ["course-assignments", id] as const,
|
||||
},
|
||||
assignmentTypes: {
|
||||
all: ["assignment-types"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["assignment-types", "list", params] as const,
|
||||
list: (params?: object) => ["assignment-types", "list", params] as const,
|
||||
},
|
||||
subjectRegistrations: {
|
||||
all: ["subject-registrations"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["subject-registrations", "list", params] as const,
|
||||
available: (params?: Record<string, unknown>) => ["subject-registrations", "available", params] as const,
|
||||
list: (params?: object) => ["subject-registrations", "list", params] as const,
|
||||
available: (params?: object) => ["subject-registrations", "available", params] as const,
|
||||
},
|
||||
courseCompletion: (courseId: number) => ["course-completion", courseId] as const,
|
||||
chapters: (courseId: number) => ["chapters", "list", courseId] as const,
|
||||
chapter: (id: number) => ["chapters", "detail", id] as const,
|
||||
chapterMaterials: (chapterId: number) => ["chapter-materials", chapterId] as const,
|
||||
chapterProgress: (chapterId: number) => ["chapter-progress", chapterId] as const,
|
||||
discussionBoards: (params?: Record<string, unknown>) => ["discussion-boards", params] as const,
|
||||
posts: (boardId: number, params?: Record<string, unknown>) => ["posts", boardId, params] as const,
|
||||
announcements: (params?: Record<string, unknown>) => ["announcements", params] as const,
|
||||
messages: (params?: Record<string, unknown>) => ["messages", params] as const,
|
||||
sentMessages: (params?: Record<string, unknown>) => ["messages", "sent", params] as const,
|
||||
discussionBoards: (params?: object) => ["discussion-boards", params] as const,
|
||||
posts: (boardId: number, params?: object) => ["posts", boardId, params] as const,
|
||||
announcements: (params?: object) => ["announcements", params] as const,
|
||||
messages: (params?: object) => ["messages", params] as const,
|
||||
sentMessages: (params?: object) => ["messages", "sent", params] as const,
|
||||
unreadMessages: ["messages", "unread-count"] as const,
|
||||
notifications: (params?: Record<string, unknown>) => ["notifications", params] as const,
|
||||
notifications: (params?: object) => ["notifications", params] as const,
|
||||
unreadNotifications: ["notifications", "unread-count"] as const,
|
||||
notificationRules: ["notification-rules"] as const,
|
||||
notificationPreferences: ["notification-preferences"] as const,
|
||||
faqCategories: (audience?: string) => ["faq", "categories", audience] as const,
|
||||
faqItems: (params?: Record<string, unknown>) => ["faq", "items", params] as const,
|
||||
faqItems: (params?: object) => ["faq", "items", params] as const,
|
||||
studentLeaves: {
|
||||
all: ["student-leaves"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["student-leaves", "list", params] as const,
|
||||
list: (params?: object) => ["student-leaves", "list", params] as const,
|
||||
},
|
||||
studentLeaveTypes: {
|
||||
all: ["student-leave-types"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["student-leave-types", "list", params] as const,
|
||||
list: (params?: object) => ["student-leave-types", "list", params] as const,
|
||||
},
|
||||
feesPlans: {
|
||||
all: ["fees-plans"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["fees-plans", "list", params] as const,
|
||||
list: (params?: object) => ["fees-plans", "list", params] as const,
|
||||
detail: (id: number) => ["fees-plans", id] as const,
|
||||
},
|
||||
studentFees: {
|
||||
all: ["student-fees"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["student-fees", "list", params] as const,
|
||||
list: (params?: object) => ["student-fees", "list", params] as const,
|
||||
},
|
||||
feesTerms: {
|
||||
all: ["fees-terms"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["fees-terms", "list", params] as const,
|
||||
list: (params?: object) => ["fees-terms", "list", params] as const,
|
||||
},
|
||||
lessons: {
|
||||
all: ["lessons"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["lessons", "list", params] as const,
|
||||
list: (params?: object) => ["lessons", "list", params] as const,
|
||||
},
|
||||
gradebooks: {
|
||||
all: ["gradebooks"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["gradebooks", "list", params] as const,
|
||||
list: (params?: object) => ["gradebooks", "list", params] as const,
|
||||
},
|
||||
gradebookLines: {
|
||||
all: ["gradebook-lines"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["gradebook-lines", "list", params] as const,
|
||||
list: (params?: object) => ["gradebook-lines", "list", params] as const,
|
||||
},
|
||||
gradingAssignments: {
|
||||
all: ["grading-assignments"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["grading-assignments", "list", params] as const,
|
||||
list: (params?: object) => ["grading-assignments", "list", params] as const,
|
||||
},
|
||||
studentProgress: {
|
||||
all: ["student-progress"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["student-progress", "list", params] as const,
|
||||
list: (params?: object) => ["student-progress", "list", params] as const,
|
||||
detail: (id: number) => ["student-progress", "detail", id] as const,
|
||||
},
|
||||
libraryMedia: {
|
||||
all: ["library-media"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["library-media", "list", params] as const,
|
||||
list: (params?: object) => ["library-media", "list", params] as const,
|
||||
},
|
||||
libraryMovements: {
|
||||
all: ["library-movements"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["library-movements", "list", params] as const,
|
||||
list: (params?: object) => ["library-movements", "list", params] as const,
|
||||
},
|
||||
libraryCards: {
|
||||
all: ["library-cards"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["library-cards", "list", params] as const,
|
||||
list: (params?: object) => ["library-cards", "list", params] as const,
|
||||
},
|
||||
activities: {
|
||||
all: ["activities"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["activities", "list", params] as const,
|
||||
list: (params?: object) => ["activities", "list", params] as const,
|
||||
},
|
||||
activityTypes: {
|
||||
all: ["activity-types"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["activity-types", "list", params] as const,
|
||||
list: (params?: object) => ["activity-types", "list", params] as const,
|
||||
},
|
||||
facilities: {
|
||||
all: ["facilities"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["facilities", "list", params] as const,
|
||||
list: (params?: object) => ["facilities", "list", params] as const,
|
||||
},
|
||||
assets: {
|
||||
all: ["assets"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["assets", "list", params] as const,
|
||||
list: (params?: object) => ["assets", "list", params] as const,
|
||||
},
|
||||
signup: {
|
||||
goals: ["signup", "goals"] as const,
|
||||
@@ -258,7 +260,7 @@ export const queryKeys = {
|
||||
},
|
||||
ieltsExam: {
|
||||
skills: (examId: number) => ["ielts-exam", examId, "skills"] as const,
|
||||
contentPool: (examId: number, filters?: Record<string, unknown>) => ["ielts-exam", examId, "content-pool", filters] as const,
|
||||
contentPool: (examId: number, filters?: object) => ["ielts-exam", examId, "content-pool", filters] as const,
|
||||
validation: (examId: number) => ["ielts-exam", examId, "validation"] as const,
|
||||
},
|
||||
examSession: {
|
||||
@@ -266,7 +268,7 @@ export const queryKeys = {
|
||||
status: (examId: number) => ["exam-session", examId, "status"] as const,
|
||||
},
|
||||
grading: {
|
||||
queue: (params?: Record<string, unknown>) => ["grading", "queue", params] as const,
|
||||
queue: (params?: object) => ["grading", "queue", params] as const,
|
||||
response: (attemptId: number, skill: string) => ["grading", attemptId, "response", skill] as const,
|
||||
rubric: (attemptId: number, skill: string) => ["grading", attemptId, "rubric", skill] as const,
|
||||
},
|
||||
@@ -282,11 +284,11 @@ export const queryKeys = {
|
||||
englishTaxonomy: ["ai-course", "english", "taxonomy"] as const,
|
||||
},
|
||||
entityOnboarding: {
|
||||
credentials: (params?: Record<string, unknown>) => ["entity-onboarding", "credentials", params] as const,
|
||||
credentials: (params?: object) => ["entity-onboarding", "credentials", params] as const,
|
||||
},
|
||||
adaptiveEngine: {
|
||||
dashboard: ["adaptive-engine", "dashboard"] as const,
|
||||
students: (params?: Record<string, unknown>) => ["adaptive-engine", "students", params] as const,
|
||||
students: (params?: object) => ["adaptive-engine", "students", params] as const,
|
||||
signals: (studentId: number) => ["adaptive-engine", "signals", studentId] as const,
|
||||
ability: (studentId: number) => ["adaptive-engine", "ability", studentId] as const,
|
||||
settings: ["adaptive-engine", "settings"] as const,
|
||||
@@ -299,7 +301,7 @@ export const queryKeys = {
|
||||
current: ["branding", "current"] as const,
|
||||
},
|
||||
scoreRelease: {
|
||||
pending: (params?: Record<string, unknown>) => ["score-release", "pending", params] as const,
|
||||
pending: (params?: object) => ["score-release", "pending", params] as const,
|
||||
},
|
||||
verification: {
|
||||
verify: (hash: string) => ["verification", hash] as const,
|
||||
|
||||
70
src/hooks/queries/useAIFeedback.ts
Normal file
70
src/hooks/queries/useAIFeedback.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { aiFeedbackService } from "@/services/ai-feedback.service";
|
||||
import type {
|
||||
AIFeedbackStatus,
|
||||
AIFeedbackSubjectType,
|
||||
AIFeedbackSubmitInput,
|
||||
} from "@/types/ai-feedback";
|
||||
|
||||
const KEY_ROOT = ["ai", "feedback"] as const;
|
||||
|
||||
export function useAIFeedbackSummary(
|
||||
subjectType: AIFeedbackSubjectType | undefined,
|
||||
subjectId: number | undefined,
|
||||
) {
|
||||
return useQuery({
|
||||
enabled: !!subjectType && !!subjectId,
|
||||
queryKey: [...KEY_ROOT, "summary", subjectType, subjectId] as const,
|
||||
queryFn: () =>
|
||||
aiFeedbackService.summary(
|
||||
subjectType as AIFeedbackSubjectType,
|
||||
subjectId as number,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubmitAIFeedback() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: AIFeedbackSubmitInput) => aiFeedbackService.submit(input),
|
||||
onSuccess: (_res, vars) => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: [...KEY_ROOT, "summary", vars.subject_type, vars.subject_id],
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: [...KEY_ROOT, "list"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAIFeedbackList(
|
||||
params: {
|
||||
status?: AIFeedbackStatus;
|
||||
rating?: "up" | "down";
|
||||
subject_type?: AIFeedbackSubjectType;
|
||||
prompt_key?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
} = {},
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [...KEY_ROOT, "list", params] as const,
|
||||
queryFn: () => aiFeedbackService.list(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useResolveAIFeedback() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
status,
|
||||
notes,
|
||||
}: {
|
||||
id: number;
|
||||
status: Exclude<AIFeedbackStatus, "open">;
|
||||
notes?: string;
|
||||
}) => aiFeedbackService.resolve(id, status, notes),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
|
||||
});
|
||||
}
|
||||
52
src/hooks/queries/useAIPrompts.ts
Normal file
52
src/hooks/queries/useAIPrompts.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { aiPromptService } from "@/services/ai-prompt.service";
|
||||
import type { AIPromptCreateInput } from "@/types/ai-prompt";
|
||||
|
||||
const KEY_ROOT = ["ai", "prompts"] as const;
|
||||
|
||||
export function useAIPromptKeys(params: { search?: string; page?: number; size?: number } = {}) {
|
||||
return useQuery({
|
||||
queryKey: [...KEY_ROOT, "keys", params] as const,
|
||||
queryFn: () => aiPromptService.listKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAIPromptVersions(key: string | undefined) {
|
||||
return useQuery({
|
||||
enabled: !!key,
|
||||
queryKey: [...KEY_ROOT, "versions", key] as const,
|
||||
queryFn: () => aiPromptService.listVersions(key as string),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAIPrompt(id: number | undefined) {
|
||||
return useQuery({
|
||||
enabled: !!id,
|
||||
queryKey: [...KEY_ROOT, "detail", id] as const,
|
||||
queryFn: () => aiPromptService.get(id as number),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAIPrompt() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: AIPromptCreateInput) => aiPromptService.create(input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useActivateAIPrompt() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => aiPromptService.activate(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRenderAIPrompt() {
|
||||
return useMutation({
|
||||
mutationFn: ({ id, variables }: { id: number; variables: Record<string, string> }) =>
|
||||
aiPromptService.render(id, variables),
|
||||
});
|
||||
}
|
||||
47
src/hooks/queries/useExamReview.ts
Normal file
47
src/hooks/queries/useExamReview.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
examReviewService,
|
||||
type ExamReviewQueueParams,
|
||||
} from "@/services/exam-review.service";
|
||||
|
||||
import { queryKeys } from "./keys";
|
||||
|
||||
export function useExamReviewQueue(params: ExamReviewQueueParams = {}) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.exams.reviewQueue(params),
|
||||
queryFn: () => examReviewService.queue(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useExamReviewDetail(examId: number | undefined) {
|
||||
return useQuery({
|
||||
enabled: !!examId,
|
||||
queryKey: queryKeys.exams.reviewDetail(examId ?? -1),
|
||||
queryFn: () => examReviewService.detail(examId as number),
|
||||
});
|
||||
}
|
||||
|
||||
export function useApproveExamReview() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ examId, notes }: { examId: number; notes?: string }) =>
|
||||
examReviewService.approve(examId, notes),
|
||||
onSuccess: (_data, { examId }) => {
|
||||
qc.invalidateQueries({ queryKey: ["exams", "review"] });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.exams.reviewDetail(examId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRejectExamReview() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ examId, notes }: { examId: number; notes: string }) =>
|
||||
examReviewService.reject(examId, notes),
|
||||
onSuccess: (_data, { examId }) => {
|
||||
qc.invalidateQueries({ queryKey: ["exams", "review"] });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.exams.reviewDetail(examId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export function useIeltsAutoAssemble() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useIeltsValidation(examId: number) {
|
||||
export function useIeltsExamValidation(examId: number) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.ieltsExam.validation(examId),
|
||||
queryFn: () => ieltsExamService.validate(examId),
|
||||
|
||||
67
src/i18n/index.ts
Normal file
67
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
src/i18n/locales/ar.ts
Normal file
85
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
src/i18n/locales/en.ts
Normal file
97
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-border: 240 18% 20%;
|
||||
--sidebar-ring: 8 50% 58%;
|
||||
|
||||
/* Chart palette — used by Recharts via hsl(var(--chart-N)). Kept on the
|
||||
warm/cool axis so pairs read well together when stacked. */
|
||||
--chart-1: 8 50% 54%;
|
||||
--chart-2: 220 70% 52%;
|
||||
--chart-3: 152 60% 42%;
|
||||
--chart-4: 38 92% 50%;
|
||||
--chart-5: 280 55% 55%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -99,6 +107,12 @@
|
||||
--sidebar-accent-foreground: 8 40% 75%;
|
||||
--sidebar-border: 240 18% 13%;
|
||||
--sidebar-ring: 8 50% 55%;
|
||||
|
||||
--chart-1: 8 52% 62%;
|
||||
--chart-2: 220 65% 65%;
|
||||
--chart-3: 152 50% 55%;
|
||||
--chart-4: 38 75% 60%;
|
||||
--chart-5: 280 55% 68%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
/** Base path or absolute URL for Odoo JSON API (dev: `/api` + Vite proxy). */
|
||||
/**
|
||||
* Odoo REST client with transparent access-token rotation.
|
||||
*
|
||||
* Tokens live in ``localStorage`` under three keys:
|
||||
* - ``encoach_token`` — short-lived access JWT (1h)
|
||||
* - ``encoach_refresh_token`` — long-lived refresh JWT (7d)
|
||||
* - ``encoach_token_exp`` — epoch seconds for the access token
|
||||
*
|
||||
* When a request receives ``401`` *and* a refresh token is present, the client
|
||||
* silently rotates the pair via ``POST /api/auth/refresh`` and retries the
|
||||
* original request exactly once. Multiple concurrent 401s coalesce onto the
|
||||
* same refresh promise so we never fire more than one rotation in flight.
|
||||
*
|
||||
* Motivation: before this change, every expired access token forced a full
|
||||
* re-login, which interrupted long admin dashboards (stats, reports) multiple
|
||||
* times per hour. With the refresh loop, access tokens can be short-lived
|
||||
* (1h) without hurting UX, giving us the security benefit of short access
|
||||
* windows without an eager logout.
|
||||
*/
|
||||
|
||||
export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL?.trim() || "/api").replace(/\/$/, "");
|
||||
const BASE_URL = API_BASE_URL;
|
||||
|
||||
@@ -21,120 +40,229 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem("encoach_token");
|
||||
const ACCESS_KEY = "encoach_token";
|
||||
const REFRESH_KEY = "encoach_refresh_token";
|
||||
const EXP_KEY = "encoach_token_exp";
|
||||
|
||||
function getAccessToken(): string | null {
|
||||
return localStorage.getItem(ACCESS_KEY);
|
||||
}
|
||||
|
||||
function getRefreshToken(): string | null {
|
||||
return localStorage.getItem(REFRESH_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem("encoach_token", token);
|
||||
localStorage.setItem(ACCESS_KEY, token);
|
||||
}
|
||||
|
||||
export function setRefreshToken(token: string | null | undefined): void {
|
||||
if (token) {
|
||||
localStorage.setItem(REFRESH_KEY, token);
|
||||
} else {
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function setTokenExpiry(epochSeconds: number | null | undefined): void {
|
||||
if (epochSeconds && Number.isFinite(epochSeconds)) {
|
||||
localStorage.setItem(EXP_KEY, String(Math.floor(epochSeconds)));
|
||||
} else {
|
||||
localStorage.removeItem(EXP_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the full token bundle returned by /api/login or /api/auth/refresh. */
|
||||
export function persistTokenBundle(bundle: {
|
||||
access_token?: string;
|
||||
token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
}): void {
|
||||
const access = bundle.access_token || bundle.token;
|
||||
if (access) setToken(access);
|
||||
setRefreshToken(bundle.refresh_token || null);
|
||||
if (bundle.expires_in) {
|
||||
setTokenExpiry(Math.floor(Date.now() / 1000) + bundle.expires_in);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
localStorage.removeItem("encoach_token");
|
||||
localStorage.removeItem(ACCESS_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
localStorage.removeItem(EXP_KEY);
|
||||
}
|
||||
|
||||
async function handleResponse<T>(response: Response): Promise<T> {
|
||||
const data = await response.json().catch(() => null);
|
||||
// Shared refresh promise — any 401 that arrives while a refresh is in flight
|
||||
// waits on the existing request instead of firing a duplicate.
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
if (response.status === 401) {
|
||||
const hadToken = !!getToken();
|
||||
clearToken();
|
||||
// Login failure is also 401 — do not hard-redirect when no session existed.
|
||||
if (hadToken) {
|
||||
window.location.href = "/login";
|
||||
async function performRefresh(): Promise<boolean> {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) return false;
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data: {
|
||||
access_token?: string;
|
||||
token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
} = await res.json().catch(() => ({} as never));
|
||||
if (!data.access_token && !data.token) return false;
|
||||
persistTokenBundle(data);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
throw new ApiError(401, response.statusText, data);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, response.statusText, data);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
function buildHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...extra,
|
||||
};
|
||||
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
function refreshOnce(): Promise<boolean> {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = performRefresh().finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
function 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;
|
||||
}
|
||||
|
||||
function buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
|
||||
/**
|
||||
* Query param values we know how to serialise into a URL. Arrays are joined
|
||||
* with commas because that's what our Odoo controllers expect for multi-value
|
||||
* filters (e.g. `?state=draft,confirmed`).
|
||||
*/
|
||||
export type QueryParamValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| undefined
|
||||
| Array<string | number | boolean>;
|
||||
|
||||
/**
|
||||
* Accept any object-shaped bag of query params. We intentionally use `object`
|
||||
* here rather than `Record<string, QueryParamValue>` because typed interfaces
|
||||
* (e.g. `PaginationParams`) don't satisfy a `Record<...>` index signature by
|
||||
* default, which would force every call-site to cast.
|
||||
*/
|
||||
export type QueryParams = object;
|
||||
|
||||
function buildUrl(path: string, params?: QueryParams): string {
|
||||
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
url.searchParams.set(key, String(value));
|
||||
for (const [key, rawValue] of Object.entries(params as Record<string, unknown>)) {
|
||||
if (rawValue === undefined || rawValue === null) continue;
|
||||
if (Array.isArray(rawValue)) {
|
||||
if (rawValue.length === 0) continue;
|
||||
url.searchParams.set(key, rawValue.map(v => String(v)).join(","));
|
||||
continue;
|
||||
}
|
||||
url.searchParams.set(key, String(rawValue));
|
||||
}
|
||||
});
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new ApiError(response.status, response.statusText, data);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
type RequestInitWithSkip = RequestInit & { _skipRetry?: boolean };
|
||||
|
||||
async function performRequest<T>(url: string, init: RequestInitWithSkip): Promise<T> {
|
||||
const response = await fetch(url, init);
|
||||
|
||||
// Auth/refresh endpoints opt out of the retry loop — otherwise a bad
|
||||
// refresh token would recurse forever.
|
||||
const isAuthEndpoint = url.includes("/auth/refresh") || url.includes("/login");
|
||||
|
||||
if (response.status === 401 && !init._skipRetry && !isAuthEndpoint) {
|
||||
const hadRefresh = !!getRefreshToken();
|
||||
if (hadRefresh) {
|
||||
const rotated = await refreshOnce();
|
||||
if (rotated) {
|
||||
// Rebuild headers so the new access token is attached.
|
||||
const newInit: RequestInitWithSkip = {
|
||||
...init,
|
||||
_skipRetry: true,
|
||||
headers: {
|
||||
...(init.headers as Record<string, string>),
|
||||
Authorization: `Bearer ${getAccessToken()}`,
|
||||
},
|
||||
};
|
||||
return performRequest<T>(url, newInit);
|
||||
}
|
||||
}
|
||||
const hadAccess = !!getAccessToken();
|
||||
clearToken();
|
||||
if (hadAccess || hadRefresh) {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
throw new ApiError(401, response.statusText, await response.json().catch(() => null));
|
||||
}
|
||||
|
||||
return parseResponse<T>(response);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async get<T>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
|
||||
const res = await fetch(buildUrl(path, params), {
|
||||
async get<T>(path: string, params?: QueryParams): Promise<T> {
|
||||
return performRequest<T>(buildUrl(path, params), {
|
||||
method: "GET",
|
||||
headers: buildHeaders(),
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async post<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "POST",
|
||||
headers: buildHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async patch<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "PATCH",
|
||||
headers: buildHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async put<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "PUT",
|
||||
headers: buildHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async delete<T>(path: string): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "DELETE",
|
||||
headers: buildHeaders(),
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async upload<T>(path: string, formData: FormData): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(buildUrl(path), {
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "POST",
|
||||
headers,
|
||||
headers: buildHeaders(undefined, false),
|
||||
body: formData,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
import "./i18n";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
@@ -31,9 +31,10 @@ interface PlatformStats {
|
||||
export default function AdminDashboard() {
|
||||
const { data: stats } = useQuery<PlatformStats>({
|
||||
queryKey: ["platform", "stats"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<{ data: PlatformStats }>("/stats");
|
||||
return (res as { data: PlatformStats }).data ?? res;
|
||||
queryFn: async (): Promise<PlatformStats> => {
|
||||
const res = await api.get<{ data: PlatformStats } | PlatformStats>("/stats");
|
||||
const wrapped = res as { data?: PlatformStats };
|
||||
return wrapped.data ?? (res as PlatformStats);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -66,11 +66,11 @@ export default function EntitiesPage() {
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const raw = entitiesQ.data;
|
||||
const raw = entitiesQ.data as unknown;
|
||||
const entities: { id: number; name: string; code: string; type: string; user_count: number; role_count: number; active: boolean }[] = (() => {
|
||||
if (!raw) return [];
|
||||
if (Array.isArray(raw)) return raw as never[];
|
||||
const r = raw as Record<string, unknown>;
|
||||
const r = raw as { items?: unknown; data?: unknown };
|
||||
const arr = (r.items ?? r.data ?? []) as never[];
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
})();
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GraduationCap, Play, Sparkles } from "lucide-react";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
|
||||
export default function ExamPage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh] gap-4 max-w-md mx-auto">
|
||||
<AiTipBanner context="exam" variant="recommendation" />
|
||||
|
||||
<Card className="border-0 shadow-sm w-full">
|
||||
<CardContent className="p-8 text-center space-y-6">
|
||||
<div className="mx-auto h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<GraduationCap className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-1">Ready to Start?</h2>
|
||||
<p className="text-muted-foreground text-sm">IELTS Academic Mock Exam</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Student</span>
|
||||
<span className="font-medium">Sarah Johnson</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Level</span>
|
||||
<Badge variant="outline">B2</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Modules</span>
|
||||
<span className="font-medium">4</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Duration</span>
|
||||
<span className="font-medium">180 min</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Mode</span>
|
||||
<Badge variant="secondary">Practice</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-primary/5 border border-primary/20 p-3 text-left">
|
||||
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-1"><Sparkles className="h-3 w-3" /> AI Pre-Exam Tip</p>
|
||||
<p className="text-xs text-muted-foreground">Your last mock scored 7.5. To target 8.0, focus on time management in Writing Task 2 — you spent 45 min last time vs recommended 40 min.</p>
|
||||
</div>
|
||||
<Button className="w-full" size="lg">
|
||||
<Play className="h-4 w-4 mr-2" /> Begin Exam
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,12 +37,14 @@ export default function ExamsListPage() {
|
||||
});
|
||||
|
||||
const sessionsQ = useInstitutionalExamSessions();
|
||||
const sessionItems = sessionsQ.data?.data ?? sessionsQ.data?.items ?? [];
|
||||
const sessions = Array.isArray(sessionItems) ? sessionItems : [];
|
||||
const sessionItems = sessionsQ.data?.items ?? [];
|
||||
const sessions: Record<string, string>[] = Array.isArray(sessionItems)
|
||||
? (sessionItems as unknown as Record<string, string>[])
|
||||
: [];
|
||||
|
||||
const customExams = customQ.data?.items ?? [];
|
||||
const q = search.toLowerCase();
|
||||
const filteredSessions = sessions.filter((s: Record<string, string>) =>
|
||||
const filteredSessions = sessions.filter((s) =>
|
||||
s.name?.toLowerCase().includes(q) || s.course_name?.toLowerCase().includes(q),
|
||||
);
|
||||
|
||||
@@ -140,7 +142,7 @@ export default function ExamsListPage() {
|
||||
{filteredSessions.length === 0 && (
|
||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No exam sessions found.</TableCell></TableRow>
|
||||
)}
|
||||
{filteredSessions.map((s: Record<string, string>, i: number) => (
|
||||
{filteredSessions.map((s, i) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell>{i + 1}</TableCell>
|
||||
<TableCell className="font-medium">{s.name}</TableCell>
|
||||
|
||||
@@ -63,7 +63,7 @@ import { mediaService, type Avatar } from "@/services/media.service";
|
||||
import { examsService } from "@/services/exams.service";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { ExamStructureConfig } from "@/types";
|
||||
import type { ExamStructure, ExamStructureConfig } from "@/types";
|
||||
|
||||
type ModuleKey = "reading" | "listening" | "writing" | "speaking" | "level" | "industry";
|
||||
|
||||
@@ -398,7 +398,7 @@ export default function GenerationPage() {
|
||||
|
||||
const createStructureMut = useMutation({
|
||||
mutationFn: (data: { name: string; modules: string[] }) =>
|
||||
examsService.createStructure(data),
|
||||
examsService.createStructure(data as unknown as Partial<ExamStructure>),
|
||||
onSuccess: (created) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["exam-structures"] });
|
||||
setExamStructure(String(created.id));
|
||||
@@ -693,7 +693,7 @@ export default function GenerationPage() {
|
||||
try {
|
||||
const status = await mediaService.getVideoStatus(videoId);
|
||||
if (status.status === "done" || status.status === "completed" || status.video_url) {
|
||||
updatedParts[pp.index] = { ...part, videoUrl: status.video_url || status.url || "" };
|
||||
updatedParts[pp.index] = { ...part, videoUrl: status.video_url || "" };
|
||||
changed = true;
|
||||
toast({ title: "Video ready!", description: "Avatar video has been generated." });
|
||||
} else if (status.status === "error" || status.status === "failed") {
|
||||
@@ -726,7 +726,7 @@ export default function GenerationPage() {
|
||||
totalMarks: st.totalMarks,
|
||||
passages: mod === "reading" ? st.passages.map((p) => ({
|
||||
text: p.text, category: p.category, type: p.type,
|
||||
exercises: p.exercises?.map((ex: Record<string, unknown>) => ({
|
||||
exercises: p.exercises?.map((ex) => ({
|
||||
type: ex.type, prompt: ex.prompt, options: ex.options,
|
||||
correct_answer: ex.correct_answer, explanation: ex.explanation,
|
||||
instructions: ex.instructions, marks: ex.marks || 1,
|
||||
@@ -735,7 +735,7 @@ export default function GenerationPage() {
|
||||
})) : undefined,
|
||||
sections: mod === "listening" ? st.listeningSections.map((s) => ({
|
||||
type: s.type, context: s.context, audioUrl: s.audioUrl,
|
||||
exercises: s.exercises?.map((ex: Record<string, unknown>) => ({
|
||||
exercises: s.exercises?.map((ex) => ({
|
||||
type: ex.type, prompt: ex.prompt, options: ex.options,
|
||||
correct_answer: ex.correct_answer, explanation: ex.explanation,
|
||||
instructions: ex.instructions, marks: ex.marks || 1,
|
||||
|
||||
@@ -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
src/pages/PrivacyCenter.tsx
Normal file
173
src/pages/PrivacyCenter.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Download, ShieldAlert, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { gdprService } from "@/services/gdpr.service";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
export default function PrivacyCenter() {
|
||||
const navigate = useNavigate();
|
||||
const { logout } = useAuth();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [erasing, setErasing] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
|
||||
const downloadExport = async () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
const payload = await gdprService.exportMyData();
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `encoach-data-export-${new Date()
|
||||
.toISOString()
|
||||
.slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Data export downloaded");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Export failed");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const eraseAccount = async () => {
|
||||
try {
|
||||
setErasing(true);
|
||||
await gdprService.eraseMyAccount();
|
||||
toast.success("Your account has been erased. Signing out…");
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await logout();
|
||||
} catch {
|
||||
// ignore — the server may have already invalidated our tokens
|
||||
}
|
||||
navigate("/login", { replace: true });
|
||||
}, 1200);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Erasure failed");
|
||||
setErasing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
<ShieldAlert className="mr-1 inline h-5 w-5" />
|
||||
Privacy Center
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Manage your personal data held by EnCoach under GDPR and equivalent
|
||||
regulations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Download your data</CardTitle>
|
||||
<CardDescription>
|
||||
We'll package your profile, entity memberships, exam attempts,
|
||||
answers, AI feedback, and tickets into a single JSON file.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={downloadExport} disabled={exporting}>
|
||||
<Download className="mr-1 h-4 w-4" />
|
||||
{exporting ? "Preparing export…" : "Download my data"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Delete my account</CardTitle>
|
||||
<CardDescription>
|
||||
This anonymises your profile and removes personal fields from our
|
||||
records. Exam attempts are retained (without identifying
|
||||
information) for statistical integrity. This action cannot be
|
||||
undone.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
disabled={erasing}
|
||||
>
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
{erasing ? "Erasing…" : "Erase my account"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogContent description="Erasure is permanent — type DELETE to confirm.">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Erase your account?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
We'll anonymise your personal data and deactivate your login.
|
||||
Aggregate analytics (exam scores, attempt counts) will remain
|
||||
but will no longer be linked to you.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="confirm-delete">
|
||||
Type <strong>DELETE</strong> to confirm:
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-delete"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder="DELETE"
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setConfirmText("")}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={confirmText !== "DELETE" || erasing}
|
||||
onClick={async () => {
|
||||
setConfirmOpen(false);
|
||||
await eraseAccount();
|
||||
setConfirmText("");
|
||||
}}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Erase account
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { User } from "lucide-react";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const nameParts = (user?.name || "Admin User").split(" ");
|
||||
const [first, setFirst] = useState(nameParts[0] || "");
|
||||
const [last, setLast] = useState(nameParts.slice(1).join(" ") || "");
|
||||
const [email, setEmail] = useState(user?.email || "");
|
||||
const [curPw, setCurPw] = useState("");
|
||||
const [newPw, setNewPw] = useState("");
|
||||
const [confirmPw, setConfirmPw] = useState("");
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: () => api.patch("/user", { first_name: first, last_name: last, email }),
|
||||
onSuccess: () => toast({ title: "Profile saved" }),
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const pwMut = useMutation({
|
||||
mutationFn: () => api.post("/user/change-password", { current_password: curPw, new_password: newPw }),
|
||||
onSuccess: () => { setCurPw(""); setNewPw(""); setConfirmPw(""); toast({ title: "Password updated" }); },
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Profile</h1>
|
||||
<p className="text-muted-foreground">Manage your account information.</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader><CardTitle className="text-base">Personal Information</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<User className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2"><Label>First Name</Label><Input value={first} onChange={(e) => setFirst(e.target.value)} /></div>
|
||||
<div className="space-y-2"><Label>Last Name</Label><Input value={last} onChange={(e) => setLast(e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="space-y-2"><Label>Email</Label><Input value={email} onChange={(e) => setEmail(e.target.value)} type="email" /></div>
|
||||
<Button onClick={() => saveMut.mutate()} disabled={saveMut.isPending}>
|
||||
{saveMut.isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader><CardTitle className="text-base">Change Password</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2"><Label>Current Password</Label><Input type="password" value={curPw} onChange={(e) => setCurPw(e.target.value)} placeholder="••••••••" /></div>
|
||||
<div className="space-y-2"><Label>New Password</Label><Input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} placeholder="••••••••" /></div>
|
||||
<div className="space-y-2"><Label>Confirm New Password</Label><Input type="password" value={confirmPw} onChange={(e) => setConfirmPw(e.target.value)} placeholder="••••••••" /></div>
|
||||
{newPw && confirmPw && newPw !== confirmPw && <p className="text-sm text-destructive">Passwords do not match.</p>}
|
||||
<Button onClick={() => pwMut.mutate()} disabled={pwMut.isPending || !curPw || !newPw || newPw !== confirmPw}>
|
||||
{pwMut.isPending ? "Updating..." : "Update Password"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -165,7 +165,7 @@ export default function RubricsPage() {
|
||||
queryKey: ["rubrics"],
|
||||
queryFn: () => examsService.listRubrics({}),
|
||||
});
|
||||
const rubrics = (rubricsQ.data?.items ?? []) as RubricItem[];
|
||||
const rubrics = (rubricsQ.data?.items ?? []) as unknown as RubricItem[];
|
||||
|
||||
const rubricGroupsQ = useQuery({
|
||||
queryKey: ["rubric-groups"],
|
||||
|
||||
348
src/pages/admin/AIFeedbackTriage.tsx
Normal file
348
src/pages/admin/AIFeedbackTriage.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { CheckCircle2, Filter, MessageSquare, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useAIFeedbackList,
|
||||
useResolveAIFeedback,
|
||||
} from "@/hooks/queries/useAIFeedback";
|
||||
import type {
|
||||
AIFeedback,
|
||||
AIFeedbackStatus,
|
||||
AIFeedbackSubjectType,
|
||||
} from "@/types/ai-feedback";
|
||||
|
||||
const STATUS_COLORS: Record<AIFeedbackStatus, string> = {
|
||||
open: "bg-amber-100 text-amber-900 border-amber-300",
|
||||
acknowledged: "bg-sky-100 text-sky-900 border-sky-300",
|
||||
fixed: "bg-emerald-100 text-emerald-900 border-emerald-300",
|
||||
dismissed: "bg-slate-100 text-slate-700 border-slate-300",
|
||||
};
|
||||
|
||||
type ResolveChoice = Exclude<AIFeedbackStatus, "open">;
|
||||
|
||||
function ResolveDialog({
|
||||
feedback,
|
||||
onClose,
|
||||
}: {
|
||||
feedback: AIFeedback | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const resolve = useResolveAIFeedback();
|
||||
const [status, setStatus] = useState<ResolveChoice>("acknowledged");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const open = !!feedback;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => (!v ? onClose() : null)}>
|
||||
<DialogContent
|
||||
className="max-w-lg"
|
||||
description="Triage this feedback by setting its status and leaving a short note for the audit trail."
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Resolve feedback</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs uppercase">
|
||||
Status
|
||||
</div>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(v) => setStatus(v as ResolveChoice)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="acknowledged">Acknowledged</SelectItem>
|
||||
<SelectItem value="fixed">Fixed</SelectItem>
|
||||
<SelectItem value="dismissed">Dismissed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs uppercase">
|
||||
Notes
|
||||
</div>
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="What did you do? (optional)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!feedback) return;
|
||||
try {
|
||||
await resolve.mutateAsync({
|
||||
id: feedback.id,
|
||||
status,
|
||||
notes,
|
||||
});
|
||||
toast.success(`Marked ${status}`);
|
||||
setNotes("");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Resolve failed",
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={resolve.isPending}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-4 w-4" />
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AIFeedbackTriage() {
|
||||
const [statusFilter, setStatusFilter] = useState<AIFeedbackStatus | "all">("open");
|
||||
const [ratingFilter, setRatingFilter] = useState<"up" | "down" | "all">("down");
|
||||
const [subjectFilter, setSubjectFilter] = useState<
|
||||
AIFeedbackSubjectType | "all"
|
||||
>("all");
|
||||
const [selected, setSelected] = useState<AIFeedback | null>(null);
|
||||
|
||||
const params = useMemo(
|
||||
() => ({
|
||||
status: statusFilter === "all" ? undefined : statusFilter,
|
||||
rating: ratingFilter === "all" ? undefined : ratingFilter,
|
||||
subject_type: subjectFilter === "all" ? undefined : subjectFilter,
|
||||
page: 0,
|
||||
size: 50,
|
||||
}),
|
||||
[statusFilter, ratingFilter, subjectFilter],
|
||||
);
|
||||
const { data, isLoading } = useAIFeedbackList(params);
|
||||
const items: AIFeedback[] = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
<MessageSquare className="mr-1 inline h-5 w-5" />
|
||||
AI feedback triage
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Student thumbs up/down on AI output — use this to catch broken
|
||||
prompts, bad questions, and low-quality coach replies.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Filter className="mr-1 inline h-4 w-4" />
|
||||
Filters
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Default view surfaces open thumbs-down reports, which are usually
|
||||
the most actionable.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs uppercase">
|
||||
Status
|
||||
</div>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(v) =>
|
||||
setStatusFilter(v as AIFeedbackStatus | "all")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="open">Open</SelectItem>
|
||||
<SelectItem value="acknowledged">Acknowledged</SelectItem>
|
||||
<SelectItem value="fixed">Fixed</SelectItem>
|
||||
<SelectItem value="dismissed">Dismissed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs uppercase">
|
||||
Rating
|
||||
</div>
|
||||
<Select
|
||||
value={ratingFilter}
|
||||
onValueChange={(v) => setRatingFilter(v as "up" | "down" | "all")}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="down">Thumbs down</SelectItem>
|
||||
<SelectItem value="up">Thumbs up</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs uppercase">
|
||||
Type
|
||||
</div>
|
||||
<Select
|
||||
value={subjectFilter}
|
||||
onValueChange={(v) =>
|
||||
setSubjectFilter(v as AIFeedbackSubjectType | "all")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="question">Exam question</SelectItem>
|
||||
<SelectItem value="coach">Coach reply</SelectItem>
|
||||
<SelectItem value="explanation">Explanation</SelectItem>
|
||||
<SelectItem value="translation">Translation</SelectItem>
|
||||
<SelectItem value="narrative">Report narrative</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Feedback ({data?.total ?? 0})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No feedback matches the current filters.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>When</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Subject</TableHead>
|
||||
<TableHead>Rating</TableHead>
|
||||
<TableHead>Prompt</TableHead>
|
||||
<TableHead>Comment</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((f) => (
|
||||
<TableRow key={f.id}>
|
||||
<TableCell className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{f.create_date
|
||||
? new Date(f.create_date).toLocaleString()
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{f.user_name}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{f.subject_key}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{f.rating === "up" ? (
|
||||
<Badge className="bg-emerald-500 text-white hover:bg-emerald-500">
|
||||
<ThumbsUp className="mr-1 h-3 w-3" />
|
||||
Up
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-rose-500 text-white hover:bg-rose-500">
|
||||
<ThumbsDown className="mr-1 h-3 w-3" />
|
||||
Down
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{f.prompt_key
|
||||
? `${f.prompt_key} v${f.prompt_version ?? "?"}`
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md text-sm">
|
||||
{f.comment || <span className="text-muted-foreground">—</span>}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={STATUS_COLORS[f.status]}
|
||||
>
|
||||
{f.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{f.status === "open" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSelected(f)}
|
||||
>
|
||||
Resolve
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{f.resolution_notes || "—"}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ResolveDialog feedback={selected} onClose={() => setSelected(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
528
src/pages/admin/AIPromptEditor.tsx
Normal file
528
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: subjectsData } = useSubjects();
|
||||
const coursesList = coursesData?.items ?? [];
|
||||
const subjectsList = Array.isArray(subjectsData) ? subjectsData : subjectsData?.data ?? subjectsData?.items ?? [];
|
||||
const subjectsList = Array.isArray(subjectsData) ? subjectsData : [];
|
||||
const createMutation = useCreateGradingAssignment();
|
||||
const updateMutation = useUpdateGradingAssignment();
|
||||
const deleteMutation = useDeleteGradingAssignment();
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function AdminLessons() {
|
||||
const { data: subjectsData } = useSubjects();
|
||||
const courses = coursesData?.items ?? [];
|
||||
const allBatches = batchesData?.items ?? [];
|
||||
const subjects = Array.isArray(subjectsData) ? subjectsData : subjectsData?.data ?? subjectsData?.items ?? [];
|
||||
const subjects = Array.isArray(subjectsData) ? subjectsData : [];
|
||||
const batchesForCourse = useMemo(
|
||||
() => (form.course_id ? allBatches.filter((b) => b.course_id === Number(form.course_id)) : allBatches),
|
||||
[allBatches, form.course_id],
|
||||
|
||||
@@ -39,9 +39,10 @@ export default function AdminLmsDashboard() {
|
||||
|
||||
const { data: dbStats } = useQuery<DashboardStats>({
|
||||
queryKey: ["dashboard", "stats"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<{ data: DashboardStats }>("/stats");
|
||||
return (res as { data: DashboardStats }).data ?? res;
|
||||
queryFn: async (): Promise<DashboardStats> => {
|
||||
const res = await api.get<{ data: DashboardStats } | DashboardStats>("/stats");
|
||||
const wrapped = res as { data?: DashboardStats };
|
||||
return wrapped.data ?? (res as DashboardStats);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function AdminTeachers() {
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [gender, setGender] = useState("male");
|
||||
const [gender, setGender] = useState<"male" | "female">("male");
|
||||
const [specialization, setSpecialization] = useState("");
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
@@ -157,7 +157,7 @@ export default function AdminTeachers() {
|
||||
<div className="space-y-2"><Label>Email *</Label><Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
|
||||
<div className="space-y-2">
|
||||
<Label>Gender</Label>
|
||||
<Select value={gender} onValueChange={setGender}>
|
||||
<Select value={gender} onValueChange={(v) => setGender(v as "male" | "female")}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="male">Male</SelectItem>
|
||||
|
||||
341
src/pages/admin/ExamReviewDetail.tsx
Normal file
341
src/pages/admin/ExamReviewDetail.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
|
||||
import {
|
||||
useApproveExamReview,
|
||||
useExamReviewDetail,
|
||||
useRejectExamReview,
|
||||
} from "@/hooks/queries/useExamReview";
|
||||
import type { ExamReviewQuestion } from "@/types/exam-review";
|
||||
|
||||
const FAILING_THRESHOLD = 0.7;
|
||||
|
||||
function QuestionCard({ q, index }: { q: ExamReviewQuestion; index: number }) {
|
||||
const failing = (q.quality_score ?? 0) < FAILING_THRESHOLD;
|
||||
return (
|
||||
<Card className={failing ? "border-destructive/40" : undefined}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
|
||||
<span className="font-mono">Q{index + 1}</span>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{q.skill}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{q.question_type}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{q.difficulty}
|
||||
</Badge>
|
||||
{q.ai_generated && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
AI
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-sm leading-snug">
|
||||
{q.stem.length > 220 ? `${q.stem.slice(0, 220)}…` : q.stem}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
(q.quality_score ?? 0) >= 0.85
|
||||
? "default"
|
||||
: failing
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
className="tabular-nums shrink-0"
|
||||
>
|
||||
{((q.quality_score ?? 0) * 100).toFixed(0)}%
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{q.quality_report && Object.keys(q.quality_report).length > 0 && (
|
||||
<CardContent>
|
||||
<Label className="text-xs text-muted-foreground">Quality report</Label>
|
||||
<pre className="mt-1 rounded-md bg-muted/50 p-3 text-xs overflow-x-auto">
|
||||
{JSON.stringify(q.quality_report, null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExamReviewDetail() {
|
||||
const { examId } = useParams<{ examId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const parsedId = examId ? Number(examId) : undefined;
|
||||
|
||||
const { data: exam, isLoading } = useExamReviewDetail(parsedId);
|
||||
const approve = useApproveExamReview();
|
||||
const reject = useRejectExamReview();
|
||||
|
||||
const [approveNotes, setApproveNotes] = useState("");
|
||||
const [rejectNotes, setRejectNotes] = useState("");
|
||||
const [approveOpen, setApproveOpen] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
|
||||
if (isLoading || !exam || !parsedId) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleApprove = async () => {
|
||||
try {
|
||||
await approve.mutateAsync({ examId: parsedId, notes: approveNotes });
|
||||
toast.success("Exam approved and published.");
|
||||
setApproveOpen(false);
|
||||
navigate("/admin/exam/review-queue");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Approve failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!rejectNotes.trim()) {
|
||||
toast.error("Rejection notes are required.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await reject.mutateAsync({ examId: parsedId, notes: rejectNotes });
|
||||
toast.success("Exam rejected and returned to draft.");
|
||||
setRejectOpen(false);
|
||||
navigate("/admin/exam/review-queue");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Reject failed");
|
||||
}
|
||||
};
|
||||
|
||||
const summary = exam.summary;
|
||||
const alreadyReviewed = exam.status !== "pending_review";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Button asChild variant="ghost" size="sm" className="-ml-2">
|
||||
<Link to="/admin/exam/review-queue">
|
||||
<ArrowLeft className="h-4 w-4 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
src/pages/admin/ExamReviewQueue.tsx
Normal file
205
src/pages/admin/ExamReviewQueue.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Search,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
import { useExamReviewQueue } from "@/hooks/queries/useExamReview";
|
||||
|
||||
/**
|
||||
* Admin-facing queue of AI-generated exams that failed the automated quality
|
||||
* gate and need a human to sign off before they can be published to students.
|
||||
*
|
||||
* The list intentionally stays dense — each row shows only the signals an
|
||||
* operator needs to prioritise (quality score, failing-question count, AI
|
||||
* model used). Clicking through to the detail page reveals the full report.
|
||||
*/
|
||||
export default function ExamReviewQueue() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
const { data, isLoading } = useExamReviewQueue({
|
||||
page,
|
||||
size: pageSize,
|
||||
search: search || undefined,
|
||||
status: "pending_review",
|
||||
});
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Exam Review Queue</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
AI-generated exams that failed the automated quality gate. Review
|
||||
each one, then approve to publish or reject back to draft.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{total} awaiting review
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by exam title…"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setSearch(e.target.value);
|
||||
}}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="p-6 space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-10 text-center">
|
||||
<CheckCircle2 className="mx-auto h-10 w-10 text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nothing in the review queue. Any time an AI-generated exam
|
||||
fails the quality gate, it will show up here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[32%]">Exam</TableHead>
|
||||
<TableHead>Subject</TableHead>
|
||||
<TableHead className="text-center">Questions</TableHead>
|
||||
<TableHead className="text-center">Avg. Quality</TableHead>
|
||||
<TableHead className="text-center">Failing</TableHead>
|
||||
<TableHead className="text-right">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((exam) => {
|
||||
const avg = exam.summary.avg_quality_score;
|
||||
const failing = exam.summary.failing_count;
|
||||
return (
|
||||
<TableRow key={exam.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-start gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to={`/admin/exam/review/${exam.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{exam.title}
|
||||
</Link>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{exam.summary.ai_generated_count} AI-generated ·{" "}
|
||||
{exam.teacher_name || "Unassigned"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{exam.subject_name || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{exam.summary.question_count}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant={
|
||||
avg >= 0.85
|
||||
? "default"
|
||||
: avg >= 0.7
|
||||
? "secondary"
|
||||
: "destructive"
|
||||
}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{(avg * 100).toFixed(0)}%
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{failing > 0 ? (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{failing}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link to={`/admin/exam/review/${exam.id}`}>Review</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{pageCount > 1 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Page {page} of {pageCount} · {total} total
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= pageCount}
|
||||
onClick={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useIeltsValidation, usePublishIeltsExam, useAssignIeltsExam } from "@/hooks/queries/useExamTemplates";
|
||||
import { useIeltsExamValidation, usePublishIeltsExam, useAssignIeltsExam } from "@/hooks/queries/useExamTemplates";
|
||||
import { useStudents, useBatches } from "@/hooks/queries/useLms";
|
||||
import { ieltsExamService } from "@/services/ielts-exam.service";
|
||||
import type { ExamValidationReport, ValidationCheck } from "@/types";
|
||||
@@ -40,7 +40,7 @@ export default function IeltsExamValidation() {
|
||||
const navigate = useNavigate();
|
||||
const examId = Number(examIdParam);
|
||||
|
||||
const validationQ = useIeltsValidation(Number.isFinite(examId) ? examId : 0);
|
||||
const validationQ = useIeltsExamValidation(Number.isFinite(examId) ? examId : 0);
|
||||
const publishMut = usePublishIeltsExam();
|
||||
const assignMut = useAssignIeltsExam();
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ function EditResourceDialog({
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Status</Label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as "pending" | "approved" | "rejected")}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function TaxonomyManager() {
|
||||
|
||||
const [showAddTopic, setShowAddTopic] = useState<number | null>(null);
|
||||
const [newTopicName, setNewTopicName] = useState("");
|
||||
const [newTopicDifficulty, setNewTopicDifficulty] = useState("medium");
|
||||
const [newTopicDifficulty, setNewTopicDifficulty] = useState<"easy" | "medium" | "hard">("medium");
|
||||
const [newTopicHours, setNewTopicHours] = useState("1");
|
||||
|
||||
const [editingSubjectId, setEditingSubjectId] = useState<number | null>(null);
|
||||
@@ -62,7 +62,7 @@ export default function TaxonomyManager() {
|
||||
|
||||
const [editingTopicId, setEditingTopicId] = useState<number | null>(null);
|
||||
const [editTopicName, setEditTopicName] = useState("");
|
||||
const [editTopicDifficulty, setEditTopicDifficulty] = useState("medium");
|
||||
const [editTopicDifficulty, setEditTopicDifficulty] = useState<"easy" | "medium" | "hard">("medium");
|
||||
const [editTopicHours, setEditTopicHours] = useState("1");
|
||||
const [editTopicDesc, setEditTopicDesc] = useState("");
|
||||
|
||||
@@ -142,7 +142,7 @@ export default function TaxonomyManager() {
|
||||
const startEditTopic = (t: { id: number; name: string; difficulty_level?: string; estimated_hours?: number; description?: string }) => {
|
||||
setEditingTopicId(t.id);
|
||||
setEditTopicName(t.name);
|
||||
setEditTopicDifficulty(t.difficulty_level ?? "medium");
|
||||
setEditTopicDifficulty((t.difficulty_level as "easy" | "medium" | "hard") ?? "medium");
|
||||
setEditTopicHours(String(t.estimated_hours ?? 1));
|
||||
setEditTopicDesc(t.description ?? "");
|
||||
};
|
||||
@@ -333,7 +333,7 @@ export default function TaxonomyManager() {
|
||||
{showAddTopic === domain.id && (
|
||||
<div className="flex items-center gap-2 p-2 rounded border bg-muted/50">
|
||||
<Input placeholder="Topic name" value={newTopicName} onChange={e => setNewTopicName(e.target.value)} className="h-8 text-sm" />
|
||||
<Select value={newTopicDifficulty} onValueChange={setNewTopicDifficulty}>
|
||||
<Select value={newTopicDifficulty} onValueChange={(v) => setNewTopicDifficulty(v as "easy" | "medium" | "hard")}>
|
||||
<SelectTrigger className="w-28 h-8"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="easy">Easy</SelectItem>
|
||||
@@ -352,7 +352,7 @@ export default function TaxonomyManager() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input value={editTopicName} onChange={e => setEditTopicName(e.target.value)} className="h-7 text-sm flex-1" placeholder="Topic name" />
|
||||
<Select value={editTopicDifficulty} onValueChange={setEditTopicDifficulty}>
|
||||
<Select value={editTopicDifficulty} onValueChange={(v) => setEditTopicDifficulty(v as "easy" | "medium" | "hard")}>
|
||||
<SelectTrigger className="w-28 h-7"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="easy">Easy</SelectItem>
|
||||
|
||||
@@ -166,7 +166,7 @@ export default function AiIeltsCourse() {
|
||||
const band = Number(targetBand || course?.target_level || 7);
|
||||
createIelts.mutate(
|
||||
{
|
||||
skill: skillsRanked[0]?.skill ?? "writing",
|
||||
skill: (skillsRanked[0]?.skill ?? "writing") as "reading" | "listening" | "writing" | "speaking",
|
||||
target_band: Number.isFinite(band) ? band : 7,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function ExamResults() {
|
||||
|
||||
const { data: results, isLoading, isError } = useQuery<ExamResultsData>({
|
||||
queryKey: ["exam-results", examId],
|
||||
queryFn: () => examSessionService.getResults(Number(examId)),
|
||||
queryFn: () => examSessionService.getResults<ExamResultsData>(Number(examId)),
|
||||
enabled: !!examId,
|
||||
});
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function StudentAttendance() {
|
||||
{attendanceRecords.map(a => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell>{a.date}</TableCell>
|
||||
<TableCell>{a.courseName}</TableCell>
|
||||
<TableCell>{a.course_name}</TableCell>
|
||||
<TableCell><Badge variant={badgeVariant(a.status)} className="capitalize">{a.status}</Badge></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function StudentDiscussionBoard() {
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
const [newContent, setNewContent] = useState("");
|
||||
|
||||
const posts = postsData?.results ?? [];
|
||||
const posts = postsData?.items ?? [];
|
||||
|
||||
const handleCreatePost = () => {
|
||||
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>;
|
||||
|
||||
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";
|
||||
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";
|
||||
|
||||
const chartData = gradeRecords.map(g => ({
|
||||
name: g.assignmentTitle.length > 15 ? g.assignmentTitle.slice(0, 15) + "…" : g.assignmentTitle,
|
||||
grade: Math.round((g.grade / g.maxGrade) * 100),
|
||||
name: g.assignment_title.length > 15 ? g.assignment_title.slice(0, 15) + "…" : g.assignment_title,
|
||||
grade: Math.round((g.grade / g.max_grade) * 100),
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -69,11 +69,11 @@ export default function StudentGrades() {
|
||||
<TableBody>
|
||||
{gradeRecords.map(g => (
|
||||
<TableRow key={g.id}>
|
||||
<TableCell className="font-medium">{g.assignmentTitle}</TableCell>
|
||||
<TableCell>{g.courseName}</TableCell>
|
||||
<TableCell className="font-medium">{g.assignment_title}</TableCell>
|
||||
<TableCell>{g.course_name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="text-xs capitalize">{g.type}</Badge></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>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -23,8 +23,8 @@ export default function StudentMessages() {
|
||||
const { data: unreadData } = useUnreadMessageCount();
|
||||
const sendMessage = useSendMessage();
|
||||
|
||||
const inbox = inboxData?.results ?? [];
|
||||
const sent = sentData?.results ?? [];
|
||||
const inbox = inboxData?.items ?? [];
|
||||
const sent = sentData?.items ?? [];
|
||||
const unreadCount = unreadData?.count ?? 0;
|
||||
|
||||
const [showCompose, setShowCompose] = useState(false);
|
||||
|
||||
@@ -8,21 +8,21 @@ export default function StudentTimetable() {
|
||||
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>;
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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]) {
|
||||
const start = parseInt(session.startTime.split(":")[0]);
|
||||
const end = parseInt(session.endTime.split(":")[0]);
|
||||
const startMin = parseInt(session.startTime.split(":")[1]);
|
||||
const endMin = parseInt(session.endTime.split(":")[1]);
|
||||
const start = parseInt(session.start_time.split(":")[0]);
|
||||
const end = parseInt(session.end_time.split(":")[0]);
|
||||
const startMin = parseInt(session.start_time.split(":")[1]);
|
||||
const endMin = parseInt(session.end_time.split(":")[1]);
|
||||
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"
|
||||
style={{ backgroundColor: session.color, minHeight: `${getSpan(session) * 48}px` }}
|
||||
>
|
||||
<p className="font-semibold">{session.courseName}</p>
|
||||
<p className="opacity-80">{session.startTime}-{session.endTime}</p>
|
||||
<p className="font-semibold">{session.course_name}</p>
|
||||
<p className="opacity-80">{session.start_time}-{session.end_time}</p>
|
||||
<p className="opacity-70">{session.room}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function ChapterDetail() {
|
||||
queryFn: () => resourcesService.list({
|
||||
search: libSearch || undefined,
|
||||
resource_type: libTypeFilter === "all" ? undefined : libTypeFilter,
|
||||
limit: 50,
|
||||
size: 50,
|
||||
}),
|
||||
enabled: showUpload,
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function TeacherAnnouncements() {
|
||||
const { toast } = useToast();
|
||||
const { data: announcements = [], isLoading } = useAnnouncements();
|
||||
const { data: coursesData } = useCourses();
|
||||
const courses = coursesData?.results ?? [];
|
||||
const courses = coursesData?.items ?? [];
|
||||
const createAnnouncement = useCreateAnnouncement();
|
||||
const publishAnnouncement = usePublishAnnouncement();
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function TeacherAnnouncements() {
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Courses</SelectItem>
|
||||
{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>
|
||||
</Select>
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function TeacherDiscussionBoard() {
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
const [newContent, setNewContent] = useState("");
|
||||
|
||||
const posts = postsData?.results ?? [];
|
||||
const posts = postsData?.items ?? [];
|
||||
|
||||
const handleCreatePost = () => {
|
||||
if (!selectedBoardId) return;
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import type {
|
||||
AICourseConfig,
|
||||
AICourseTrack,
|
||||
QualityGateResult,
|
||||
IELTSValidationResult,
|
||||
} from "@/types/ai-course";
|
||||
|
||||
export type { QualityGateResult, IELTSValidationResult };
|
||||
|
||||
export interface AiCourseCreateEnglishRequest {
|
||||
cefr_level: string;
|
||||
@@ -18,7 +26,11 @@ export interface AiCourseCreateResponse {
|
||||
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;
|
||||
readability_score: number;
|
||||
cefr_alignment: boolean;
|
||||
@@ -26,7 +38,7 @@ export interface QualityGateResult {
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
export interface IELTSValidationResult {
|
||||
export interface IELTSValidationResultLegacy {
|
||||
type: string;
|
||||
validation_results: Record<string, unknown>;
|
||||
overall_passed: boolean;
|
||||
@@ -40,10 +52,10 @@ export const aiCourseService = {
|
||||
api.post<AiCourseCreateResponse>("/ai-course/ielts/create", data),
|
||||
|
||||
getCourse: (courseId: number) =>
|
||||
api.get<Record<string, unknown>>(`/ai-course/${courseId}`),
|
||||
api.get<AICourseConfig>(`/ai-course/${courseId}`),
|
||||
|
||||
getTracks: (courseId: number) =>
|
||||
api.get<unknown[]>(`/ai-course/${courseId}/tracks`),
|
||||
api.get<AICourseTrack[]>(`/ai-course/${courseId}/tracks`),
|
||||
|
||||
getQualityGate: (courseId: number) =>
|
||||
api.get<QualityGateResult>(`/ai-course/${courseId}/quality`),
|
||||
|
||||
54
src/services/ai-feedback.service.ts
Normal file
54
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
src/services/ai-prompt.service.ts
Normal file
61
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 { ApiSuccessResponse } from "@/types";
|
||||
|
||||
export const authService = {
|
||||
async login(data: LoginRequest): Promise<LoginResponse> {
|
||||
// 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 & {
|
||||
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;
|
||||
},
|
||||
|
||||
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();
|
||||
},
|
||||
|
||||
|
||||
57
src/services/exam-review.service.ts
Normal file
57
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) =>
|
||||
api.get<{ status: string; scores_available: boolean }>(`/exam/${examId}/status`),
|
||||
|
||||
getResults: (examId: number) =>
|
||||
api.get(`/exam/${examId}/results`),
|
||||
getResults: <T = unknown>(examId: number) =>
|
||||
api.get<T>(`/exam/${examId}/results`),
|
||||
};
|
||||
|
||||
36
src/services/gdpr.service.ts
Normal file
36
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>> {
|
||||
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);
|
||||
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> {
|
||||
@@ -183,9 +184,10 @@ export const lmsService = {
|
||||
},
|
||||
|
||||
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);
|
||||
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> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 = {
|
||||
async getSessions(params?: Record<string, string | number | boolean | undefined>): Promise<ExamSession[]> {
|
||||
|
||||
@@ -11,7 +11,8 @@ export const taxonomyService = {
|
||||
|
||||
async getSubject(id: number): Promise<Subject> {
|
||||
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> {
|
||||
@@ -28,7 +29,8 @@ export const taxonomyService = {
|
||||
|
||||
async getTaxonomyTree(subjectId: number): Promise<TaxonomyTree> {
|
||||
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> {
|
||||
|
||||
@@ -25,9 +25,10 @@ function mapPortalUser(raw: Record<string, unknown>): User {
|
||||
|
||||
export const usersService = {
|
||||
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);
|
||||
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> {
|
||||
|
||||
@@ -179,10 +179,10 @@ export interface StudentAbilityModel {
|
||||
}
|
||||
|
||||
export interface AdaptiveThresholdSettings {
|
||||
difficulty_step_up_threshold: number;
|
||||
difficulty_step_down_threshold: number;
|
||||
micro_lesson_trigger: number;
|
||||
module_skip_threshold: number;
|
||||
no_progress_alert_days: number;
|
||||
max_retries_per_exercise: number;
|
||||
difficulty_step_up_threshold?: number;
|
||||
difficulty_step_down_threshold?: number;
|
||||
micro_lesson_trigger?: number;
|
||||
module_skip_threshold?: number;
|
||||
no_progress_alert_days?: number;
|
||||
max_retries_per_exercise?: number;
|
||||
}
|
||||
|
||||
55
src/types/ai-feedback.ts
Normal file
55
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
src/types/ai-prompt.ts
Normal file
32
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 {
|
||||
logo_url?: string;
|
||||
primary_color: string;
|
||||
secondary_color: string;
|
||||
background_color: string;
|
||||
primary_color?: string;
|
||||
secondary_color?: string;
|
||||
background_color?: string;
|
||||
custom_subdomain?: string;
|
||||
login_title?: string;
|
||||
login_description?: string;
|
||||
|
||||
@@ -55,11 +55,13 @@ export interface ModuleConfig {
|
||||
estimated_hours: number;
|
||||
cefr_target?: string;
|
||||
resources: ModuleResource[];
|
||||
completion_criteria: "complete_all" | "score_threshold" | "teacher_approval";
|
||||
completion_criteria?: "complete_all" | "score_threshold" | "teacher_approval";
|
||||
score_threshold?: number;
|
||||
prerequisite_module_id?: number;
|
||||
status: "locked" | "available" | "in_progress" | "completed" | "skipped";
|
||||
status?: "locked" | "available" | "in_progress" | "completed" | "skipped";
|
||||
progress_percent?: number;
|
||||
/** Optional assessment gate attached to the module (quiz, project, etc.) */
|
||||
assessment_type?: string;
|
||||
}
|
||||
|
||||
export interface ModuleResource {
|
||||
|
||||
64
src/types/exam-review.ts
Normal file
64
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 {
|
||||
id: number;
|
||||
course_id: number;
|
||||
course_id: string | number;
|
||||
course_name: string;
|
||||
teacher_name: string;
|
||||
batch_name: string;
|
||||
@@ -71,6 +71,8 @@ export interface TimetableSession {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
room: string;
|
||||
/** Optional hex/rgb used by the weekly grid renderer. */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface AttendanceRecord {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface PendingScore {
|
||||
id: number;
|
||||
attempt_id: number;
|
||||
exam_id?: number;
|
||||
student_name: string;
|
||||
exam_title: string;
|
||||
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;
|
||||
user_id: number;
|
||||
exam_id: number;
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface Domain {
|
||||
export interface Topic {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
code: string;
|
||||
domain_id: number;
|
||||
domain_name: string;
|
||||
|
||||
@@ -25,4 +25,45 @@ export default defineConfig(({ mode }) => ({
|
||||
"@": 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