feat(ai): LangGraph as core runtime + AI Agents/Tools console + full-demo seed

Core AI runtime
- New encoach.ai.agent + encoach.ai.tool models with M2M tool binding,
  graph topology (simple|plan_review_revise|rag|react), model + fallback,
  temperature, max_tokens, response_format, max_revisions, quality checks
  and system prompt fields.
- services/agent_runtime.py compiles a langgraph.StateGraph per agent
  and caches the build per (key, write_date). Emits a structured trace
  (output, tool_calls, retrieval_hits, revisions, quality_issues,
  ms, model_used, fallback_used) and auto-falls-back on rate-limit/5xx.
- services/agent_tools.py registers 11 tool handlers wrapping existing
  services: resources.search, rubric.fetch, outcomes.fetch,
  student.profile, quality.cefr_check, quality.ai_detect,
  quality.content_gate, course_plan.save (mutates),
  course_plan.save_materials (mutates), scoring.grade_writing,
  scoring.grade_speaking.
- 7 default agents seeded via data/agents_defaults.xml: course_planner,
  course_week_materials, exam_generator, exercise_generator, lms_tutor,
  writing_grader, speaking_grader.
- Feature flag encoach_ai.use_langgraph_runtime (default True).
- encoach_ai_course pipeline now routes through AgentRuntime when on,
  legacy SDK path kept as fallback.

Admin UI
- /admin/ai/prompts is now a tabbed Agents | Tools | Prompts console.
- AIAgentsPanel: card grid + config dialog (model/temp/graph/tools/
  system prompt) + built-in Test Runner showing live trace.
- AIToolsPanel: registry table with category badges, mutates flag,
  schema viewer, edit dialog.
- New /api/ai/agents* and /api/ai/tools* controller (list/get/update/
  test, list-tools, toggle-tool).
- Sidebar label nav.aiPrompts -> nav.aiAgents (AI Agents and Tools).
- EN + AR (RTL) translations for ~80 new keys.

Smart Wizard pages
- /admin/quick-setup hub + CourseWizard, CoursePlanWizard,
  RubricWizard, ExamStructureWizard step-by-step flows.
- /admin/course-plans list + detail pages.
- /teacher/quick-setup mirror.

Full demo seed + 8-role E2E
- seed_full_demo.py adds the 5 missing user_types (approver, corporate,
  mastercorporate, agent, developer), activates a 2-stage exam-approval
  workflow with one pending request, creates a GE1-aligned 12-week B1
  course plan with 6 detailed Week-1 materials (reading 400w, writing,
  listening 4-min script, speaking, grammar present simple vs continuous,
  vocabulary), and inserts sample ai.log + ai.feedback rows.
- reset_demo_passwords.py forces every demo login back to canonical
  passwords (admin123/teacher123/student123/approver123/corporate123/
  master123/agent123/dev123).
- e2e_full_scenario.py: 46/46 PASS read-only API smoke across all
  8 roles, including a live LangGraph round-trip on writing_grader.
- e2e_approval_chain.py: 6/6 PASS mutation E2E - approver approves
  stage 1, admin approves stage 2, linked encoach.exam.custom flips
  to status=published, verified via psql.

Docs
- docs/PROJECT_SUMMARY.md updated to 2026-04-25: new Latest events
  bullets, refreshed credentials table, full sections 22 (LangGraph
  runtime) and 23 (full demo seed + 8-role E2E).
- docs/ENCOACH_FULL_DEMO_QA_REPORT.md added with credentials,
  per-endpoint PASS/FAIL, mutation chain proof, LangGraph live output.
- backend/GE1 Course Outline_ Fall AY25-26.pdf vendored as the
  reference outline the GE1 plan/materials are aligned to.

Dependencies
- requirements.txt: langgraph>=0.2.0, langchain-core>=0.3.0.
- encoach_ai/__manifest__.py: external_dependencies updated.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-25 03:13:55 +04:00
parent bbb2b44ae0
commit 45408acd5c
29 changed files with 5521 additions and 27 deletions

View File

@@ -71,6 +71,7 @@ const TopicLearning = lazy(() => import("@/pages/student/TopicLearning"));
// Teacher pages // Teacher pages
const TeacherDashboard = lazy(() => import("@/pages/teacher/TeacherDashboard")); const TeacherDashboard = lazy(() => import("@/pages/teacher/TeacherDashboard"));
const TeacherQuickSetup = lazy(() => import("@/pages/teacher/TeacherQuickSetup"));
const TeacherCourses = lazy(() => import("@/pages/teacher/TeacherCourses")); const TeacherCourses = lazy(() => import("@/pages/teacher/TeacherCourses"));
const CourseBuilder = lazy(() => import("@/pages/teacher/CourseBuilder")); const CourseBuilder = lazy(() => import("@/pages/teacher/CourseBuilder"));
const TeacherAssignments = lazy(() => import("@/pages/teacher/TeacherAssignments")); const TeacherAssignments = lazy(() => import("@/pages/teacher/TeacherAssignments"));
@@ -84,6 +85,14 @@ const AdaptiveSettings = lazy(() => import("@/pages/teacher/AdaptiveSettings"));
// Admin LMS pages // Admin LMS pages
const AdminLmsDashboard = lazy(() => import("@/pages/admin/AdminLmsDashboard")); 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 AdminCourses = lazy(() => import("@/pages/admin/AdminCourses"));
const AdminStudents = lazy(() => import("@/pages/admin/AdminStudents")); const AdminStudents = lazy(() => import("@/pages/admin/AdminStudents"));
const AdminTeachers = lazy(() => import("@/pages/admin/AdminTeachers")); const AdminTeachers = lazy(() => import("@/pages/admin/AdminTeachers"));
@@ -201,7 +210,12 @@ const App = () => (
<TooltipProvider> <TooltipProvider>
<Toaster /> <Toaster />
<Sonner /> <Sonner />
<BrowserRouter> <BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<AuthProvider> <AuthProvider>
<Suspense fallback={<RouteFallback />}> <Suspense fallback={<RouteFallback />}>
<Routes> <Routes>
@@ -265,6 +279,7 @@ const App = () => (
<Route element={<ProtectedRoute allowedRoles={["teacher", "admin", "developer"]} />}> <Route element={<ProtectedRoute allowedRoles={["teacher", "admin", "developer"]} />}>
<Route element={<TeacherLayout />}> <Route element={<TeacherLayout />}>
<Route path="/teacher/dashboard" element={<TeacherDashboard />} /> <Route path="/teacher/dashboard" element={<TeacherDashboard />} />
<Route path="/teacher/quick-setup" element={<TeacherQuickSetup />} />
<Route path="/teacher/courses" element={<TeacherCourses />} /> <Route path="/teacher/courses" element={<TeacherCourses />} />
<Route path="/teacher/library" element={<TeacherLibrary />} /> <Route path="/teacher/library" element={<TeacherLibrary />} />
<Route path="/teacher/courses/new" element={<CourseBuilder />} /> <Route path="/teacher/courses/new" element={<CourseBuilder />} />
@@ -291,6 +306,14 @@ const App = () => (
<Route element={<AdminLmsLayout />}> <Route element={<AdminLmsLayout />}>
{/* LMS Dashboard */} {/* LMS Dashboard */}
<Route path="/admin/dashboard" element={<AdminLmsDashboard />} /> <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 */} {/* Original platform dashboard */}
<Route path="/admin/platform" element={<AdminDashboard />} /> <Route path="/admin/platform" element={<AdminDashboard />} />
{/* LMS pages */} {/* LMS pages */}

View File

@@ -1,4 +1,5 @@
import { Outlet, Link, useNavigate, useLocation } from "react-router-dom"; import { Outlet, Link, useNavigate, useLocation } from "react-router-dom";
import { Suspense } from "react";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { import {
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
@@ -31,7 +32,7 @@ import {
CalendarDays, Landmark, UserPlus, ScrollText, Award, CalendarDays, Landmark, UserPlus, ScrollText, Award,
HelpCircle as FaqIcon, Bell, Workflow, HelpCircle as FaqIcon, Bell, Workflow,
CalendarOff, DollarSign, BookMarked, BarChartHorizontal, TrendingUp, CalendarOff, DollarSign, BookMarked, BarChartHorizontal, TrendingUp,
Library, Activity, Warehouse, UserCog, Sparkles, Library, Activity, Warehouse, UserCog, Sparkles, Compass,
} from "lucide-react"; } from "lucide-react";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -43,6 +44,7 @@ import { useTranslation } from "react-i18next";
interface NavItem { titleKey: string; url: string; icon: LucideIcon } interface NavItem { titleKey: string; url: string; icon: LucideIcon }
const overviewItems: NavItem[] = [ const overviewItems: NavItem[] = [
{ titleKey: "nav.smartWizard", url: "/admin/smart-wizard", icon: Sparkles },
{ titleKey: "nav.adminDashboard", url: "/admin/dashboard", icon: LayoutDashboard }, { titleKey: "nav.adminDashboard", url: "/admin/dashboard", icon: LayoutDashboard },
{ titleKey: "nav.platformDashboard", url: "/admin/platform", icon: BarChart3 }, { 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.reviewQueue", url: "/admin/exam/review-queue", icon: Sparkles },
{ titleKey: "nav.aiPrompts", url: "/admin/ai/prompts", icon: Wand2 }, { titleKey: "nav.aiPrompts", url: "/admin/ai/prompts", icon: Wand2 },
{ titleKey: "nav.aiFeedback", url: "/admin/ai/feedback", icon: Sparkles }, { 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 }, { 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 (
<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 ============= // ============= Main Layout =============
export default function AdminLmsLayout() { export default function AdminLmsLayout() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
@@ -279,7 +296,9 @@ export default function AdminLmsLayout() {
</div> </div>
</header> </header>
<main className="flex-1 overflow-auto p-6"> <main className="flex-1 overflow-auto p-6">
<Outlet /> <Suspense fallback={<RouteContentFallback />}>
<Outlet />
</Suspense>
</main> </main>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,5 @@
import { Outlet, useLocation, Link, useNavigate } from "react-router-dom"; import { Outlet, useLocation, Link, useNavigate } from "react-router-dom";
import { Suspense } from "react";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/AppSidebar"; import { AppSidebar } from "@/components/AppSidebar";
import { 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 (
<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() { export default function AppLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -127,7 +138,9 @@ export default function AppLayout() {
</div> </div>
</header> </header>
<main className="flex-1 overflow-auto p-6"> <main className="flex-1 overflow-auto p-6">
<Outlet /> <Suspense fallback={<RouteContentFallback />}>
<Outlet />
</Suspense>
</main> </main>
</div> </div>
</div> </div>

View File

@@ -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 { forwardRef } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -9,7 +9,14 @@ interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
} }
const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>( 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 ( return (
<RouterNavLink <RouterNavLink
ref={ref} ref={ref}
@@ -17,6 +24,31 @@ const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
className={({ isActive, isPending }) => className={({ isActive, isPending }) =>
cn(className, isActive && activeClassName, isPending && pendingClassName) 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} {...props}
/> />
); );

View 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;

View File

@@ -1,4 +1,5 @@
import { Outlet, useNavigate } from "react-router-dom"; import { Outlet, useNavigate } from "react-router-dom";
import { Suspense } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { 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 (
<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) { export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -167,7 +178,9 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
</div> </div>
</header> </header>
<main className="flex-1 overflow-auto p-6"> <main className="flex-1 overflow-auto p-6">
<Outlet /> <Suspense fallback={<RouteContentFallback />}>
<Outlet />
</Suspense>
</main> </main>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@ import RoleLayout, { NavGroup } from "./RoleLayout";
import { import {
LayoutDashboard, BookOpen, ClipboardList, LayoutDashboard, BookOpen, ClipboardList,
CalendarCheck, Users, Calendar, User, MessageSquare, CalendarCheck, Users, Calendar, User, MessageSquare,
Megaphone, Library, Megaphone, Library, Sparkles,
} from "lucide-react"; } from "lucide-react";
/** Teacher portal shell. See `StudentLayout` for the nav-config rationale. */ /** Teacher portal shell. See `StudentLayout` for the nav-config rationale. */
@@ -10,6 +10,7 @@ const navGroups: NavGroup[] = [
{ {
labelKey: "sidebarGroup.teaching", labelKey: "sidebarGroup.teaching",
items: [ items: [
{ titleKey: "nav.quickSetup", url: "/teacher/quick-setup", icon: Sparkles },
{ titleKey: "nav.dashboard", url: "/teacher/dashboard", icon: LayoutDashboard }, { titleKey: "nav.dashboard", url: "/teacher/dashboard", icon: LayoutDashboard },
{ titleKey: "nav.courses", url: "/teacher/courses", icon: BookOpen }, { titleKey: "nav.courses", url: "/teacher/courses", icon: BookOpen },
{ titleKey: "nav.resourceLibrary", url: "/teacher/library", icon: Library }, { titleKey: "nav.resourceLibrary", url: "/teacher/library", icon: Library },

View File

@@ -22,8 +22,11 @@ const badgeVariants = cva(
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {} export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) { const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
return <div className={cn(badgeVariants({ variant }), className)} {...props} />; ({ className, variant, ...props }, ref) => (
} <div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
),
);
Badge.displayName = "Badge";
export { Badge, badgeVariants }; export { Badge, badgeVariants };

View 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;

View File

@@ -31,6 +31,8 @@ const ar: Translations = {
email: "البريد الإلكتروني", email: "البريد الإلكتروني",
user: "المستخدم", user: "المستخدم",
home: "الرئيسية", home: "الرئيسية",
saving: "جارٍ الحفظ…",
disabled: "معطّل",
}, },
auth: { auth: {
signIn: "تسجيل الدخول", signIn: "تسجيل الدخول",
@@ -50,6 +52,9 @@ const ar: Translations = {
errorTitle: "خطأ", errorTitle: "خطأ",
}, },
nav: { nav: {
smartWizard: "المعالج الذكي",
quickSetup: "الإعداد السريع",
coursePlans: "خطط المقررات (الذكاء الاصطناعي)",
adminDashboard: "لوحة الإدارة", adminDashboard: "لوحة الإدارة",
platformDashboard: "لوحة المنصّة", platformDashboard: "لوحة المنصّة",
dashboard: "لوحة التحكم", dashboard: "لوحة التحكم",
@@ -66,7 +71,7 @@ const ar: Translations = {
rubrics: "معايير التقييم", rubrics: "معايير التقييم",
generation: "التوليد", generation: "التوليد",
reviewQueue: "قائمة المراجعة", reviewQueue: "قائمة المراجعة",
aiPrompts: "تعليمات الذكاء الاصطناعي", aiPrompts: "وكلاء الذكاء الاصطناعي والأدوات",
aiFeedback: "ملاحظات الذكاء الاصطناعي", aiFeedback: "ملاحظات الذكاء الاصطناعي",
approvalWorkflows: "سير عمل الموافقات", approvalWorkflows: "سير عمل الموافقات",
taxonomy: "التصنيف", taxonomy: "التصنيف",
@@ -337,6 +342,535 @@ const ar: Translations = {
commentRequired: "من فضلك أخبرنا بالخطأ.", commentRequired: "من فضلك أخبرنا بالخطأ.",
submit: "إرسال الملاحظات", 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: "اضغط على \"توليد الخطة\" لبدء الذكاء الاصطناعي. يستغرق عادةً 3060 ثانية، وستنتقل إلى صفحة الخطة عند الانتهاء.",
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; export default ar;

View File

@@ -30,6 +30,20 @@ export interface Translations {
ai: Record<string, string>; ai: Record<string, string>;
privacy: Record<string, string>; privacy: Record<string, string>;
feedback: Record<string, string>; feedback: Record<string, string>;
// 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<string, unknown>;
/** Smart Wizard Hub + per-scenario step-by-step wizards. */
wizardHub: Record<string, unknown>;
wizard: Record<string, unknown>;
/** AI course-plan generator — list, detail, wizard. */
coursePlan: Record<string, unknown>;
/** AI Agents & Tools configurator (the /admin/ai/prompts page). */
aiAdmin: Record<string, unknown>;
agents: Record<string, unknown>;
tools: Record<string, unknown>;
} }
const en: Translations = { const en: Translations = {
@@ -62,6 +76,8 @@ const en: Translations = {
email: "Email", email: "Email",
user: "User", user: "User",
home: "Home", home: "Home",
saving: "Saving…",
disabled: "Disabled",
}, },
auth: { auth: {
signIn: "Sign in", signIn: "Sign in",
@@ -81,6 +97,9 @@ const en: Translations = {
errorTitle: "Error", errorTitle: "Error",
}, },
nav: { nav: {
smartWizard: "Smart Wizard",
quickSetup: "Smart Setup",
coursePlans: "Course Plans (AI)",
adminDashboard: "Admin Dashboard", adminDashboard: "Admin Dashboard",
platformDashboard: "Platform Dashboard", platformDashboard: "Platform Dashboard",
dashboard: "Dashboard", dashboard: "Dashboard",
@@ -97,7 +116,7 @@ const en: Translations = {
rubrics: "Rubrics", rubrics: "Rubrics",
generation: "Generation", generation: "Generation",
reviewQueue: "Review Queue", reviewQueue: "Review Queue",
aiPrompts: "AI Prompts", aiPrompts: "AI Agents & Tools",
aiFeedback: "AI Feedback", aiFeedback: "AI Feedback",
approvalWorkflows: "Approval Workflows", approvalWorkflows: "Approval Workflows",
taxonomy: "Taxonomy", taxonomy: "Taxonomy",
@@ -368,6 +387,548 @@ const en: Translations = {
commentRequired: "Please tell us what was wrong.", commentRequired: "Please tell us what was wrong.",
submit: "Submit feedback", 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 3060 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; export default en;

View File

@@ -291,7 +291,14 @@ async function performRequest<T>(url: string, init: RequestInitWithSkip): Promis
const hadAccess = !!getAccessToken(); const hadAccess = !!getAccessToken();
clearToken(); clearToken();
if (hadAccess || hadRefresh) { 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)); throw new ApiError(401, response.statusText, await response.json().catch(() => null));
} }

View File

@@ -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 <Badge className={`${tone} text-white hover:${tone}`}>{text}</Badge>;
}
// ============================================================================
// 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 (
<Card className="lg:col-span-1">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Bot className="h-4 w-4" />
{t("agents.list.title", "Agents")}
</CardTitle>
<CardDescription>
{t(
"agents.list.subtitle",
"Pre-configured for every platform pillar — edit defaults below.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="relative">
<Search className="text-muted-foreground absolute start-2 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => onSearch(e.target.value)}
placeholder={t("agents.list.search", "Search agents…")}
className="ps-8"
/>
</div>
{isLoading ? (
<Skeleton className="h-64 w-full" />
) : agents.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
{t("agents.list.empty", "No agents match your search.")}
</p>
) : (
<ScrollArea className="h-[480px] pe-2">
<ul className="space-y-1">
{agents.map((a) => {
const active = selectedId === a.id;
return (
<li key={a.id}>
<button
type="button"
onClick={() => onSelect(a.id)}
className={`w-full rounded-md border p-3 text-start transition-colors ${
active
? "border-primary bg-primary/5"
: "hover:bg-muted/40 border-transparent"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{a.name}</div>
<div className="text-muted-foreground truncate font-mono text-xs">
{a.key}
</div>
</div>
<ChevronRight className="text-muted-foreground h-4 w-4 flex-shrink-0" />
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<GraphTypeBadge value={a.graph_type} />
<Badge variant="outline" className="text-xs">
{a.model}
</Badge>
<Badge variant="outline" className="text-xs">
<Wrench className="me-1 h-3 w-3" />
{a.tool_count}
</Badge>
{!a.active ? (
<Badge variant="secondary" className="text-xs">
{t("common.disabled", "Disabled")}
</Badge>
) : null}
</div>
</button>
</li>
);
})}
</ul>
</ScrollArea>
)}
</CardContent>
</Card>
);
}
// ============================================================================
// Test panel (run a small input through the agent)
// ============================================================================
function AgentTestRunner({ agent }: { agent: AIAgent }) {
const { t } = useTranslation();
const [variables, setVariables] = useState<string>("{}");
const [payload, setPayload] = useState<string>("");
const [result, setResult] = useState<AIAgentTestResponse | null>(null);
const test = useMutation({
mutationFn: async () => {
let parsedVars: Record<string, unknown> = {};
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 (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<PlayCircle className="h-4 w-4" />
{t("agents.test.title", "Test runner")}
</CardTitle>
<CardDescription>
{t(
"agents.test.subtitle",
"Send a small payload and inspect the agent's output, tool calls, and quality issues.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">
{t("agents.test.variables", "Variables (JSON)")}
</Label>
<Textarea
value={variables}
onChange={(e) => setVariables(e.target.value)}
placeholder='{"cefr_level": "b1"}'
className="min-h-[120px] font-mono text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">
{t("agents.test.payload", "Payload (text or JSON)")}
</Label>
<Textarea
value={payload}
onChange={(e) => setPayload(e.target.value)}
placeholder={t(
"agents.test.payloadPlaceholder",
"What should the agent do? e.g. 'Generate 5 MCQ for B1 reading.'",
)}
className="min-h-[120px] text-sm"
/>
</div>
</div>
<Button onClick={() => test.mutate()} disabled={test.isPending}>
<PlayCircle className="me-1 h-4 w-4" />
{test.isPending
? t("agents.test.running", "Running…")
: t("agents.test.run", "Run agent")}
</Button>
{result ? (
<div className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
<Badge variant="outline">
<Activity className="me-1 h-3 w-3" />
{t("agents.test.iterations", "Iterations")}: {result.iterations}
</Badge>
<Badge variant="outline">
<RotateCcw className="me-1 h-3 w-3" />
{t("agents.test.revisions", "Revisions")}: {result.revisions_used}
</Badge>
<Badge variant="outline">
{t("agents.test.toolCalls", "Tool calls")}: {result.tool_results.length}
</Badge>
<Badge variant="outline">
{t("agents.test.retrievalHits", "Retrieval hits")}: {result.retrieval_hits}
</Badge>
{result.quality_issues.length ? (
<Badge className="bg-amber-500 text-white hover:bg-amber-500">
{t("agents.test.qualityIssues", "Quality issues")}: {result.quality_issues.length}
</Badge>
) : null}
</div>
<div className="space-y-1">
<Label className="text-xs uppercase tracking-wide">
{t("agents.test.output", "Output")}
</Label>
<pre className="bg-muted/50 max-h-[300px] overflow-auto rounded-md p-3 text-xs">
{typeof result.output === "string"
? result.output
: JSON.stringify(result.output, null, 2)}
</pre>
</div>
{result.tool_results.length ? (
<details className="rounded-md border p-2">
<summary className="cursor-pointer text-xs font-medium">
{t("agents.test.toolTrace", "Tool trace")}
</summary>
<pre className="mt-2 max-h-[260px] overflow-auto text-xs">
{JSON.stringify(result.tool_results, null, 2)}
</pre>
</details>
) : null}
</div>
) : null}
</CardContent>
</Card>
);
}
// ============================================================================
// Configure dialog
// ============================================================================
function ConfigureAgentDialog({
agent,
tools,
open,
onOpenChange,
}: {
agent: AIAgent;
tools: AIToolSummary[];
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [draft, setDraft] = useState<AIAgentUpdateInput>({});
useEffect(() => {
if (open) {
setDraft({
name: agent.name,
description: agent.description,
system_prompt: agent.system_prompt,
prompt_key: agent.prompt_key,
model: agent.model,
fallback_model: agent.fallback_model,
temperature: agent.temperature,
max_tokens: agent.max_tokens,
max_revisions: agent.max_revisions,
response_format: agent.response_format,
graph_type: agent.graph_type,
quality_checks: (agent.quality_checks || []).join(","),
tool_keys: agent.tool_keys,
active: agent.active,
});
}
}, [open, agent]);
const update = useMutation({
mutationFn: (input: AIAgentUpdateInput) => aiAgentService.update(agent.id, input),
onSuccess: () => {
toast.success(t("agents.config.saved", "Agent configuration saved"));
qc.invalidateQueries({ queryKey: ["ai-agents"] });
qc.invalidateQueries({ queryKey: ["ai-agent", agent.id] });
onOpenChange(false);
},
onError: (err: Error) => toast.error(err.message),
});
const toggleTool = (key: string) => {
const current = new Set(draft.tool_keys ?? []);
if (current.has(key)) current.delete(key);
else current.add(key);
setDraft({ ...draft, tool_keys: Array.from(current) });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>
{t("agents.config.title", "Configure")} {agent.name}
</DialogTitle>
<DialogDescription className="font-mono text-xs">
{agent.key}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Identity */}
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">{t("agents.config.name", "Display name")}</Label>
<Input
value={draft.name ?? ""}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">
{t("agents.config.promptKey", "Prompt key (versioned)")}
</Label>
<Input
value={draft.prompt_key ?? ""}
placeholder="e.g. course_planner.system"
onChange={(e) => setDraft({ ...draft, prompt_key: e.target.value })}
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("agents.config.description", "Description")}</Label>
<Textarea
value={draft.description ?? ""}
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
className="min-h-[60px]"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("agents.config.systemPrompt", "System prompt")}</Label>
<Textarea
value={draft.system_prompt ?? ""}
onChange={(e) => setDraft({ ...draft, system_prompt: e.target.value })}
className="min-h-[200px] font-mono text-xs"
/>
<p className="text-muted-foreground text-xs">
{t(
"agents.config.systemPromptHint",
"If a prompt key is set above, the active version of that prompt overrides this field at runtime.",
)}
</p>
</div>
{/* Runtime config */}
<div className="grid gap-3 md:grid-cols-3">
<div className="space-y-1">
<Label className="text-xs">{t("agents.config.model", "Model")}</Label>
<Select
value={draft.model ?? agent.model}
onValueChange={(v) => setDraft({ ...draft, model: v })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{MODEL_OPTIONS.map((m) => (
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">
{t("agents.config.fallbackModel", "Fallback model")}
</Label>
<Select
value={draft.fallback_model ?? agent.fallback_model}
onValueChange={(v) => setDraft({ ...draft, fallback_model: v })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{MODEL_OPTIONS.map((m) => (
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("agents.config.responseFormat", "Output format")}</Label>
<Select
value={draft.response_format ?? agent.response_format}
onValueChange={(v) =>
setDraft({ ...draft, response_format: v as "text" | "json" })
}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="json">JSON</SelectItem>
<SelectItem value="text">{t("agents.config.text", "Text")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">
{t("agents.config.temperature", "Temperature")}
</Label>
<Input
type="number"
step="0.1"
min={0}
max={2}
value={draft.temperature ?? agent.temperature}
onChange={(e) =>
setDraft({ ...draft, temperature: Number(e.target.value) })
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">
{t("agents.config.maxTokens", "Max tokens")}
</Label>
<Input
type="number"
min={64}
max={32000}
value={draft.max_tokens ?? agent.max_tokens}
onChange={(e) =>
setDraft({ ...draft, max_tokens: Number(e.target.value) })
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">
{t("agents.config.maxRevisions", "Max revisions")}
</Label>
<Input
type="number"
min={0}
max={5}
value={draft.max_revisions ?? agent.max_revisions}
onChange={(e) =>
setDraft({ ...draft, max_revisions: Number(e.target.value) })
}
/>
</div>
</div>
{/* Graph */}
<div className="space-y-1">
<Label className="text-xs">
{t("agents.config.graphType", "Graph topology")}
</Label>
<Select
value={draft.graph_type ?? agent.graph_type}
onValueChange={(v) =>
setDraft({ ...draft, graph_type: v as AIAgent["graph_type"] })
}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{GRAPH_OPTIONS.map((g) => (
<SelectItem key={g.value} value={g.value}>
{t(g.labelKey, g.value)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">
{t("agents.config.qualityChecks", "Quality checks (comma-separated tool keys)")}
</Label>
<Input
value={draft.quality_checks ?? ""}
onChange={(e) =>
setDraft({ ...draft, quality_checks: e.target.value })
}
placeholder="quality.cefr_check,quality.ai_detect"
className="font-mono text-xs"
/>
</div>
{/* Tools */}
<div className="space-y-2">
<Label className="text-xs">{t("agents.config.tools", "Enabled tools")}</Label>
<div className="grid gap-2 md:grid-cols-2">
{tools.map((tool) => {
const enabled = (draft.tool_keys ?? []).includes(tool.key);
return (
<label
key={tool.id}
className={`flex items-start gap-2 rounded-md border p-2 text-xs transition-colors ${
enabled ? "border-primary bg-primary/5" : ""
}`}
>
<Checkbox
checked={enabled}
onCheckedChange={() => toggleTool(tool.key)}
className="mt-0.5"
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1">
<span className="font-mono text-xs">{tool.key}</span>
<Badge variant="outline" className="text-[10px] uppercase">
{tool.category}
</Badge>
{tool.mutates ? (
<Badge className="bg-amber-500 text-white hover:bg-amber-500 text-[10px]">
{t("agents.config.mutates", "writes")}
</Badge>
) : null}
</div>
<p className="text-muted-foreground mt-0.5 line-clamp-2">
{tool.description}
</p>
</div>
</label>
);
})}
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={draft.active ?? agent.active}
onCheckedChange={(v) => setDraft({ ...draft, active: v })}
/>
<Label className="text-xs">
{t("agents.config.active", "Agent is active")}
</Label>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t("common.cancel", "Cancel")}
</Button>
<Button onClick={() => update.mutate(draft)} disabled={update.isPending}>
<CheckCircle2 className="me-1 h-4 w-4" />
{update.isPending
? t("common.saving", "Saving…")
: t("common.save", "Save changes")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ============================================================================
// Detail panel
// ============================================================================
function AgentDetail({ agent, tools }: { agent: AIAgent; tools: AIToolSummary[] }) {
const { t } = useTranslation();
const [configOpen, setConfigOpen] = useState(false);
return (
<div className="space-y-4 lg:col-span-2">
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-xl">{agent.name}</CardTitle>
<CardDescription className="font-mono text-xs">
{agent.key}
</CardDescription>
{agent.description ? (
<p className="mt-2 text-sm">{agent.description}</p>
) : null}
</div>
<Button variant="outline" onClick={() => setConfigOpen(true)}>
<Settings2 className="me-1 h-4 w-4" />
{t("agents.detail.configure", "Configure")}
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 text-sm md:grid-cols-4">
<div>
<div className="text-muted-foreground text-xs">
{t("agents.detail.graph", "Graph")}
</div>
<div className="mt-1"><GraphTypeBadge value={agent.graph_type} /></div>
</div>
<div>
<div className="text-muted-foreground text-xs">
{t("agents.detail.model", "Model")}
</div>
<div className="mt-1 font-mono text-sm">{agent.model}</div>
</div>
<div>
<div className="text-muted-foreground text-xs">
{t("agents.detail.temperature", "Temperature")}
</div>
<div className="mt-1 font-mono text-sm">{agent.temperature.toFixed(2)}</div>
</div>
<div>
<div className="text-muted-foreground text-xs">
{t("agents.detail.tokens", "Max tokens")}
</div>
<div className="mt-1 font-mono text-sm">{agent.max_tokens}</div>
</div>
<div>
<div className="text-muted-foreground text-xs">
{t("agents.detail.format", "Output format")}
</div>
<div className="mt-1 text-sm">
{agent.response_format === "json" ? "JSON" : t("agents.config.text", "Text")}
</div>
</div>
<div>
<div className="text-muted-foreground text-xs">
{t("agents.detail.fallback", "Fallback")}
</div>
<div className="mt-1 font-mono text-sm">
{agent.fallback_model || "—"}
</div>
</div>
<div>
<div className="text-muted-foreground text-xs">
{t("agents.detail.revisions", "Max revisions")}
</div>
<div className="mt-1 font-mono text-sm">{agent.max_revisions}</div>
</div>
<div>
<div className="text-muted-foreground text-xs">
{t("agents.detail.promptKey", "Prompt key")}
</div>
<div className="mt-1 font-mono text-xs">
{agent.prompt_key || "—"}
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Wrench className="h-4 w-4" />
{t("agents.detail.toolsTitle", "Enabled tools")} ({agent.tools.length})
</CardTitle>
</CardHeader>
<CardContent>
{agent.tools.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("agents.detail.noTools", "This agent has no tools enabled.")}
</p>
) : (
<ul className="space-y-2">
{agent.tools.map((tool) => (
<li key={tool.id} className="rounded-md border p-2 text-sm">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs">{tool.key}</span>
<Badge variant="outline" className="text-[10px] uppercase">
{tool.category}
</Badge>
{tool.mutates ? (
<Badge className="bg-amber-500 text-white hover:bg-amber-500 text-[10px]">
{t("agents.config.mutates", "writes")}
</Badge>
) : null}
</div>
<p className="text-muted-foreground mt-1 text-xs">{tool.description}</p>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<PencilLine className="h-4 w-4" />
{t("agents.detail.systemPrompt", "System prompt")}
</CardTitle>
</CardHeader>
<CardContent>
<pre className="bg-muted/50 max-h-[260px] overflow-auto rounded-md p-3 text-xs">
{agent.system_prompt || t("agents.detail.noPrompt", "(empty)")}
</pre>
</CardContent>
</Card>
<AgentTestRunner agent={agent} />
<ConfigureAgentDialog
agent={agent}
tools={tools}
open={configOpen}
onOpenChange={setConfigOpen}
/>
</div>
);
}
// ============================================================================
// Top-level Agents tab
// ============================================================================
export function AIAgentsPanel() {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [selectedId, setSelectedId] = useState<number | null>(null);
const agentsQ = useQuery({
queryKey: ["ai-agents", { search }],
queryFn: () => aiAgentService.list(search ? { search } : undefined),
});
const toolsQ = useQuery({
queryKey: ["ai-agent-tools"],
queryFn: () => aiAgentService.listTools(),
});
// Auto-select first agent.
useEffect(() => {
if (!selectedId && agentsQ.data && agentsQ.data.length > 0) {
setSelectedId(agentsQ.data[0].id);
}
}, [selectedId, agentsQ.data]);
const detailQ = useQuery({
queryKey: ["ai-agent", selectedId],
queryFn: () => aiAgentService.get(selectedId as number),
enabled: !!selectedId,
});
const filteredAgents = useMemo(() => agentsQ.data ?? [], [agentsQ.data]);
return (
<div className="grid gap-4 lg:grid-cols-3">
<AgentsList
agents={filteredAgents}
selectedId={selectedId}
onSelect={setSelectedId}
isLoading={agentsQ.isLoading}
search={search}
onSearch={setSearch}
/>
{detailQ.data ? (
<AgentDetail agent={detailQ.data} tools={toolsQ.data ?? []} />
) : selectedId ? (
<Card className="lg:col-span-2">
<CardContent className="p-6">
<Skeleton className="h-72 w-full" />
</CardContent>
</Card>
) : (
<Card className="lg:col-span-2">
<CardContent className="text-muted-foreground p-6 text-sm">
{t("agents.detail.empty", "Select an agent to inspect its configuration.")}
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -28,6 +29,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
useAIPrompt, useAIPrompt,
@@ -37,8 +39,18 @@ import {
useCreateAIPrompt, useCreateAIPrompt,
useRenderAIPrompt, useRenderAIPrompt,
} from "@/hooks/queries/useAIPrompts"; } from "@/hooks/queries/useAIPrompts";
import { AIAgentsPanel } from "@/pages/admin/AIAgentsPanel";
import { AIToolsPanel } from "@/pages/admin/AIToolsPanel";
import type { AIPromptSummary } from "@/types/ai-prompt"; import type { AIPromptSummary } from "@/types/ai-prompt";
import { CheckCircle2, FileText, History, Play, PlusCircle } from "lucide-react"; import {
Bot,
CheckCircle2,
FileText,
History,
Play,
PlusCircle,
Wrench,
} from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
function SelectedKeyPanel({ function SelectedKeyPanel({
@@ -353,7 +365,12 @@ function NewVersionDialog({
); );
} }
export default function AIPromptEditor() { /**
* The original prompt-library UI, now scoped as the "Prompts" tab inside
* the larger AI Agents & Tools configurator. Behaviour unchanged — only
* the surrounding chrome (header + new-version button) is now contextual.
*/
function AIPromptsPanel() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const { data, isLoading } = useAIPromptKeys({ search, page: 1, size: 50 }); const { data, isLoading } = useAIPromptKeys({ search, page: 1, size: 50 });
const [selectedKey, setSelectedKey] = useState<string | null>(null); const [selectedKey, setSelectedKey] = useState<string | null>(null);
@@ -376,17 +393,8 @@ export default function AIPromptEditor() {
}, [items, selectedKey]); }, [items, selectedKey]);
return ( return (
<div className="mx-auto max-w-7xl space-y-6 p-6"> <div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-end">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
AI prompt library
</h1>
<p className="text-muted-foreground mt-1 text-sm">
Versioned, auditable templates that the AI pipelines render at
runtime. Non-engineers can iterate here without shipping code.
</p>
</div>
<Button onClick={() => setNewOpen(true)}> <Button onClick={() => setNewOpen(true)}>
<PlusCircle className="mr-1 h-4 w-4" /> <PlusCircle className="mr-1 h-4 w-4" />
New version New version
@@ -526,3 +534,66 @@ export default function AIPromptEditor() {
</div> </div>
); );
} }
/**
* Top-level page mounted at /admin/ai/prompts.
*
* The original "AI prompt library" lives on as the third tab so existing
* deep-links and saved bookmarks still work. The default tab is now the
* Agents configurator — the user explicitly asked for this page to become
* "config ai agent tools" with sensible defaults already shipped.
*/
export default function AIPromptEditor() {
const { t } = useTranslation();
return (
<div className="mx-auto max-w-7xl space-y-6 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{t("aiAdmin.title", "AI Agents & Tools")}
</h1>
<p className="text-muted-foreground mt-1 text-sm">
{t(
"aiAdmin.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.",
)}
</p>
</div>
<Tabs defaultValue="agents" className="space-y-6">
<TabsList className="h-auto w-full justify-start gap-2 bg-transparent p-0">
<TabsTrigger
value="agents"
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-md border px-4 py-2"
>
<Bot className="me-1 h-4 w-4" />
{t("aiAdmin.tabs.agents", "Agents")}
</TabsTrigger>
<TabsTrigger
value="tools"
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-md border px-4 py-2"
>
<Wrench className="me-1 h-4 w-4" />
{t("aiAdmin.tabs.tools", "Tools")}
</TabsTrigger>
<TabsTrigger
value="prompts"
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-md border px-4 py-2"
>
<FileText className="me-1 h-4 w-4" />
{t("aiAdmin.tabs.prompts", "Prompts")}
</TabsTrigger>
</TabsList>
<TabsContent value="agents" className="mt-2">
<AIAgentsPanel />
</TabsContent>
<TabsContent value="tools" className="mt-2">
<AIToolsPanel />
</TabsContent>
<TabsContent value="prompts" className="mt-2">
<AIPromptsPanel />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,179 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Search, Wrench } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { aiAgentService } from "@/services/aiAgent.service";
import type { AIToolSummary } from "@/types/aiAgent";
const CATEGORY_TONES: Record<string, string> = {
retrieval: "bg-blue-500",
persistence: "bg-amber-500",
quality: "bg-purple-500",
scoring: "bg-emerald-500",
reference: "bg-slate-500",
other: "bg-zinc-500",
};
export function AIToolsPanel() {
const { t } = useTranslation();
const qc = useQueryClient();
const [search, setSearch] = useState("");
const toolsQ = useQuery({
queryKey: ["ai-agent-tools"],
queryFn: () => aiAgentService.listTools(),
});
const toggle = useMutation({
mutationFn: (tool: AIToolSummary) =>
aiAgentService.updateTool(tool.id, { active: !tool.active }),
onSuccess: (tool) => {
toast.success(
tool.active
? t("tools.toggle.enabled", "Tool enabled")
: t("tools.toggle.disabled", "Tool disabled"),
);
qc.invalidateQueries({ queryKey: ["ai-agent-tools"] });
},
onError: (err: Error) => toast.error(err.message),
});
const filtered = useMemo(() => {
const items = toolsQ.data ?? [];
if (!search.trim()) return items;
const needle = search.toLowerCase();
return items.filter(
(t) =>
t.key.toLowerCase().includes(needle) ||
t.name.toLowerCase().includes(needle) ||
(t.description || "").toLowerCase().includes(needle),
);
}, [toolsQ.data, search]);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Wrench className="h-4 w-4" />
{t("tools.title", "Agent tools")}
</CardTitle>
<CardDescription>
{t(
"tools.subtitle",
"Capabilities your agents can call. Toggle a tool off to take it out of every agent without editing each one.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="relative max-w-sm">
<Search className="text-muted-foreground absolute start-2 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("tools.search", "Search tools…")}
className="ps-8"
/>
</div>
{toolsQ.isLoading ? (
<Skeleton className="h-64 w-full" />
) : filtered.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("tools.empty", "No tools match your search.")}
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("tools.col.key", "Key")}</TableHead>
<TableHead>{t("tools.col.name", "Name")}</TableHead>
<TableHead>{t("tools.col.category", "Category")}</TableHead>
<TableHead>{t("tools.col.description", "Description")}</TableHead>
<TableHead>{t("tools.col.params", "Params")}</TableHead>
<TableHead className="w-[100px] text-end">
{t("tools.col.active", "Active")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((tool) => {
const tone = CATEGORY_TONES[tool.category] || "bg-zinc-500";
const params =
(tool.schema?.properties as Record<string, unknown>) || {};
const paramKeys = Object.keys(params);
return (
<TableRow key={tool.id}>
<TableCell className="font-mono text-xs">{tool.key}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<span>{tool.name}</span>
{tool.mutates ? (
<Badge className="bg-amber-500 text-white text-[10px] hover:bg-amber-500">
{t("tools.writes", "writes")}
</Badge>
) : null}
</div>
</TableCell>
<TableCell>
<Badge className={`${tone} text-white hover:${tone}`}>
{tool.category}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground max-w-[360px] text-xs">
{tool.description}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{paramKeys.length === 0 ? (
<span className="text-muted-foreground text-xs"></span>
) : (
paramKeys.slice(0, 5).map((k) => (
<Badge key={k} variant="outline" className="text-[10px]">
{k}
</Badge>
))
)}
{paramKeys.length > 5 ? (
<Badge variant="outline" className="text-[10px]">
+{paramKeys.length - 5}
</Badge>
) : null}
</div>
</TableCell>
<TableCell className="text-end">
<Switch
checked={tool.active}
onCheckedChange={() => toggle.mutate(tool)}
disabled={toggle.isPending}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,423 @@
import { useMemo } from "react";
import { Link, useParams } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
ArrowLeft,
BookOpen,
Calendar,
ClipboardList,
Headphones,
Library,
MessageSquare,
Mic,
PenSquare,
Sparkles,
Type,
Wand2,
type LucideIcon,
} 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 { Skeleton } from "@/components/ui/skeleton";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { coursePlanService } from "@/services/coursePlan.service";
import { describeApiError } from "@/lib/api-client";
import type {
CoursePlan,
CoursePlanMaterial,
CoursePlanSkill,
CoursePlanWeek,
} from "@/types";
/**
* Detail page for a single AI-generated course plan.
*
* Sections:
* - Header with course metadata + CEFR badge
* - Course description
* - Objectives list
* - Learning outcomes grouped by skill
* - Grammar scope
* - Assessment breakdown
* - Resources
* - Weekly delivery plan (accordion). Each week shows the items table
* from the AI plus a "Generate materials" button. When the user
* clicks it, we POST to `/weeks/:n/materials` and re-render the
* week content with the returned reading/listening/grammar/…
* materials.
*/
const SKILL_ICONS: Record<string, LucideIcon> = {
reading: BookOpen,
writing: PenSquare,
listening: Headphones,
speaking: Mic,
grammar: Type,
vocabulary: Library,
integrated: MessageSquare,
};
export default function AdminCoursePlanDetail() {
const { t } = useTranslation();
const params = useParams<{ planId: string }>();
const planId = Number(params.planId);
const qc = useQueryClient();
const { data, isLoading, isError, error } = useQuery({
queryKey: ["course-plan", planId],
queryFn: () => coursePlanService.get(planId),
enabled: Number.isFinite(planId) && planId > 0,
});
const plan = data?.data as CoursePlan | undefined;
const generateMut = useMutation({
mutationFn: (weekNumber: number) =>
coursePlanService.generateWeekMaterials(planId, weekNumber),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["course-plan", planId] });
toast.success(t("coursePlan.weekMaterialsGenerated"));
},
onError: (err) =>
toast.error(describeApiError(err, t("coursePlan.weekMaterialsFailed"))),
});
const materialsByWeek = useMemo(() => {
const map = new Map<number, CoursePlanMaterial[]>();
if (!plan?.materials) return map;
for (const m of plan.materials) {
const list = map.get(m.week_number) ?? [];
list.push(m);
map.set(m.week_number, list);
}
return map;
}, [plan?.materials]);
return (
<div className="space-y-6">
<Button variant="ghost" size="sm" asChild className="-ml-2 text-muted-foreground">
<Link to="/admin/course-plans" className="flex items-center gap-1">
<ArrowLeft className="h-4 w-4" />
{t("coursePlan.backToList")}
</Link>
</Button>
{isLoading && (
<div className="space-y-4">
<Skeleton className="h-20 rounded-lg" />
<Skeleton className="h-40 rounded-lg" />
<Skeleton className="h-60 rounded-lg" />
</div>
)}
{isError && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{describeApiError(error, t("coursePlan.loadFailed"))}
</div>
)}
{plan && (
<>
{/* Header */}
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="space-y-1 flex-1 min-w-0">
<CardTitle className="text-2xl">{plan.name}</CardTitle>
{plan.description && (
<CardDescription className="max-w-3xl">
{plan.description}
</CardDescription>
)}
</div>
<div className="flex gap-2 flex-wrap">
<Badge variant="secondary" className="uppercase">
{plan.cefr_level || "—"}
</Badge>
<Badge variant="outline">
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
</Badge>
<Badge variant="outline">
{t("coursePlan.hoursPerWeek", {
count: plan.contact_hours_per_week,
})}
</Badge>
</div>
</div>
{plan.skills_division && (
<p className="text-sm text-muted-foreground">
{plan.skills_division}
</p>
)}
</CardHeader>
</Card>
{/* Objectives */}
{plan.objectives.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-primary" />
{t("coursePlan.sections.objectives")}
</CardTitle>
</CardHeader>
<CardContent>
<ol className="list-decimal list-inside space-y-1 text-sm">
{plan.objectives.map((obj, i) => (
<li key={i}>{obj}</li>
))}
</ol>
</CardContent>
</Card>
)}
{/* Outcomes per skill */}
{Object.keys(plan.outcomes).length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{t("coursePlan.sections.outcomes")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{(Object.keys(plan.outcomes) as CoursePlanSkill[]).map((skill) => {
const list = plan.outcomes[skill];
if (!list?.length) return null;
const Icon = SKILL_ICONS[skill] ?? ClipboardList;
return (
<div key={skill}>
<div className="flex items-center gap-2 mb-2">
<Icon className="h-4 w-4" />
<div className="font-medium capitalize">{t(`coursePlan.skill.${skill}`, skill)}</div>
</div>
<ul className="space-y-1 text-sm">
{list.map((o) => (
<li key={o.code} className="flex gap-2">
<Badge variant="outline" className="font-mono text-[10px] shrink-0">
{o.code}
</Badge>
<span className="flex-1">{o.description}</span>
</li>
))}
</ul>
</div>
);
})}
</CardContent>
</Card>
)}
{/* Grammar scope */}
{plan.grammar.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Type className="h-4 w-4 text-primary" />
{t("coursePlan.sections.grammar")}
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{plan.grammar.map((g) => (
<li key={g.code}>
<div className="font-medium">
<Badge variant="outline" className="font-mono text-[10px] mr-2">
{g.code}
</Badge>
{g.label}
</div>
{g.sub_items?.length ? (
<ul className="list-disc list-inside ml-6 mt-1 text-muted-foreground text-xs">
{g.sub_items.map((s, i) => (
<li key={i}>{s}</li>
))}
</ul>
) : null}
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Weekly delivery plan */}
{plan.weeks && plan.weeks.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Calendar className="h-4 w-4 text-primary" />
{t("coursePlan.sections.delivery")}
</CardTitle>
<CardDescription>
{t("coursePlan.deliveryHint")}
</CardDescription>
</CardHeader>
<CardContent>
<Accordion type="multiple" className="w-full">
{plan.weeks.map((week) => (
<WeekAccordionItem
key={week.id}
week={week}
materials={materialsByWeek.get(week.week_number) ?? []}
generating={generateMut.isPending && generateMut.variables === week.week_number}
onGenerate={() => generateMut.mutate(week.week_number)}
/>
))}
</Accordion>
</CardContent>
</Card>
)}
</>
)}
</div>
);
}
function WeekAccordionItem({
week,
materials,
generating,
onGenerate,
}: {
week: CoursePlanWeek;
materials: CoursePlanMaterial[];
generating: boolean;
onGenerate: () => void;
}) {
const { t } = useTranslation();
return (
<AccordionItem value={String(week.week_number)}>
<AccordionTrigger className="text-left">
<div className="flex-1 flex items-center gap-3 min-w-0">
<Badge variant="outline" className="shrink-0">
{t("coursePlan.weekN", { n: week.week_number })}
</Badge>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{week.focus || week.unit || "—"}</div>
{week.date_label && (
<div className="text-xs text-muted-foreground truncate">
{week.date_label}
</div>
)}
</div>
{materials.length > 0 && (
<Badge variant="secondary" className="shrink-0">
{t("coursePlan.materialsCount", { count: materials.length })}
</Badge>
)}
</div>
</AccordionTrigger>
<AccordionContent className="space-y-4">
{/* Items table */}
{week.items.length > 0 && (
<div className="rounded-md border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-medium">
{t("coursePlan.table.skill")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("coursePlan.table.outcomes")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("coursePlan.table.remarks")}
</th>
</tr>
</thead>
<tbody>
{week.items.map((item, i) => {
const Icon = SKILL_ICONS[item.skill] ?? ClipboardList;
return (
<tr key={i} className="border-t">
<td className="px-3 py-2">
<span className="flex items-center gap-1.5 capitalize">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
{item.skill}
</span>
</td>
<td className="px-3 py-2 flex flex-wrap gap-1">
{item.outcome_codes.map((c) => (
<Badge
key={c}
variant="outline"
className="font-mono text-[10px]"
>
{c}
</Badge>
))}
</td>
<td className="px-3 py-2 text-muted-foreground">
{item.remarks || "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Generate materials */}
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" onClick={onGenerate} disabled={generating}>
<Wand2 className="mr-1 h-4 w-4" />
{generating
? t("coursePlan.generating")
: materials.length > 0
? t("coursePlan.regenerateMaterials")
: t("coursePlan.generateMaterials")}
</Button>
<span className="text-xs text-muted-foreground">
{t("coursePlan.generateHint")}
</span>
</div>
{/* Generated materials */}
{materials.length > 0 && (
<div className="space-y-3">
{materials.map((m) => (
<MaterialCard key={m.id} material={m} />
))}
</div>
)}
</AccordionContent>
</AccordionItem>
);
}
function MaterialCard({ material }: { material: CoursePlanMaterial }) {
const { t } = useTranslation();
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-2 flex-wrap">
<Icon className="h-4 w-4 text-primary" />
<CardTitle className="text-base flex-1 min-w-0">{material.title}</CardTitle>
<Badge variant="outline" className="text-[10px]">
{t(`coursePlan.materialType.${material.material_type}`, material.material_type)}
</Badge>
</div>
{material.summary && (
<CardDescription>{material.summary}</CardDescription>
)}
</CardHeader>
<CardContent>
<pre className="whitespace-pre-wrap text-xs font-mono bg-muted/40 rounded-md p-3 max-h-80 overflow-auto">
{material.body_text || JSON.stringify(material.body, null, 2)}
</pre>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,175 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
BookOpen,
Plus,
Sparkles,
Trash2,
Calendar,
Layers,
} from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { coursePlanService } from "@/services/coursePlan.service";
import { describeApiError } from "@/lib/api-client";
/**
* Admin list of AI-generated course plans.
*
* Each plan row shows name + CEFR + weeks + status, plus an "Open"
* button that deep-links to the detail page. A prominent "Generate new"
* CTA routes to the Smart Wizard's CoursePlanWizard.
*/
export default function AdminCoursePlans() {
const { t } = useTranslation();
const navigate = useNavigate();
const qc = useQueryClient();
const [search, setSearch] = useState("");
const { data, isLoading, isError, error } = useQuery({
queryKey: ["course-plans", { search }],
queryFn: () =>
coursePlanService.list({
page: 0,
size: 50,
search: search || undefined,
}),
});
const removeMut = useMutation({
mutationFn: (id: number) => coursePlanService.remove(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["course-plans"] });
toast.success(t("coursePlan.deleted"));
},
onError: (err) =>
toast.error(describeApiError(err, t("coursePlan.deleteFailed"))),
});
const items = data?.items ?? [];
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-1">
<div className="flex items-center gap-2">
<BookOpen className="h-5 w-5 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">
{t("coursePlan.listTitle")}
</h1>
</div>
<p className="text-muted-foreground max-w-2xl">
{t("coursePlan.listSubtitle")}
</p>
</div>
<Button onClick={() => navigate("/admin/smart-wizard/course-plan")}>
<Sparkles className="mr-1 h-4 w-4" />
{t("coursePlan.generateNew")}
</Button>
</div>
<div className="max-w-sm">
<Input
placeholder={t("coursePlan.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{isLoading && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-40 rounded-lg" />
))}
</div>
)}
{isError && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{describeApiError(error, t("coursePlan.loadFailed"))}
</div>
)}
{!isLoading && !isError && items.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center gap-3 py-12">
<Sparkles className="h-8 w-8 text-muted-foreground" />
<div className="text-center">
<div className="font-medium">{t("coursePlan.emptyTitle")}</div>
<div className="text-sm text-muted-foreground max-w-md">
{t("coursePlan.emptySubtitle")}
</div>
</div>
<Button onClick={() => navigate("/admin/smart-wizard/course-plan")}>
<Plus className="mr-1 h-4 w-4" />
{t("coursePlan.generateNew")}
</Button>
</CardContent>
</Card>
)}
{!isLoading && items.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map((plan) => (
<Card key={plan.id} className="flex flex-col">
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base line-clamp-2">{plan.name}</CardTitle>
<Badge variant={plan.status === "approved" ? "default" : "outline"}>
{t(`coursePlan.status.${plan.status}`, plan.status)}
</Badge>
</div>
<CardDescription className="line-clamp-2">
{plan.description || t("coursePlan.noDescription")}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-between gap-3">
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Badge variant="secondary" className="text-[10px] uppercase">
{plan.cefr_level || "—"}
</Badge>
</span>
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
</span>
<span className="flex items-center gap-1">
<Layers className="h-3.5 w-3.5" />
{t("coursePlan.materialsCount", { count: plan.material_count })}
</span>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="default" size="sm" className="flex-1">
<Link to={`/admin/course-plans/${plan.id}`}>
{t("coursePlan.open")}
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive"
onClick={() => {
if (!window.confirm(t("coursePlan.confirmDelete", { name: plan.name }))) return;
removeMut.mutate(plan.id);
}}
aria-label={t("coursePlan.delete")}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,165 @@
import {
BookOpen,
Layers,
Wand2,
GitBranch,
ClipboardList,
FolderOpen,
Users,
GraduationCap,
FileText,
Ticket,
Building2,
Sparkles,
} from "lucide-react";
import { QuickSetupWizard, type WizardStep, type QuickCreate } from "@/components/QuickSetupWizard";
import { examsService } from "@/services/exams.service";
import { assignmentsService } from "@/services/assignments.service";
/**
* Admin smart-setup wizard.
*
* Surfaces the recommended order for standing up an exam-ready platform:
* rubric → structure → generate → approve → assign. Each step deep-links
* into the existing creation page and auto-ticks when the backend confirms
* at least one record exists, so coming back here after a sub-task shows
* visible progress.
*
* "Other quick creates" covers the most common ad-hoc actions (new course,
* upload resource, add user, etc.) so admins can jump straight in without
* hunting through the sidebar.
*/
const steps: WizardStep[] = [
{
id: "rubric",
titleKey: "quickSetup.admin.step1.title",
descriptionKey: "quickSetup.admin.step1.description",
helpKey: "quickSetup.admin.step1.help",
to: "/admin/rubrics",
icon: BookOpen,
check: async () => {
const res = await examsService.listRubrics({ page: 1, size: 1 });
return (res.items?.length ?? 0) > 0;
},
},
{
id: "structure",
titleKey: "quickSetup.admin.step2.title",
descriptionKey: "quickSetup.admin.step2.description",
helpKey: "quickSetup.admin.step2.help",
to: "/admin/exam-structures",
icon: Layers,
check: async () => {
const res = await examsService.listStructures({ page: 1, size: 1 });
return (res.items?.length ?? 0) > 0;
},
},
{
id: "generate",
titleKey: "quickSetup.admin.step3.title",
descriptionKey: "quickSetup.admin.step3.description",
helpKey: "quickSetup.admin.step3.help",
to: "/admin/generation",
icon: Wand2,
// "Is there at least one exam of any module?" — cheapest existence
// check; the writing module is a common default.
check: async () => {
const res = await examsService.list("writing", { page: 1, size: 1 });
return (res.items?.length ?? 0) > 0;
},
},
{
id: "approve",
titleKey: "quickSetup.admin.step4.title",
descriptionKey: "quickSetup.admin.step4.description",
helpKey: "quickSetup.admin.step4.help",
to: "/admin/approval-workflows",
icon: GitBranch,
// No cheap "any workflow exists" endpoint — leave this one uncheckable
// so it stays neutral and always visible in the flow.
},
{
id: "assign",
titleKey: "quickSetup.admin.step5.title",
descriptionKey: "quickSetup.admin.step5.description",
helpKey: "quickSetup.admin.step5.help",
to: "/admin/assignments",
icon: ClipboardList,
check: async () => {
const res = await assignmentsService.listSchedules({ page: 1, size: 1 });
return (res.items?.length ?? 0) > 0;
},
},
];
const quickCreates: QuickCreate[] = [
{
id: "course",
titleKey: "quickSetup.admin.quick.course.title",
descriptionKey: "quickSetup.admin.quick.course.description",
to: "/admin/courses",
icon: BookOpen,
},
{
id: "resource",
titleKey: "quickSetup.admin.quick.resource.title",
descriptionKey: "quickSetup.admin.quick.resource.description",
to: "/admin/resources",
icon: FolderOpen,
},
{
id: "student",
titleKey: "quickSetup.admin.quick.student.title",
descriptionKey: "quickSetup.admin.quick.student.description",
to: "/admin/students",
icon: Users,
},
{
id: "teacher",
titleKey: "quickSetup.admin.quick.teacher.title",
descriptionKey: "quickSetup.admin.quick.teacher.description",
to: "/admin/teachers",
icon: GraduationCap,
},
{
id: "classroom",
titleKey: "quickSetup.admin.quick.classroom.title",
descriptionKey: "quickSetup.admin.quick.classroom.description",
to: "/admin/classrooms",
icon: Building2,
},
{
id: "exam-session",
titleKey: "quickSetup.admin.quick.examSession.title",
descriptionKey: "quickSetup.admin.quick.examSession.description",
to: "/admin/exam-sessions",
icon: FileText,
},
{
id: "custom-exam",
titleKey: "quickSetup.admin.quick.customExam.title",
descriptionKey: "quickSetup.admin.quick.customExam.description",
to: "/admin/exams/new",
icon: Sparkles,
},
{
id: "ticket",
titleKey: "quickSetup.admin.quick.ticket.title",
descriptionKey: "quickSetup.admin.quick.ticket.description",
to: "/admin/tickets",
icon: Ticket,
},
];
export default function AdminQuickSetup() {
return (
<QuickSetupWizard
titleKey="quickSetup.adminTitle"
subtitleKey="quickSetup.adminSubtitle"
steps={steps}
quickCreates={quickCreates}
/>
);
}

View File

@@ -0,0 +1,215 @@
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
BookOpen,
Layers,
Wand2,
GitBranch,
ClipboardList,
FolderOpen,
Users,
GraduationCap,
Sparkles,
Compass,
ChevronRight,
ArrowRight,
type LucideIcon,
} 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";
/**
* Smart Wizard Hub.
*
* Landing page that groups every step-by-step wizard in a single place.
* Admins pick a scenario → land in the inline Next/Next/Finish flow. The
* page also exposes "Advanced" deep links to the full creation pages for
* power users who don't need hand-holding.
*/
interface WizardCard {
id: string;
to: string;
icon: LucideIcon;
titleKey: string;
descriptionKey: string;
badgeKey?: string;
/** When true, deep-links to the full creation page instead of a wizard. */
advanced?: boolean;
}
const guidedWizards: WizardCard[] = [
{
id: "rubric",
to: "/admin/smart-wizard/rubric",
icon: BookOpen,
titleKey: "wizardHub.cards.rubric.title",
descriptionKey: "wizardHub.cards.rubric.description",
},
{
id: "exam-structure",
to: "/admin/smart-wizard/exam-structure",
icon: Layers,
titleKey: "wizardHub.cards.examStructure.title",
descriptionKey: "wizardHub.cards.examStructure.description",
},
{
id: "course",
to: "/admin/smart-wizard/course",
icon: GraduationCap,
titleKey: "wizardHub.cards.course.title",
descriptionKey: "wizardHub.cards.course.description",
},
{
id: "course-plan",
to: "/admin/smart-wizard/course-plan",
icon: Compass,
titleKey: "wizardHub.cards.coursePlan.title",
descriptionKey: "wizardHub.cards.coursePlan.description",
badgeKey: "wizardHub.aiBadge",
},
];
const advancedLinks: WizardCard[] = [
{
id: "generation",
to: "/admin/generation",
icon: Wand2,
titleKey: "wizardHub.cards.generation.title",
descriptionKey: "wizardHub.cards.generation.description",
advanced: true,
},
{
id: "approval",
to: "/admin/approval-workflows",
icon: GitBranch,
titleKey: "wizardHub.cards.approval.title",
descriptionKey: "wizardHub.cards.approval.description",
advanced: true,
},
{
id: "assign",
to: "/admin/assignments",
icon: ClipboardList,
titleKey: "wizardHub.cards.assign.title",
descriptionKey: "wizardHub.cards.assign.description",
advanced: true,
},
{
id: "resource",
to: "/admin/resources",
icon: FolderOpen,
titleKey: "wizardHub.cards.resource.title",
descriptionKey: "wizardHub.cards.resource.description",
advanced: true,
},
{
id: "student",
to: "/admin/students",
icon: Users,
titleKey: "wizardHub.cards.student.title",
descriptionKey: "wizardHub.cards.student.description",
advanced: true,
},
];
export default function SmartWizardHub() {
const { t } = useTranslation();
return (
<div className="space-y-8">
<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("wizardHub.title")}</h1>
</div>
<p className="text-muted-foreground max-w-3xl">{t("wizardHub.subtitle")}</p>
</div>
{/* Recommended order hint */}
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
<div className="font-medium mb-1">{t("wizardHub.recommendedOrder")}</div>
<ol className="list-decimal list-inside text-muted-foreground space-y-0.5">
<li>{t("wizardHub.order.rubric")}</li>
<li>{t("wizardHub.order.structure")}</li>
<li>{t("wizardHub.order.generate")}</li>
<li>{t("wizardHub.order.approve")}</li>
<li>{t("wizardHub.order.assign")}</li>
</ol>
</div>
{/* Guided wizards */}
<section aria-labelledby="wizard-hub-guided">
<h2 id="wizard-hub-guided" className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3">
{t("wizardHub.guided")}
</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{guidedWizards.map((card) => (
<WizardCardView key={card.id} card={card} />
))}
</div>
</section>
{/* Advanced deep links */}
<section aria-labelledby="wizard-hub-advanced">
<h2 id="wizard-hub-advanced" className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3">
{t("wizardHub.advanced")}
</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{advancedLinks.map((card) => (
<WizardCardView key={card.id} card={card} />
))}
</div>
</section>
</div>
);
}
function WizardCardView({ card }: { card: WizardCard }) {
const { t } = useTranslation();
const Icon = card.icon;
return (
<Card className="transition-all hover:shadow-md hover:-translate-y-0.5">
<CardHeader className="pb-2">
<div className="flex items-center gap-2 flex-wrap">
<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 flex-1 min-w-0">{t(card.titleKey)}</CardTitle>
{card.badgeKey && (
<Badge variant="secondary" className="text-[10px]">
{t(card.badgeKey)}
</Badge>
)}
{card.advanced && (
<Badge variant="outline" className="text-[10px]">
{t("wizardHub.advancedBadge")}
</Badge>
)}
</div>
<CardDescription className="line-clamp-3 min-h-[3em]">
{t(card.descriptionKey)}
</CardDescription>
</CardHeader>
<CardContent>
<Button
asChild
size="sm"
variant={card.advanced ? "secondary" : "default"}
className="w-full"
>
<Link to={card.to} className="flex items-center justify-center gap-1">
{card.advanced ? t("wizardHub.openPage") : t("wizardHub.startWizard")}
{card.advanced ? (
<ChevronRight className="h-3.5 w-3.5" />
) : (
<ArrowRight className="h-3.5 w-3.5" />
)}
</Link>
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,373 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { X } from "lucide-react";
import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { coursePlanService } from "@/services/coursePlan.service";
import { describeApiError } from "@/lib/api-client";
import type { CoursePlanGenerateBrief } from "@/types";
/**
* AI course-plan generation wizard.
*
* Four steps:
* 1. Basics — title, CEFR level, total weeks, contact hours.
* 2. Coverage — skills division (e.g. "10 hrs Reading+Writing /
* 8 hrs Listening+Speaking"), learner profile.
* 3. Scope — grammar focus chips, resource citations chips,
* optional free-form notes.
* 4. Review — finish → POST /api/ai/course-plan which triggers
* the OpenAI call, persists the plan, and returns
* the finished record; we navigate to the detail
* page so the user can start generating Week 1
* materials immediately.
*/
interface CoursePlanWizardState {
title: string;
cefr_level: string;
total_weeks: number;
contact_hours_per_week: number;
skills_division: string;
learner_profile: string;
grammar_focus: string[];
resources: string[];
notes: string;
}
const CEFR_OPTIONS = [
{ value: "pre_a1", label: "Pre-A1" },
{ value: "a1", label: "A1" },
{ value: "a2", label: "A2" },
{ value: "b1", label: "B1" },
{ value: "b2", label: "B2" },
{ value: "c1", label: "C1" },
{ value: "c2", label: "C2" },
];
export default function CoursePlanWizard() {
const { t } = useTranslation();
const navigate = useNavigate();
const qc = useQueryClient();
const mutation = useMutation({
mutationFn: (brief: CoursePlanGenerateBrief) =>
coursePlanService.generate(brief),
onSuccess: (resp) => {
qc.invalidateQueries({ queryKey: ["course-plans"] });
toast.success(t("coursePlan.generateSuccess"));
const id = resp?.data?.id;
if (id) navigate(`/admin/course-plans/${id}`);
else navigate("/admin/course-plans");
},
onError: (err) => {
toast.error(describeApiError(err, t("coursePlan.generateFailed")));
},
});
const steps: WizardStepDef<CoursePlanWizardState>[] = [
{
id: "basics",
titleKey: "coursePlan.wizard.steps.basics",
descriptionKey: "coursePlan.wizard.steps.basicsDesc",
validate: (s) => {
if (!s.title.trim()) return t("coursePlan.wizard.errors.titleRequired");
if (!s.cefr_level) return t("coursePlan.wizard.errors.cefrRequired");
if (!s.total_weeks || s.total_weeks < 1)
return t("coursePlan.wizard.errors.weeksRange");
return null;
},
render: ({ state, update }) => (
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<Label htmlFor="title">{t("coursePlan.wizard.fields.title")}</Label>
<Input
id="title"
placeholder="General English 1"
value={state.title}
onChange={(e) => update({ title: e.target.value })}
/>
</div>
<div>
<Label>{t("coursePlan.wizard.fields.cefr")}</Label>
<Select
value={state.cefr_level}
onValueChange={(v) => update({ cefr_level: v })}
>
<SelectTrigger>
<SelectValue placeholder={t("coursePlan.wizard.fields.cefrPlaceholder")} />
</SelectTrigger>
<SelectContent>
{CEFR_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="weeks">{t("coursePlan.wizard.fields.totalWeeks")}</Label>
<Input
id="weeks"
type="number"
min={1}
max={30}
value={state.total_weeks}
onChange={(e) => update({ total_weeks: Number(e.target.value) || 0 })}
/>
</div>
<div>
<Label htmlFor="hours">{t("coursePlan.wizard.fields.contactHours")}</Label>
<Input
id="hours"
type="number"
min={1}
max={40}
value={state.contact_hours_per_week}
onChange={(e) =>
update({ contact_hours_per_week: Number(e.target.value) || 0 })
}
/>
</div>
</div>
),
},
{
id: "coverage",
titleKey: "coursePlan.wizard.steps.coverage",
descriptionKey: "coursePlan.wizard.steps.coverageDesc",
render: ({ state, update }) => (
<div className="grid gap-4">
<div>
<Label htmlFor="skills">
{t("coursePlan.wizard.fields.skillsDivision")}
</Label>
<Input
id="skills"
placeholder="10 hrs/wk Reading & Writing + 8 hrs/wk Listening & Speaking"
value={state.skills_division}
onChange={(e) => update({ skills_division: e.target.value })}
/>
<p className="mt-1 text-xs text-muted-foreground">
{t("coursePlan.wizard.fields.skillsDivisionHint")}
</p>
</div>
<div>
<Label htmlFor="profile">
{t("coursePlan.wizard.fields.learnerProfile")}
</Label>
<Textarea
id="profile"
rows={3}
placeholder={t("coursePlan.wizard.fields.learnerProfilePlaceholder")}
value={state.learner_profile}
onChange={(e) => update({ learner_profile: e.target.value })}
/>
</div>
</div>
),
},
{
id: "scope",
titleKey: "coursePlan.wizard.steps.scope",
descriptionKey: "coursePlan.wizard.steps.scopeDesc",
render: ({ state, update }) => (
<div className="grid gap-4">
<ChipInput
label={t("coursePlan.wizard.fields.grammarFocus")}
placeholder={t("coursePlan.wizard.fields.grammarFocusPlaceholder")}
values={state.grammar_focus}
onChange={(next) => update({ grammar_focus: next })}
/>
<ChipInput
label={t("coursePlan.wizard.fields.resources")}
placeholder={t("coursePlan.wizard.fields.resourcesPlaceholder")}
values={state.resources}
onChange={(next) => update({ resources: next })}
/>
<div>
<Label htmlFor="notes">{t("coursePlan.wizard.fields.notes")}</Label>
<Textarea
id="notes"
rows={3}
placeholder={t("coursePlan.wizard.fields.notesPlaceholder")}
value={state.notes}
onChange={(e) => update({ notes: e.target.value })}
/>
</div>
</div>
),
},
{
id: "review",
titleKey: "coursePlan.wizard.steps.review",
descriptionKey: "coursePlan.wizard.steps.reviewDesc",
render: ({ state }) => (
<div className="space-y-3 text-sm">
<ReviewRow label={t("coursePlan.wizard.fields.title")} value={state.title} />
<ReviewRow
label={t("coursePlan.wizard.fields.cefr")}
value={
CEFR_OPTIONS.find((o) => o.value === state.cefr_level)?.label ||
state.cefr_level ||
"—"
}
/>
<ReviewRow
label={t("coursePlan.wizard.fields.totalWeeks")}
value={String(state.total_weeks || "—")}
/>
<ReviewRow
label={t("coursePlan.wizard.fields.contactHours")}
value={String(state.contact_hours_per_week || "—")}
/>
<ReviewRow
label={t("coursePlan.wizard.fields.skillsDivision")}
value={state.skills_division || "—"}
/>
<ReviewRow
label={t("coursePlan.wizard.fields.learnerProfile")}
value={state.learner_profile || "—"}
/>
<ReviewRow
label={t("coursePlan.wizard.fields.grammarFocus")}
value={state.grammar_focus.join(", ") || "—"}
/>
<ReviewRow
label={t("coursePlan.wizard.fields.resources")}
value={state.resources.join(", ") || "—"}
/>
<ReviewRow
label={t("coursePlan.wizard.fields.notes")}
value={state.notes || "—"}
/>
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
{t("coursePlan.wizard.reviewHint")}
</div>
</div>
),
},
];
return (
<StepWizard<CoursePlanWizardState>
titleKey="coursePlan.wizard.title"
subtitleKey="coursePlan.wizard.subtitle"
backTo="/admin/smart-wizard"
steps={steps}
submitting={mutation.isPending}
finishLabelKey="coursePlan.wizard.finish"
initialState={{
title: "",
cefr_level: "a2",
total_weeks: 12,
contact_hours_per_week: 18,
skills_division: "",
learner_profile: "",
grammar_focus: [],
resources: [],
notes: "",
}}
onFinish={async (state) => {
await mutation.mutateAsync({
title: state.title.trim(),
cefr_level: state.cefr_level,
total_weeks: state.total_weeks,
contact_hours_per_week: state.contact_hours_per_week,
skills_division: state.skills_division.trim() || undefined,
learner_profile: state.learner_profile.trim() || undefined,
grammar_focus: state.grammar_focus.length
? state.grammar_focus
: undefined,
resources: state.resources.length ? state.resources : undefined,
notes: state.notes.trim() || undefined,
});
}}
/>
);
}
function ReviewRow({ label, value }: { label: string; value: string }) {
return (
<div className="grid grid-cols-3 gap-2">
<div className="col-span-1 text-muted-foreground">{label}</div>
<div className="col-span-2 font-medium break-words">{value}</div>
</div>
);
}
/**
* Tiny chip input: press Enter (or comma) to commit a value. Used for
* grammar focus items and resource citations. Kept local because it's
* not worth promoting to a reusable component yet.
*/
function ChipInput({
label,
placeholder,
values,
onChange,
}: {
label: string;
placeholder?: string;
values: string[];
onChange: (next: string[]) => void;
}) {
const [draft, setDraft] = useState("");
const commit = () => {
const clean = draft.trim();
if (!clean) return;
if (!values.includes(clean)) onChange([...values, clean]);
setDraft("");
};
return (
<div>
<Label>{label}</Label>
<div className="mt-1 flex flex-wrap items-center gap-1.5 rounded-md border px-2 py-2">
{values.map((v, i) => (
<Badge key={`${v}-${i}`} variant="secondary" className="gap-1">
{v}
<button
type="button"
className="ml-1 rounded-sm hover:bg-destructive/20"
onClick={() => onChange(values.filter((_, idx) => idx !== i))}
aria-label={`Remove ${v}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
<Input
placeholder={placeholder}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
commit();
} else if (e.key === "Backspace" && !draft && values.length) {
onChange(values.slice(0, -1));
}
}}
onBlur={commit}
className="flex-1 border-0 shadow-none focus-visible:ring-0 h-8 min-w-[8rem] px-1"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,271 @@
import { useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { lmsService } from "@/services/lms.service";
import { describeApiError } from "@/lib/api-client";
import type { CourseCreateRequest } from "@/types";
/**
* Course creation wizard (3 steps).
*
* 1. Basics — title, code (auto-derived from title by default), description
* 2. Level & capacity — difficulty, CEFR level, max capacity
* 3. Review → Finish posts to lmsService.createCourse
*
* Mirrors the fields of the existing AdminCourses "New course" dialog but
* surfaced as a guided flow. Fields the admin normally leaves blank
* (topic_ids, tag_ids, subject_id) are omitted here; they can be refined
* on the full course page after the course is created.
*/
interface CourseWizardState {
title: string;
code: string;
description: string;
difficulty_level: "beginner" | "intermediate" | "advanced" | "";
cefr_level: string;
max_capacity: number;
}
const INITIAL: CourseWizardState = {
title: "",
code: "",
description: "",
difficulty_level: "",
cefr_level: "",
max_capacity: 30,
};
/**
* Odoo `op.course.cefr_level` is a Selection field with lowercase keys
* (`pre_a1`, `a1`, `a2`, …). Sending `"A2"` raises
* `Wrong value for op.course.cefr_level` (HTTP 500). We display the
* uppercase label but always submit the lowercase key.
*/
const CEFR_LEVELS: { value: string; label: string }[] = [
{ value: "pre_a1", label: "Pre-A1" },
{ value: "a1", label: "A1" },
{ value: "a2", label: "A2" },
{ value: "b1", label: "B1" },
{ value: "b2", label: "B2" },
{ value: "c1", label: "C1" },
{ value: "c2", label: "C2" },
];
function deriveCode(title: string): string {
return title.trim().toUpperCase().replace(/\s+/g, "-").slice(0, 16);
}
export default function CourseWizard() {
const { t } = useTranslation();
const navigate = useNavigate();
const qc = useQueryClient();
const createMut = useMutation({
mutationFn: (data: CourseCreateRequest) => lmsService.createCourse(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
toast.success(t("wizard.course.toastSuccess"));
navigate("/admin/courses");
},
onError: (err: unknown) => {
toast.error(describeApiError(err, t("wizard.course.toastError")));
},
});
const steps: WizardStepDef<CourseWizardState>[] = [
{
id: "basics",
titleKey: "wizard.course.step1.title",
descriptionKey: "wizard.course.step1.description",
validate: (s) => {
if (!s.title.trim()) return t("wizard.course.errors.titleRequired");
return null;
},
render: ({ state, update }) => (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="course-title">{t("wizard.course.labels.title")}</Label>
<Input
id="course-title"
value={state.title}
onChange={(e) => update({ title: e.target.value })}
placeholder={t("wizard.course.placeholders.title")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="course-code">{t("wizard.course.labels.code")}</Label>
<Input
id="course-code"
value={state.code}
onChange={(e) => update({ code: e.target.value })}
placeholder={deriveCode(state.title) || t("wizard.course.placeholders.code")}
/>
<p className="text-xs text-muted-foreground">{t("wizard.course.codeHint")}</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="course-desc">{t("wizard.course.labels.description")}</Label>
<Textarea
id="course-desc"
value={state.description}
onChange={(e) => update({ description: e.target.value })}
placeholder={t("wizard.course.placeholders.description")}
rows={4}
/>
</div>
</div>
),
},
{
id: "level",
titleKey: "wizard.course.step2.title",
descriptionKey: "wizard.course.step2.description",
validate: (s) => {
if (s.max_capacity < 1) return t("wizard.course.errors.capacityRequired");
return null;
},
render: ({ state, update }) => (
<div className="space-y-4">
<div className="space-y-1.5">
<Label>{t("wizard.course.labels.difficulty")}</Label>
<Select
value={state.difficulty_level || undefined}
onValueChange={(v) =>
update({ difficulty_level: v as CourseWizardState["difficulty_level"] })
}
>
<SelectTrigger>
<SelectValue placeholder={t("wizard.course.placeholders.difficulty")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="beginner">{t("wizard.course.difficulty.beginner")}</SelectItem>
<SelectItem value="intermediate">
{t("wizard.course.difficulty.intermediate")}
</SelectItem>
<SelectItem value="advanced">{t("wizard.course.difficulty.advanced")}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t("wizard.course.difficultyHint")}</p>
</div>
<div className="space-y-1.5">
<Label>{t("wizard.course.labels.cefrLevel")}</Label>
<Select
value={state.cefr_level || undefined}
onValueChange={(v) => update({ cefr_level: v })}
>
<SelectTrigger>
<SelectValue placeholder={t("wizard.course.placeholders.cefr")} />
</SelectTrigger>
<SelectContent>
{CEFR_LEVELS.map((lvl) => (
<SelectItem key={lvl.value} value={lvl.value}>
{lvl.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t("wizard.course.cefrHint")}</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="course-capacity">{t("wizard.course.labels.capacity")}</Label>
<Input
id="course-capacity"
type="number"
min={1}
value={state.max_capacity}
onChange={(e) => update({ max_capacity: Number(e.target.value) || 0 })}
/>
</div>
</div>
),
},
{
id: "review",
titleKey: "wizard.course.step3.title",
descriptionKey: "wizard.course.step3.description",
render: ({ state }) => (
<div className="space-y-4 text-sm">
<ReviewRow label={t("wizard.course.labels.title")} value={state.title} />
<ReviewRow
label={t("wizard.course.labels.code")}
value={state.code || deriveCode(state.title)}
/>
{state.description && (
<ReviewRow
label={t("wizard.course.labels.description")}
value={state.description}
wrap
/>
)}
<ReviewRow
label={t("wizard.course.labels.difficulty")}
value={
state.difficulty_level
? t(`wizard.course.difficulty.${state.difficulty_level}`)
: "—"
}
/>
<ReviewRow
label={t("wizard.course.labels.cefrLevel")}
value={
CEFR_LEVELS.find((l) => l.value === state.cefr_level)?.label || "—"
}
/>
<ReviewRow
label={t("wizard.course.labels.capacity")}
value={String(state.max_capacity)}
/>
</div>
),
},
];
return (
<StepWizard<CourseWizardState>
titleKey="wizard.course.title"
subtitleKey="wizard.course.subtitle"
backTo="/admin/smart-wizard"
steps={steps}
initialState={INITIAL}
submitting={createMut.isPending}
finishLabelKey="wizard.course.finish"
onFinish={async (state) => {
const payload: Partial<CourseCreateRequest> = {
title: state.title.trim(),
code: state.code.trim() || deriveCode(state.title),
description: state.description,
max_capacity: state.max_capacity,
difficulty_level: state.difficulty_level || undefined,
cefr_level: state.cefr_level || undefined,
};
await createMut.mutateAsync(payload as CourseCreateRequest);
}}
/>
);
}
function ReviewRow({ label, value, wrap }: { label: string; value: string; wrap?: boolean }) {
return (
<div className="grid grid-cols-[140px_1fr] gap-2">
<div className="text-muted-foreground">{label}</div>
<div className={wrap ? "whitespace-pre-wrap" : ""}>{value || "—"}</div>
</div>
);
}

View File

@@ -0,0 +1,348 @@
import { useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Plus, Trash2 } from "lucide-react";
import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { examsService } from "@/services/exams.service";
import { describeApiError } from "@/lib/api-client";
import type { ExamModule, ExamStructure } from "@/types";
/**
* Exam Structure creation wizard (4 steps).
*
* 1. Basics — name, industry, exam type (academic/general)
* 2. Modules — which modules this structure covers (multi-select)
* 3. Writing tasks — min-words per task (only if writing module selected)
* 4. Review — summary → Finish posts to /api/exam-structures
*
* The backend expects `modules` as a JSON-serialisable array and `config`
* as an arbitrary JSON object. We only populate writing-task config from
* the wizard; listening/reading/speaking can be refined later on the
* full structure edit page.
*/
interface WritingTaskDraft {
type: string;
min_words: number;
label: string;
}
interface StructureWizardState {
name: string;
industry: string;
exam_type: "academic" | "general";
modules: ExamModule[];
writing_tasks: WritingTaskDraft[];
}
const INITIAL: StructureWizardState = {
name: "",
industry: "",
exam_type: "academic",
modules: ["writing"],
writing_tasks: [
{ type: "task_1", label: "Task 1", min_words: 150 },
{ type: "task_2", label: "Task 2", min_words: 250 },
],
};
const ALL_MODULES: ExamModule[] = ["listening", "reading", "writing", "speaking"];
export default function ExamStructureWizard() {
const { t } = useTranslation();
const navigate = useNavigate();
const qc = useQueryClient();
const createMut = useMutation({
mutationFn: (data: Partial<ExamStructure>) =>
examsService.createStructure(data as Partial<ExamStructure>),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["exam-structures"] });
toast.success(t("wizard.structure.toastSuccess"));
navigate("/admin/exam-structures");
},
onError: (err: unknown) => {
toast.error(describeApiError(err, t("wizard.structure.toastError")));
},
});
const steps: WizardStepDef<StructureWizardState>[] = [
{
id: "basics",
titleKey: "wizard.structure.step1.title",
descriptionKey: "wizard.structure.step1.description",
validate: (s) => {
if (!s.name.trim()) return t("wizard.structure.errors.nameRequired");
return null;
},
render: ({ state, update }) => (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="str-name">{t("wizard.structure.labels.name")}</Label>
<Input
id="str-name"
value={state.name}
onChange={(e) => update({ name: e.target.value })}
placeholder={t("wizard.structure.placeholders.name")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="str-industry">{t("wizard.structure.labels.industry")}</Label>
<Input
id="str-industry"
value={state.industry}
onChange={(e) => update({ industry: e.target.value })}
placeholder={t("wizard.structure.placeholders.industry")}
/>
<p className="text-xs text-muted-foreground">{t("wizard.structure.industryHint")}</p>
</div>
<div className="space-y-1.5">
<Label>{t("wizard.structure.labels.examType")}</Label>
<Select
value={state.exam_type}
onValueChange={(v) => update({ exam_type: v as StructureWizardState["exam_type"] })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="academic">{t("wizard.structure.examTypes.academic")}</SelectItem>
<SelectItem value="general">{t("wizard.structure.examTypes.general")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
),
},
{
id: "modules",
titleKey: "wizard.structure.step2.title",
descriptionKey: "wizard.structure.step2.description",
validate: (s) => {
if (s.modules.length === 0) return t("wizard.structure.errors.moduleRequired");
return null;
},
render: ({ state, update }) => (
<div className="space-y-2">
{ALL_MODULES.map((m) => {
const checked = state.modules.includes(m);
return (
<label
key={m}
className="flex items-start gap-3 rounded-md border p-3 cursor-pointer hover:bg-muted/40"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
const next = v
? [...state.modules, m]
: state.modules.filter((x) => x !== m);
update({ modules: next });
}}
/>
<div className="flex-1">
<div className="font-medium">{t(`wizard.structure.modules.${m}.title`)}</div>
<div className="text-xs text-muted-foreground">
{t(`wizard.structure.modules.${m}.description`)}
</div>
</div>
</label>
);
})}
</div>
),
},
{
id: "writing-tasks",
titleKey: "wizard.structure.step3.title",
descriptionKey: "wizard.structure.step3.description",
validate: (s) => {
if (!s.modules.includes("writing")) return null;
if (s.writing_tasks.length === 0) {
return t("wizard.structure.errors.writingTaskRequired");
}
for (const task of s.writing_tasks) {
if (!task.label.trim()) return t("wizard.structure.errors.writingTaskLabel");
if (task.min_words < 1) return t("wizard.structure.errors.writingTaskWords");
}
return null;
},
render: ({ state, update }) => {
if (!state.modules.includes("writing")) {
return (
<p className="text-sm text-muted-foreground">
{t("wizard.structure.writingSkipped")}
</p>
);
}
return (
<div className="space-y-3">
{state.writing_tasks.map((task, i) => (
<div key={i} className="flex items-end gap-2 rounded-md border p-3">
<div className="flex-1 space-y-1.5">
<Label htmlFor={`task-label-${i}`}>
{t("wizard.structure.labels.taskLabel")}
</Label>
<Input
id={`task-label-${i}`}
value={task.label}
onChange={(e) => {
const next = [...state.writing_tasks];
next[i] = { ...task, label: e.target.value };
update({ writing_tasks: next });
}}
/>
</div>
<div className="w-28 space-y-1.5">
<Label htmlFor={`task-words-${i}`}>
{t("wizard.structure.labels.minWords")}
</Label>
<Input
id={`task-words-${i}`}
type="number"
min={1}
value={task.min_words}
onChange={(e) => {
const next = [...state.writing_tasks];
next[i] = { ...task, min_words: Number(e.target.value) || 0 };
update({ writing_tasks: next });
}}
/>
</div>
<Button
variant="ghost"
size="icon"
aria-label={t("wizard.structure.removeTask")}
onClick={() => {
update({
writing_tasks: state.writing_tasks.filter((_, idx) => idx !== i),
});
}}
disabled={state.writing_tasks.length === 1}
className="text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() =>
update({
writing_tasks: [
...state.writing_tasks,
{
type: `task_${state.writing_tasks.length + 1}`,
label: `Task ${state.writing_tasks.length + 1}`,
min_words: 250,
},
],
})
}
>
<Plus className="mr-1 h-4 w-4" />
{t("wizard.structure.addTask")}
</Button>
</div>
);
},
},
{
id: "review",
titleKey: "wizard.structure.step4.title",
descriptionKey: "wizard.structure.step4.description",
render: ({ state }) => (
<div className="space-y-4 text-sm">
<ReviewRow label={t("wizard.structure.labels.name")} value={state.name} />
<ReviewRow
label={t("wizard.structure.labels.industry")}
value={state.industry || "—"}
/>
<ReviewRow
label={t("wizard.structure.labels.examType")}
value={t(`wizard.structure.examTypes.${state.exam_type}`)}
/>
<ReviewRow
label={t("wizard.structure.labels.modules")}
value={state.modules
.map((m) => t(`wizard.structure.modules.${m}.title`))
.join(", ")}
/>
{state.modules.includes("writing") && (
<div>
<div className="font-medium mb-1">{t("wizard.structure.step3.title")}</div>
<ul className="rounded-md border divide-y">
{state.writing_tasks.map((task, i) => (
<li key={i} className="flex items-center justify-between p-2">
<span>{task.label}</span>
<span className="text-muted-foreground tabular-nums">
{task.min_words} {t("wizard.structure.wordsSuffix")}
</span>
</li>
))}
</ul>
</div>
)}
</div>
),
},
];
return (
<StepWizard<StructureWizardState>
titleKey="wizard.structure.title"
subtitleKey="wizard.structure.subtitle"
backTo="/admin/smart-wizard"
steps={steps}
initialState={INITIAL}
submitting={createMut.isPending}
finishLabelKey="wizard.structure.finish"
onFinish={async (state) => {
const config: Record<string, unknown> = {
exam_type: state.exam_type,
};
if (state.modules.includes("writing")) {
config.writing = {
tasks: state.writing_tasks.map((t) => ({
type: t.type,
min_words: t.min_words,
label: t.label,
})),
};
}
await createMut.mutateAsync({
name: state.name.trim(),
industry: state.industry.trim(),
modules: state.modules,
config,
} as unknown as Partial<ExamStructure>);
}}
/>
);
}
function ReviewRow({ label, value, wrap }: { label: string; value: string; wrap?: boolean }) {
return (
<div className="grid grid-cols-[140px_1fr] gap-2">
<div className="text-muted-foreground">{label}</div>
<div className={wrap ? "whitespace-pre-wrap" : ""}>{value || "—"}</div>
</div>
);
}

View File

@@ -0,0 +1,320 @@
import { useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Plus, Trash2 } from "lucide-react";
import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { examsService } from "@/services/exams.service";
import { describeApiError } from "@/lib/api-client";
import type { ExamModule } from "@/types";
/**
* Rubric creation wizard (4 steps).
*
* 1. Basics — name, skill (module), description
* 2. Criteria — rows of name + max score
* 3. Descriptors — per-criterion optional descriptors
* 4. Review — summary then Finish calls examsService.createRubric
*
* Only Writing / Speaking make sense as rubric targets because the other
* two modules (Listening / Reading) are auto-graded; QA flagged this as an
* explicit UX requirement earlier in the project.
*/
interface CriterionDraft {
name: string;
max_score: number;
description: string;
}
interface RubricWizardState {
name: string;
module: ExamModule;
description: string;
criteria: CriterionDraft[];
}
const INITIAL: RubricWizardState = {
name: "",
module: "writing",
description: "",
criteria: [
{ name: "Task Response", max_score: 9, description: "" },
{ name: "Coherence & Cohesion", max_score: 9, description: "" },
],
};
export default function RubricWizard() {
const { t } = useTranslation();
const navigate = useNavigate();
const qc = useQueryClient();
const createMut = useMutation({
mutationFn: (data: Record<string, unknown>) =>
examsService.createRubric(data as never),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["rubrics"] });
toast.success(t("wizard.rubric.toastSuccess"));
navigate("/admin/rubrics");
},
onError: (err: unknown) => {
toast.error(describeApiError(err, t("wizard.rubric.toastError")));
},
});
const steps: WizardStepDef<RubricWizardState>[] = [
{
id: "basics",
titleKey: "wizard.rubric.step1.title",
descriptionKey: "wizard.rubric.step1.description",
validate: (s) => {
if (!s.name.trim()) return t("wizard.rubric.errors.nameRequired");
if (s.module !== "writing" && s.module !== "speaking") {
return t("wizard.rubric.errors.moduleRestricted");
}
return null;
},
render: ({ state, update }) => (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="rubric-name">{t("wizard.rubric.labels.name")}</Label>
<Input
id="rubric-name"
value={state.name}
onChange={(e) => update({ name: e.target.value })}
placeholder={t("wizard.rubric.placeholders.name")}
/>
</div>
<div className="space-y-1.5">
<Label>{t("wizard.rubric.labels.module")}</Label>
<Select value={state.module} onValueChange={(v) => update({ module: v as ExamModule })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="writing">{t("wizard.rubric.modules.writing")}</SelectItem>
<SelectItem value="speaking">{t("wizard.rubric.modules.speaking")}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("wizard.rubric.moduleHint")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="rubric-desc">{t("wizard.rubric.labels.description")}</Label>
<Textarea
id="rubric-desc"
value={state.description}
onChange={(e) => update({ description: e.target.value })}
placeholder={t("wizard.rubric.placeholders.description")}
rows={3}
/>
</div>
</div>
),
},
{
id: "criteria",
titleKey: "wizard.rubric.step2.title",
descriptionKey: "wizard.rubric.step2.description",
validate: (s) => {
if (s.criteria.length === 0) return t("wizard.rubric.errors.criterionRequired");
for (const c of s.criteria) {
if (!c.name.trim()) return t("wizard.rubric.errors.criterionName");
if (c.max_score < 1 || c.max_score > 100) {
return t("wizard.rubric.errors.criterionScore");
}
}
return null;
},
render: ({ state, update }) => (
<div className="space-y-3">
<div className="space-y-2">
{state.criteria.map((c, i) => (
<div key={i} className="flex items-end gap-2 rounded-md border p-3">
<div className="flex-1 space-y-1.5">
<Label htmlFor={`crit-name-${i}`}>{t("wizard.rubric.labels.criterionName")}</Label>
<Input
id={`crit-name-${i}`}
value={c.name}
onChange={(e) => {
const next = [...state.criteria];
next[i] = { ...c, name: e.target.value };
update({ criteria: next });
}}
placeholder={t("wizard.rubric.placeholders.criterionName")}
/>
</div>
<div className="w-24 space-y-1.5">
<Label htmlFor={`crit-score-${i}`}>{t("wizard.rubric.labels.maxScore")}</Label>
<Input
id={`crit-score-${i}`}
type="number"
min={1}
max={100}
value={c.max_score}
onChange={(e) => {
const next = [...state.criteria];
next[i] = { ...c, max_score: Number(e.target.value) || 0 };
update({ criteria: next });
}}
/>
</div>
<Button
variant="ghost"
size="icon"
aria-label={t("wizard.rubric.removeCriterion")}
onClick={() => {
const next = state.criteria.filter((_, idx) => idx !== i);
update({ criteria: next });
}}
disabled={state.criteria.length === 1}
className="text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
update({
criteria: [
...state.criteria,
{ name: "", max_score: 9, description: "" },
],
})
}
>
<Plus className="mr-1 h-4 w-4" />
{t("wizard.rubric.addCriterion")}
</Button>
</div>
),
},
{
id: "descriptors",
titleKey: "wizard.rubric.step3.title",
descriptionKey: "wizard.rubric.step3.description",
// Descriptors are optional — no validation.
render: ({ state, update }) => (
<div className="space-y-3">
{state.criteria.map((c, i) => (
<div key={i} className="space-y-1.5">
<Label htmlFor={`crit-desc-${i}`}>
{c.name || t("wizard.rubric.unnamedCriterion")}
</Label>
<Textarea
id={`crit-desc-${i}`}
value={c.description}
onChange={(e) => {
const next = [...state.criteria];
next[i] = { ...c, description: e.target.value };
update({ criteria: next });
}}
placeholder={t("wizard.rubric.placeholders.descriptor")}
rows={2}
/>
</div>
))}
<p className="text-xs text-muted-foreground">
{t("wizard.rubric.descriptorHint")}
</p>
</div>
),
},
{
id: "review",
titleKey: "wizard.rubric.step4.title",
descriptionKey: "wizard.rubric.step4.description",
render: ({ state }) => (
<div className="space-y-4 text-sm">
<ReviewRow label={t("wizard.rubric.labels.name")} value={state.name} />
<ReviewRow
label={t("wizard.rubric.labels.module")}
value={t(`wizard.rubric.modules.${state.module}`)}
/>
{state.description && (
<ReviewRow
label={t("wizard.rubric.labels.description")}
value={state.description}
wrap
/>
)}
<div>
<div className="font-medium mb-1">
{t("wizard.rubric.step2.title")} ({state.criteria.length})
</div>
<ul className="rounded-md border divide-y">
{state.criteria.map((c, i) => (
<li key={i} className="flex items-start justify-between gap-3 p-2">
<div className="flex-1 min-w-0">
<div className="font-medium">{c.name}</div>
{c.description && (
<div className="text-muted-foreground text-xs mt-0.5">
{c.description}
</div>
)}
</div>
<div className="shrink-0 tabular-nums text-muted-foreground">
{t("wizard.rubric.maxLabel")}: {c.max_score}
</div>
</li>
))}
</ul>
</div>
</div>
),
},
];
return (
<StepWizard<RubricWizardState>
titleKey="wizard.rubric.title"
subtitleKey="wizard.rubric.subtitle"
backTo="/admin/smart-wizard"
steps={steps}
initialState={INITIAL}
submitting={createMut.isPending}
finishLabelKey="wizard.rubric.finish"
onFinish={async (state) => {
await createMut.mutateAsync({
name: state.name.trim(),
module: state.module,
description: state.description.trim(),
criteria: state.criteria.map((c) => ({
name: c.name.trim(),
description: c.description.trim(),
max_score: c.max_score,
})),
});
}}
/>
);
}
function ReviewRow({ label, value, wrap }: { label: string; value: string; wrap?: boolean }) {
return (
<div className="grid grid-cols-[120px_1fr] gap-2">
<div className="text-muted-foreground">{label}</div>
<div className={wrap ? "whitespace-pre-wrap" : ""}>{value || "—"}</div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
import {
BookOpen,
Library,
ClipboardList,
Users,
MessageSquare,
Megaphone,
CalendarCheck,
} from "lucide-react";
import { QuickSetupWizard, type WizardStep, type QuickCreate } from "@/components/QuickSetupWizard";
import { lmsService } from "@/services/lms.service";
import { assignmentsService } from "@/services/assignments.service";
/**
* Teacher smart-setup wizard.
*
* Walks a teacher through the "launch a new course" happy path:
* create course → add chapters → upload resources → assign & track.
* The same cards are available later as ad-hoc "quick creates".
*/
const steps: WizardStep[] = [
{
id: "course",
titleKey: "quickSetup.teacher.step1.title",
descriptionKey: "quickSetup.teacher.step1.description",
helpKey: "quickSetup.teacher.step1.help",
to: "/teacher/courses/new",
icon: BookOpen,
check: async () => {
const res = await lmsService.listCourses({ page: 1, size: 1 });
return (res.items?.length ?? 0) > 0;
},
},
{
id: "chapters",
titleKey: "quickSetup.teacher.step2.title",
descriptionKey: "quickSetup.teacher.step2.description",
helpKey: "quickSetup.teacher.step2.help",
to: "/teacher/courses",
icon: Library,
// Chapter counts live under /courses/:id/chapters — too expensive to
// sample for existence across all courses, so leave uncheckable.
},
{
id: "resources",
titleKey: "quickSetup.teacher.step3.title",
descriptionKey: "quickSetup.teacher.step3.description",
helpKey: "quickSetup.teacher.step3.help",
to: "/teacher/library",
icon: Library,
},
{
id: "assignments",
titleKey: "quickSetup.teacher.step4.title",
descriptionKey: "quickSetup.teacher.step4.description",
helpKey: "quickSetup.teacher.step4.help",
to: "/teacher/assignments",
icon: ClipboardList,
check: async () => {
const res = await assignmentsService.listSchedules({ page: 1, size: 1 });
return (res.items?.length ?? 0) > 0;
},
},
{
id: "track",
titleKey: "quickSetup.teacher.step5.title",
descriptionKey: "quickSetup.teacher.step5.description",
helpKey: "quickSetup.teacher.step5.help",
to: "/teacher/students",
icon: Users,
},
];
const quickCreates: QuickCreate[] = [
{
id: "discussion",
titleKey: "quickSetup.teacher.quick.discussion.title",
descriptionKey: "quickSetup.teacher.quick.discussion.description",
to: "/teacher/discussions",
icon: MessageSquare,
},
{
id: "announcement",
titleKey: "quickSetup.teacher.quick.announcement.title",
descriptionKey: "quickSetup.teacher.quick.announcement.description",
to: "/teacher/announcements",
icon: Megaphone,
},
{
id: "attendance",
titleKey: "quickSetup.teacher.quick.attendance.title",
descriptionKey: "quickSetup.teacher.quick.attendance.description",
to: "/teacher/attendance",
icon: CalendarCheck,
},
];
export default function TeacherQuickSetup() {
return (
<QuickSetupWizard
titleKey="quickSetup.teacherTitle"
subtitleKey="quickSetup.teacherSubtitle"
steps={steps}
quickCreates={quickCreates}
/>
);
}

View File

@@ -0,0 +1,53 @@
import { api } from "@/lib/api-client";
import type {
AIAgent,
AIAgentSummary,
AIAgentTestRequest,
AIAgentTestResponse,
AIAgentUpdateInput,
AIToolSummary,
} from "@/types/aiAgent";
/**
* REST helpers for the AI Agent configurator.
*
* Backend: `backend/custom_addons/encoach_ai/controllers/agents_controller.py`.
* Mount point: `/api/ai/agents`.
*/
export const aiAgentService = {
async list(params?: { search?: string }): Promise<AIAgentSummary[]> {
const res = await api.get<{ items?: AIAgentSummary[]; data?: AIAgentSummary[] }>(
"/ai/agents",
params,
);
return res.items ?? res.data ?? [];
},
async get(id: number): Promise<AIAgent> {
return api.get<AIAgent>(`/ai/agents/${id}`);
},
async update(id: number, input: AIAgentUpdateInput): Promise<AIAgent> {
return api.patch<AIAgent>(`/ai/agents/${id}`, input);
},
async test(id: number, body: AIAgentTestRequest): Promise<AIAgentTestResponse> {
return api.post<AIAgentTestResponse>(`/ai/agents/${id}/test`, body);
},
async listTools(): Promise<AIToolSummary[]> {
const res = await api.get<{ items?: AIToolSummary[]; data?: AIToolSummary[] }>(
"/ai/agents/tools",
);
return res.items ?? res.data ?? [];
},
async updateTool(
id: number,
input: Partial<Pick<AIToolSummary, "active" | "name" | "description">> & {
schema?: Record<string, unknown>;
},
): Promise<AIToolSummary> {
return api.patch<AIToolSummary>(`/ai/agents/tools/${id}`, input);
},
};

View File

@@ -0,0 +1,48 @@
import { api } from "@/lib/api-client";
import type {
CoursePlan,
CoursePlanGenerateBrief,
CoursePlanMaterial,
} from "@/types";
/**
* REST helpers for the AI course plan generator.
*
* The backend routes are mounted under `/api/ai/course-plan`; see
* `backend/custom_addons/encoach_ai_course/controllers/course_plan.py`.
*/
export const coursePlanService = {
async list(params?: {
page?: number;
size?: number;
search?: string;
}): Promise<{ items: CoursePlan[]; page: { page: number; size: number; total: number } }> {
return api.get("/ai/course-plan", params);
},
async get(planId: number): Promise<{ data: CoursePlan }> {
return api.get(`/ai/course-plan/${planId}`);
},
async generate(brief: CoursePlanGenerateBrief): Promise<{ data: CoursePlan }> {
return api.post("/ai/course-plan", brief);
},
async remove(planId: number): Promise<{ success: boolean }> {
return api.delete(`/ai/course-plan/${planId}`);
},
async generateWeekMaterials(
planId: number,
weekNumber: number,
): Promise<{ items: CoursePlanMaterial[]; count: number }> {
return api.post(`/ai/course-plan/${planId}/weeks/${weekNumber}/materials`);
},
async listWeekMaterials(
planId: number,
weekNumber: number,
): Promise<{ items: CoursePlanMaterial[]; count: number }> {
return api.get(`/ai/course-plan/${planId}/weeks/${weekNumber}/materials`);
},
};

91
src/types/aiAgent.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* Types for the LangGraph-backed AI Agent layer.
*
* The backend models are `encoach.ai.agent` and `encoach.ai.tool`; the
* controller is `backend/custom_addons/encoach_ai/controllers/agents_controller.py`.
*/
export type AgentGraphType = "simple" | "plan_review_revise" | "rag" | "react";
export type AgentResponseFormat = "text" | "json";
export type AIToolCategory =
| "retrieval"
| "persistence"
| "quality"
| "scoring"
| "reference"
| "other";
export interface AIToolSummary {
id: number;
key: string;
name: string;
description: string;
category: AIToolCategory;
schema: Record<string, unknown>;
mutates: boolean;
active: boolean;
}
export interface AIAgentSummary {
id: number;
key: string;
name: string;
description: string;
model: string;
fallback_model: string;
temperature: number;
max_tokens: number;
response_format: AgentResponseFormat;
graph_type: AgentGraphType;
max_revisions: number;
quality_checks: string[];
prompt_key: string;
tool_count: number;
tool_keys: string[];
active: boolean;
}
export interface AIAgent extends AIAgentSummary {
system_prompt: string;
tools: AIToolSummary[];
}
export interface AIAgentUpdateInput {
name?: string;
description?: string;
system_prompt?: string;
prompt_key?: string;
model?: string;
fallback_model?: string;
temperature?: number;
max_tokens?: number;
max_revisions?: number;
response_format?: AgentResponseFormat;
graph_type?: AgentGraphType;
quality_checks?: string;
active?: boolean;
tool_keys?: string[];
}
export interface AIAgentTestRequest {
variables?: Record<string, unknown>;
payload?: unknown;
language?: string;
}
export interface AIAgentTestResponse {
error: string;
output: unknown;
output_raw: string;
tool_results: Array<{
tool: string;
args: unknown;
result: unknown;
}>;
retrieval_hits: number;
revisions_used: number;
quality_issues: string[];
iterations: number;
}

120
src/types/coursePlan.ts Normal file
View File

@@ -0,0 +1,120 @@
/**
* AI-generated course plan — types mirror the backend controller's
* response shape in `encoach_ai_course.controllers.course_plan`.
*/
export type CoursePlanSkill =
| "reading"
| "writing"
| "listening"
| "speaking"
| "grammar"
| "vocabulary"
| "integrated";
export type CoursePlanMaterialType =
| "reading_text"
| "listening_script"
| "speaking_prompt"
| "writing_prompt"
| "grammar_lesson"
| "vocabulary_list"
| "practice"
| "other";
export interface CoursePlanOutcome {
code: string;
description: string;
}
export interface CoursePlanGrammarItem {
code: string;
label: string;
sub_items?: string[];
}
export interface CoursePlanAssessmentComponent {
name: string;
weight: number;
}
export interface CoursePlanAssessment {
continuous_assessment?: {
total_weight?: number;
components?: CoursePlanAssessmentComponent[];
};
final_exam?: {
total_weight?: number;
};
}
export interface CoursePlanResource {
type: string;
citation: string;
}
export interface CoursePlanWeekItem {
skill: CoursePlanSkill | string;
outcome_codes: string[];
remarks?: string;
}
export interface CoursePlanWeek {
id: number;
week_number: number;
date_label: string;
unit: string;
focus: string;
items: CoursePlanWeekItem[];
material_count: number;
}
export interface CoursePlanMaterial {
id: number;
plan_id: number;
week_id: number | null;
week_number: number;
skill: string;
material_type: CoursePlanMaterialType | string;
title: string;
summary: string;
/** Loose shape: depends on material_type. */
body: Record<string, unknown>;
body_text: string;
}
export interface CoursePlan {
id: number;
name: string;
course_id: number | null;
course_name: string;
cefr_level: string;
total_weeks: number;
contact_hours_per_week: number;
skills_division: string;
description: string;
status: "draft" | "generated" | "approved" | "archived";
objectives: string[];
outcomes: Partial<Record<CoursePlanSkill, CoursePlanOutcome[]>>;
grammar: CoursePlanGrammarItem[];
assessment: CoursePlanAssessment;
resources: CoursePlanResource[];
week_count: number;
material_count: number;
created_at: string | null;
weeks?: CoursePlanWeek[];
materials?: CoursePlanMaterial[];
}
export interface CoursePlanGenerateBrief {
title: string;
cefr_level?: string;
total_weeks?: number;
contact_hours_per_week?: number;
skills_division?: string;
grammar_focus?: string[];
resources?: string[];
learner_profile?: string;
notes?: string;
course_id?: number;
}

View File

@@ -36,6 +36,7 @@ export * from "./exam-session";
export * from "./grading"; export * from "./grading";
export * from "./course-generation"; export * from "./course-generation";
export * from "./ai-course"; export * from "./ai-course";
export * from "./coursePlan";
export * from "./entity-onboarding"; export * from "./entity-onboarding";
export * from "./level-mapping"; export * from "./level-mapping";
export * from "./branding"; export * from "./branding";