diff --git a/src/App.tsx b/src/App.tsx index 148daa8..01d5f7b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -71,6 +71,7 @@ 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")); @@ -84,6 +85,14 @@ 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")); @@ -201,7 +210,12 @@ const App = () => ( - + }> @@ -265,6 +279,7 @@ const App = () => ( }> }> } /> + } /> } /> } /> } /> @@ -291,6 +306,14 @@ const App = () => ( }> {/* LMS Dashboard */} } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* Original platform dashboard */} } /> {/* LMS pages */} diff --git a/src/components/AdminLmsLayout.tsx b/src/components/AdminLmsLayout.tsx index b734e53..9f1a4bb 100644 --- a/src/components/AdminLmsLayout.tsx +++ b/src/components/AdminLmsLayout.tsx @@ -1,4 +1,5 @@ import { Outlet, Link, useNavigate, useLocation } from "react-router-dom"; +import { Suspense } from "react"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, @@ -31,7 +32,7 @@ import { CalendarDays, Landmark, UserPlus, ScrollText, Award, HelpCircle as FaqIcon, Bell, Workflow, CalendarOff, DollarSign, BookMarked, BarChartHorizontal, TrendingUp, - Library, Activity, Warehouse, UserCog, Sparkles, + Library, Activity, Warehouse, UserCog, Sparkles, Compass, } from "lucide-react"; import React from "react"; import { useTranslation } from "react-i18next"; @@ -43,6 +44,7 @@ import { useTranslation } from "react-i18next"; interface NavItem { titleKey: string; url: string; icon: LucideIcon } const overviewItems: NavItem[] = [ + { 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 }, ]; @@ -65,6 +67,7 @@ const academicItems: NavItem[] = [ { 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 }, ]; @@ -219,6 +222,20 @@ 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 ( +
+
+
+ ); +} + // ============= Main Layout ============= export default function AdminLmsLayout() { const { user, logout } = useAuth(); @@ -279,7 +296,9 @@ export default function AdminLmsLayout() {
- + }> + +
diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 52751a1..0140528 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -1,4 +1,5 @@ import { Outlet, useLocation, Link, useNavigate } from "react-router-dom"; +import { Suspense } from "react"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { AppSidebar } from "@/components/AppSidebar"; import { @@ -77,6 +78,16 @@ function AppBreadcrumbs() { ); } +// Local Suspense fallback so the sidebar/header keep rendering while a +// lazy route chunk loads. See AdminLmsLayout for the full rationale. +function RouteContentFallback() { + return ( +
+
+
+ ); +} + export default function AppLayout() { const navigate = useNavigate(); @@ -127,7 +138,9 @@ export default function AppLayout() {
- + }> + +
diff --git a/src/components/NavLink.tsx b/src/components/NavLink.tsx index a561a95..8947eaf 100644 --- a/src/components/NavLink.tsx +++ b/src/components/NavLink.tsx @@ -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 { } const NavLink = forwardRef( - ({ 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 ( ( 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} /> ); diff --git a/src/components/QuickSetupWizard.tsx b/src/components/QuickSetupWizard.tsx new file mode 100644 index 0000000..152dc88 --- /dev/null +++ b/src/components/QuickSetupWizard.tsx @@ -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; 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; + /** 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 ( + +
+ {/* Heading + progress */} +
+
+
+ +

{t(titleKey)}

+
+

{t(subtitleKey)}

+
+ {totalCheckable > 0 && ( +
+
+ {t("quickSetup.progressLabel")} +
+
+ {completedCount}/{totalCheckable} +
+
+
+
+
+ )} +
+ + {/* Recommended flow */} +
+

+ {t("quickSetup.recommendedFlow")} +

+ +
    + {steps.map((step, index) => { + const query = queries[index]; + const Icon = step.icon; + const isDone = query?.data === true; + + return ( +
  1. + + + {/* Step number / done tick */} +
    + {isDone ? : index + 1} +
    + + {/* Title + description */} +
    +
    + +

    {t(step.titleKey)}

    + {isDone && ( + + {t("quickSetup.ready")} + + )} +
    +

    + {t(step.descriptionKey)} +

    +
    + + {/* CTA */} +
    + {step.helpKey && ( + + + + + + {t(step.helpKey)} + + + )} + +
    +
    +
    +
  2. + ); + })} +
+
+ + {/* Quick creates */} + {quickCreates.length > 0 && ( +
+

+ {t("quickSetup.otherQuickCreates")} +

+ +
+ {quickCreates.map((item) => { + const Icon = item.icon; + return ( + + +
+
+ +
+ {t(item.titleKey)} +
+ + {t(item.descriptionKey)} + +
+ + + +
+ ); + })} +
+
+ )} +
+ + ); +} + +export default QuickSetupWizard; diff --git a/src/components/RoleLayout.tsx b/src/components/RoleLayout.tsx index d069884..5cb34f4 100644 --- a/src/components/RoleLayout.tsx +++ b/src/components/RoleLayout.tsx @@ -1,4 +1,5 @@ import { Outlet, useNavigate } from "react-router-dom"; +import { Suspense } from "react"; import { useTranslation } from "react-i18next"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { @@ -77,6 +78,16 @@ 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 ( +
+
+
+ ); +} + export default function RoleLayout({ navGroups, role }: RoleLayoutProps) { const { user, logout } = useAuth(); const navigate = useNavigate(); @@ -167,7 +178,9 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
- + }> + +
diff --git a/src/components/TeacherLayout.tsx b/src/components/TeacherLayout.tsx index 768d481..5843d7e 100644 --- a/src/components/TeacherLayout.tsx +++ b/src/components/TeacherLayout.tsx @@ -2,7 +2,7 @@ import RoleLayout, { NavGroup } from "./RoleLayout"; import { LayoutDashboard, BookOpen, ClipboardList, CalendarCheck, Users, Calendar, User, MessageSquare, - Megaphone, Library, + Megaphone, Library, Sparkles, } from "lucide-react"; /** Teacher portal shell. See `StudentLayout` for the nav-config rationale. */ @@ -10,6 +10,7 @@ const navGroups: NavGroup[] = [ { labelKey: "sidebarGroup.teaching", items: [ + { 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 }, diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index 0853c44..e2df0ac 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -22,8 +22,11 @@ const badgeVariants = cva( export interface BadgeProps extends React.HTMLAttributes, VariantProps {} -function Badge({ className, variant, ...props }: BadgeProps) { - return
; -} +const Badge = React.forwardRef( + ({ className, variant, ...props }, ref) => ( +
+ ), +); +Badge.displayName = "Badge"; export { Badge, badgeVariants }; diff --git a/src/components/wizard/StepWizard.tsx b/src/components/wizard/StepWizard.tsx new file mode 100644 index 0000000..2440d87 --- /dev/null +++ b/src/components/wizard/StepWizard.tsx @@ -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 { + 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) => 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 { + state: TState; + update: (patch: Partial) => void; + error: string | null; +} + +export interface StepWizardProps { + /** 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[]; + initialState: TState; + /** Called once the user clicks Finish on the last step. */ + onFinish: (state: TState) => Promise | 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({ + titleKey, + subtitleKey, + backTo, + backLabelKey = "wizard.backToHub", + steps, + initialState, + onFinish, + finishLabelKey = "wizard.finish", + submitting = false, +}: StepWizardProps) { + const { t } = useTranslation(); + const [index, setIndex] = useState(0); + const [state, setState] = useState(initialState); + const [error, setError] = useState(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) => { + 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 ( +
+ {/* Heading + back link */} +
+ {backTo && ( + + )} +

{t(titleKey)}

+ {subtitleKey &&

{t(subtitleKey)}

} +
+ + {/* Stepper */} +
    + {steps.map((step, i) => { + const s = stepStatus[i]; + return ( +
  1. +
    + {s === "done" ? : i + 1} +
    + + {t(step.titleKey)} + + {i < steps.length - 1 &&
    } +
  2. + ); + })} +
+ + {/* Progress bar */} +
+
+
+ + {/* Active step */} + + +

{t(current.titleKey)}

+ {current.descriptionKey && ( +

{t(current.descriptionKey)}

+ )} +
+ + {current.render({ state, update, error })} + + {error && ( +
+ {error} +
+ )} +
+
+ + {/* Navigation */} +
+ + +
+ {t("wizard.stepOf", { current: index + 1, total: steps.length })} +
+ + +
+
+ ); +} + +export default StepWizard; diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index 1a97888..04c9e34 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -31,6 +31,8 @@ const ar: Translations = { email: "البريد الإلكتروني", user: "المستخدم", home: "الرئيسية", + saving: "جارٍ الحفظ…", + disabled: "معطّل", }, auth: { signIn: "تسجيل الدخول", @@ -50,6 +52,9 @@ const ar: Translations = { errorTitle: "خطأ", }, nav: { + smartWizard: "المعالج الذكي", + quickSetup: "الإعداد السريع", + coursePlans: "خطط المقررات (الذكاء الاصطناعي)", adminDashboard: "لوحة الإدارة", platformDashboard: "لوحة المنصّة", dashboard: "لوحة التحكم", @@ -66,7 +71,7 @@ const ar: Translations = { rubrics: "معايير التقييم", generation: "التوليد", reviewQueue: "قائمة المراجعة", - aiPrompts: "تعليمات الذكاء الاصطناعي", + aiPrompts: "وكلاء الذكاء الاصطناعي والأدوات", aiFeedback: "ملاحظات الذكاء الاصطناعي", approvalWorkflows: "سير عمل الموافقات", taxonomy: "التصنيف", @@ -337,6 +342,535 @@ const ar: Translations = { commentRequired: "من فضلك أخبرنا بالخطأ.", submit: "إرسال الملاحظات", }, + quickSetup: { + adminTitle: "الإعداد السريع", + adminSubtitle: + "كل ما تحتاجه لإطلاق منصّة جاهزة للامتحانات بالترتيب الموصى به. يتم تعليم كل خطوة تلقائياً عند اكتمالها.", + teacherTitle: "الإعداد السريع", + teacherSubtitle: "ابدأ دورة جديدة من البداية إلى النهاية، ثم انتقل إلى المهام اليومية الشائعة.", + progressLabel: "التقدم", + recommendedFlow: "المسار الموصى به", + otherQuickCreates: "إنشاء سريع آخر", + ready: "جاهز", + start: "ابدأ", + review: "مراجعة", + open: "فتح", + helpAria: "عرض المساعدة", + admin: { + step1: { + title: "أنشئ معيار تقييم (Rubric)", + description: "حدّد معايير التقييم لمهام الكتابة والتحدّث. تُستخدم هذه المعايير لاحقاً عند توليد الامتحانات واعتمادها.", + help: "المعيار هو شبكة تقييم (درجات × عناصر). يمكنك البدء من قالب جاهز أو تركيبه من عناصر محدّدة مسبقاً.", + }, + step2: { + title: "عرّف بنية الامتحان", + description: "حدّد الأقسام والمهام والأجزاء التي يجب أن يحتويها كل امتحان من هذا النوع سواء مولّداً أو مخصّصاً.", + help: "البنى تضمن الاتساق. مثلاً: بنية IELTS للكتابة تحتوي على المهمة ١ (١٥٠ كلمة) والمهمة ٢ (٢٥٠ كلمة)؛ سيرفض النظام الإرسال إن نقصت إحداهما.", + }, + step3: { + title: "ولّد أو أنشئ امتحاناً", + description: "استخدم التوليد بالذكاء الاصطناعي (الأسرع) أو أنشئ امتحاناً مخصّصاً يدوياً. احفظ كمسودة أو أرسله للموافقة.", + help: "التوليد يختار بنية ومعيار تقييم ثم يُنتج الأسئلة. المنشئ المخصّص يتيح لك تحكّماً كاملاً للامتحانات التجريبية.", + }, + step4: { + title: "راجع واعتمد", + description: "يعتمد المصدّقون الامتحانات قبل ظهورها للطلاب. اضبط سير العمل مرة واحدة ليتم توجيه الطلبات تلقائياً.", + help: "قائمة الاعتماد تعرض كل امتحان في انتظار المصادقة. الرفض يعيده للمؤلّف، والقبول ينشره.", + }, + step5: { + title: "عيّن للطلاب", + description: "جدول الامتحان المنشور، اختر الدفعة، وأرسله. يراه الطلاب فوراً في بوّابتهم.", + help: "يمكن استهداف طلاب أفراد أو دفعات أو صفوف كاملة مع مراعاة المنطقة الزمنية.", + }, + quick: { + course: { title: "دورة جديدة", description: "أنشئ هيكل دورة ليملأها المعلّمون بالفصول." }, + resource: { title: "رفع مورد", description: "أضف ملفات PDF أو صوت أو فيديو أو روابط إلى المكتبة المشتركة." }, + student: { title: "إضافة طالب", description: "أنشئ حساب طالب وعيّنه إلى دفعة." }, + teacher: { title: "إضافة معلّم", description: "ادعُ معلّماً وامنحه صلاحيات التدريس." }, + classroom: { title: "صف جديد", description: "جمّع الطلاب لأغراض الجدولة والحضور." }, + examSession: { title: "جدولة جلسة امتحان", description: "أنشئ جلسة امتحان مراقبة لامتحان مؤسسي." }, + customExam: { title: "امتحان مخصّص", description: "أنشئ امتحاناً يدوياً من الصفر بتحكّم كامل." }, + ticket: { title: "فتح تذكرة", description: "افتح تذكرة دعم نيابة عن مستخدم." }, + }, + }, + teacher: { + step1: { + title: "أنشئ دورة", + description: "امنح دورتك اسماً، اختر المادّة، وحدّد مستواها. يمكنك تعديل الفصول بعد الإنشاء.", + help: "الدورات هي الحاوية للفصول والمواد والواجبات.", + }, + step2: { + title: "أضف الفصول والمحتوى", + description: "افتح دورتك وأضف فصولاً تحتوي على دروس وفيديوهات واختبارات ومهام تدريب.", + help: "الفصول تنظّم أهداف التعلّم. استخدم الورشة الذكية لإنشاء المحتوى تلقائياً.", + }, + step3: { + title: "ارفع الموارد", + description: "شارك مع طلابك ملفات PDF أو صوت أو فيديو داعمة عبر المكتبة.", + help: "الملفات الكبيرة مدعومة — يقبل الخادم حتى ١٢٨ ميغابايت للرفعة الواحدة.", + }, + step4: { + title: "أنشئ واجباً", + description: "حوّل امتحاناً منشوراً أو مهمّة إلى واجب بتاريخ تسليم ودفعة مستهدفة.", + help: "تظهر الواجبات تلقائياً في لوحة كل طالب وفي جدوله.", + }, + step5: { + title: "تابع تقدّم الطلاب", + description: "راقب التسليمات والحضور ورؤى التعلّم التكيّفي لصفّك.", + help: "يحدّد محرك التعلّم التكيّفي الطلاب المعرّضين للخطر لتتدخّل مبكّراً.", + }, + quick: { + discussion: { title: "نقاش جديد", description: "ابدأ موضوع نقاش لصفّك." }, + announcement: { title: "إعلان جديد", description: "أرسل رسالة لجميع طلابك." }, + attendance: { title: "تسجيل الحضور", description: "سجّل حضور اليوم لإحدى الجلسات." }, + }, + }, + }, + wizardHub: { + title: "المعالج الذكي", + subtitle: + "اختر أي سيناريو وسيرشدك المعالج خطوة بخطوة. لا حاجة للبحث في الإعدادات — كلّ ضغطة على \"التالي\" تقرّبك أكثر من الإنجاز.", + recommendedOrder: "الترتيب الموصى به", + order: { + rubric: "أنشئ معايير التقييم (الكتابة / المحادثة)", + structure: "حدّد هيكل الامتحان (الأقسام والمهام والمدد)", + generate: "أنشئ امتحاناً تلقائياً أو يدوياً", + approve: "راجع الامتحانات المعلّقة واعتمدها", + assign: "أسند الامتحانات إلى الطلاب", + }, + guided: "معالجات موجّهة", + advanced: "الصفحات الكاملة (متقدّم)", + advancedBadge: "متقدّم", + aiBadge: "ذكاء اصطناعي", + startWizard: "ابدأ المعالج", + openPage: "افتح الصفحة", + cards: { + rubric: { + title: "إنشاء معيار تقييم", + description: "الاسم ← المهارة ← المعايير ← الوصف ← المراجعة. للكتابة والمحادثة فقط.", + }, + examStructure: { + title: "تعريف هيكل امتحان", + description: "الاسم ← الوحدات ← مهام الكتابة ← المراجعة. قالب قابل لإعادة الاستخدام.", + }, + course: { + title: "إنشاء مساق", + description: "العنوان ← المستوى والسعة ← المراجعة. الفصول تُضاف من صفحة المساق.", + }, + coursePlan: { + title: "توليد خطة مقرر (ذكاء اصطناعي)", + description: "اوصف المقرر فيكتب الذكاء الاصطناعي الأهداف ونتائج التعلّم لكل مهارة ونطاق القواعد وخطة التسليم أسبوعياً، ثم يولّد المواد التعليمية الجاهزة لكل أسبوع.", + }, + generation: { + title: "توليد امتحان", + description: "صفحة التوليد الكاملة بخيارات المعايير والهيكل وتحكّم في كل وحدة.", + }, + approval: { + title: "مسارات الموافقة", + description: "حدّد من يراجع الامتحانات، وتابع الطلبات المعلّقة.", + }, + assign: { + title: "إسناد إلى الطلاب", + description: "صفحة الجدولة الكاملة مع منتقي الطلاب والنوافذ الزمنية والمنطقة الزمنية.", + }, + resource: { + title: "رفع مورد", + description: "أضف ملفات PDF أو صوت أو فيديو أو روابط إلى المكتبة.", + }, + student: { + title: "إضافة طالب", + description: "أنشئ حساب طالب وسجّله في دفعة.", + }, + }, + }, + wizard: { + back: "السابق", + next: "التالي", + finish: "إنهاء", + backToHub: "العودة إلى المعالجات", + stepOf: "الخطوة {{current}} من {{total}}", + rubric: { + title: "إنشاء معيار تقييم", + subtitle: "شبكة تقييم لمهام الكتابة أو المحادثة، يُرجَع إليها عند توليد الامتحانات أو اعتمادها.", + finish: "إنشاء المعيار", + toastSuccess: "تم إنشاء المعيار", + toastError: "تعذّر إنشاء المعيار", + addCriterion: "إضافة معيار", + removeCriterion: "حذف المعيار", + unnamedCriterion: "(معيار بلا اسم)", + descriptorHint: "الوصف اختياري. يظهر للمصحّحين كإرشاد لكل معيار.", + maxLabel: "الحد الأقصى", + moduleHint: "تنطبق المعايير على الكتابة والمحادثة فقط. الاستماع والقراءة يُصحَّحان تلقائياً.", + step1: { + title: "الأساسيات", + description: "امنح المعيار اسماً وحدّد المهارة التي يطبّق عليها.", + }, + step2: { + title: "المعايير", + description: "أضف المعايير التي سيقوم المصحّحون بتقييمها. لكل معيار حد أقصى (مثل 9 لامتحان IELTS).", + }, + step3: { + title: "الأوصاف", + description: "اختياري: صف ما يقيسه كل معيار. يراها المصحّحون كتلميحات.", + }, + step4: { + title: "المراجعة", + description: "راجع وأنشئ.", + }, + labels: { + name: "اسم المعيار", + module: "المهارة", + description: "الوصف", + criterionName: "اسم المعيار الفرعي", + maxScore: "الحد الأقصى", + }, + placeholders: { + name: "مثال: IELTS مهمة كتابة 2", + description: "وصف قصير يظهر للمصحّحين.", + criterionName: "مثال: الاستجابة للمهمة", + descriptor: "ماذا يجب على المصحّح أن يتحقق منه لهذا المعيار.", + }, + modules: { + writing: "الكتابة", + speaking: "المحادثة", + }, + errors: { + nameRequired: "يرجى إدخال اسم للمعيار.", + moduleRestricted: "المعايير تنطبق على الكتابة أو المحادثة فقط.", + criterionRequired: "أضف معياراً واحداً على الأقل.", + criterionName: "كل معيار يحتاج إلى اسم.", + criterionScore: "الحد الأقصى يجب أن يكون بين 1 و 100.", + }, + }, + structure: { + title: "تعريف هيكل امتحان", + subtitle: "قالب يحدّد الأقسام والمهام التي يجب أن يحتويها كل امتحان من هذا النوع.", + finish: "إنشاء الهيكل", + toastSuccess: "تم إنشاء هيكل الامتحان", + toastError: "تعذّر إنشاء هيكل الامتحان", + industryHint: "اختياري: مثل \"الإنجليزية التجارية\"، \"الرعاية الصحية\".", + writingSkipped: "الكتابة غير مُحدَّدة ضمن الوحدات — تم تخطّي هذه الخطوة.", + wordsSuffix: "كلمة", + addTask: "إضافة مهمة", + removeTask: "حذف المهمة", + step1: { + title: "الأساسيات", + description: "سمِّ الهيكل وحدّد نوع الامتحان.", + }, + step2: { + title: "الوحدات", + description: "اختر الوحدات التي يغطّيها هذا الهيكل. سيراها الطلاب كأقسام.", + }, + step3: { + title: "مهام الكتابة", + description: "للكتابة، حدّد الحد الأدنى لعدد الكلمات لكل مهمة.", + }, + step4: { + title: "المراجعة", + description: "راجع وأنشئ.", + }, + labels: { + name: "اسم الهيكل", + industry: "المجال / السياق", + examType: "نوع الامتحان", + modules: "الوحدات", + taskLabel: "اسم المهمة", + minWords: "أدنى كلمات", + }, + placeholders: { + name: "مثال: IELTS أكاديمي كتابة", + industry: "مثال: الإنجليزية التجارية", + }, + examTypes: { + academic: "أكاديمي", + general: "عام", + }, + modules: { + listening: { + title: "الاستماع", + description: "أسئلة مبنيّة على الصوت (تصحيح تلقائي).", + }, + reading: { + title: "القراءة", + description: "أسئلة مبنيّة على مقاطع (تصحيح تلقائي).", + }, + writing: { + title: "الكتابة", + description: "مهام مقاليّة تُصحَّح باستخدام معيار تقييم.", + }, + speaking: { + title: "المحادثة", + description: "مهام شفهيّة تُصحَّح باستخدام معيار تقييم.", + }, + }, + errors: { + nameRequired: "يرجى إدخال اسم للهيكل.", + moduleRequired: "اختر وحدة واحدة على الأقل.", + writingTaskRequired: "الكتابة تحتاج إلى مهمة واحدة على الأقل.", + writingTaskLabel: "كل مهمة كتابة تحتاج إلى اسم.", + writingTaskWords: "الحد الأدنى للكلمات يجب أن يكون 1 على الأقل.", + }, + }, + course: { + title: "إنشاء مساق", + subtitle: "هيكل مساق خفيف. يمكنك إضافة الفصول والمواد وتسجيل الطلاب بعد الإنشاء.", + finish: "إنشاء المساق", + toastSuccess: "تم إنشاء المساق", + toastError: "تعذّر إنشاء المساق", + codeHint: "اتركه فارغاً لتوليده تلقائياً من العنوان.", + difficultyHint: "ما مدى تحدّي هذا المساق إجمالاً؟ يُستخدم للبحث والتصفية.", + cefrHint: "إن كان المساق يستهدف مستوى CEFR محدداً، اختره هنا.", + step1: { + title: "الأساسيات", + description: "امنح المساق اسماً ووصفاً قصيراً.", + }, + step2: { + title: "المستوى والسعة", + description: "حدّد الصعوبة ومستوى CEFR المستهدف والحد الأقصى لعدد الطلاب.", + }, + step3: { + title: "المراجعة", + description: "راجع وأنشئ.", + }, + labels: { + title: "عنوان المساق", + code: "رمز المساق", + description: "الوصف", + difficulty: "الصعوبة", + cefrLevel: "مستوى CEFR", + capacity: "السعة القصوى", + }, + placeholders: { + title: "مثال: إنجليزية تأسيسية المستوى 1", + code: "سيتم توليده تلقائياً إذا تُرك فارغاً", + description: "لمن هذا المساق؟ ماذا سيتعلّمون؟", + difficulty: "اختر مستوى صعوبة", + cefr: "اختر مستوى CEFR", + }, + difficulty: { + beginner: "مبتدئ", + intermediate: "متوسط", + advanced: "متقدّم", + }, + errors: { + titleRequired: "يرجى إدخال عنوان المساق.", + capacityRequired: "السعة القصوى يجب أن تكون 1 على الأقل.", + }, + }, + }, + coursePlan: { + listTitle: "خطط المقررات", + listSubtitle: + "خطط مناهج يُنشئها الذكاء الاصطناعي: الأهداف، ونتائج التعلّم لكل مهارة، ونطاق القواعد، وخطة التسليم أسبوعياً، مع توليد المواد التعليمية لكل أسبوع عند الطلب.", + generateNew: "توليد خطة جديدة", + searchPlaceholder: "ابحث عن خطة بالاسم…", + loadFailed: "تعذّر تحميل الخطط.", + deleted: "تم حذف الخطة.", + deleteFailed: "تعذّر حذف الخطة.", + delete: "حذف", + confirmDelete: "حذف الخطة \"{{name}}\"؟ سيؤدي ذلك إلى إزالة جميع الأسابيع والمواد.", + emptyTitle: "لا توجد خطط بعد", + emptySubtitle: "استخدم معالج الذكاء الاصطناعي لتوليد أول خطة — يستغرق الأمر نحو دقيقة.", + open: "فتح", + backToList: "العودة إلى الخطط", + noDescription: "لا يوجد وصف.", + weeksCount_one: "{{count}} أسبوع", + weeksCount_other: "{{count}} أسابيع", + materialsCount_one: "{{count}} مادة", + materialsCount_other: "{{count}} مواد", + hoursPerWeek: "{{count}} ساعة/أسبوع", + weekN: "الأسبوع {{n}}", + generateMaterials: "توليد مواد الأسبوع (ذكاء اصطناعي)", + regenerateMaterials: "إعادة توليد مواد الأسبوع", + generating: "جاري التوليد…", + generateHint: + "سيُنتج الذكاء الاصطناعي نصّ قراءة وسيناريو استماع ومحفّزات محادثة وسؤال كتابة ودرس قواعد وقائمة مفردات لهذا الأسبوع.", + weekMaterialsGenerated: "تم توليد مواد الأسبوع.", + weekMaterialsFailed: "تعذّر توليد مواد الأسبوع.", + generateSuccess: "تم توليد خطة المقرر.", + generateFailed: "تعذّر توليد خطة المقرر.", + deliveryHint: "افتح أي أسبوع لعرض النتائج المخطَّطة وتوليد المواد الجاهزة للاستخدام.", + status: { + draft: "مسودة", + generated: "مُولّدة", + approved: "معتمدة", + archived: "مؤرشفة", + }, + sections: { + objectives: "أهداف المقرر", + outcomes: "نتائج التعلّم حسب المهارة", + grammar: "نطاق القواعد", + assessment: "التقييم", + resources: "المصادر", + delivery: "خطة التسليم الأسبوعية", + }, + skill: { + reading: "القراءة", + writing: "الكتابة", + listening: "الاستماع", + speaking: "المحادثة", + grammar: "القواعد", + vocabulary: "المفردات", + integrated: "متكامل", + }, + materialType: { + reading_text: "نص قراءة", + listening_script: "نص استماع", + speaking_prompt: "محفّز محادثة", + writing_prompt: "مهمّة كتابة", + grammar_lesson: "درس قواعد", + vocabulary_list: "قائمة مفردات", + practice: "تمرين", + other: "مادة", + }, + table: { + skill: "المهارة", + outcomes: "النتائج", + remarks: "ملاحظات", + }, + wizard: { + title: "توليد خطة مقرر", + subtitle: + "اوصف المقرر مرّة واحدة، ويكتب الذكاء الاصطناعي الخطة كاملة — الأهداف والنتائج والقواعد والخطة الأسبوعية — ويمكنك توليد مواد الأسبوع الأول بضغطة واحدة.", + finish: "توليد الخطة", + reviewHint: "اضغط على \"توليد الخطة\" لبدء الذكاء الاصطناعي. يستغرق عادةً 30–60 ثانية، وستنتقل إلى صفحة الخطة عند الانتهاء.", + steps: { + basics: "الأساسيات", + basicsDesc: "سمِّ المقرر وحدّد المستوى والمدة وعدد الساعات الأسبوعية.", + coverage: "التغطية", + coverageDesc: "أخبر الذكاء الاصطناعي بتوزيع الساعات على المهارات ومن هم المتعلّمون.", + scope: "النطاق", + scopeDesc: "اختياري: تركيز القواعد، والمصادر، وملاحظات حرّة.", + review: "المراجعة", + reviewDesc: "راجع البيانات قبل بدء التوليد.", + }, + fields: { + title: "عنوان المقرر", + cefr: "مستوى CEFR", + cefrPlaceholder: "اختر مستوى", + totalWeeks: "إجمالي الأسابيع", + contactHours: "عدد الساعات أسبوعياً", + skillsDivision: "توزيع المهارات", + skillsDivisionHint: + "صيغة حرّة — مثال: \"10 س/أسبوع قراءة وكتابة + 8 س/أسبوع استماع ومحادثة\". اتركه فارغاً ليقرّر الذكاء الاصطناعي.", + learnerProfile: "ملف المتعلّمين", + learnerProfilePlaceholder: "من هم المتعلّمون؟ الفئة العمرية، اللغة الأم، الخلفية الدراسية، الأهداف…", + grammarFocus: "تركيز القواعد (Enter للإضافة)", + grammarFocusPlaceholder: "مثال: المضارع البسيط", + resources: "المصادر (Enter للإضافة)", + resourcesPlaceholder: "مثال: Pathways 1 (National Geographic)", + notes: "ملاحظات إضافية", + notesPlaceholder: "أي شيء آخر يحتاج الذكاء الاصطناعي معرفته.", + }, + errors: { + titleRequired: "يرجى إدخال عنوان المقرر.", + cefrRequired: "يرجى اختيار مستوى CEFR.", + weeksRange: "عدد الأسابيع يجب أن يكون 1 على الأقل.", + }, + }, + }, + aiAdmin: { + title: "وكلاء الذكاء الاصطناعي والأدوات", + subtitle: + "هيّئ الوكلاء المبنيين على LangGraph الذين يشغّلون تخطيط المقررات وتوليد الاختبارات والتمارين والمدرّس داخل LMS والتصحيح. الإعدادات الافتراضية جاهزة للاستخدام مباشرة.", + tabs: { + agents: "الوكلاء", + tools: "الأدوات", + prompts: "التعليمات", + }, + }, + agents: { + list: { + title: "الوكلاء", + subtitle: "مهيّأون مسبقاً لكل ركيزة في المنصة — يمكن تعديل الإعدادات الافتراضية أدناه.", + search: "ابحث عن وكيل…", + empty: "لا توجد وكلاء مطابقون لبحثك.", + }, + detail: { + configure: "ضبط الإعدادات", + empty: "اختر وكيلاً لعرض إعداداته.", + graph: "نوع الرسم البياني", + model: "النموذج", + temperature: "درجة الإبداع", + tokens: "أقصى عدد رموز", + format: "صيغة المخرجات", + fallback: "الاحتياطي", + revisions: "أقصى عدد مراجعات", + promptKey: "مفتاح القالب", + toolsTitle: "الأدوات المفعّلة", + noTools: "لا توجد أدوات مفعّلة لهذا الوكيل.", + systemPrompt: "تعليمات النظام", + noPrompt: "(فارغ)", + }, + config: { + title: "ضبط الإعدادات", + saved: "تم حفظ إعدادات الوكيل", + name: "اسم العرض", + promptKey: "مفتاح القالب (مع نسخ)", + description: "الوصف", + systemPrompt: "تعليمات النظام", + systemPromptHint: + "إذا تم تعيين مفتاح قالب أعلاه، فإن النسخة النشطة من ذلك القالب تستبدل هذا الحقل وقت التشغيل.", + model: "النموذج", + fallbackModel: "النموذج الاحتياطي", + responseFormat: "صيغة المخرجات", + text: "نص", + temperature: "درجة الإبداع", + maxTokens: "أقصى عدد رموز", + maxRevisions: "أقصى عدد مراجعات", + graphType: "بنية الرسم البياني", + qualityChecks: "فحوصات الجودة (مفاتيح أدوات مفصولة بفواصل)", + tools: "الأدوات المفعّلة", + mutates: "كتابة", + active: "الوكيل مفعّل", + }, + test: { + title: "محرّك الاختبار", + subtitle: + "أرسل طلباً صغيراً وافحص مخرجات الوكيل واستدعاءات الأدوات وتنبيهات الجودة.", + variables: "المتغيّرات (JSON)", + payload: "الطلب (نص أو JSON)", + payloadPlaceholder: + "ماذا يجب أن يفعل الوكيل؟ مثال: «أنشئ 5 أسئلة اختيار من متعدد للقراءة لمستوى B1.»", + run: "تشغيل الوكيل", + running: "جارٍ التشغيل…", + ok: "تم تنفيذ الوكيل بنجاح", + output: "المخرجات", + toolTrace: "سجلّ استدعاءات الأدوات", + iterations: "التكرارات", + revisions: "المراجعات", + toolCalls: "استدعاءات الأدوات", + retrievalHits: "نتائج الاسترجاع", + qualityIssues: "تنبيهات الجودة", + badVarsJson: "يجب أن تكون المتغيّرات بصيغة JSON صحيحة.", + }, + graph: { + simple: "بسيط", + planReviewRevise: "تخطيط • مراجعة • تنقيح", + rag: "استرجاع وتوليد", + react: "ReAct (استدعاء أدوات)", + }, + }, + tools: { + title: "أدوات الوكلاء", + subtitle: + "القدرات التي يمكن للوكلاء استدعاؤها. أوقف أداة لإخراجها من جميع الوكلاء دون تعديل كل واحد منهم.", + search: "ابحث عن أداة…", + empty: "لا توجد أدوات مطابقة لبحثك.", + writes: "كتابة", + toggle: { + enabled: "تم تفعيل الأداة", + disabled: "تم تعطيل الأداة", + }, + col: { + key: "المفتاح", + name: "الاسم", + category: "الفئة", + description: "الوصف", + params: "المعاملات", + active: "نشط", + }, + }, }; export default ar; diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 0c08350..3e8e006 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -30,6 +30,20 @@ export interface Translations { ai: Record; privacy: Record; feedback: Record; + // quickSetup mixes flat strings (page chrome) and nested blocks + // ("admin.step1.title", "teacher.quick.discussion.title") so its values + // can be either strings or nested records. We intentionally use a loose + // shape here rather than maintain a hand-authored deep type. + quickSetup: Record; + /** Smart Wizard Hub + per-scenario step-by-step wizards. */ + wizardHub: Record; + wizard: Record; + /** AI course-plan generator — list, detail, wizard. */ + coursePlan: Record; + /** AI Agents & Tools configurator (the /admin/ai/prompts page). */ + aiAdmin: Record; + agents: Record; + tools: Record; } const en: Translations = { @@ -62,6 +76,8 @@ const en: Translations = { email: "Email", user: "User", home: "Home", + saving: "Saving…", + disabled: "Disabled", }, auth: { signIn: "Sign in", @@ -81,6 +97,9 @@ const en: Translations = { errorTitle: "Error", }, nav: { + smartWizard: "Smart Wizard", + quickSetup: "Smart Setup", + coursePlans: "Course Plans (AI)", adminDashboard: "Admin Dashboard", platformDashboard: "Platform Dashboard", dashboard: "Dashboard", @@ -97,7 +116,7 @@ const en: Translations = { rubrics: "Rubrics", generation: "Generation", reviewQueue: "Review Queue", - aiPrompts: "AI Prompts", + aiPrompts: "AI Agents & Tools", aiFeedback: "AI Feedback", approvalWorkflows: "Approval Workflows", taxonomy: "Taxonomy", @@ -368,6 +387,548 @@ const en: Translations = { commentRequired: "Please tell us what was wrong.", submit: "Submit feedback", }, + // Smart-setup wizard. Nested so i18next's default "." key separator + // resolves e.g. t("quickSetup.admin.step1.title"). + quickSetup: { + adminTitle: "Smart Setup", + adminSubtitle: + "Everything you need to launch an exam-ready platform, in the recommended order. Tick each step off as you go — the wizard auto-detects progress.", + teacherTitle: "Smart Setup", + teacherSubtitle: + "Launch a new course end-to-end, then jump to common day-to-day tasks.", + progressLabel: "Progress", + recommendedFlow: "Recommended flow", + otherQuickCreates: "Other quick creates", + ready: "Ready", + start: "Start", + review: "Review", + open: "Open", + helpAria: "Show help", + admin: { + step1: { + title: "Create a rubric", + description: + "Define the grading criteria for Writing and Speaking tasks. Rubrics are referenced later when generating or approving exams.", + help: "A rubric is a scoring grid (bands × criteria). You can start from a template or compose one from predefined criteria.", + }, + step2: { + title: "Define an exam structure", + description: + "Blueprint the sections, tasks, and parts that every generated or custom exam of this type must contain.", + help: "Structures enforce consistency. E.g. an IELTS Writing structure has Task 1 (150w) + Task 2 (250w); generation will refuse to submit if either is missing.", + }, + step3: { + title: "Generate or create an exam", + description: + "Use AI generation (fastest) or hand-build a custom exam. Save as draft or submit for approval.", + help: "Generation picks a structure + rubric and produces questions. The custom builder gives you full control for pilot/test exams.", + }, + step4: { + title: "Review & approve", + description: + "Approvers sign off on exams before students can see them. Configure the workflow once, then route submissions automatically.", + help: "The approval queue lists every exam waiting for sign-off. Reject sends it back to the author; approve publishes it.", + }, + step5: { + title: "Assign to students", + description: + "Schedule the published exam, pick a cohort, and send it out. Students see it immediately in their portal.", + help: "Assignments can target individual students, batches, or classrooms. Timezone-aware windows are supported.", + }, + quick: { + course: { title: "New course", description: "Stand up a course shell for teachers to fill with chapters." }, + resource: { title: "Upload resource", description: "Add PDFs, audio, video, or links to the shared library." }, + student: { title: "Add student", description: "Create a student account and assign them to a batch." }, + teacher: { title: "Add teacher", description: "Invite a teacher and grant teaching permissions." }, + classroom: { title: "New classroom", description: "Group students for scheduling and attendance." }, + examSession: { title: "Schedule exam session", description: "Create a proctored sitting for an institutional exam." }, + customExam: { title: "Custom exam", description: "Hand-build an exam from scratch with full control." }, + ticket: { title: "Open ticket", description: "Raise a support ticket on behalf of a user." }, + }, + }, + teacher: { + step1: { + title: "Create a course", + description: "Give your course a name, pick a subject, and set its level. You can edit chapters after creation.", + help: "Courses are the container for chapters, materials, and assignments.", + }, + step2: { + title: "Add chapters & content", + description: "Open your course and add chapters with lessons, videos, quizzes, and practice tasks.", + help: "Chapters organise learning objectives. Use the AI workbench to auto-draft content.", + }, + step3: { + title: "Upload resources", + description: "Share supporting PDFs, audio, or video with your students via the library.", + help: "Large files are fine — the server accepts up to 128 MB per upload.", + }, + step4: { + title: "Create an assignment", + description: "Turn a published exam or task into an assignment with a due date and target cohort.", + help: "Assignments auto-surface in each student's dashboard and on their timetable.", + }, + step5: { + title: "Track student progress", + description: "Monitor submissions, attendance, and adaptive learning insights for your class.", + help: "The adaptive engine flags at-risk students so you can intervene early.", + }, + quick: { + discussion: { title: "New discussion", description: "Start a topic thread for your class." }, + announcement: { title: "New announcement", description: "Broadcast a message to all your students." }, + attendance: { title: "Mark attendance", description: "Record today's attendance for a session." }, + }, + }, + }, + wizardHub: { + title: "Smart Wizard", + subtitle: + "Pick any scenario and the wizard will walk you through it step by step. No need to hunt through settings — every Next button moves you closer to done.", + recommendedOrder: "Recommended order", + order: { + rubric: "Create rubrics (Writing / Speaking)", + structure: "Define exam structures (sections, tasks, durations)", + generate: "Generate or create exams", + approve: "Review & approve pending exams", + assign: "Assign exams to students", + }, + guided: "Guided wizards", + advanced: "Full pages (advanced)", + advancedBadge: "Advanced", + aiBadge: "AI", + startWizard: "Start wizard", + openPage: "Open page", + cards: { + rubric: { + title: "Create a rubric", + description: "Name → skill → criteria → descriptors → review. Writing and Speaking only.", + }, + examStructure: { + title: "Define an exam structure", + description: "Name → modules → writing tasks → review. Reusable blueprint for generation.", + }, + course: { + title: "Create a course", + description: "Title → level & capacity → review. Chapters can be added from the course page.", + }, + coursePlan: { + title: "Generate a course plan (AI)", + description: "Describe the course → AI writes objectives, per-skill outcomes, grammar scope and a week-by-week delivery plan, then produces real teaching materials per week.", + }, + generation: { + title: "Generate an exam", + description: "Full AI generation page with rubric/structure pickers and per-module controls.", + }, + approval: { + title: "Approval workflows", + description: "Configure who reviews which exams and see pending requests.", + }, + assign: { + title: "Assign to students", + description: "Full scheduling page with student picker, windows, and timezone support.", + }, + resource: { + title: "Upload resource", + description: "Add PDFs, audio, video, or links to the shared library.", + }, + student: { + title: "Add student", + description: "Create a student account and enroll them in a batch.", + }, + }, + }, + wizard: { + back: "Back", + next: "Next", + finish: "Finish", + backToHub: "Back to wizards", + stepOf: "Step {{current}} of {{total}}", + rubric: { + title: "Create a rubric", + subtitle: "Grading grid for Writing or Speaking tasks. Referenced later when generating or approving exams.", + finish: "Create rubric", + toastSuccess: "Rubric created", + toastError: "Could not create rubric", + addCriterion: "Add criterion", + removeCriterion: "Remove criterion", + unnamedCriterion: "(unnamed criterion)", + descriptorHint: "Descriptors are optional. They appear to graders as guidance for each criterion.", + maxLabel: "Max", + moduleHint: + "Rubrics only apply to Writing and Speaking. Listening and Reading are auto-graded and don't need one.", + step1: { + title: "Basics", + description: "Give the rubric a name and pick the skill it applies to.", + }, + step2: { + title: "Criteria", + description: "Add the criteria you want graders to score. Each criterion has a max score (e.g. 9 for IELTS).", + }, + step3: { + title: "Descriptors", + description: "Optional: describe what each criterion measures. Graders see these as hints.", + }, + step4: { + title: "Review", + description: "Double-check and create.", + }, + labels: { + name: "Rubric name", + module: "Skill", + description: "Description", + criterionName: "Criterion name", + maxScore: "Max score", + }, + placeholders: { + name: "e.g. IELTS Writing Task 2", + description: "Short description shown to graders.", + criterionName: "e.g. Task Response", + descriptor: "What graders should check for this criterion.", + }, + modules: { + writing: "Writing", + speaking: "Speaking", + }, + errors: { + nameRequired: "Please enter a name for this rubric.", + moduleRestricted: "Rubrics apply only to Writing or Speaking.", + criterionRequired: "Add at least one criterion.", + criterionName: "Every criterion needs a name.", + criterionScore: "Max score must be between 1 and 100.", + }, + }, + structure: { + title: "Define an exam structure", + subtitle: "Blueprint the sections, tasks, and parts every generated or custom exam of this type must contain.", + finish: "Create structure", + toastSuccess: "Exam structure created", + toastError: "Could not create exam structure", + industryHint: "Optional: e.g. 'Business English', 'Healthcare'.", + writingSkipped: "Writing is not in the selected modules — this step is skipped.", + wordsSuffix: "words", + addTask: "Add task", + removeTask: "Remove task", + step1: { + title: "Basics", + description: "Name the structure and pick its exam type.", + }, + step2: { + title: "Modules", + description: "Select which modules this structure covers. Students will see these sections.", + }, + step3: { + title: "Writing tasks", + description: "For writing, define each task's minimum word count.", + }, + step4: { + title: "Review", + description: "Double-check and create.", + }, + labels: { + name: "Structure name", + industry: "Industry / context", + examType: "Exam type", + modules: "Modules", + taskLabel: "Task label", + minWords: "Min words", + }, + placeholders: { + name: "e.g. IELTS Academic Writing", + industry: "e.g. Business English", + }, + examTypes: { + academic: "Academic", + general: "General", + }, + modules: { + listening: { + title: "Listening", + description: "Audio-based questions (auto-graded).", + }, + reading: { + title: "Reading", + description: "Passage-based questions (auto-graded).", + }, + writing: { + title: "Writing", + description: "Essay tasks graded with a rubric.", + }, + speaking: { + title: "Speaking", + description: "Oral tasks graded with a rubric.", + }, + }, + errors: { + nameRequired: "Please enter a name for this structure.", + moduleRequired: "Select at least one module.", + writingTaskRequired: "Writing needs at least one task.", + writingTaskLabel: "Each writing task needs a label.", + writingTaskWords: "Minimum words must be at least 1.", + }, + }, + course: { + title: "Create a course", + subtitle: "A lightweight course skeleton. You can add chapters, materials and enroll students after creation.", + finish: "Create course", + toastSuccess: "Course created", + toastError: "Could not create course", + codeHint: "Leave blank to auto-generate from the title.", + difficultyHint: "How challenging is this course overall? Used for search & filtering.", + cefrHint: "If this course targets a specific CEFR band, pick it here.", + step1: { + title: "Basics", + description: "Give your course a name and a short description.", + }, + step2: { + title: "Level & capacity", + description: "Set difficulty, target CEFR level, and how many students can enroll.", + }, + step3: { + title: "Review", + description: "Double-check and create.", + }, + labels: { + title: "Course title", + code: "Course code", + description: "Description", + difficulty: "Difficulty", + cefrLevel: "CEFR level", + capacity: "Max capacity", + }, + placeholders: { + title: "e.g. Foundation English Level 1", + code: "Auto-generated if empty", + description: "Who is this course for? What will they learn?", + difficulty: "Select a difficulty", + cefr: "Select a CEFR level", + }, + difficulty: { + beginner: "Beginner", + intermediate: "Intermediate", + advanced: "Advanced", + }, + errors: { + titleRequired: "Please enter a course title.", + capacityRequired: "Max capacity must be at least 1.", + }, + }, + }, + coursePlan: { + listTitle: "Course Plans", + listSubtitle: + "AI-generated curriculum outlines: objectives, per-skill learning outcomes, grammar scope, a week-by-week delivery plan, and on-demand teaching materials for each week.", + generateNew: "Generate new plan", + searchPlaceholder: "Search plans by name…", + loadFailed: "Couldn't load course plans.", + deleted: "Plan deleted.", + deleteFailed: "Couldn't delete plan.", + delete: "Delete", + confirmDelete: "Delete plan \"{{name}}\"? This removes all weeks and materials.", + emptyTitle: "No course plans yet", + emptySubtitle: + "Use the AI wizard to generate your first plan — it only takes about a minute.", + open: "Open", + backToList: "Back to course plans", + noDescription: "No description.", + weeksCount_one: "{{count}} week", + weeksCount_other: "{{count}} weeks", + materialsCount_one: "{{count}} material", + materialsCount_other: "{{count}} materials", + hoursPerWeek: "{{count}} hrs/week", + weekN: "Week {{n}}", + generateMaterials: "Generate Week materials (AI)", + regenerateMaterials: "Regenerate Week materials", + generating: "Generating…", + generateHint: + "AI will produce a reading text, listening script, speaking prompts, writing prompt, grammar mini-lesson and vocabulary for this week.", + weekMaterialsGenerated: "Week materials generated.", + weekMaterialsFailed: "Couldn't generate week materials.", + generateSuccess: "Course plan generated.", + generateFailed: "Couldn't generate course plan.", + deliveryHint: + "Expand any week to view the planned outcomes and generate ready-to-use teaching materials.", + status: { + draft: "Draft", + generated: "Generated", + approved: "Approved", + archived: "Archived", + }, + sections: { + objectives: "Course objectives", + outcomes: "Learning outcomes by skill", + grammar: "Grammar scope", + assessment: "Assessment", + resources: "Resources", + delivery: "Weekly delivery plan", + }, + skill: { + reading: "Reading", + writing: "Writing", + listening: "Listening", + speaking: "Speaking", + grammar: "Grammar", + vocabulary: "Vocabulary", + integrated: "Integrated", + }, + materialType: { + reading_text: "Reading text", + listening_script: "Listening script", + speaking_prompt: "Speaking prompt", + writing_prompt: "Writing prompt", + grammar_lesson: "Grammar lesson", + vocabulary_list: "Vocabulary list", + practice: "Practice", + other: "Material", + }, + table: { + skill: "Skill", + outcomes: "Outcomes", + remarks: "Remarks", + }, + wizard: { + title: "Generate a course plan", + subtitle: + "Describe the course once. The AI writes the full outline — objectives, outcomes, grammar, weekly plan — and you can generate Week 1 teaching material in one click afterwards.", + finish: "Generate plan", + reviewHint: + "Click Generate plan to start the AI. This usually takes 30–60 seconds; you'll land on the plan page once it's done.", + steps: { + basics: "Basics", + basicsDesc: "Name the course and set level, duration, and weekly hours.", + coverage: "Coverage", + coverageDesc: "Tell the AI how hours split across skills and who the learners are.", + scope: "Scope", + scopeDesc: "Optional: grammar focus, resources to reference, free-form notes.", + review: "Review", + reviewDesc: "Double-check your brief before kicking off the generation.", + }, + fields: { + title: "Course title", + cefr: "CEFR level", + cefrPlaceholder: "Pick a level", + totalWeeks: "Total weeks", + contactHours: "Contact hours / week", + skillsDivision: "Skills division", + skillsDivisionHint: + "Free-form — e.g. \"10 hrs/wk Reading & Writing + 8 hrs/wk Listening & Speaking\". Leave blank to let the AI decide.", + learnerProfile: "Learner profile", + learnerProfilePlaceholder: + "Who are the learners? Age range, L1, prior study, goals…", + grammarFocus: "Grammar focus (press Enter to add)", + grammarFocusPlaceholder: "e.g. present simple", + resources: "Resources to reference (press Enter to add)", + resourcesPlaceholder: "e.g. Pathways 1 (National Geographic)", + notes: "Additional notes", + notesPlaceholder: "Anything else the AI should know.", + }, + errors: { + titleRequired: "Please enter a course title.", + cefrRequired: "Please pick a CEFR level.", + weeksRange: "Total weeks must be at least 1.", + }, + }, + }, + aiAdmin: { + title: "AI Agents & Tools", + subtitle: + "Configure the LangGraph-backed agents that power course planning, exam generation, exercise generation, the LMS tutor, and grading. Defaults are pre-seeded and ready to use.", + tabs: { + agents: "Agents", + tools: "Tools", + prompts: "Prompts", + }, + }, + agents: { + list: { + title: "Agents", + subtitle: "Pre-configured for every platform pillar — edit defaults below.", + search: "Search agents…", + empty: "No agents match your search.", + }, + detail: { + configure: "Configure", + empty: "Select an agent to inspect its configuration.", + graph: "Graph", + model: "Model", + temperature: "Temperature", + tokens: "Max tokens", + format: "Output format", + fallback: "Fallback", + revisions: "Max revisions", + promptKey: "Prompt key", + toolsTitle: "Enabled tools", + noTools: "This agent has no tools enabled.", + systemPrompt: "System prompt", + noPrompt: "(empty)", + }, + config: { + title: "Configure", + saved: "Agent configuration saved", + name: "Display name", + promptKey: "Prompt key (versioned)", + description: "Description", + systemPrompt: "System prompt", + systemPromptHint: + "If a prompt key is set above, the active version of that prompt overrides this field at runtime.", + model: "Model", + fallbackModel: "Fallback model", + responseFormat: "Output format", + text: "Text", + temperature: "Temperature", + maxTokens: "Max tokens", + maxRevisions: "Max revisions", + graphType: "Graph topology", + qualityChecks: "Quality checks (comma-separated tool keys)", + tools: "Enabled tools", + mutates: "writes", + active: "Agent is active", + }, + test: { + title: "Test runner", + subtitle: + "Send a small payload and inspect the agent's output, tool calls, and quality issues.", + variables: "Variables (JSON)", + payload: "Payload (text or JSON)", + payloadPlaceholder: + "What should the agent do? e.g. 'Generate 5 MCQ for B1 reading.'", + run: "Run agent", + running: "Running…", + ok: "Agent ran successfully", + output: "Output", + toolTrace: "Tool trace", + iterations: "Iterations", + revisions: "Revisions", + toolCalls: "Tool calls", + retrievalHits: "Retrieval hits", + qualityIssues: "Quality issues", + badVarsJson: "Variables must be valid JSON.", + }, + graph: { + simple: "Simple", + planReviewRevise: "Plan • Review • Revise", + rag: "RAG", + react: "ReAct (tool-calling)", + }, + }, + tools: { + title: "Agent tools", + subtitle: + "Capabilities your agents can call. Toggle a tool off to take it out of every agent without editing each one.", + search: "Search tools…", + empty: "No tools match your search.", + writes: "writes", + toggle: { + enabled: "Tool enabled", + disabled: "Tool disabled", + }, + col: { + key: "Key", + name: "Name", + category: "Category", + description: "Description", + params: "Params", + active: "Active", + }, + }, }; export default en; diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index e3b5563..0d2ad17 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -291,7 +291,14 @@ async function performRequest(url: string, init: RequestInitWithSkip): Promis const hadAccess = !!getAccessToken(); clearToken(); if (hadAccess || hadRefresh) { - window.location.href = "/login"; + // 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)); } diff --git a/src/pages/admin/AIAgentsPanel.tsx b/src/pages/admin/AIAgentsPanel.tsx new file mode 100644 index 0000000..b4c076a --- /dev/null +++ b/src/pages/admin/AIAgentsPanel.tsx @@ -0,0 +1,842 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + Activity, + Bot, + CheckCircle2, + ChevronRight, + PencilLine, + PlayCircle, + RotateCcw, + Search, + Settings2, + Wrench, +} from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { aiAgentService } from "@/services/aiAgent.service"; +import type { + AIAgent, + AIAgentSummary, + AIAgentTestResponse, + AIAgentUpdateInput, + AIToolSummary, +} from "@/types/aiAgent"; + +const MODEL_OPTIONS = [ + { value: "gpt-4o", label: "GPT-4o (quality)" }, + { value: "gpt-4o-mini", label: "GPT-4o mini (cheap / fast)" }, + { value: "gpt-4.1", label: "GPT-4.1" }, + { value: "gpt-4.1-mini", label: "GPT-4.1 mini" }, + { value: "gpt-3.5-turbo", label: "GPT-3.5 turbo (legacy)" }, +]; + +const GRAPH_OPTIONS = [ + { value: "simple", labelKey: "agents.graph.simple" }, + { value: "plan_review_revise", labelKey: "agents.graph.planReviewRevise" }, + { value: "rag", labelKey: "agents.graph.rag" }, + { value: "react", labelKey: "agents.graph.react" }, +]; + +function GraphTypeBadge({ value }: { value: string }) { + const { t } = useTranslation(); + const text = t(`agents.graph.${value === "plan_review_revise" ? "planReviewRevise" : value}`, value); + const tone = + value === "react" + ? "bg-purple-500" + : value === "rag" + ? "bg-blue-500" + : value === "plan_review_revise" + ? "bg-emerald-500" + : "bg-slate-500"; + return {text}; +} + +// ============================================================================ +// Agent list (left rail) +// ============================================================================ +function AgentsList({ + agents, + selectedId, + onSelect, + isLoading, + search, + onSearch, +}: { + agents: AIAgentSummary[]; + selectedId: number | null; + onSelect: (id: number) => void; + isLoading: boolean; + search: string; + onSearch: (v: string) => void; +}) { + const { t } = useTranslation(); + return ( + + + + + {t("agents.list.title", "Agents")} + + + {t( + "agents.list.subtitle", + "Pre-configured for every platform pillar — edit defaults below.", + )} + + + +
+ + onSearch(e.target.value)} + placeholder={t("agents.list.search", "Search agents…")} + className="ps-8" + /> +
+ {isLoading ? ( + + ) : agents.length === 0 ? ( +

+ {t("agents.list.empty", "No agents match your search.")} +

+ ) : ( + +
    + {agents.map((a) => { + const active = selectedId === a.id; + return ( +
  • + +
  • + ); + })} +
+
+ )} +
+
+ ); +} + +// ============================================================================ +// Test panel (run a small input through the agent) +// ============================================================================ +function AgentTestRunner({ agent }: { agent: AIAgent }) { + const { t } = useTranslation(); + const [variables, setVariables] = useState("{}"); + const [payload, setPayload] = useState(""); + const [result, setResult] = useState(null); + + const test = useMutation({ + mutationFn: async () => { + let parsedVars: Record = {}; + try { + parsedVars = variables.trim() ? JSON.parse(variables) : {}; + } catch { + throw new Error(t("agents.test.badVarsJson", "Variables must be valid JSON.")); + } + let parsedPayload: unknown = payload; + const trimmed = payload.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + parsedPayload = JSON.parse(trimmed); + } catch { + parsedPayload = payload; + } + } + return aiAgentService.test(agent.id, { + variables: parsedVars, + payload: parsedPayload, + }); + }, + onSuccess: (res) => { + setResult(res); + if (res.error) { + toast.error(res.error); + } else { + toast.success(t("agents.test.ok", "Agent ran successfully")); + } + }, + onError: (err: Error) => toast.error(err.message), + }); + + return ( + + + + + {t("agents.test.title", "Test runner")} + + + {t( + "agents.test.subtitle", + "Send a small payload and inspect the agent's output, tool calls, and quality issues.", + )} + + + +
+
+ +