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:
@@ -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;
|
||||
Reference in New Issue
Block a user