change things

This commit is contained in:
rgrgogu
2026-08-03 13:31:51 +08:00
parent 6be0c29850
commit 8dfa54a731
10 changed files with 420 additions and 385 deletions
+125 -90
View File
@@ -3,12 +3,13 @@ import { useNavigate, useParams, useLocation } from "react-router-dom";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronLeft, ChevronRight, Check, FileText, ListChecks } from "lucide-react";
import { ChevronLeft, ChevronRight, Check, FileText, ListChecks, Link2, Plus } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext";
import { cn } from "@/lib/utils";
import api from "@/utils/api.util";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -16,6 +17,7 @@ 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";
import AttachUnitsDialog from "@/modules/admin/components/library/AttachUnitsDialog";
const schema = z.object({
title: z.string().min(1, "Title is required."),
@@ -42,8 +44,10 @@ export default function AddUnit() {
const backTarget = location.state?.returnTo ?? `/admin/courses/${courseId}/units`;
const [mode, setMode] = useState(null); // null | "create" — the Details/Requirements form only shows once "Create" is chosen
const [step, setStep] = useState(0);
const [requirements, setRequirements] = useState([]);
const [attachOpen, setAttachOpen] = useState(false);
const { register, trigger, getValues, formState: { errors, isDirty } } = useForm({
resolver: zodResolver(schema),
@@ -89,7 +93,7 @@ export default function AddUnit() {
type="button"
variant="ghost"
size="icon"
onClick={() => (step === 0 ? navigate(backTarget) : setStep(0))}
onClick={() => (mode === "create" && step > 0 ? setStep(0) : navigate(backTarget))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
@@ -101,105 +105,136 @@ export default function AddUnit() {
</div>
</div>
{/* Stepper */}
<div className="flex items-center gap-0">
{STEPS.map((s, i) => {
const Icon = s.icon;
const isActive = step === i;
const isDone = step > i;
{/* Select existing vs. create new */}
<div className="flex items-start justify-between gap-3 pb-3 border-b border-border">
<p className="text-xs text-muted-foreground">
Attach an existing library unit to reuse its content, or create a new one from scratch.
</p>
<div className="flex items-center gap-2 shrink-0">
<Button type="button" variant="outline" size="sm" onClick={() => setAttachOpen(true)}>
<Link2 className="h-3.5 w-3.5 mr-1.5" /> Select
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setMode("create")}>
<Plus className="h-3.5 w-3.5 mr-1.5" /> Create
</Button>
</div>
</div>
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" />}
{mode === "create" && (
<>
{/* Stepper */}
<div className="flex items-center gap-0">
{STEPS.map((s, i) => {
const Icon = s.icon;
const isActive = step === i;
const isDone = step > i;
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>
<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>
{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>
)}
{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 className="flex justify-between gap-3">
<Button
type="button"
variant="outline"
onClick={() => (step === 0 ? navigate(backTarget) : setStep(0))}
disabled={loading}
>
<ChevronLeft className="h-4 w-4 mr-1" />
{step === 0 ? "Cancel" : "Back"}
</Button>
{step === 0 ? (
<Button type="button" onClick={handleNext}>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
// 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>
)}
</div>
<div className="flex justify-between gap-3">
<Button
type="button"
variant="outline"
onClick={() => (step === 0 ? navigate(backTarget) : setStep(0))}
disabled={loading}
>
<ChevronLeft className="h-4 w-4 mr-1" />
{step === 0 ? "Cancel" : "Back"}
</Button>
{step === 0 ? (
<Button type="button" onClick={handleNext}>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
// 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>
</>
)}
</div>
</div>
{unsavedChangesDialog}
<AttachUnitsDialog
open={attachOpen}
onOpenChange={setAttachOpen}
attachedUnitIds={course?.units?.map((u) => u.unit_id) ?? []}
onAttach={async (unitIds) => {
await api.post(`/admin/courses/${courseId}/units/attach`, { unit_ids: unitIds });
bypassOnce();
navigate(backTarget);
}}
loading={loading}
/>
</section>
);
}