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:
373
src/pages/admin/wizards/CoursePlanWizard.tsx
Normal file
373
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
src/pages/admin/wizards/CourseWizard.tsx
Normal file
271
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
src/pages/admin/wizards/ExamStructureWizard.tsx
Normal file
348
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
src/pages/admin/wizards/RubricWizard.tsx
Normal file
320
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user