fix some issues

This commit is contained in:
rgrgogu
2026-07-19 14:59:46 +08:00
parent bd7200a9cf
commit 51133a51a5
6 changed files with 121 additions and 43 deletions
@@ -1,22 +1,27 @@
// modules/admin/components/courses/CreateUnitDialog.jsx // modules/admin/components/courses/CreateUnitDialog.jsx
// Lightweight "create a brand-new unit and attach it to this course" dialog — // Lightweight "create a brand-new unit and attach it to this course" dialog —
// the create-new counterpart to AttachUnitsDialog's attach-existing flow. // the create-new counterpart to AttachUnitsDialog's attach-existing flow.
// Two steps (Details -> Completion Requirements) to match the other two
// create-unit surfaces (AddUnit.jsx, AddLibraryUnit.jsx) — same capability,
// just condensed into a dialog since this one runs inline in a wizard.
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronLeft } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext"; import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import DraftRequirementsEditor from "./DraftRequirementsEditor";
const schema = z.object({ const schema = z.object({
title: z.string().min(1, "Title is required."), title: z.string().min(1, "Title is required."),
@@ -25,19 +30,33 @@ const schema = z.object({
}); });
export default function CreateUnitDialog({ open, onOpenChange, courseId, nextOrder = 0, onCreated, draftMode = false }) { export default function CreateUnitDialog({ open, onOpenChange, courseId, nextOrder = 0, onCreated, draftMode = false }) {
const { createUnit, loading } = useCourses(); const { createUnit, syncUnitRequirements, loading } = useCourses();
const { user } = useAuth(); const { user } = useAuth();
const { register, handleSubmit, reset, formState: { errors } } = useForm({ const [step, setStep] = useState(0);
const [requirements, setRequirements] = useState([]);
const { register, handleSubmit, trigger, reset, formState: { errors } } = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { title: "", description: "", order: nextOrder }, defaultValues: { title: "", description: "", order: nextOrder },
}); });
useEffect(() => { useEffect(() => {
if (open) reset({ title: "", description: "", order: nextOrder }); if (open) {
reset({ title: "", description: "", order: nextOrder });
setStep(0);
setRequirements([]);
}
}, [open, nextOrder, reset]); }, [open, nextOrder, reset]);
const handleNext = async () => {
const valid = await trigger();
if (valid) setStep(1);
};
const onValid = async (values) => { const onValid = async (values) => {
const clean = requirements.map(({ _key, ...r }) => r);
if (draftMode) { if (draftMode) {
onCreated?.({ onCreated?.({
unit_id: null, unit_id: null,
@@ -46,6 +65,7 @@ export default function CreateUnitDialog({ open, onOpenChange, courseId, nextOrd
description: values.description, description: values.description,
order_index: values.order, order_index: values.order,
lessons: [], lessons: [],
requirements: clean,
}); });
onOpenChange(false); onOpenChange(false);
return; return;
@@ -54,6 +74,11 @@ export default function CreateUnitDialog({ open, onOpenChange, courseId, nextOrd
const result = await createUnit(courseId, { ...values, createdBy: user?.user_id }); const result = await createUnit(courseId, { ...values, createdBy: user?.user_id });
const unit = result?.data?.data ?? null; const unit = result?.data?.data ?? null;
if (!unit) return; if (!unit) return;
if (clean.length > 0) {
await syncUnitRequirements(courseId, unit.unit_id, clean);
}
onCreated?.(unit); onCreated?.(unit);
onOpenChange(false); onOpenChange(false);
}; };
@@ -63,35 +88,63 @@ export default function CreateUnitDialog({ open, onOpenChange, courseId, nextOrd
<DialogContent className="sm:max-w-[440px]"> <DialogContent className="sm:max-w-[440px]">
<DialogHeader> <DialogHeader>
<DialogTitle>New Unit</DialogTitle> <DialogTitle>New Unit</DialogTitle>
<DialogDescription>
Creates a new unit in the shared Units Library and attaches it to this course.
</DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit(onValid)} className="space-y-4"> <form onSubmit={handleSubmit(onValid)} className="space-y-4">
<div className="space-y-1.5"> {step === 0 && (
<Label htmlFor="unit_title"> <>
Title <span className="text-destructive">*</span> <div className="space-y-1.5">
</Label> <Label htmlFor="unit_title">
<Input id="unit_title" placeholder="e.g. Getting Started" {...register("title")} /> Title <span className="text-destructive">*</span>
{errors.title && <p className="text-sm text-destructive">{errors.title.message}</p>} </Label>
</div> <Input id="unit_title" placeholder="e.g. Getting Started" {...register("title")} />
{errors.title && <p className="text-sm text-destructive">{errors.title.message}</p>}
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="unit_description">Description</Label> <Label htmlFor="unit_description">Description</Label>
<Textarea id="unit_description" placeholder="Optional unit description" rows={3} {...register("description")} /> <Textarea id="unit_description" placeholder="Optional unit description" rows={3} {...register("description")} />
</div> </div>
<div className="space-y-1.5 max-w-[120px]"> <div className="space-y-1.5 max-w-[120px]">
<Label htmlFor="unit_order">Order</Label> <Label htmlFor="unit_order">Order</Label>
<Input id="unit_order" type="number" min={0} {...register("order")} /> <Input id="unit_order" type="number" min={0} {...register("order")} />
</div> </div>
</>
)}
{step === 1 && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Configure how learners complete this unit — optional, sensible defaults apply automatically.
</p>
<DraftRequirementsEditor entityType="unit" items={requirements} onChange={setRequirements} />
</div>
)}
<DialogFooter> <DialogFooter>
<DialogClose asChild> {step === 0 ? (
<Button type="button" variant="outline" disabled={loading}>Cancel</Button> <>
</DialogClose> <DialogClose asChild>
<Button type="submit" disabled={loading}> <Button type="button" variant="outline" disabled={loading}>Cancel</Button>
{loading && <Spinner className="h-4 w-4 mr-2" />} </DialogClose>
Create Unit <Button type="button" onClick={handleNext}>Next</Button>
</Button> </>
) : (
<>
<Button type="button" variant="outline" onClick={() => setStep(0)} disabled={loading}>
<ChevronLeft className="h-4 w-4 mr-1" />
Back
</Button>
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Unit
</Button>
</>
)}
</DialogFooter> </DialogFooter>
</form> </form>
</DialogContent> </DialogContent>
@@ -18,7 +18,7 @@ import { buildRowActions } from "../../config/courses/units/rowActions.config";
import { getTimestamp } from "@/utils/timestamp.util"; import { getTimestamp } from "@/utils/timestamp.util";
export default function UnitsTable({ courseId }) { export default function UnitsTable({ courseId, returnTo }) {
const [archiveTarget, setArchiveTarget] = useState(null); const [archiveTarget, setArchiveTarget] = useState(null);
const [archiveIds, setArchiveIds] = useState(null); const [archiveIds, setArchiveIds] = useState(null);
const [attachOpen, setAttachOpen] = useState(false); const [attachOpen, setAttachOpen] = useState(false);
@@ -68,6 +68,7 @@ export default function UnitsTable({ courseId }) {
exportConfig, exportConfig,
navigate, navigate,
courseId, courseId,
returnTo,
getFilters: () => tableRefsRef.current.getFilters(), getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(), getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance, getTableInstance: () => tableRefsRef.current.tableInstance,
@@ -7,6 +7,7 @@ export function buildToolbarActions({
exportConfig, exportConfig,
navigate, navigate,
courseId, courseId,
returnTo,
getFilters, getFilters,
getSort, getSort,
getTableInstance, getTableInstance,
@@ -42,7 +43,7 @@ export function buildToolbarActions({
label: "New Unit", label: "New Unit",
icon: <Plus className="h-3.5 w-3.5" />, icon: <Plus className="h-3.5 w-3.5" />,
variant: "default", variant: "default",
onClick: () => navigate(`/admin/courses/${courseId}/units/add`), onClick: () => navigate(`/admin/courses/${courseId}/units/add`, returnTo ? { state: { returnTo } } : undefined),
}, },
{ {
key: "archived-units", key: "archived-units",
@@ -271,6 +271,7 @@ export default function AddCourse() {
unit_id: u.unit_id, unit_id: u.unit_id,
title: u.title, title: u.title,
description: u.description, description: u.description,
requirements: u.requirements ?? [],
lessons: u.lessons.map((l) => ({ lessons: u.lessons.map((l) => ({
lesson_id: l.lesson_id, lesson_id: l.lesson_id,
title: l.title, title: l.title,
+26 -9
View File
@@ -1,5 +1,5 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useForm, useFieldArray, useWatch } from "react-hook-form"; import { useForm, useFieldArray, useWatch } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -14,6 +14,7 @@ import { TIER_COLOR_OPTIONS } from "@/utils/tierColors";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet"; import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import CourseInstructorPicker from "@/modules/admin/components/courses/CourseInstructorPicker"; import CourseInstructorPicker from "@/modules/admin/components/courses/CourseInstructorPicker";
import UnitsTable from "@/modules/admin/components/courses/UnitsTable";
import CoursePrerequisiteBuilder from "@/modules/admin/components/courses/CoursePrerequisiteBuilder"; import CoursePrerequisiteBuilder from "@/modules/admin/components/courses/CoursePrerequisiteBuilder";
import { useCategories } from "@/contexts/AdminCategoriesContext"; import { useCategories } from "@/contexts/AdminCategoriesContext";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
@@ -61,6 +62,7 @@ const schema = z.object({
const STEPS = [ const STEPS = [
{ label: "Basic Info", description: "Title, level & objectives" }, { label: "Basic Info", description: "Title, level & objectives" },
{ label: "Units", description: "Roadmap for this course" },
{ label: "Categories", description: "Tags & instructors" }, { label: "Categories", description: "Tags & instructors" },
{ label: "Rewards", description: "Badge & achievements" }, { label: "Rewards", description: "Badge & achievements" },
{ label: "Requirements", description: "Prerequisites & completion" }, { label: "Requirements", description: "Prerequisites & completion" },
@@ -157,7 +159,12 @@ export default function EditCourse() {
const { categories: allCategories, fetchCategories } = useCategories(); const { categories: allCategories, fetchCategories } = useCategories();
const { user } = useAuth(); const { user } = useAuth();
const [currentStep, setCurrentStep] = useState(0); const [searchParams] = useSearchParams();
const initialStep = Math.min(
Math.max(Number(searchParams.get("step")) || 0, 0),
STEPS.length - 1,
);
const [currentStep, setCurrentStep] = useState(initialStep);
// ─── Tier categories ────────────────────────────────────────────────────── // ─── Tier categories ──────────────────────────────────────────────────────
const [tierCategories, setTierCategories] = useState([]); const [tierCategories, setTierCategories] = useState([]);
@@ -751,8 +758,18 @@ export default function EditCourse() {
</> </>
)} )}
{/* ── Step 1: Categories & Instructors ── */} {/* ── Step 1: Units ── */}
{currentStep === 1 && ( {currentStep === 1 && (
<SectionCard
title="Units"
description="This course's roadmap. Units live independently in the shared Units Library — creating one here attaches it automatically, and attaching an existing one reuses it without copying."
>
<UnitsTable courseId={courseId} returnTo={`/admin/courses/${courseId}/edit?step=1`} />
</SectionCard>
)}
{/* ── Step 2: Categories & Instructors ── */}
{currentStep === 2 && (
<> <>
<SectionCard title="Categories" description="Assign this course to one or more categories for browsing."> <SectionCard title="Categories" description="Assign this course to one or more categories for browsing.">
{selectedCategoryIds.length > 0 && ( {selectedCategoryIds.length > 0 && (
@@ -900,8 +917,8 @@ export default function EditCourse() {
</> </>
)} )}
{/* ── Step 2: Rewards ── */} {/* ── Step 3: Rewards ── */}
{currentStep === 2 && ( {currentStep === 3 && (
<SectionCard <SectionCard
title="Rewards" title="Rewards"
description="Badge and achievements awarded to learners who complete this course." description="Badge and achievements awarded to learners who complete this course."
@@ -1109,8 +1126,8 @@ export default function EditCourse() {
</SectionCard> </SectionCard>
)} )}
{/* ── Step 3: Prerequisites & Completion Requirements ── */} {/* ── Step 4: Prerequisites & Completion Requirements ── */}
{currentStep === 3 && ( {currentStep === 4 && (
<> <>
<SectionCard <SectionCard
title="Prerequisites" title="Prerequisites"
@@ -1153,8 +1170,8 @@ export default function EditCourse() {
</> </>
)} )}
{/* ── Step 4: Pricing ── */} {/* ── Step 5: Pricing ── */}
{currentStep === 4 && ( {currentStep === 5 && (
<SectionCard <SectionCard
title="Product Listing" title="Product Listing"
description="Allow learners to purchase this course individually via PayPal." description="Allow learners to purchase this course individually via PayPal."
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams, useLocation } from "react-router-dom";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -35,10 +35,13 @@ function FieldError({ message }) {
export default function AddUnit() { export default function AddUnit() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { courseId } = useParams(); const { courseId } = useParams();
const { createUnit, fetchCourse, course, loading, syncUnitRequirements } = useCourses(); const { createUnit, fetchCourse, course, loading, syncUnitRequirements } = useCourses();
const { user } = useAuth(); const { user } = useAuth();
const backTarget = location.state?.returnTo ?? `/admin/courses/${courseId}/units`;
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [requirements, setRequirements] = useState([]); const [requirements, setRequirements] = useState([]);
@@ -72,7 +75,7 @@ export default function AddUnit() {
} }
bypassOnce(); bypassOnce();
navigate(`/admin/courses/${courseId}/units`); navigate(backTarget);
}; };
return ( return (
@@ -86,13 +89,15 @@ export default function AddUnit() {
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => (step === 0 ? navigate(`/admin/courses/${courseId}/units`) : setStep(0))} onClick={() => (step === 0 ? navigate(backTarget) : setStep(0))}
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
<div> <div>
<h1 className="text-xl font-semibold">Create Unit</h1> <h1 className="text-xl font-semibold">Create Unit</h1>
<p className="text-sm text-muted-foreground">Add a new unit to this course.</p> <p className="text-sm text-muted-foreground">
Creates a new unit in the shared Units Library and attaches it to this course.
</p>
</div> </div>
</div> </div>
@@ -169,7 +174,7 @@ export default function AddUnit() {
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => (step === 0 ? navigate(`/admin/courses/${courseId}/units`) : setStep(0))} onClick={() => (step === 0 ? navigate(backTarget) : setStep(0))}
disabled={loading} disabled={loading}
> >
<ChevronLeft className="h-4 w-4 mr-1" /> <ChevronLeft className="h-4 w-4 mr-1" />