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:
@@ -71,6 +71,7 @@ const TopicLearning = lazy(() => import("@/pages/student/TopicLearning"));
|
||||
|
||||
// Teacher pages
|
||||
const TeacherDashboard = lazy(() => import("@/pages/teacher/TeacherDashboard"));
|
||||
const TeacherQuickSetup = lazy(() => import("@/pages/teacher/TeacherQuickSetup"));
|
||||
const TeacherCourses = lazy(() => import("@/pages/teacher/TeacherCourses"));
|
||||
const CourseBuilder = lazy(() => import("@/pages/teacher/CourseBuilder"));
|
||||
const TeacherAssignments = lazy(() => import("@/pages/teacher/TeacherAssignments"));
|
||||
@@ -84,6 +85,14 @@ const AdaptiveSettings = lazy(() => import("@/pages/teacher/AdaptiveSettings"));
|
||||
|
||||
// Admin LMS pages
|
||||
const AdminLmsDashboard = lazy(() => import("@/pages/admin/AdminLmsDashboard"));
|
||||
const AdminQuickSetup = lazy(() => import("@/pages/admin/AdminQuickSetup"));
|
||||
const SmartWizardHub = lazy(() => import("@/pages/admin/SmartWizardHub"));
|
||||
const RubricWizard = lazy(() => import("@/pages/admin/wizards/RubricWizard"));
|
||||
const ExamStructureWizard = lazy(() => import("@/pages/admin/wizards/ExamStructureWizard"));
|
||||
const CourseWizard = lazy(() => import("@/pages/admin/wizards/CourseWizard"));
|
||||
const CoursePlanWizard = lazy(() => import("@/pages/admin/wizards/CoursePlanWizard"));
|
||||
const AdminCoursePlans = lazy(() => import("@/pages/admin/AdminCoursePlans"));
|
||||
const AdminCoursePlanDetail = lazy(() => import("@/pages/admin/AdminCoursePlanDetail"));
|
||||
const AdminCourses = lazy(() => import("@/pages/admin/AdminCourses"));
|
||||
const AdminStudents = lazy(() => import("@/pages/admin/AdminStudents"));
|
||||
const AdminTeachers = lazy(() => import("@/pages/admin/AdminTeachers"));
|
||||
@@ -201,7 +210,12 @@ const App = () => (
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
<BrowserRouter>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
<AuthProvider>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Routes>
|
||||
@@ -265,6 +279,7 @@ const App = () => (
|
||||
<Route element={<ProtectedRoute allowedRoles={["teacher", "admin", "developer"]} />}>
|
||||
<Route element={<TeacherLayout />}>
|
||||
<Route path="/teacher/dashboard" element={<TeacherDashboard />} />
|
||||
<Route path="/teacher/quick-setup" element={<TeacherQuickSetup />} />
|
||||
<Route path="/teacher/courses" element={<TeacherCourses />} />
|
||||
<Route path="/teacher/library" element={<TeacherLibrary />} />
|
||||
<Route path="/teacher/courses/new" element={<CourseBuilder />} />
|
||||
@@ -291,6 +306,14 @@ const App = () => (
|
||||
<Route element={<AdminLmsLayout />}>
|
||||
{/* LMS Dashboard */}
|
||||
<Route path="/admin/dashboard" element={<AdminLmsDashboard />} />
|
||||
<Route path="/admin/quick-setup" element={<AdminQuickSetup />} />
|
||||
<Route path="/admin/smart-wizard" element={<SmartWizardHub />} />
|
||||
<Route path="/admin/smart-wizard/rubric" element={<RubricWizard />} />
|
||||
<Route path="/admin/smart-wizard/exam-structure" element={<ExamStructureWizard />} />
|
||||
<Route path="/admin/smart-wizard/course" element={<CourseWizard />} />
|
||||
<Route path="/admin/smart-wizard/course-plan" element={<CoursePlanWizard />} />
|
||||
<Route path="/admin/course-plans" element={<AdminCoursePlans />} />
|
||||
<Route path="/admin/course-plans/:planId" element={<AdminCoursePlanDetail />} />
|
||||
{/* Original platform dashboard */}
|
||||
<Route path="/admin/platform" element={<AdminDashboard />} />
|
||||
{/* LMS pages */}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Outlet, Link, useNavigate, useLocation } from "react-router-dom";
|
||||
import { Suspense } from "react";
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import {
|
||||
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
||||
@@ -31,7 +32,7 @@ import {
|
||||
CalendarDays, Landmark, UserPlus, ScrollText, Award,
|
||||
HelpCircle as FaqIcon, Bell, Workflow,
|
||||
CalendarOff, DollarSign, BookMarked, BarChartHorizontal, TrendingUp,
|
||||
Library, Activity, Warehouse, UserCog, Sparkles,
|
||||
Library, Activity, Warehouse, UserCog, Sparkles, Compass,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -43,6 +44,7 @@ import { useTranslation } from "react-i18next";
|
||||
interface NavItem { titleKey: string; url: string; icon: LucideIcon }
|
||||
|
||||
const overviewItems: NavItem[] = [
|
||||
{ titleKey: "nav.smartWizard", url: "/admin/smart-wizard", icon: Sparkles },
|
||||
{ titleKey: "nav.adminDashboard", url: "/admin/dashboard", icon: LayoutDashboard },
|
||||
{ titleKey: "nav.platformDashboard", url: "/admin/platform", icon: BarChart3 },
|
||||
];
|
||||
@@ -65,6 +67,7 @@ const academicItems: NavItem[] = [
|
||||
{ titleKey: "nav.reviewQueue", url: "/admin/exam/review-queue", icon: Sparkles },
|
||||
{ titleKey: "nav.aiPrompts", url: "/admin/ai/prompts", icon: Wand2 },
|
||||
{ titleKey: "nav.aiFeedback", url: "/admin/ai/feedback", icon: Sparkles },
|
||||
{ titleKey: "nav.coursePlans", url: "/admin/course-plans", icon: Compass },
|
||||
{ titleKey: "nav.approvalWorkflows", url: "/admin/approval-workflows", icon: GitBranch },
|
||||
];
|
||||
|
||||
@@ -219,6 +222,20 @@ function AppBreadcrumbs() {
|
||||
);
|
||||
}
|
||||
|
||||
// ============= Route content fallback =============
|
||||
// Shown only inside the main content area while a lazy-loaded route chunk
|
||||
// is fetching. Keeping the fallback local means the sidebar and header
|
||||
// stay mounted during navigation — previously the page felt like a full
|
||||
// browser reload because the outer App-level Suspense replaced everything
|
||||
// with a full-viewport spinner.
|
||||
function RouteContentFallback() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============= Main Layout =============
|
||||
export default function AdminLmsLayout() {
|
||||
const { user, logout } = useAuth();
|
||||
@@ -279,7 +296,9 @@ export default function AdminLmsLayout() {
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Outlet, useLocation, Link, useNavigate } from "react-router-dom";
|
||||
import { Suspense } from "react";
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { AppSidebar } from "@/components/AppSidebar";
|
||||
import {
|
||||
@@ -77,6 +78,16 @@ function AppBreadcrumbs() {
|
||||
);
|
||||
}
|
||||
|
||||
// Local Suspense fallback so the sidebar/header keep rendering while a
|
||||
// lazy route chunk loads. See AdminLmsLayout for the full rationale.
|
||||
function RouteContentFallback() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppLayout() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -127,7 +138,9 @@ export default function AppLayout() {
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NavLink as RouterNavLink, NavLinkProps } from "react-router-dom";
|
||||
import { NavLink as RouterNavLink, NavLinkProps, useNavigate } from "react-router-dom";
|
||||
import { forwardRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -9,7 +9,14 @@ interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
|
||||
}
|
||||
|
||||
const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
|
||||
({ className, activeClassName, pendingClassName, to, ...props }, ref) => {
|
||||
({ className, activeClassName, pendingClassName, to, onClick, ...props }, ref) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isExternalTo = (value: NavLinkProps["to"]): boolean => {
|
||||
if (typeof value !== "string") return false;
|
||||
return /^(https?:)?\/\//.test(value) || value.startsWith("mailto:") || value.startsWith("tel:");
|
||||
};
|
||||
|
||||
return (
|
||||
<RouterNavLink
|
||||
ref={ref}
|
||||
@@ -17,6 +24,31 @@ const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
|
||||
className={({ isActive, isPending }) =>
|
||||
cn(className, isActive && activeClassName, isPending && pendingClassName)
|
||||
}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.altKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
props.target === "_blank" ||
|
||||
isExternalTo(to)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Force client-side route transitions for app menu links.
|
||||
event.preventDefault();
|
||||
navigate(to, {
|
||||
replace: props.replace,
|
||||
state: props.state,
|
||||
relative: props.relative,
|
||||
preventScrollReset: props.preventScrollReset,
|
||||
viewTransition: props.viewTransition,
|
||||
});
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
250
frontend/src/components/QuickSetupWizard.tsx
Normal file
250
frontend/src/components/QuickSetupWizard.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LucideIcon, Check, ChevronRight, Sparkles } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Smart-setup wizard primitives.
|
||||
*
|
||||
* The wizard is purposefully thin: each step / quick-create card is a deep
|
||||
* link into an existing creation page that already knows how to talk to the
|
||||
* backend. We don't re-implement forms here — the value of this screen is
|
||||
* the guided sequence, the visual "what's next" hint, and the completion
|
||||
* ticks that show which parts of the setup still need attention.
|
||||
*
|
||||
* Completion detection is optional and client-only. A caller can supply a
|
||||
* `check` function that returns a Promise<boolean>; the wizard runs it via
|
||||
* react-query so the UI refreshes automatically when the user returns from
|
||||
* a sub-page without any extra plumbing.
|
||||
*/
|
||||
|
||||
export interface WizardStep {
|
||||
/** Stable key used for react-query cache. */
|
||||
id: string;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
/** Destination page that performs the actual creation. */
|
||||
to: string;
|
||||
icon: LucideIcon;
|
||||
/** Optional completion check. When omitted the step is never auto-ticked. */
|
||||
check?: () => Promise<boolean>;
|
||||
/** Optional i18n key for a longer help tooltip. */
|
||||
helpKey?: string;
|
||||
}
|
||||
|
||||
export interface QuickCreate {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
to: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export interface QuickSetupWizardProps {
|
||||
/** Page heading i18n key, e.g. "quickSetup.adminTitle". */
|
||||
titleKey: string;
|
||||
/** Short lead paragraph i18n key. */
|
||||
subtitleKey: string;
|
||||
/** Ordered "Recommended flow" steps. */
|
||||
steps: WizardStep[];
|
||||
/** Side-grid of one-click creates that don't belong to the main flow. */
|
||||
quickCreates: QuickCreate[];
|
||||
}
|
||||
|
||||
export function QuickSetupWizard({
|
||||
titleKey,
|
||||
subtitleKey,
|
||||
steps,
|
||||
quickCreates,
|
||||
}: QuickSetupWizardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Run all completion checks in parallel. Each step with a `check` gets its
|
||||
// own cached query keyed by the step id. Steps without a `check` just
|
||||
// resolve to `undefined` and render as neutral.
|
||||
const queries = useQueries({
|
||||
queries: steps.map((step) => ({
|
||||
queryKey: ["quick-setup-check", step.id],
|
||||
queryFn: step.check ?? (async () => undefined),
|
||||
enabled: Boolean(step.check),
|
||||
// Re-check when the user comes back from a sub-page — that's the most
|
||||
// common path to "un-greying" a step after they've created a rubric /
|
||||
// structure / exam.
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 30_000,
|
||||
})),
|
||||
});
|
||||
|
||||
const completedCount = queries.filter((q) => q.data === true).length;
|
||||
const totalCheckable = steps.filter((s) => s.check).length;
|
||||
const progress = totalCheckable > 0 ? Math.round((completedCount / totalCheckable) * 100) : 0;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="space-y-6">
|
||||
{/* Heading + progress */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t(titleKey)}</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground max-w-2xl">{t(subtitleKey)}</p>
|
||||
</div>
|
||||
{totalCheckable > 0 && (
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("quickSetup.progressLabel")}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">
|
||||
{completedCount}/{totalCheckable}
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 w-32 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recommended flow */}
|
||||
<section aria-labelledby="quick-setup-flow">
|
||||
<h2
|
||||
id="quick-setup-flow"
|
||||
className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3"
|
||||
>
|
||||
{t("quickSetup.recommendedFlow")}
|
||||
</h2>
|
||||
|
||||
<ol className="space-y-3">
|
||||
{steps.map((step, index) => {
|
||||
const query = queries[index];
|
||||
const Icon = step.icon;
|
||||
const isDone = query?.data === true;
|
||||
|
||||
return (
|
||||
<li key={step.id}>
|
||||
<Card
|
||||
className={cn(
|
||||
"transition-shadow hover:shadow-md",
|
||||
isDone && "border-green-500/40 bg-green-500/5",
|
||||
)}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
{/* Step number / done tick */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full font-semibold",
|
||||
isDone
|
||||
? "bg-green-500 text-white"
|
||||
: "bg-primary/10 text-primary",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{isDone ? <Check className="h-5 w-5" /> : index + 1}
|
||||
</div>
|
||||
|
||||
{/* Title + description */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<h3 className="font-medium">{t(step.titleKey)}</h3>
|
||||
{isDone && (
|
||||
<Badge variant="outline" className="border-green-500/50 text-green-600 text-[10px]">
|
||||
{t("quickSetup.ready")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{t(step.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{step.helpKey && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground"
|
||||
aria-label={t("quickSetup.helpAria")}
|
||||
>
|
||||
?
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-xs">
|
||||
{t(step.helpKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button asChild size="sm" variant={isDone ? "outline" : "default"}>
|
||||
<Link to={step.to} className="flex items-center gap-1">
|
||||
{isDone ? t("quickSetup.review") : t("quickSetup.start")}
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Quick creates */}
|
||||
{quickCreates.length > 0 && (
|
||||
<section aria-labelledby="quick-setup-other" className="pt-2">
|
||||
<h2
|
||||
id="quick-setup-other"
|
||||
className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3"
|
||||
>
|
||||
{t("quickSetup.otherQuickCreates")}
|
||||
</h2>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{quickCreates.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Card key={item.id} className="transition-all hover:shadow-md hover:-translate-y-0.5">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<CardTitle className="text-base">{t(item.titleKey)}</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="line-clamp-2">
|
||||
{t(item.descriptionKey)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild variant="secondary" size="sm" className="w-full">
|
||||
<Link to={item.to} className="flex items-center justify-center gap-1">
|
||||
{t("quickSetup.open")}
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuickSetupWizard;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Outlet, useNavigate } from "react-router-dom";
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import {
|
||||
@@ -77,6 +78,16 @@ function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Keeps the student/teacher sidebar + header mounted while the lazy route
|
||||
// chunk is fetching. See AdminLmsLayout for the rationale.
|
||||
function RouteContentFallback() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@@ -167,7 +178,9 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import RoleLayout, { NavGroup } from "./RoleLayout";
|
||||
import {
|
||||
LayoutDashboard, BookOpen, ClipboardList,
|
||||
CalendarCheck, Users, Calendar, User, MessageSquare,
|
||||
Megaphone, Library,
|
||||
Megaphone, Library, Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
/** Teacher portal shell. See `StudentLayout` for the nav-config rationale. */
|
||||
@@ -10,6 +10,7 @@ const navGroups: NavGroup[] = [
|
||||
{
|
||||
labelKey: "sidebarGroup.teaching",
|
||||
items: [
|
||||
{ titleKey: "nav.quickSetup", url: "/teacher/quick-setup", icon: Sparkles },
|
||||
{ titleKey: "nav.dashboard", url: "/teacher/dashboard", icon: LayoutDashboard },
|
||||
{ titleKey: "nav.courses", url: "/teacher/courses", icon: BookOpen },
|
||||
{ titleKey: "nav.resourceLibrary", url: "/teacher/library", icon: Library },
|
||||
|
||||
@@ -22,8 +22,11 @@ const badgeVariants = cva(
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||
({ className, variant, ...props }, ref) => (
|
||||
<div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
),
|
||||
);
|
||||
Badge.displayName = "Badge";
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
234
frontend/src/components/wizard/StepWizard.tsx
Normal file
234
frontend/src/components/wizard/StepWizard.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { ReactNode, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowLeft, ArrowRight, Check, ChevronLeft } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Generic multi-step wizard shell.
|
||||
*
|
||||
* A wizard is defined as an ordered array of {@link WizardStepDef} items.
|
||||
* Each step owns its own piece of the accumulated state and returns a node
|
||||
* that renders the form. The shell handles:
|
||||
*
|
||||
* - rendering the numbered stepper (with `completed` / `active` styles)
|
||||
* - Back / Next navigation
|
||||
* - a final "Finish" button that calls `onFinish(state)`
|
||||
* - per-step validation via `step.validate(state)` → return an error
|
||||
* message or `null` when valid
|
||||
* - a persistent progress bar
|
||||
*
|
||||
* Keeping this shell free of domain logic means every scenario wizard
|
||||
* (rubric, course, structure, …) is just a tiny file that defines steps
|
||||
* and wires the final submit to the right service.
|
||||
*/
|
||||
|
||||
export interface WizardStepDef<TState> {
|
||||
id: string;
|
||||
/** i18n key for the step title. */
|
||||
titleKey: string;
|
||||
/** Optional i18n key for a short description shown under the title. */
|
||||
descriptionKey?: string;
|
||||
/**
|
||||
* Render the step's form. Call `update(patch)` to merge fields into the
|
||||
* shared state. Do **not** call `onNext` directly — the shell handles
|
||||
* Next/Back; the render function should just update local fields.
|
||||
*/
|
||||
render: (props: StepRenderProps<TState>) => ReactNode;
|
||||
/**
|
||||
* Synchronous validator. Return a human-readable error message that will
|
||||
* be displayed under the form and block Next, or `null` when valid.
|
||||
*/
|
||||
validate?: (state: TState) => string | null;
|
||||
}
|
||||
|
||||
export interface StepRenderProps<TState> {
|
||||
state: TState;
|
||||
update: (patch: Partial<TState>) => void;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface StepWizardProps<TState> {
|
||||
/** i18n key for the wizard heading. */
|
||||
titleKey: string;
|
||||
/** i18n key for the short lead paragraph shown under the heading. */
|
||||
subtitleKey?: string;
|
||||
/** Optional back-link to the hub. */
|
||||
backTo?: string;
|
||||
backLabelKey?: string;
|
||||
steps: WizardStepDef<TState>[];
|
||||
initialState: TState;
|
||||
/** Called once the user clicks Finish on the last step. */
|
||||
onFinish: (state: TState) => Promise<void> | void;
|
||||
/** i18n key for the Finish button label. Defaults to "wizard.finish". */
|
||||
finishLabelKey?: string;
|
||||
/** Disable all form controls (used by consumers while submitting). */
|
||||
submitting?: boolean;
|
||||
}
|
||||
|
||||
export function StepWizard<TState>({
|
||||
titleKey,
|
||||
subtitleKey,
|
||||
backTo,
|
||||
backLabelKey = "wizard.backToHub",
|
||||
steps,
|
||||
initialState,
|
||||
onFinish,
|
||||
finishLabelKey = "wizard.finish",
|
||||
submitting = false,
|
||||
}: StepWizardProps<TState>) {
|
||||
const { t } = useTranslation();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [state, setState] = useState<TState>(initialState);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submittingInternal, setSubmittingInternal] = useState(false);
|
||||
|
||||
const busy = submitting || submittingInternal;
|
||||
const current = steps[index];
|
||||
const isLast = index === steps.length - 1;
|
||||
const progress = Math.round(((index + 1) / steps.length) * 100);
|
||||
|
||||
const update = (patch: Partial<TState>) => {
|
||||
setState((prev) => ({ ...prev, ...patch }));
|
||||
// Clear validation error as soon as the user starts typing again.
|
||||
if (error) setError(null);
|
||||
};
|
||||
|
||||
const validateAndAdvance = async () => {
|
||||
const msg = current.validate?.(state) ?? null;
|
||||
if (msg) {
|
||||
setError(msg);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
|
||||
if (!isLast) {
|
||||
setIndex((i) => i + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Last step: submit.
|
||||
try {
|
||||
setSubmittingInternal(true);
|
||||
await onFinish(state);
|
||||
} finally {
|
||||
setSubmittingInternal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stepStatus = useMemo(
|
||||
() =>
|
||||
steps.map((_, i) => {
|
||||
if (i < index) return "done" as const;
|
||||
if (i === index) return "active" as const;
|
||||
return "upcoming" as const;
|
||||
}),
|
||||
[steps, index],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
{/* Heading + back link */}
|
||||
<div className="space-y-2">
|
||||
{backTo && (
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 text-muted-foreground">
|
||||
<Link to={backTo} className="flex items-center gap-1">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t(backLabelKey)}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t(titleKey)}</h1>
|
||||
{subtitleKey && <p className="text-muted-foreground">{t(subtitleKey)}</p>}
|
||||
</div>
|
||||
|
||||
{/* Stepper */}
|
||||
<ol className="flex items-center gap-2 overflow-x-auto pb-2" role="list">
|
||||
{steps.map((step, i) => {
|
||||
const s = stepStatus[i];
|
||||
return (
|
||||
<li key={step.id} className="flex items-center gap-2 shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold",
|
||||
s === "done" && "bg-primary text-primary-foreground",
|
||||
s === "active" && "bg-primary/10 text-primary ring-2 ring-primary",
|
||||
s === "upcoming" && "bg-muted text-muted-foreground",
|
||||
)}
|
||||
aria-current={s === "active" ? "step" : undefined}
|
||||
>
|
||||
{s === "done" ? <Check className="h-4 w-4" /> : i + 1}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm whitespace-nowrap",
|
||||
s === "active" ? "font-medium" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t(step.titleKey)}
|
||||
</span>
|
||||
{i < steps.length - 1 && <div className="mx-1 h-px w-8 bg-border" />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Active step */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<h2 className="text-lg font-semibold">{t(current.titleKey)}</h2>
|
||||
{current.descriptionKey && (
|
||||
<p className="text-sm text-muted-foreground">{t(current.descriptionKey)}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{current.render({ state, update, error })}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIndex((i) => Math.max(0, i - 1))}
|
||||
disabled={busy || index === 0}
|
||||
>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
{t("wizard.back")}
|
||||
</Button>
|
||||
|
||||
<div className="text-xs text-muted-foreground tabular-nums">
|
||||
{t("wizard.stepOf", { current: index + 1, total: steps.length })}
|
||||
</div>
|
||||
|
||||
<Button onClick={validateAndAdvance} disabled={busy}>
|
||||
{isLast ? t(finishLabelKey) : t("wizard.next")}
|
||||
{!isLast && <ArrowRight className="ml-1 h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StepWizard;
|
||||
@@ -31,6 +31,8 @@ const ar: Translations = {
|
||||
email: "البريد الإلكتروني",
|
||||
user: "المستخدم",
|
||||
home: "الرئيسية",
|
||||
saving: "جارٍ الحفظ…",
|
||||
disabled: "معطّل",
|
||||
},
|
||||
auth: {
|
||||
signIn: "تسجيل الدخول",
|
||||
@@ -50,6 +52,9 @@ const ar: Translations = {
|
||||
errorTitle: "خطأ",
|
||||
},
|
||||
nav: {
|
||||
smartWizard: "المعالج الذكي",
|
||||
quickSetup: "الإعداد السريع",
|
||||
coursePlans: "خطط المقررات (الذكاء الاصطناعي)",
|
||||
adminDashboard: "لوحة الإدارة",
|
||||
platformDashboard: "لوحة المنصّة",
|
||||
dashboard: "لوحة التحكم",
|
||||
@@ -66,7 +71,7 @@ const ar: Translations = {
|
||||
rubrics: "معايير التقييم",
|
||||
generation: "التوليد",
|
||||
reviewQueue: "قائمة المراجعة",
|
||||
aiPrompts: "تعليمات الذكاء الاصطناعي",
|
||||
aiPrompts: "وكلاء الذكاء الاصطناعي والأدوات",
|
||||
aiFeedback: "ملاحظات الذكاء الاصطناعي",
|
||||
approvalWorkflows: "سير عمل الموافقات",
|
||||
taxonomy: "التصنيف",
|
||||
@@ -337,6 +342,535 @@ const ar: Translations = {
|
||||
commentRequired: "من فضلك أخبرنا بالخطأ.",
|
||||
submit: "إرسال الملاحظات",
|
||||
},
|
||||
quickSetup: {
|
||||
adminTitle: "الإعداد السريع",
|
||||
adminSubtitle:
|
||||
"كل ما تحتاجه لإطلاق منصّة جاهزة للامتحانات بالترتيب الموصى به. يتم تعليم كل خطوة تلقائياً عند اكتمالها.",
|
||||
teacherTitle: "الإعداد السريع",
|
||||
teacherSubtitle: "ابدأ دورة جديدة من البداية إلى النهاية، ثم انتقل إلى المهام اليومية الشائعة.",
|
||||
progressLabel: "التقدم",
|
||||
recommendedFlow: "المسار الموصى به",
|
||||
otherQuickCreates: "إنشاء سريع آخر",
|
||||
ready: "جاهز",
|
||||
start: "ابدأ",
|
||||
review: "مراجعة",
|
||||
open: "فتح",
|
||||
helpAria: "عرض المساعدة",
|
||||
admin: {
|
||||
step1: {
|
||||
title: "أنشئ معيار تقييم (Rubric)",
|
||||
description: "حدّد معايير التقييم لمهام الكتابة والتحدّث. تُستخدم هذه المعايير لاحقاً عند توليد الامتحانات واعتمادها.",
|
||||
help: "المعيار هو شبكة تقييم (درجات × عناصر). يمكنك البدء من قالب جاهز أو تركيبه من عناصر محدّدة مسبقاً.",
|
||||
},
|
||||
step2: {
|
||||
title: "عرّف بنية الامتحان",
|
||||
description: "حدّد الأقسام والمهام والأجزاء التي يجب أن يحتويها كل امتحان من هذا النوع سواء مولّداً أو مخصّصاً.",
|
||||
help: "البنى تضمن الاتساق. مثلاً: بنية IELTS للكتابة تحتوي على المهمة ١ (١٥٠ كلمة) والمهمة ٢ (٢٥٠ كلمة)؛ سيرفض النظام الإرسال إن نقصت إحداهما.",
|
||||
},
|
||||
step3: {
|
||||
title: "ولّد أو أنشئ امتحاناً",
|
||||
description: "استخدم التوليد بالذكاء الاصطناعي (الأسرع) أو أنشئ امتحاناً مخصّصاً يدوياً. احفظ كمسودة أو أرسله للموافقة.",
|
||||
help: "التوليد يختار بنية ومعيار تقييم ثم يُنتج الأسئلة. المنشئ المخصّص يتيح لك تحكّماً كاملاً للامتحانات التجريبية.",
|
||||
},
|
||||
step4: {
|
||||
title: "راجع واعتمد",
|
||||
description: "يعتمد المصدّقون الامتحانات قبل ظهورها للطلاب. اضبط سير العمل مرة واحدة ليتم توجيه الطلبات تلقائياً.",
|
||||
help: "قائمة الاعتماد تعرض كل امتحان في انتظار المصادقة. الرفض يعيده للمؤلّف، والقبول ينشره.",
|
||||
},
|
||||
step5: {
|
||||
title: "عيّن للطلاب",
|
||||
description: "جدول الامتحان المنشور، اختر الدفعة، وأرسله. يراه الطلاب فوراً في بوّابتهم.",
|
||||
help: "يمكن استهداف طلاب أفراد أو دفعات أو صفوف كاملة مع مراعاة المنطقة الزمنية.",
|
||||
},
|
||||
quick: {
|
||||
course: { title: "دورة جديدة", description: "أنشئ هيكل دورة ليملأها المعلّمون بالفصول." },
|
||||
resource: { title: "رفع مورد", description: "أضف ملفات PDF أو صوت أو فيديو أو روابط إلى المكتبة المشتركة." },
|
||||
student: { title: "إضافة طالب", description: "أنشئ حساب طالب وعيّنه إلى دفعة." },
|
||||
teacher: { title: "إضافة معلّم", description: "ادعُ معلّماً وامنحه صلاحيات التدريس." },
|
||||
classroom: { title: "صف جديد", description: "جمّع الطلاب لأغراض الجدولة والحضور." },
|
||||
examSession: { title: "جدولة جلسة امتحان", description: "أنشئ جلسة امتحان مراقبة لامتحان مؤسسي." },
|
||||
customExam: { title: "امتحان مخصّص", description: "أنشئ امتحاناً يدوياً من الصفر بتحكّم كامل." },
|
||||
ticket: { title: "فتح تذكرة", description: "افتح تذكرة دعم نيابة عن مستخدم." },
|
||||
},
|
||||
},
|
||||
teacher: {
|
||||
step1: {
|
||||
title: "أنشئ دورة",
|
||||
description: "امنح دورتك اسماً، اختر المادّة، وحدّد مستواها. يمكنك تعديل الفصول بعد الإنشاء.",
|
||||
help: "الدورات هي الحاوية للفصول والمواد والواجبات.",
|
||||
},
|
||||
step2: {
|
||||
title: "أضف الفصول والمحتوى",
|
||||
description: "افتح دورتك وأضف فصولاً تحتوي على دروس وفيديوهات واختبارات ومهام تدريب.",
|
||||
help: "الفصول تنظّم أهداف التعلّم. استخدم الورشة الذكية لإنشاء المحتوى تلقائياً.",
|
||||
},
|
||||
step3: {
|
||||
title: "ارفع الموارد",
|
||||
description: "شارك مع طلابك ملفات PDF أو صوت أو فيديو داعمة عبر المكتبة.",
|
||||
help: "الملفات الكبيرة مدعومة — يقبل الخادم حتى ١٢٨ ميغابايت للرفعة الواحدة.",
|
||||
},
|
||||
step4: {
|
||||
title: "أنشئ واجباً",
|
||||
description: "حوّل امتحاناً منشوراً أو مهمّة إلى واجب بتاريخ تسليم ودفعة مستهدفة.",
|
||||
help: "تظهر الواجبات تلقائياً في لوحة كل طالب وفي جدوله.",
|
||||
},
|
||||
step5: {
|
||||
title: "تابع تقدّم الطلاب",
|
||||
description: "راقب التسليمات والحضور ورؤى التعلّم التكيّفي لصفّك.",
|
||||
help: "يحدّد محرك التعلّم التكيّفي الطلاب المعرّضين للخطر لتتدخّل مبكّراً.",
|
||||
},
|
||||
quick: {
|
||||
discussion: { title: "نقاش جديد", description: "ابدأ موضوع نقاش لصفّك." },
|
||||
announcement: { title: "إعلان جديد", description: "أرسل رسالة لجميع طلابك." },
|
||||
attendance: { title: "تسجيل الحضور", description: "سجّل حضور اليوم لإحدى الجلسات." },
|
||||
},
|
||||
},
|
||||
},
|
||||
wizardHub: {
|
||||
title: "المعالج الذكي",
|
||||
subtitle:
|
||||
"اختر أي سيناريو وسيرشدك المعالج خطوة بخطوة. لا حاجة للبحث في الإعدادات — كلّ ضغطة على \"التالي\" تقرّبك أكثر من الإنجاز.",
|
||||
recommendedOrder: "الترتيب الموصى به",
|
||||
order: {
|
||||
rubric: "أنشئ معايير التقييم (الكتابة / المحادثة)",
|
||||
structure: "حدّد هيكل الامتحان (الأقسام والمهام والمدد)",
|
||||
generate: "أنشئ امتحاناً تلقائياً أو يدوياً",
|
||||
approve: "راجع الامتحانات المعلّقة واعتمدها",
|
||||
assign: "أسند الامتحانات إلى الطلاب",
|
||||
},
|
||||
guided: "معالجات موجّهة",
|
||||
advanced: "الصفحات الكاملة (متقدّم)",
|
||||
advancedBadge: "متقدّم",
|
||||
aiBadge: "ذكاء اصطناعي",
|
||||
startWizard: "ابدأ المعالج",
|
||||
openPage: "افتح الصفحة",
|
||||
cards: {
|
||||
rubric: {
|
||||
title: "إنشاء معيار تقييم",
|
||||
description: "الاسم ← المهارة ← المعايير ← الوصف ← المراجعة. للكتابة والمحادثة فقط.",
|
||||
},
|
||||
examStructure: {
|
||||
title: "تعريف هيكل امتحان",
|
||||
description: "الاسم ← الوحدات ← مهام الكتابة ← المراجعة. قالب قابل لإعادة الاستخدام.",
|
||||
},
|
||||
course: {
|
||||
title: "إنشاء مساق",
|
||||
description: "العنوان ← المستوى والسعة ← المراجعة. الفصول تُضاف من صفحة المساق.",
|
||||
},
|
||||
coursePlan: {
|
||||
title: "توليد خطة مقرر (ذكاء اصطناعي)",
|
||||
description: "اوصف المقرر فيكتب الذكاء الاصطناعي الأهداف ونتائج التعلّم لكل مهارة ونطاق القواعد وخطة التسليم أسبوعياً، ثم يولّد المواد التعليمية الجاهزة لكل أسبوع.",
|
||||
},
|
||||
generation: {
|
||||
title: "توليد امتحان",
|
||||
description: "صفحة التوليد الكاملة بخيارات المعايير والهيكل وتحكّم في كل وحدة.",
|
||||
},
|
||||
approval: {
|
||||
title: "مسارات الموافقة",
|
||||
description: "حدّد من يراجع الامتحانات، وتابع الطلبات المعلّقة.",
|
||||
},
|
||||
assign: {
|
||||
title: "إسناد إلى الطلاب",
|
||||
description: "صفحة الجدولة الكاملة مع منتقي الطلاب والنوافذ الزمنية والمنطقة الزمنية.",
|
||||
},
|
||||
resource: {
|
||||
title: "رفع مورد",
|
||||
description: "أضف ملفات PDF أو صوت أو فيديو أو روابط إلى المكتبة.",
|
||||
},
|
||||
student: {
|
||||
title: "إضافة طالب",
|
||||
description: "أنشئ حساب طالب وسجّله في دفعة.",
|
||||
},
|
||||
},
|
||||
},
|
||||
wizard: {
|
||||
back: "السابق",
|
||||
next: "التالي",
|
||||
finish: "إنهاء",
|
||||
backToHub: "العودة إلى المعالجات",
|
||||
stepOf: "الخطوة {{current}} من {{total}}",
|
||||
rubric: {
|
||||
title: "إنشاء معيار تقييم",
|
||||
subtitle: "شبكة تقييم لمهام الكتابة أو المحادثة، يُرجَع إليها عند توليد الامتحانات أو اعتمادها.",
|
||||
finish: "إنشاء المعيار",
|
||||
toastSuccess: "تم إنشاء المعيار",
|
||||
toastError: "تعذّر إنشاء المعيار",
|
||||
addCriterion: "إضافة معيار",
|
||||
removeCriterion: "حذف المعيار",
|
||||
unnamedCriterion: "(معيار بلا اسم)",
|
||||
descriptorHint: "الوصف اختياري. يظهر للمصحّحين كإرشاد لكل معيار.",
|
||||
maxLabel: "الحد الأقصى",
|
||||
moduleHint: "تنطبق المعايير على الكتابة والمحادثة فقط. الاستماع والقراءة يُصحَّحان تلقائياً.",
|
||||
step1: {
|
||||
title: "الأساسيات",
|
||||
description: "امنح المعيار اسماً وحدّد المهارة التي يطبّق عليها.",
|
||||
},
|
||||
step2: {
|
||||
title: "المعايير",
|
||||
description: "أضف المعايير التي سيقوم المصحّحون بتقييمها. لكل معيار حد أقصى (مثل 9 لامتحان IELTS).",
|
||||
},
|
||||
step3: {
|
||||
title: "الأوصاف",
|
||||
description: "اختياري: صف ما يقيسه كل معيار. يراها المصحّحون كتلميحات.",
|
||||
},
|
||||
step4: {
|
||||
title: "المراجعة",
|
||||
description: "راجع وأنشئ.",
|
||||
},
|
||||
labels: {
|
||||
name: "اسم المعيار",
|
||||
module: "المهارة",
|
||||
description: "الوصف",
|
||||
criterionName: "اسم المعيار الفرعي",
|
||||
maxScore: "الحد الأقصى",
|
||||
},
|
||||
placeholders: {
|
||||
name: "مثال: IELTS مهمة كتابة 2",
|
||||
description: "وصف قصير يظهر للمصحّحين.",
|
||||
criterionName: "مثال: الاستجابة للمهمة",
|
||||
descriptor: "ماذا يجب على المصحّح أن يتحقق منه لهذا المعيار.",
|
||||
},
|
||||
modules: {
|
||||
writing: "الكتابة",
|
||||
speaking: "المحادثة",
|
||||
},
|
||||
errors: {
|
||||
nameRequired: "يرجى إدخال اسم للمعيار.",
|
||||
moduleRestricted: "المعايير تنطبق على الكتابة أو المحادثة فقط.",
|
||||
criterionRequired: "أضف معياراً واحداً على الأقل.",
|
||||
criterionName: "كل معيار يحتاج إلى اسم.",
|
||||
criterionScore: "الحد الأقصى يجب أن يكون بين 1 و 100.",
|
||||
},
|
||||
},
|
||||
structure: {
|
||||
title: "تعريف هيكل امتحان",
|
||||
subtitle: "قالب يحدّد الأقسام والمهام التي يجب أن يحتويها كل امتحان من هذا النوع.",
|
||||
finish: "إنشاء الهيكل",
|
||||
toastSuccess: "تم إنشاء هيكل الامتحان",
|
||||
toastError: "تعذّر إنشاء هيكل الامتحان",
|
||||
industryHint: "اختياري: مثل \"الإنجليزية التجارية\"، \"الرعاية الصحية\".",
|
||||
writingSkipped: "الكتابة غير مُحدَّدة ضمن الوحدات — تم تخطّي هذه الخطوة.",
|
||||
wordsSuffix: "كلمة",
|
||||
addTask: "إضافة مهمة",
|
||||
removeTask: "حذف المهمة",
|
||||
step1: {
|
||||
title: "الأساسيات",
|
||||
description: "سمِّ الهيكل وحدّد نوع الامتحان.",
|
||||
},
|
||||
step2: {
|
||||
title: "الوحدات",
|
||||
description: "اختر الوحدات التي يغطّيها هذا الهيكل. سيراها الطلاب كأقسام.",
|
||||
},
|
||||
step3: {
|
||||
title: "مهام الكتابة",
|
||||
description: "للكتابة، حدّد الحد الأدنى لعدد الكلمات لكل مهمة.",
|
||||
},
|
||||
step4: {
|
||||
title: "المراجعة",
|
||||
description: "راجع وأنشئ.",
|
||||
},
|
||||
labels: {
|
||||
name: "اسم الهيكل",
|
||||
industry: "المجال / السياق",
|
||||
examType: "نوع الامتحان",
|
||||
modules: "الوحدات",
|
||||
taskLabel: "اسم المهمة",
|
||||
minWords: "أدنى كلمات",
|
||||
},
|
||||
placeholders: {
|
||||
name: "مثال: IELTS أكاديمي كتابة",
|
||||
industry: "مثال: الإنجليزية التجارية",
|
||||
},
|
||||
examTypes: {
|
||||
academic: "أكاديمي",
|
||||
general: "عام",
|
||||
},
|
||||
modules: {
|
||||
listening: {
|
||||
title: "الاستماع",
|
||||
description: "أسئلة مبنيّة على الصوت (تصحيح تلقائي).",
|
||||
},
|
||||
reading: {
|
||||
title: "القراءة",
|
||||
description: "أسئلة مبنيّة على مقاطع (تصحيح تلقائي).",
|
||||
},
|
||||
writing: {
|
||||
title: "الكتابة",
|
||||
description: "مهام مقاليّة تُصحَّح باستخدام معيار تقييم.",
|
||||
},
|
||||
speaking: {
|
||||
title: "المحادثة",
|
||||
description: "مهام شفهيّة تُصحَّح باستخدام معيار تقييم.",
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
nameRequired: "يرجى إدخال اسم للهيكل.",
|
||||
moduleRequired: "اختر وحدة واحدة على الأقل.",
|
||||
writingTaskRequired: "الكتابة تحتاج إلى مهمة واحدة على الأقل.",
|
||||
writingTaskLabel: "كل مهمة كتابة تحتاج إلى اسم.",
|
||||
writingTaskWords: "الحد الأدنى للكلمات يجب أن يكون 1 على الأقل.",
|
||||
},
|
||||
},
|
||||
course: {
|
||||
title: "إنشاء مساق",
|
||||
subtitle: "هيكل مساق خفيف. يمكنك إضافة الفصول والمواد وتسجيل الطلاب بعد الإنشاء.",
|
||||
finish: "إنشاء المساق",
|
||||
toastSuccess: "تم إنشاء المساق",
|
||||
toastError: "تعذّر إنشاء المساق",
|
||||
codeHint: "اتركه فارغاً لتوليده تلقائياً من العنوان.",
|
||||
difficultyHint: "ما مدى تحدّي هذا المساق إجمالاً؟ يُستخدم للبحث والتصفية.",
|
||||
cefrHint: "إن كان المساق يستهدف مستوى CEFR محدداً، اختره هنا.",
|
||||
step1: {
|
||||
title: "الأساسيات",
|
||||
description: "امنح المساق اسماً ووصفاً قصيراً.",
|
||||
},
|
||||
step2: {
|
||||
title: "المستوى والسعة",
|
||||
description: "حدّد الصعوبة ومستوى CEFR المستهدف والحد الأقصى لعدد الطلاب.",
|
||||
},
|
||||
step3: {
|
||||
title: "المراجعة",
|
||||
description: "راجع وأنشئ.",
|
||||
},
|
||||
labels: {
|
||||
title: "عنوان المساق",
|
||||
code: "رمز المساق",
|
||||
description: "الوصف",
|
||||
difficulty: "الصعوبة",
|
||||
cefrLevel: "مستوى CEFR",
|
||||
capacity: "السعة القصوى",
|
||||
},
|
||||
placeholders: {
|
||||
title: "مثال: إنجليزية تأسيسية المستوى 1",
|
||||
code: "سيتم توليده تلقائياً إذا تُرك فارغاً",
|
||||
description: "لمن هذا المساق؟ ماذا سيتعلّمون؟",
|
||||
difficulty: "اختر مستوى صعوبة",
|
||||
cefr: "اختر مستوى CEFR",
|
||||
},
|
||||
difficulty: {
|
||||
beginner: "مبتدئ",
|
||||
intermediate: "متوسط",
|
||||
advanced: "متقدّم",
|
||||
},
|
||||
errors: {
|
||||
titleRequired: "يرجى إدخال عنوان المساق.",
|
||||
capacityRequired: "السعة القصوى يجب أن تكون 1 على الأقل.",
|
||||
},
|
||||
},
|
||||
},
|
||||
coursePlan: {
|
||||
listTitle: "خطط المقررات",
|
||||
listSubtitle:
|
||||
"خطط مناهج يُنشئها الذكاء الاصطناعي: الأهداف، ونتائج التعلّم لكل مهارة، ونطاق القواعد، وخطة التسليم أسبوعياً، مع توليد المواد التعليمية لكل أسبوع عند الطلب.",
|
||||
generateNew: "توليد خطة جديدة",
|
||||
searchPlaceholder: "ابحث عن خطة بالاسم…",
|
||||
loadFailed: "تعذّر تحميل الخطط.",
|
||||
deleted: "تم حذف الخطة.",
|
||||
deleteFailed: "تعذّر حذف الخطة.",
|
||||
delete: "حذف",
|
||||
confirmDelete: "حذف الخطة \"{{name}}\"؟ سيؤدي ذلك إلى إزالة جميع الأسابيع والمواد.",
|
||||
emptyTitle: "لا توجد خطط بعد",
|
||||
emptySubtitle: "استخدم معالج الذكاء الاصطناعي لتوليد أول خطة — يستغرق الأمر نحو دقيقة.",
|
||||
open: "فتح",
|
||||
backToList: "العودة إلى الخطط",
|
||||
noDescription: "لا يوجد وصف.",
|
||||
weeksCount_one: "{{count}} أسبوع",
|
||||
weeksCount_other: "{{count}} أسابيع",
|
||||
materialsCount_one: "{{count}} مادة",
|
||||
materialsCount_other: "{{count}} مواد",
|
||||
hoursPerWeek: "{{count}} ساعة/أسبوع",
|
||||
weekN: "الأسبوع {{n}}",
|
||||
generateMaterials: "توليد مواد الأسبوع (ذكاء اصطناعي)",
|
||||
regenerateMaterials: "إعادة توليد مواد الأسبوع",
|
||||
generating: "جاري التوليد…",
|
||||
generateHint:
|
||||
"سيُنتج الذكاء الاصطناعي نصّ قراءة وسيناريو استماع ومحفّزات محادثة وسؤال كتابة ودرس قواعد وقائمة مفردات لهذا الأسبوع.",
|
||||
weekMaterialsGenerated: "تم توليد مواد الأسبوع.",
|
||||
weekMaterialsFailed: "تعذّر توليد مواد الأسبوع.",
|
||||
generateSuccess: "تم توليد خطة المقرر.",
|
||||
generateFailed: "تعذّر توليد خطة المقرر.",
|
||||
deliveryHint: "افتح أي أسبوع لعرض النتائج المخطَّطة وتوليد المواد الجاهزة للاستخدام.",
|
||||
status: {
|
||||
draft: "مسودة",
|
||||
generated: "مُولّدة",
|
||||
approved: "معتمدة",
|
||||
archived: "مؤرشفة",
|
||||
},
|
||||
sections: {
|
||||
objectives: "أهداف المقرر",
|
||||
outcomes: "نتائج التعلّم حسب المهارة",
|
||||
grammar: "نطاق القواعد",
|
||||
assessment: "التقييم",
|
||||
resources: "المصادر",
|
||||
delivery: "خطة التسليم الأسبوعية",
|
||||
},
|
||||
skill: {
|
||||
reading: "القراءة",
|
||||
writing: "الكتابة",
|
||||
listening: "الاستماع",
|
||||
speaking: "المحادثة",
|
||||
grammar: "القواعد",
|
||||
vocabulary: "المفردات",
|
||||
integrated: "متكامل",
|
||||
},
|
||||
materialType: {
|
||||
reading_text: "نص قراءة",
|
||||
listening_script: "نص استماع",
|
||||
speaking_prompt: "محفّز محادثة",
|
||||
writing_prompt: "مهمّة كتابة",
|
||||
grammar_lesson: "درس قواعد",
|
||||
vocabulary_list: "قائمة مفردات",
|
||||
practice: "تمرين",
|
||||
other: "مادة",
|
||||
},
|
||||
table: {
|
||||
skill: "المهارة",
|
||||
outcomes: "النتائج",
|
||||
remarks: "ملاحظات",
|
||||
},
|
||||
wizard: {
|
||||
title: "توليد خطة مقرر",
|
||||
subtitle:
|
||||
"اوصف المقرر مرّة واحدة، ويكتب الذكاء الاصطناعي الخطة كاملة — الأهداف والنتائج والقواعد والخطة الأسبوعية — ويمكنك توليد مواد الأسبوع الأول بضغطة واحدة.",
|
||||
finish: "توليد الخطة",
|
||||
reviewHint: "اضغط على \"توليد الخطة\" لبدء الذكاء الاصطناعي. يستغرق عادةً 30–60 ثانية، وستنتقل إلى صفحة الخطة عند الانتهاء.",
|
||||
steps: {
|
||||
basics: "الأساسيات",
|
||||
basicsDesc: "سمِّ المقرر وحدّد المستوى والمدة وعدد الساعات الأسبوعية.",
|
||||
coverage: "التغطية",
|
||||
coverageDesc: "أخبر الذكاء الاصطناعي بتوزيع الساعات على المهارات ومن هم المتعلّمون.",
|
||||
scope: "النطاق",
|
||||
scopeDesc: "اختياري: تركيز القواعد، والمصادر، وملاحظات حرّة.",
|
||||
review: "المراجعة",
|
||||
reviewDesc: "راجع البيانات قبل بدء التوليد.",
|
||||
},
|
||||
fields: {
|
||||
title: "عنوان المقرر",
|
||||
cefr: "مستوى CEFR",
|
||||
cefrPlaceholder: "اختر مستوى",
|
||||
totalWeeks: "إجمالي الأسابيع",
|
||||
contactHours: "عدد الساعات أسبوعياً",
|
||||
skillsDivision: "توزيع المهارات",
|
||||
skillsDivisionHint:
|
||||
"صيغة حرّة — مثال: \"10 س/أسبوع قراءة وكتابة + 8 س/أسبوع استماع ومحادثة\". اتركه فارغاً ليقرّر الذكاء الاصطناعي.",
|
||||
learnerProfile: "ملف المتعلّمين",
|
||||
learnerProfilePlaceholder: "من هم المتعلّمون؟ الفئة العمرية، اللغة الأم، الخلفية الدراسية، الأهداف…",
|
||||
grammarFocus: "تركيز القواعد (Enter للإضافة)",
|
||||
grammarFocusPlaceholder: "مثال: المضارع البسيط",
|
||||
resources: "المصادر (Enter للإضافة)",
|
||||
resourcesPlaceholder: "مثال: Pathways 1 (National Geographic)",
|
||||
notes: "ملاحظات إضافية",
|
||||
notesPlaceholder: "أي شيء آخر يحتاج الذكاء الاصطناعي معرفته.",
|
||||
},
|
||||
errors: {
|
||||
titleRequired: "يرجى إدخال عنوان المقرر.",
|
||||
cefrRequired: "يرجى اختيار مستوى CEFR.",
|
||||
weeksRange: "عدد الأسابيع يجب أن يكون 1 على الأقل.",
|
||||
},
|
||||
},
|
||||
},
|
||||
aiAdmin: {
|
||||
title: "وكلاء الذكاء الاصطناعي والأدوات",
|
||||
subtitle:
|
||||
"هيّئ الوكلاء المبنيين على LangGraph الذين يشغّلون تخطيط المقررات وتوليد الاختبارات والتمارين والمدرّس داخل LMS والتصحيح. الإعدادات الافتراضية جاهزة للاستخدام مباشرة.",
|
||||
tabs: {
|
||||
agents: "الوكلاء",
|
||||
tools: "الأدوات",
|
||||
prompts: "التعليمات",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: {
|
||||
title: "الوكلاء",
|
||||
subtitle: "مهيّأون مسبقاً لكل ركيزة في المنصة — يمكن تعديل الإعدادات الافتراضية أدناه.",
|
||||
search: "ابحث عن وكيل…",
|
||||
empty: "لا توجد وكلاء مطابقون لبحثك.",
|
||||
},
|
||||
detail: {
|
||||
configure: "ضبط الإعدادات",
|
||||
empty: "اختر وكيلاً لعرض إعداداته.",
|
||||
graph: "نوع الرسم البياني",
|
||||
model: "النموذج",
|
||||
temperature: "درجة الإبداع",
|
||||
tokens: "أقصى عدد رموز",
|
||||
format: "صيغة المخرجات",
|
||||
fallback: "الاحتياطي",
|
||||
revisions: "أقصى عدد مراجعات",
|
||||
promptKey: "مفتاح القالب",
|
||||
toolsTitle: "الأدوات المفعّلة",
|
||||
noTools: "لا توجد أدوات مفعّلة لهذا الوكيل.",
|
||||
systemPrompt: "تعليمات النظام",
|
||||
noPrompt: "(فارغ)",
|
||||
},
|
||||
config: {
|
||||
title: "ضبط الإعدادات",
|
||||
saved: "تم حفظ إعدادات الوكيل",
|
||||
name: "اسم العرض",
|
||||
promptKey: "مفتاح القالب (مع نسخ)",
|
||||
description: "الوصف",
|
||||
systemPrompt: "تعليمات النظام",
|
||||
systemPromptHint:
|
||||
"إذا تم تعيين مفتاح قالب أعلاه، فإن النسخة النشطة من ذلك القالب تستبدل هذا الحقل وقت التشغيل.",
|
||||
model: "النموذج",
|
||||
fallbackModel: "النموذج الاحتياطي",
|
||||
responseFormat: "صيغة المخرجات",
|
||||
text: "نص",
|
||||
temperature: "درجة الإبداع",
|
||||
maxTokens: "أقصى عدد رموز",
|
||||
maxRevisions: "أقصى عدد مراجعات",
|
||||
graphType: "بنية الرسم البياني",
|
||||
qualityChecks: "فحوصات الجودة (مفاتيح أدوات مفصولة بفواصل)",
|
||||
tools: "الأدوات المفعّلة",
|
||||
mutates: "كتابة",
|
||||
active: "الوكيل مفعّل",
|
||||
},
|
||||
test: {
|
||||
title: "محرّك الاختبار",
|
||||
subtitle:
|
||||
"أرسل طلباً صغيراً وافحص مخرجات الوكيل واستدعاءات الأدوات وتنبيهات الجودة.",
|
||||
variables: "المتغيّرات (JSON)",
|
||||
payload: "الطلب (نص أو JSON)",
|
||||
payloadPlaceholder:
|
||||
"ماذا يجب أن يفعل الوكيل؟ مثال: «أنشئ 5 أسئلة اختيار من متعدد للقراءة لمستوى B1.»",
|
||||
run: "تشغيل الوكيل",
|
||||
running: "جارٍ التشغيل…",
|
||||
ok: "تم تنفيذ الوكيل بنجاح",
|
||||
output: "المخرجات",
|
||||
toolTrace: "سجلّ استدعاءات الأدوات",
|
||||
iterations: "التكرارات",
|
||||
revisions: "المراجعات",
|
||||
toolCalls: "استدعاءات الأدوات",
|
||||
retrievalHits: "نتائج الاسترجاع",
|
||||
qualityIssues: "تنبيهات الجودة",
|
||||
badVarsJson: "يجب أن تكون المتغيّرات بصيغة JSON صحيحة.",
|
||||
},
|
||||
graph: {
|
||||
simple: "بسيط",
|
||||
planReviewRevise: "تخطيط • مراجعة • تنقيح",
|
||||
rag: "استرجاع وتوليد",
|
||||
react: "ReAct (استدعاء أدوات)",
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
title: "أدوات الوكلاء",
|
||||
subtitle:
|
||||
"القدرات التي يمكن للوكلاء استدعاؤها. أوقف أداة لإخراجها من جميع الوكلاء دون تعديل كل واحد منهم.",
|
||||
search: "ابحث عن أداة…",
|
||||
empty: "لا توجد أدوات مطابقة لبحثك.",
|
||||
writes: "كتابة",
|
||||
toggle: {
|
||||
enabled: "تم تفعيل الأداة",
|
||||
disabled: "تم تعطيل الأداة",
|
||||
},
|
||||
col: {
|
||||
key: "المفتاح",
|
||||
name: "الاسم",
|
||||
category: "الفئة",
|
||||
description: "الوصف",
|
||||
params: "المعاملات",
|
||||
active: "نشط",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default ar;
|
||||
|
||||
@@ -30,6 +30,20 @@ export interface Translations {
|
||||
ai: Record<string, string>;
|
||||
privacy: 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 = {
|
||||
@@ -62,6 +76,8 @@ const en: Translations = {
|
||||
email: "Email",
|
||||
user: "User",
|
||||
home: "Home",
|
||||
saving: "Saving…",
|
||||
disabled: "Disabled",
|
||||
},
|
||||
auth: {
|
||||
signIn: "Sign in",
|
||||
@@ -81,6 +97,9 @@ const en: Translations = {
|
||||
errorTitle: "Error",
|
||||
},
|
||||
nav: {
|
||||
smartWizard: "Smart Wizard",
|
||||
quickSetup: "Smart Setup",
|
||||
coursePlans: "Course Plans (AI)",
|
||||
adminDashboard: "Admin Dashboard",
|
||||
platformDashboard: "Platform Dashboard",
|
||||
dashboard: "Dashboard",
|
||||
@@ -97,7 +116,7 @@ const en: Translations = {
|
||||
rubrics: "Rubrics",
|
||||
generation: "Generation",
|
||||
reviewQueue: "Review Queue",
|
||||
aiPrompts: "AI Prompts",
|
||||
aiPrompts: "AI Agents & Tools",
|
||||
aiFeedback: "AI Feedback",
|
||||
approvalWorkflows: "Approval Workflows",
|
||||
taxonomy: "Taxonomy",
|
||||
@@ -368,6 +387,548 @@ const en: Translations = {
|
||||
commentRequired: "Please tell us what was wrong.",
|
||||
submit: "Submit feedback",
|
||||
},
|
||||
// Smart-setup wizard. Nested so i18next's default "." key separator
|
||||
// resolves e.g. t("quickSetup.admin.step1.title").
|
||||
quickSetup: {
|
||||
adminTitle: "Smart Setup",
|
||||
adminSubtitle:
|
||||
"Everything you need to launch an exam-ready platform, in the recommended order. Tick each step off as you go — the wizard auto-detects progress.",
|
||||
teacherTitle: "Smart Setup",
|
||||
teacherSubtitle:
|
||||
"Launch a new course end-to-end, then jump to common day-to-day tasks.",
|
||||
progressLabel: "Progress",
|
||||
recommendedFlow: "Recommended flow",
|
||||
otherQuickCreates: "Other quick creates",
|
||||
ready: "Ready",
|
||||
start: "Start",
|
||||
review: "Review",
|
||||
open: "Open",
|
||||
helpAria: "Show help",
|
||||
admin: {
|
||||
step1: {
|
||||
title: "Create a rubric",
|
||||
description:
|
||||
"Define the grading criteria for Writing and Speaking tasks. Rubrics are referenced later when generating or approving exams.",
|
||||
help: "A rubric is a scoring grid (bands × criteria). You can start from a template or compose one from predefined criteria.",
|
||||
},
|
||||
step2: {
|
||||
title: "Define an exam structure",
|
||||
description:
|
||||
"Blueprint the sections, tasks, and parts that every generated or custom exam of this type must contain.",
|
||||
help: "Structures enforce consistency. E.g. an IELTS Writing structure has Task 1 (150w) + Task 2 (250w); generation will refuse to submit if either is missing.",
|
||||
},
|
||||
step3: {
|
||||
title: "Generate or create an exam",
|
||||
description:
|
||||
"Use AI generation (fastest) or hand-build a custom exam. Save as draft or submit for approval.",
|
||||
help: "Generation picks a structure + rubric and produces questions. The custom builder gives you full control for pilot/test exams.",
|
||||
},
|
||||
step4: {
|
||||
title: "Review & approve",
|
||||
description:
|
||||
"Approvers sign off on exams before students can see them. Configure the workflow once, then route submissions automatically.",
|
||||
help: "The approval queue lists every exam waiting for sign-off. Reject sends it back to the author; approve publishes it.",
|
||||
},
|
||||
step5: {
|
||||
title: "Assign to students",
|
||||
description:
|
||||
"Schedule the published exam, pick a cohort, and send it out. Students see it immediately in their portal.",
|
||||
help: "Assignments can target individual students, batches, or classrooms. Timezone-aware windows are supported.",
|
||||
},
|
||||
quick: {
|
||||
course: { title: "New course", description: "Stand up a course shell for teachers to fill with chapters." },
|
||||
resource: { title: "Upload resource", description: "Add PDFs, audio, video, or links to the shared library." },
|
||||
student: { title: "Add student", description: "Create a student account and assign them to a batch." },
|
||||
teacher: { title: "Add teacher", description: "Invite a teacher and grant teaching permissions." },
|
||||
classroom: { title: "New classroom", description: "Group students for scheduling and attendance." },
|
||||
examSession: { title: "Schedule exam session", description: "Create a proctored sitting for an institutional exam." },
|
||||
customExam: { title: "Custom exam", description: "Hand-build an exam from scratch with full control." },
|
||||
ticket: { title: "Open ticket", description: "Raise a support ticket on behalf of a user." },
|
||||
},
|
||||
},
|
||||
teacher: {
|
||||
step1: {
|
||||
title: "Create a course",
|
||||
description: "Give your course a name, pick a subject, and set its level. You can edit chapters after creation.",
|
||||
help: "Courses are the container for chapters, materials, and assignments.",
|
||||
},
|
||||
step2: {
|
||||
title: "Add chapters & content",
|
||||
description: "Open your course and add chapters with lessons, videos, quizzes, and practice tasks.",
|
||||
help: "Chapters organise learning objectives. Use the AI workbench to auto-draft content.",
|
||||
},
|
||||
step3: {
|
||||
title: "Upload resources",
|
||||
description: "Share supporting PDFs, audio, or video with your students via the library.",
|
||||
help: "Large files are fine — the server accepts up to 128 MB per upload.",
|
||||
},
|
||||
step4: {
|
||||
title: "Create an assignment",
|
||||
description: "Turn a published exam or task into an assignment with a due date and target cohort.",
|
||||
help: "Assignments auto-surface in each student's dashboard and on their timetable.",
|
||||
},
|
||||
step5: {
|
||||
title: "Track student progress",
|
||||
description: "Monitor submissions, attendance, and adaptive learning insights for your class.",
|
||||
help: "The adaptive engine flags at-risk students so you can intervene early.",
|
||||
},
|
||||
quick: {
|
||||
discussion: { title: "New discussion", description: "Start a topic thread for your class." },
|
||||
announcement: { title: "New announcement", description: "Broadcast a message to all your students." },
|
||||
attendance: { title: "Mark attendance", description: "Record today's attendance for a session." },
|
||||
},
|
||||
},
|
||||
},
|
||||
wizardHub: {
|
||||
title: "Smart Wizard",
|
||||
subtitle:
|
||||
"Pick any scenario and the wizard will walk you through it step by step. No need to hunt through settings — every Next button moves you closer to done.",
|
||||
recommendedOrder: "Recommended order",
|
||||
order: {
|
||||
rubric: "Create rubrics (Writing / Speaking)",
|
||||
structure: "Define exam structures (sections, tasks, durations)",
|
||||
generate: "Generate or create exams",
|
||||
approve: "Review & approve pending exams",
|
||||
assign: "Assign exams to students",
|
||||
},
|
||||
guided: "Guided wizards",
|
||||
advanced: "Full pages (advanced)",
|
||||
advancedBadge: "Advanced",
|
||||
aiBadge: "AI",
|
||||
startWizard: "Start wizard",
|
||||
openPage: "Open page",
|
||||
cards: {
|
||||
rubric: {
|
||||
title: "Create a rubric",
|
||||
description: "Name → skill → criteria → descriptors → review. Writing and Speaking only.",
|
||||
},
|
||||
examStructure: {
|
||||
title: "Define an exam structure",
|
||||
description: "Name → modules → writing tasks → review. Reusable blueprint for generation.",
|
||||
},
|
||||
course: {
|
||||
title: "Create a course",
|
||||
description: "Title → level & capacity → review. Chapters can be added from the course page.",
|
||||
},
|
||||
coursePlan: {
|
||||
title: "Generate a course plan (AI)",
|
||||
description: "Describe the course → AI writes objectives, per-skill outcomes, grammar scope and a week-by-week delivery plan, then produces real teaching materials per week.",
|
||||
},
|
||||
generation: {
|
||||
title: "Generate an exam",
|
||||
description: "Full AI generation page with rubric/structure pickers and per-module controls.",
|
||||
},
|
||||
approval: {
|
||||
title: "Approval workflows",
|
||||
description: "Configure who reviews which exams and see pending requests.",
|
||||
},
|
||||
assign: {
|
||||
title: "Assign to students",
|
||||
description: "Full scheduling page with student picker, windows, and timezone support.",
|
||||
},
|
||||
resource: {
|
||||
title: "Upload resource",
|
||||
description: "Add PDFs, audio, video, or links to the shared library.",
|
||||
},
|
||||
student: {
|
||||
title: "Add student",
|
||||
description: "Create a student account and enroll them in a batch.",
|
||||
},
|
||||
},
|
||||
},
|
||||
wizard: {
|
||||
back: "Back",
|
||||
next: "Next",
|
||||
finish: "Finish",
|
||||
backToHub: "Back to wizards",
|
||||
stepOf: "Step {{current}} of {{total}}",
|
||||
rubric: {
|
||||
title: "Create a rubric",
|
||||
subtitle: "Grading grid for Writing or Speaking tasks. Referenced later when generating or approving exams.",
|
||||
finish: "Create rubric",
|
||||
toastSuccess: "Rubric created",
|
||||
toastError: "Could not create rubric",
|
||||
addCriterion: "Add criterion",
|
||||
removeCriterion: "Remove criterion",
|
||||
unnamedCriterion: "(unnamed criterion)",
|
||||
descriptorHint: "Descriptors are optional. They appear to graders as guidance for each criterion.",
|
||||
maxLabel: "Max",
|
||||
moduleHint:
|
||||
"Rubrics only apply to Writing and Speaking. Listening and Reading are auto-graded and don't need one.",
|
||||
step1: {
|
||||
title: "Basics",
|
||||
description: "Give the rubric a name and pick the skill it applies to.",
|
||||
},
|
||||
step2: {
|
||||
title: "Criteria",
|
||||
description: "Add the criteria you want graders to score. Each criterion has a max score (e.g. 9 for IELTS).",
|
||||
},
|
||||
step3: {
|
||||
title: "Descriptors",
|
||||
description: "Optional: describe what each criterion measures. Graders see these as hints.",
|
||||
},
|
||||
step4: {
|
||||
title: "Review",
|
||||
description: "Double-check and create.",
|
||||
},
|
||||
labels: {
|
||||
name: "Rubric name",
|
||||
module: "Skill",
|
||||
description: "Description",
|
||||
criterionName: "Criterion name",
|
||||
maxScore: "Max score",
|
||||
},
|
||||
placeholders: {
|
||||
name: "e.g. IELTS Writing Task 2",
|
||||
description: "Short description shown to graders.",
|
||||
criterionName: "e.g. Task Response",
|
||||
descriptor: "What graders should check for this criterion.",
|
||||
},
|
||||
modules: {
|
||||
writing: "Writing",
|
||||
speaking: "Speaking",
|
||||
},
|
||||
errors: {
|
||||
nameRequired: "Please enter a name for this rubric.",
|
||||
moduleRestricted: "Rubrics apply only to Writing or Speaking.",
|
||||
criterionRequired: "Add at least one criterion.",
|
||||
criterionName: "Every criterion needs a name.",
|
||||
criterionScore: "Max score must be between 1 and 100.",
|
||||
},
|
||||
},
|
||||
structure: {
|
||||
title: "Define an exam structure",
|
||||
subtitle: "Blueprint the sections, tasks, and parts every generated or custom exam of this type must contain.",
|
||||
finish: "Create structure",
|
||||
toastSuccess: "Exam structure created",
|
||||
toastError: "Could not create exam structure",
|
||||
industryHint: "Optional: e.g. 'Business English', 'Healthcare'.",
|
||||
writingSkipped: "Writing is not in the selected modules — this step is skipped.",
|
||||
wordsSuffix: "words",
|
||||
addTask: "Add task",
|
||||
removeTask: "Remove task",
|
||||
step1: {
|
||||
title: "Basics",
|
||||
description: "Name the structure and pick its exam type.",
|
||||
},
|
||||
step2: {
|
||||
title: "Modules",
|
||||
description: "Select which modules this structure covers. Students will see these sections.",
|
||||
},
|
||||
step3: {
|
||||
title: "Writing tasks",
|
||||
description: "For writing, define each task's minimum word count.",
|
||||
},
|
||||
step4: {
|
||||
title: "Review",
|
||||
description: "Double-check and create.",
|
||||
},
|
||||
labels: {
|
||||
name: "Structure name",
|
||||
industry: "Industry / context",
|
||||
examType: "Exam type",
|
||||
modules: "Modules",
|
||||
taskLabel: "Task label",
|
||||
minWords: "Min words",
|
||||
},
|
||||
placeholders: {
|
||||
name: "e.g. IELTS Academic Writing",
|
||||
industry: "e.g. Business English",
|
||||
},
|
||||
examTypes: {
|
||||
academic: "Academic",
|
||||
general: "General",
|
||||
},
|
||||
modules: {
|
||||
listening: {
|
||||
title: "Listening",
|
||||
description: "Audio-based questions (auto-graded).",
|
||||
},
|
||||
reading: {
|
||||
title: "Reading",
|
||||
description: "Passage-based questions (auto-graded).",
|
||||
},
|
||||
writing: {
|
||||
title: "Writing",
|
||||
description: "Essay tasks graded with a rubric.",
|
||||
},
|
||||
speaking: {
|
||||
title: "Speaking",
|
||||
description: "Oral tasks graded with a rubric.",
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
nameRequired: "Please enter a name for this structure.",
|
||||
moduleRequired: "Select at least one module.",
|
||||
writingTaskRequired: "Writing needs at least one task.",
|
||||
writingTaskLabel: "Each writing task needs a label.",
|
||||
writingTaskWords: "Minimum words must be at least 1.",
|
||||
},
|
||||
},
|
||||
course: {
|
||||
title: "Create a course",
|
||||
subtitle: "A lightweight course skeleton. You can add chapters, materials and enroll students after creation.",
|
||||
finish: "Create course",
|
||||
toastSuccess: "Course created",
|
||||
toastError: "Could not create course",
|
||||
codeHint: "Leave blank to auto-generate from the title.",
|
||||
difficultyHint: "How challenging is this course overall? Used for search & filtering.",
|
||||
cefrHint: "If this course targets a specific CEFR band, pick it here.",
|
||||
step1: {
|
||||
title: "Basics",
|
||||
description: "Give your course a name and a short description.",
|
||||
},
|
||||
step2: {
|
||||
title: "Level & capacity",
|
||||
description: "Set difficulty, target CEFR level, and how many students can enroll.",
|
||||
},
|
||||
step3: {
|
||||
title: "Review",
|
||||
description: "Double-check and create.",
|
||||
},
|
||||
labels: {
|
||||
title: "Course title",
|
||||
code: "Course code",
|
||||
description: "Description",
|
||||
difficulty: "Difficulty",
|
||||
cefrLevel: "CEFR level",
|
||||
capacity: "Max capacity",
|
||||
},
|
||||
placeholders: {
|
||||
title: "e.g. Foundation English Level 1",
|
||||
code: "Auto-generated if empty",
|
||||
description: "Who is this course for? What will they learn?",
|
||||
difficulty: "Select a difficulty",
|
||||
cefr: "Select a CEFR level",
|
||||
},
|
||||
difficulty: {
|
||||
beginner: "Beginner",
|
||||
intermediate: "Intermediate",
|
||||
advanced: "Advanced",
|
||||
},
|
||||
errors: {
|
||||
titleRequired: "Please enter a course title.",
|
||||
capacityRequired: "Max capacity must be at least 1.",
|
||||
},
|
||||
},
|
||||
},
|
||||
coursePlan: {
|
||||
listTitle: "Course Plans",
|
||||
listSubtitle:
|
||||
"AI-generated curriculum outlines: objectives, per-skill learning outcomes, grammar scope, a week-by-week delivery plan, and on-demand teaching materials for each week.",
|
||||
generateNew: "Generate new plan",
|
||||
searchPlaceholder: "Search plans by name…",
|
||||
loadFailed: "Couldn't load course plans.",
|
||||
deleted: "Plan deleted.",
|
||||
deleteFailed: "Couldn't delete plan.",
|
||||
delete: "Delete",
|
||||
confirmDelete: "Delete plan \"{{name}}\"? This removes all weeks and materials.",
|
||||
emptyTitle: "No course plans yet",
|
||||
emptySubtitle:
|
||||
"Use the AI wizard to generate your first plan — it only takes about a minute.",
|
||||
open: "Open",
|
||||
backToList: "Back to course plans",
|
||||
noDescription: "No description.",
|
||||
weeksCount_one: "{{count}} week",
|
||||
weeksCount_other: "{{count}} weeks",
|
||||
materialsCount_one: "{{count}} material",
|
||||
materialsCount_other: "{{count}} materials",
|
||||
hoursPerWeek: "{{count}} hrs/week",
|
||||
weekN: "Week {{n}}",
|
||||
generateMaterials: "Generate Week materials (AI)",
|
||||
regenerateMaterials: "Regenerate Week materials",
|
||||
generating: "Generating…",
|
||||
generateHint:
|
||||
"AI will produce a reading text, listening script, speaking prompts, writing prompt, grammar mini-lesson and vocabulary for this week.",
|
||||
weekMaterialsGenerated: "Week materials generated.",
|
||||
weekMaterialsFailed: "Couldn't generate week materials.",
|
||||
generateSuccess: "Course plan generated.",
|
||||
generateFailed: "Couldn't generate course plan.",
|
||||
deliveryHint:
|
||||
"Expand any week to view the planned outcomes and generate ready-to-use teaching materials.",
|
||||
status: {
|
||||
draft: "Draft",
|
||||
generated: "Generated",
|
||||
approved: "Approved",
|
||||
archived: "Archived",
|
||||
},
|
||||
sections: {
|
||||
objectives: "Course objectives",
|
||||
outcomes: "Learning outcomes by skill",
|
||||
grammar: "Grammar scope",
|
||||
assessment: "Assessment",
|
||||
resources: "Resources",
|
||||
delivery: "Weekly delivery plan",
|
||||
},
|
||||
skill: {
|
||||
reading: "Reading",
|
||||
writing: "Writing",
|
||||
listening: "Listening",
|
||||
speaking: "Speaking",
|
||||
grammar: "Grammar",
|
||||
vocabulary: "Vocabulary",
|
||||
integrated: "Integrated",
|
||||
},
|
||||
materialType: {
|
||||
reading_text: "Reading text",
|
||||
listening_script: "Listening script",
|
||||
speaking_prompt: "Speaking prompt",
|
||||
writing_prompt: "Writing prompt",
|
||||
grammar_lesson: "Grammar lesson",
|
||||
vocabulary_list: "Vocabulary list",
|
||||
practice: "Practice",
|
||||
other: "Material",
|
||||
},
|
||||
table: {
|
||||
skill: "Skill",
|
||||
outcomes: "Outcomes",
|
||||
remarks: "Remarks",
|
||||
},
|
||||
wizard: {
|
||||
title: "Generate a course plan",
|
||||
subtitle:
|
||||
"Describe the course once. The AI writes the full outline — objectives, outcomes, grammar, weekly plan — and you can generate Week 1 teaching material in one click afterwards.",
|
||||
finish: "Generate plan",
|
||||
reviewHint:
|
||||
"Click Generate plan to start the AI. This usually takes 30–60 seconds; you'll land on the plan page once it's done.",
|
||||
steps: {
|
||||
basics: "Basics",
|
||||
basicsDesc: "Name the course and set level, duration, and weekly hours.",
|
||||
coverage: "Coverage",
|
||||
coverageDesc: "Tell the AI how hours split across skills and who the learners are.",
|
||||
scope: "Scope",
|
||||
scopeDesc: "Optional: grammar focus, resources to reference, free-form notes.",
|
||||
review: "Review",
|
||||
reviewDesc: "Double-check your brief before kicking off the generation.",
|
||||
},
|
||||
fields: {
|
||||
title: "Course title",
|
||||
cefr: "CEFR level",
|
||||
cefrPlaceholder: "Pick a level",
|
||||
totalWeeks: "Total weeks",
|
||||
contactHours: "Contact hours / week",
|
||||
skillsDivision: "Skills division",
|
||||
skillsDivisionHint:
|
||||
"Free-form — e.g. \"10 hrs/wk Reading & Writing + 8 hrs/wk Listening & Speaking\". Leave blank to let the AI decide.",
|
||||
learnerProfile: "Learner profile",
|
||||
learnerProfilePlaceholder:
|
||||
"Who are the learners? Age range, L1, prior study, goals…",
|
||||
grammarFocus: "Grammar focus (press Enter to add)",
|
||||
grammarFocusPlaceholder: "e.g. present simple",
|
||||
resources: "Resources to reference (press Enter to add)",
|
||||
resourcesPlaceholder: "e.g. Pathways 1 (National Geographic)",
|
||||
notes: "Additional notes",
|
||||
notesPlaceholder: "Anything else the AI should know.",
|
||||
},
|
||||
errors: {
|
||||
titleRequired: "Please enter a course title.",
|
||||
cefrRequired: "Please pick a CEFR level.",
|
||||
weeksRange: "Total weeks must be at least 1.",
|
||||
},
|
||||
},
|
||||
},
|
||||
aiAdmin: {
|
||||
title: "AI Agents & Tools",
|
||||
subtitle:
|
||||
"Configure the LangGraph-backed agents that power course planning, exam generation, exercise generation, the LMS tutor, and grading. Defaults are pre-seeded and ready to use.",
|
||||
tabs: {
|
||||
agents: "Agents",
|
||||
tools: "Tools",
|
||||
prompts: "Prompts",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: {
|
||||
title: "Agents",
|
||||
subtitle: "Pre-configured for every platform pillar — edit defaults below.",
|
||||
search: "Search agents…",
|
||||
empty: "No agents match your search.",
|
||||
},
|
||||
detail: {
|
||||
configure: "Configure",
|
||||
empty: "Select an agent to inspect its configuration.",
|
||||
graph: "Graph",
|
||||
model: "Model",
|
||||
temperature: "Temperature",
|
||||
tokens: "Max tokens",
|
||||
format: "Output format",
|
||||
fallback: "Fallback",
|
||||
revisions: "Max revisions",
|
||||
promptKey: "Prompt key",
|
||||
toolsTitle: "Enabled tools",
|
||||
noTools: "This agent has no tools enabled.",
|
||||
systemPrompt: "System prompt",
|
||||
noPrompt: "(empty)",
|
||||
},
|
||||
config: {
|
||||
title: "Configure",
|
||||
saved: "Agent configuration saved",
|
||||
name: "Display name",
|
||||
promptKey: "Prompt key (versioned)",
|
||||
description: "Description",
|
||||
systemPrompt: "System prompt",
|
||||
systemPromptHint:
|
||||
"If a prompt key is set above, the active version of that prompt overrides this field at runtime.",
|
||||
model: "Model",
|
||||
fallbackModel: "Fallback model",
|
||||
responseFormat: "Output format",
|
||||
text: "Text",
|
||||
temperature: "Temperature",
|
||||
maxTokens: "Max tokens",
|
||||
maxRevisions: "Max revisions",
|
||||
graphType: "Graph topology",
|
||||
qualityChecks: "Quality checks (comma-separated tool keys)",
|
||||
tools: "Enabled tools",
|
||||
mutates: "writes",
|
||||
active: "Agent is active",
|
||||
},
|
||||
test: {
|
||||
title: "Test runner",
|
||||
subtitle:
|
||||
"Send a small payload and inspect the agent's output, tool calls, and quality issues.",
|
||||
variables: "Variables (JSON)",
|
||||
payload: "Payload (text or JSON)",
|
||||
payloadPlaceholder:
|
||||
"What should the agent do? e.g. 'Generate 5 MCQ for B1 reading.'",
|
||||
run: "Run agent",
|
||||
running: "Running…",
|
||||
ok: "Agent ran successfully",
|
||||
output: "Output",
|
||||
toolTrace: "Tool trace",
|
||||
iterations: "Iterations",
|
||||
revisions: "Revisions",
|
||||
toolCalls: "Tool calls",
|
||||
retrievalHits: "Retrieval hits",
|
||||
qualityIssues: "Quality issues",
|
||||
badVarsJson: "Variables must be valid JSON.",
|
||||
},
|
||||
graph: {
|
||||
simple: "Simple",
|
||||
planReviewRevise: "Plan • Review • Revise",
|
||||
rag: "RAG",
|
||||
react: "ReAct (tool-calling)",
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
title: "Agent tools",
|
||||
subtitle:
|
||||
"Capabilities your agents can call. Toggle a tool off to take it out of every agent without editing each one.",
|
||||
search: "Search tools…",
|
||||
empty: "No tools match your search.",
|
||||
writes: "writes",
|
||||
toggle: {
|
||||
enabled: "Tool enabled",
|
||||
disabled: "Tool disabled",
|
||||
},
|
||||
col: {
|
||||
key: "Key",
|
||||
name: "Name",
|
||||
category: "Category",
|
||||
description: "Description",
|
||||
params: "Params",
|
||||
active: "Active",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -291,7 +291,14 @@ async function performRequest<T>(url: string, init: RequestInitWithSkip): Promis
|
||||
const hadAccess = !!getAccessToken();
|
||||
clearToken();
|
||||
if (hadAccess || hadRefresh) {
|
||||
window.location.href = "/login";
|
||||
// Use SPA-style navigation when possible; fall back to a hard nav only
|
||||
// when we're inside a worker / non-browser context. A full document
|
||||
// reload here used to feel like "the browser refreshes on every click"
|
||||
// whenever an access token silently expired.
|
||||
if (typeof window !== "undefined" && !window.location.pathname.startsWith("/login")) {
|
||||
window.history.pushState({}, "", "/login");
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
}
|
||||
}
|
||||
throw new ApiError(401, response.statusText, await response.json().catch(() => null));
|
||||
}
|
||||
|
||||
842
frontend/src/pages/admin/AIAgentsPanel.tsx
Normal file
842
frontend/src/pages/admin/AIAgentsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useAIPrompt,
|
||||
@@ -37,8 +39,18 @@ import {
|
||||
useCreateAIPrompt,
|
||||
useRenderAIPrompt,
|
||||
} from "@/hooks/queries/useAIPrompts";
|
||||
import { AIAgentsPanel } from "@/pages/admin/AIAgentsPanel";
|
||||
import { AIToolsPanel } from "@/pages/admin/AIToolsPanel";
|
||||
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";
|
||||
|
||||
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 { data, isLoading } = useAIPromptKeys({ search, page: 1, size: 50 });
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
@@ -376,17 +393,8 @@ export default function AIPromptEditor() {
|
||||
}, [items, selectedKey]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-6 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
AI prompt library
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Versioned, auditable templates that the AI pipelines render at
|
||||
runtime. Non-engineers can iterate here without shipping code.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-end">
|
||||
<Button onClick={() => setNewOpen(true)}>
|
||||
<PlusCircle className="mr-1 h-4 w-4" />
|
||||
New version
|
||||
@@ -526,3 +534,66 @@ export default function AIPromptEditor() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
179
frontend/src/pages/admin/AIToolsPanel.tsx
Normal file
179
frontend/src/pages/admin/AIToolsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
423
frontend/src/pages/admin/AdminCoursePlanDetail.tsx
Normal file
423
frontend/src/pages/admin/AdminCoursePlanDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
frontend/src/pages/admin/AdminCoursePlans.tsx
Normal file
175
frontend/src/pages/admin/AdminCoursePlans.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
165
frontend/src/pages/admin/AdminQuickSetup.tsx
Normal file
165
frontend/src/pages/admin/AdminQuickSetup.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
215
frontend/src/pages/admin/SmartWizardHub.tsx
Normal file
215
frontend/src/pages/admin/SmartWizardHub.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
373
frontend/src/pages/admin/wizards/CoursePlanWizard.tsx
Normal file
373
frontend/src/pages/admin/wizards/CoursePlanWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
271
frontend/src/pages/admin/wizards/CourseWizard.tsx
Normal file
271
frontend/src/pages/admin/wizards/CourseWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
348
frontend/src/pages/admin/wizards/ExamStructureWizard.tsx
Normal file
348
frontend/src/pages/admin/wizards/ExamStructureWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
320
frontend/src/pages/admin/wizards/RubricWizard.tsx
Normal file
320
frontend/src/pages/admin/wizards/RubricWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
frontend/src/pages/teacher/TeacherQuickSetup.tsx
Normal file
109
frontend/src/pages/teacher/TeacherQuickSetup.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
53
frontend/src/services/aiAgent.service.ts
Normal file
53
frontend/src/services/aiAgent.service.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
48
frontend/src/services/coursePlan.service.ts
Normal file
48
frontend/src/services/coursePlan.service.ts
Normal 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
frontend/src/types/aiAgent.ts
Normal file
91
frontend/src/types/aiAgent.ts
Normal 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
frontend/src/types/coursePlan.ts
Normal file
120
frontend/src/types/coursePlan.ts
Normal 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;
|
||||
}
|
||||
@@ -36,6 +36,7 @@ export * from "./exam-session";
|
||||
export * from "./grading";
|
||||
export * from "./course-generation";
|
||||
export * from "./ai-course";
|
||||
export * from "./coursePlan";
|
||||
export * from "./entity-onboarding";
|
||||
export * from "./level-mapping";
|
||||
export * from "./branding";
|
||||
|
||||
Reference in New Issue
Block a user