added new requirements and fix UI bugs

This commit is contained in:
rgrgogu
2026-07-15 16:26:59 +08:00
parent 95987edd22
commit 5a9c0390e6
27 changed files with 1423 additions and 232 deletions
+128 -35
View File
@@ -1,20 +1,21 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, House } from "lucide-react";
import { ChevronLeft, ChevronRight, Check, FileText, ListChecks } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import DraftRequirementsEditor from "@/modules/admin/components/courses/DraftRequirementsEditor";
const schema = z.object({
title: z.string().min(1, "Title is required."),
@@ -22,6 +23,11 @@ const schema = z.object({
order: z.coerce.number().min(0).default(0),
});
const STEPS = [
{ id: 0, label: "Details", icon: FileText },
{ id: 1, label: "Completion Requirements", icon: ListChecks },
];
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
@@ -30,23 +36,41 @@ function FieldError({ message }) {
export default function AddUnit() {
const navigate = useNavigate();
const { courseId } = useParams();
const { createUnit, fetchCourse, course, loading } = useCourses();
const { createUnit, fetchCourse, course, loading, syncUnitRequirements } = useCourses();
const { user } = useAuth();
const { register, handleSubmit, formState: { errors, isDirty } } = useForm({
const [step, setStep] = useState(0);
const [requirements, setRequirements] = useState([]);
const { register, trigger, getValues, formState: { errors, isDirty } } = useForm({
resolver: zodResolver(schema),
defaultValues: { title: "", description: "", order: 0 },
});
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty || requirements.length > 0);
useEffect(() => {
fetchCourse(courseId);
}, [courseId]);
const onSubmit = async (data) => {
const result = await createUnit(courseId, { ...data, createdBy: user?.user_id });
if (!result) return;
const handleNext = async () => {
const valid = await trigger();
if (valid) setStep(1);
};
// The one and only persistence point — nothing is created until this final
// click at Requirements (the last step), which is purely a draft form
// until now.
const handleCreate = async () => {
const result = await createUnit(courseId, { ...getValues(), createdBy: user?.user_id });
const newUnitId = result?.data?.data?.unit_id;
if (!newUnitId) return;
if (requirements.length > 0) {
const clean = requirements.map(({ _key, ...r }) => r);
await syncUnitRequirements(courseId, newUnitId, clean);
}
bypassOnce();
navigate(`/admin/courses/${courseId}/units`);
};
@@ -56,10 +80,15 @@ export default function AddUnit() {
<PageMeta title={course ? `Add Unit – ${course.title} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/courses/${courseId}/units`)}>
<ArrowLeft className="h-4 w-4" />
<div className="w-full max-w-2xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => (step === 0 ? navigate(`/admin/courses/${courseId}/units`) : setStep(0))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Create Unit</h1>
@@ -67,41 +96,105 @@ export default function AddUnit() {
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="rounded-lg border bg-card p-6 space-y-5">
{/* Stepper */}
<div className="flex items-center gap-0">
{STEPS.map((s, i) => {
const Icon = s.icon;
const isActive = step === i;
const isDone = step > i;
<div className="space-y-1.5">
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
<Input id="title" placeholder="Unit title" {...register("title")} />
<FieldError message={errors.title?.message} />
return (
<div key={s.id} className="flex items-center flex-1 last:flex-none">
<div className="flex flex-col items-center gap-1">
<div className={cn(
"h-8 w-8 rounded-full flex items-center justify-center border transition-colors",
isDone && "bg-emerald-600 border-emerald-600 text-white",
isActive && "border-primary bg-primary text-primary-foreground",
!isActive && !isDone && "border-border bg-background text-muted-foreground"
)}>
{isDone ? <Check className="h-4 w-4" /> : <Icon className="h-4 w-4" />}
</div>
<span className={cn(
"text-[11px] font-medium whitespace-nowrap hidden sm:block",
isActive ? "text-foreground" : "text-muted-foreground",
isDone ? "text-emerald-600" : ""
)}>
{s.label}
</span>
</div>
{i < STEPS.length - 1 && (
<div className={cn(
"flex-1 h-px mx-2 mb-4 transition-colors",
step > i ? "bg-emerald-600" : "bg-border"
)} />
)}
</div>
);
})}
</div>
{/* Step content */}
<div className="rounded-lg border bg-card p-6 space-y-5">
{step === 0 && (
<div className="space-y-5">
<div className="space-y-1.5">
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
<Input id="title" placeholder="Unit title" {...register("title")} />
<FieldError message={errors.title?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
</div>
<div className="space-y-1.5 max-w-[120px]">
<Label htmlFor="order">Order</Label>
<Input id="order" type="number" min={0} {...register("order")} />
</div>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
{step === 1 && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Configure how learners complete this unit — optional, sensible defaults apply automatically. This is created together with the rest of the unit when you finish.
</p>
<DraftRequirementsEditor entityType="unit" items={requirements} onChange={setRequirements} />
</div>
)}
</div>
<div className="space-y-1.5 max-w-[120px]">
<Label htmlFor="order">Order</Label>
<Input id="order" type="number" min={0} {...register("order")} />
</div>
<div className="flex justify-between gap-3">
<Button
type="button"
variant="outline"
onClick={() => (step === 0 ? navigate(`/admin/courses/${courseId}/units`) : setStep(0))}
disabled={loading}
>
<ChevronLeft className="h-4 w-4 mr-1" />
{step === 0 ? "Cancel" : "Back"}
</Button>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/units`)} disabled={loading}>
Cancel
{step === 0 ? (
<Button type="button" onClick={handleNext}>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
<Button type="submit" disabled={loading}>
) : (
// Only the true final step (Requirements) actually persists
// anything — the unit and any draft requirements are created
// together in one shot here.
<Button type="button" onClick={handleCreate} disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Unit
</Button>
</div>
</form>
)}
</div>
</div>
</div>
{unsavedChangesDialog}
</section>
);
}
}