chore(release): sync frontend from monorepo v4 #6
@@ -1,3 +0,0 @@
|
||||
# Relative URL: Vite proxies /api → Odoo :8069 (avoids "Failed to fetch" from CORS).
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_APP_NAME=EnCoach
|
||||
14
Dockerfile
@@ -18,13 +18,25 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Default nginx body cap is 1 MB which causes HTTP 413 on:
|
||||
# * resource uploads (PDF / audio / video)
|
||||
# * /api/exam/generation/submit (whole exam with generated questions)
|
||||
# * /api/approval-requests (attachments)
|
||||
# Match Odoo's raised limit in odoo-docker.conf (128 MB).
|
||||
client_max_body_size 128m;
|
||||
client_body_buffer_size 1m;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8069/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
# OpenAI streaming calls + multi-module AI generation can run >3 min.
|
||||
# Keep read/send timeouts above Odoo's limit_time_real (900s).
|
||||
proxy_read_timeout 900s;
|
||||
proxy_send_timeout 900s;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
||||
27
e2e/login.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Smoke test: the app loads, serves the login page, and the main form
|
||||
* controls are present and reachable. We deliberately avoid hitting the
|
||||
* real backend — this is a lightweight sanity check that catches broken
|
||||
* routing / bundle / asset problems before they reach production.
|
||||
*/
|
||||
test("login page renders the sign-in form", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /sign in|login|welcome/i }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const email = page.getByLabel(/email/i);
|
||||
const password = page.getByLabel(/password/i);
|
||||
await expect(email).toBeVisible();
|
||||
await expect(password).toBeVisible();
|
||||
|
||||
await expect(page.getByRole("button", { name: /sign in|log in/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test("root redirects to login for anonymous users", async ({ page }) => {
|
||||
const response = await page.goto("/");
|
||||
expect(response?.ok()).toBeTruthy();
|
||||
await page.waitForURL(/\/login/i, { timeout: 10_000 });
|
||||
});
|
||||
@@ -6,12 +6,18 @@
|
||||
<title>EnCoach — IELTS & English Learning Platform</title>
|
||||
<meta name="description" content="EnCoach is a comprehensive IELTS and English learning platform for students, teachers, and corporates." />
|
||||
<meta name="author" content="EnCoach" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="/apple-touch-icon.png" />
|
||||
<meta name="theme-color" content="#4A4068" />
|
||||
<meta property="og:title" content="EnCoach — IELTS & English Learning Platform" />
|
||||
<meta property="og:description" content="Comprehensive IELTS preparation and English learning platform." />
|
||||
<meta property="og:image" content="/logo-icon.png" />
|
||||
<meta property="og:type" content="website" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@300;400;500;600;700;800&family=DM+Sans:wght@400;500;600;700&family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
3704
package-lock.json
generated
11
package.json
@@ -10,7 +10,9 @@
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:install": "playwright install --with-deps chromium"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
@@ -47,6 +49,8 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"i18next": "^26.0.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next-themes": "^0.3.0",
|
||||
@@ -54,6 +58,7 @@
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.61.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-resizable-panels": "^2.1.9",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"recharts": "^2.15.4",
|
||||
@@ -65,9 +70,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/node": "^22.16.5",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
@@ -81,6 +87,7 @@
|
||||
"lovable-tagger": "^1.1.13",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-rtl": "^0.9.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.38.0",
|
||||
"vite": "^5.4.19",
|
||||
|
||||
42
playwright.config.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Playwright configuration for EnCoach frontend smoke tests.
|
||||
*
|
||||
* By default we run against a Vite preview server on localhost:4173. In CI
|
||||
* this is started by the `playwright` step in .github/workflows/ci.yml after
|
||||
* `npm run build`.
|
||||
*
|
||||
* Override the base URL with `PLAYWRIGHT_BASE_URL=https://... npx playwright
|
||||
* test` to run against a deployed environment.
|
||||
*/
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:4173";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 5_000 },
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: process.env.CI ? [["github"], ["list"]] : [["list"]],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
webServer: process.env.PLAYWRIGHT_SKIP_WEBSERVER
|
||||
? undefined
|
||||
: {
|
||||
command: "npm run preview -- --host 127.0.0.1 --port 4173",
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60_000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 532 B |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 888 B |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 554 B |
BIN
public/logo-icon.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
12
public/logo.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="EnCoach">
|
||||
<defs>
|
||||
<linearGradient id="eg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#4f46e5"/>
|
||||
<stop offset="50%" stop-color="#7c3aed"/>
|
||||
<stop offset="100%" stop-color="#2563eb"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#eg)"/>
|
||||
<path d="M20 20h22a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H26v6h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H26v6h16a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H20a2 2 0 0 1-2-2V22a2 2 0 0 1 2-2Z" fill="#ffffff"/>
|
||||
<circle cx="48" cy="48" r="5" fill="#facc15" stroke="#ffffff" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 697 B |
2
public/templates/students.csv
Normal file
@@ -0,0 +1,2 @@
|
||||
name,email
|
||||
Jane Doe,jane@example.com
|
||||
|
377
src/App.tsx
@@ -1,126 +1,233 @@
|
||||
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";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { BrowserRouter, Routes, Route, Navigate, useNavigate } from "react-router-dom";
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
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 ForgotPassword from "@/pages/ForgotPassword";
|
||||
// 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";
|
||||
// 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 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 OfficialExamAccess from "@/pages/OfficialExamAccess";
|
||||
import FaqPage from "@/pages/FaqPage";
|
||||
import NotFound from "@/pages/NotFound";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Lazy-loaded route pages
|
||||
// -----------------------------------------------------------------------------
|
||||
// Keeping the initial bundle small matters on slow networks / modest hardware
|
||||
// (many teachers/students open the app from school Wi-Fi). Each page below is
|
||||
// split into its own chunk and only fetched when the user hits the matching
|
||||
// route. The shell (layouts, providers, router) is still eagerly imported so
|
||||
// the first paint stays snappy.
|
||||
|
||||
// Auth
|
||||
const Login = lazy(() => import("@/pages/Login"));
|
||||
const Register = lazy(() => import("@/pages/Register"));
|
||||
const EmailVerification = lazy(() => import("@/pages/EmailVerification"));
|
||||
const OnboardingWizard = lazy(() => import("@/pages/OnboardingWizard"));
|
||||
const ForgotPassword = lazy(() => import("@/pages/ForgotPassword"));
|
||||
const ResetPassword = lazy(() => import("@/pages/ResetPassword"));
|
||||
const ScoreVerification = lazy(() => import("@/pages/ScoreVerification"));
|
||||
|
||||
// Original platform pages
|
||||
const AdminDashboard = lazy(() => import("@/pages/AdminDashboard"));
|
||||
const UsersPage = lazy(() => import("@/pages/UsersPage"));
|
||||
const EntitiesPage = lazy(() => import("@/pages/EntitiesPage"));
|
||||
const AssignmentsPage = lazy(() => import("@/pages/AssignmentsPage"));
|
||||
const ExamsListPage = lazy(() => import("@/pages/ExamsListPage"));
|
||||
const ExamStructuresPage = lazy(() => import("@/pages/ExamStructuresPage"));
|
||||
const RubricsPage = lazy(() => import("@/pages/RubricsPage"));
|
||||
const GenerationPage = lazy(() => import("@/pages/GenerationPage"));
|
||||
const ApprovalWorkflowsPage = lazy(() => import("@/pages/ApprovalWorkflowsPage"));
|
||||
const ClassroomsPage = lazy(() => import("@/pages/ClassroomsPage"));
|
||||
const StudentPerformancePage = lazy(() => import("@/pages/StudentPerformancePage"));
|
||||
const StatsCorporatePage = lazy(() => import("@/pages/StatsCorporatePage"));
|
||||
const RecordPage = lazy(() => import("@/pages/RecordPage"));
|
||||
const VocabularyPage = lazy(() => import("@/pages/VocabularyPage"));
|
||||
const GrammarPage = lazy(() => import("@/pages/GrammarPage"));
|
||||
const PaymentRecordPage = lazy(() => import("@/pages/PaymentRecordPage"));
|
||||
const TicketsPage = lazy(() => import("@/pages/TicketsPage"));
|
||||
const SettingsPage = lazy(() => import("@/pages/SettingsPage"));
|
||||
|
||||
// Student pages
|
||||
const StudentDashboard = lazy(() => import("@/pages/student/StudentDashboard"));
|
||||
const StudentCourses = lazy(() => import("@/pages/student/StudentCourses"));
|
||||
const StudentCourseDetail = lazy(() => import("@/pages/student/StudentCourseDetail"));
|
||||
const StudentAssignments = lazy(() => import("@/pages/student/StudentAssignments"));
|
||||
const StudentGrades = lazy(() => import("@/pages/student/StudentGrades"));
|
||||
const StudentAttendance = lazy(() => import("@/pages/student/StudentAttendance"));
|
||||
const StudentTimetable = lazy(() => import("@/pages/student/StudentTimetable"));
|
||||
const StudentProfile = lazy(() => import("@/pages/student/StudentProfile"));
|
||||
|
||||
// Adaptive learning pages
|
||||
const SubjectSelection = lazy(() => import("@/pages/student/SubjectSelection"));
|
||||
const DiagnosticTest = lazy(() => import("@/pages/student/DiagnosticTest"));
|
||||
const ProficiencyProfile = lazy(() => import("@/pages/student/ProficiencyProfile"));
|
||||
const LearningPlanPage = lazy(() => import("@/pages/student/LearningPlan"));
|
||||
const TopicLearning = lazy(() => import("@/pages/student/TopicLearning"));
|
||||
|
||||
// Teacher pages
|
||||
const TeacherDashboard = lazy(() => import("@/pages/teacher/TeacherDashboard"));
|
||||
const TeacherQuickSetup = lazy(() => import("@/pages/teacher/TeacherQuickSetup"));
|
||||
const TeacherCourses = lazy(() => import("@/pages/teacher/TeacherCourses"));
|
||||
const CourseBuilder = lazy(() => import("@/pages/teacher/CourseBuilder"));
|
||||
const TeacherAssignments = lazy(() => import("@/pages/teacher/TeacherAssignments"));
|
||||
const TeacherAssignmentDetail = lazy(() => import("@/pages/teacher/TeacherAssignmentDetail"));
|
||||
const TeacherAttendance = lazy(() => import("@/pages/teacher/TeacherAttendance"));
|
||||
const TeacherStudents = lazy(() => import("@/pages/teacher/TeacherStudents"));
|
||||
const TeacherTimetable = lazy(() => import("@/pages/teacher/TeacherTimetable"));
|
||||
const TeacherProfile = lazy(() => import("@/pages/teacher/TeacherProfile"));
|
||||
const TeacherLibrary = lazy(() => import("@/pages/teacher/TeacherLibrary"));
|
||||
const AdaptiveSettings = lazy(() => import("@/pages/teacher/AdaptiveSettings"));
|
||||
|
||||
// Admin LMS pages
|
||||
const AdminLmsDashboard = lazy(() => import("@/pages/admin/AdminLmsDashboard"));
|
||||
const AdminQuickSetup = lazy(() => import("@/pages/admin/AdminQuickSetup"));
|
||||
const SmartWizardHub = lazy(() => import("@/pages/admin/SmartWizardHub"));
|
||||
const RubricWizard = lazy(() => import("@/pages/admin/wizards/RubricWizard"));
|
||||
const ExamStructureWizard = lazy(() => import("@/pages/admin/wizards/ExamStructureWizard"));
|
||||
const CourseWizard = lazy(() => import("@/pages/admin/wizards/CourseWizard"));
|
||||
const CoursePlanWizard = lazy(() => import("@/pages/admin/wizards/CoursePlanWizard"));
|
||||
const AdminCoursePlans = lazy(() => import("@/pages/admin/AdminCoursePlans"));
|
||||
const AdminCoursePlanDetail = lazy(() => import("@/pages/admin/AdminCoursePlanDetail"));
|
||||
const AdminCourses = lazy(() => import("@/pages/admin/AdminCourses"));
|
||||
const AdminStudents = lazy(() => import("@/pages/admin/AdminStudents"));
|
||||
const AdminTeachers = lazy(() => import("@/pages/admin/AdminTeachers"));
|
||||
const AdminBatches = lazy(() => import("@/pages/admin/AdminBatches"));
|
||||
const AdminBatchDetail = lazy(() => import("@/pages/admin/AdminBatchDetail"));
|
||||
const AdminTimetable = lazy(() => import("@/pages/admin/AdminTimetable"));
|
||||
const AdminReports = lazy(() => import("@/pages/admin/AdminReports"));
|
||||
const AdminSettings = lazy(() => import("@/pages/admin/AdminSettings"));
|
||||
const AdminProfileLms = lazy(() => import("@/pages/admin/AdminProfile"));
|
||||
const TaxonomyManager = lazy(() => import("@/pages/admin/TaxonomyManager"));
|
||||
const ResourceManager = lazy(() => import("@/pages/admin/ResourceManager"));
|
||||
const AcademicYearManager = lazy(() => import("@/pages/admin/AcademicYearManager"));
|
||||
const DepartmentManager = lazy(() => import("@/pages/admin/DepartmentManager"));
|
||||
const AdmissionList = lazy(() => import("@/pages/admin/AdmissionList"));
|
||||
const AdmissionDetail = lazy(() => import("@/pages/admin/AdmissionDetail"));
|
||||
const AdmissionRegisterPage = lazy(() => import("@/pages/admin/AdmissionRegisterPage"));
|
||||
const InstitutionalExamSessions = lazy(() => import("@/pages/admin/InstitutionalExamSessions"));
|
||||
const MarksheetManager = lazy(() => import("@/pages/admin/MarksheetManager"));
|
||||
const AdminStudentLeave = lazy(() => import("@/pages/admin/AdminStudentLeave"));
|
||||
const AdminFees = lazy(() => import("@/pages/admin/AdminFees"));
|
||||
const AdminLessons = lazy(() => import("@/pages/admin/AdminLessons"));
|
||||
const AdminGradebook = lazy(() => import("@/pages/admin/AdminGradebook"));
|
||||
const AdminStudentProgress = lazy(() => import("@/pages/admin/AdminStudentProgress"));
|
||||
const AdminLibrary = lazy(() => import("@/pages/admin/AdminLibrary"));
|
||||
const AdminActivities = lazy(() => import("@/pages/admin/AdminActivities"));
|
||||
const AdminFacilities = lazy(() => import("@/pages/admin/AdminFacilities"));
|
||||
const AdmissionApplication = lazy(() => import("@/pages/AdmissionApplication"));
|
||||
const SubjectRegistrationPage = lazy(() => import("@/pages/student/SubjectRegistrationPage"));
|
||||
|
||||
// Courseware pages
|
||||
const CourseChapters = lazy(() => import("@/pages/teacher/CourseChapters"));
|
||||
const ChapterDetail = lazy(() => import("@/pages/teacher/ChapterDetail"));
|
||||
const AiWorkbench = lazy(() => import("@/pages/teacher/AiWorkbench"));
|
||||
const TeacherDiscussionBoard = lazy(() => import("@/pages/teacher/TeacherDiscussionBoard"));
|
||||
const TeacherAnnouncements = lazy(() => import("@/pages/teacher/TeacherAnnouncements"));
|
||||
const StudentChapterView = lazy(() => import("@/pages/student/StudentChapterView"));
|
||||
const StudentDiscussionBoard = lazy(() => import("@/pages/student/StudentDiscussionBoard"));
|
||||
const StudentAnnouncements = lazy(() => import("@/pages/student/StudentAnnouncements"));
|
||||
const StudentMessages = lazy(() => import("@/pages/student/StudentMessages"));
|
||||
const StudentJourney = lazy(() => import("@/pages/student/StudentJourney"));
|
||||
const StudentCoursePlans = lazy(() => import("@/pages/student/StudentCoursePlans"));
|
||||
const StudentCoursePlanDetail = lazy(() => import("@/pages/student/StudentCoursePlanDetail"));
|
||||
const AiEnglishCourse = lazy(() => import("@/pages/student/AiEnglishCourse"));
|
||||
const AiIeltsCourse = lazy(() => import("@/pages/student/AiIeltsCourse"));
|
||||
const ExamSession = lazy(() => import("@/pages/student/ExamSession"));
|
||||
const ExamStatus = lazy(() => import("@/pages/student/ExamStatus"));
|
||||
const ExamResults = lazy(() => import("@/pages/student/ExamResults"));
|
||||
const GapAnalysis = lazy(() => import("@/pages/student/GapAnalysis"));
|
||||
const CourseDelivery = lazy(() => import("@/pages/student/CourseDelivery"));
|
||||
const GradingQueue = lazy(() => import("@/pages/admin/GradingQueue"));
|
||||
const CourseConfig = lazy(() => import("@/pages/admin/CourseConfig"));
|
||||
const ModuleBuilder = lazy(() => import("@/pages/admin/ModuleBuilder"));
|
||||
const CourseProgress = lazy(() => import("@/pages/teacher/CourseProgress"));
|
||||
const PlacementBriefing = lazy(() => import("@/pages/student/PlacementBriefing"));
|
||||
const PlacementTest = lazy(() => import("@/pages/student/PlacementTest"));
|
||||
const PlacementResults = lazy(() => import("@/pages/student/PlacementResults"));
|
||||
const PlacementAccess = lazy(() => import("@/pages/student/PlacementAccess"));
|
||||
const FaqManager = lazy(() => import("@/pages/admin/FaqManager"));
|
||||
const NotificationRules = lazy(() => import("@/pages/admin/NotificationRules"));
|
||||
const ApprovalWorkflowConfig = lazy(() => import("@/pages/admin/ApprovalWorkflowConfig"));
|
||||
const RolesPermissions = lazy(() => import("@/pages/admin/RolesPermissions"));
|
||||
const AuthorityMatrix = lazy(() => import("@/pages/admin/AuthorityMatrix"));
|
||||
const UserRoles = lazy(() => import("@/pages/admin/UserRoles"));
|
||||
const BulkStudentUpload = lazy(() => import("@/pages/admin/BulkStudentUpload"));
|
||||
const CredentialDashboard = lazy(() => import("@/pages/admin/CredentialDashboard"));
|
||||
const AiEnglishQuality = lazy(() => import("@/pages/admin/AiEnglishQuality"));
|
||||
const AiEnglishTaxonomy = lazy(() => import("@/pages/admin/AiEnglishTaxonomy"));
|
||||
const AiIeltsValidation = lazy(() => import("@/pages/admin/AiIeltsValidation"));
|
||||
const AdaptiveDashboard = lazy(() => import("@/pages/admin/AdaptiveDashboard"));
|
||||
const AdaptiveStudentDetail = lazy(() => import("@/pages/admin/AdaptiveStudentDetail"));
|
||||
const LevelMappingConfig = lazy(() => import("@/pages/admin/LevelMappingConfig"));
|
||||
const WhiteLabelBranding = lazy(() => import("@/pages/admin/WhiteLabelBranding"));
|
||||
const ScoreApprovalQueue = lazy(() => import("@/pages/admin/ScoreApprovalQueue"));
|
||||
const ExamTemplateSelection = lazy(() => import("@/pages/admin/ExamTemplateSelection"));
|
||||
const IeltsExamCreate = lazy(() => import("@/pages/admin/IeltsExamCreate"));
|
||||
const IeltsSkillConfig = lazy(() => import("@/pages/admin/IeltsSkillConfig"));
|
||||
const IeltsContentPool = lazy(() => import("@/pages/admin/IeltsContentPool"));
|
||||
const IeltsExamValidation = lazy(() => import("@/pages/admin/IeltsExamValidation"));
|
||||
const CustomExamCreate = lazy(() => import("@/pages/admin/CustomExamCreate"));
|
||||
const ExamReviewQueue = lazy(() => import("@/pages/admin/ExamReviewQueue"));
|
||||
const ExamReviewDetail = lazy(() => import("@/pages/admin/ExamReviewDetail"));
|
||||
const AIPromptEditor = lazy(() => import("@/pages/admin/AIPromptEditor"));
|
||||
const AIFeedbackTriage = lazy(() => import("@/pages/admin/AIFeedbackTriage"));
|
||||
const PrivacyCenter = lazy(() => import("@/pages/PrivacyCenter"));
|
||||
const OfficialExamAccess = lazy(() => import("@/pages/OfficialExamAccess"));
|
||||
const FaqPage = lazy(() => import("@/pages/FaqPage"));
|
||||
const NotFound = lazy(() => import("@/pages/NotFound"));
|
||||
|
||||
function StudentSubscriptionPlaceholder() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center gap-4 p-8">
|
||||
<p className="text-muted-foreground text-center max-w-sm">Subscription checkout will be available here.</p>
|
||||
<Button onClick={() => navigate("/student/dashboard")}>Back to dashboard</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteFallback() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const App = () => (
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
storageKey="encoach-theme"
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
<BrowserRouter>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
<AuthProvider>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Routes>
|
||||
{/* Auth routes */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/verify-email" element={<EmailVerification />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/verify/:verificationHash" element={<ScoreVerification />} />
|
||||
{/* Public pages */}
|
||||
<Route path="/apply" element={<AdmissionApplication />} />
|
||||
<Route path="/exam/access/:token" element={<OfficialExamAccess />} />
|
||||
@@ -128,8 +235,16 @@ const App = () => (
|
||||
<Route path="/exam/login/:examId" element={<OfficialExamAccess />} />
|
||||
<Route path="/faq" element={<FaqPage />} />
|
||||
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/onboarding" element={<OnboardingWizard />} />
|
||||
</Route>
|
||||
|
||||
{/* Student routes */}
|
||||
<Route element={<ProtectedRoute allowedRoles={["student"]} />}>
|
||||
<Route path="/student/exam/:examId/session" element={<ExamSession />} />
|
||||
<Route path="/student/exam/:examId/status" element={<ExamStatus />} />
|
||||
<Route path="/student/placement/test" element={<PlacementTest />} />
|
||||
<Route path="/student/subscription" element={<StudentSubscriptionPlaceholder />} />
|
||||
<Route element={<StudentLayout />}>
|
||||
<Route path="/student/dashboard" element={<StudentDashboard />} />
|
||||
<Route path="/student/courses" element={<StudentCourses />} />
|
||||
@@ -139,6 +254,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 />} />
|
||||
@@ -150,14 +266,26 @@ const App = () => (
|
||||
<Route path="/student/announcements" element={<StudentAnnouncements />} />
|
||||
<Route path="/student/messages" element={<StudentMessages />} />
|
||||
<Route path="/student/journey" element={<StudentJourney />} />
|
||||
<Route path="/student/placement" element={<PlacementBriefing />} />
|
||||
<Route path="/student/placement/results" element={<PlacementResults />} />
|
||||
<Route path="/student/placement/access" element={<PlacementAccess />} />
|
||||
<Route path="/student/exam/:examId/results" element={<ExamResults />} />
|
||||
<Route path="/student/course/generate" element={<GapAnalysis />} />
|
||||
<Route path="/student/course-plans" element={<StudentCoursePlans />} />
|
||||
<Route path="/student/course-plans/:planId" element={<StudentCoursePlanDetail />} />
|
||||
<Route path="/student/course/ai-english/:courseId" element={<AiEnglishCourse />} />
|
||||
<Route path="/student/course/ai-ielts/:courseId" element={<AiIeltsCourse />} />
|
||||
<Route path="/student/course/:courseId" element={<CourseDelivery />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Teacher routes */}
|
||||
<Route element={<ProtectedRoute allowedRoles={["teacher"]} />}>
|
||||
<Route element={<ProtectedRoute allowedRoles={["teacher", "admin", "developer"]} />}>
|
||||
<Route element={<TeacherLayout />}>
|
||||
<Route path="/teacher/dashboard" element={<TeacherDashboard />} />
|
||||
<Route path="/teacher/quick-setup" element={<TeacherQuickSetup />} />
|
||||
<Route path="/teacher/courses" element={<TeacherCourses />} />
|
||||
<Route path="/teacher/library" element={<TeacherLibrary />} />
|
||||
<Route path="/teacher/courses/new" element={<CourseBuilder />} />
|
||||
<Route path="/teacher/courses/:id/edit" element={<CourseBuilder />} />
|
||||
<Route path="/teacher/assignments" element={<TeacherAssignments />} />
|
||||
@@ -165,12 +293,15 @@ const App = () => (
|
||||
<Route path="/teacher/attendance" element={<TeacherAttendance />} />
|
||||
<Route path="/teacher/students" element={<TeacherStudents />} />
|
||||
<Route path="/teacher/timetable" element={<TeacherTimetable />} />
|
||||
<Route path="/teacher/courses/:id/chapters" element={<CourseChapters />} />
|
||||
<Route path="/teacher/courses/:id/chapters/:chapterId" element={<ChapterDetail />} />
|
||||
<Route path="/teacher/courses/:id/workbench" element={<AiWorkbench />} />
|
||||
<Route path="/teacher/courses/:courseId/chapters" element={<CourseChapters />} />
|
||||
<Route path="/teacher/courses/:courseId/chapters/:chapterId" element={<ChapterDetail />} />
|
||||
<Route path="/teacher/courses/:courseId/workbench" element={<AiWorkbench />} />
|
||||
<Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} />
|
||||
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
|
||||
<Route path="/teacher/profile" element={<TeacherProfile />} />
|
||||
<Route path="/teacher/privacy" element={<PrivacyCenter />} />
|
||||
<Route path="/teacher/course/:courseId/progress" element={<CourseProgress />} />
|
||||
<Route path="/teacher/adaptive/settings" element={<AdaptiveSettings />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -179,6 +310,14 @@ const App = () => (
|
||||
<Route element={<AdminLmsLayout />}>
|
||||
{/* LMS Dashboard */}
|
||||
<Route path="/admin/dashboard" element={<AdminLmsDashboard />} />
|
||||
<Route path="/admin/quick-setup" element={<AdminQuickSetup />} />
|
||||
<Route path="/admin/smart-wizard" element={<SmartWizardHub />} />
|
||||
<Route path="/admin/smart-wizard/rubric" element={<RubricWizard />} />
|
||||
<Route path="/admin/smart-wizard/exam-structure" element={<ExamStructureWizard />} />
|
||||
<Route path="/admin/smart-wizard/course" element={<CourseWizard />} />
|
||||
<Route path="/admin/smart-wizard/course-plan" element={<CoursePlanWizard />} />
|
||||
<Route path="/admin/course-plans" element={<AdminCoursePlans />} />
|
||||
<Route path="/admin/course-plans/:planId" element={<AdminCoursePlanDetail />} />
|
||||
{/* Original platform dashboard */}
|
||||
<Route path="/admin/platform" element={<AdminDashboard />} />
|
||||
{/* LMS pages */}
|
||||
@@ -191,6 +330,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 />} />
|
||||
@@ -213,7 +353,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 */}
|
||||
@@ -239,6 +388,19 @@ const App = () => (
|
||||
<Route path="/admin/roles-permissions" element={<RolesPermissions />} />
|
||||
<Route path="/admin/authority-matrix" element={<AuthorityMatrix />} />
|
||||
<Route path="/admin/user-roles" element={<UserRoles />} />
|
||||
<Route path="/admin/exam/:examId/grading" element={<GradingQueue />} />
|
||||
<Route path="/admin/course/configure/:courseId" element={<CourseConfig />} />
|
||||
<Route path="/admin/course/:courseId/modules" element={<ModuleBuilder />} />
|
||||
<Route path="/admin/entity/students/upload" element={<BulkStudentUpload />} />
|
||||
<Route path="/admin/entity/students/credentials" element={<CredentialDashboard />} />
|
||||
<Route path="/admin/entity/:entityId/level-mapping" element={<LevelMappingConfig />} />
|
||||
<Route path="/admin/entity/:entityId/branding" element={<WhiteLabelBranding />} />
|
||||
<Route path="/admin/ai-course/english/:courseId/quality" element={<AiEnglishQuality />} />
|
||||
<Route path="/admin/ai-course/english/taxonomy" element={<AiEnglishTaxonomy />} />
|
||||
<Route path="/admin/ai-course/ielts/:courseId/validation" element={<AiIeltsValidation />} />
|
||||
<Route path="/admin/adaptive/dashboard" element={<AdaptiveDashboard />} />
|
||||
<Route path="/admin/adaptive/student/:studentId" element={<AdaptiveStudentDetail />} />
|
||||
<Route path="/admin/scores/pending" element={<ScoreApprovalQueue />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -249,10 +411,13 @@ const App = () => (
|
||||
<Route path="/dashboard/admin" element={<Navigate to="/admin/platform" replace />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
export default App;
|
||||
|
||||
164
src/components/AIFeedbackButtons.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useAIFeedbackSummary,
|
||||
useSubmitAIFeedback,
|
||||
} from "@/hooks/queries/useAIFeedback";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
AIFeedbackRating,
|
||||
AIFeedbackSubjectType,
|
||||
} from "@/types/ai-feedback";
|
||||
|
||||
export interface AIFeedbackButtonsProps {
|
||||
subjectType: AIFeedbackSubjectType;
|
||||
subjectId: number;
|
||||
promptKey?: string;
|
||||
promptVersion?: number;
|
||||
aiLogId?: number;
|
||||
entityId?: number;
|
||||
courseId?: number;
|
||||
size?: "sm" | "md";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Thumbs up/down widget for any AI-generated artefact.
|
||||
*
|
||||
* Always renders the same UI regardless of whether the user has rated the
|
||||
* artefact before — a previous rating will show as highlighted. Clicking the
|
||||
* same button again is a no-op; clicking the opposite button updates the
|
||||
* server-side row in place. A thumbs-down always prompts the user for a short
|
||||
* comment (required by the backend).
|
||||
*/
|
||||
export function AIFeedbackButtons({
|
||||
subjectType,
|
||||
subjectId,
|
||||
promptKey,
|
||||
promptVersion,
|
||||
aiLogId,
|
||||
entityId,
|
||||
courseId,
|
||||
size = "sm",
|
||||
className,
|
||||
}: AIFeedbackButtonsProps) {
|
||||
const summary = useAIFeedbackSummary(subjectType, subjectId);
|
||||
const submit = useSubmitAIFeedback();
|
||||
|
||||
const [downOpen, setDownOpen] = useState(false);
|
||||
const [comment, setComment] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (downOpen) {
|
||||
setComment(summary.data?.my_comment ?? "");
|
||||
}
|
||||
}, [downOpen, summary.data?.my_comment]);
|
||||
|
||||
const myRating: AIFeedbackRating | null = summary.data?.my_rating ?? null;
|
||||
|
||||
const submitUp = async () => {
|
||||
if (myRating === "up") return;
|
||||
try {
|
||||
await submit.mutateAsync({
|
||||
subject_type: subjectType,
|
||||
subject_id: subjectId,
|
||||
rating: "up",
|
||||
prompt_key: promptKey,
|
||||
prompt_version: promptVersion,
|
||||
ai_log_id: aiLogId,
|
||||
entity_id: entityId,
|
||||
course_id: courseId,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to submit");
|
||||
}
|
||||
};
|
||||
|
||||
const submitDown = async () => {
|
||||
if (!comment.trim()) {
|
||||
toast.error("Please tell us what was wrong.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await submit.mutateAsync({
|
||||
subject_type: subjectType,
|
||||
subject_id: subjectId,
|
||||
rating: "down",
|
||||
comment: comment.trim(),
|
||||
prompt_key: promptKey,
|
||||
prompt_version: promptVersion,
|
||||
ai_log_id: aiLogId,
|
||||
entity_id: entityId,
|
||||
course_id: courseId,
|
||||
});
|
||||
setDownOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to submit");
|
||||
}
|
||||
};
|
||||
|
||||
const btnSize = size === "sm" ? "sm" : "default";
|
||||
const iconCls = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", className)}>
|
||||
<Button
|
||||
variant={myRating === "up" ? "default" : "outline"}
|
||||
size={btnSize}
|
||||
onClick={submitUp}
|
||||
disabled={submit.isPending}
|
||||
aria-label="Thumbs up"
|
||||
>
|
||||
<ThumbsUp className={iconCls} />
|
||||
<span className="ml-1 text-xs">{summary.data?.up ?? 0}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={myRating === "down" ? "default" : "outline"}
|
||||
size={btnSize}
|
||||
onClick={() => setDownOpen(true)}
|
||||
disabled={submit.isPending}
|
||||
aria-label="Thumbs down"
|
||||
>
|
||||
<ThumbsDown className={iconCls} />
|
||||
<span className="ml-1 text-xs">{summary.data?.down ?? 0}</span>
|
||||
</Button>
|
||||
|
||||
<Dialog open={downOpen} onOpenChange={setDownOpen}>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
description="Tell us what was wrong so we can improve this AI output."
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>What went wrong?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="e.g. Wrong answer, confusing wording, off-topic…"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDownOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submitDown} disabled={submit.isPending}>
|
||||
Submit feedback
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AIFeedbackButtons;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Outlet, Link, useNavigate, useLocation } from "react-router-dom";
|
||||
import { Outlet, Link, useNavigate, useLocation, useMatch } from "react-router-dom";
|
||||
import { Suspense } from "react";
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import {
|
||||
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
||||
@@ -9,10 +10,13 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
|
||||
import { NavLink } from "@/components/NavLink";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import NotificationDropdown from "@/components/NotificationDropdown";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { LanguageToggle } from "@/components/LanguageToggle";
|
||||
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
|
||||
import AiSearchBar from "@/components/ai/AiSearchBar";
|
||||
import { usePermissions } from "@/hooks/usePermissions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
@@ -29,115 +33,128 @@ import {
|
||||
CalendarDays, Landmark, UserPlus, ScrollText, Award,
|
||||
HelpCircle as FaqIcon, Bell, Workflow,
|
||||
CalendarOff, DollarSign, BookMarked, BarChartHorizontal, TrendingUp,
|
||||
Library, Activity, Warehouse, UserCog,
|
||||
Library, Activity, Warehouse, UserCog, Sparkles, Compass,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// ============= Navigation Config =============
|
||||
interface NavItem { title: string; url: string; icon: LucideIcon }
|
||||
// Items store i18n keys (`titleKey`) rather than literal strings so Arabic
|
||||
// language switches update the sidebar without the component having to
|
||||
// re-mount.
|
||||
interface NavItem { titleKey: string; url: string; icon: LucideIcon }
|
||||
|
||||
const overviewItems: NavItem[] = [
|
||||
{ title: "Admin Dashboard", url: "/admin/dashboard", icon: LayoutDashboard },
|
||||
{ title: "Platform Dashboard", url: "/admin/platform", icon: BarChart3 },
|
||||
{ titleKey: "nav.smartWizard", url: "/admin/smart-wizard", icon: Sparkles },
|
||||
{ titleKey: "nav.adminDashboard", url: "/admin/dashboard", icon: LayoutDashboard },
|
||||
{ titleKey: "nav.platformDashboard", url: "/admin/platform", icon: BarChart3 },
|
||||
];
|
||||
|
||||
const lmsItems: NavItem[] = [
|
||||
{ title: "Courses", url: "/admin/courses", icon: BookOpen },
|
||||
{ title: "Students", url: "/admin/students", icon: Users },
|
||||
{ title: "Teachers", url: "/admin/teachers", icon: GraduationCap },
|
||||
{ title: "Batches", url: "/admin/batches", icon: Layers },
|
||||
{ title: "Timetable", url: "/admin/timetable", icon: Calendar },
|
||||
{ title: "Reports", url: "/admin/reports", icon: BarChart3 },
|
||||
{ titleKey: "nav.courses", url: "/admin/courses", icon: BookOpen },
|
||||
{ titleKey: "nav.students", url: "/admin/students", icon: Users },
|
||||
{ titleKey: "nav.teachers", url: "/admin/teachers", icon: GraduationCap },
|
||||
{ titleKey: "nav.batches", url: "/admin/batches", icon: Layers },
|
||||
{ titleKey: "nav.timetable", url: "/admin/timetable", icon: Calendar },
|
||||
{ titleKey: "nav.reports", url: "/admin/reports", icon: BarChart3 },
|
||||
];
|
||||
|
||||
const academicItems: NavItem[] = [
|
||||
{ title: "Assignments", url: "/admin/assignments", icon: ClipboardList },
|
||||
{ title: "Exams List", url: "/admin/examsList", icon: FileText },
|
||||
{ title: "Exam Structures", url: "/admin/exam-structures", icon: Layers },
|
||||
{ title: "Rubrics", url: "/admin/rubrics", icon: BookOpen },
|
||||
{ title: "Generation", url: "/admin/generation", icon: Wand2 },
|
||||
{ title: "Approval Workflows", url: "/admin/approval-workflows", icon: GitBranch },
|
||||
{ titleKey: "nav.assignments", url: "/admin/assignments", icon: ClipboardList },
|
||||
{ titleKey: "nav.examsList", url: "/admin/examsList", icon: FileText },
|
||||
{ titleKey: "nav.examStructures", url: "/admin/exam-structures", icon: Layers },
|
||||
{ titleKey: "nav.rubrics", url: "/admin/rubrics", icon: BookOpen },
|
||||
{ titleKey: "nav.generation", url: "/admin/generation", icon: Wand2 },
|
||||
{ titleKey: "nav.reviewQueue", url: "/admin/exam/review-queue", icon: Sparkles },
|
||||
{ titleKey: "nav.aiPrompts", url: "/admin/ai/prompts", icon: Wand2 },
|
||||
{ titleKey: "nav.aiFeedback", url: "/admin/ai/feedback", icon: Sparkles },
|
||||
{ titleKey: "nav.coursePlans", url: "/admin/course-plans", icon: Compass },
|
||||
{ titleKey: "nav.approvalWorkflows", url: "/admin/approval-workflows", icon: GitBranch },
|
||||
];
|
||||
|
||||
const adaptiveItems: NavItem[] = [
|
||||
{ title: "Taxonomy", url: "/admin/taxonomy", icon: Target },
|
||||
{ title: "Resources", url: "/admin/resources", icon: FolderOpen },
|
||||
{ titleKey: "nav.taxonomy", url: "/admin/taxonomy", icon: Target },
|
||||
{ titleKey: "nav.resources", url: "/admin/resources", icon: FolderOpen },
|
||||
];
|
||||
|
||||
const institutionalItems: NavItem[] = [
|
||||
{ title: "Academic Years", url: "/admin/academic-years", icon: CalendarDays },
|
||||
{ title: "Departments", url: "/admin/departments", icon: Landmark },
|
||||
{ title: "Admissions", url: "/admin/admissions", icon: UserPlus },
|
||||
{ title: "Admission Register", url: "/admin/admission-register", icon: ScrollText },
|
||||
{ title: "Exam Sessions", url: "/admin/exam-sessions", icon: FileText },
|
||||
{ title: "Marksheets", url: "/admin/marksheets", icon: Award },
|
||||
{ title: "Student Leave", url: "/admin/student-leave", icon: CalendarOff },
|
||||
{ title: "Fees & Payments", url: "/admin/fees", icon: DollarSign },
|
||||
{ title: "Lessons", url: "/admin/lessons", icon: BookMarked },
|
||||
{ title: "Gradebook", url: "/admin/gradebook", icon: BarChartHorizontal },
|
||||
{ title: "Student Progress", url: "/admin/student-progress", icon: TrendingUp },
|
||||
{ title: "Library", url: "/admin/library", icon: Library },
|
||||
{ title: "Activities", url: "/admin/activities", icon: Activity },
|
||||
{ title: "Facilities", url: "/admin/facilities", icon: Warehouse },
|
||||
{ titleKey: "nav.academicYears", url: "/admin/academic-years", icon: CalendarDays },
|
||||
{ titleKey: "nav.departments", url: "/admin/departments", icon: Landmark },
|
||||
{ titleKey: "nav.admissions", url: "/admin/admissions", icon: UserPlus },
|
||||
{ titleKey: "nav.admissionRegister", url: "/admin/admission-register", icon: ScrollText },
|
||||
{ titleKey: "nav.examSessions", url: "/admin/exam-sessions", icon: FileText },
|
||||
{ titleKey: "nav.marksheets", url: "/admin/marksheets", icon: Award },
|
||||
{ titleKey: "nav.studentLeave", url: "/admin/student-leave", icon: CalendarOff },
|
||||
{ titleKey: "nav.fees", url: "/admin/fees", icon: DollarSign },
|
||||
{ titleKey: "nav.lessons", url: "/admin/lessons", icon: BookMarked },
|
||||
{ titleKey: "nav.gradebook", url: "/admin/gradebook", icon: BarChartHorizontal },
|
||||
{ titleKey: "nav.studentProgress", url: "/admin/student-progress", icon: TrendingUp },
|
||||
{ titleKey: "nav.library", url: "/admin/library", icon: Library },
|
||||
{ titleKey: "nav.activities", url: "/admin/activities", icon: Activity },
|
||||
{ titleKey: "nav.facilities", url: "/admin/facilities", icon: Warehouse },
|
||||
];
|
||||
|
||||
const managementItems: NavItem[] = [
|
||||
{ title: "Users", url: "/admin/users", icon: Users },
|
||||
{ title: "Entities", url: "/admin/entities", icon: Building2 },
|
||||
{ title: "Classrooms", url: "/admin/classrooms", icon: GraduationCap },
|
||||
{ title: "User Roles", url: "/admin/user-roles", icon: UserCog },
|
||||
{ title: "Roles & Permissions", url: "/admin/roles-permissions", icon: Settings },
|
||||
{ title: "Authority Matrix", url: "/admin/authority-matrix", icon: Workflow },
|
||||
{ titleKey: "nav.users", url: "/admin/users", icon: Users },
|
||||
{ titleKey: "nav.entities", url: "/admin/entities", icon: Building2 },
|
||||
{ titleKey: "nav.classrooms", url: "/admin/classrooms", icon: GraduationCap },
|
||||
{ titleKey: "nav.userRoles", url: "/admin/user-roles", icon: UserCog },
|
||||
{ titleKey: "nav.rolesPermissions", url: "/admin/roles-permissions", icon: Settings },
|
||||
{ titleKey: "nav.authorityMatrix", url: "/admin/authority-matrix", icon: Workflow },
|
||||
];
|
||||
|
||||
const reportItems: NavItem[] = [
|
||||
{ title: "Student Performance", url: "/admin/student-performance", icon: BarChart3 },
|
||||
{ title: "Stats Corporate", url: "/admin/stats-corporate", icon: Building2 },
|
||||
{ title: "Record", url: "/admin/record", icon: History },
|
||||
{ titleKey: "nav.studentPerformance", url: "/admin/student-performance", icon: BarChart3 },
|
||||
{ titleKey: "nav.statsCorporate", url: "/admin/stats-corporate", icon: Building2 },
|
||||
{ titleKey: "nav.record", url: "/admin/record", icon: History },
|
||||
];
|
||||
|
||||
const trainingItems: NavItem[] = [
|
||||
{ title: "Vocabulary", url: "/admin/training/vocabulary", icon: BookA },
|
||||
{ title: "Grammar", url: "/admin/training/grammar", icon: PenTool },
|
||||
{ titleKey: "nav.vocabulary", url: "/admin/training/vocabulary", icon: BookA },
|
||||
{ titleKey: "nav.grammar", url: "/admin/training/grammar", icon: PenTool },
|
||||
];
|
||||
|
||||
const configItems: NavItem[] = [
|
||||
{ title: "FAQ Manager", url: "/admin/faq", icon: FaqIcon },
|
||||
{ title: "Notification Rules", url: "/admin/notification-rules", icon: Bell },
|
||||
{ title: "Approval Config", url: "/admin/approval-config", icon: Workflow },
|
||||
{ titleKey: "nav.faqManager", url: "/admin/faq", icon: FaqIcon },
|
||||
{ titleKey: "nav.notificationRules", url: "/admin/notification-rules", icon: Bell },
|
||||
{ titleKey: "nav.approvalConfig", url: "/admin/approval-config", icon: Workflow },
|
||||
];
|
||||
|
||||
const supportItems: NavItem[] = [
|
||||
{ title: "Payment Record", url: "/admin/payment-record", icon: CreditCard },
|
||||
{ title: "Tickets", url: "/admin/tickets", icon: Ticket },
|
||||
{ title: "Settings", url: "/admin/settings-platform", icon: Settings },
|
||||
{ titleKey: "nav.paymentRecord", url: "/admin/payment-record", icon: CreditCard },
|
||||
{ titleKey: "nav.tickets", url: "/admin/tickets", icon: Ticket },
|
||||
{ titleKey: "nav.settings", url: "/admin/settings-platform", icon: Settings },
|
||||
];
|
||||
|
||||
// ============= Reusable sidebar group =============
|
||||
function SidebarNavGroup({ label, items }: { label: string; items: NavItem[] }) {
|
||||
function SidebarNavGroup({ labelKey, items }: { labelKey: string; items: NavItem[] }) {
|
||||
const { state } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
const collapsed = state === "collapsed";
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
||||
<SidebarGroupLabel>{t(labelKey)}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton asChild tooltip={item.title}>
|
||||
<NavLink
|
||||
to={item.url}
|
||||
end={item.url.endsWith("/dashboard") || item.url.endsWith("/platform")}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{item.title}</span>}
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{items.map((item) => {
|
||||
const label = t(item.titleKey);
|
||||
return (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton asChild tooltip={label}>
|
||||
<NavLink
|
||||
to={item.url}
|
||||
end={item.url.endsWith("/dashboard") || item.url.endsWith("/platform")}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
@@ -145,40 +162,52 @@ function SidebarNavGroup({ label, items }: { label: string; items: NavItem[] })
|
||||
}
|
||||
|
||||
// ============= Breadcrumbs =============
|
||||
const routeLabels: Record<string, string> = {
|
||||
admin: "Admin", dashboard: "Dashboard", platform: "Platform", courses: "Courses",
|
||||
students: "Students", teachers: "Teachers", batches: "Batches", timetable: "Timetable",
|
||||
reports: "Reports", assignments: "Assignments", examsList: "Exams List",
|
||||
"exam-structures": "Exam Structures", rubrics: "Rubrics", generation: "Generation",
|
||||
"approval-workflows": "Approval Workflows", users: "Users", entities: "Entities",
|
||||
classrooms: "Classrooms", "student-performance": "Student Performance",
|
||||
"stats-corporate": "Stats Corporate", record: "Record", training: "Training",
|
||||
vocabulary: "Vocabulary", grammar: "Grammar", "payment-record": "Payment Record",
|
||||
tickets: "Tickets", "settings-platform": "Settings", settings: "Settings",
|
||||
profile: "Profile", exam: "Exam",
|
||||
"academic-years": "Academic Years", departments: "Departments",
|
||||
admissions: "Admissions", "admission-register": "Admission Register",
|
||||
"exam-sessions": "Exam Sessions", marksheets: "Marksheets",
|
||||
faq: "FAQ Manager", "notification-rules": "Notification Rules",
|
||||
"approval-config": "Approval Config",
|
||||
"student-leave": "Student Leave", fees: "Fees & Payments", lessons: "Lessons",
|
||||
gradebook: "Gradebook", "student-progress": "Student Progress",
|
||||
library: "Library", activities: "Activities", facilities: "Facilities",
|
||||
// Map URL segments to i18n keys so breadcrumbs follow the active locale.
|
||||
// Segments without an entry fall back to a Title-cased version of the slug.
|
||||
const routeLabelKeys: Record<string, string> = {
|
||||
admin: "breadcrumb.admin", dashboard: "breadcrumb.dashboard",
|
||||
platform: "breadcrumb.platform", courses: "nav.courses",
|
||||
students: "nav.students", teachers: "nav.teachers", batches: "nav.batches",
|
||||
timetable: "nav.timetable", reports: "nav.reports",
|
||||
assignments: "nav.assignments", examsList: "nav.examsList",
|
||||
"exam-structures": "nav.examStructures", rubrics: "nav.rubrics",
|
||||
generation: "nav.generation", "review-queue": "nav.reviewQueue",
|
||||
review: "breadcrumb.review", prompts: "nav.aiPrompts", ai: "breadcrumb.ai",
|
||||
feedback: "breadcrumb.feedback",
|
||||
"approval-workflows": "nav.approvalWorkflows", users: "nav.users",
|
||||
entities: "nav.entities", classrooms: "nav.classrooms",
|
||||
"student-performance": "nav.studentPerformance",
|
||||
"stats-corporate": "nav.statsCorporate", record: "nav.record",
|
||||
training: "sidebarGroup.training", vocabulary: "nav.vocabulary",
|
||||
grammar: "nav.grammar", "payment-record": "nav.paymentRecord",
|
||||
tickets: "nav.tickets", "settings-platform": "nav.settings",
|
||||
settings: "nav.settings", profile: "nav.profile", exam: "breadcrumb.exam",
|
||||
"academic-years": "nav.academicYears", departments: "nav.departments",
|
||||
admissions: "nav.admissions", "admission-register": "nav.admissionRegister",
|
||||
"exam-sessions": "nav.examSessions", marksheets: "nav.marksheets",
|
||||
faq: "nav.faqManager", "notification-rules": "nav.notificationRules",
|
||||
"approval-config": "nav.approvalConfig",
|
||||
"student-leave": "nav.studentLeave", fees: "nav.fees",
|
||||
lessons: "nav.lessons", gradebook: "nav.gradebook",
|
||||
"student-progress": "nav.studentProgress", library: "nav.library",
|
||||
activities: "nav.activities", facilities: "nav.facilities",
|
||||
};
|
||||
|
||||
function AppBreadcrumbs() {
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const segments = location.pathname.split("/").filter(Boolean);
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild><Link to="/admin/dashboard">Home</Link></BreadcrumbLink>
|
||||
<BreadcrumbLink asChild><Link to="/admin/dashboard">{t("common.home")}</Link></BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
{segments.map((seg, i) => {
|
||||
const path = "/" + segments.slice(0, i + 1).join("/");
|
||||
const label = routeLabels[seg] || seg.charAt(0).toUpperCase() + seg.slice(1);
|
||||
const key = routeLabelKeys[seg];
|
||||
const label = key ? t(key) : seg.charAt(0).toUpperCase() + seg.slice(1);
|
||||
const isLast = i === segments.length - 1;
|
||||
return (
|
||||
<React.Fragment key={path}>
|
||||
@@ -194,10 +223,32 @@ function AppBreadcrumbs() {
|
||||
);
|
||||
}
|
||||
|
||||
// ============= Route content fallback =============
|
||||
// Shown only inside the main content area while a lazy-loaded route chunk
|
||||
// is fetching. Keeping the fallback local means the sidebar and header
|
||||
// stay mounted during navigation — previously the page felt like a full
|
||||
// browser reload because the outer App-level Suspense replaced everything
|
||||
// with a full-viewport spinner.
|
||||
function RouteContentFallback() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============= Main Layout =============
|
||||
export default function AdminLmsLayout() {
|
||||
const { user, logout } = useAuth();
|
||||
const { user, logout, selectedEntity, setSelectedEntityId } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
// Hide the floating "Need help?" pill on multi-step wizard routes.
|
||||
// It sits at `fixed bottom-6 end-6` z-50 and was intercepting clicks
|
||||
// on the wizard's own Next/Finish footer (real bug repro: trying to
|
||||
// click "Next" in the Course-plan wizard at the standard 1024×768
|
||||
// viewport hits the pill instead). The AI Assistant orb also at the
|
||||
// bottom-right is preserved — it's smaller and useful in-wizard.
|
||||
const isWizardRoute = !!useMatch("/admin/smart-wizard/*");
|
||||
|
||||
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
|
||||
|
||||
@@ -213,13 +264,53 @@ export default function AdminLmsLayout() {
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<SidebarTrigger />
|
||||
<SidebarTrigger aria-label={t("chrome.toggleSidebar")} />
|
||||
<AppBreadcrumbs />
|
||||
</div>
|
||||
<AiSearchBar />
|
||||
<div className="flex items-center gap-2">
|
||||
{user?.entities?.length ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="max-w-40 truncate">
|
||||
{selectedEntity?.name ?? user.entities[0]?.name ?? t("nav.entities")}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{t("nav.entities")}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{user.entities.map((entity) => (
|
||||
<DropdownMenuItem
|
||||
key={entity.id}
|
||||
onClick={() => {
|
||||
setSelectedEntityId(entity.id);
|
||||
// Force page data to refresh against the newly
|
||||
// selected entity scope.
|
||||
window.location.reload();
|
||||
}}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<span className="truncate">{entity.name}</span>
|
||||
{selectedEntity?.id === entity.id && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{t("common.active", "Active")}
|
||||
</Badge>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<LanguageToggle />
|
||||
<ThemeToggle />
|
||||
<NotificationDropdown />
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/tickets")} className="text-muted-foreground hover:text-foreground">
|
||||
<Button variant="ghost" size="icon" aria-label={t("chrome.ticketsTooltip")} onClick={() => navigate("/admin/tickets")} className="text-muted-foreground hover:text-foreground">
|
||||
<Ticket className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
@@ -232,37 +323,41 @@ export default function AdminLmsLayout() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-sm font-medium">{user?.name ?? "User"}</p>
|
||||
<p className="text-sm font-medium">{user?.name ?? t("userMenu.userFallback")}</p>
|
||||
<p className="text-xs text-muted-foreground">{user?.email ?? ""}</p>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => navigate("/admin/profile")}>
|
||||
<User className="mr-2 h-4 w-4" /> Profile
|
||||
<User className="me-2 h-4 w-4" /> {t("userMenu.profile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate("/admin/settings-platform")}>
|
||||
<Settings className="mr-2 h-4 w-4" /> Settings
|
||||
<Settings className="me-2 h-4 w-4" /> {t("userMenu.settings")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" /> Logout
|
||||
<LogOut className="me-2 h-4 w-4" /> {t("userMenu.logout")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<AiAssistantDrawer />
|
||||
<Link
|
||||
to="/admin/tickets"
|
||||
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Need help?</span>
|
||||
</Link>
|
||||
{!isWizardRoute && (
|
||||
<Link
|
||||
to="/admin/tickets"
|
||||
className="fixed bottom-6 end-6 z-40 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">{t("chrome.needHelp")}</span>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -273,67 +368,78 @@ function AdminSidebar() {
|
||||
const collapsed = state === "collapsed";
|
||||
const { user } = useAuth();
|
||||
const { hasAnyPermission, isAdmin } = usePermissions();
|
||||
const { t, i18n } = useTranslation();
|
||||
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
|
||||
|
||||
const showManagement = isAdmin || hasAnyPermission(["view_entities", "view_students", "view_teachers"]);
|
||||
const showReports = isAdmin || hasAnyPermission(["view_statistics", "view_student_performance", "view_entity_statistics"]);
|
||||
const showPayments = isAdmin || hasAnyPermission(["view_payment_record", "pay_entity"]);
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<Sidebar side={sidebarSide} collapsible="icon">
|
||||
<SidebarHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/logo.png" alt="EnCoach" className="h-8 w-8 rounded-lg shrink-0 object-contain" />
|
||||
{!collapsed && (
|
||||
<span className="text-lg font-bold tracking-tight text-sidebar-foreground">
|
||||
<span className="text-[hsl(42,40%,62%)]">En</span>
|
||||
<span>Coach</span>
|
||||
</span>
|
||||
{collapsed ? (
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt={t("chrome.adminAlt")}
|
||||
className="h-9 w-9 shrink-0 rounded-lg object-contain"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt={t("chrome.adminAltLong")}
|
||||
className="h-14 w-auto shrink-0 object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarSeparator />
|
||||
<SidebarContent>
|
||||
<SidebarNavGroup label="Overview" items={overviewItems} />
|
||||
<SidebarNavGroup label="LMS" items={lmsItems} />
|
||||
<SidebarNavGroup label="Adaptive Learning" items={adaptiveItems} />
|
||||
<SidebarNavGroup label="Institutional" items={institutionalItems} />
|
||||
<SidebarNavGroup label="Academic" items={academicItems} />
|
||||
{showManagement && <SidebarNavGroup label="Management" items={managementItems} />}
|
||||
{showReports && <SidebarNavGroup label="Reports" items={reportItems} />}
|
||||
<SidebarNavGroup label="Configuration" items={configItems} />
|
||||
<SidebarNavGroup labelKey="sidebarGroup.overview" items={overviewItems} />
|
||||
<SidebarNavGroup labelKey="sidebarGroup.lms" items={lmsItems} />
|
||||
<SidebarNavGroup labelKey="sidebarGroup.adaptiveLearning" items={adaptiveItems} />
|
||||
<SidebarNavGroup labelKey="sidebarGroup.institutional" items={institutionalItems} />
|
||||
<SidebarNavGroup labelKey="sidebarGroup.academic" items={academicItems} />
|
||||
{showManagement && <SidebarNavGroup labelKey="sidebarGroup.management" items={managementItems} />}
|
||||
{showReports && <SidebarNavGroup labelKey="sidebarGroup.reports" items={reportItems} />}
|
||||
<SidebarNavGroup labelKey="sidebarGroup.configuration" items={configItems} />
|
||||
|
||||
<SidebarGroup>
|
||||
<Collapsible defaultOpen className="group/collapsible">
|
||||
<SidebarGroupLabel asChild>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between">
|
||||
Training
|
||||
{t("sidebarGroup.training")}
|
||||
{!collapsed && <ChevronDown className="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />}
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{trainingItems.map((item) => (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton asChild tooltip={item.title}>
|
||||
<NavLink
|
||||
to={item.url}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{item.title}</span>}
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{trainingItems.map((item) => {
|
||||
const label = t(item.titleKey);
|
||||
return (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton asChild tooltip={label}>
|
||||
<NavLink
|
||||
to={item.url}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarNavGroup label="Support" items={showPayments ? supportItems : supportItems.filter(i => i.url !== "/admin/payment-record")} />
|
||||
<SidebarNavGroup labelKey="sidebarGroup.support" items={showPayments ? supportItems : supportItems.filter(i => i.url !== "/admin/payment-record")} />
|
||||
</SidebarContent>
|
||||
<SidebarSeparator />
|
||||
<SidebarFooter className="p-3">
|
||||
@@ -343,7 +449,7 @@ function AdminSidebar() {
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name ?? "User"}</span>
|
||||
<span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name ?? t("userMenu.userFallback")}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{user?.email ?? ""}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Outlet, useLocation, Link, useNavigate } from "react-router-dom";
|
||||
import { Outlet, useLocation, Link, useNavigate, useMatch } from "react-router-dom";
|
||||
import { Suspense } from "react";
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { AppSidebar } from "@/components/AppSidebar";
|
||||
import {
|
||||
@@ -6,14 +7,16 @@ import {
|
||||
BreadcrumbPage, BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Ticket, Settings, User, LogOut, HelpCircle } from "lucide-react";
|
||||
import { Ticket, Settings, User, LogOut, HelpCircle, Building2, ChevronDown } from "lucide-react";
|
||||
import React from "react";
|
||||
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
|
||||
import AiSearchBar from "@/components/ai/AiSearchBar";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
const routeLabels: Record<string, string> = {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -77,8 +80,26 @@ function AppBreadcrumbs() {
|
||||
);
|
||||
}
|
||||
|
||||
// Local Suspense fallback so the sidebar/header keep rendering while a
|
||||
// lazy route chunk loads. See AdminLmsLayout for the full rationale.
|
||||
function RouteContentFallback() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppLayout() {
|
||||
const navigate = useNavigate();
|
||||
const { user, selectedEntity, setSelectedEntityId } = useAuth();
|
||||
// Hide the floating "Need help?" pill on multi-step wizard routes.
|
||||
// The pill sits at `fixed bottom-6 right-6` with z-50 and was
|
||||
// intercepting clicks on the wizard's own Next/Finish footer at most
|
||||
// viewport heights, blocking users from finishing the flow. The AI
|
||||
// Assistant orb (also bottom-right) stays — it's smaller and
|
||||
// genuinely useful inside the wizard.
|
||||
const isWizardRoute = !!useMatch("/admin/smart-wizard/*");
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
@@ -92,6 +113,34 @@ export default function AppLayout() {
|
||||
</div>
|
||||
<AiSearchBar />
|
||||
<div className="flex items-center gap-2">
|
||||
{user?.entities?.length ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="max-w-40 truncate">
|
||||
{selectedEntity?.name ?? user.entities[0]?.name ?? "Entity"}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
{user.entities.map((entity) => {
|
||||
const active = selectedEntity?.id === entity.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={entity.id}
|
||||
onClick={() => setSelectedEntityId(entity.id)}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="truncate">{entity.name}</span>
|
||||
{active ? <Badge variant="secondary">Current</Badge> : null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/tickets")} className="text-muted-foreground hover:text-foreground">
|
||||
<Ticket className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -127,19 +176,22 @@ export default function AppLayout() {
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<AiAssistantDrawer />
|
||||
{/* Floating help button */}
|
||||
<Link
|
||||
to="/tickets"
|
||||
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Need help?</span>
|
||||
</Link>
|
||||
{!isWizardRoute && (
|
||||
<Link
|
||||
to="/tickets"
|
||||
className="fixed bottom-6 right-6 z-40 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Need help?</span>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { NavLink } from "@/components/NavLink";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
||||
SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
|
||||
@@ -81,9 +82,11 @@ export function AppSidebar() {
|
||||
const { state } = useSidebar();
|
||||
const collapsed = state === "collapsed";
|
||||
const location = useLocation();
|
||||
const { i18n } = useTranslation();
|
||||
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<Sidebar side={sidebarSide} collapsible="icon">
|
||||
<SidebarHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center shrink-0">
|
||||
|
||||
71
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { withTranslation, type WithTranslation } from "react-i18next";
|
||||
|
||||
interface Props extends WithTranslation {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary is a classical React class component, so we bridge it to
|
||||
* i18next via the `withTranslation` HOC. That keeps the tree-shaking of
|
||||
* `react-i18next`'s hook path intact while giving us a `t` prop here.
|
||||
*/
|
||||
class ErrorBoundaryInner extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("ErrorBoundary caught:", error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) return this.props.fallback;
|
||||
const { t } = this.props;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-6">
|
||||
<div className="max-w-md w-full text-center space-y-4">
|
||||
<div className="mx-auto h-16 w-16 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||
<svg className="h-8 w-8 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">{t("errors.somethingWrong")}</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("errors.unexpectedDescription")}
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<details className="text-start text-xs text-muted-foreground bg-muted rounded-lg p-3">
|
||||
<summary className="cursor-pointer font-medium">{t("errors.errorDetails")}</summary>
|
||||
<pre className="mt-2 whitespace-pre-wrap break-words">{this.state.error.message}</pre>
|
||||
</details>
|
||||
)}
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{t("errors.refreshPage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export const ErrorBoundary = withTranslation()(ErrorBoundaryInner);
|
||||
48
src/components/LanguageToggle.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Languages } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { SUPPORTED_LANGS, type SupportedLang } from "@/i18n";
|
||||
|
||||
export function LanguageToggle() {
|
||||
const { i18n, t } = useTranslation();
|
||||
const current = (i18n.language?.split("-")[0] || "en") as SupportedLang;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("language.change")}
|
||||
title={t("language.change")}
|
||||
>
|
||||
<Languages className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{t("language.label")}</DropdownMenuLabel>
|
||||
{SUPPORTED_LANGS.map((lang) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={lang.code}
|
||||
checked={current === lang.code}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) void i18n.changeLanguage(lang.code);
|
||||
}}
|
||||
>
|
||||
{lang.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default LanguageToggle;
|
||||
235
src/components/MaterialViewer.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink, Download, FileText, Video, Music, Image, Link2 } from "lucide-react";
|
||||
import { API_BASE_URL } from "@/lib/api-client";
|
||||
import type { ChapterMaterial, MaterialType } from "@/types/courseware";
|
||||
|
||||
interface MaterialViewerProps {
|
||||
material: ChapterMaterial | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onDownload?: (materialId: number, name: string) => void;
|
||||
}
|
||||
|
||||
const typeLabels: Record<MaterialType, string> = {
|
||||
pdf: "PDF Document",
|
||||
document: "Document",
|
||||
video: "Video",
|
||||
audio: "Audio",
|
||||
image: "Image",
|
||||
link: "External Link",
|
||||
article: "Article",
|
||||
};
|
||||
|
||||
const typeIcons: Record<MaterialType, React.ReactNode> = {
|
||||
pdf: <FileText className="h-4 w-4" />,
|
||||
document: <FileText className="h-4 w-4" />,
|
||||
video: <Video className="h-4 w-4" />,
|
||||
audio: <Music className="h-4 w-4" />,
|
||||
image: <Image className="h-4 w-4" />,
|
||||
link: <Link2 className="h-4 w-4" />,
|
||||
article: <FileText className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
function getContentUrl(materialId: number): string {
|
||||
const token = localStorage.getItem("encoach_token") ?? "";
|
||||
return `${API_BASE_URL}/materials/${materialId}/content?token=${token}`;
|
||||
}
|
||||
|
||||
function isYouTubeUrl(url: string): boolean {
|
||||
return /(?:youtube\.com\/(?:watch|embed)|youtu\.be\/)/.test(url);
|
||||
}
|
||||
|
||||
function getYouTubeEmbedUrl(url: string): string {
|
||||
const match = url.match(
|
||||
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([\w-]+)/
|
||||
);
|
||||
return match ? `https://www.youtube.com/embed/${match[1]}` : url;
|
||||
}
|
||||
|
||||
function PdfViewer({ material }: { material: ChapterMaterial }) {
|
||||
if (material.file_url) {
|
||||
return (
|
||||
<iframe
|
||||
src={getContentUrl(material.id)}
|
||||
className="w-full h-full min-h-[600px] rounded-lg border"
|
||||
title={material.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (material.url) {
|
||||
return (
|
||||
<iframe
|
||||
src={material.url}
|
||||
className="w-full h-full min-h-[600px] rounded-lg border"
|
||||
title={material.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <EmptyState message="No PDF file available." />;
|
||||
}
|
||||
|
||||
function VideoViewer({ material }: { material: ChapterMaterial }) {
|
||||
if (material.url && isYouTubeUrl(material.url)) {
|
||||
return (
|
||||
<iframe
|
||||
src={getYouTubeEmbedUrl(material.url)}
|
||||
className="w-full aspect-video rounded-lg border"
|
||||
title={material.name}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (material.url) {
|
||||
return (
|
||||
<video
|
||||
src={material.url}
|
||||
controls
|
||||
className="w-full rounded-lg border"
|
||||
title={material.name}
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
);
|
||||
}
|
||||
if (material.file_url) {
|
||||
return (
|
||||
<video
|
||||
src={getContentUrl(material.id)}
|
||||
controls
|
||||
className="w-full rounded-lg border"
|
||||
title={material.name}
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
);
|
||||
}
|
||||
return <EmptyState message="No video source available." />;
|
||||
}
|
||||
|
||||
function AudioViewer({ material }: { material: ChapterMaterial }) {
|
||||
const src = material.url || (material.file_url ? getContentUrl(material.id) : null);
|
||||
if (!src) return <EmptyState message="No audio source available." />;
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<audio src={src} controls className="w-full max-w-lg">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageViewer({ material }: { material: ChapterMaterial }) {
|
||||
const src = material.url || (material.file_url ? getContentUrl(material.id) : null);
|
||||
if (!src) return <EmptyState message="No image available." />;
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<img
|
||||
src={src}
|
||||
alt={material.name}
|
||||
className="max-w-full max-h-[70vh] rounded-lg border object-contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArticleViewer({ material }: { material: ChapterMaterial }) {
|
||||
if (!material.description) return <EmptyState message="No article content available." />;
|
||||
return (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none p-4 rounded-lg border bg-card">
|
||||
<div className="whitespace-pre-wrap">{material.description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkViewer({ material }: { material: ChapterMaterial }) {
|
||||
if (!material.url) return <EmptyState message="No URL available." />;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 p-4 rounded-lg border bg-muted/50">
|
||||
<Link2 className="h-5 w-5 text-primary shrink-0" />
|
||||
<a
|
||||
href={material.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline break-all flex-1"
|
||||
>
|
||||
{material.url}
|
||||
</a>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={material.url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4 mr-1" /> Open
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<iframe
|
||||
src={material.url}
|
||||
className="w-full min-h-[500px] rounded-lg border"
|
||||
title={material.name}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<FileText className="h-12 w-12 mb-3 opacity-40" />
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const viewers: Record<MaterialType, React.FC<{ material: ChapterMaterial }>> = {
|
||||
pdf: PdfViewer,
|
||||
document: PdfViewer,
|
||||
video: VideoViewer,
|
||||
audio: AudioViewer,
|
||||
image: ImageViewer,
|
||||
link: LinkViewer,
|
||||
article: ArticleViewer,
|
||||
};
|
||||
|
||||
export default function MaterialViewer({ material, open, onOpenChange, onDownload }: MaterialViewerProps) {
|
||||
if (!material) return null;
|
||||
|
||||
const Viewer = viewers[material.type] ?? ArticleViewer;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-2xl lg:max-w-4xl overflow-y-auto">
|
||||
<SheetHeader className="space-y-3 pb-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
{typeIcons[material.type]}
|
||||
<Badge variant="outline">{typeLabels[material.type]}</Badge>
|
||||
</div>
|
||||
<SheetTitle className="text-lg">{material.name}</SheetTitle>
|
||||
{material.description && material.type !== "article" && (
|
||||
<p className="text-sm text-muted-foreground">{material.description}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{material.allow_download && material.file_url && onDownload && (
|
||||
<Button variant="outline" size="sm" onClick={() => onDownload(material.id, material.name)}>
|
||||
<Download className="h-4 w-4 mr-1" /> Download
|
||||
</Button>
|
||||
)}
|
||||
{material.url && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={material.url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4 mr-1" /> Open Link
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-6">
|
||||
<Viewer material={material} />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NavLink as RouterNavLink, NavLinkProps } from "react-router-dom";
|
||||
import { NavLink as RouterNavLink, NavLinkProps, useNavigate } from "react-router-dom";
|
||||
import { forwardRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -9,7 +9,14 @@ interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
|
||||
}
|
||||
|
||||
const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
|
||||
({ className, activeClassName, pendingClassName, to, ...props }, ref) => {
|
||||
({ className, activeClassName, pendingClassName, to, onClick, ...props }, ref) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isExternalTo = (value: NavLinkProps["to"]): boolean => {
|
||||
if (typeof value !== "string") return false;
|
||||
return /^(https?:)?\/\//.test(value) || value.startsWith("mailto:") || value.startsWith("tel:");
|
||||
};
|
||||
|
||||
return (
|
||||
<RouterNavLink
|
||||
ref={ref}
|
||||
@@ -17,6 +24,31 @@ const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
|
||||
className={({ isActive, isPending }) =>
|
||||
cn(className, isActive && activeClassName, isPending && pendingClassName)
|
||||
}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.altKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
props.target === "_blank" ||
|
||||
isExternalTo(to)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Force client-side route transitions for app menu links.
|
||||
event.preventDefault();
|
||||
navigate(to, {
|
||||
replace: props.replace,
|
||||
state: props.state,
|
||||
relative: props.relative,
|
||||
preventScrollReset: props.preventScrollReset,
|
||||
viewTransition: props.viewTransition,
|
||||
});
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,35 +1,50 @@
|
||||
import { Bell } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export default function NotificationDropdown() {
|
||||
const { t } = useTranslation();
|
||||
const notifications: { id: string; title: string; message: string; time: string; read: boolean; type: string }[] = [];
|
||||
const unread = notifications.filter((n) => !n.read).length;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative text-muted-foreground hover:text-foreground">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("notifications.ariaLabel")}
|
||||
title={t("notifications.ariaLabel")}
|
||||
className="relative text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
{unread > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 h-4 w-4 rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground flex items-center justify-center">
|
||||
<span className="absolute -top-0.5 -end-0.5 h-4 w-4 rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground flex items-center justify-center">
|
||||
{unread}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
<div className="px-3 py-2 font-semibold text-sm">Notifications</div>
|
||||
<div className="px-3 py-2 font-semibold text-sm">{t("notifications.title")}</div>
|
||||
<DropdownMenuSeparator />
|
||||
{notifications.slice(0, 4).map((n) => (
|
||||
<DropdownMenuItem key={n.id} className="flex flex-col items-start gap-0.5 py-2.5 cursor-pointer">
|
||||
<span className={`text-sm font-medium ${!n.read ? "text-foreground" : "text-muted-foreground"}`}>{n.title}</span>
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">{n.message}</span>
|
||||
<span className="text-[10px] text-muted-foreground/70">{n.time}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{t("notifications.empty")}
|
||||
</div>
|
||||
) : (
|
||||
notifications.slice(0, 4).map((n) => (
|
||||
<DropdownMenuItem key={n.id} className="flex flex-col items-start gap-0.5 py-2.5 cursor-pointer">
|
||||
<span className={`text-sm font-medium ${!n.read ? "text-foreground" : "text-muted-foreground"}`}>{n.title}</span>
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">{n.message}</span>
|
||||
<span className="text-[10px] text-muted-foreground/70">{n.time}</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
250
src/components/QuickSetupWizard.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LucideIcon, Check, ChevronRight, Sparkles } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Smart-setup wizard primitives.
|
||||
*
|
||||
* The wizard is purposefully thin: each step / quick-create card is a deep
|
||||
* link into an existing creation page that already knows how to talk to the
|
||||
* backend. We don't re-implement forms here — the value of this screen is
|
||||
* the guided sequence, the visual "what's next" hint, and the completion
|
||||
* ticks that show which parts of the setup still need attention.
|
||||
*
|
||||
* Completion detection is optional and client-only. A caller can supply a
|
||||
* `check` function that returns a Promise<boolean>; the wizard runs it via
|
||||
* react-query so the UI refreshes automatically when the user returns from
|
||||
* a sub-page without any extra plumbing.
|
||||
*/
|
||||
|
||||
export interface WizardStep {
|
||||
/** Stable key used for react-query cache. */
|
||||
id: string;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
/** Destination page that performs the actual creation. */
|
||||
to: string;
|
||||
icon: LucideIcon;
|
||||
/** Optional completion check. When omitted the step is never auto-ticked. */
|
||||
check?: () => Promise<boolean>;
|
||||
/** Optional i18n key for a longer help tooltip. */
|
||||
helpKey?: string;
|
||||
}
|
||||
|
||||
export interface QuickCreate {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
to: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export interface QuickSetupWizardProps {
|
||||
/** Page heading i18n key, e.g. "quickSetup.adminTitle". */
|
||||
titleKey: string;
|
||||
/** Short lead paragraph i18n key. */
|
||||
subtitleKey: string;
|
||||
/** Ordered "Recommended flow" steps. */
|
||||
steps: WizardStep[];
|
||||
/** Side-grid of one-click creates that don't belong to the main flow. */
|
||||
quickCreates: QuickCreate[];
|
||||
}
|
||||
|
||||
export function QuickSetupWizard({
|
||||
titleKey,
|
||||
subtitleKey,
|
||||
steps,
|
||||
quickCreates,
|
||||
}: QuickSetupWizardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Run all completion checks in parallel. Each step with a `check` gets its
|
||||
// own cached query keyed by the step id. Steps without a `check` just
|
||||
// resolve to `undefined` and render as neutral.
|
||||
const queries = useQueries({
|
||||
queries: steps.map((step) => ({
|
||||
queryKey: ["quick-setup-check", step.id],
|
||||
queryFn: step.check ?? (async () => undefined),
|
||||
enabled: Boolean(step.check),
|
||||
// Re-check when the user comes back from a sub-page — that's the most
|
||||
// common path to "un-greying" a step after they've created a rubric /
|
||||
// structure / exam.
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 30_000,
|
||||
})),
|
||||
});
|
||||
|
||||
const completedCount = queries.filter((q) => q.data === true).length;
|
||||
const totalCheckable = steps.filter((s) => s.check).length;
|
||||
const progress = totalCheckable > 0 ? Math.round((completedCount / totalCheckable) * 100) : 0;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="space-y-6">
|
||||
{/* Heading + progress */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t(titleKey)}</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground max-w-2xl">{t(subtitleKey)}</p>
|
||||
</div>
|
||||
{totalCheckable > 0 && (
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("quickSetup.progressLabel")}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">
|
||||
{completedCount}/{totalCheckable}
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 w-32 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recommended flow */}
|
||||
<section aria-labelledby="quick-setup-flow">
|
||||
<h2
|
||||
id="quick-setup-flow"
|
||||
className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3"
|
||||
>
|
||||
{t("quickSetup.recommendedFlow")}
|
||||
</h2>
|
||||
|
||||
<ol className="space-y-3">
|
||||
{steps.map((step, index) => {
|
||||
const query = queries[index];
|
||||
const Icon = step.icon;
|
||||
const isDone = query?.data === true;
|
||||
|
||||
return (
|
||||
<li key={step.id}>
|
||||
<Card
|
||||
className={cn(
|
||||
"transition-shadow hover:shadow-md",
|
||||
isDone && "border-green-500/40 bg-green-500/5",
|
||||
)}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
{/* Step number / done tick */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full font-semibold",
|
||||
isDone
|
||||
? "bg-green-500 text-white"
|
||||
: "bg-primary/10 text-primary",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{isDone ? <Check className="h-5 w-5" /> : index + 1}
|
||||
</div>
|
||||
|
||||
{/* Title + description */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<h3 className="font-medium">{t(step.titleKey)}</h3>
|
||||
{isDone && (
|
||||
<Badge variant="outline" className="border-green-500/50 text-green-600 text-[10px]">
|
||||
{t("quickSetup.ready")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{t(step.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{step.helpKey && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground"
|
||||
aria-label={t("quickSetup.helpAria")}
|
||||
>
|
||||
?
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-xs">
|
||||
{t(step.helpKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button asChild size="sm" variant={isDone ? "outline" : "default"}>
|
||||
<Link to={step.to} className="flex items-center gap-1">
|
||||
{isDone ? t("quickSetup.review") : t("quickSetup.start")}
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Quick creates */}
|
||||
{quickCreates.length > 0 && (
|
||||
<section aria-labelledby="quick-setup-other" className="pt-2">
|
||||
<h2
|
||||
id="quick-setup-other"
|
||||
className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3"
|
||||
>
|
||||
{t("quickSetup.otherQuickCreates")}
|
||||
</h2>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{quickCreates.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Card key={item.id} className="transition-all hover:shadow-md hover:-translate-y-0.5">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<CardTitle className="text-base">{t(item.titleKey)}</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="line-clamp-2">
|
||||
{t(item.descriptionKey)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild variant="secondary" size="sm" className="w-full">
|
||||
<Link to={item.to} className="flex items-center justify-center gap-1">
|
||||
{t("quickSetup.open")}
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuickSetupWizard;
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Outlet, Link, useNavigate, useLocation } from "react-router-dom";
|
||||
import { Outlet, useNavigate } from "react-router-dom";
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import {
|
||||
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
||||
@@ -8,22 +10,28 @@ import {
|
||||
import { NavLink } from "@/components/NavLink";
|
||||
import { useAuth, UserRole } from "@/contexts/AuthContext";
|
||||
import NotificationDropdown from "@/components/NotificationDropdown";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { LanguageToggle } from "@/components/LanguageToggle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { LogOut, User, Settings, LucideIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { LogOut, User, LucideIcon } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Non-admin portal shell (student / teacher / etc.). Nav items hold i18n
|
||||
* keys (`titleKey` / `labelKey`) rather than literal strings so they react
|
||||
* to language changes without re-mounting the layout.
|
||||
*/
|
||||
export interface NavItem {
|
||||
title: string;
|
||||
titleKey: string;
|
||||
url: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export interface NavGroup {
|
||||
label: string;
|
||||
labelKey: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
@@ -34,30 +42,34 @@ interface RoleLayoutProps {
|
||||
|
||||
function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) {
|
||||
const { state } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
const collapsed = state === "collapsed";
|
||||
|
||||
return (
|
||||
<>
|
||||
{navGroups.map((group) => (
|
||||
<SidebarGroup key={group.label}>
|
||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||||
<SidebarGroup key={group.labelKey}>
|
||||
<SidebarGroupLabel>{t(group.labelKey)}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton asChild tooltip={item.title}>
|
||||
<NavLink
|
||||
to={item.url}
|
||||
end={item.url.endsWith("/dashboard")}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{item.title}</span>}
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{group.items.map((item) => {
|
||||
const label = t(item.titleKey);
|
||||
return (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton asChild tooltip={label}>
|
||||
<NavLink
|
||||
to={item.url}
|
||||
end={item.url.endsWith("/dashboard")}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
@@ -66,9 +78,21 @@ function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Keeps the student/teacher sidebar + header mounted while the lazy route
|
||||
// chunk is fetching. See AdminLmsLayout for the rationale.
|
||||
function RouteContentFallback() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation();
|
||||
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
|
||||
|
||||
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
|
||||
|
||||
@@ -77,17 +101,25 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
const roleLabel = t(`roles.${role}`, { defaultValue: role });
|
||||
const portalTitle = `${roleLabel} ${t("chrome.portalSuffix")}`.trim();
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="min-h-screen flex w-full">
|
||||
<Sidebar collapsible="icon">
|
||||
<Sidebar side={sidebarSide} collapsible="icon">
|
||||
<SidebarHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/logo.png" alt="EnCoach" className="h-8 w-8 rounded-lg shrink-0 object-contain" />
|
||||
<span className="text-lg font-bold tracking-tight text-sidebar-foreground group-data-[collapsible=icon]:hidden">
|
||||
<span className="text-[hsl(42,40%,62%)]">En</span>
|
||||
<span>Coach</span>
|
||||
</span>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt={t("chrome.adminAlt")}
|
||||
className="h-9 w-9 shrink-0 rounded-lg object-contain group-data-[collapsible=icon]:block hidden"
|
||||
/>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt={t("chrome.adminAltLong")}
|
||||
className="h-14 w-auto shrink-0 object-contain group-data-[collapsible=icon]:hidden"
|
||||
/>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarSeparator />
|
||||
@@ -101,7 +133,9 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
||||
<span className="text-xs font-semibold text-primary">{initials}</span>
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 group-data-[collapsible=icon]:hidden">
|
||||
<span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name}</span>
|
||||
<span className="text-sm font-medium truncate text-sidebar-foreground">
|
||||
{user?.name ?? t("userMenu.userFallback")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{user?.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,10 +145,12 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<SidebarTrigger />
|
||||
<span className="text-sm font-medium text-muted-foreground capitalize">{role} Portal</span>
|
||||
<SidebarTrigger aria-label={t("chrome.toggleSidebar")} />
|
||||
<span className="text-sm font-medium text-muted-foreground">{portalTitle}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LanguageToggle />
|
||||
<ThemeToggle />
|
||||
<NotificationDropdown />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -126,23 +162,25 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-sm font-medium">{user?.name}</p>
|
||||
<p className="text-sm font-medium">{user?.name ?? t("userMenu.userFallback")}</p>
|
||||
<p className="text-xs text-muted-foreground">{user?.email}</p>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => navigate(`/${role}/profile`)}>
|
||||
<User className="mr-2 h-4 w-4" /> Profile
|
||||
<User className="me-2 h-4 w-4" /> {t("userMenu.profile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" /> Logout
|
||||
<LogOut className="me-2 h-4 w-4" /> {t("userMenu.logout")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,51 +1,63 @@
|
||||
import RoleLayout, { NavGroup } from "./RoleLayout";
|
||||
import ExamPopup from "./student/ExamPopup";
|
||||
import {
|
||||
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
|
||||
CalendarCheck, Calendar, User, Target, GraduationCap, ListChecks,
|
||||
MessageSquare, Megaphone, Mail, TrendingUp,
|
||||
CalendarCheck, Calendar, User, Target, ListChecks,
|
||||
MessageSquare, Megaphone, Mail, TrendingUp, Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* Student portal shell. Nav items hold i18n keys which `RoleLayout`
|
||||
* resolves via `useTranslation`, so switching language does not require
|
||||
* re-mounting the layout.
|
||||
*/
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
label: "Learning",
|
||||
labelKey: "sidebarGroup.learning",
|
||||
items: [
|
||||
{ title: "Dashboard", url: "/student/dashboard", icon: LayoutDashboard },
|
||||
{ title: "My Courses", url: "/student/courses", icon: BookOpen },
|
||||
{ title: "Subject Registration", url: "/student/subject-registration", icon: ListChecks },
|
||||
{ title: "Assignments", url: "/student/assignments", icon: ClipboardList },
|
||||
{ titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard },
|
||||
{ titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen },
|
||||
{ titleKey: "nav.myCoursePlans", url: "/student/course-plans", icon: Sparkles },
|
||||
{ titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
|
||||
{ titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Adaptive Learning",
|
||||
labelKey: "sidebarGroup.adaptiveLearning",
|
||||
items: [
|
||||
{ title: "My Subjects", url: "/student/subjects", icon: Target },
|
||||
{ titleKey: "nav.mySubjects", url: "/student/subjects", icon: Target },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Progress",
|
||||
labelKey: "sidebarGroup.progress",
|
||||
items: [
|
||||
{ title: "Grades", url: "/student/grades", icon: BarChart3 },
|
||||
{ title: "Attendance", url: "/student/attendance", icon: CalendarCheck },
|
||||
{ title: "Timetable", url: "/student/timetable", icon: Calendar },
|
||||
{ title: "My Journey", url: "/student/journey", icon: TrendingUp },
|
||||
{ titleKey: "nav.grades", url: "/student/grades", icon: BarChart3 },
|
||||
{ titleKey: "nav.attendance", url: "/student/attendance", icon: CalendarCheck },
|
||||
{ titleKey: "nav.timetable", url: "/student/timetable", icon: Calendar },
|
||||
{ titleKey: "nav.myJourney", url: "/student/journey", icon: TrendingUp },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Communication",
|
||||
labelKey: "sidebarGroup.communication",
|
||||
items: [
|
||||
{ title: "Discussions", url: "/student/discussions", icon: MessageSquare },
|
||||
{ title: "Messages", url: "/student/messages", icon: Mail },
|
||||
{ title: "Announcements", url: "/student/announcements", icon: Megaphone },
|
||||
{ titleKey: "nav.discussions", url: "/student/discussions", icon: MessageSquare },
|
||||
{ titleKey: "nav.messages", url: "/student/messages", icon: Mail },
|
||||
{ titleKey: "nav.announcements", url: "/student/announcements", icon: Megaphone },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Account",
|
||||
labelKey: "sidebarGroup.account",
|
||||
items: [
|
||||
{ title: "Profile", url: "/student/profile", icon: User },
|
||||
{ titleKey: "nav.profile", url: "/student/profile", icon: User },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function StudentLayout() {
|
||||
return <RoleLayout navGroups={navGroups} role="student" />;
|
||||
return (
|
||||
<>
|
||||
<RoleLayout navGroups={navGroups} role="student" />
|
||||
<ExamPopup />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
143
src/components/TaxonomyCascade.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { taxonomyService } from "@/services/taxonomy.service";
|
||||
import { lmsService } from "@/services/lms.service";
|
||||
|
||||
interface TaxonomyCascadeProps {
|
||||
subjectId: string;
|
||||
onSubjectChange: (id: string) => void;
|
||||
domainId: string;
|
||||
onDomainChange: (id: string) => void;
|
||||
topicIds: number[];
|
||||
onTopicIdsChange: (ids: number[]) => void;
|
||||
objectiveIds: number[];
|
||||
onObjectiveIdsChange: (ids: number[]) => void;
|
||||
}
|
||||
|
||||
export function TaxonomyCascade({
|
||||
subjectId, onSubjectChange,
|
||||
domainId, onDomainChange,
|
||||
topicIds, onTopicIdsChange,
|
||||
objectiveIds, onObjectiveIdsChange,
|
||||
}: TaxonomyCascadeProps) {
|
||||
const { data: subjects } = useQuery({
|
||||
queryKey: ["taxonomy", "subjects"],
|
||||
queryFn: () => taxonomyService.listSubjects(),
|
||||
});
|
||||
|
||||
const numericSubjectId = subjectId !== "none" ? Number(subjectId) : undefined;
|
||||
const numericDomainId = domainId !== "none" ? Number(domainId) : undefined;
|
||||
|
||||
const { data: domains } = useQuery({
|
||||
queryKey: ["taxonomy", "domains", numericSubjectId],
|
||||
queryFn: () => taxonomyService.listDomains({ subject_id: numericSubjectId }),
|
||||
enabled: !!numericSubjectId,
|
||||
});
|
||||
|
||||
const { data: topics } = useQuery({
|
||||
queryKey: ["taxonomy", "topics", numericDomainId],
|
||||
queryFn: () => taxonomyService.listTopics({ domain_id: numericDomainId }),
|
||||
enabled: !!numericDomainId,
|
||||
});
|
||||
|
||||
const { data: objectivesData } = useQuery({
|
||||
queryKey: ["learning-objectives", topicIds],
|
||||
queryFn: () => lmsService.listLearningObjectives({ topic_ids: topicIds.join(",") }),
|
||||
enabled: topicIds.length > 0,
|
||||
});
|
||||
const objectives = objectivesData?.items ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (subjectId === "none") {
|
||||
if (domainId !== "none") onDomainChange("none");
|
||||
if (topicIds.length > 0) onTopicIdsChange([]);
|
||||
if (objectiveIds.length > 0) onObjectiveIdsChange([]);
|
||||
}
|
||||
}, [subjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (domainId === "none") {
|
||||
if (topicIds.length > 0) onTopicIdsChange([]);
|
||||
if (objectiveIds.length > 0) onObjectiveIdsChange([]);
|
||||
}
|
||||
}, [domainId]);
|
||||
|
||||
const toggleTopic = (id: number) => {
|
||||
const next = topicIds.includes(id) ? topicIds.filter(t => t !== id) : [...topicIds, id];
|
||||
onTopicIdsChange(next);
|
||||
if (next.length === 0 && objectiveIds.length > 0) onObjectiveIdsChange([]);
|
||||
};
|
||||
|
||||
const toggleObjective = (id: number) => {
|
||||
onObjectiveIdsChange(
|
||||
objectiveIds.includes(id) ? objectiveIds.filter(o => o !== id) : [...objectiveIds, id],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Subject</Label>
|
||||
<Select value={subjectId} onValueChange={(v) => { onSubjectChange(v); if (v === "none") { onDomainChange("none"); } }}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="Select subject" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
{(subjects ?? []).map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Domain</Label>
|
||||
<Select value={domainId} onValueChange={onDomainChange} disabled={!numericSubjectId}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder={numericSubjectId ? "Select domain" : "Select subject first"} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
{(domains ?? []).map(d => <SelectItem key={d.id} value={String(d.id)}>{d.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{numericDomainId && (topics ?? []).length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Topics</Label>
|
||||
<div className="flex flex-wrap gap-1.5 p-2 rounded border bg-muted/30 max-h-[100px] overflow-y-auto">
|
||||
{(topics ?? []).map(t => (
|
||||
<Badge
|
||||
key={t.id}
|
||||
variant={topicIds.includes(t.id) ? "default" : "outline"}
|
||||
className="cursor-pointer select-none text-xs"
|
||||
onClick={() => toggleTopic(t.id)}
|
||||
>
|
||||
{t.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{topicIds.length > 0 && objectives.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Learning Objectives</Label>
|
||||
<div className="space-y-1 p-2 rounded border bg-muted/30 max-h-[100px] overflow-y-auto">
|
||||
{objectives.map(o => (
|
||||
<label key={o.id} className="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<Checkbox
|
||||
checked={objectiveIds.includes(o.id)}
|
||||
onCheckedChange={() => toggleObjective(o.id)}
|
||||
/>
|
||||
<span>{o.name}</span>
|
||||
{o.bloom_level && <Badge variant="outline" className="text-[10px] h-4">{o.bloom_level}</Badge>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +1,41 @@
|
||||
import RoleLayout, { NavGroup } from "./RoleLayout";
|
||||
import {
|
||||
LayoutDashboard, BookOpen, ClipboardList, FileText,
|
||||
LayoutDashboard, BookOpen, ClipboardList,
|
||||
CalendarCheck, Users, Calendar, User, MessageSquare,
|
||||
Megaphone, Wand2,
|
||||
Megaphone, Library, Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
/** Teacher portal shell. See `StudentLayout` for the nav-config rationale. */
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
label: "Teaching",
|
||||
labelKey: "sidebarGroup.teaching",
|
||||
items: [
|
||||
{ title: "Dashboard", url: "/teacher/dashboard", icon: LayoutDashboard },
|
||||
{ title: "Courses", url: "/teacher/courses", icon: BookOpen },
|
||||
{ title: "Assignments", url: "/teacher/assignments", icon: ClipboardList },
|
||||
{ titleKey: "nav.quickSetup", url: "/teacher/quick-setup", icon: Sparkles },
|
||||
{ titleKey: "nav.dashboard", url: "/teacher/dashboard", icon: LayoutDashboard },
|
||||
{ titleKey: "nav.courses", url: "/teacher/courses", icon: BookOpen },
|
||||
{ titleKey: "nav.resourceLibrary", url: "/teacher/library", icon: Library },
|
||||
{ titleKey: "nav.assignments", url: "/teacher/assignments", icon: ClipboardList },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Management",
|
||||
labelKey: "sidebarGroup.management",
|
||||
items: [
|
||||
{ title: "Attendance", url: "/teacher/attendance", icon: CalendarCheck },
|
||||
{ title: "Students", url: "/teacher/students", icon: Users },
|
||||
{ title: "Timetable", url: "/teacher/timetable", icon: Calendar },
|
||||
{ titleKey: "nav.attendance", url: "/teacher/attendance", icon: CalendarCheck },
|
||||
{ titleKey: "nav.students", url: "/teacher/students", icon: Users },
|
||||
{ titleKey: "nav.timetable", url: "/teacher/timetable", icon: Calendar },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Communication",
|
||||
labelKey: "sidebarGroup.communication",
|
||||
items: [
|
||||
{ title: "Discussions", url: "/teacher/discussions", icon: MessageSquare },
|
||||
{ title: "Announcements", url: "/teacher/announcements", icon: Megaphone },
|
||||
{ titleKey: "nav.discussions", url: "/teacher/discussions", icon: MessageSquare },
|
||||
{ titleKey: "nav.announcements", url: "/teacher/announcements", icon: Megaphone },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Account",
|
||||
labelKey: "sidebarGroup.account",
|
||||
items: [
|
||||
{ title: "Profile", url: "/teacher/profile", icon: User },
|
||||
{ titleKey: "nav.profile", url: "/teacher/profile", icon: User },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
71
src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Moon, Sun, Monitor } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
/**
|
||||
* Light/dark/system theme toggle.
|
||||
*
|
||||
* We intentionally render a neutral placeholder until `next-themes` has
|
||||
* hydrated — otherwise the first client render disagrees with the server-less
|
||||
* pre-render and the icon briefly flashes the wrong state.
|
||||
*/
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const isDark = mounted && (resolvedTheme ?? theme) === "dark";
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("theme.toggleLabel")}
|
||||
title={t("theme.toggleLabel")}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{mounted ? (
|
||||
isDark ? (
|
||||
<Moon className="h-4 w-4" />
|
||||
) : (
|
||||
<Sun className="h-4 w-4" />
|
||||
)
|
||||
) : (
|
||||
<Sun className="h-4 w-4 opacity-0" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
<Sun className="me-2 h-4 w-4" /> {t("theme.light")}
|
||||
{theme === "light" && <span className="ms-auto text-xs text-muted-foreground">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
<Moon className="me-2 h-4 w-4" /> {t("theme.dark")}
|
||||
{theme === "dark" && <span className="ms-auto text-xs text-muted-foreground">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
<Monitor className="me-2 h-4 w-4" /> {t("theme.system")}
|
||||
{theme === "system" && <span className="ms-auto text-xs text-muted-foreground">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThemeToggle;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, X, Sparkles, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { analyticsService } from "@/services/analytics.service";
|
||||
@@ -7,32 +8,34 @@ import { analyticsService } from "@/services/analytics.service";
|
||||
export default function AiAlertBanner() {
|
||||
const [dismissedIds, setDismissedIds] = useState<Set<string>>(() => new Set());
|
||||
const [errorDismissed, setErrorDismissed] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: alerts, isLoading, isError, error } = useQuery({
|
||||
const { data: resp, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["ai", "alerts"],
|
||||
queryFn: () => analyticsService.getAlerts(),
|
||||
});
|
||||
|
||||
const visible = alerts?.filter((a) => !dismissedIds.has(a.id)) ?? [];
|
||||
const alerts = resp?.alerts ?? [];
|
||||
const visible = alerts.filter((a, i) => !dismissedIds.has(String(i)));
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-center gap-3">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-warning shrink-0" />
|
||||
<p className="text-sm text-muted-foreground">Loading alerts…</p>
|
||||
<p className="text-sm text-muted-foreground">{t("ai.loadingAlerts")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError && !errorDismissed) {
|
||||
return (
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3">
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3" dir="auto">
|
||||
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3 text-primary" /> Alerts unavailable
|
||||
<Sparkles className="h-3 w-3 text-primary shrink-0" /> {t("ai.alertsUnavailable")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{error instanceof Error ? error.message : "Could not load alerts."}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{error instanceof Error ? error.message : t("ai.couldNotLoadAlerts")}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setErrorDismissed(true)}>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -43,11 +46,11 @@ export default function AiAlertBanner() {
|
||||
|
||||
if (isError && errorDismissed) return null;
|
||||
|
||||
if (!alerts?.length) {
|
||||
if (!alerts.length) {
|
||||
return (
|
||||
<div className="rounded-lg border border-muted bg-muted/20 p-4 flex items-start gap-3">
|
||||
<Sparkles className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-muted-foreground">No AI alerts right now.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("ai.noAlertsRightNow")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,12 +59,12 @@ export default function AiAlertBanner() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{visible.map((alert) => (
|
||||
<div key={alert.id} className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3">
|
||||
{visible.map((alert, idx) => (
|
||||
<div key={idx} className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3" dir="auto">
|
||||
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3 text-primary" /> {alert.title}
|
||||
<Sparkles className="h-3 w-3 text-primary shrink-0" /> {alert.title}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{alert.description}</p>
|
||||
</div>
|
||||
@@ -69,7 +72,7 @@ export default function AiAlertBanner() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => setDismissedIds((prev) => new Set(prev).add(alert.id))}
|
||||
onClick={() => setDismissedIds((prev) => new Set(prev).add(String(idx)))}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -8,38 +9,43 @@ import { Sparkles, Send, Loader2 } from "lucide-react";
|
||||
import { coachingService } from "@/services/coaching.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const quickActions = [
|
||||
"Platform health summary",
|
||||
"Show dropout risks",
|
||||
"Compare course performance",
|
||||
"Suggest batch improvements",
|
||||
];
|
||||
|
||||
export default function AiAssistantDrawer() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<{ role: "user" | "ai"; text: string }[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const location = useLocation();
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Re-compute when language switches so quick-action chips show translated
|
||||
// labels immediately. Chips are stored by label (what we send to the
|
||||
// backend), so localizing the label is safe here — the backend treats
|
||||
// them as free-form prompts.
|
||||
const quickActions = useMemo(
|
||||
() => [
|
||||
t("ai.quickHealth"),
|
||||
t("ai.quickDropouts"),
|
||||
t("ai.quickCompare"),
|
||||
t("ai.quickBatch"),
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const chatMutation = useMutation({
|
||||
mutationFn: (message: string) =>
|
||||
coachingService.chat({ message, context: { page: location.pathname } }),
|
||||
onSuccess: (data) => {
|
||||
setMessages((prev) => [...prev, { role: "ai", text: data.message }]);
|
||||
setMessages((prev) => [...prev, { role: "ai", text: data.reply }]);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not get a reply",
|
||||
description: err.message || "Something went wrong. Try again.",
|
||||
title: t("ai.replyErrorTitle"),
|
||||
description: err.message || t("ai.replyErrorDesc"),
|
||||
});
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: "ai",
|
||||
text: "Sorry, I could not reach the assistant. Please try again in a moment.",
|
||||
},
|
||||
{ role: "ai", text: t("ai.fallbackReply") },
|
||||
]);
|
||||
},
|
||||
});
|
||||
@@ -56,8 +62,9 @@ export default function AiAssistantDrawer() {
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed bottom-20 right-6 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
aria-label="AI Assistant"
|
||||
className="fixed bottom-20 end-6 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
aria-label={t("ai.assistantLabel")}
|
||||
title={t("ai.assistantLabel")}
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -67,7 +74,7 @@ export default function AiAssistantDrawer() {
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
EnCoach AI Assistant
|
||||
{t("ai.assistantTitle")}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -90,8 +97,8 @@ export default function AiAssistantDrawer() {
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center text-muted-foreground text-sm py-8">
|
||||
<Sparkles className="h-8 w-8 mx-auto mb-3 text-primary/40" />
|
||||
<p>Ask me anything about the platform,</p>
|
||||
<p>or click a quick action above.</p>
|
||||
<p>{t("ai.emptyLine1")}</p>
|
||||
<p>{t("ai.emptyLine2")}</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
@@ -99,27 +106,27 @@ export default function AiAssistantDrawer() {
|
||||
key={i}
|
||||
className={`rounded-lg p-3 text-sm ${
|
||||
msg.role === "user"
|
||||
? "bg-primary text-primary-foreground ml-8"
|
||||
: "bg-muted mr-8"
|
||||
? "bg-primary text-primary-foreground ms-8"
|
||||
: "bg-muted me-8"
|
||||
}`}
|
||||
>
|
||||
{msg.role === "ai" && (
|
||||
<Sparkles className="h-3 w-3 text-primary inline mr-1.5 -mt-0.5" />
|
||||
<Sparkles className="h-3 w-3 text-primary inline me-1.5 -mt-0.5" />
|
||||
)}
|
||||
{msg.text}
|
||||
</div>
|
||||
))}
|
||||
{chatMutation.isPending && (
|
||||
<div className="bg-muted rounded-lg p-3 text-sm mr-8 flex items-center gap-2">
|
||||
<div className="bg-muted rounded-lg p-3 text-sm me-8 flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<span className="text-muted-foreground">Thinking...</span>
|
||||
<span className="text-muted-foreground">{t("ai.thinking")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-3 border-t mt-auto">
|
||||
<Input
|
||||
placeholder="Ask anything..."
|
||||
placeholder={t("ai.placeholder")}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSend(input)}
|
||||
@@ -128,6 +135,8 @@ export default function AiAssistantDrawer() {
|
||||
size="icon"
|
||||
onClick={() => handleSend(input)}
|
||||
disabled={!input.trim() || chatMutation.isPending}
|
||||
aria-label={t("ai.send")}
|
||||
title={t("ai.send")}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -25,6 +25,8 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
||||
},
|
||||
});
|
||||
|
||||
type OptResult = Awaited<ReturnType<typeof analyticsService.getBatchOptimization>>;
|
||||
|
||||
const handleOpen = () => {
|
||||
if (batchId == null) {
|
||||
toast({
|
||||
@@ -39,9 +41,23 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
||||
mutation.mutate(batchId);
|
||||
};
|
||||
|
||||
const applyMutation = useMutation({
|
||||
mutationFn: () => analyticsService.applyBatchOptimization(batchId!, mutation.data?.optimized ?? []),
|
||||
onSuccess: (res) => {
|
||||
toast({ title: "Suggestion Applied", description: `${res.applied} optimization(s) saved successfully.` });
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Apply failed",
|
||||
description: err.message || "Could not apply batch optimization.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleApply = () => {
|
||||
toast({ title: "Suggestion Applied", description: "Batch split recommendation has been saved successfully." });
|
||||
setOpen(false);
|
||||
applyMutation.mutate();
|
||||
};
|
||||
|
||||
const onOpenChange = (next: boolean) => {
|
||||
@@ -49,9 +65,10 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
||||
if (!next) mutation.reset();
|
||||
};
|
||||
|
||||
const suggestions = mutation.data ?? [];
|
||||
const showResults = !mutation.isPending && !mutation.isError && suggestions.length > 0;
|
||||
const showEmpty = !mutation.isPending && !mutation.isError && mutation.isSuccess && suggestions.length === 0;
|
||||
const optData = mutation.data as OptResult | undefined;
|
||||
const hasSuggestions = !!optData?.summary;
|
||||
const showResults = !mutation.isPending && !mutation.isError && hasSuggestions;
|
||||
const showEmpty = !mutation.isPending && !mutation.isError && mutation.isSuccess && !hasSuggestions;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -71,20 +88,28 @@ export default function AiBatchOptimizer({ batchId }: Props) {
|
||||
</div>
|
||||
) : mutation.isError ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">Something went wrong. Try again.</p>
|
||||
) : showResults ? (
|
||||
) : showResults && optData ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3 max-h-[50vh] overflow-y-auto">
|
||||
{suggestions.map((s, i) => (
|
||||
<div key={i} className="rounded-lg bg-muted/30 p-4 border border-border/60">
|
||||
<p className="text-xs font-semibold text-primary uppercase tracking-wide mb-1">{s.impact} impact</p>
|
||||
<p className="text-sm font-medium">{s.suggestion}</p>
|
||||
{s.details ? <p className="text-sm text-muted-foreground mt-2 leading-relaxed">{s.details}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
<div className="rounded-lg bg-muted/30 p-4 border border-border/60">
|
||||
<p className="text-xs font-semibold text-primary uppercase tracking-wide mb-1">{optData.impact} impact</p>
|
||||
<p className="text-sm font-medium">{optData.summary}</p>
|
||||
</div>
|
||||
{Array.isArray(optData.optimized) && optData.optimized.length > 0 && (
|
||||
<div className="space-y-2 max-h-[40vh] overflow-y-auto">
|
||||
{optData.optimized.map((item, i) => (
|
||||
<div key={i} className="rounded-lg bg-muted/20 p-3 border text-sm">
|
||||
{typeof item === "object" && item !== null ? JSON.stringify(item) : String(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button className="flex-1" onClick={handleApply}>
|
||||
Apply Suggestion
|
||||
<Button className="flex-1" onClick={handleApply} disabled={applyMutation.isPending}>
|
||||
{applyMutation.isPending ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Applying...</>
|
||||
) : (
|
||||
"Apply Suggestion"
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Dismiss
|
||||
|
||||
@@ -40,8 +40,9 @@ export default function AiGeneratorModal() {
|
||||
difficulty,
|
||||
count,
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
setLocalExercises(Array.isArray(res.exercises) ? res.exercises : []);
|
||||
onSuccess: (res: Record<string, unknown>) => {
|
||||
const items = Array.isArray(res.questions) ? res.questions : Array.isArray(res.exercises) ? res.exercises : [];
|
||||
setLocalExercises(items);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
@@ -57,6 +58,21 @@ export default function AiGeneratorModal() {
|
||||
generateMutation.mutate();
|
||||
};
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => generationService.saveGenerated(moduleType, localExercises ?? []),
|
||||
onSuccess: (res) => {
|
||||
toast({ title: "Saved", description: `${res.saved} assignments saved successfully.` });
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Save failed",
|
||||
description: err.message || "Could not save generated assignments.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const generated = localExercises;
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
@@ -188,7 +204,17 @@ export default function AiGeneratorModal() {
|
||||
);
|
||||
})}
|
||||
<div className="flex gap-2">
|
||||
<Button className="flex-1">Save All</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending || !generated?.length}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Saving...</>
|
||||
) : (
|
||||
"Save All"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
|
||||
@@ -19,8 +19,8 @@ export default function AiGradeExplainer({
|
||||
const explainMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
coachingService.explain({
|
||||
context: `IELTS / course grades for student: ${studentName}. Summarize what the scores mean and what to focus on next.`,
|
||||
scores,
|
||||
score_data: scores ?? {},
|
||||
student_context: `IELTS / course grades for student: ${studentName}. Summarize what the scores mean and what to focus on next.`,
|
||||
}),
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
|
||||
@@ -26,9 +26,8 @@ export default function AiGradingAssistant({
|
||||
const gradeMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
analyticsService.getGradingSuggestion({
|
||||
submission_id: submissionId,
|
||||
text: submissionText,
|
||||
...(rubricId !== undefined ? { rubric_id: rubricId } : {}),
|
||||
submission_text: submissionText,
|
||||
skill: "writing",
|
||||
}),
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
@@ -45,7 +44,7 @@ export default function AiGradingAssistant({
|
||||
}, [submissionId, submissionText, rubricId]);
|
||||
|
||||
const data = gradeMutation.data;
|
||||
const marks = data ? Math.round(data.overall_score) : 0;
|
||||
const marks = data ? Math.round(data.overall_band * 100 / 9) : 0;
|
||||
const feedbackBlock = data
|
||||
? [
|
||||
data.feedback,
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Sparkles, TrendingUp, AlertTriangle, Trophy, Loader2 } from "lucide-react";
|
||||
import { analyticsService } from "@/services/analytics.service";
|
||||
import type { AiInsight } from "@/types";
|
||||
import { Sparkles, TrendingUp, AlertTriangle, Info, Loader2 } from "lucide-react";
|
||||
import { analyticsService, type AiInsightItem } from "@/services/analytics.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const EMPTY_PAYLOAD: Record<string, unknown> = {};
|
||||
|
||||
function insightIcon(type: AiInsight["type"]) {
|
||||
switch (type) {
|
||||
case "positive":
|
||||
return Trophy;
|
||||
case "warning":
|
||||
function insightIcon(severity: AiInsightItem["severity"]) {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return AlertTriangle;
|
||||
default:
|
||||
case "warning":
|
||||
return TrendingUp;
|
||||
default:
|
||||
return Info;
|
||||
}
|
||||
}
|
||||
|
||||
function insightColor(type: AiInsight["type"]) {
|
||||
switch (type) {
|
||||
case "positive":
|
||||
return "text-primary";
|
||||
function insightColor(severity: AiInsightItem["severity"]) {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return "text-destructive";
|
||||
case "warning":
|
||||
return "text-warning";
|
||||
default:
|
||||
return "text-success";
|
||||
return "text-primary";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,14 +36,15 @@ interface Props {
|
||||
|
||||
export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
const payloadKey = useMemo(() => JSON.stringify(data), [data]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (payload: Record<string, unknown>) => analyticsService.getInsights(payload),
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
title: "Insights unavailable",
|
||||
description: err.message || "Could not load AI insights.",
|
||||
title: t("ai.insightsUnavailable"),
|
||||
description: err.message || t("ai.couldNotLoadInsights"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
@@ -51,47 +52,47 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
mutation.mutate(data);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- refetch when serialized payload changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [payloadKey]);
|
||||
|
||||
const items = mutation.data ?? [];
|
||||
const items = mutation.data?.insights ?? [];
|
||||
|
||||
return (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
AI Platform Insights
|
||||
{t("ai.platformInsightsTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mutation.isPending && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-8 justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
Loading insights…
|
||||
{t("ai.loadingInsights")}
|
||||
</div>
|
||||
)}
|
||||
{mutation.isError && !mutation.isPending && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">Could not load insights.</p>
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">{t("ai.couldNotLoadInsights")}</p>
|
||||
)}
|
||||
{mutation.isSuccess && items.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">No insights available for this view.</p>
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">{t("ai.noInsightsAvailable")}</p>
|
||||
)}
|
||||
{!mutation.isPending && items.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{items.map((item) => {
|
||||
const Icon = insightIcon(item.type);
|
||||
const color = insightColor(item.type);
|
||||
{items.map((item, idx) => {
|
||||
const Icon = insightIcon(item.severity);
|
||||
const color = insightColor(item.severity);
|
||||
return (
|
||||
<div key={item.id} className="rounded-lg border bg-muted/30 p-4">
|
||||
<div key={idx} className="rounded-lg border bg-muted/30 p-4" dir="auto">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className={`h-4 w-4 ${color}`} />
|
||||
<span className="text-sm font-semibold">{item.title}</span>
|
||||
<Icon className={`h-4 w-4 shrink-0 ${color}`} />
|
||||
<span className="text-sm font-semibold min-w-0">{item.title}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{item.description}</p>
|
||||
{item.metric != null && item.value != null && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{item.metric}: {item.value}
|
||||
{item.recommendation && (
|
||||
<p className="text-xs text-muted-foreground mt-2 italic">
|
||||
{item.recommendation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Sparkles, Search, Loader2, X } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -10,14 +11,15 @@ export default function AiSearchBar() {
|
||||
const [query, setQuery] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const searchMutation = useMutation({
|
||||
mutationFn: (q: string) => analyticsService.search(q),
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Search failed",
|
||||
description: err.message || "Could not complete AI search.",
|
||||
title: t("chrome.aiSearchFailedTitle"),
|
||||
description: err.message || t("chrome.aiSearchFailedDesc"),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -27,16 +29,16 @@ export default function AiSearchBar() {
|
||||
searchMutation.mutate(query.trim());
|
||||
};
|
||||
|
||||
const results = searchMutation.data;
|
||||
const result = searchMutation.data;
|
||||
|
||||
return (
|
||||
<div className="relative max-w-md w-full">
|
||||
<div className="relative">
|
||||
<Sparkles className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary" />
|
||||
<Search className="absolute left-7 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Sparkles className="absolute start-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary" />
|
||||
<Search className="absolute start-7 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Ask anything... e.g. 'Show students with low attendance'"
|
||||
className="pl-12 pr-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1"
|
||||
placeholder={t("chrome.aiSearchPlaceholder")}
|
||||
className="ps-12 pe-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
@@ -50,50 +52,56 @@ export default function AiSearchBar() {
|
||||
setQuery("");
|
||||
searchMutation.reset();
|
||||
}}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
className="absolute end-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(searchMutation.isPending || results !== undefined) && (
|
||||
<div className="absolute top-full mt-1 left-0 right-0 z-50 rounded-lg border bg-popover p-3 shadow-md">
|
||||
{(searchMutation.isPending || result !== undefined) && (
|
||||
<div className="absolute top-full mt-1 start-0 end-0 z-50 rounded-lg border bg-popover p-3 shadow-md">
|
||||
{searchMutation.isPending ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
AI is searching...
|
||||
{t("chrome.aiSearching")}
|
||||
</div>
|
||||
) : results && results.length > 0 ? (
|
||||
) : result?.answer ? (
|
||||
<div className="text-sm space-y-2 max-h-64 overflow-y-auto">
|
||||
{results.map((r, i) => (
|
||||
<div
|
||||
key={`${r.title}-${i}`}
|
||||
className="flex items-start gap-2 border-b border-border/60 pb-2 last:border-0 last:pb-0"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{r.title}</p>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">{r.description}</p>
|
||||
{r.url && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary mt-1 hover:underline"
|
||||
onClick={() => navigate(r.url!)}
|
||||
>
|
||||
Go to {r.url}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-2 pb-2">
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
<p className="text-muted-foreground">{result.answer}</p>
|
||||
</div>
|
||||
{result.suggestions?.length > 0 && (
|
||||
<div className="border-t pt-2 space-y-1">
|
||||
<p className="text-xs font-semibold text-primary">{t("chrome.aiRelatedQueries")}</p>
|
||||
{result.suggestions.map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="block text-xs text-primary hover:underline"
|
||||
onClick={() => { setQuery(s); searchMutation.mutate(s); }}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{result.related_actions?.map((a, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={() => navigate(a.action)}
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm flex items-start gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
<p>
|
||||
No results for "{query}". Try a different question or keyword.
|
||||
</p>
|
||||
<p>{t("chrome.aiNoResults", { q: query })}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -29,8 +29,11 @@ export default function AiStudyCoach() {
|
||||
suggestMutation.mutate();
|
||||
};
|
||||
|
||||
const suggestions = suggestMutation.data?.suggestions ?? [];
|
||||
const planTips = suggestMutation.data?.study_plan_tips ?? [];
|
||||
const d = suggestMutation.data;
|
||||
const suggestions = d ? [d.suggestion, ...(d.focus_areas ?? []).map((a: string) => `Focus area: ${a}`)].filter(Boolean) : [];
|
||||
const planTips = d?.daily_plan?.length
|
||||
? d.daily_plan.map((p: { activity: string; duration_min: number; skill: string }) => `${p.activity} (${p.duration_min}min — ${p.skill})`)
|
||||
: d?.motivation ? [d.motivation] : [];
|
||||
|
||||
return (
|
||||
<Card className="border-0 shadow-sm bg-primary/5">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sparkles, X, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { coachingService } from "@/services/coaching.service";
|
||||
@@ -12,6 +13,7 @@ interface Props {
|
||||
|
||||
export default function AiTipBanner({ context = "dashboard", variant = "tip", dismissible = true }: Props) {
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["ai", "tip", context],
|
||||
@@ -22,23 +24,30 @@ export default function AiTipBanner({ context = "dashboard", variant = "tip", di
|
||||
|
||||
const bgClass = variant === "recommendation" ? "bg-accent/50 border-accent" : variant === "insight" ? "bg-primary/5 border-primary/20" : "bg-muted/50 border-muted-foreground/10";
|
||||
|
||||
const variantLabel =
|
||||
variant === "insight"
|
||||
? t("ai.insightLabel")
|
||||
: variant === "recommendation"
|
||||
? t("ai.recommendationLabel")
|
||||
: t("ai.tipLabel");
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-center gap-3`}>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">Loading AI tip…</span>
|
||||
<span className="text-sm text-muted-foreground">{t("ai.loadingTip")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`}>
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`} dir="auto">
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<span className="text-xs font-semibold text-primary">AI Tip</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="block text-xs font-semibold text-primary">{t("ai.tipLabel")}</span>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{isError ? (error instanceof Error ? error.message : "Could not load tip.") : "No tip available."}
|
||||
{isError ? (error instanceof Error ? error.message : t("ai.couldNotLoadTip")) : t("ai.noTipAvailable")}
|
||||
</p>
|
||||
</div>
|
||||
{dismissible && (
|
||||
@@ -50,26 +59,28 @@ export default function AiTipBanner({ context = "dashboard", variant = "tip", di
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.content?.trim() && !data.title?.trim()) {
|
||||
if (!data.tip?.trim()) {
|
||||
return (
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`}>
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`} dir="auto">
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<span className="text-xs font-semibold text-primary">AI {variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}</span>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">No tip for this context yet.</p>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="block text-xs font-semibold text-primary">{variantLabel}</span>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{t("ai.noTipForContext")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const label = data.category && data.category !== "general"
|
||||
? t("ai.categoryTipLabel", { category: data.category.charAt(0).toUpperCase() + data.category.slice(1) })
|
||||
: variantLabel;
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3 animate-in fade-in slide-in-from-top-2 duration-300`}>
|
||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3 animate-in fade-in slide-in-from-top-2 duration-300`} dir="auto">
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<span className="text-xs font-semibold text-primary">
|
||||
{data.title?.trim() || `AI ${variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}`}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{data.content}</p>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="block text-xs font-semibold text-primary">{label}</span>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{data.tip}</p>
|
||||
</div>
|
||||
{dismissible && (
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => setDismissed(true)}>
|
||||
|
||||
@@ -22,8 +22,9 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
|
||||
const mutation = useMutation({
|
||||
mutationFn: (mode: NonNullable<Mode>) =>
|
||||
coachingService.writingHelp({
|
||||
text: text.trim(),
|
||||
task_type: `${task_type}:${mode}`,
|
||||
task: task_type,
|
||||
draft: text.trim(),
|
||||
help_type: mode,
|
||||
}),
|
||||
onSuccess: () => setShowResult(true),
|
||||
onError: (err: Error) => {
|
||||
@@ -84,20 +85,20 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
|
||||
|
||||
{showResult && !loading && mutation.data && activeMode === "improve" && (
|
||||
<div className="space-y-3">
|
||||
{mutation.data.feedback && (
|
||||
{mutation.data.tips?.length > 0 && (
|
||||
<div className="rounded-lg border bg-muted/30 p-3">
|
||||
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3" /> Feedback
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{mutation.data.feedback}</p>
|
||||
<p className="text-sm text-muted-foreground">{mutation.data.tips.join(" ")}</p>
|
||||
</div>
|
||||
)}
|
||||
{mutation.data.improved && (
|
||||
{mutation.data.improved_text && (
|
||||
<div className="rounded-lg border bg-muted/30 p-3">
|
||||
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3" /> Improved Version
|
||||
</p>
|
||||
<p className="text-sm">{mutation.data.improved}</p>
|
||||
<p className="text-sm">{mutation.data.improved_text}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -108,17 +109,17 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
|
||||
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3" /> Grammar notes
|
||||
</p>
|
||||
{(mutation.data.grammar_notes?.length ?? 0) > 0 ? (
|
||||
mutation.data.grammar_notes!.map((note, i) => (
|
||||
{(mutation.data.changes?.length ?? 0) > 0 ? (
|
||||
mutation.data.changes.map((c, i) => (
|
||||
<div key={i} className="text-sm border-l-2 border-warning pl-2">
|
||||
<p className="text-muted-foreground">{note}</p>
|
||||
<p className="text-muted-foreground"><strong>{c.original}</strong> → {c.revised} — {c.reason}</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No grammar issues flagged.</p>
|
||||
)}
|
||||
{mutation.data.feedback ? (
|
||||
<p className="text-xs text-muted-foreground pt-2 border-t">{mutation.data.feedback}</p>
|
||||
{mutation.data.tips?.length > 0 ? (
|
||||
<p className="text-xs text-muted-foreground pt-2 border-t">{mutation.data.tips.join("; ")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
@@ -128,9 +129,9 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
|
||||
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3" /> Estimated band / assessment
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{mutation.data.feedback}</p>
|
||||
{mutation.data.improved ? (
|
||||
<p className="text-sm mt-2 pt-2 border-t">{mutation.data.improved}</p>
|
||||
<p className="text-sm text-muted-foreground">{mutation.data.tips?.join(" ") ?? ""}</p>
|
||||
{mutation.data.improved_text ? (
|
||||
<p className="text-sm mt-2 pt-2 border-t">{mutation.data.improved_text}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
214
src/components/coursePlan/LibraryPickerDialog.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Library, Loader2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
import { resourcesService } from "@/services/resources.service";
|
||||
import type { Resource } from "@/types";
|
||||
|
||||
/**
|
||||
* Generic, reusable picker over `/api/resources`.
|
||||
*
|
||||
* Two callers wire this up today:
|
||||
*
|
||||
* 1. **AdminCoursePlanDetail** — for an *existing* plan, so it forwards
|
||||
* the picked resources to `coursePlanService.attachResources` in the
|
||||
* `onConfirm` handler and invalidates the sources query.
|
||||
*
|
||||
* 2. **CoursePlanWizard** — the plan does not exist yet at pick time,
|
||||
* so the wizard simply pushes the picks into its draft state and
|
||||
* attaches them in one batch after the plan is created.
|
||||
*
|
||||
* The dialog itself is purposefully agnostic: it only reports the
|
||||
* selected `Resource` rows; the parent decides what to do.
|
||||
*/
|
||||
export function LibraryPickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
alreadyLinkedIds,
|
||||
onConfirm,
|
||||
isPending,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Resources whose ids are in this set render disabled + checked. */
|
||||
alreadyLinkedIds?: Set<number>;
|
||||
/** Called when the admin clicks "Attach selected". */
|
||||
onConfirm: (resources: Resource[]) => void | Promise<void>;
|
||||
/** Optional spinner state from the parent's mutation. */
|
||||
isPending?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
|
||||
// Reset every time the dialog re-opens so a previous session's
|
||||
// selection doesn't bleed into the next attach.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelected(new Set());
|
||||
setSearch("");
|
||||
setTypeFilter("all");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["library-resources", { search, type: typeFilter }],
|
||||
queryFn: () =>
|
||||
resourcesService.list({
|
||||
search: search || undefined,
|
||||
resource_type: typeFilter === "all" ? undefined : typeFilter,
|
||||
review_status: "approved",
|
||||
}),
|
||||
enabled: open,
|
||||
});
|
||||
const resources: Resource[] = data?.items ?? [];
|
||||
|
||||
const toggle = (id: number) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const picked = resources.filter((r) => selected.has(r.id));
|
||||
if (!picked.length) return;
|
||||
await onConfirm(picked);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[640px] max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Library className="h-5 w-5 text-primary" />
|
||||
{t("coursePlan.sources.libraryTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("coursePlan.sources.libraryDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Input
|
||||
placeholder={t("coursePlan.sources.librarySearchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("coursePlan.sources.libraryTypeAll")}
|
||||
</SelectItem>
|
||||
<SelectItem value="pdf">PDF</SelectItem>
|
||||
<SelectItem value="document">DOCX</SelectItem>
|
||||
<SelectItem value="link">URL</SelectItem>
|
||||
<SelectItem value="article">Article</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto -mx-2 px-2 space-y-1">
|
||||
{isLoading && <Skeleton className="h-32 w-full" />}
|
||||
{!isLoading && resources.length === 0 && (
|
||||
<p className="text-center text-sm text-muted-foreground py-8">
|
||||
{t("coursePlan.sources.libraryEmpty")}
|
||||
</p>
|
||||
)}
|
||||
{!isLoading &&
|
||||
resources.map((r) => {
|
||||
const isLinked = alreadyLinkedIds?.has(r.id) ?? false;
|
||||
const isSelected = selected.has(r.id);
|
||||
return (
|
||||
<label
|
||||
key={r.id}
|
||||
className={`flex items-start gap-3 rounded-md border p-2 text-sm transition-colors ${
|
||||
isLinked
|
||||
? "opacity-60 cursor-not-allowed bg-muted/40"
|
||||
: "cursor-pointer hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isLinked || isSelected}
|
||||
disabled={isLinked}
|
||||
onCheckedChange={() => !isLinked && toggle(r.id)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium truncate">{r.name}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] capitalize"
|
||||
>
|
||||
{r.resource_type || r.type || "resource"}
|
||||
</Badge>
|
||||
{isLinked && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{t("coursePlan.sources.libraryAlreadyLinked")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{(r.subject_name || (r.topic_names ?? []).length > 0) && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{[r.subject_name, ...(r.topic_names?.slice(0, 2) ?? [])]
|
||||
.filter(Boolean)
|
||||
.join(" › ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t pt-3">
|
||||
<span className="mr-auto text-xs text-muted-foreground self-center">
|
||||
{t("coursePlan.sources.librarySelectedCount", {
|
||||
count: selected.size,
|
||||
})}
|
||||
</span>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selected.size || isPending}
|
||||
>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t("coursePlan.sources.libraryAttach")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
78
src/components/coursePlan/MaterialBookView.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CoursePlanMaterial } from "@/types";
|
||||
|
||||
export const SKILL_STYLE: Record<string, string> = {
|
||||
reading: "bg-blue-100 text-blue-800 border-blue-200",
|
||||
writing: "bg-purple-100 text-purple-800 border-purple-200",
|
||||
listening: "bg-emerald-100 text-emerald-800 border-emerald-200",
|
||||
speaking: "bg-amber-100 text-amber-800 border-amber-200",
|
||||
grammar: "bg-rose-100 text-rose-800 border-rose-200",
|
||||
vocabulary: "bg-cyan-100 text-cyan-800 border-cyan-200",
|
||||
integrated: "bg-slate-100 text-slate-800 border-slate-200",
|
||||
};
|
||||
|
||||
export function SkillBadge({ skill }: { skill: string }) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("capitalize border", SKILL_STYLE[skill] ?? SKILL_STYLE.integrated)}
|
||||
>
|
||||
{skill || "integrated"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function renderAny(value: unknown): React.ReactNode {
|
||||
if (value == null) return null;
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return <p className="leading-7">{String(value)}</p>;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return null;
|
||||
return (
|
||||
<ul className="list-disc ps-5 space-y-1">
|
||||
{value.map((item, idx) => (
|
||||
<li key={idx}>{renderAny(item)}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{entries.map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<h5 className="font-medium capitalize text-sm text-muted-foreground mb-1">
|
||||
{k.replace(/_/g, " ")}
|
||||
</h5>
|
||||
<div className="text-sm">{renderAny(v)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function MaterialBookView({
|
||||
material,
|
||||
}: {
|
||||
material: Pick<CoursePlanMaterial, "body" | "body_text" | "material_type">;
|
||||
}) {
|
||||
const body = material.body ?? {};
|
||||
const hasStructured = Object.keys(body).length > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background/70 p-4 space-y-4">
|
||||
{hasStructured ? (
|
||||
renderAny(body)
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap leading-7">
|
||||
{material.body_text || "No content available yet."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/components/student/ExamPopup.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Clock, Calendar, PlayCircle, AlertCircle } from "lucide-react";
|
||||
import { assignmentsService } from "@/services/assignments.service";
|
||||
import type { StudentExamAssignment } from "@/types";
|
||||
|
||||
export default function ExamPopup() {
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dismissed, setDismissed] = useState<Set<number>>(new Set());
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["student-my-exams"],
|
||||
queryFn: () => assignmentsService.getStudentExams(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const exams = (data?.items ?? []) as StudentExamAssignment[];
|
||||
const pendingExams = exams.filter((e) => !dismissed.has(e.id));
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingExams.length > 0 && !open) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [pendingExams.length]);
|
||||
|
||||
if (pendingExams.length === 0) return null;
|
||||
|
||||
// Localize dates with the active language so Arabic users see ar-EG
|
||||
// month names instead of "Apr 19, 2026". The locale is taken from i18n.
|
||||
const dateLocale = i18n.language?.startsWith("ar") ? "ar-EG" : "en-US";
|
||||
const formatDate = (iso: string | null) => {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString(dateLocale, { month: "short", day: "numeric", year: "numeric" }) +
|
||||
" " + d.toLocaleTimeString(dateLocale, { hour: "2-digit", minute: "2-digit" });
|
||||
};
|
||||
|
||||
const handleStart = (exam: StudentExamAssignment) => {
|
||||
setOpen(false);
|
||||
navigate(`/student/exam/${exam.exam_id}/session`);
|
||||
};
|
||||
|
||||
const handleDismiss = (id: number) => {
|
||||
setDismissed((prev) => new Set([...prev, id]));
|
||||
const remaining = pendingExams.filter((e) => e.id !== id);
|
||||
if (remaining.length === 0) setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-primary" />
|
||||
{t("examPopup.title", { n: pendingExams.length })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pe-1">
|
||||
{pendingExams.map((exam) => (
|
||||
<div key={exam.id} className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-sm">{exam.exam_title || exam.schedule_name}</h3>
|
||||
<Badge
|
||||
variant={exam.schedule_state === "active" ? "default" : "secondary"}
|
||||
className="capitalize text-xs"
|
||||
>
|
||||
{exam.schedule_state === "active" ? t("examPopup.activeNow") : exam.schedule_state}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{exam.schedule_name && exam.exam_title && (
|
||||
<p className="text-xs text-muted-foreground">{exam.schedule_name}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{t("examPopup.from")} {formatDate(exam.start_date)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{t("examPopup.to")} {formatDate(exam.end_date)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!exam.can_start}
|
||||
onClick={() => handleStart(exam)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<PlayCircle className="h-3.5 w-3.5" />
|
||||
{exam.can_start ? t("examPopup.startExam") : t("examPopup.notAvailableYet")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDismiss(exam.id)}
|
||||
>
|
||||
{t("common.dismiss")}
|
||||
</Button>
|
||||
{!exam.can_start && (
|
||||
<span className="text-[11px] text-muted-foreground italic ms-auto">
|
||||
{exam.schedule_state === "planned"
|
||||
? t("examPopup.willBeAvailable")
|
||||
: t("examPopup.notActive")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -25,22 +25,72 @@ const AlertDialogOverlay = React.forwardRef<
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
function hasAlertDialogDescription(children: React.ReactNode): boolean {
|
||||
let found = false;
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (found || !React.isValidElement(child)) return;
|
||||
const type = child.type as { displayName?: string } | undefined;
|
||||
if (
|
||||
type === AlertDialogPrimitive.Description ||
|
||||
(type && type.displayName === AlertDialogPrimitive.Description.displayName)
|
||||
) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
const nested = (child.props as { children?: React.ReactNode } | undefined)
|
||||
?.children;
|
||||
if (nested !== undefined && hasAlertDialogDescription(nested)) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
type AlertDialogContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof AlertDialogPrimitive.Content
|
||||
> & {
|
||||
/**
|
||||
* Optional accessible description rendered visually-hidden. When omitted and
|
||||
* no `AlertDialogDescription` is found in the subtree, a generic fallback is
|
||||
* emitted so screen readers still have something to announce.
|
||||
*/
|
||||
description?: React.ReactNode;
|
||||
};
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContentProps
|
||||
>(({ className, children, description, ...props }, ref) => {
|
||||
const needsFallback =
|
||||
!props["aria-describedby"] &&
|
||||
description === undefined &&
|
||||
!hasAlertDialogDescription(children);
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{description !== undefined && (
|
||||
<AlertDialogPrimitive.Description className="sr-only">
|
||||
{description}
|
||||
</AlertDialogPrimitive.Description>
|
||||
)}
|
||||
{needsFallback && (
|
||||
<AlertDialogPrimitive.Description className="sr-only">
|
||||
Alert dialog content
|
||||
</AlertDialogPrimitive.Description>
|
||||
)}
|
||||
{children}
|
||||
</AlertDialogPrimitive.Content>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
});
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
|
||||
@@ -22,8 +22,11 @@ const badgeVariants = cva(
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||
({ className, variant, ...props }, ref) => (
|
||||
<div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
),
|
||||
);
|
||||
Badge.displayName = "Badge";
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -60,7 +60,12 @@ const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWit
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage";
|
||||
|
||||
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
|
||||
<li role="presentation" aria-hidden="true" className={cn("[&>svg]:size-3.5", className)} {...props}>
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5 rtl:[&>svg]:rotate-180", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -42,8 +42,8 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
|
||||
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4 rtl:rotate-180" />,
|
||||
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4 rtl:rotate-180" />,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -185,7 +185,7 @@ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProp
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<ArrowLeft className="h-4 w-4 rtl:rotate-180" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
@@ -213,7 +213,7 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ const ContextMenuSubTrigger = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
<ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
));
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
@@ -27,28 +27,86 @@ const DialogOverlay = React.forwardRef<
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
/**
|
||||
* Walks `children` looking for any element whose component is
|
||||
* `DialogPrimitive.Description` (or our re-exported `DialogDescription`).
|
||||
* Radix emits an a11y warning when a `DialogContent` has neither a
|
||||
* `Description` nor an explicit `aria-describedby`, so we use this to decide
|
||||
* whether to inject a visually-hidden fallback description.
|
||||
*/
|
||||
function hasDialogDescription(children: React.ReactNode): boolean {
|
||||
let found = false;
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (found || !React.isValidElement(child)) return;
|
||||
const type = child.type as { displayName?: string } | undefined;
|
||||
if (
|
||||
type === DialogPrimitive.Description ||
|
||||
(type && type.displayName === DialogPrimitive.Description.displayName)
|
||||
) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
const nested = (child.props as { children?: React.ReactNode } | undefined)
|
||||
?.children;
|
||||
if (nested !== undefined && hasDialogDescription(nested)) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
type DialogContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof DialogPrimitive.Content
|
||||
> & {
|
||||
/**
|
||||
* Optional accessible description. When supplied, a visually-hidden
|
||||
* `DialogDescription` is rendered so screen readers announce it without
|
||||
* affecting layout. Prefer this for simple dialogs; for richer descriptions
|
||||
* continue to place a `<DialogDescription>` inside `<DialogHeader>`.
|
||||
*/
|
||||
description?: React.ReactNode;
|
||||
};
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContentProps
|
||||
>(({ className, children, description, ...props }, ref) => {
|
||||
const hasExplicitDescribedBy = !!props["aria-describedby"];
|
||||
const needsFallback =
|
||||
!hasExplicitDescribedBy &&
|
||||
description === undefined &&
|
||||
!hasDialogDescription(children);
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{description !== undefined && (
|
||||
<DialogPrimitive.Description className="sr-only">
|
||||
{description}
|
||||
</DialogPrimitive.Description>
|
||||
)}
|
||||
{needsFallback && (
|
||||
<DialogPrimitive.Description className="sr-only">
|
||||
Dialog content
|
||||
</DialogPrimitive.Description>
|
||||
)}
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
|
||||
@@ -32,7 +32,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
<ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
@@ -57,7 +57,7 @@ const MenubarSubTrigger = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
<ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
));
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
|
||||
|
||||
@@ -47,17 +47,17 @@ const PaginationLink = ({ className, isActive, size = "icon", ...props }: Pagina
|
||||
PaginationLink.displayName = "PaginationLink";
|
||||
|
||||
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 pl-2.5", className)} {...props}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 ps-2.5", className)} {...props}>
|
||||
<ChevronLeft className="h-4 w-4 rtl:rotate-180" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationPrevious.displayName = "PaginationPrevious";
|
||||
|
||||
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 pr-2.5", className)} {...props}>
|
||||
<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 pe-2.5", className)} {...props}>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<ChevronRight className="h-4 w-4 rtl:rotate-180" />
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationNext.displayName = "PaginationNext";
|
||||
|
||||
@@ -49,21 +49,61 @@ const sheetVariants = cva(
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
VariantProps<typeof sheetVariants> {
|
||||
/**
|
||||
* Optional accessible description rendered visually-hidden. Falls back to a
|
||||
* generic string when no `SheetDescription` is found in the subtree, which
|
||||
* keeps Radix happy without forcing every call-site to add one.
|
||||
*/
|
||||
description?: React.ReactNode;
|
||||
}
|
||||
|
||||
function hasSheetDescription(children: React.ReactNode): boolean {
|
||||
let found = false;
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (found || !React.isValidElement(child)) return;
|
||||
const type = child.type as { displayName?: string } | undefined;
|
||||
if (
|
||||
type === SheetPrimitive.Description ||
|
||||
(type && type.displayName === SheetPrimitive.Description.displayName)
|
||||
) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
const nested = (child.props as { children?: React.ReactNode } | undefined)
|
||||
?.children;
|
||||
if (nested !== undefined && hasSheetDescription(nested)) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
|
||||
({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
),
|
||||
({ side = "right", className, children, description, ...props }, ref) => {
|
||||
const needsFallback =
|
||||
!props["aria-describedby"] &&
|
||||
description === undefined &&
|
||||
!hasSheetDescription(children);
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{description !== undefined && (
|
||||
<SheetPrimitive.Description className="sr-only">{description}</SheetPrimitive.Description>
|
||||
)}
|
||||
{needsFallback && (
|
||||
<SheetPrimitive.Description className="sr-only">Sheet content</SheetPrimitive.Description>
|
||||
)}
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
},
|
||||
);
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ const Sidebar = React.forwardRef<
|
||||
Sidebar.displayName = "Sidebar";
|
||||
|
||||
const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
|
||||
({ className, onClick, ...props }, ref) => {
|
||||
({ className, onClick, "aria-label": ariaLabel, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
@@ -226,6 +226,7 @@ const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.C
|
||||
data-sidebar="trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={ariaLabel ?? "Toggle sidebar"}
|
||||
className={cn("h-7 w-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
@@ -233,8 +234,8 @@ const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.C
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeft />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
<PanelLeft className="rtl:rotate-180" />
|
||||
<span className="sr-only">{ariaLabel ?? "Toggle sidebar"}</span>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
|
||||
234
src/components/wizard/StepWizard.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { ReactNode, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowLeft, ArrowRight, Check, ChevronLeft } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Generic multi-step wizard shell.
|
||||
*
|
||||
* A wizard is defined as an ordered array of {@link WizardStepDef} items.
|
||||
* Each step owns its own piece of the accumulated state and returns a node
|
||||
* that renders the form. The shell handles:
|
||||
*
|
||||
* - rendering the numbered stepper (with `completed` / `active` styles)
|
||||
* - Back / Next navigation
|
||||
* - a final "Finish" button that calls `onFinish(state)`
|
||||
* - per-step validation via `step.validate(state)` → return an error
|
||||
* message or `null` when valid
|
||||
* - a persistent progress bar
|
||||
*
|
||||
* Keeping this shell free of domain logic means every scenario wizard
|
||||
* (rubric, course, structure, …) is just a tiny file that defines steps
|
||||
* and wires the final submit to the right service.
|
||||
*/
|
||||
|
||||
export interface WizardStepDef<TState> {
|
||||
id: string;
|
||||
/** i18n key for the step title. */
|
||||
titleKey: string;
|
||||
/** Optional i18n key for a short description shown under the title. */
|
||||
descriptionKey?: string;
|
||||
/**
|
||||
* Render the step's form. Call `update(patch)` to merge fields into the
|
||||
* shared state. Do **not** call `onNext` directly — the shell handles
|
||||
* Next/Back; the render function should just update local fields.
|
||||
*/
|
||||
render: (props: StepRenderProps<TState>) => ReactNode;
|
||||
/**
|
||||
* Synchronous validator. Return a human-readable error message that will
|
||||
* be displayed under the form and block Next, or `null` when valid.
|
||||
*/
|
||||
validate?: (state: TState) => string | null;
|
||||
}
|
||||
|
||||
export interface StepRenderProps<TState> {
|
||||
state: TState;
|
||||
update: (patch: Partial<TState>) => void;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface StepWizardProps<TState> {
|
||||
/** i18n key for the wizard heading. */
|
||||
titleKey: string;
|
||||
/** i18n key for the short lead paragraph shown under the heading. */
|
||||
subtitleKey?: string;
|
||||
/** Optional back-link to the hub. */
|
||||
backTo?: string;
|
||||
backLabelKey?: string;
|
||||
steps: WizardStepDef<TState>[];
|
||||
initialState: TState;
|
||||
/** Called once the user clicks Finish on the last step. */
|
||||
onFinish: (state: TState) => Promise<void> | void;
|
||||
/** i18n key for the Finish button label. Defaults to "wizard.finish". */
|
||||
finishLabelKey?: string;
|
||||
/** Disable all form controls (used by consumers while submitting). */
|
||||
submitting?: boolean;
|
||||
}
|
||||
|
||||
export function StepWizard<TState>({
|
||||
titleKey,
|
||||
subtitleKey,
|
||||
backTo,
|
||||
backLabelKey = "wizard.backToHub",
|
||||
steps,
|
||||
initialState,
|
||||
onFinish,
|
||||
finishLabelKey = "wizard.finish",
|
||||
submitting = false,
|
||||
}: StepWizardProps<TState>) {
|
||||
const { t } = useTranslation();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [state, setState] = useState<TState>(initialState);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submittingInternal, setSubmittingInternal] = useState(false);
|
||||
|
||||
const busy = submitting || submittingInternal;
|
||||
const current = steps[index];
|
||||
const isLast = index === steps.length - 1;
|
||||
const progress = Math.round(((index + 1) / steps.length) * 100);
|
||||
|
||||
const update = (patch: Partial<TState>) => {
|
||||
setState((prev) => ({ ...prev, ...patch }));
|
||||
// Clear validation error as soon as the user starts typing again.
|
||||
if (error) setError(null);
|
||||
};
|
||||
|
||||
const validateAndAdvance = async () => {
|
||||
const msg = current.validate?.(state) ?? null;
|
||||
if (msg) {
|
||||
setError(msg);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
|
||||
if (!isLast) {
|
||||
setIndex((i) => i + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Last step: submit.
|
||||
try {
|
||||
setSubmittingInternal(true);
|
||||
await onFinish(state);
|
||||
} finally {
|
||||
setSubmittingInternal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stepStatus = useMemo(
|
||||
() =>
|
||||
steps.map((_, i) => {
|
||||
if (i < index) return "done" as const;
|
||||
if (i === index) return "active" as const;
|
||||
return "upcoming" as const;
|
||||
}),
|
||||
[steps, index],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
{/* Heading + back link */}
|
||||
<div className="space-y-2">
|
||||
{backTo && (
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 text-muted-foreground">
|
||||
<Link to={backTo} className="flex items-center gap-1">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t(backLabelKey)}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t(titleKey)}</h1>
|
||||
{subtitleKey && <p className="text-muted-foreground">{t(subtitleKey)}</p>}
|
||||
</div>
|
||||
|
||||
{/* Stepper */}
|
||||
<ol className="flex items-center gap-2 overflow-x-auto pb-2" role="list">
|
||||
{steps.map((step, i) => {
|
||||
const s = stepStatus[i];
|
||||
return (
|
||||
<li key={step.id} className="flex items-center gap-2 shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold",
|
||||
s === "done" && "bg-primary text-primary-foreground",
|
||||
s === "active" && "bg-primary/10 text-primary ring-2 ring-primary",
|
||||
s === "upcoming" && "bg-muted text-muted-foreground",
|
||||
)}
|
||||
aria-current={s === "active" ? "step" : undefined}
|
||||
>
|
||||
{s === "done" ? <Check className="h-4 w-4" /> : i + 1}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm whitespace-nowrap",
|
||||
s === "active" ? "font-medium" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t(step.titleKey)}
|
||||
</span>
|
||||
{i < steps.length - 1 && <div className="mx-1 h-px w-8 bg-border" />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Active step */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<h2 className="text-lg font-semibold">{t(current.titleKey)}</h2>
|
||||
{current.descriptionKey && (
|
||||
<p className="text-sm text-muted-foreground">{t(current.descriptionKey)}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{current.render({ state, update, error })}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIndex((i) => Math.max(0, i - 1))}
|
||||
disabled={busy || index === 0}
|
||||
>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
{t("wizard.back")}
|
||||
</Button>
|
||||
|
||||
<div className="text-xs text-muted-foreground tabular-nums">
|
||||
{t("wizard.stepOf", { current: index + 1, total: steps.length })}
|
||||
</div>
|
||||
|
||||
<Button onClick={validateAndAdvance} disabled={busy}>
|
||||
{isLast ? t(finishLabelKey) : t("wizard.next")}
|
||||
{!isLast && <ArrowRight className="ml-1 h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StepWizard;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
|
||||
import { authService } from "@/services/auth.service";
|
||||
import { clearToken } from "@/lib/api-client";
|
||||
import { clearToken, getActiveEntityId, setActiveEntityId } from "@/lib/api-client";
|
||||
import type { User, UserRole } from "@/types/auth";
|
||||
|
||||
export type { UserRole };
|
||||
@@ -9,6 +9,9 @@ interface AuthContextType {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
selectedEntityId: number | null;
|
||||
selectedEntity: User["entities"][number] | null;
|
||||
setSelectedEntityId: (entityId: number | null) => void;
|
||||
login: (email: string, password: string) => Promise<User>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
@@ -17,8 +20,23 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [selectedEntityId, setSelectedEntityIdState] = useState<number | null>(getActiveEntityId());
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const syncEntitySelection = useCallback((nextUser: User | null) => {
|
||||
const available = nextUser?.entities ?? [];
|
||||
if (available.length === 0) {
|
||||
setSelectedEntityIdState(null);
|
||||
setActiveEntityId(null);
|
||||
return;
|
||||
}
|
||||
const stored = getActiveEntityId();
|
||||
const validStored = stored && available.some((e) => e.id === stored) ? stored : null;
|
||||
const next = validStored ?? available[0].id;
|
||||
setSelectedEntityIdState(next);
|
||||
setActiveEntityId(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("encoach_token");
|
||||
if (!token) {
|
||||
@@ -28,30 +46,61 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
authService
|
||||
.getUser()
|
||||
.then(setUser)
|
||||
.then((u) => {
|
||||
setUser(u);
|
||||
syncEntitySelection(u);
|
||||
})
|
||||
.catch(() => {
|
||||
clearToken();
|
||||
setActiveEntityId(null);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
}, [syncEntitySelection]);
|
||||
|
||||
const login = useCallback(async (email: string, password: string): Promise<User> => {
|
||||
const res = await authService.login({ login: email, password });
|
||||
setUser(res.user);
|
||||
syncEntitySelection(res.user);
|
||||
return res.user;
|
||||
}, []);
|
||||
}, [syncEntitySelection]);
|
||||
|
||||
const setSelectedEntityId = useCallback((entityId: number | null) => {
|
||||
if (!user) {
|
||||
setSelectedEntityIdState(null);
|
||||
setActiveEntityId(null);
|
||||
return;
|
||||
}
|
||||
if (entityId == null) {
|
||||
setSelectedEntityIdState(null);
|
||||
setActiveEntityId(null);
|
||||
return;
|
||||
}
|
||||
if (!user.entities.some((e) => e.id === entityId)) return;
|
||||
setSelectedEntityIdState(entityId);
|
||||
setActiveEntityId(entityId);
|
||||
}, [user]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await authService.logout();
|
||||
setUser(null);
|
||||
setSelectedEntityIdState(null);
|
||||
setActiveEntityId(null);
|
||||
}, []);
|
||||
|
||||
const selectedEntity =
|
||||
user?.entities.find((e) => e.id === selectedEntityId) ??
|
||||
user?.entities[0] ??
|
||||
null;
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
isLoading,
|
||||
selectedEntityId,
|
||||
selectedEntity,
|
||||
setSelectedEntityId,
|
||||
login,
|
||||
logout,
|
||||
}}
|
||||
|
||||
@@ -20,4 +20,9 @@ export * from "./useStudentProgress";
|
||||
export * from "./useLibrary";
|
||||
export * from "./useActivities";
|
||||
export * from "./useFacilities";
|
||||
export * from "./useExamTemplates";
|
||||
export * from "./useEntityOnboarding";
|
||||
export * from "./useAiCourse";
|
||||
export * from "./useAdaptiveEngine";
|
||||
export * from "./useScoreRelease";
|
||||
export { usePermissions } from "../usePermissions";
|
||||
|
||||
@@ -4,240 +4,306 @@ export const queryKeys = {
|
||||
},
|
||||
users: {
|
||||
all: ["users"] as const,
|
||||
list: (params: Record<string, unknown>) => ["users", "list", params] as const,
|
||||
list: (params: object) => ["users", "list", params] as const,
|
||||
detail: (id: number) => ["users", id] as const,
|
||||
},
|
||||
entities: {
|
||||
all: ["entities"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["entities", "list", params] as const,
|
||||
list: (params?: object) => ["entities", "list", params] as const,
|
||||
detail: (id: number) => ["entities", id] as const,
|
||||
roles: (entityId: number) => ["entities", entityId, "roles"] as const,
|
||||
permissions: (entityId: number) => ["entities", entityId, "permissions"] as const,
|
||||
},
|
||||
exams: {
|
||||
all: ["exams"] as const,
|
||||
list: (module: string, params?: Record<string, unknown>) => ["exams", module, params] as const,
|
||||
list: (module: string, params?: object) => ["exams", module, params] as const,
|
||||
detail: (module: string, id: number) => ["exams", module, id] as const,
|
||||
rubrics: (params?: Record<string, unknown>) => ["exams", "rubrics", params] as const,
|
||||
rubricGroups: (params?: Record<string, unknown>) => ["exams", "rubric-groups", params] as const,
|
||||
structures: (params?: Record<string, unknown>) => ["exams", "structures", params] as const,
|
||||
rubrics: (params?: object) => ["exams", "rubrics", params] as const,
|
||||
rubricGroups: (params?: object) => ["exams", "rubric-groups", params] as const,
|
||||
structures: (params?: object) => ["exams", "structures", params] as const,
|
||||
avatars: ["exams", "avatars"] as const,
|
||||
reviewQueue: (params?: object) => ["exams", "review", "queue", params] as const,
|
||||
reviewDetail: (id: number) => ["exams", "review", "detail", id] as const,
|
||||
},
|
||||
assignments: {
|
||||
all: ["assignments"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["assignments", "list", params] as const,
|
||||
list: (params?: object) => ["assignments", "list", params] as const,
|
||||
detail: (id: number) => ["assignments", id] as const,
|
||||
},
|
||||
classrooms: {
|
||||
all: ["classrooms"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["classrooms", "list", params] as const,
|
||||
list: (params?: object) => ["classrooms", "list", params] as const,
|
||||
detail: (id: number) => ["classrooms", id] as const,
|
||||
},
|
||||
stats: {
|
||||
sessions: (params?: Record<string, unknown>) => ["stats", "sessions", params] as const,
|
||||
stats: (params?: Record<string, unknown>) => ["stats", "stats", params] as const,
|
||||
statistical: (params?: Record<string, unknown>) => ["stats", "statistical", params] as const,
|
||||
performance: (params?: Record<string, unknown>) => ["stats", "performance", params] as const,
|
||||
sessions: (params?: object) => ["stats", "sessions", params] as const,
|
||||
stats: (params?: object) => ["stats", "stats", params] as const,
|
||||
statistical: (params?: object) => ["stats", "statistical", params] as const,
|
||||
performance: (params?: object) => ["stats", "performance", params] as const,
|
||||
},
|
||||
training: {
|
||||
all: ["training"] as const,
|
||||
tips: (params?: Record<string, unknown>) => ["training", "tips", params] as const,
|
||||
tips: (params?: object) => ["training", "tips", params] as const,
|
||||
walkthroughs: (module?: string) => ["training", "walkthroughs", module] as const,
|
||||
},
|
||||
subscriptions: {
|
||||
packages: ["subscriptions", "packages"] as const,
|
||||
payments: (params?: Record<string, unknown>) => ["subscriptions", "payments", params] as const,
|
||||
discounts: (params?: Record<string, unknown>) => ["subscriptions", "discounts", params] as const,
|
||||
payments: (params?: object) => ["subscriptions", "payments", params] as const,
|
||||
discounts: (params?: object) => ["subscriptions", "discounts", params] as const,
|
||||
},
|
||||
tickets: {
|
||||
all: ["tickets"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["tickets", "list", params] as const,
|
||||
list: (params?: object) => ["tickets", "list", params] as const,
|
||||
assigned: ["tickets", "assigned"] as const,
|
||||
},
|
||||
approvals: {
|
||||
all: ["approvals"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["approvals", "list", params] as const,
|
||||
list: (params?: object) => ["approvals", "list", params] as const,
|
||||
},
|
||||
taxonomy: {
|
||||
subjects: ["taxonomy", "subjects"] as const,
|
||||
subject: (id: number) => ["taxonomy", "subjects", id] as const,
|
||||
tree: (subjectId: number) => ["taxonomy", "tree", subjectId] as const,
|
||||
domains: (params?: Record<string, unknown>) => ["taxonomy", "domains", params] as const,
|
||||
topics: (params?: Record<string, unknown>) => ["taxonomy", "topics", params] as const,
|
||||
domains: (params?: object) => ["taxonomy", "domains", params] as const,
|
||||
topics: (params?: object) => ["taxonomy", "topics", params] as const,
|
||||
},
|
||||
adaptive: {
|
||||
proficiency: (params?: Record<string, unknown>) => ["adaptive", "proficiency", params] as const,
|
||||
proficiency: (params?: object) => ["adaptive", "proficiency", params] as const,
|
||||
summary: ["adaptive", "proficiency", "summary"] as const,
|
||||
plan: (params?: Record<string, unknown>) => ["adaptive", "plan", params] as const,
|
||||
plan: (params?: object) => ["adaptive", "plan", params] as const,
|
||||
topicContent: (topicId: number) => ["adaptive", "topic", topicId, "content"] as const,
|
||||
diagnostic: (sessionId: number) => ["adaptive", "diagnostic", sessionId] as const,
|
||||
},
|
||||
resources: {
|
||||
all: ["resources"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["resources", "list", params] as const,
|
||||
list: (params?: object) => ["resources", "list", params] as const,
|
||||
},
|
||||
lms: {
|
||||
courses: (params?: Record<string, unknown>) => ["lms", "courses", params] as const,
|
||||
courses: (params?: object) => ["lms", "courses", params] as const,
|
||||
course: (id: number) => ["lms", "courses", id] as const,
|
||||
students: (params?: Record<string, unknown>) => ["lms", "students", params] as const,
|
||||
teachers: (params?: Record<string, unknown>) => ["lms", "teachers", params] as const,
|
||||
batches: (params?: Record<string, unknown>) => ["lms", "batches", params] as const,
|
||||
myCourses: ["lms", "my-courses"] as const,
|
||||
students: (params?: object) => ["lms", "students", params] as const,
|
||||
teachers: (params?: object) => ["lms", "teachers", params] as const,
|
||||
batches: (params?: object) => ["lms", "batches", params] as const,
|
||||
batch: (id: number) => ["lms", "batches", id] as const,
|
||||
timetable: (params?: Record<string, unknown>) => ["lms", "timetable", params] as const,
|
||||
attendance: (params?: Record<string, unknown>) => ["lms", "attendance", params] as const,
|
||||
grades: (params?: Record<string, unknown>) => ["lms", "grades", params] as const,
|
||||
timetable: (params?: object) => ["lms", "timetable", params] as const,
|
||||
attendance: (params?: object) => ["lms", "attendance", params] as const,
|
||||
grades: (params?: object) => ["lms", "grades", params] as const,
|
||||
},
|
||||
analytics: {
|
||||
student: (params?: Record<string, unknown>) => ["analytics", "student", params] as const,
|
||||
class: (params?: Record<string, unknown>) => ["analytics", "class", params] as const,
|
||||
subject: (params?: Record<string, unknown>) => ["analytics", "subject", params] as const,
|
||||
contentGaps: (params?: Record<string, unknown>) => ["analytics", "content-gaps", params] as const,
|
||||
student: (params?: object) => ["analytics", "student", params] as const,
|
||||
class: (params?: object) => ["analytics", "class", params] as const,
|
||||
subject: (params?: object) => ["analytics", "subject", params] as const,
|
||||
contentGaps: (params?: object) => ["analytics", "content-gaps", params] as const,
|
||||
alerts: ["analytics", "alerts"] as const,
|
||||
},
|
||||
academicYears: {
|
||||
all: ["academic-years"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["academic-years", "list", params] as const,
|
||||
list: (params?: object) => ["academic-years", "list", params] as const,
|
||||
detail: (id: number) => ["academic-years", id] as const,
|
||||
},
|
||||
academicTerms: {
|
||||
all: ["academic-terms"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["academic-terms", "list", params] as const,
|
||||
list: (params?: object) => ["academic-terms", "list", params] as const,
|
||||
detail: (id: number) => ["academic-terms", id] as const,
|
||||
},
|
||||
departments: {
|
||||
all: ["departments"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["departments", "list", params] as const,
|
||||
list: (params?: object) => ["departments", "list", params] as const,
|
||||
detail: (id: number) => ["departments", id] as const,
|
||||
},
|
||||
admissionRegisters: {
|
||||
all: ["admission-registers"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["admission-registers", "list", params] as const,
|
||||
list: (params?: object) => ["admission-registers", "list", params] as const,
|
||||
detail: (id: number) => ["admission-registers", id] as const,
|
||||
},
|
||||
admissions: {
|
||||
all: ["admissions"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["admissions", "list", params] as const,
|
||||
list: (params?: object) => ["admissions", "list", params] as const,
|
||||
detail: (id: number) => ["admissions", id] as const,
|
||||
},
|
||||
instExamSessions: {
|
||||
all: ["inst-exam-sessions"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["inst-exam-sessions", "list", params] as const,
|
||||
list: (params?: object) => ["inst-exam-sessions", "list", params] as const,
|
||||
detail: (id: number) => ["inst-exam-sessions", id] as const,
|
||||
},
|
||||
examTypes: {
|
||||
all: ["exam-types"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["exam-types", "list", params] as const,
|
||||
list: (params?: object) => ["exam-types", "list", params] as const,
|
||||
},
|
||||
gradeConfigs: {
|
||||
all: ["grade-configs"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["grade-configs", "list", params] as const,
|
||||
list: (params?: object) => ["grade-configs", "list", params] as const,
|
||||
},
|
||||
resultTemplates: {
|
||||
all: ["result-templates"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["result-templates", "list", params] as const,
|
||||
list: (params?: object) => ["result-templates", "list", params] as const,
|
||||
},
|
||||
marksheets: {
|
||||
all: ["marksheets"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["marksheets", "list", params] as const,
|
||||
list: (params?: object) => ["marksheets", "list", params] as const,
|
||||
detail: (id: number) => ["marksheets", id] as const,
|
||||
},
|
||||
courseAssignments: {
|
||||
all: ["course-assignments"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["course-assignments", "list", params] as const,
|
||||
list: (params?: object) => ["course-assignments", "list", params] as const,
|
||||
detail: (id: number) => ["course-assignments", id] as const,
|
||||
},
|
||||
assignmentTypes: {
|
||||
all: ["assignment-types"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["assignment-types", "list", params] as const,
|
||||
list: (params?: object) => ["assignment-types", "list", params] as const,
|
||||
},
|
||||
subjectRegistrations: {
|
||||
all: ["subject-registrations"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["subject-registrations", "list", params] as const,
|
||||
available: (params?: Record<string, unknown>) => ["subject-registrations", "available", params] as const,
|
||||
list: (params?: object) => ["subject-registrations", "list", params] as const,
|
||||
available: (params?: object) => ["subject-registrations", "available", params] as const,
|
||||
},
|
||||
courseCompletion: (courseId: number) => ["course-completion", courseId] as const,
|
||||
chapters: (courseId: number) => ["chapters", "list", courseId] as const,
|
||||
chapter: (id: number) => ["chapters", "detail", id] as const,
|
||||
chapterMaterials: (chapterId: number) => ["chapter-materials", chapterId] as const,
|
||||
chapterProgress: (chapterId: number) => ["chapter-progress", chapterId] as const,
|
||||
discussionBoards: (params?: Record<string, unknown>) => ["discussion-boards", params] as const,
|
||||
posts: (boardId: number, params?: Record<string, unknown>) => ["posts", boardId, params] as const,
|
||||
announcements: (params?: Record<string, unknown>) => ["announcements", params] as const,
|
||||
messages: (params?: Record<string, unknown>) => ["messages", params] as const,
|
||||
sentMessages: (params?: Record<string, unknown>) => ["messages", "sent", params] as const,
|
||||
discussionBoards: (params?: object) => ["discussion-boards", params] as const,
|
||||
posts: (boardId: number, params?: object) => ["posts", boardId, params] as const,
|
||||
announcements: (params?: object) => ["announcements", params] as const,
|
||||
messages: (params?: object) => ["messages", params] as const,
|
||||
sentMessages: (params?: object) => ["messages", "sent", params] as const,
|
||||
unreadMessages: ["messages", "unread-count"] as const,
|
||||
notifications: (params?: Record<string, unknown>) => ["notifications", params] as const,
|
||||
notifications: (params?: object) => ["notifications", params] as const,
|
||||
unreadNotifications: ["notifications", "unread-count"] as const,
|
||||
notificationRules: ["notification-rules"] as const,
|
||||
notificationPreferences: ["notification-preferences"] as const,
|
||||
faqCategories: (audience?: string) => ["faq", "categories", audience] as const,
|
||||
faqItems: (params?: Record<string, unknown>) => ["faq", "items", params] as const,
|
||||
faqItems: (params?: object) => ["faq", "items", params] as const,
|
||||
studentLeaves: {
|
||||
all: ["student-leaves"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["student-leaves", "list", params] as const,
|
||||
list: (params?: object) => ["student-leaves", "list", params] as const,
|
||||
},
|
||||
studentLeaveTypes: {
|
||||
all: ["student-leave-types"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["student-leave-types", "list", params] as const,
|
||||
list: (params?: object) => ["student-leave-types", "list", params] as const,
|
||||
},
|
||||
feesPlans: {
|
||||
all: ["fees-plans"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["fees-plans", "list", params] as const,
|
||||
list: (params?: object) => ["fees-plans", "list", params] as const,
|
||||
detail: (id: number) => ["fees-plans", id] as const,
|
||||
},
|
||||
studentFees: {
|
||||
all: ["student-fees"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["student-fees", "list", params] as const,
|
||||
list: (params?: object) => ["student-fees", "list", params] as const,
|
||||
},
|
||||
feesTerms: {
|
||||
all: ["fees-terms"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["fees-terms", "list", params] as const,
|
||||
list: (params?: object) => ["fees-terms", "list", params] as const,
|
||||
},
|
||||
lessons: {
|
||||
all: ["lessons"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["lessons", "list", params] as const,
|
||||
list: (params?: object) => ["lessons", "list", params] as const,
|
||||
},
|
||||
gradebooks: {
|
||||
all: ["gradebooks"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["gradebooks", "list", params] as const,
|
||||
list: (params?: object) => ["gradebooks", "list", params] as const,
|
||||
},
|
||||
gradebookLines: {
|
||||
all: ["gradebook-lines"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["gradebook-lines", "list", params] as const,
|
||||
list: (params?: object) => ["gradebook-lines", "list", params] as const,
|
||||
},
|
||||
gradingAssignments: {
|
||||
all: ["grading-assignments"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["grading-assignments", "list", params] as const,
|
||||
list: (params?: object) => ["grading-assignments", "list", params] as const,
|
||||
},
|
||||
studentProgress: {
|
||||
all: ["student-progress"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["student-progress", "list", params] as const,
|
||||
list: (params?: object) => ["student-progress", "list", params] as const,
|
||||
detail: (id: number) => ["student-progress", "detail", id] as const,
|
||||
},
|
||||
libraryMedia: {
|
||||
all: ["library-media"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["library-media", "list", params] as const,
|
||||
list: (params?: object) => ["library-media", "list", params] as const,
|
||||
},
|
||||
libraryMovements: {
|
||||
all: ["library-movements"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["library-movements", "list", params] as const,
|
||||
list: (params?: object) => ["library-movements", "list", params] as const,
|
||||
},
|
||||
libraryCards: {
|
||||
all: ["library-cards"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["library-cards", "list", params] as const,
|
||||
list: (params?: object) => ["library-cards", "list", params] as const,
|
||||
},
|
||||
activities: {
|
||||
all: ["activities"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["activities", "list", params] as const,
|
||||
list: (params?: object) => ["activities", "list", params] as const,
|
||||
},
|
||||
activityTypes: {
|
||||
all: ["activity-types"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["activity-types", "list", params] as const,
|
||||
list: (params?: object) => ["activity-types", "list", params] as const,
|
||||
},
|
||||
facilities: {
|
||||
all: ["facilities"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["facilities", "list", params] as const,
|
||||
list: (params?: object) => ["facilities", "list", params] as const,
|
||||
},
|
||||
assets: {
|
||||
all: ["assets"] as const,
|
||||
list: (params?: Record<string, unknown>) => ["assets", "list", params] as const,
|
||||
list: (params?: object) => ["assets", "list", params] as const,
|
||||
},
|
||||
signup: {
|
||||
goals: ["signup", "goals"] as const,
|
||||
},
|
||||
placement: {
|
||||
results: (sessionId: string) => ["placement", "results", sessionId] as const,
|
||||
speakingStatus: (sessionId: string) => ["placement", "speaking-status", sessionId] as const,
|
||||
learningPath: (sessionId: string) => ["placement", "learning-path", sessionId] as const,
|
||||
},
|
||||
examTemplates: {
|
||||
international: ["exam-templates", "international"] as const,
|
||||
custom: ["exam-templates", "custom"] as const,
|
||||
detail: (id: number) => ["exam-templates", id] as const,
|
||||
},
|
||||
ieltsExam: {
|
||||
skills: (examId: number) => ["ielts-exam", examId, "skills"] 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: {
|
||||
session: (examId: number) => ["exam-session", examId] as const,
|
||||
status: (examId: number) => ["exam-session", examId, "status"] as const,
|
||||
},
|
||||
grading: {
|
||||
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,
|
||||
},
|
||||
courseGeneration: {
|
||||
gapAnalysis: (source: string, sourceId: number) => ["course-gen", "gap", source, sourceId] as const,
|
||||
course: (id: number) => ["course-gen", "course", id] as const,
|
||||
},
|
||||
aiCourse: {
|
||||
course: (id: number) => ["ai-course", id] as const,
|
||||
tracks: (id: number) => ["ai-course", id, "tracks"] as const,
|
||||
quality: (id: number) => ["ai-course", id, "quality"] as const,
|
||||
ieltsValidation: (id: number) => ["ai-course", id, "ielts-validation"] as const,
|
||||
englishTaxonomy: ["ai-course", "english", "taxonomy"] as const,
|
||||
},
|
||||
entityOnboarding: {
|
||||
credentials: (params?: object) => ["entity-onboarding", "credentials", params] as const,
|
||||
},
|
||||
adaptiveEngine: {
|
||||
dashboard: ["adaptive-engine", "dashboard"] 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,
|
||||
},
|
||||
levelMapping: {
|
||||
get: (entityId: number) => ["level-mapping", entityId] as const,
|
||||
},
|
||||
branding: {
|
||||
get: (entityId: number) => ["branding", entityId] as const,
|
||||
current: ["branding", "current"] as const,
|
||||
},
|
||||
scoreRelease: {
|
||||
pending: (params?: object) => ["score-release", "pending", params] as const,
|
||||
},
|
||||
verification: {
|
||||
verify: (hash: string) => ["verification", hash] as const,
|
||||
},
|
||||
};
|
||||
|
||||
70
src/hooks/queries/useAIFeedback.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { aiFeedbackService } from "@/services/ai-feedback.service";
|
||||
import type {
|
||||
AIFeedbackStatus,
|
||||
AIFeedbackSubjectType,
|
||||
AIFeedbackSubmitInput,
|
||||
} from "@/types/ai-feedback";
|
||||
|
||||
const KEY_ROOT = ["ai", "feedback"] as const;
|
||||
|
||||
export function useAIFeedbackSummary(
|
||||
subjectType: AIFeedbackSubjectType | undefined,
|
||||
subjectId: number | undefined,
|
||||
) {
|
||||
return useQuery({
|
||||
enabled: !!subjectType && !!subjectId,
|
||||
queryKey: [...KEY_ROOT, "summary", subjectType, subjectId] as const,
|
||||
queryFn: () =>
|
||||
aiFeedbackService.summary(
|
||||
subjectType as AIFeedbackSubjectType,
|
||||
subjectId as number,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubmitAIFeedback() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: AIFeedbackSubmitInput) => aiFeedbackService.submit(input),
|
||||
onSuccess: (_res, vars) => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: [...KEY_ROOT, "summary", vars.subject_type, vars.subject_id],
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: [...KEY_ROOT, "list"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAIFeedbackList(
|
||||
params: {
|
||||
status?: AIFeedbackStatus;
|
||||
rating?: "up" | "down";
|
||||
subject_type?: AIFeedbackSubjectType;
|
||||
prompt_key?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
} = {},
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [...KEY_ROOT, "list", params] as const,
|
||||
queryFn: () => aiFeedbackService.list(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useResolveAIFeedback() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
status,
|
||||
notes,
|
||||
}: {
|
||||
id: number;
|
||||
status: Exclude<AIFeedbackStatus, "open">;
|
||||
notes?: string;
|
||||
}) => aiFeedbackService.resolve(id, status, notes),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
|
||||
});
|
||||
}
|
||||
52
src/hooks/queries/useAIPrompts.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { aiPromptService } from "@/services/ai-prompt.service";
|
||||
import type { AIPromptCreateInput } from "@/types/ai-prompt";
|
||||
|
||||
const KEY_ROOT = ["ai", "prompts"] as const;
|
||||
|
||||
export function useAIPromptKeys(params: { search?: string; page?: number; size?: number } = {}) {
|
||||
return useQuery({
|
||||
queryKey: [...KEY_ROOT, "keys", params] as const,
|
||||
queryFn: () => aiPromptService.listKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAIPromptVersions(key: string | undefined) {
|
||||
return useQuery({
|
||||
enabled: !!key,
|
||||
queryKey: [...KEY_ROOT, "versions", key] as const,
|
||||
queryFn: () => aiPromptService.listVersions(key as string),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAIPrompt(id: number | undefined) {
|
||||
return useQuery({
|
||||
enabled: !!id,
|
||||
queryKey: [...KEY_ROOT, "detail", id] as const,
|
||||
queryFn: () => aiPromptService.get(id as number),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAIPrompt() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: AIPromptCreateInput) => aiPromptService.create(input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useActivateAIPrompt() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => aiPromptService.activate(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRenderAIPrompt() {
|
||||
return useMutation({
|
||||
mutationFn: ({ id, variables }: { id: number; variables: Record<string, string> }) =>
|
||||
aiPromptService.render(id, variables),
|
||||
});
|
||||
}
|
||||
@@ -66,7 +66,10 @@ export function useGenerateTerms() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (yearId: number) => academicService.generateTerms(yearId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.academicTerms.all }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.academicTerms.all });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.academicYears.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
51
src/hooks/queries/useAdaptiveEngine.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "./keys";
|
||||
import { adaptiveEngineService } from "@/services/adaptive-engine.service";
|
||||
import type { AdaptiveThresholdSettings } from "@/types";
|
||||
|
||||
export function useAdaptiveDashboard() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.adaptiveEngine.dashboard,
|
||||
queryFn: () => adaptiveEngineService.getDashboard(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdaptiveStudents(params?: { page?: number; limit?: number }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.adaptiveEngine.students(params as Record<string, unknown> | undefined),
|
||||
queryFn: () => adaptiveEngineService.getStudents(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useStudentSignals(studentId: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.adaptiveEngine.signals(studentId ?? 0),
|
||||
queryFn: () => adaptiveEngineService.getStudentSignals(studentId!),
|
||||
enabled: !!studentId && studentId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useStudentAbility(studentId: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.adaptiveEngine.ability(studentId ?? 0),
|
||||
queryFn: () => adaptiveEngineService.getStudentAbility(studentId!),
|
||||
enabled: !!studentId && studentId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdaptiveSettings() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.adaptiveEngine.settings,
|
||||
queryFn: () => adaptiveEngineService.getSettings(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAdaptiveSettings() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: AdaptiveThresholdSettings) => adaptiveEngineService.updateSettings(data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.adaptiveEngine.settings });
|
||||
},
|
||||
});
|
||||
}
|
||||
101
src/hooks/queries/useAiCourse.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "./keys";
|
||||
import {
|
||||
aiCourseService,
|
||||
type AiCourseCreateEnglishRequest,
|
||||
type AiCourseCreateIeltsRequest,
|
||||
} from "@/services/ai-course.service";
|
||||
|
||||
export function useAiCourse(courseId: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.aiCourse.course(courseId ?? 0),
|
||||
queryFn: () => aiCourseService.getCourse(courseId!),
|
||||
enabled: !!courseId && courseId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAiCourseTracks(courseId: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.aiCourse.tracks(courseId ?? 0),
|
||||
queryFn: () => aiCourseService.getTracks(courseId!),
|
||||
enabled: !!courseId && courseId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateEnglishCourse() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: AiCourseCreateEnglishRequest) =>
|
||||
aiCourseService.createEnglish(data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateIeltsCourse() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: AiCourseCreateIeltsRequest) =>
|
||||
aiCourseService.createIelts(data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useQualityGate(courseId: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.aiCourse.quality(courseId ?? 0),
|
||||
queryFn: () => aiCourseService.getQualityGate(courseId!),
|
||||
enabled: !!courseId && courseId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useApproveQuality() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (courseId: number) => aiCourseService.approveQuality(courseId),
|
||||
onSuccess: (_d, courseId) => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.quality(courseId) });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.course(courseId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRejectQuality() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ courseId, reason }: { courseId: number; reason: string }) =>
|
||||
aiCourseService.rejectQuality(courseId, reason),
|
||||
onSuccess: (_d, { courseId }) => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.quality(courseId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useEnglishTaxonomy() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.aiCourse.englishTaxonomy,
|
||||
queryFn: () => aiCourseService.getEnglishTaxonomy(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useIeltsValidation(courseId: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.aiCourse.ieltsValidation(courseId ?? 0),
|
||||
queryFn: () => aiCourseService.getIeltsValidation(courseId!),
|
||||
enabled: !!courseId && courseId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubmitExaminerReview() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { logId: number; action: string; examiner_notes?: string }) =>
|
||||
aiCourseService.submitExaminerReview(data.logId, { action: data.action, examiner_notes: data.examiner_notes }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["ai-course"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
48
src/hooks/queries/useCourseGeneration.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "./keys";
|
||||
import { courseGenerationService } from "@/services/course-generation.service";
|
||||
import type { CourseConfig, CourseProgress } from "@/types";
|
||||
|
||||
export function useGapAnalysis(source: "exam" | "placement", sourceId: number) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.courseGeneration.gapAnalysis(source, sourceId),
|
||||
queryFn: () => courseGenerationService.getGapAnalysis(source, sourceId),
|
||||
enabled: !!sourceId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAutoGenerateCourse() {
|
||||
return useMutation({
|
||||
mutationFn: (sourceId: number) => courseGenerationService.autoGenerate(sourceId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCourse() {
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<CourseConfig>) => courseGenerationService.create(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCourseDetail(courseId: number) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.courseGeneration.course(courseId),
|
||||
queryFn: () => courseGenerationService.getCourse(courseId),
|
||||
enabled: !!courseId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCourse() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { courseId: number; payload: Partial<CourseConfig> }) =>
|
||||
courseGenerationService.updateCourse(data.courseId, data.payload),
|
||||
onSuccess: (_, vars) => qc.invalidateQueries({ queryKey: queryKeys.courseGeneration.course(vars.courseId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTrackProgress() {
|
||||
return useMutation({
|
||||
mutationFn: (data: { courseId: number; progress: CourseProgress }) =>
|
||||
courseGenerationService.trackProgress(data.courseId, data.progress),
|
||||
});
|
||||
}
|
||||
@@ -120,6 +120,19 @@ export function useUploadMaterial() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateMaterialFromResource() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ chapterId, resourceId, name }: { chapterId: number; resourceId: number; name?: string }) =>
|
||||
coursewareService.createMaterialFromResource(chapterId, resourceId, name),
|
||||
onSuccess: (_, { chapterId }) => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.chapterMaterials(chapterId) });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.chapterProgress(chapterId) });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.chapter(chapterId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMaterial() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -145,13 +158,24 @@ export function useMarkMaterialViewed() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCourseCompletion(courseId: number) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.courseCompletion(courseId),
|
||||
queryFn: () => coursewareService.getCourseCompletion(courseId),
|
||||
enabled: !!courseId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCompleteChapter() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (chapterId: number) => coursewareService.markChapterComplete(chapterId),
|
||||
onSuccess: (_, chapterId) => {
|
||||
mutationFn: ({ chapterId, courseId }: { chapterId: number; courseId: number }) =>
|
||||
coursewareService.markChapterComplete(chapterId),
|
||||
onSuccess: (_, { chapterId, courseId }) => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.chapterProgress(chapterId) });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.chapter(chapterId) });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.courseCompletion(courseId) });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.lms.myCourses });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
57
src/hooks/queries/useEntityOnboarding.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "./keys";
|
||||
import { entityOnboardingService } from "@/services/entity-onboarding.service";
|
||||
import type { CredentialFilters } from "@/types";
|
||||
|
||||
export function useValidateCSV() {
|
||||
return useMutation({
|
||||
mutationFn: (file: File) => entityOnboardingService.validateCsv(file),
|
||||
});
|
||||
}
|
||||
|
||||
export function useBulkCreate() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: { validate_session_id?: string }) => entityOnboardingService.bulkCreate(payload),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["entity-onboarding"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendCredentials() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (studentIds: number[]) => entityOnboardingService.sendCredentials(studentIds),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.entityOnboarding.credentials() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCredentialStatuses(filters?: CredentialFilters & { page?: number; limit?: number }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.entityOnboarding.credentials(filters as Record<string, unknown> | undefined),
|
||||
queryFn: () => entityOnboardingService.getCredentialStatuses(filters),
|
||||
});
|
||||
}
|
||||
|
||||
export function useResendCredential() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (studentId: number) => entityOnboardingService.resendCredential(studentId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["entity-onboarding", "credentials"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useResendAllPendingCredentials() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => entityOnboardingService.resendAllPending(),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["entity-onboarding", "credentials"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
35
src/hooks/queries/useExamSession.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { queryKeys } from "./keys";
|
||||
import { examSessionService } from "@/services/exam-session.service";
|
||||
import type { ExamAutoSave } from "@/types";
|
||||
|
||||
export function useExamSession(examId: number) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.examSession.session(examId),
|
||||
queryFn: () => examSessionService.getSession(examId),
|
||||
enabled: !!examId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useExamStatus(examId: number, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.examSession.status(examId),
|
||||
queryFn: () => examSessionService.getStatus(examId),
|
||||
enabled: !!examId && enabled,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useExamAutoSave() {
|
||||
return useMutation({
|
||||
mutationFn: (data: { examId: number; payload: ExamAutoSave }) =>
|
||||
examSessionService.autoSave(data.examId, data.payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function useExamSubmit() {
|
||||
return useMutation({
|
||||
mutationFn: (data: { examId: number; attempt_id: number; answers: { question_id: number; answer: unknown }[] }) =>
|
||||
examSessionService.submit(data.examId, { attempt_id: data.attempt_id, answers: data.answers }),
|
||||
});
|
||||
}
|
||||
85
src/hooks/queries/useExamTemplates.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "./keys";
|
||||
import { examTemplateService } from "@/services/exam-template.service";
|
||||
import { ieltsExamService } from "@/services/ielts-exam.service";
|
||||
import { customExamService } from "@/services/custom-exam.service";
|
||||
import type { IELTSExamConfig, ContentPoolFilters, ExamAssignRequest, CustomExamCreateRequest, SaveAsTemplateRequest } from "@/types";
|
||||
|
||||
export function useInternationalTemplates() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.examTemplates.international,
|
||||
queryFn: examTemplateService.listInternational,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCustomTemplates() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.examTemplates.custom,
|
||||
queryFn: examTemplateService.listCustom,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateIeltsExam() {
|
||||
return useMutation({
|
||||
mutationFn: (data: IELTSExamConfig) => ieltsExamService.create(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useIeltsSkills(examId: number) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.ieltsExam.skills(examId),
|
||||
queryFn: () => ieltsExamService.getSkills(examId),
|
||||
enabled: !!examId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useIeltsContentPool(examId: number, filters: ContentPoolFilters) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.ieltsExam.contentPool(examId, filters),
|
||||
queryFn: () => ieltsExamService.getContentPool(examId, filters),
|
||||
enabled: !!examId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useIeltsAutoAssemble() {
|
||||
return useMutation({
|
||||
mutationFn: (examId: number) => ieltsExamService.autoAssemble(examId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useIeltsExamValidation(examId: number) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.ieltsExam.validation(examId),
|
||||
queryFn: () => ieltsExamService.validate(examId),
|
||||
enabled: !!examId,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePublishIeltsExam() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (examId: number) => ieltsExamService.update(examId, { status: "published" }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["ielts-exam"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssignIeltsExam() {
|
||||
return useMutation({
|
||||
mutationFn: (data: { examId: number; assignment: ExamAssignRequest }) =>
|
||||
ieltsExamService.assign(data.examId, data.assignment),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCustomExam() {
|
||||
return useMutation({
|
||||
mutationFn: (data: CustomExamCreateRequest) => customExamService.create(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveAsTemplate() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: SaveAsTemplateRequest) => examTemplateService.saveCustomTemplate(data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.examTemplates.custom }),
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { queryKeys } from "./keys";
|
||||
import { feesService } from "@/services/fees.service";
|
||||
import { feesService, type FeesPlanFilters } from "@/services/fees.service";
|
||||
import type { PaginationParams } from "@/types";
|
||||
|
||||
export function useFeesPlans(params?: PaginationParams) {
|
||||
export function useFeesPlans(params?: FeesPlanFilters) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.feesPlans.list(params),
|
||||
queryFn: () => feesService.listFeesPlans(params),
|
||||
@@ -18,7 +18,7 @@ export function useFeesPlan(id: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useStudentFees(params?: PaginationParams) {
|
||||
export function useStudentFees(params?: FeesPlanFilters) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.studentFees.list(params),
|
||||
queryFn: () => feesService.listStudentFees(params),
|
||||
|
||||
@@ -11,10 +11,11 @@ export function useGradebooks(params?: PaginationParams) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useGradebookLines(params?: PaginationParams) {
|
||||
export function useGradebookLines(params?: PaginationParams & { gradebook_id?: number }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.gradebookLines.list(params),
|
||||
queryFn: () => gradebookService.listGradebookLines(params),
|
||||
enabled: params?.gradebook_id ? !!params.gradebook_id : true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
42
src/hooks/queries/useGrading.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "./keys";
|
||||
import { gradingService } from "@/services/grading.service";
|
||||
import type { GradeSubmission } from "@/types";
|
||||
|
||||
export function useGradingQueue(params?: { skill?: string; status?: string; exam_id?: number }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.grading.queue(params),
|
||||
queryFn: () => gradingService.getQueue(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useStudentResponse(attemptId: number, skill: string) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.grading.response(attemptId, skill),
|
||||
queryFn: () => gradingService.getStudentResponse(attemptId, skill),
|
||||
enabled: !!attemptId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGradingRubric(attemptId: number, skill: string) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.grading.rubric(attemptId, skill),
|
||||
queryFn: () => gradingService.getRubric(attemptId, skill),
|
||||
enabled: !!attemptId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAIGradeSuggestion() {
|
||||
return useMutation({
|
||||
mutationFn: (data: { attemptId: number; skill: string }) =>
|
||||
gradingService.getAISuggestion(data.attemptId, data.skill),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubmitGrade() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: GradeSubmission) => gradingService.submitGrade(data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["grading"] }),
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "./keys";
|
||||
import { lmsService } from "@/services";
|
||||
import type { PaginationParams, CourseCreateRequest, LmsStudentCreateRequest, LmsTeacherCreateRequest } from "@/types";
|
||||
import type { PaginationParams, CourseCreateRequest, LmsStudentCreateRequest, LmsTeacherCreateRequest, EnrollStudentRequest, BulkEnrollRequest } from "@/types";
|
||||
|
||||
export function useCourses(params?: PaginationParams & { status?: string }) {
|
||||
return useQuery({
|
||||
@@ -26,6 +26,38 @@ export function useCreateCourse() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useMyEnrolledCourses() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.lms.myCourses,
|
||||
queryFn: () => lmsService.getMyEnrolledCourses(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useEnrollStudent() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ studentId, data }: { studentId: number; data: EnrollStudentRequest }) =>
|
||||
lmsService.enrollStudent(studentId, data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["lms", "students"] });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.lms.myCourses });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useBulkEnroll() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ courseId, data }: { courseId: number; data: BulkEnrollRequest }) =>
|
||||
lmsService.bulkEnrollInCourse(courseId, data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["lms", "students"] });
|
||||
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.lms.myCourses });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useStudents(params?: PaginationParams & { search?: string; batch_id?: number }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.lms.students(params),
|
||||
|
||||
54
src/hooks/queries/usePlacement.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { queryKeys } from "./keys";
|
||||
import { placementService } from "@/services/placement.service";
|
||||
import type { PlacementSubject, CATAnswer, CATAutoSave } from "@/types";
|
||||
|
||||
export function usePlacementStart() {
|
||||
return useMutation({
|
||||
mutationFn: (subject: PlacementSubject) => placementService.start(subject),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlacementAnswer() {
|
||||
return useMutation({
|
||||
mutationFn: (data: CATAnswer) => placementService.submitAnswer(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlacementAutoSave() {
|
||||
return useMutation({
|
||||
mutationFn: (data: CATAutoSave) => placementService.autoSave(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlacementResults(sessionId: string) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.placement.results(sessionId),
|
||||
queryFn: () => placementService.getResults(sessionId),
|
||||
enabled: !!sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSpeakingStatus(sessionId: string, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.placement.speakingStatus(sessionId),
|
||||
queryFn: () => placementService.getSpeakingStatus(sessionId),
|
||||
enabled: !!sessionId && enabled,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLearningPath(sessionId: string) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.placement.learningPath(sessionId),
|
||||
queryFn: () => placementService.getLearningPath(sessionId),
|
||||
enabled: !!sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSpeakingUpload() {
|
||||
return useMutation({
|
||||
mutationFn: (data: { sessionId: string; promptId: number; audioFile: File }) =>
|
||||
placementService.uploadSpeaking(data.sessionId, data.promptId, data.audioFile),
|
||||
});
|
||||
}
|
||||
41
src/hooks/queries/useScoreRelease.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "./keys";
|
||||
import { scoreReleaseService } from "@/services/score-release.service";
|
||||
|
||||
export function usePendingScores(params?: { page?: number; limit?: number }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.scoreRelease.pending(params as Record<string, unknown> | undefined),
|
||||
queryFn: () => scoreReleaseService.getPending(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useReleaseScore() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (attemptId: number) => scoreReleaseService.release(attemptId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["score-release", "pending"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRejectScore() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ attemptId, reason }: { attemptId: number; reason: string }) =>
|
||||
scoreReleaseService.reject(attemptId, reason),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["score-release", "pending"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useBulkRelease() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (attemptIds: number[]) => scoreReleaseService.bulkRelease(attemptIds),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["score-release", "pending"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
41
src/hooks/queries/useSignup.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { queryKeys } from "./keys";
|
||||
import { signupService } from "@/services/signup.service";
|
||||
import type { RegisterRequest, VerifyEmailRequest, OnboardingData } from "@/types";
|
||||
|
||||
export function useGoals() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.signup.goals,
|
||||
queryFn: signupService.getGoals,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRegister() {
|
||||
return useMutation({
|
||||
mutationFn: (data: RegisterRequest) => signupService.register(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useVerifyEmail() {
|
||||
return useMutation({
|
||||
mutationFn: (data: VerifyEmailRequest) => signupService.verifyEmail(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useResendOtp() {
|
||||
return useMutation({
|
||||
mutationFn: (email: string) => signupService.resendOtp({ email }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCheckEmail() {
|
||||
return useMutation({
|
||||
mutationFn: (email: string) => signupService.checkEmail(email),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCompleteOnboarding() {
|
||||
return useMutation({
|
||||
mutationFn: (data: OnboardingData) => signupService.completeOnboarding(data),
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { queryKeys } from "./keys";
|
||||
import { studentProgressService } from "@/services/student-progress.service";
|
||||
import type { PaginationParams } from "@/types";
|
||||
import { studentProgressService, type StudentProgressFilters } from "@/services/student-progress.service";
|
||||
|
||||
export function useStudentProgressList(params?: PaginationParams) {
|
||||
export function useStudentProgressList(params?: StudentProgressFilters) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.studentProgress.list(params),
|
||||
queryFn: () => studentProgressService.listProgress(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useStudentProgressDetail(id: number | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.studentProgress.detail(id ?? 0),
|
||||
queryFn: () => studentProgressService.getProgress(id as number),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
80
src/i18n/index.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* EnCoach i18n bootstrap.
|
||||
*
|
||||
* We keep the translation strings in TypeScript modules (one per locale) so
|
||||
* they go through type-checking and tree-shaking, and so non-translators
|
||||
* can't ship a broken JSON file that blocks the build.
|
||||
*
|
||||
* Language selection order (product decision: English is the default UI
|
||||
* language at startup; Arabic is opt-in via the language toggle):
|
||||
* 1. localStorage key ``encoach-lang`` (explicit user pick, persisted)
|
||||
* 2. ``en`` — fallback used on first visit, regardless of browser locale
|
||||
*
|
||||
* We intentionally do **not** read ``navigator.language`` anymore: an
|
||||
* Arabic-locale browser should still land on the English UI the first time
|
||||
* a user visits, because the QA team and the product manager both sign off
|
||||
* against the English baseline. Users that actively want Arabic flip the
|
||||
* toggle once and the preference is remembered from then on.
|
||||
*
|
||||
* RTL handling: whenever the active language switches to one of RTL_LANGS,
|
||||
* we flip ``document.documentElement.dir`` so Tailwind's RTL utilities +
|
||||
* Radix primitives pick up the change automatically.
|
||||
*/
|
||||
|
||||
import i18n from "i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import ar from "./locales/ar";
|
||||
import en from "./locales/en";
|
||||
|
||||
const RTL_LANGS = new Set(["ar", "he", "fa", "ur"]);
|
||||
|
||||
export const SUPPORTED_LANGS = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "ar", label: "العربية" },
|
||||
] as const;
|
||||
|
||||
export type SupportedLang = (typeof SUPPORTED_LANGS)[number]["code"];
|
||||
|
||||
export const DEFAULT_LANG: SupportedLang = "en";
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
ar: { translation: ar },
|
||||
},
|
||||
// Explicit default — english is the baseline UI language at startup.
|
||||
lng: DEFAULT_LANG,
|
||||
fallbackLng: DEFAULT_LANG,
|
||||
supportedLngs: SUPPORTED_LANGS.map((l) => l.code),
|
||||
interpolation: { escapeValue: false },
|
||||
detection: {
|
||||
// Only honour the user's saved pick. Navigator/htmlTag detection was
|
||||
// removed so Arabic-locale browsers don't silently boot the UI in
|
||||
// Arabic before the user has chosen.
|
||||
order: ["localStorage"],
|
||||
caches: ["localStorage"],
|
||||
lookupLocalStorage: "encoach-lang",
|
||||
},
|
||||
returnEmptyString: false,
|
||||
});
|
||||
|
||||
export function applyDirectionForLang(lang: string) {
|
||||
const base = lang.split("-")[0];
|
||||
const dir = RTL_LANGS.has(base) ? "rtl" : "ltr";
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.dir = dir;
|
||||
document.documentElement.lang = base;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
applyDirectionForLang(i18n.language || "en");
|
||||
i18n.on("languageChanged", applyDirectionForLang);
|
||||
}
|
||||
|
||||
export default i18n;
|
||||
1131
src/i18n/locales/ar.ts
Normal file
1193
src/i18n/locales/en.ts
Normal file
114
src/index.css
@@ -51,6 +51,14 @@
|
||||
--sidebar-accent-foreground: 8 40% 78%;
|
||||
--sidebar-border: 240 18% 20%;
|
||||
--sidebar-ring: 8 50% 58%;
|
||||
|
||||
/* Chart palette — used by Recharts via hsl(var(--chart-N)). Kept on the
|
||||
warm/cool axis so pairs read well together when stacked. */
|
||||
--chart-1: 8 50% 54%;
|
||||
--chart-2: 220 70% 52%;
|
||||
--chart-3: 152 60% 42%;
|
||||
--chart-4: 38 92% 50%;
|
||||
--chart-5: 280 55% 55%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -99,6 +107,12 @@
|
||||
--sidebar-accent-foreground: 8 40% 75%;
|
||||
--sidebar-border: 240 18% 13%;
|
||||
--sidebar-ring: 8 50% 55%;
|
||||
|
||||
--chart-1: 8 52% 62%;
|
||||
--chart-2: 220 65% 65%;
|
||||
--chart-3: 152 50% 55%;
|
||||
--chart-4: 38 75% 60%;
|
||||
--chart-5: 280 55% 68%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,3 +134,103 @@
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* RTL / Arabic support
|
||||
*
|
||||
* `tailwindcss-rtl` handles the bulk of the work: physical margin / padding /
|
||||
* position / border / rounded / text-align / space-x utilities are mirrored
|
||||
* automatically when `html[dir="rtl"]` is active.
|
||||
*
|
||||
* Below we cover the things Tailwind can't: the webfont (Inter has weak
|
||||
* Arabic glyphs, so swap in Cairo), recharts tooltips/axes which have their
|
||||
* own DOM, and a few icons that should NOT mirror (arrows used as pure
|
||||
* visual affordance e.g. ChevronRight in a dropdown).
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
html[dir="rtl"] body,
|
||||
html[dir="rtl"] h1,
|
||||
html[dir="rtl"] h2,
|
||||
html[dir="rtl"] h3,
|
||||
html[dir="rtl"] h4,
|
||||
html[dir="rtl"] h5,
|
||||
html[dir="rtl"] h6,
|
||||
html[dir="rtl"] input,
|
||||
html[dir="rtl"] textarea,
|
||||
html[dir="rtl"] button,
|
||||
html[dir="rtl"] select {
|
||||
font-family: 'Cairo', 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html[dir="rtl"] code,
|
||||
html[dir="rtl"] pre {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .recharts-wrapper,
|
||||
html[dir="rtl"] .recharts-legend-wrapper {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
/* Breadcrumb and submenu chevrons: always pointing "forward" in reading
|
||||
direction. Lucide's ChevronRight is ">" which is forward in LTR, but in
|
||||
RTL the same arrow must become "<". We flip via CSS so no component has
|
||||
to know about direction. */
|
||||
html[dir="rtl"] nav[aria-label="breadcrumb"] li[role="presentation"] > svg,
|
||||
html[dir="rtl"] [data-radix-menu-content] [role="menuitem"] > svg:last-child,
|
||||
html[dir="rtl"] [data-radix-popper-content-wrapper] [role="menuitem"] > svg:last-child {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Numbers, percentages, emails, URLs, dates should stay LTR-isolated even
|
||||
when embedded in Arabic paragraphs, otherwise slashes / percent signs /
|
||||
dashes drift to the wrong side and the value becomes unreadable.
|
||||
<bdi> already gives us this per-element; the helper classes below are
|
||||
for places where adding a wrapper element is awkward. */
|
||||
.ltr-nums,
|
||||
.dir-ltr {
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
/* Pure-number cells & badges align to the end (right in LTR, left in RTL)
|
||||
but the digits themselves still read left-to-right. */
|
||||
html[dir="rtl"] .numeric {
|
||||
text-align: end;
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
/* Some lucide icons are pure affordances (play, external-link, send) and
|
||||
should NOT be mirrored even if tailwindcss-rtl would otherwise flip the
|
||||
button that contains them. Authors opt-in with data-no-flip. */
|
||||
html[dir="rtl"] [data-no-flip] {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* Keep code snippets, identifiers, and file paths readable in RTL. */
|
||||
html[dir="rtl"] code,
|
||||
html[dir="rtl"] pre,
|
||||
html[dir="rtl"] kbd,
|
||||
html[dir="rtl"] samp,
|
||||
html[dir="rtl"] [data-monospace] {
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
/* Scrollbars inside horizontally-scrollable tables already flip naturally
|
||||
in RTL, but the shadow Radix portals (dropdowns, tooltips, dialogs) use
|
||||
`right-0` / `left-0` positioning computed in JS. For safety, make sure
|
||||
they inherit the document direction. */
|
||||
html[dir="rtl"] [data-radix-popper-content-wrapper] {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
/* Inputs with dir="ltr" (email, password, URL) override but keep the
|
||||
placeholder aligned to the visual start (right in RTL, left in LTR). */
|
||||
html[dir="rtl"] input[dir="ltr"]::placeholder,
|
||||
html[dir="rtl"] textarea[dir="ltr"]::placeholder {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
/** Base path or absolute URL for Odoo JSON API (dev: `/api` + Vite proxy). */
|
||||
/**
|
||||
* Odoo REST client with transparent access-token rotation.
|
||||
*
|
||||
* Tokens live in ``localStorage`` under three keys:
|
||||
* - ``encoach_token`` — short-lived access JWT (1h)
|
||||
* - ``encoach_refresh_token`` — long-lived refresh JWT (7d)
|
||||
* - ``encoach_token_exp`` — epoch seconds for the access token
|
||||
*
|
||||
* When a request receives ``401`` *and* a refresh token is present, the client
|
||||
* silently rotates the pair via ``POST /api/auth/refresh`` and retries the
|
||||
* original request exactly once. Multiple concurrent 401s coalesce onto the
|
||||
* same refresh promise so we never fire more than one rotation in flight.
|
||||
*
|
||||
* Motivation: before this change, every expired access token forced a full
|
||||
* re-login, which interrupted long admin dashboards (stats, reports) multiple
|
||||
* times per hour. With the refresh loop, access tokens can be short-lived
|
||||
* (1h) without hurting UX, giving us the security benefit of short access
|
||||
* windows without an eager logout.
|
||||
*/
|
||||
|
||||
export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL?.trim() || "/api").replace(/\/$/, "");
|
||||
const BASE_URL = API_BASE_URL;
|
||||
|
||||
@@ -21,120 +40,387 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem("encoach_token");
|
||||
/**
|
||||
* Turn an arbitrary thrown value from an API mutation into a human-readable,
|
||||
* actionable toast description. Deployment QA kept reporting generic
|
||||
* "Submit failed" / "Upload failed" toasts — this helper surfaces the HTTP
|
||||
* status and, when we recognise the code, a hint about what to do next
|
||||
* (payload too big → ask ops to raise nginx, timeout → retry, etc.).
|
||||
*
|
||||
* @param err the error thrown by an `api.*` call (usually an ApiError)
|
||||
* @param fallback text to show if we can't classify the error
|
||||
* @param context short context word used in the 413/504 hints
|
||||
* ("file", "request", "payload" …)
|
||||
*/
|
||||
export function describeApiError(
|
||||
err: unknown,
|
||||
fallback = "Something went wrong",
|
||||
context = "request",
|
||||
): string {
|
||||
const e = err as { status?: number; message?: string; data?: unknown } | null;
|
||||
const status = e?.status;
|
||||
// Try to extract a server-side error body even if the generic ApiError
|
||||
// message wasn't populated (nginx 413/504 respond with an HTML page so
|
||||
// `data.error` is empty and `message` looks like "413 ").
|
||||
const serverMsg =
|
||||
e?.data && typeof e.data === "object" && "error" in (e.data as object)
|
||||
? String((e.data as { error: unknown }).error || "").trim()
|
||||
: "";
|
||||
|
||||
if (status === 413) {
|
||||
return `The ${context} is too large for the server to accept (HTTP 413). Try a smaller file / fewer tasks, or ask an admin to raise the nginx \`client_max_body_size\` and Odoo \`limit_request\`.`;
|
||||
}
|
||||
if (status === 504) {
|
||||
return `The server took too long to respond (HTTP 504). The ${context} may still be processing — wait a minute and reload before retrying.`;
|
||||
}
|
||||
if (status === 502) {
|
||||
return "The backend is unreachable (HTTP 502). Odoo may be restarting — try again in a moment.";
|
||||
}
|
||||
if (status === 401) {
|
||||
return "Your session expired — please sign in again.";
|
||||
}
|
||||
if (status === 403) {
|
||||
return serverMsg || "You don't have permission to do that (HTTP 403).";
|
||||
}
|
||||
if (status === 404) {
|
||||
return serverMsg || "The requested item no longer exists (HTTP 404).";
|
||||
}
|
||||
if (status === 422 || status === 400) {
|
||||
return serverMsg || `The server rejected the ${context} as invalid (HTTP ${status}).`;
|
||||
}
|
||||
if (status && status >= 500) {
|
||||
return `${serverMsg || "The server returned an error"} (HTTP ${status}). Check the Odoo logs for a stack trace.`;
|
||||
}
|
||||
|
||||
// Network failure / fetch aborted / CORS — ApiError not thrown at all.
|
||||
if (!status && e?.message) return e.message;
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const ACCESS_KEY = "encoach_token";
|
||||
const REFRESH_KEY = "encoach_refresh_token";
|
||||
const EXP_KEY = "encoach_token_exp";
|
||||
const ENTITY_KEY = "encoach_entity_id";
|
||||
|
||||
function getAccessToken(): string | null {
|
||||
return localStorage.getItem(ACCESS_KEY);
|
||||
}
|
||||
|
||||
export function getActiveEntityId(): number | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(ENTITY_KEY);
|
||||
if (!raw) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setActiveEntityId(entityId: number | null | undefined): void {
|
||||
try {
|
||||
if (entityId && Number.isFinite(entityId)) {
|
||||
localStorage.setItem(ENTITY_KEY, String(Math.floor(entityId)));
|
||||
} else {
|
||||
localStorage.removeItem(ENTITY_KEY);
|
||||
}
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a URL to a media-streaming endpoint with the JWT attached as a
|
||||
* query parameter. Used by ``<img>`` / ``<audio>`` / ``<video>`` tags
|
||||
* (which can't attach custom Authorization headers) and by ``<a download>``
|
||||
* tags so the browser can fetch the binary directly without going through
|
||||
* a fetch + blob URL dance.
|
||||
*
|
||||
* Returns the original path unchanged when no token is stored, which lets
|
||||
* callers render a placeholder rather than crashing on ``null``.
|
||||
*/
|
||||
export function withAuthQuery(path: string): string {
|
||||
if (!path) return path;
|
||||
const token = getAccessToken();
|
||||
if (!token) return path;
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
return `${path}${sep}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
function getRefreshToken(): string | null {
|
||||
return localStorage.getItem(REFRESH_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem("encoach_token", token);
|
||||
localStorage.setItem(ACCESS_KEY, token);
|
||||
}
|
||||
|
||||
export function setRefreshToken(token: string | null | undefined): void {
|
||||
if (token) {
|
||||
localStorage.setItem(REFRESH_KEY, token);
|
||||
} else {
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function setTokenExpiry(epochSeconds: number | null | undefined): void {
|
||||
if (epochSeconds && Number.isFinite(epochSeconds)) {
|
||||
localStorage.setItem(EXP_KEY, String(Math.floor(epochSeconds)));
|
||||
} else {
|
||||
localStorage.removeItem(EXP_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the full token bundle returned by /api/login or /api/auth/refresh. */
|
||||
export function persistTokenBundle(bundle: {
|
||||
access_token?: string;
|
||||
token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
}): void {
|
||||
const access = bundle.access_token || bundle.token;
|
||||
if (access) setToken(access);
|
||||
setRefreshToken(bundle.refresh_token || null);
|
||||
if (bundle.expires_in) {
|
||||
setTokenExpiry(Math.floor(Date.now() / 1000) + bundle.expires_in);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
localStorage.removeItem("encoach_token");
|
||||
localStorage.removeItem(ACCESS_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
localStorage.removeItem(EXP_KEY);
|
||||
}
|
||||
|
||||
async function handleResponse<T>(response: Response): Promise<T> {
|
||||
const data = await response.json().catch(() => null);
|
||||
// Shared refresh promise — any 401 that arrives while a refresh is in flight
|
||||
// waits on the existing request instead of firing a duplicate.
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
if (response.status === 401) {
|
||||
const hadToken = !!getToken();
|
||||
clearToken();
|
||||
// Login failure is also 401 — do not hard-redirect when no session existed.
|
||||
if (hadToken) {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
throw new ApiError(401, response.statusText, data);
|
||||
async function performRefresh(): Promise<boolean> {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) return false;
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data: {
|
||||
access_token?: string;
|
||||
token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
} = await res.json().catch(() => ({} as never));
|
||||
if (!data.access_token && !data.token) return false;
|
||||
persistTokenBundle(data);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, response.statusText, data);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
function buildHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...extra,
|
||||
};
|
||||
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
function refreshOnce(): Promise<boolean> {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = performRefresh().finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
function getCurrentLanguage(): string {
|
||||
// Mirrors the i18n bootstrap in src/i18n/index.ts: the user's explicit
|
||||
// pick wins; everything else falls back to English. We intentionally do
|
||||
// not consult navigator.language here so AI-generated content stays in
|
||||
// English on first visit (matching the UI default) until the user flips
|
||||
// the language toggle.
|
||||
try {
|
||||
const stored = localStorage.getItem("encoach-lang");
|
||||
if (stored) return stored;
|
||||
} catch {
|
||||
// localStorage unavailable (SSR, sandboxing, etc.)
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
function buildHeaders(extra?: Record<string, string>, withJson = true): Record<string, string> {
|
||||
const headers: Record<string, string> = { ...extra };
|
||||
if (withJson) headers["Content-Type"] = headers["Content-Type"] || "application/json";
|
||||
|
||||
const token = getAccessToken();
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
// Tell the backend which UI language the user is on so AI-generated content
|
||||
// (coaching tips, insights, alerts, narratives) can be returned in the same
|
||||
// language instead of always defaulting to English.
|
||||
if (!headers["Accept-Language"]) {
|
||||
headers["Accept-Language"] = getCurrentLanguage();
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
|
||||
/**
|
||||
* Query param values we know how to serialise into a URL. Arrays are joined
|
||||
* with commas because that's what our Odoo controllers expect for multi-value
|
||||
* filters (e.g. `?state=draft,confirmed`).
|
||||
*/
|
||||
export type QueryParamValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| undefined
|
||||
| Array<string | number | boolean>;
|
||||
|
||||
/**
|
||||
* Accept any object-shaped bag of query params. We intentionally use `object`
|
||||
* here rather than `Record<string, QueryParamValue>` because typed interfaces
|
||||
* (e.g. `PaginationParams`) don't satisfy a `Record<...>` index signature by
|
||||
* default, which would force every call-site to cast.
|
||||
*/
|
||||
export type QueryParams = object;
|
||||
|
||||
const ENTITY_QUERY_SCOPE_RE =
|
||||
/^\/(courses|students|teachers|batches|student\/my-courses|ai\/course-plan)(\/|$)/;
|
||||
const ENTITY_BODY_SCOPE_RE =
|
||||
/^\/(courses|students|teachers|batches|ai\/course-plan)(\/|$)/;
|
||||
|
||||
function buildUrl(path: string, params?: QueryParams): string {
|
||||
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
url.searchParams.set(key, String(value));
|
||||
for (const [key, rawValue] of Object.entries(params as Record<string, unknown>)) {
|
||||
if (rawValue === undefined || rawValue === null) continue;
|
||||
if (Array.isArray(rawValue)) {
|
||||
if (rawValue.length === 0) continue;
|
||||
url.searchParams.set(key, rawValue.map(v => String(v)).join(","));
|
||||
continue;
|
||||
}
|
||||
});
|
||||
url.searchParams.set(key, String(rawValue));
|
||||
}
|
||||
}
|
||||
// Multi-entity LMS scope: when the user has selected an active entity
|
||||
// in the UI, append it to entity-scoped endpoints unless the caller
|
||||
// already provided an explicit entity_id.
|
||||
const activeEntityId = getActiveEntityId();
|
||||
if (
|
||||
activeEntityId &&
|
||||
!url.searchParams.has("entity_id") &&
|
||||
ENTITY_QUERY_SCOPE_RE.test(path)
|
||||
) {
|
||||
url.searchParams.set("entity_id", String(activeEntityId));
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function maybeInjectEntityIntoBody(path: string, body: unknown): unknown {
|
||||
const activeEntityId = getActiveEntityId();
|
||||
if (!activeEntityId) return body;
|
||||
if (!ENTITY_BODY_SCOPE_RE.test(path)) return body;
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
|
||||
const rec = body as Record<string, unknown>;
|
||||
if (rec.entity_id !== undefined && rec.entity_id !== null) return body;
|
||||
return { ...rec, entity_id: activeEntityId };
|
||||
}
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new ApiError(response.status, response.statusText, data);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
type RequestInitWithSkip = RequestInit & { _skipRetry?: boolean };
|
||||
|
||||
async function performRequest<T>(url: string, init: RequestInitWithSkip): Promise<T> {
|
||||
const response = await fetch(url, init);
|
||||
|
||||
// Auth/refresh endpoints opt out of the retry loop — otherwise a bad
|
||||
// refresh token would recurse forever.
|
||||
const isAuthEndpoint = url.includes("/auth/refresh") || url.includes("/login");
|
||||
|
||||
if (response.status === 401 && !init._skipRetry && !isAuthEndpoint) {
|
||||
const hadRefresh = !!getRefreshToken();
|
||||
if (hadRefresh) {
|
||||
const rotated = await refreshOnce();
|
||||
if (rotated) {
|
||||
// Rebuild headers so the new access token is attached.
|
||||
const newInit: RequestInitWithSkip = {
|
||||
...init,
|
||||
_skipRetry: true,
|
||||
headers: {
|
||||
...(init.headers as Record<string, string>),
|
||||
Authorization: `Bearer ${getAccessToken()}`,
|
||||
},
|
||||
};
|
||||
return performRequest<T>(url, newInit);
|
||||
}
|
||||
}
|
||||
const hadAccess = !!getAccessToken();
|
||||
clearToken();
|
||||
if (hadAccess || hadRefresh) {
|
||||
// Use SPA-style navigation when possible; fall back to a hard nav only
|
||||
// when we're inside a worker / non-browser context. A full document
|
||||
// reload here used to feel like "the browser refreshes on every click"
|
||||
// whenever an access token silently expired.
|
||||
if (typeof window !== "undefined" && !window.location.pathname.startsWith("/login")) {
|
||||
window.history.pushState({}, "", "/login");
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
}
|
||||
}
|
||||
throw new ApiError(401, response.statusText, await response.json().catch(() => null));
|
||||
}
|
||||
|
||||
return parseResponse<T>(response);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async get<T>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
|
||||
const res = await fetch(buildUrl(path, params), {
|
||||
async get<T>(path: string, params?: QueryParams): Promise<T> {
|
||||
return performRequest<T>(buildUrl(path, params), {
|
||||
method: "GET",
|
||||
headers: buildHeaders(),
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async post<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
const payload = maybeInjectEntityIntoBody(path, body);
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "POST",
|
||||
headers: buildHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async patch<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
const payload = maybeInjectEntityIntoBody(path, body);
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "PATCH",
|
||||
headers: buildHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async put<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
const payload = maybeInjectEntityIntoBody(path, body);
|
||||
return performRequest<T>(buildUrl(path), {
|
||||
method: "PUT",
|
||||
headers: buildHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
return handleResponse<T>(res);
|
||||
},
|
||||
|
||||
async delete<T>(path: string): Promise<T> {
|
||||
const res = await fetch(buildUrl(path), {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -1,119 +1,556 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, CheckCircle, XCircle, Clock } from "lucide-react";
|
||||
import AiGradingAssistant from "@/components/ai/AiGradingAssistant";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Plus,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Loader2,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
ShieldCheck,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/lib/api-client";
|
||||
|
||||
const workflows = [
|
||||
{
|
||||
id: 1, name: "Exam Content Review", status: "In Progress",
|
||||
steps: [
|
||||
{ name: "Initial Review", assignee: "Dr. Smith", status: "Approved" },
|
||||
{ name: "Quality Check", assignee: "Prof. Lee", status: "Pending" },
|
||||
{ name: "Final Approval", assignee: "Admin", status: "Waiting" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2, name: "Rubric Approval", status: "Completed",
|
||||
steps: [
|
||||
{ name: "Draft Review", assignee: "Mr. Kim", status: "Approved" },
|
||||
{ name: "Academic Board", assignee: "Dr. Smith", status: "Approved" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3, name: "New Exam Structure", status: "Rejected",
|
||||
steps: [
|
||||
{ name: "Content Review", assignee: "Prof. Lee", status: "Approved" },
|
||||
{ name: "Standards Check", assignee: "Dr. Smith", status: "Rejected" },
|
||||
],
|
||||
},
|
||||
];
|
||||
interface WorkflowStep {
|
||||
id: number;
|
||||
sequence: number;
|
||||
approver_id: number | null;
|
||||
approver_name: string;
|
||||
status: string;
|
||||
comment: string;
|
||||
auto_escalate: boolean;
|
||||
max_days: number;
|
||||
acted_at: string | null;
|
||||
}
|
||||
|
||||
interface Workflow {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
entity_id: number | null;
|
||||
entity_name: string | null;
|
||||
allow_bypass: boolean;
|
||||
active: boolean;
|
||||
steps: WorkflowStep[];
|
||||
created: string;
|
||||
}
|
||||
|
||||
interface ApprovalRequest {
|
||||
id: number;
|
||||
workflow_id: number | null;
|
||||
workflow_name: string;
|
||||
res_model: string;
|
||||
res_id: number;
|
||||
state: string;
|
||||
requester_id: number | null;
|
||||
requester_name: string;
|
||||
current_stage_id: number | null;
|
||||
current_stage_sequence: number | null;
|
||||
bypass_reason: string;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
interface UserItem {
|
||||
id: number;
|
||||
name: string;
|
||||
login: string;
|
||||
}
|
||||
|
||||
const statusIcon = (s: string) => {
|
||||
if (s === "Approved") return <CheckCircle className="h-4 w-4 text-success" />;
|
||||
if (s === "Rejected") return <XCircle className="h-4 w-4 text-destructive" />;
|
||||
return <Clock className="h-4 w-4 text-warning" />;
|
||||
if (s === "approved") return <CheckCircle className="h-4 w-4 text-green-600" />;
|
||||
if (s === "rejected") return <XCircle className="h-4 w-4 text-destructive" />;
|
||||
return <Clock className="h-4 w-4 text-amber-500" />;
|
||||
};
|
||||
|
||||
const stateBadge = (state: string) => {
|
||||
const map: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
approved: "default",
|
||||
in_progress: "secondary",
|
||||
rejected: "destructive",
|
||||
draft: "outline",
|
||||
};
|
||||
return (
|
||||
<Badge variant={map[state] || "outline"} className="capitalize">
|
||||
{state.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export default function ApprovalWorkflowsPage() {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [formName, setFormName] = useState("");
|
||||
const [formType, setFormType] = useState("custom");
|
||||
const [formSteps, setFormSteps] = useState<{ approver_id: string }[]>([
|
||||
{ approver_id: "" },
|
||||
{ approver_id: "" },
|
||||
]);
|
||||
|
||||
const [rejectDialog, setRejectDialog] = useState<number | null>(null);
|
||||
const [rejectComment, setRejectComment] = useState("");
|
||||
|
||||
const workflowsQ = useQuery({
|
||||
queryKey: ["approval-workflows"],
|
||||
queryFn: () => api.get<{ items: Workflow[]; total: number }>("/approval-workflows"),
|
||||
});
|
||||
|
||||
const requestsQ = useQuery({
|
||||
queryKey: ["approval-requests"],
|
||||
queryFn: () => api.get<{ items: ApprovalRequest[]; total: number }>("/approval-requests"),
|
||||
});
|
||||
|
||||
const usersQ = useQuery({
|
||||
queryKey: ["approval-users"],
|
||||
queryFn: () => api.get<{ items: UserItem[] }>("/approval-users"),
|
||||
});
|
||||
|
||||
const workflows = workflowsQ.data?.items ?? [];
|
||||
const requests = requestsQ.data?.items ?? [];
|
||||
const users = usersQ.data?.items ?? [];
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => api.post("/approval-workflows", data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
|
||||
setCreateOpen(false);
|
||||
resetForm();
|
||||
toast({ title: "Workflow created" });
|
||||
},
|
||||
onError: (e: Error) => toast({ variant: "destructive", title: "Create failed", description: e.message }),
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: number) => api.delete(`/approval-workflows/${id}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
|
||||
toast({ title: "Workflow deleted" });
|
||||
},
|
||||
});
|
||||
|
||||
const approveMut = useMutation({
|
||||
mutationFn: (id: number) => api.post(`/approval-requests/${id}/approve`, {}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["approval-requests"] });
|
||||
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
|
||||
toast({ title: "Request approved" });
|
||||
},
|
||||
onError: (e: Error) => toast({ variant: "destructive", title: "Approve failed", description: e.message }),
|
||||
});
|
||||
|
||||
const rejectMut = useMutation({
|
||||
mutationFn: ({ id, comment }: { id: number; comment: string }) =>
|
||||
api.post(`/approval-requests/${id}/reject`, { comment }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["approval-requests"] });
|
||||
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
|
||||
setRejectDialog(null);
|
||||
setRejectComment("");
|
||||
toast({ title: "Request rejected" });
|
||||
},
|
||||
onError: (e: Error) => toast({ variant: "destructive", title: "Reject failed", description: e.message }),
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setFormName("");
|
||||
setFormType("custom");
|
||||
setFormSteps([{ approver_id: "" }, { approver_id: "" }]);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
createMut.mutate({
|
||||
name: formName.trim(),
|
||||
type: formType,
|
||||
steps: formSteps
|
||||
.filter((s) => s.approver_id)
|
||||
.map((s) => ({ approver_id: Number(s.approver_id) })),
|
||||
});
|
||||
};
|
||||
|
||||
const pendingRequests = requests.filter((r) => r.state === "in_progress");
|
||||
const completedRequests = requests.filter((r) => r.state !== "in_progress");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Approval Workflows</h1>
|
||||
<p className="text-muted-foreground">Manage multi-step approval processes for exam content.</p>
|
||||
<p className="text-muted-foreground">
|
||||
Manage multi-step approval processes for exam content.
|
||||
</p>
|
||||
</div>
|
||||
<Dialog>
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
if (!open) resetForm();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Workflow</Button>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" /> Create Workflow
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Create Workflow</DialogTitle></DialogHeader>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Workflow</DialogTitle>
|
||||
<DialogDescription>Define a multi-step approval process.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2"><Label>Workflow Name</Label><Input placeholder="e.g. Exam Content Review" /></div>
|
||||
<div className="space-y-2">
|
||||
<Label>Step 1 — Assignee</Label>
|
||||
<Select><SelectTrigger><SelectValue placeholder="Select assignee" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="smith">Dr. Smith</SelectItem><SelectItem value="lee">Prof. Lee</SelectItem><SelectItem value="kim">Mr. Kim</SelectItem></SelectContent>
|
||||
</Select>
|
||||
<Label>
|
||||
Workflow Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder="e.g. Exam Content Review"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Step 2 — Assignee</Label>
|
||||
<Select><SelectTrigger><SelectValue placeholder="Select assignee" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="smith">Dr. Smith</SelectItem><SelectItem value="lee">Prof. Lee</SelectItem><SelectItem value="admin">Admin</SelectItem></SelectContent>
|
||||
<Label>Type</Label>
|
||||
<Select value={formType} onValueChange={setFormType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
<SelectItem value="exam_publish">Exam Publication</SelectItem>
|
||||
<SelectItem value="assignment_publish">Assignment Publication</SelectItem>
|
||||
<SelectItem value="content_publish">Content Publication</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button className="w-full">Create</Button>
|
||||
|
||||
{formSteps.map((step, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Step {i + 1} — Approver</Label>
|
||||
{formSteps.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={() =>
|
||||
setFormSteps((s) => s.filter((_, idx) => idx !== i))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
value={step.approver_id}
|
||||
onValueChange={(v) =>
|
||||
setFormSteps((s) =>
|
||||
s.map((st, idx) => (idx === i ? { ...st, approver_id: v } : st))
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select approver" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{users.map((u) => (
|
||||
<SelectItem key={u.id} value={String(u.id)}>
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setFormSteps((s) => [...s, { approver_id: "" }])}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" /> Add Step
|
||||
</Button>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!formName.trim() || createMut.isPending}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{createMut.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Loading */}
|
||||
{(workflowsQ.isLoading || requestsQ.isLoading) && (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending Requests */}
|
||||
{pendingRequests.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-amber-500" />
|
||||
Pending Approval ({pendingRequests.length})
|
||||
</h2>
|
||||
{pendingRequests.map((req) => {
|
||||
const wf = workflows.find((w) => w.id === req.workflow_id);
|
||||
return (
|
||||
<Card key={req.id} className="border-amber-200 bg-amber-50/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<p className="font-semibold">{req.workflow_name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Requested by {req.requester_name} ·{" "}
|
||||
{req.res_model.replace("encoach.", "")} #{req.res_id}
|
||||
</p>
|
||||
</div>
|
||||
{stateBadge(req.state)}
|
||||
</div>
|
||||
|
||||
{wf && (
|
||||
<div className="flex items-center gap-2 flex-wrap mb-3">
|
||||
{wf.steps.map((step, i) => {
|
||||
const isActive = step.id === req.current_stage_id;
|
||||
return (
|
||||
<div key={step.id} className="flex items-center gap-2">
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border p-3 min-w-[160px] transition-all ${
|
||||
isActive
|
||||
? "border-amber-400 bg-amber-50 ring-2 ring-amber-200"
|
||||
: step.status === "approved"
|
||||
? "border-green-200 bg-green-50/50"
|
||||
: step.status === "rejected"
|
||||
? "border-red-200 bg-red-50/50"
|
||||
: "bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
{statusIcon(step.status)}
|
||||
<div>
|
||||
<p className="text-sm font-medium">Step {i + 1}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{step.approver_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{i < wf.steps.length - 1 && (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={approveMut.isPending}
|
||||
onClick={() => approveMut.mutate(req.id)}
|
||||
>
|
||||
{approveMut.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||
) : (
|
||||
<CheckCircle className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setRejectDialog(req.id)}
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5 mr-1" />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Workflow Templates */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
Workflow Templates ({workflows.length})
|
||||
</h2>
|
||||
{workflows.length === 0 && !workflowsQ.isLoading && (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
No workflows defined yet. Create one to get started.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{workflows.map((wf) => (
|
||||
<Card key={wf.id} className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">{wf.name}</CardTitle>
|
||||
<Badge variant={wf.status === "Completed" ? "default" : wf.status === "Rejected" ? "destructive" : "secondary"}>{wf.status}</Badge>
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle className="text-base font-semibold">{wf.name}</CardTitle>
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{wf.type.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => {
|
||||
if (confirm(`Delete workflow "${wf.name}"?`)) deleteMut.mutate(wf.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{wf.steps.map((step, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<div key={step.id} className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 rounded-lg border p-3 bg-muted/30 min-w-[160px]">
|
||||
{statusIcon(step.status)}
|
||||
<div className="h-6 w-6 rounded-full bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary">
|
||||
{i + 1}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{step.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{step.assignee}</p>
|
||||
<p className="text-sm font-medium">{step.approver_name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{step.max_days}d limit
|
||||
{step.auto_escalate ? " · auto-escalate" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{i < wf.steps.length - 1 && <div className="w-8 h-0.5 bg-border" />}
|
||||
{i < wf.steps.length - 1 && (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{wf.status === "In Progress" && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<AiGradingAssistant onAccept={(marks, feedback) => {
|
||||
toast({ title: "AI Grade Accepted", description: `Marks: ${marks}/100 applied with AI feedback.` });
|
||||
}} />
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="default">Approve</Button>
|
||||
<Button size="sm" variant="outline">Reject</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Completed Requests */}
|
||||
{completedRequests.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Recent Decisions ({completedRequests.length})
|
||||
</h2>
|
||||
{completedRequests.map((req) => (
|
||||
<Card key={req.id} className="border-0 shadow-sm">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{req.workflow_name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{req.requester_name} · {req.res_model.replace("encoach.", "")} #
|
||||
{req.res_id}
|
||||
{req.created_at && (
|
||||
<>
|
||||
{" "}
|
||||
· {new Date(req.created_at).toLocaleDateString()}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{stateBadge(req.state)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reject Dialog */}
|
||||
<Dialog
|
||||
open={rejectDialog !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setRejectDialog(null);
|
||||
setRejectComment("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reject Request</DialogTitle>
|
||||
<DialogDescription>Provide a reason for rejection.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label>Comment</Label>
|
||||
<Textarea
|
||||
value={rejectComment}
|
||||
onChange={(e) => setRejectComment(e.target.value)}
|
||||
placeholder="Reason for rejection..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRejectDialog(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={rejectMut.isPending}
|
||||
onClick={() => {
|
||||
if (rejectDialog !== null) {
|
||||
rejectMut.mutate({ id: rejectDialog, comment: rejectComment });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{rejectMut.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : null}
|
||||
Reject
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,91 +1,341 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Search, Plus, Trash2 } from "lucide-react";
|
||||
import { useAssignments, useCreateAssignment } from "@/hooks/queries";
|
||||
import {
|
||||
Search, Plus, Trash2, Loader2, Calendar, Users, Clock,
|
||||
CheckCircle2, Archive, AlertTriangle, PlayCircle, Eye
|
||||
} from "lucide-react";
|
||||
import { assignmentsService } from "@/services/assignments.service";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useBatches, useStudents } from "@/hooks/queries";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { ExamSchedule, ScheduleState, ExamScheduleCreateRequest } from "@/types";
|
||||
|
||||
interface CustomExam {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface Entity {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const STATE_TABS: { key: ScheduleState | "all"; label: string; icon: React.ReactNode }[] = [
|
||||
{ key: "all", label: "All", icon: <Eye className="h-3.5 w-3.5" /> },
|
||||
{ key: "active", label: "Active", icon: <PlayCircle className="h-3.5 w-3.5" /> },
|
||||
{ key: "planned", label: "Planned", icon: <Clock className="h-3.5 w-3.5" /> },
|
||||
{ key: "past", label: "Past", icon: <CheckCircle2 className="h-3.5 w-3.5" /> },
|
||||
{ key: "start_expired", label: "Start Expired", icon: <AlertTriangle className="h-3.5 w-3.5" /> },
|
||||
{ key: "archived", label: "Archived", icon: <Archive className="h-3.5 w-3.5" /> },
|
||||
];
|
||||
|
||||
const OPTION_FIELDS: { key: keyof ExamScheduleCreateRequest; label: string }[] = [
|
||||
{ key: "full_length", label: "Full length exams" },
|
||||
{ key: "generate_different", label: "Generate different exams" },
|
||||
{ key: "auto_release_results", label: "Auto release results" },
|
||||
{ key: "auto_start", label: "Auto start exam" },
|
||||
{ key: "official_exam", label: "Official Exam" },
|
||||
{ key: "hide_assignee_details", label: "Hide Assignees Details from Teachers" },
|
||||
];
|
||||
|
||||
const stateBadgeVariant = (s: ScheduleState): "default" | "secondary" | "outline" | "destructive" => {
|
||||
switch (s) {
|
||||
case "active": return "default";
|
||||
case "planned": return "secondary";
|
||||
case "past": return "outline";
|
||||
case "start_expired": return "destructive";
|
||||
case "archived": return "outline";
|
||||
default: return "secondary";
|
||||
}
|
||||
};
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
exam_id: string;
|
||||
entity_id: string;
|
||||
start_date: string;
|
||||
start_time: string;
|
||||
end_date: string;
|
||||
end_time: string;
|
||||
assign_mode: "entity" | "batch" | "individual";
|
||||
batch_ids: Set<number>;
|
||||
student_ids: Set<number>;
|
||||
full_length: boolean;
|
||||
generate_different: boolean;
|
||||
auto_release_results: boolean;
|
||||
auto_start: boolean;
|
||||
official_exam: boolean;
|
||||
hide_assignee_details: boolean;
|
||||
};
|
||||
|
||||
const _iso = (d: Date) => {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
};
|
||||
|
||||
const emptyForm = (): FormState => {
|
||||
const today = new Date();
|
||||
const inOneWeek = new Date();
|
||||
inOneWeek.setDate(today.getDate() + 7);
|
||||
return {
|
||||
name: "",
|
||||
exam_id: "",
|
||||
entity_id: "",
|
||||
start_date: _iso(today),
|
||||
start_time: "09:00",
|
||||
end_date: _iso(inOneWeek),
|
||||
end_time: "17:00",
|
||||
assign_mode: "batch",
|
||||
batch_ids: new Set(),
|
||||
student_ids: new Set(),
|
||||
full_length: true,
|
||||
generate_different: false,
|
||||
auto_release_results: false,
|
||||
auto_start: false,
|
||||
official_exam: false,
|
||||
hide_assignee_details: false,
|
||||
};
|
||||
};
|
||||
|
||||
export default function AssignmentsPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [stateFilter, setStateFilter] = useState<ScheduleState | "all">("all");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState({ title: "", entity_id: "", start_date: "", end_date: "" });
|
||||
const [form, setForm] = useState<FormState>(emptyForm());
|
||||
const [studentQuery, setStudentQuery] = useState("");
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const assignmentsQ = useAssignments({ size: 200 });
|
||||
const createMut = useCreateAssignment();
|
||||
const schedulesQ = useQuery({
|
||||
queryKey: ["exam-schedules", stateFilter],
|
||||
queryFn: () => assignmentsService.listSchedules(
|
||||
stateFilter === "all" ? {} : { state: stateFilter }
|
||||
),
|
||||
});
|
||||
const schedules = schedulesQ.data?.items ?? [];
|
||||
|
||||
const items = assignmentsQ.data?.items ?? assignmentsQ.data?.data ?? [];
|
||||
const assignments = Array.isArray(items) ? items : [];
|
||||
|
||||
const q = search.toLowerCase();
|
||||
const filtered = assignments.filter(a =>
|
||||
a.title?.toLowerCase().includes(q) || a.entity_name?.toLowerCase().includes(q),
|
||||
const customExamsQ = useQuery({
|
||||
queryKey: ["custom-exams-for-assign"],
|
||||
queryFn: () => api.get<{ items: CustomExam[]; total: number }>("/exam/custom/list?per_page=200"),
|
||||
});
|
||||
const publishedExams = (customExamsQ.data?.items ?? []).filter(
|
||||
(e) => e.status === "published" || e.status === "draft"
|
||||
);
|
||||
|
||||
const loading = assignmentsQ.isLoading;
|
||||
const entitiesQ = useQuery({
|
||||
queryKey: ["entities-for-assign"],
|
||||
queryFn: () => api.get<{ items: Entity[] }>("/entities"),
|
||||
});
|
||||
const entities = entitiesQ.data?.items ?? [];
|
||||
|
||||
const batchesQ = useBatches({ size: 200 });
|
||||
const batches = batchesQ.data?.items ?? [];
|
||||
|
||||
const studentsQ = useStudents({ size: 200 });
|
||||
const students = studentsQ.data?.items ?? [];
|
||||
const filteredStudents = useMemo(() => {
|
||||
const q = studentQuery.trim().toLowerCase();
|
||||
if (!q) return students;
|
||||
return students.filter((s) =>
|
||||
(s.name || "").toLowerCase().includes(q) ||
|
||||
(s.email || "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [students, studentQuery]);
|
||||
|
||||
const stateCounts = useMemo(() => {
|
||||
const all = schedulesQ.data?.items ?? [];
|
||||
const counts: Record<string, number> = { all: all.length };
|
||||
for (const s of all) {
|
||||
counts[s.state] = (counts[s.state] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [schedulesQ.data]);
|
||||
|
||||
const allSchedulesQ = useQuery({
|
||||
queryKey: ["exam-schedules", "all"],
|
||||
queryFn: () => assignmentsService.listSchedules({}),
|
||||
enabled: stateFilter !== "all",
|
||||
});
|
||||
|
||||
const totalCounts = useMemo(() => {
|
||||
const items = stateFilter === "all" ? schedules : (allSchedulesQ.data?.items ?? []);
|
||||
const counts: Record<string, number> = { all: items.length };
|
||||
for (const s of items) {
|
||||
counts[s.state] = (counts[s.state] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [stateFilter, schedules, allSchedulesQ.data]);
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (data: ExamScheduleCreateRequest) => assignmentsService.createSchedule(data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
|
||||
setCreateOpen(false);
|
||||
setForm(emptyForm());
|
||||
toast({ title: "Exam scheduled successfully" });
|
||||
},
|
||||
onError: (err: Error) => toast({ variant: "destructive", title: "Error", description: err.message }),
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: number) => assignmentsService.deleteSchedule(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
|
||||
toast({ title: "Schedule deleted" });
|
||||
},
|
||||
onError: (err: Error) => toast({ variant: "destructive", title: "Error", description: err.message }),
|
||||
});
|
||||
|
||||
const archiveMut = useMutation({
|
||||
mutationFn: (id: number) => assignmentsService.archiveSchedule(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
|
||||
toast({ title: "Schedule archived" });
|
||||
},
|
||||
});
|
||||
|
||||
const q = search.toLowerCase();
|
||||
const filtered = schedules.filter(
|
||||
(s) => s.name.toLowerCase().includes(q) || s.exam_title.toLowerCase().includes(q)
|
||||
);
|
||||
|
||||
function handleCreate() {
|
||||
createMut.mutate(
|
||||
{ title: form.title, entity_id: Number(form.entity_id) || 0, start_date: form.start_date, end_date: form.end_date },
|
||||
{
|
||||
onSuccess: () => { setCreateOpen(false); setForm({ title: "", entity_id: "", start_date: "", end_date: "" }); toast({ title: "Assignment created" }); },
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
},
|
||||
);
|
||||
const startDt = form.start_date && form.start_time ? `${form.start_date}T${form.start_time}:00` : "";
|
||||
const endDt = form.end_date && form.end_time ? `${form.end_date}T${form.end_time}:00` : "";
|
||||
|
||||
if (!form.name || !form.exam_id || !startDt || !endDt) {
|
||||
toast({ variant: "destructive", title: "Missing fields", description: "Please fill in name, exam, start date/time, and end date/time." });
|
||||
return;
|
||||
}
|
||||
|
||||
createMut.mutate({
|
||||
name: form.name,
|
||||
exam_id: Number(form.exam_id),
|
||||
entity_id: form.entity_id ? Number(form.entity_id) : undefined,
|
||||
start_date: startDt,
|
||||
end_date: endDt,
|
||||
assign_mode: form.assign_mode,
|
||||
batch_ids: [...form.batch_ids],
|
||||
student_ids: [...form.student_ids],
|
||||
full_length: form.full_length,
|
||||
generate_different: form.generate_different,
|
||||
auto_release_results: form.auto_release_results,
|
||||
auto_start: form.auto_start,
|
||||
official_exam: form.official_exam,
|
||||
hide_assignee_details: form.hide_assignee_details,
|
||||
});
|
||||
}
|
||||
|
||||
const updateF = (patch: Partial<FormState>) => setForm((p) => ({ ...p, ...patch }));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Assignments</h1>
|
||||
<p className="text-muted-foreground">Create and manage assignments.</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Calendar className="h-6 w-6 text-primary" /> Assignments
|
||||
</h1>
|
||||
<p className="text-muted-foreground">Schedule exams and assign them to entities, classes, or students.</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Create Assignment
|
||||
<Button onClick={() => { setForm(emptyForm()); setCreateOpen(true); }}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Schedule Exam
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* State tabs */}
|
||||
<div className="inline-flex items-center rounded-full border-2 border-primary bg-primary/5 p-1 gap-0">
|
||||
{STATE_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium transition-all ${
|
||||
stateFilter === tab.key
|
||||
? "bg-white text-primary shadow-sm"
|
||||
: "text-primary/70 hover:text-primary"
|
||||
}`}
|
||||
onClick={() => setStateFilter(tab.key)}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
<span className={`text-xs ml-0.5 ${stateFilter === tab.key ? "text-primary" : "text-primary/50"}`}>
|
||||
({totalCounts[tab.key] ?? 0})
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder="Search assignments..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<Input placeholder="Search schedules..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
||||
{schedulesQ.isLoading ? (
|
||||
<div className="flex justify-center py-12"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>
|
||||
) : filtered.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="p-12 text-center text-muted-foreground">
|
||||
<Calendar className="h-10 w-10 mx-auto mb-3 opacity-40" />
|
||||
<p className="font-medium">No exam schedules found</p>
|
||||
<p className="text-sm mt-1">Create a new schedule to assign exams to students.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Schedule Name</TableHead>
|
||||
<TableHead>Exam</TableHead>
|
||||
<TableHead>Entity</TableHead>
|
||||
<TableHead>Start</TableHead>
|
||||
<TableHead>End</TableHead>
|
||||
<TableHead>State</TableHead>
|
||||
<TableHead>Assignees</TableHead>
|
||||
<TableHead>Completed</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.length === 0 && (
|
||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No assignments found.</TableCell></TableRow>
|
||||
)}
|
||||
{filtered.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-medium">{a.title}</TableCell>
|
||||
<TableCell>{a.entity_name || "—"}</TableCell>
|
||||
<TableCell>{a.start_date || "—"}</TableCell>
|
||||
<TableCell>{a.end_date || "—"}</TableCell>
|
||||
<TableCell><Badge variant={a.state === "active" ? "default" : "secondary"} className="capitalize">{a.state}</Badge></TableCell>
|
||||
<TableCell>{a.assignee_count ?? 0}</TableCell>
|
||||
<TableCell>{a.completed_count ?? 0}</TableCell>
|
||||
{filtered.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="font-medium">{s.name}</TableCell>
|
||||
<TableCell>{s.exam_title}</TableCell>
|
||||
<TableCell>{s.entity_name || "—"}</TableCell>
|
||||
<TableCell className="text-xs">{s.start_date ? new Date(s.start_date).toLocaleString() : "—"}</TableCell>
|
||||
<TableCell className="text-xs">{s.end_date ? new Date(s.end_date).toLocaleString() : "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={stateBadgeVariant(s.state)} className="capitalize">{s.state.replace("_", " ")}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1"><Users className="h-3.5 w-3.5 text-muted-foreground" />{s.assignee_count}</div>
|
||||
</TableCell>
|
||||
<TableCell>{s.completed_count}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{s.state !== "archived" && (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" title="Archive"
|
||||
onClick={() => archiveMut.mutate(s.id)}>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" title="Delete"
|
||||
onClick={() => { if (confirm(`Delete schedule "${s.name}"?`)) deleteMut.mutate(s.id); }}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -94,21 +344,188 @@ export default function AssignmentsPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create Schedule Dialog */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Create Assignment</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2"><Label>Title</Label><Input value={form.title} onChange={(e) => setForm(f => ({ ...f, title: e.target.value }))} placeholder="e.g. IELTS Prep Q2" /></div>
|
||||
<div className="space-y-2"><Label>Entity ID</Label><Input type="number" value={form.entity_id} onChange={(e) => setForm(f => ({ ...f, entity_id: e.target.value }))} /></div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2"><Label>Start Date</Label><Input type="date" value={form.start_date} onChange={(e) => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
|
||||
<div className="space-y-2"><Label>End Date</Label><Input type="date" value={form.end_date} onChange={(e) => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schedule Exam</DialogTitle>
|
||||
<DialogDescription>Select an exam, assign it to students, and set scheduling options.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 py-2">
|
||||
{/* Basic info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Schedule Name <span className="text-destructive">*</span></Label>
|
||||
<Input value={form.name} onChange={(e) => updateF({ name: e.target.value })} placeholder="e.g. Q2 IELTS Exam" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Select Exam <span className="text-destructive">*</span></Label>
|
||||
<Select value={form.exam_id} onValueChange={(v) => updateF({ exam_id: v })}>
|
||||
<SelectTrigger><SelectValue placeholder="Choose exam..." /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{publishedExams.map((e) => (
|
||||
<SelectItem key={e.id} value={String(e.id)}>
|
||||
{e.title} <span className="text-xs text-muted-foreground ml-1">({e.status})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
{publishedExams.length === 0 && (
|
||||
<SelectItem value="_none" disabled>No exams available</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date/Time */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">Schedule Date & Time</Label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Start Date <span className="text-destructive">*</span></Label>
|
||||
<Input type="date" value={form.start_date} onChange={(e) => updateF({ start_date: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Start Time</Label>
|
||||
<Input type="time" value={form.start_time} onChange={(e) => updateF({ start_time: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">End Date <span className="text-destructive">*</span></Label>
|
||||
<Input type="date" value={form.end_date} onChange={(e) => updateF({ end_date: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">End Time</Label>
|
||||
<Input type="time" value={form.end_time} onChange={(e) => updateF({ end_time: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options checkboxes */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">Exam Options</Label>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2">
|
||||
{OPTION_FIELDS.map((opt) => (
|
||||
<label key={opt.key} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={form[opt.key] as boolean}
|
||||
onCheckedChange={(checked) => updateF({ [opt.key]: !!checked } as Partial<FormState>)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assignment target */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">Assign To</Label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Assignment Mode</Label>
|
||||
<Select value={form.assign_mode} onValueChange={(v) => updateF({ assign_mode: v as FormState["assign_mode"] })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="entity">Entire Entity</SelectItem>
|
||||
<SelectItem value="batch">Class / Batch</SelectItem>
|
||||
<SelectItem value="individual">Individual Students</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Entity</Label>
|
||||
<Select value={form.entity_id} onValueChange={(v) => updateF({ entity_id: v })}>
|
||||
<SelectTrigger><SelectValue placeholder="Select entity..." /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{entities.map((e) => (
|
||||
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
|
||||
))}
|
||||
{entities.length === 0 && (
|
||||
<SelectItem value="_none" disabled>No entities</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Batch selection */}
|
||||
{form.assign_mode === "batch" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Select Classes</Label>
|
||||
<div className="max-h-36 overflow-y-auto border rounded-md p-2 space-y-1">
|
||||
{batches.map((b) => (
|
||||
<label key={b.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
||||
<Checkbox
|
||||
checked={form.batch_ids.has(b.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setForm((p) => {
|
||||
const next = new Set(p.batch_ids);
|
||||
checked ? next.add(b.id) : next.delete(b.id);
|
||||
return { ...p, batch_ids: next };
|
||||
});
|
||||
}}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
{b.name}
|
||||
</label>
|
||||
))}
|
||||
{batches.length === 0 && <p className="text-xs text-muted-foreground italic text-center py-2">No classes available</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Individual student selection */}
|
||||
{form.assign_mode === "individual" && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs">Select Students</Label>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{form.student_ids.size} selected · {students.length} total
|
||||
</span>
|
||||
</div>
|
||||
{/* Search box — needed because QA reported long student lists
|
||||
made bottom entries unreachable inside the inner scroll
|
||||
container. Filtering keeps the list short and predictable. */}
|
||||
<Input
|
||||
placeholder="Search students by name or email…"
|
||||
value={studentQuery}
|
||||
onChange={(e) => setStudentQuery(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<div className="max-h-64 overflow-y-auto border rounded-md p-2 space-y-1">
|
||||
{filteredStudents.map((s) => (
|
||||
<label key={s.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
||||
<Checkbox
|
||||
checked={form.student_ids.has(s.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setForm((p) => {
|
||||
const next = new Set(p.student_ids);
|
||||
checked ? next.add(s.id) : next.delete(s.id);
|
||||
return { ...p, student_ids: next };
|
||||
});
|
||||
}}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
{s.name}
|
||||
{s.email && <span className="text-xs text-muted-foreground ml-auto">{s.email}</span>}
|
||||
</label>
|
||||
))}
|
||||
{filteredStudents.length === 0 && students.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground italic text-center py-2">
|
||||
No students match “{studentQuery}”.
|
||||
</p>
|
||||
)}
|
||||
{students.length === 0 && <p className="text-xs text-muted-foreground italic text-center py-2">No students available</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button disabled={createMut.isPending || !form.title} onClick={handleCreate}>
|
||||
{createMut.isPending ? "Creating..." : "Create"}
|
||||
<Button disabled={createMut.isPending || !form.name || !form.exam_id || !form.start_date || !form.end_date} onClick={handleCreate}>
|
||||
{createMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Schedule Exam"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -3,11 +3,12 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Search, Plus, Trash2, Users } from "lucide-react";
|
||||
import { useBatches, useCourses } from "@/hooks/queries";
|
||||
import { Search, Plus, Trash2, Users, UserPlus, UserMinus, Pencil } from "lucide-react";
|
||||
import { useBatches, useCourses, useStudents } from "@/hooks/queries";
|
||||
import { lmsService } from "@/services/lms.service";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -17,49 +18,124 @@ export default function ClassroomsPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" });
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editBatch, setEditBatch] = useState<{ id: number; name: string; course_id: string; start_date: string; end_date: string } | null>(null);
|
||||
const [studentsOpen, setStudentsOpen] = useState(false);
|
||||
const [studentsBatchId, setStudentsBatchId] = useState<number | null>(null);
|
||||
const [addStudentsOpen, setAddStudentsOpen] = useState(false);
|
||||
const [selectedAddIds, setSelectedAddIds] = useState<number[]>([]);
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const batchesQ = useBatches({ size: 200 });
|
||||
const coursesQ = useCourses({ size: 200 });
|
||||
const studentsQ = useStudents({ size: 200 });
|
||||
const batches = batchesQ.data?.items ?? [];
|
||||
const courses = coursesQ.data?.items ?? [];
|
||||
const allStudents = studentsQ.data?.items ?? [];
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (data: Parameters<typeof lmsService.createBatch>[0]) => lmsService.createBatch(data),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "batches"] }); setCreateOpen(false); setForm({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" }); toast({ title: "Classroom created" }); },
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
||||
setCreateOpen(false);
|
||||
setForm({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" });
|
||||
toast({ title: "Classroom created" });
|
||||
},
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) => lmsService.updateBatch(id, data as never),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
||||
setEditOpen(false);
|
||||
toast({ title: "Classroom updated" });
|
||||
},
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: number) => lmsService.deleteBatch(id),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "batches"] }); toast({ title: "Deleted" }); },
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
||||
toast({ title: "Deleted" });
|
||||
},
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const addStudentsMut = useMutation({
|
||||
mutationFn: ({ batchId, studentIds }: { batchId: number; studentIds: number[] }) =>
|
||||
lmsService.addStudentsToBatch(batchId, studentIds),
|
||||
onSuccess: (res) => {
|
||||
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
||||
setAddStudentsOpen(false);
|
||||
setSelectedAddIds([]);
|
||||
toast({ title: `Added ${res.total} student(s)` });
|
||||
},
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const removeStudentMut = useMutation({
|
||||
mutationFn: ({ batchId, studentIds }: { batchId: number; studentIds: number[] }) =>
|
||||
lmsService.removeStudentsFromBatch(batchId, studentIds),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
|
||||
toast({ title: "Student removed from classroom" });
|
||||
},
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const q = search.toLowerCase();
|
||||
const filtered = batches.filter(b => b.name.toLowerCase().includes(q) || b.course_name?.toLowerCase().includes(q));
|
||||
const filtered = batches.filter(
|
||||
(b) => b.name.toLowerCase().includes(q) || b.course_name?.toLowerCase().includes(q),
|
||||
);
|
||||
const loading = batchesQ.isLoading;
|
||||
|
||||
const currentBatch = batches.find((b) => b.id === studentsBatchId);
|
||||
const batchStudentIds = new Set(currentBatch?.student_ids ?? []);
|
||||
const availableStudents = allStudents.filter((s) => !batchStudentIds.has(s.id));
|
||||
|
||||
function openStudents(batchId: number) {
|
||||
setStudentsBatchId(batchId);
|
||||
setStudentsOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(b: typeof batches[0]) {
|
||||
setEditBatch({
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
course_id: String(b.course_id || ""),
|
||||
start_date: b.start_date,
|
||||
end_date: b.end_date,
|
||||
});
|
||||
setEditOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Classrooms</h1>
|
||||
<p className="text-muted-foreground">Create and manage batches (classrooms).</p>
|
||||
<p className="text-muted-foreground">Create and manage classrooms with students.</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Create Classroom
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder="Search classrooms..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder="Search classrooms..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<Badge variant="secondary">{batches.length} total</Badge>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
||||
<div className="flex items-center justify-center min-h-[300px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
@@ -69,29 +145,67 @@ export default function ClassroomsPage() {
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Course</TableHead>
|
||||
<TableHead>Students</TableHead>
|
||||
<TableHead>Capacity</TableHead>
|
||||
<TableHead>Dates</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-10" />
|
||||
<TableHead className="w-[140px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.length === 0 && (
|
||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No classrooms found.</TableCell></TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||
No classrooms found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{filtered.map((b) => (
|
||||
<TableRow key={b.id}>
|
||||
<TableCell className="font-medium">{b.name}</TableCell>
|
||||
<TableCell>{b.course_name}</TableCell>
|
||||
<TableCell><Badge variant="secondary"><Users className="h-3 w-3 mr-1" />{b.student_count}</Badge></TableCell>
|
||||
<TableCell>{b.capacity}</TableCell>
|
||||
<TableCell className="text-sm">{b.start_date} — {b.end_date}</TableCell>
|
||||
<TableCell><Badge variant={b.status === "active" ? "default" : "secondary"} className="capitalize">{b.status}</Badge></TableCell>
|
||||
<TableCell>{b.course_name || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => { if (window.confirm(`Delete "${b.name}"?`)) deleteMut.mutate(b.id); }}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 h-7 px-2"
|
||||
onClick={() => openStudents(b.id)}
|
||||
>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
{b.student_count} students
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{b.start_date && b.end_date
|
||||
? `${b.start_date} — ${b.end_date}`
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={b.status === "active" ? "default" : "secondary"}
|
||||
className="capitalize"
|
||||
>
|
||||
{b.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" title="Manage students" onClick={() => openStudents(b.id)}>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" title="Edit" onClick={() => openEdit(b)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete "${b.name}"?`)) deleteMut.mutate(b.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -100,34 +214,234 @@ export default function ClassroomsPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create Dialog */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Create Classroom</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2"><Label>Name</Label><Input value={form.name} onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))} placeholder="e.g. IELTS Prep Group A" /></div>
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. IELTS Prep Group A" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Course</Label>
|
||||
<Select value={form.course_id} onValueChange={(v) => setForm(f => ({ ...f, course_id: v }))}>
|
||||
<Select value={form.course_id} onValueChange={(v) => setForm((f) => ({ ...f, course_id: v }))}>
|
||||
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{courses.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.title || c.code}</SelectItem>)}
|
||||
{courses.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>{c.title || c.code}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2"><Label>Start Date</Label><Input type="date" value={form.start_date} onChange={(e) => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
|
||||
<div className="space-y-2"><Label>End Date</Label><Input type="date" value={form.end_date} onChange={(e) => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
|
||||
<div className="space-y-2">
|
||||
<Label>Start Date</Label>
|
||||
<Input type="date" value={form.start_date} onChange={(e) => setForm((f) => ({ ...f, start_date: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>End Date</Label>
|
||||
<Input type="date" value={form.end_date} onChange={(e) => setForm((f) => ({ ...f, end_date: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2"><Label>Max Students</Label><Input type="number" value={form.max_students} onChange={(e) => setForm(f => ({ ...f, max_students: e.target.value }))} placeholder="30" /></div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button disabled={createMut.isPending || !form.name} onClick={() => createMut.mutate({ name: form.name, course_id: Number(form.course_id) || undefined, start_date: form.start_date || undefined, end_date: form.end_date || undefined, capacity: Number(form.max_students) || undefined } as never)}>
|
||||
<Button
|
||||
disabled={createMut.isPending || !form.name}
|
||||
onClick={() =>
|
||||
createMut.mutate({
|
||||
name: form.name,
|
||||
course_id: Number(form.course_id) || undefined,
|
||||
start_date: form.start_date || undefined,
|
||||
end_date: form.end_date || undefined,
|
||||
} as never)
|
||||
}
|
||||
>
|
||||
{createMut.isPending ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Edit Classroom</DialogTitle></DialogHeader>
|
||||
{editBatch && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
value={editBatch.name}
|
||||
onChange={(e) => setEditBatch((prev) => prev ? { ...prev, name: e.target.value } : prev)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Course</Label>
|
||||
<Select
|
||||
value={editBatch.course_id}
|
||||
onValueChange={(v) => setEditBatch((prev) => prev ? { ...prev, course_id: v } : prev)}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{courses.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>{c.title || c.code}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>Start Date</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={editBatch.start_date}
|
||||
onChange={(e) => setEditBatch((prev) => prev ? { ...prev, start_date: e.target.value } : prev)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>End Date</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={editBatch.end_date}
|
||||
onChange={(e) => setEditBatch((prev) => prev ? { ...prev, end_date: e.target.value } : prev)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={updateMut.isPending || !editBatch?.name}
|
||||
onClick={() => {
|
||||
if (!editBatch) return;
|
||||
updateMut.mutate({
|
||||
id: editBatch.id,
|
||||
data: {
|
||||
name: editBatch.name,
|
||||
course_id: Number(editBatch.course_id) || undefined,
|
||||
start_date: editBatch.start_date || undefined,
|
||||
end_date: editBatch.end_date || undefined,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{updateMut.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Students Management Dialog */}
|
||||
<Dialog open={studentsOpen} onOpenChange={setStudentsOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Classroom Students — {currentBatch?.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{currentBatch?.course_name && `Course: ${currentBatch.course_name}`}
|
||||
{currentBatch ? ` · ${currentBatch.student_count} student(s)` : ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[320px] overflow-y-auto space-y-1">
|
||||
{currentBatch?.student_ids?.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
No students in this classroom yet.
|
||||
</p>
|
||||
) : (
|
||||
allStudents
|
||||
.filter((s) => batchStudentIds.has(s.id))
|
||||
.map((s) => (
|
||||
<div key={s.id} className="flex items-center justify-between p-2 rounded hover:bg-muted/50">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{s.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{s.email}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-destructive"
|
||||
onClick={() => {
|
||||
if (studentsBatchId && window.confirm(`Remove ${s.name} from this classroom?`))
|
||||
removeStudentMut.mutate({ batchId: studentsBatchId, studentIds: [s.id] });
|
||||
}}
|
||||
>
|
||||
<UserMinus className="h-3.5 w-3.5 mr-1" /> Remove
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setStudentsOpen(false)}>Close</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedAddIds([]);
|
||||
setAddStudentsOpen(true);
|
||||
}}
|
||||
>
|
||||
<UserPlus className="h-4 w-4 mr-1" /> Add Students
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Add Students to Batch Dialog */}
|
||||
<Dialog open={addStudentsOpen} onOpenChange={setAddStudentsOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Students to {currentBatch?.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select students to add to this classroom.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[320px] overflow-y-auto space-y-1">
|
||||
{availableStudents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
All students are already in this classroom.
|
||||
</p>
|
||||
) : (
|
||||
availableStudents.map((s) => (
|
||||
<label key={s.id} className="flex items-center gap-3 p-2 rounded hover:bg-muted/50 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={selectedAddIds.includes(s.id)}
|
||||
onCheckedChange={() =>
|
||||
setSelectedAddIds((prev) =>
|
||||
prev.includes(s.id) ? prev.filter((x) => x !== s.id) : [...prev, s.id],
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{s.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{s.email}</p>
|
||||
</div>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddStudentsOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={addStudentsMut.isPending || selectedAddIds.length === 0}
|
||||
onClick={() => {
|
||||
if (studentsBatchId)
|
||||
addStudentsMut.mutate({ batchId: studentsBatchId, studentIds: selectedAddIds });
|
||||
}}
|
||||
>
|
||||
{addStudentsMut.isPending
|
||||
? "Adding..."
|
||||
: `Add ${selectedAddIds.length} Student(s)`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
191
src/pages/EmailVerification.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
|
||||
import { GraduationCap, Loader2 } from "lucide-react";
|
||||
import { useResendOtp, useVerifyEmail } from "@/hooks/queries/useSignup";
|
||||
import { ApiError } from "@/lib/api-client";
|
||||
|
||||
const RESEND_LIMIT = 3;
|
||||
const COOLDOWN_SEC = 60;
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
if (err instanceof ApiError) return err.message;
|
||||
if (err instanceof Error) return err.message;
|
||||
return "Verification failed";
|
||||
}
|
||||
|
||||
export default function EmailVerification() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const email = searchParams.get("email")?.trim() || "";
|
||||
const navigate = useNavigate();
|
||||
const verify = useVerifyEmail();
|
||||
const resend = useResendOtp();
|
||||
const [otp, setOtp] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [resendAttempts, setResendAttempts] = useState(0);
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const submittingRef = useRef(false);
|
||||
const lastSubmittedOtp = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (otp.length < 6) lastSubmittedOtp.current = null;
|
||||
}, [otp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldown <= 0) return;
|
||||
const t = window.setInterval(() => {
|
||||
setCooldown((c) => (c <= 1 ? 0 : c - 1));
|
||||
}, 1000);
|
||||
return () => window.clearInterval(t);
|
||||
}, [cooldown]);
|
||||
|
||||
const runVerify = useCallback(
|
||||
async (code: string) => {
|
||||
if (!email || code.length !== 6) return;
|
||||
setError(null);
|
||||
submittingRef.current = true;
|
||||
try {
|
||||
await verify.mutateAsync({ email, otp: code });
|
||||
navigate("/onboarding", { replace: true });
|
||||
} catch (e) {
|
||||
lastSubmittedOtp.current = null;
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
submittingRef.current = false;
|
||||
}
|
||||
},
|
||||
[email, navigate, verify],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (otp.length !== 6 || submittingRef.current || verify.isPending) return;
|
||||
if (lastSubmittedOtp.current === otp) return;
|
||||
lastSubmittedOtp.current = otp;
|
||||
void runVerify(otp);
|
||||
}, [otp, runVerify, verify.isPending]);
|
||||
|
||||
const handleResend = async () => {
|
||||
if (!email || cooldown > 0 || resendAttempts >= RESEND_LIMIT || resend.isPending) return;
|
||||
setError(null);
|
||||
try {
|
||||
await resend.mutateAsync(email);
|
||||
setResendAttempts((n) => n + 1);
|
||||
setCooldown(COOLDOWN_SEC);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
if (!email) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-muted/40 to-background p-4">
|
||||
<Card className="w-full max-w-md border-0 shadow-lg">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>Missing email</CardTitle>
|
||||
<CardDescription>Add an email query parameter or return to registration.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/register">Back to registration</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-muted/40 to-background p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="h-10 w-10 rounded-xl bg-primary flex items-center justify-center shadow-sm">
|
||||
<GraduationCap className="h-6 w-6 text-primary-foreground" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Verify your email</h1>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 shadow-xl bg-card/80 backdrop-blur-sm">
|
||||
<CardHeader className="text-center space-y-2">
|
||||
<CardTitle className="text-lg">Check your inbox</CardTitle>
|
||||
<CardDescription className="text-base leading-relaxed">
|
||||
We've sent a 6-digit verification code to{" "}
|
||||
<span className="font-medium text-foreground">{email}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<InputOTP maxLength={6} value={otp} onChange={setOtp} disabled={verify.isPending}>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
{verify.isPending && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Verifying…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-center text-sm text-destructive bg-destructive/10 rounded-md py-2 px-3">{error}</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={() => void runVerify(otp)}
|
||||
disabled={otp.length !== 6 || verify.isPending}
|
||||
>
|
||||
{verify.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Verifying
|
||||
</>
|
||||
) : (
|
||||
"Verify"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm space-y-2">
|
||||
<p className="text-muted-foreground">
|
||||
Resend attempts:{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{resendAttempts}/{RESEND_LIMIT}
|
||||
</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleResend()}
|
||||
disabled={cooldown > 0 || resendAttempts >= RESEND_LIMIT || resend.isPending}
|
||||
className="text-primary font-medium hover:underline disabled:opacity-50 disabled:no-underline"
|
||||
>
|
||||
{resend.isPending ? (
|
||||
"Sending…"
|
||||
) : cooldown > 0 ? (
|
||||
`Resend code in ${cooldown}s`
|
||||
) : (
|
||||
"Resend code"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Wrong address?{" "}
|
||||
<Link to="/register" className="text-primary font-medium hover:underline">
|
||||
Change email
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +1,429 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Search, Plus, Building2, Trash2 } from "lucide-react";
|
||||
import { useDepartments, useCreateDepartment, useDeleteDepartment } from "@/hooks/queries";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Search, Plus, Building2, Trash2, Pencil, Users, Shield, UserCog } from "lucide-react";
|
||||
import { entitiesService } from "@/services/entities.service";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
const ENTITY_TYPES = [
|
||||
{ value: "corporate", label: "Corporate" },
|
||||
{ value: "university", label: "University" },
|
||||
{ value: "school", label: "School" },
|
||||
{ value: "government", label: "Government" },
|
||||
{ value: "freelance", label: "Freelance" },
|
||||
];
|
||||
|
||||
export default function EntitiesPage() {
|
||||
const { user } = useAuth();
|
||||
const isAdminUser = user?.user_type === "admin";
|
||||
const [search, setSearch] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState({ name: "", code: "" });
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editEntity, setEditEntity] = useState<{ id: number; name: string; code: string; type: string } | null>(null);
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
const [manageEntity, setManageEntity] = useState<{ id: number; name: string } | null>(null);
|
||||
const [usersSearch, setUsersSearch] = useState("");
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<Set<number>>(new Set());
|
||||
const [form, setForm] = useState({ name: "", code: "", type: "" });
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const deptsQ = useDepartments({ size: 200 });
|
||||
const createMut = useCreateDepartment();
|
||||
const deleteMut = useDeleteDepartment();
|
||||
const entitiesQ = useQuery({
|
||||
queryKey: ["entities"],
|
||||
queryFn: () => entitiesService.list({ size: 200 }),
|
||||
});
|
||||
|
||||
const depts = deptsQ.data?.items ?? deptsQ.data?.data ?? [];
|
||||
const departments = Array.isArray(depts) ? depts : [];
|
||||
const filtered = departments.filter(d => d.name?.toLowerCase().includes(search.toLowerCase()));
|
||||
const loading = deptsQ.isLoading;
|
||||
const platformUsersQ = useQuery({
|
||||
queryKey: ["platform-users-for-entities"],
|
||||
queryFn: () => entitiesService.listPlatformUsers({ size: 500 }),
|
||||
enabled: isAdminUser && manageOpen,
|
||||
});
|
||||
|
||||
function handleCreate() {
|
||||
createMut.mutate(
|
||||
{ name: form.name, code: form.code || undefined },
|
||||
{
|
||||
onSuccess: () => { setCreateOpen(false); setForm({ name: "", code: "" }); toast({ title: "Department created" }); },
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
},
|
||||
const entityUsersQ = useQuery({
|
||||
queryKey: ["entity-users", manageEntity?.id],
|
||||
queryFn: () => entitiesService.listEntityUsers(manageEntity!.id),
|
||||
enabled: isAdminUser && !!manageEntity?.id,
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (data: { name: string; code?: string; type?: string }) =>
|
||||
entitiesService.create(data as Partial<import("@/types").Entity>),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["entities"] });
|
||||
setCreateOpen(false);
|
||||
setForm({ name: "", code: "", type: "" });
|
||||
toast({ title: "Entity created" });
|
||||
},
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) =>
|
||||
entitiesService.update(id, data as Partial<import("@/types").Entity>),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["entities"] });
|
||||
setEditOpen(false);
|
||||
toast({ title: "Entity updated" });
|
||||
},
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: number) => entitiesService.delete(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["entities"] });
|
||||
toast({ title: "Deleted" });
|
||||
},
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const saveEntityUsersMut = useMutation({
|
||||
mutationFn: ({ entityId, userIds }: { entityId: number; userIds: number[] }) =>
|
||||
entitiesService.updateEntityUsers(entityId, userIds),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["entities"] });
|
||||
qc.invalidateQueries({ queryKey: ["entity-users"] });
|
||||
setManageOpen(false);
|
||||
setManageEntity(null);
|
||||
setUsersSearch("");
|
||||
setSelectedUserIds(new Set());
|
||||
toast({ title: "Entity users updated" });
|
||||
},
|
||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const raw = entitiesQ.data as unknown;
|
||||
const entities: { id: number; name: string; code: string; type: string; user_count: number; role_count: number; active: boolean }[] = (() => {
|
||||
if (!raw) return [];
|
||||
if (Array.isArray(raw)) return raw as never[];
|
||||
const r = raw as { items?: unknown; data?: unknown };
|
||||
const arr = (r.items ?? r.data ?? []) as never[];
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
})();
|
||||
|
||||
const filtered = entities.filter(
|
||||
(e) =>
|
||||
e.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
e.code?.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
const loading = entitiesQ.isLoading;
|
||||
|
||||
useEffect(() => {
|
||||
if (!entityUsersQ.data) return;
|
||||
setSelectedUserIds(new Set(entityUsersQ.data.map((u) => u.id)));
|
||||
}, [entityUsersQ.data]);
|
||||
|
||||
const filteredPlatformUsers = useMemo(() => {
|
||||
const all = platformUsersQ.data ?? [];
|
||||
const q = usersSearch.trim().toLowerCase();
|
||||
if (!q) return all;
|
||||
return all.filter((u) =>
|
||||
(u.name || "").toLowerCase().includes(q)
|
||||
|| (u.email || "").toLowerCase().includes(q)
|
||||
|| (u.login || "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [platformUsersQ.data, usersSearch]);
|
||||
|
||||
function openEdit(e: typeof entities[0]) {
|
||||
setEditEntity({ id: e.id, name: e.name, code: e.code, type: e.type });
|
||||
setEditOpen(true);
|
||||
}
|
||||
|
||||
function openManageUsers(e: typeof entities[0]) {
|
||||
setManageEntity({ id: e.id, name: e.name });
|
||||
setUsersSearch("");
|
||||
setSelectedUserIds(new Set());
|
||||
setManageOpen(true);
|
||||
}
|
||||
|
||||
function toggleUser(userId: number) {
|
||||
setSelectedUserIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(userId)) next.delete(userId);
|
||||
else next.add(userId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Entities / Departments</h1>
|
||||
<p className="text-muted-foreground">Manage organizational departments.</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Entities</h1>
|
||||
<p className="text-muted-foreground">Manage organizations and entities on the platform.</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Create Department
|
||||
<Plus className="h-4 w-4 mr-1" /> Create Entity
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder="Search departments..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder="Search entities..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<Badge variant="secondary">{entities.length} total</Badge>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filtered.length === 0 && <p className="col-span-full text-center text-muted-foreground py-8">No departments found.</p>}
|
||||
{filtered.map((d) => (
|
||||
<Card key={d.id} className="border-0 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-primary" />
|
||||
{d.name}
|
||||
</CardTitle>
|
||||
<Button size="sm" variant="ghost" className="text-destructive" onClick={() => { if (window.confirm(`Delete "${d.name}"?`)) deleteMut.mutate(d.id, { onSuccess: () => toast({ title: "Deleted" }), onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }) }); }}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{d.code || "No code"}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<div className="flex items-center justify-center min-h-[300px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Users</TableHead>
|
||||
<TableHead>Roles</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
|
||||
No entities found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{filtered.map((e) => (
|
||||
<TableRow key={e.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-primary" />
|
||||
{e.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell><code className="text-xs bg-muted px-1.5 py-0.5 rounded">{e.code}</code></TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">{e.type || "—"}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary"><Users className="h-3 w-3 mr-1" />{e.user_count ?? 0}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary"><Shield className="h-3 w-3 mr-1" />{e.role_count ?? 0}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={e.active !== false ? "default" : "secondary"}>
|
||||
{e.active !== false ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
{isAdminUser ? (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openManageUsers(e)}>
|
||||
<UserCog className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(e)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete entity "${e.name}"?`))
|
||||
deleteMut.mutate(e.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create Dialog */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Create Department</DialogTitle></DialogHeader>
|
||||
<DialogHeader><DialogTitle>Create Entity</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2"><Label>Name</Label><Input value={form.name} onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))} placeholder="Department name" /></div>
|
||||
<div className="space-y-2"><Label>Code</Label><Input value={form.code} onChange={(e) => setForm(f => ({ ...f, code: e.target.value }))} placeholder="DEPT01" /></div>
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Organization name" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Code</Label>
|
||||
<Input value={form.code} onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))} placeholder="ORG_CODE (auto-generated if empty)" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Type</Label>
|
||||
<Select value={form.type} onValueChange={(v) => setForm((f) => ({ ...f, type: v }))}>
|
||||
<SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ENTITY_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button disabled={createMut.isPending || !form.name} onClick={handleCreate}>
|
||||
<Button
|
||||
disabled={createMut.isPending || !form.name.trim()}
|
||||
onClick={() =>
|
||||
createMut.mutate({
|
||||
name: form.name.trim(),
|
||||
code: form.code.trim() || undefined,
|
||||
type: form.type || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
{createMut.isPending ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Edit Entity</DialogTitle></DialogHeader>
|
||||
{editEntity && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
value={editEntity.name}
|
||||
onChange={(e) => setEditEntity((prev) => prev ? { ...prev, name: e.target.value } : prev)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Code</Label>
|
||||
<Input
|
||||
value={editEntity.code}
|
||||
onChange={(e) => setEditEntity((prev) => prev ? { ...prev, code: e.target.value } : prev)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Type</Label>
|
||||
<Select
|
||||
value={editEntity.type}
|
||||
onValueChange={(v) => setEditEntity((prev) => prev ? { ...prev, type: v } : prev)}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ENTITY_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={updateMut.isPending || !editEntity?.name.trim()}
|
||||
onClick={() => {
|
||||
if (!editEntity) return;
|
||||
updateMut.mutate({
|
||||
id: editEntity.id,
|
||||
data: { name: editEntity.name, code: editEntity.code, type: editEntity.type },
|
||||
});
|
||||
}}
|
||||
>
|
||||
{updateMut.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Manage Users Dialog (admin only) */}
|
||||
<Dialog open={manageOpen} onOpenChange={(open) => {
|
||||
setManageOpen(open);
|
||||
if (!open) {
|
||||
setManageEntity(null);
|
||||
setUsersSearch("");
|
||||
setSelectedUserIds(new Set());
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Users — {manageEntity?.name ?? ""}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{!isAdminUser ? (
|
||||
<div className="text-sm text-muted-foreground py-4">Only admin users can manage entity members.</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search users by name/email/login..."
|
||||
className="pl-9"
|
||||
value={usersSearch}
|
||||
onChange={(e) => setUsersSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border max-h-[420px] overflow-y-auto">
|
||||
{platformUsersQ.isLoading || entityUsersQ.isLoading ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">Loading users...</div>
|
||||
) : filteredPlatformUsers.length === 0 ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">No users found.</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{filteredPlatformUsers.map((u) => (
|
||||
<label key={u.id} className="flex items-center gap-3 p-3 hover:bg-muted/40 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={selectedUserIds.has(u.id)}
|
||||
onCheckedChange={() => toggleUser(u.id)}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{u.name || u.login || u.email}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{u.email || u.login}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<div className="text-xs text-muted-foreground mr-auto">
|
||||
{selectedUserIds.size} user(s) selected
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setManageOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={!manageEntity || saveEntityUsersMut.isPending || !isAdminUser}
|
||||
onClick={() => {
|
||||
if (!manageEntity) return;
|
||||
saveEntityUsersMut.mutate({
|
||||
entityId: manageEntity.id,
|
||||
userIds: Array.from(selectedUserIds),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{saveEntityUsersMut.isPending ? "Saving..." : "Save Users"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GraduationCap, Play, Sparkles } from "lucide-react";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
|
||||
export default function ExamPage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh] gap-4 max-w-md mx-auto">
|
||||
<AiTipBanner tip="Based on your practice history, focus on Reading Part 3 (sentence completion) — your accuracy there is 58% vs 82% average. Budget 20 min for the writing section." variant="recommendation" />
|
||||
|
||||
<Card className="border-0 shadow-sm w-full">
|
||||
<CardContent className="p-8 text-center space-y-6">
|
||||
<div className="mx-auto h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<GraduationCap className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-1">Ready to Start?</h2>
|
||||
<p className="text-muted-foreground text-sm">IELTS Academic Mock Exam</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Student</span>
|
||||
<span className="font-medium">Sarah Johnson</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Level</span>
|
||||
<Badge variant="outline">B2</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Modules</span>
|
||||
<span className="font-medium">4</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Duration</span>
|
||||
<span className="font-medium">180 min</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Mode</span>
|
||||
<Badge variant="secondary">Practice</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-primary/5 border border-primary/20 p-3 text-left">
|
||||
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-1"><Sparkles className="h-3 w-3" /> AI Pre-Exam Tip</p>
|
||||
<p className="text-xs text-muted-foreground">Your last mock scored 7.5. To target 8.0, focus on time management in Writing Task 2 — you spent 45 min last time vs recommended 40 min.</p>
|
||||
</div>
|
||||
<Button className="w-full" size="lg">
|
||||
<Play className="h-4 w-4 mr-2" /> Begin Exam
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,50 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Search } from "lucide-react";
|
||||
import { Search, Plus, FileText, Clock } from "lucide-react";
|
||||
import { useInstitutionalExamSessions } from "@/hooks/queries";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
interface CustomExam {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
total_time_min: number;
|
||||
sections: { id: number; title: string; skill: string }[];
|
||||
teacher_id: number | null;
|
||||
template_id: number | null;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
|
||||
published: "default",
|
||||
draft: "secondary",
|
||||
archived: "outline",
|
||||
};
|
||||
|
||||
export default function ExamsListPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const sessionsQ = useInstitutionalExamSessions();
|
||||
const items = sessionsQ.data?.data ?? sessionsQ.data?.items ?? [];
|
||||
const sessions = Array.isArray(items) ? items : [];
|
||||
const [tab, setTab] = useState<"custom" | "sessions">("custom");
|
||||
|
||||
const customQ = useQuery({
|
||||
queryKey: ["custom-exams", search],
|
||||
queryFn: () => api.get<{ items: CustomExam[]; total: number }>(`/exam/custom/list?search=${encodeURIComponent(search)}&per_page=50`),
|
||||
});
|
||||
|
||||
const sessionsQ = useInstitutionalExamSessions();
|
||||
const sessionItems = sessionsQ.data?.items ?? [];
|
||||
const sessions: Record<string, string>[] = Array.isArray(sessionItems)
|
||||
? (sessionItems as unknown as Record<string, string>[])
|
||||
: [];
|
||||
|
||||
const customExams = customQ.data?.items ?? [];
|
||||
const q = search.toLowerCase();
|
||||
const filtered = sessions.filter(s =>
|
||||
const filteredSessions = sessions.filter((s) =>
|
||||
s.name?.toLowerCase().includes(q) || s.course_name?.toLowerCase().includes(q),
|
||||
);
|
||||
|
||||
@@ -23,8 +53,20 @@ export default function ExamsListPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Exams List</h1>
|
||||
<p className="text-muted-foreground">Browse and manage all exam sessions.</p>
|
||||
<p className="text-muted-foreground">Browse and manage all exams and exam sessions.</p>
|
||||
</div>
|
||||
<Link to="/admin/exam/create">
|
||||
<Button><Plus className="h-4 w-4 mr-2" /> Create Exam</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant={tab === "custom" ? "default" : "outline"} size="sm" onClick={() => setTab("custom")}>
|
||||
<FileText className="h-4 w-4 mr-1" /> Custom Exams ({customExams.length})
|
||||
</Button>
|
||||
<Button variant={tab === "sessions" ? "default" : "outline"} size="sm" onClick={() => setTab("sessions")}>
|
||||
<Clock className="h-4 w-4 mr-1" /> Exam Sessions ({filteredSessions.length})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-sm">
|
||||
@@ -32,44 +74,92 @@ export default function ExamsListPage() {
|
||||
<Input placeholder="Search exams..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{sessionsQ.isLoading ? (
|
||||
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
||||
) : (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>#</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Course</TableHead>
|
||||
<TableHead>Batch</TableHead>
|
||||
<TableHead>Start</TableHead>
|
||||
<TableHead>End</TableHead>
|
||||
<TableHead>State</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.length === 0 && (
|
||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No exam sessions found.</TableCell></TableRow>
|
||||
)}
|
||||
{filtered.map((s, i) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell>{i + 1}</TableCell>
|
||||
<TableCell className="font-medium">{s.name}</TableCell>
|
||||
<TableCell>{s.course_name || "—"}</TableCell>
|
||||
<TableCell>{s.batch_name || "—"}</TableCell>
|
||||
<TableCell>{s.start_date || "—"}</TableCell>
|
||||
<TableCell>{s.end_date || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={s.state === "done" ? "default" : s.state === "schedule" ? "secondary" : "outline"} className="capitalize">{s.state}</Badge>
|
||||
</TableCell>
|
||||
{tab === "custom" && (
|
||||
customQ.isLoading ? (
|
||||
<div className="flex items-center justify-center min-h-[200px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
||||
) : (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Modules</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{customExams.length === 0 && (
|
||||
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">No custom exams found. Submit one from the Generation page.</TableCell></TableRow>
|
||||
)}
|
||||
{customExams.map((exam, i) => (
|
||||
<TableRow key={exam.id}>
|
||||
<TableCell className="text-muted-foreground">{exam.id}</TableCell>
|
||||
<TableCell className="font-medium">{exam.title}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{exam.sections.length > 0
|
||||
? exam.sections.map((s) => (
|
||||
<Badge key={s.id} variant="outline" className="text-xs capitalize">{s.skill || s.title}</Badge>
|
||||
))
|
||||
: <span className="text-muted-foreground text-xs">—</span>}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{exam.total_time_min ? `${exam.total_time_min} min` : "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusColors[exam.status] ?? "outline"} className="capitalize">{exam.status}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === "sessions" && (
|
||||
sessionsQ.isLoading ? (
|
||||
<div className="flex items-center justify-center min-h-[200px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
||||
) : (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>#</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Course</TableHead>
|
||||
<TableHead>Batch</TableHead>
|
||||
<TableHead>Start</TableHead>
|
||||
<TableHead>End</TableHead>
|
||||
<TableHead>State</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredSessions.length === 0 && (
|
||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No exam sessions found.</TableCell></TableRow>
|
||||
)}
|
||||
{filteredSessions.map((s, i) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell>{i + 1}</TableCell>
|
||||
<TableCell className="font-medium">{s.name}</TableCell>
|
||||
<TableCell>{s.course_name || "—"}</TableCell>
|
||||
<TableCell>{s.batch_name || "—"}</TableCell>
|
||||
<TableCell>{s.start_date || "—"}</TableCell>
|
||||
<TableCell>{s.end_date || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={s.state === "done" ? "default" : s.state === "schedule" ? "secondary" : "outline"} className="capitalize">{s.state}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function ForgotPassword() {
|
||||
) : null}
|
||||
<div className="mt-6 text-center">
|
||||
<Link to="/login" className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
|
||||
<ArrowLeft className="h-3 w-3" /> Back to sign in
|
||||
<ArrowLeft className="h-3 w-3 rtl:rotate-180" /> Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,34 +1,122 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CheckCircle, PenTool, Sparkles } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
CheckCircle,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Circle,
|
||||
} from "lucide-react";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { trainingService, type CefrLevel } from "@/services/training.service";
|
||||
|
||||
const grammarItems = [
|
||||
{ rule: "Conditional Sentences (Type 2 & 3)", description: "If + past simple / past perfect for hypothetical situations", level: "B2", completed: true },
|
||||
{ rule: "Passive Voice in Academic Writing", description: "Using passive constructions in formal reports", level: "B2", completed: false },
|
||||
{ rule: "Relative Clauses", description: "Defining vs non-defining relative clauses", level: "B1", completed: true },
|
||||
{ rule: "Subject-Verb Agreement", description: "Complex subjects with prepositional phrases", level: "B1", completed: false },
|
||||
{ rule: "Modal Verbs for Speculation", description: "Must have, could have, might have + past participle", level: "C1", completed: false },
|
||||
{ rule: "Reported Speech", description: "Tense changes and time references in indirect speech", level: "B2", completed: true },
|
||||
];
|
||||
const LEVELS: (CefrLevel | "all")[] = ["all", "A1", "A2", "B1", "B2", "C1", "C2"];
|
||||
|
||||
export default function GrammarPage() {
|
||||
const [showCompleted, setShowCompleted] = useState(false);
|
||||
const completed = grammarItems.filter(g => g.completed).length;
|
||||
const filtered = showCompleted ? grammarItems : grammarItems.filter(g => !g.completed);
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [showCompleted, setShowCompleted] = useState(true);
|
||||
const [levelFilter, setLevelFilter] = useState<string>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
example: "",
|
||||
level: "B1" as CefrLevel,
|
||||
category: "general",
|
||||
});
|
||||
|
||||
const listQ = useQuery({
|
||||
queryKey: ["training-grammar", levelFilter, search],
|
||||
queryFn: () =>
|
||||
trainingService.listGrammar({
|
||||
...(levelFilter !== "all" ? { level: levelFilter } : {}),
|
||||
...(search ? { search } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
const items = listQ.data?.items ?? [];
|
||||
const summary = listQ.data?.summary;
|
||||
const filtered = showCompleted ? items : items.filter((g) => !g.completed);
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: trainingService.createGrammar,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["training-grammar"] });
|
||||
setCreateOpen(false);
|
||||
setForm({
|
||||
name: "",
|
||||
description: "",
|
||||
example: "",
|
||||
level: "B1",
|
||||
category: "general",
|
||||
});
|
||||
toast({ title: "Rule added" });
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg =
|
||||
err && typeof err === "object" && "message" in err
|
||||
? String((err as { message: unknown }).message)
|
||||
: "Failed to add rule";
|
||||
toast({ title: msg, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const delMut = useMutation({
|
||||
mutationFn: trainingService.deleteGrammar,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["training-grammar"] });
|
||||
toast({ title: "Rule deleted" });
|
||||
},
|
||||
});
|
||||
|
||||
const progressMut = useMutation({
|
||||
mutationFn: ({ id, completed }: { id: number; completed: boolean }) =>
|
||||
trainingService.setGrammarProgress(id, { completed }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["training-grammar"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Grammar Training</h1>
|
||||
<p className="text-muted-foreground">Master grammar rules essential for IELTS.</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Grammar Training</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage the grammar-rule library and track mastery by CEFR level.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AiTipBanner tip="You've completed 50% of grammar topics. Focus on Passive Voice next — it appears in 73% of IELTS Writing Task 1 questions and will boost your band score." variant="recommendation" />
|
||||
<AiTipBanner context="grammar" variant="recommendation" />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
@@ -36,31 +124,104 @@ export default function GrammarPage() {
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">Progress</CardTitle>
|
||||
<span className="text-sm text-muted-foreground">{completed}/{grammarItems.length} completed</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{summary
|
||||
? `${summary.completed}/${summary.total} completed (${summary.completion_rate}%)`
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Progress value={(completed / grammarItems.length) * 100} className="h-3" />
|
||||
<Progress value={summary?.completion_rate ?? 0} className="h-3" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch id="show" checked={showCompleted} onCheckedChange={setShowCompleted} />
|
||||
<Label htmlFor="show" className="text-sm">Show completed</Label>
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search rules…"
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Select value={levelFilter} onValueChange={setLevelFilter}>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LEVELS.map((l) => (
|
||||
<SelectItem key={l} value={l}>
|
||||
{l === "all" ? "All levels" : l}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="show-g"
|
||||
checked={showCompleted}
|
||||
onCheckedChange={setShowCompleted}
|
||||
/>
|
||||
<Label htmlFor="show-g" className="text-sm">
|
||||
Show completed
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{listQ.isLoading && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
|
||||
</div>
|
||||
)}
|
||||
{!listQ.isLoading && filtered.length === 0 && (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="text-center text-muted-foreground py-8">
|
||||
No grammar rules match.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{filtered.map((g) => (
|
||||
<Card key={g.rule} className="border-0 shadow-sm">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{g.completed && <CheckCircle className="h-4 w-4 text-success shrink-0" />}
|
||||
<div>
|
||||
<p className="font-semibold text-sm">{g.rule}</p>
|
||||
<p className="text-xs text-muted-foreground">{g.description}</p>
|
||||
</div>
|
||||
<Card key={g.id} className="border-0 shadow-sm">
|
||||
<CardContent className="p-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
onClick={() =>
|
||||
progressMut.mutate({ id: g.id, completed: !g.completed })
|
||||
}
|
||||
className="shrink-0 rounded-full p-0.5 hover:bg-muted transition"
|
||||
aria-label={g.completed ? "Mark incomplete" : "Mark complete"}
|
||||
>
|
||||
{g.completed ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-sm">{g.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{g.description}</p>
|
||||
{g.example && (
|
||||
<p className="text-xs italic text-muted-foreground mt-0.5">
|
||||
e.g. {g.example}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant="outline">{g.level}</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{g.category}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive"
|
||||
onClick={() => delMut.mutate(g.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Badge variant="outline">{g.level}</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
@@ -70,25 +231,133 @@ export default function GrammarPage() {
|
||||
<div className="space-y-4">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2"><Sparkles className="h-4 w-4 text-primary" /> AI Recommendations</CardTitle>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" /> AI Recommendations
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Practice passive voice transformations — high exam frequency</li>
|
||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Review modal verbs for IELTS Writing Task 1</li>
|
||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Focus on complex sentence structures for C1 readiness</li>
|
||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Your conditional sentences are strong — try advanced mixed conditionals</li>
|
||||
<li className="text-sm text-muted-foreground flex items-start gap-2">
|
||||
<span className="text-primary font-bold">•</span>
|
||||
Practice passive-voice transformations — high exam frequency.
|
||||
</li>
|
||||
<li className="text-sm text-muted-foreground flex items-start gap-2">
|
||||
<span className="text-primary font-bold">•</span>
|
||||
Review modal verbs for IELTS Writing Task 1.
|
||||
</li>
|
||||
<li className="text-sm text-muted-foreground flex items-start gap-2">
|
||||
<span className="text-primary font-bold">•</span>
|
||||
Focus on complex sentences for C1 readiness.
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-2"><Sparkles className="h-3 w-3" /> AI Study Plan</p>
|
||||
<p className="text-sm text-muted-foreground">Based on your progress, complete Passive Voice and Subject-Verb Agreement this week. At your current pace, you'll finish all B2 grammar by April 15.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{summary && (
|
||||
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-2">
|
||||
<Sparkles className="h-3 w-3" /> AI Study Plan
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{summary.remaining > 0
|
||||
? `Finish the remaining ${summary.remaining} rule(s) at one per day to stay on track.`
|
||||
: "You've mastered every active rule in the library."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Grammar Rule</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>Rule name</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder="e.g. Present Perfect vs Past Simple"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, description: e.target.value }))
|
||||
}
|
||||
placeholder="When and how this rule applies"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Example (optional)</Label>
|
||||
<Textarea
|
||||
value={form.example}
|
||||
onChange={(e) => setForm((f) => ({ ...f, example: e.target.value }))}
|
||||
placeholder="A sample sentence"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>CEFR level</Label>
|
||||
<Select
|
||||
value={form.level}
|
||||
onValueChange={(v) =>
|
||||
setForm((f) => ({ ...f, level: v as CefrLevel }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(LEVELS.filter((l) => l !== "all") as CefrLevel[]).map(
|
||||
(l) => (
|
||||
<SelectItem key={l} value={l}>
|
||||
{l}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Input
|
||||
value={form.category}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, category: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
!form.name.trim() ||
|
||||
!form.description.trim() ||
|
||||
createMut.isPending
|
||||
}
|
||||
onClick={() => createMut.mutate(form)}
|
||||
>
|
||||
{createMut.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> Adding…
|
||||
</>
|
||||
) : (
|
||||
"Add"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
// Update this page (the content is just a fallback if you fail to update the page)
|
||||
|
||||
const Index = () => {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<h1 className="mb-4 text-4xl font-bold">Welcome to Your Blank App</h1>
|
||||
<p className="text-xl text-muted-foreground">Start building your amazing project here!</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -10,6 +11,7 @@ import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { ApiError } from "@/lib/api-client";
|
||||
import type { UserRole } from "@/types/auth";
|
||||
import { LanguageToggle } from "@/components/LanguageToggle";
|
||||
|
||||
/** Keep in sync with `ProtectedRoute` post-login targets */
|
||||
function getRoleDashboard(role: string): string {
|
||||
@@ -48,11 +50,16 @@ export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email || !password) {
|
||||
toast({ title: "Error", description: "Please enter email and password", variant: "destructive" });
|
||||
toast({
|
||||
title: t("auth.errorTitle"),
|
||||
description: t("auth.missingCredentials"),
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,7 +68,11 @@ export default function Login() {
|
||||
const user = await login(email, password);
|
||||
navigate(getRoleDashboard(user.user_type));
|
||||
} catch (err: unknown) {
|
||||
toast({ title: "Login Failed", description: loginErrorMessage(err), variant: "destructive" });
|
||||
toast({
|
||||
title: t("auth.loginFailedTitle"),
|
||||
description: loginErrorMessage(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -69,35 +80,39 @@ export default function Login() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<div className="absolute top-4 right-4">
|
||||
<LanguageToggle />
|
||||
</div>
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center gap-3 mb-8">
|
||||
<img src="/logo.png" alt="EnCoach" className="h-12 w-12 rounded-xl object-contain" />
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<span className="text-[hsl(42,40%,62%)]">En</span>
|
||||
<span className="text-[hsl(240,20%,30%)]">Coach</span>
|
||||
</h1>
|
||||
<div className="flex items-center justify-center mb-8">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="EnCoach — Unlock your potential with AI powered platform"
|
||||
className="h-36 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-lg border-0 bg-card">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<CardTitle className="text-xl">Welcome back</CardTitle>
|
||||
<CardDescription>Sign in to your account to continue</CardDescription>
|
||||
<CardTitle className="text-xl">{t("auth.welcomeBack")}</CardTitle>
|
||||
<CardDescription>{t("auth.signInDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Label htmlFor="email">{t("auth.email")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
type="text"
|
||||
placeholder={t("auth.emailPlaceholder")}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={loading}
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
@@ -106,11 +121,13 @@ export default function Login() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
dir="ltr"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showPassword ? t("auth.password") : t("auth.password")}
|
||||
className="absolute end-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
@@ -119,19 +136,25 @@ export default function Login() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox id="remember" />
|
||||
<Label htmlFor="remember" className="text-sm font-normal cursor-pointer">Remember me</Label>
|
||||
<Label htmlFor="remember" className="text-sm font-normal cursor-pointer">
|
||||
{t("auth.rememberMe")}
|
||||
</Label>
|
||||
</div>
|
||||
<Link to="/forgot-password" className="text-sm text-primary hover:underline">Forgot password?</Link>
|
||||
<Link to="/forgot-password" className="text-sm text-primary hover:underline">
|
||||
{t("auth.forgotPassword")}
|
||||
</Link>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Sign in
|
||||
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
|
||||
{t("auth.signIn")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{" "}
|
||||
<Link to="/register" className="text-primary font-medium hover:underline">Sign up</Link>
|
||||
{t("auth.needAccount")}{" "}
|
||||
<Link to="/register" className="text-primary font-medium hover:underline">
|
||||
{t("auth.signUp")}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const NotFound = () => {
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
|
||||
@@ -11,10 +13,10 @@ const NotFound = () => {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted">
|
||||
<div className="text-center">
|
||||
<h1 className="mb-4 text-4xl font-bold">404</h1>
|
||||
<p className="mb-4 text-xl text-muted-foreground">Oops! Page not found</p>
|
||||
<h1 className="mb-4 text-4xl font-bold">{t("errors.notFoundCode")}</h1>
|
||||
<p className="mb-4 text-xl text-muted-foreground">{t("errors.notFoundMessage")}</p>
|
||||
<a href="/" className="text-primary underline hover:text-primary/90">
|
||||
Return to Home
|
||||
{t("errors.returnHome")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||