Files
starr-philproperties/src/modules/admin/pages/courses/units/AddUnit.jsx
T
2026-08-05 04:32:29 +08:00

278 lines
12 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import { useForm, useWatch } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
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";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
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."),
description: z.string().optional(),
order: z.coerce.number().min(0).default(0),
subscription: z.string().optional(),
});
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>;
}
export default function AddUnit() {
const navigate = useNavigate();
const location = useLocation();
const { courseId } = useParams();
const { createUnit, fetchCourse, course, loading, syncUnitRequirements } = useCourses();
const { user } = useAuth();
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 [tierCategories, setTierCategories] = useState([]);
useEffect(() => {
api.get("/admin/tiers/categories")
.then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active)))
.catch(() => {});
}, []);
const { register, trigger, getValues, control, setValue, formState: { errors, isDirty } } = useForm({
resolver: zodResolver(schema),
defaultValues: { title: "", description: "", order: 0, subscription: "free" },
});
const watchedSubscr = useWatch({ control, name: "subscription" });
const defaultTierSlug = tierCategories.find((c) => c.is_default)?.slug || "free";
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty || requirements.length > 0);
useEffect(() => {
fetchCourse(courseId);
}, [courseId]);
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 values = getValues();
const result = await createUnit(courseId, { ...values, subscription: values.subscription || defaultTierSlug, 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(backTarget);
};
return (
<section className="bg-muted h-full">
<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 space-y-6">
<div className="flex items-center gap-3">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => (mode === "create" && step > 0 ? setStep(0) : navigate(backTarget))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Create Unit</h1>
<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>
{/* 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>
{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>
<div className="space-y-1.5">
<Label>Subscription</Label>
<Select
value={watchedSubscr || defaultTierSlug}
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="No tier gate" />
</SelectTrigger>
<SelectContent>
{tierCategories.map((c) => (
<SelectItem key={c.slug} value={c.slug}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Optional. Gates this unit directly, independent of the course it's attached to.
</p>
</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>
)}
</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>
);
}