Files
full_encoach_platform/frontend/src/pages/admin/wizards/CoursePlanWizard.tsx
Yamen Ahmad 35ccc0dfb2 feat(platform): ship AI fallback stack and entity-scoped course planning
Unifies the new LangGraph-driven course-plan/media flow with robust provider fallbacks, admin AI provider settings, editable book-style materials, and strict entity isolation across LMS/course-plan APIs. Adds admin-only entity membership management in the Entities UI so users can switch linked entities directly from the platform.

Made-with: Cursor
2026-04-26 02:34:52 +04:00

820 lines
26 KiB
TypeScript

import { useRef, 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 {
FileText,
Image as ImageIcon,
Library,
Link2,
Mic,
Music,
Trash2,
Upload,
Video,
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 { LibraryPickerDialog } from "@/components/coursePlan/LibraryPickerDialog";
import { coursePlanService } from "@/services/coursePlan.service";
import { describeApiError } from "@/lib/api-client";
import type { CoursePlanGenerateBrief, Resource } 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.
*/
type DraftSourceKind = "file" | "url" | "text" | "resource";
interface DraftSource {
/** Stable client-side id; not persisted. */
uid: string;
kind: DraftSourceKind;
name: string;
/** Only set when kind === "file". */
file?: File;
/** Only set when kind === "url". */
url?: string;
/** Only set when kind === "text". */
text?: string;
/** Only set when kind === "resource" — pointer into the central library. */
resourceId?: number;
/** Display-only metadata so we can render the row without a refetch. */
resourceType?: string;
}
interface MediaToggleState {
/** Generate narration audio (Polly / ElevenLabs) for listening +
* speaking materials right after each week's materials are produced. */
audio: boolean;
/** Generate DALL-E images for reading texts, listening scripts, and
* vocabulary lists. Bound to the per-plan image budget. */
image: boolean;
/** Compose audio + image into a slideshow MP4 with ffmpeg. Slowest
* option; off by default. */
video: boolean;
}
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;
sources: DraftSource[];
media: MediaToggleState;
}
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: "sources",
titleKey: "coursePlan.wizard.steps.sources",
descriptionKey: "coursePlan.wizard.steps.sourcesDesc",
render: ({ state, update }) => (
<SourcesStep
sources={state.sources}
onChange={(sources) => update({ sources })}
/>
),
},
{
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: "media",
titleKey: "coursePlan.wizard.steps.media",
descriptionKey: "coursePlan.wizard.steps.mediaDesc",
render: ({ state, update }) => (
<MediaStep
media={state.media}
onChange={(media) => update({ media })}
/>
),
},
{
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 || "—"}
/>
<ReviewRow
label={t("coursePlan.wizard.steps.sources")}
value={
state.sources.length
? t("coursePlan.wizard.review.sourcesCount", {
count: state.sources.length,
})
: t("coursePlan.wizard.review.noSources")
}
/>
<ReviewRow
label={t("coursePlan.wizard.steps.media")}
value={[
state.media.audio ? t("coursePlan.media.audio") : null,
state.media.image ? t("coursePlan.media.image") : null,
state.media.video ? t("coursePlan.media.video") : null,
]
.filter(Boolean)
.join(", ") || t("coursePlan.wizard.review.mediaNone")}
/>
<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: "",
sources: [],
media: { audio: true, image: true, video: false },
}}
onFinish={async (state) => {
const resp = 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,
});
const planId = resp?.data?.id;
if (planId && state.sources.length) {
// Library picks go through the dedicated bulk endpoint — one
// round-trip for the whole set, with server-side dedupe — so we
// collect their ids first and post them together. The remaining
// file/url/text drafts are posted individually as before.
const resourceIds = state.sources
.filter((s) => s.kind === "resource" && s.resourceId)
.map((s) => s.resourceId as number);
if (resourceIds.length) {
try {
await coursePlanService.attachResources(planId, resourceIds);
} catch (err) {
toast.error(
describeApiError(
err,
t("coursePlan.sources.libraryAttachFailed"),
),
);
}
}
// Best-effort upload — we keep going even if a single one fails so
// the user lands on the detail page with whatever did make it in.
for (const src of state.sources) {
try {
if (src.kind === "file" && src.file) {
await coursePlanService.uploadSource(planId, src.file, {
name: src.name || src.file.name,
});
} else if (src.kind === "url" && src.url) {
await coursePlanService.createSource(planId, {
kind: "url",
url: src.url,
name: src.name || src.url,
});
} else if (src.kind === "text" && src.text) {
await coursePlanService.createSource(planId, {
kind: "text",
inline_text: src.text,
name: src.name || t("coursePlan.sourceKind.text"),
});
}
// src.kind === "resource" was already handled in the bulk
// attach above.
} catch (err) {
toast.error(
describeApiError(err, t("coursePlan.sources.uploadFailed", {
name: src.name || src.kind,
})),
);
}
}
}
}}
/>
);
}
function genUid() {
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
}
function SourcesStep({
sources,
onChange,
}: {
sources: DraftSource[];
onChange: (next: DraftSource[]) => void;
}) {
const { t } = useTranslation();
const fileRef = useRef<HTMLInputElement>(null);
const [draftUrl, setDraftUrl] = useState("");
const [draftText, setDraftText] = useState("");
const [libraryOpen, setLibraryOpen] = useState(false);
// Resource ids already queued in this wizard session; passed to the
// picker so the same resource can't be added twice.
const queuedResourceIds = new Set(
sources
.filter((s) => s.kind === "resource" && s.resourceId)
.map((s) => s.resourceId as number),
);
const addFiles = (files: FileList | null) => {
if (!files || !files.length) return;
const additions: DraftSource[] = [];
for (const f of Array.from(files)) {
additions.push({
uid: genUid(),
kind: "file",
name: f.name,
file: f,
});
}
onChange([...sources, ...additions]);
};
const addUrl = () => {
const url = draftUrl.trim();
if (!url) return;
onChange([...sources, { uid: genUid(), kind: "url", name: url, url }]);
setDraftUrl("");
};
const addText = () => {
const text = draftText.trim();
if (!text) return;
onChange([
...sources,
{
uid: genUid(),
kind: "text",
name: text.slice(0, 60).replace(/\s+/g, " "),
text,
},
]);
setDraftText("");
};
const addLibraryResources = (picked: Resource[]) => {
if (!picked.length) return;
const additions: DraftSource[] = picked.map((r) => ({
uid: genUid(),
kind: "resource",
name: r.name,
resourceId: r.id,
resourceType: r.resource_type || r.type || "resource",
}));
onChange([...sources, ...additions]);
setLibraryOpen(false);
};
const remove = (uid: string) => onChange(sources.filter((s) => s.uid !== uid));
return (
<div className="space-y-5">
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-md border-dashed border-2 px-4 py-6 text-center bg-muted/20">
<FileText className="mx-auto h-6 w-6 text-muted-foreground" />
<p className="mt-2 text-sm font-medium">
{t("coursePlan.sources.dropTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("coursePlan.sources.dropHint")}
</p>
<div className="mt-3">
<input
ref={fileRef}
type="file"
multiple
className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.html,.htm,.rtf"
onChange={(e) => {
addFiles(e.currentTarget.files);
if (fileRef.current) fileRef.current.value = "";
}}
/>
<Button
variant="outline"
size="sm"
onClick={() => fileRef.current?.click()}
>
<Upload className="mr-1 h-4 w-4" />
{t("coursePlan.sources.uploadFiles")}
</Button>
</div>
</div>
<div className="rounded-md border-dashed border-2 px-4 py-6 text-center bg-muted/20">
<Library className="mx-auto h-6 w-6 text-muted-foreground" />
<p className="mt-2 text-sm font-medium">
{t("coursePlan.sources.libraryPickTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("coursePlan.sources.libraryPickHint")}
</p>
<div className="mt-3">
<Button
variant="outline"
size="sm"
onClick={() => setLibraryOpen(true)}
>
<Library className="mr-1 h-4 w-4" />
{t("coursePlan.sources.libraryPickButton")}
</Button>
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label>{t("coursePlan.sources.urlLabel")}</Label>
<div className="mt-1 flex gap-2">
<Input
value={draftUrl}
onChange={(e) => setDraftUrl(e.target.value)}
placeholder="https://example.com/article"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addUrl();
}
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={addUrl}
disabled={!draftUrl.trim()}
>
<Link2 className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<Label>{t("coursePlan.sources.textLabel")}</Label>
<div className="mt-1 flex gap-2">
<Textarea
rows={3}
value={draftText}
onChange={(e) => setDraftText(e.target.value)}
placeholder={t("coursePlan.sources.textPlaceholder")}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={addText}
disabled={!draftText.trim()}
className="self-start"
>
<FileText className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{sources.length > 0 && (
<div className="rounded-md border">
<div className="px-3 py-2 border-b bg-muted/30 text-xs uppercase tracking-wide text-muted-foreground">
{t("coursePlan.sources.collected", { count: sources.length })}
</div>
<ul className="divide-y">
{sources.map((s) => (
<li
key={s.uid}
className="flex items-center gap-2 px-3 py-2 text-sm"
>
<Badge variant="outline" className="capitalize text-[10px]">
{s.kind === "resource"
? s.resourceType || "library"
: s.kind}
</Badge>
{s.kind === "resource" && (
<Badge
variant="secondary"
className="gap-1 text-[10px] flex items-center"
>
<Library className="h-3 w-3" />
{t("coursePlan.sources.fromLibrary")}
</Badge>
)}
<span className="flex-1 truncate">{s.name}</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => remove(s.uid)}
aria-label={t("common.remove", "Remove")}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
</div>
)}
<LibraryPickerDialog
open={libraryOpen}
onOpenChange={setLibraryOpen}
alreadyLinkedIds={queuedResourceIds}
onConfirm={addLibraryResources}
/>
</div>
);
}
function MediaStep({
media,
onChange,
}: {
media: MediaToggleState;
onChange: (next: MediaToggleState) => void;
}) {
const { t } = useTranslation();
return (
<div className="grid gap-3 sm:grid-cols-3">
<MediaToggleCard
icon={Music}
title={t("coursePlan.media.audioTitle")}
description={t("coursePlan.media.audioDesc")}
checked={media.audio}
onToggle={(v) => onChange({ ...media, audio: v })}
/>
<MediaToggleCard
icon={ImageIcon}
title={t("coursePlan.media.imageTitle")}
description={t("coursePlan.media.imageDesc")}
checked={media.image}
onToggle={(v) => onChange({ ...media, image: v })}
/>
<MediaToggleCard
icon={Video}
title={t("coursePlan.media.videoTitle")}
description={t("coursePlan.media.videoDesc")}
checked={media.video}
onToggle={(v) => onChange({ ...media, video: v })}
/>
<div className="sm:col-span-3 text-xs text-muted-foreground flex items-center gap-2">
<Mic className="h-3.5 w-3.5" />
{t("coursePlan.media.hint")}
</div>
</div>
);
}
function MediaToggleCard({
icon: Icon,
title,
description,
checked,
onToggle,
}: {
icon: typeof Music;
title: string;
description: string;
checked: boolean;
onToggle: (v: boolean) => void;
}) {
return (
<button
type="button"
onClick={() => onToggle(!checked)}
className={[
"rounded-md border p-3 text-left transition-colors",
checked
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/40",
].join(" ")}
>
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-primary" />
<div className="font-medium text-sm">{title}</div>
</div>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
</button>
);
}
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>
);
}