course,tasklist,task and completed validation

Signed-off-by: rgrgogu <obsequio.rus@gmail.com>
This commit is contained in:
rgrgogu
2026-07-17 13:04:40 +08:00
parent 5b9f174718
commit 2e9ab5786c
22 changed files with 1345 additions and 117 deletions
@@ -0,0 +1,217 @@
import { useState } from 'react';
import { Plus, Trash2, GripVertical, ChevronsUpDown, BookOpen, Layers, FileText, Link2, Unlink } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '@/components/ui/command';
// ─── Prerequisite type config ──────────────────────────────────────────────────
// idKey is the numeric FK field each flat list carries (course_id/unit_id/
// lesson_id) — CoursePrerequisite.ref_id is a BIGINT FK, not a uuid, so the
// picker must select on the numeric id even though the list is keyed/searched
// by uuid for React purposes.
const REF_TYPES = [
{ value: 'course', label: 'Course', icon: BookOpen, idKey: 'course_id' },
{ value: 'unit', label: 'Unit', icon: Layers, idKey: 'unit_id' },
{ value: 'lesson', label: 'Lesson', icon: FileText, idKey: 'lesson_id' },
];
const TYPE_MAP = Object.fromEntries(REF_TYPES.map((t) => [t.value, t]));
// ─── Course binding indicator (units/lessons may sit under 0..N courses) ──────
function BindingLine({ courses = [] }) {
if (!courses.length) {
return (
<span className="flex items-center gap-1 text-xs italic text-muted-foreground truncate">
<Unlink className="size-3 shrink-0" />
Standalone
</span>
);
}
return (
<span className="flex items-center gap-1 text-xs text-muted-foreground truncate">
<Link2 className="size-3 shrink-0" />
<span className="truncate">{courses.map((c) => c.title).join(', ')}</span>
</span>
);
}
// ─── Custom content picker — own Dialog rather than an anchored Popover ───────
function ContentPicker({ value, options, idKey, placeholder = 'Select…', dialogTitle, onSelect, renderTrigger, renderItem }) {
const [open, setOpen] = useState(false);
const selected = options.find((o) => String(o[idKey]) === String(value));
const searchValue = (o) => {
const courseNames = (o.courses ?? []).map((c) => c.title).join(' ');
return `${o.title ?? ''} ${courseNames}`.trim() || String(o[idKey]);
};
return (
<>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className="h-auto min-h-8 w-full justify-between text-sm font-normal py-1.5 px-3"
onClick={() => setOpen(true)}
>
{selected
? renderTrigger(selected)
: <span className="text-muted-foreground">{placeholder}</span>}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="top-[15%] translate-y-0 flex flex-col max-h-[70vh] overflow-hidden rounded-xl! p-0 gap-0 sm:max-w-md">
<DialogHeader className="px-4 pt-4 pb-3 border-b pr-10">
<DialogTitle className="text-sm">{dialogTitle ?? placeholder}</DialogTitle>
</DialogHeader>
<Command className="flex-1 min-h-0 rounded-none! bg-transparent p-0" loop>
<CommandInput placeholder="Search…" />
<CommandList className="max-h-[50vh]">
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((o) => (
<CommandItem
key={o[idKey]}
value={searchValue(o)}
onSelect={() => { onSelect(o); setOpen(false); }}
className="p-0"
>
{renderItem(o)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</DialogContent>
</Dialog>
</>
);
}
function createPrereq(type = 'course') {
return { _key: crypto.randomUUID(), ref_type: type, ref_id: '', title: '' };
}
// ─── CoursePrerequisiteBuilder ─────────────────────────────────────────────────
// value: [{ prereq_id?, ref_type, ref_id, title? }]
export default function CoursePrerequisiteBuilder({ value = [], onChange, courses = [], units = [], lessons = [], excludeCourseId }) {
const [items, setItems] = useState(
value.length > 0
? value.map((v) => ({ _key: crypto.randomUUID(), ...v }))
: []
);
const filteredCourses = courses.filter((c) => String(c.course_id) !== String(excludeCourseId));
const optionsFor = { course: filteredCourses, unit: units, lesson: lessons };
const emit = (next) => {
setItems(next);
onChange?.(next.map(({ _key, ...r }) => r));
};
const addItem = () => emit([...items, createPrereq('course')]);
const removeItem = (key) => emit(items.filter((i) => i._key !== key));
const updateItem = (key, patch) =>
emit(items.map((i) => (i._key === key ? { ...i, ...patch } : i)));
return (
<div className="space-y-3">
{items.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-6 border border-dashed rounded-lg">
No prerequisites added. Learners can start this course freely.
</p>
)}
{items.map((item, idx) => {
const typeDef = TYPE_MAP[item.ref_type];
const Icon = typeDef?.icon ?? BookOpen;
const options = optionsFor[item.ref_type] ?? [];
return (
<Card key={item._key}>
<CardContent className="pt-4 pb-4 space-y-3">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0" />
<Badge variant="outline" className="text-xs gap-1 shrink-0">
<Icon className="h-3 w-3" />
{idx + 1}
</Badge>
<Select
value={item.ref_type}
onValueChange={(v) => updateItem(item._key, { ref_type: v, ref_id: '', title: '' })}
>
<SelectTrigger className="h-8 text-sm w-32 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REF_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
<span className="flex items-center gap-2">
<t.icon className="h-3.5 w-3.5" />
{t.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex-1 min-w-0">
<ContentPicker
value={item.ref_id}
options={options}
idKey={typeDef.idKey}
placeholder={`Select a ${typeDef.label.toLowerCase()}`}
dialogTitle={`Select a ${typeDef.label.toLowerCase()}`}
onSelect={(o) => updateItem(item._key, { ref_id: o[typeDef.idKey], title: o.title })}
renderTrigger={(o) => (
<div className="flex items-center gap-2 min-w-0 flex-1">
{item.ref_type !== 'course' && (
<div className="flex flex-col items-start flex-1 min-w-0">
<BindingLine courses={o.courses} />
<span className="text-sm leading-tight truncate">{o.title}</span>
</div>
)}
{item.ref_type === 'course' && (
<span className="flex-1 truncate text-sm">{o.title}</span>
)}
</div>
)}
renderItem={(o) => (
<div className="flex items-center gap-2 px-3 py-2 w-full">
<div className="flex flex-col flex-1 min-w-0">
<span className="text-sm leading-tight truncate">{o.title}</span>
{item.ref_type !== 'course' && <BindingLine courses={o.courses} />}
</div>
</div>
)}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-destructive hover:text-destructive"
onClick={() => removeItem(item._key)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
);
})}
<Button type="button" variant="outline" size="sm" className="w-full gap-2" onClick={addItem}>
<Plus className="h-4 w-4" />
Add Prerequisite
</Button>
</div>
);
}
@@ -135,11 +135,12 @@ export function PreviewVideo({ url, thumb }) {
// AudioBlock themselves) attaches the block's own id/type to each call, since a lesson
// can have several blocks of the same type and watch_video/listen_audio need to know
// which specific one just reported progress.
export function PreviewBlock({ block, onWatchProgress }) {
export function PreviewBlock({ block, onWatchProgress, resumeMap, antiSkipEnabled }) {
const { id, type, content } = block;
const withBlockMeta = onWatchProgress
? (percent) => onWatchProgress(percent, { blockId: id, blockType: type })
: undefined;
const resumePercent = resumeMap?.[id];
switch (type) {
case "text":
@@ -149,11 +150,11 @@ export function PreviewBlock({ block, onWatchProgress }) {
case "text-image":
return <TextImageBlock blockId={id} content={content} readOnly />;
case "video":
return <VideoBlock content={content} readOnly onWatchProgress={withBlockMeta} />;
return <VideoBlock content={content} readOnly onWatchProgress={withBlockMeta} resumePercent={resumePercent} antiSkipEnabled={antiSkipEnabled} />;
case "text-video":
return <TextVideoBlock blockId={id} content={content} readOnly onWatchProgress={withBlockMeta} />;
return <TextVideoBlock blockId={id} content={content} readOnly onWatchProgress={withBlockMeta} resumePercent={resumePercent} antiSkipEnabled={antiSkipEnabled} />;
case "audio":
return <AudioBlock content={content} onWatchProgress={withBlockMeta} />;
return <AudioBlock content={content} onWatchProgress={withBlockMeta} resumePercent={resumePercent} antiSkipEnabled={antiSkipEnabled} />;
case "code":
return <CodeBlock content={content} />;
case "markdown":
@@ -167,7 +168,13 @@ export function PreviewBlock({ block, onWatchProgress }) {
// PhotoProvider wraps ALL blocks so images across the whole lesson share
// one lightbox session — users can swipe between them naturally.
// Only these completion-requirement types gate video/audio behind the anti-skip
// seek-cap — everything else (or no requirement configured) allows free seeking.
const WATCH_TYPE_REQUIREMENTS = ["watch_percent", "watch_video", "listen_audio"];
export function PreviewContent({ lesson, blocks, empty = "No content yet.", showHeader = true, onWatchProgress }) {
const resumeMap = lesson?.resume_positions;
const antiSkipEnabled = WATCH_TYPE_REQUIREMENTS.includes(lesson?.completion?.type);
return (
<PhotoProvider
speed={() => 300}
@@ -194,7 +201,7 @@ export function PreviewContent({ lesson, blocks, empty = "No content yet.", show
<div className="space-y-4 sm:space-y-5">
{blocks.map((block) => (
<div key={block.id}>
<PreviewBlock block={block} onWatchProgress={onWatchProgress} />
<PreviewBlock block={block} onWatchProgress={onWatchProgress} resumeMap={resumeMap} antiSkipEnabled={antiSkipEnabled} />
</div>
))}
</div>
+114 -4
View File
@@ -24,6 +24,7 @@ import {
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
import RoadmapBuilder from "@/modules/admin/components/courses/RoadmapBuilder";
import AchievementsBuilder from "@/modules/admin/components/courses/AchievementsBuilder";
import CoursePrerequisiteBuilder from "@/modules/admin/components/courses/CoursePrerequisiteBuilder";
import { TIER_COLOR_OPTIONS } from "@/utils/tierColors";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
@@ -40,6 +41,7 @@ const schema = z.object({
status: z.enum(["draft", "published", "unpublished"]).default("draft"),
objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") }))
.min(1, "At least one learning objective is required."),
roles: z.array(z.object({ text: z.string().min(1, "Role cannot be empty.") })).default([]),
achievement_keys: z.array(z.string()).max(1).default([]),
});
@@ -47,7 +49,7 @@ const schema = z.object({
const STEPS = [
{ label: "Basic Info", description: "Title, level & objectives" },
{ label: "Roadmap", description: "Units & lessons" },
{ label: "Roadmap", description: "Units, lessons & prerequisites" },
{ label: "Rewards", description: "Badge & achievements" },
{ label: "Review", description: "Confirm & create" },
];
@@ -127,11 +129,12 @@ function StepIndicator({ steps, current, onStepClick }) {
export default function AddCourse() {
const navigate = useNavigate();
const { createCourseFull, loading } = useCourses();
const { createCourseFull, loading, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat } = useCourses();
const { user } = useAuth();
const [currentStep, setCurrentStep] = useState(0);
const [roadmapUnits, setRoadmapUnits] = useState([]);
const [prerequisites, setPrerequisites] = useState([]);
const [tierCategories, setTierCategories] = useState([]);
useEffect(() => {
api.get("/admin/tiers/categories")
@@ -139,6 +142,18 @@ export default function AddCourse() {
.catch(() => {});
}, []);
const [flatCourses, setFlatCourses] = useState([]);
const [flatUnits, setFlatUnits] = useState([]);
const [flatLessons, setFlatLessons] = useState([]);
useEffect(() => {
(async () => {
const [c, u, l] = await Promise.all([fetchCoursesFlat(), fetchUnitsFlat(), fetchLessonsFlat()]);
setFlatCourses(c ?? []);
setFlatUnits(u ?? []);
setFlatLessons(l ?? []);
})();
}, []);
const [badgeColor, setBadgeColor] = useState("purple");
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
const [badgeAssetId, setBadgeAssetId] = useState(null);
@@ -163,12 +178,15 @@ export default function AddCourse() {
subscription: "free",
status: "draft",
objectives: [],
roles: [],
achievement_keys: [],
},
});
const { fields: objectiveFields, append: appendObjective, remove: removeObjective } =
useFieldArray({ control, name: "objectives" });
const { fields: roleFields, append: appendRole, remove: removeRole } =
useFieldArray({ control, name: "roles" });
const currentAchKeys = useWatch({ control, name: "achievement_keys" });
const watchedTitle = useWatch({ control, name: "title" });
@@ -179,6 +197,7 @@ export default function AddCourse() {
const watchedSubscr = useWatch({ control, name: "subscription" });
const watchedStatus = useWatch({ control, name: "status" });
const watchedObjectives = useWatch({ control, name: "objectives" });
const watchedRoles = useWatch({ control, name: "roles" });
const [achievementRegistry, setAchievementRegistry] = useState([]);
useEffect(() => {
@@ -198,6 +217,7 @@ export default function AddCourse() {
const hasUnsavedChanges =
isDirty ||
roadmapUnits.length > 0 ||
prerequisites.length > 0 ||
currentAchKeys.length > 0 ||
!!badgeImageUrl ||
badgeColor !== "purple";
@@ -237,6 +257,12 @@ export default function AddCourse() {
subscription: values.subscription,
status: values.status,
objectives: values.objectives.map((o) => o.text),
roles: values.roles.map((r) => r.text),
// Drop rows where a type was picked but no item was actually selected —
// sending an empty ref_id fails at the DB level.
prerequisites: prerequisites
.filter((p) => p.ref_id !== "" && p.ref_id != null)
.map(({ ref_type, ref_id }) => ({ ref_type, ref_id })),
achievement_keys: currentAchKeys,
badge_color: badgeColor,
badge_asset_id: badgeAssetId ?? null,
@@ -426,12 +452,66 @@ export default function AddCourse() {
</Button>
</div>
</SectionCard>
<SectionCard
title="Course Roles"
description="Who is this course intended for? e.g. Sales Agent, Property Manager."
>
<div className="space-y-2">
{roleFields.map((field, index) => (
<div key={field.id} className="flex items-start gap-2">
<div className="flex-1 space-y-1">
<Input
placeholder={`Role ${index + 1}`}
{...register(`roles.${index}.text`)}
/>
<FieldError message={errors.roles?.[index]?.text?.message} />
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-0.5 text-muted-foreground hover:text-destructive"
onClick={() => removeRole(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full mt-1"
onClick={() => appendRole({ text: "" })}
>
<Plus className="h-4 w-4 mr-2" />
Add Role
</Button>
</div>
</SectionCard>
</>
)}
{/* ── Step 1: Roadmap ── */}
{currentStep === 1 && (
<RoadmapBuilder units={roadmapUnits} onUnitsChange={setRoadmapUnits} />
<>
<RoadmapBuilder units={roadmapUnits} onUnitsChange={setRoadmapUnits} />
<SectionCard
title="Prerequisites"
description="What a learner must complete before starting this course."
>
<CoursePrerequisiteBuilder
value={prerequisites}
onChange={setPrerequisites}
courses={flatCourses}
units={flatUnits}
lessons={flatLessons}
/>
</SectionCard>
</>
)}
{/* ── Step 2: Rewards ── */}
@@ -587,11 +667,26 @@ export default function AddCourse() {
</ul>
)}
</div>
<div className="border-t pt-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
Course Roles
</p>
{!watchedRoles?.length ? (
<p className="text-sm text-muted-foreground">None added.</p>
) : (
<div className="flex flex-wrap gap-1.5">
{watchedRoles.map((r, i) => (
<Badge key={i} variant="secondary">{r.text}</Badge>
))}
</div>
)}
</div>
</SectionCard>
<SectionCard
title="Roadmap"
description={`${roadmapUnits.length} unit${roadmapUnits.length === 1 ? "" : "s"} · ${totalLessons} lesson${totalLessons === 1 ? "" : "s"} added.`}
description={`${roadmapUnits.length} unit${roadmapUnits.length === 1 ? "" : "s"} · ${totalLessons} lesson${totalLessons === 1 ? "" : "s"} added · ${prerequisites.length} prerequisite${prerequisites.length === 1 ? "" : "s"}.`}
>
{roadmapUnits.length === 0 ? (
<p className="text-sm text-muted-foreground">No units added.</p>
@@ -614,6 +709,21 @@ export default function AddCourse() {
})}
</div>
)}
{prerequisites.length > 0 && (
<div className="border-t pt-3 mt-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
Prerequisites
</p>
<div className="flex flex-wrap gap-1.5">
{prerequisites.map((p, i) => (
<Badge key={i} variant="outline" className="capitalize gap-1">
{p.ref_type}: {p.title || `#${p.ref_id}`}
</Badge>
))}
</div>
</div>
)}
</SectionCard>
<SectionCard title="Rewards">
+139 -16
View File
@@ -14,6 +14,7 @@ import { TIER_COLOR_OPTIONS } from "@/utils/tierColors";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { PageMeta } from "@/contexts/MetadataContext";
import CourseInstructorPicker from "@/modules/admin/components/courses/CourseInstructorPicker";
import CoursePrerequisiteBuilder from "@/modules/admin/components/courses/CoursePrerequisiteBuilder";
import { useCategories } from "@/contexts/AdminCategoriesContext";
import { useAuth } from "@/contexts/AuthContext";
import api from "@/utils/api.util";
@@ -51,6 +52,9 @@ const schema = z.object({
objectives: z
.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") }))
.default([]),
roles: z
.array(z.object({ text: z.string().min(1, "Role cannot be empty.") }))
.default([]),
});
// ─── Steps config ─────────────────────────────────────────────────────────────
@@ -59,7 +63,7 @@ const STEPS = [
{ label: "Basic Info", description: "Title, level & objectives" },
{ label: "Categories", description: "Tags & instructors" },
{ label: "Rewards", description: "Badge & achievements" },
{ label: "Requirements", description: "What counts as complete" },
{ label: "Requirements", description: "Prerequisites & completion" },
{ label: "Pricing", description: "Product listing" },
];
@@ -146,6 +150,8 @@ export default function EditCourse() {
fetchInstructors, syncInstructors,
fetchCourseAchievements, syncCourseAchievements,
fetchCourseRequirements, syncCourseRequirements,
fetchPrerequisites, syncPrerequisites,
fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat,
loading, course,
} = useCourses();
const { categories: allCategories, fetchCategories } = useCategories();
@@ -172,6 +178,22 @@ export default function EditCourse() {
const [instructorsDirty, setInstructorsDirty] = useState(false);
const [instructorsLoading, setInstructorsLoading] = useState(false);
// ─── Prerequisites state ──────────────────────────────────────────────────
const [prerequisites, setPrerequisites] = useState([]);
const [prereqDirty, setPrereqDirty] = useState(false);
const [prereqLoading, setPrereqLoading] = useState(false);
const [flatCourses, setFlatCourses] = useState([]);
const [flatUnits, setFlatUnits] = useState([]);
const [flatLessons, setFlatLessons] = useState([]);
useEffect(() => {
(async () => {
const [c, u, l] = await Promise.all([fetchCoursesFlat(), fetchUnitsFlat(), fetchLessonsFlat()]);
setFlatCourses(c ?? []);
setFlatUnits(u ?? []);
setFlatLessons(l ?? []);
})();
}, []);
// ─── Achievements state ───────────────────────────────────────────────────
const [selectedAchievementKeys, setSelectedAchievementKeys] = useState([]);
const [achievementsDirty, setAchievementsDirty] = useState(false);
@@ -207,12 +229,14 @@ export default function EditCourse() {
resolver: zodResolver(schema),
defaultValues: {
title: "", description: "", course_code: "",
order_index: 0, level: undefined, subscription: "free", status: "draft", objectives: [],
order_index: 0, level: undefined, subscription: "free", status: "draft", objectives: [], roles: [],
},
});
const { fields: objectiveFields, append: appendObjective, remove: removeObjective } =
useFieldArray({ control, name: "objectives" });
const { fields: roleFields, append: appendRole, remove: removeRole } =
useFieldArray({ control, name: "roles" });
// Each section (Categories, Instructors, Badge, Achievements, Product) tracks
// its own dirty flag already (see handleDone below) — fold them in here too
@@ -224,7 +248,8 @@ export default function EditCourse() {
instructorsDirty ||
badgeDirty ||
achievementsDirty ||
productDirty;
productDirty ||
prereqDirty;
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(hasUnsavedChanges);
@@ -252,6 +277,10 @@ export default function EditCourse() {
objective_id: o.objective_id ?? null,
text: o.text ?? "",
})),
roles: (c.roles ?? []).map((r) => ({
role_id: r.role_id ?? null,
text: r.text ?? "",
})),
});
setBadgeColor(c.badge_color ?? "purple");
setBadgeAssetId(c.badge_asset_id ?? null);
@@ -272,13 +301,15 @@ export default function EditCourse() {
(async () => {
await fetchCategories();
const [cats, prod, insts, achKeys] = await Promise.all([
const [cats, prod, insts, achKeys, prereqRes] = await Promise.all([
fetchCourseCategories(courseId),
fetchCourseProduct(courseId),
fetchInstructors(courseId),
fetchCourseAchievements(courseId),
fetchPrerequisites(courseId),
]);
setSelectedCategoryIds((cats ?? []).map((c) => String(c.id)));
setPrerequisites(prereqRes?.data?.data ?? []);
if (prod) {
setProduct(prod);
setProductForm({
@@ -414,6 +445,24 @@ export default function EditCourse() {
setAchievementsLoading(false);
};
// ─── Prerequisite handlers ────────────────────────────────────────────────
const handlePrerequisitesChange = useCallback((next) => {
setPrerequisites(next);
setPrereqDirty(true);
}, []);
const handleSavePrerequisites = async () => {
setPrereqLoading(true);
// Drop rows where the admin picked a type but never actually selected an
// item from the picker — sending an empty ref_id fails at the DB level.
const complete = prerequisites
.filter((p) => p.ref_id !== "" && p.ref_id != null)
.map(({ ref_type, ref_id }) => ({ ref_type, ref_id }));
await syncPrerequisites(courseId, complete);
setPrereqDirty(false);
setPrereqLoading(false);
};
// ─── Form submit (step 0 → step 1) ───────────────────────────────────────
const saveBasicInfo = async (values) => {
const payload = {
@@ -423,6 +472,11 @@ export default function EditCourse() {
text: o.text,
order_index: i,
})) ?? [],
roles: values.roles?.map((r, i) => ({
role_id: r.role_id ?? null,
text: r.text,
order_index: i,
})) ?? [],
level: values.level || null,
course_code: values.course_code || null,
updatedBy: user?.user_id ?? null,
@@ -462,6 +516,7 @@ export default function EditCourse() {
if (badgeDirty) await handleSaveBadge();
if (achievementsDirty) await handleSaveAchievements();
if (productDirty && productForm.price) await handleSaveProduct();
if (prereqDirty) await handleSavePrerequisites();
bypassOnce();
navigate(`/admin/courses/${courseId}/view`);
@@ -654,6 +709,45 @@ export default function EditCourse() {
</Button>
</div>
</SectionCard>
<SectionCard
title="Course Roles"
description="Who is this course intended for? e.g. Sales Agent, Property Manager."
>
<div className="space-y-2">
{roleFields.map((field, index) => (
<div key={field.id} className="flex items-start gap-2">
<div className="flex-1 space-y-1">
<Input
placeholder={`Role ${index + 1}`}
{...register(`roles.${index}.text`)}
/>
<FieldError message={errors.roles?.[index]?.text?.message} />
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-0.5 text-muted-foreground hover:text-destructive"
onClick={() => removeRole(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full mt-1"
onClick={() => appendRole({ text: "" })}
>
<Plus className="h-4 w-4 mr-2" />
Add Role
</Button>
</div>
</SectionCard>
</>
)}
@@ -1015,19 +1109,48 @@ export default function EditCourse() {
</SectionCard>
)}
{/* ── Step 3: Completion Requirements ── */}
{/* ── Step 3: Prerequisites & Completion Requirements ── */}
{currentStep === 3 && (
<SectionCard
title="Completion Requirements"
description="What a learner must do for this course to count as complete."
>
<CompletionRequirementBuilder
entityType="course"
fetchFn={fetchCourseRequirements}
syncFn={syncCourseRequirements}
args={[courseId]}
/>
</SectionCard>
<>
<SectionCard
title="Prerequisites"
description="What a learner must complete before starting this course."
>
<CoursePrerequisiteBuilder
value={prerequisites}
onChange={handlePrerequisitesChange}
courses={flatCourses}
units={flatUnits}
lessons={flatLessons}
excludeCourseId={courseId}
/>
<div className="flex justify-end pt-2 border-t">
<Button
type="button"
size="sm"
disabled={!prereqDirty || prereqLoading}
onClick={handleSavePrerequisites}
>
{prereqLoading && <Spinner className="h-3 w-3 mr-1.5" />}
<Save className="h-3 w-3 mr-1.5" />
Save Prerequisites
</Button>
</div>
</SectionCard>
<SectionCard
title="Completion Requirements"
description="What a learner must do for this course to count as complete."
>
<CompletionRequirementBuilder
entityType="course"
fetchFn={fetchCourseRequirements}
syncFn={syncCourseRequirements}
args={[courseId]}
/>
</SectionCard>
</>
)}
{/* ── Step 4: Pricing ── */}
+11 -1
View File
@@ -244,13 +244,23 @@ function CourseDetailsTab({ course, loading, instructors, achievementKeys, achie
{course.prerequisites.map((p, i) => (
<li key={p.prereq_id ?? i} className="flex items-center gap-2 text-sm">
<Badge variant="outline" className="capitalize text-xs">{p.ref_type}</Badge>
<span className="text-muted-foreground">ID: {p.ref_id}</span>
<span>{p.title ?? `Untitled (ID: ${p.ref_id})`}</span>
</li>
))}
</ul>
</SectionCard>
)}
{course.roles?.length > 0 && (
<SectionCard icon={Users} title="Course Roles">
<div className="flex flex-wrap gap-1.5">
{course.roles.map((r, i) => (
<Badge key={r.role_id ?? i} variant="secondary">{r.text}</Badge>
))}
</div>
</SectionCard>
)}
{course.assessment && (
<SectionCard icon={Lock} title="Final Assessment">
<div className="grid grid-cols-2 gap-4">
@@ -15,7 +15,7 @@ import { ArrowLeft } from 'lucide-react';
export default function EditTaskList() {
const navigate = useNavigate();
const { taskListId } = useParams();
const { fetchTaskList, updateTaskList, assignGroups, unassignGroups, loading } = useAdminTask();
const { fetchTaskList, updateTaskList, syncGroups, loading } = useAdminTask();
const [form, setForm] = useState(null);
const [errors, setErrors] = useState({});
@@ -77,14 +77,14 @@ export default function EditTaskList() {
if (!updated) return;
// ── 2. Diff groups ────────────────────────────────────────────────────
const toAssign = selectedGroupIds.filter((id) => !originalGroupIds.includes(id));
const toUnassign = originalGroupIds.filter((id) => !selectedGroupIds.includes(id));
// ── 2. Sync groups (single call — avoids burning two sensitive-op hits) ──
const groupsChanged =
selectedGroupIds.length !== originalGroupIds.length ||
selectedGroupIds.some((id) => !originalGroupIds.includes(id));
await Promise.all([
toAssign.length ? assignGroups(taskListId, toAssign) : Promise.resolve(),
toUnassign.length ? unassignGroups(taskListId, toUnassign) : Promise.resolve(),
]);
if (groupsChanged) {
await syncGroups(taskListId, selectedGroupIds);
}
navigate(`/admin/taskList`);
};
@@ -16,7 +16,7 @@ import {
import {
ArrowLeft, Pencil, Users, ClipboardList, FileText,
Link2, Upload, BookOpen, BookMarked, FileCheck2,
CalendarClock, Equal, Tag, Globe, Copy, File, Bookmark,
CalendarClock, Equal, Tag, Globe, Copy, File, Bookmark, GitBranch,
} from 'lucide-react';
// ─── All styling uses shadcn tokens — only label/icon differs per type
@@ -281,6 +281,18 @@ export default function ViewTaskList() {
</div>
)}
{task.prerequisites?.length > 0 && (
<div className="flex items-center gap-2 text-muted-foreground">
<GitBranch className="size-4 shrink-0" />
<span className="text-sm">
Requires:{' '}
<span className="text-foreground font-medium">
{task.prerequisites.map((p) => p.name).join(', ')}
</span>
</span>
</div>
)}
<TaskRequirementsSection requirements={task.requirements} />
</div>
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils';
import RequirementBuilder from './RequirementBuilder';
import { taskSchema, REQUIREMENT_TYPE_META, requirementSummaryText } from './task.schema';
import TaskMultiSelect from '@/components/generic/TaskMultiSelect';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
@@ -39,7 +40,7 @@ function SummaryRow({ label, value }) {
export default function CreateTask() {
const navigate = useNavigate();
const { taskListId } = useParams();
const { createTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, fetchQuizzesFlat, loading } = useAdminTask();
const { createTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, fetchQuizzesFlat, fetchTasksFlat, loading } = useAdminTask();
const [step, setStep] = useState(0);
const [form, setForm] = useState({
@@ -48,18 +49,21 @@ export default function CreateTask() {
deadline: '',
is_required: true,
requirements: [],
prerequisite_task_ids: [],
});
const [errors, setErrors] = useState({});
const [courses, setCourses] = useState([]);
const [units, setUnits] = useState([]);
const [lessons, setLessons] = useState([]);
const [quizzes, setQuizzes] = useState([]);
const [siblingTasks, setSiblingTasks] = useState([]);
useEffect(() => {
fetchCoursesFlat().then((d) => d && setCourses(d));
fetchUnitsFlat().then((d) => d && setUnits(d));
fetchLessonsFlat().then((d) => d && setLessons(d));
fetchQuizzesFlat().then((d) => d && setQuizzes(d));
fetchTasksFlat(taskListId).then((d) => d && setSiblingTasks(d));
}, []);
const validateStep = (s) => {
@@ -113,6 +117,7 @@ export default function CreateTask() {
delete req.duration_seconds;
return req;
}),
prerequisite_task_ids: form.prerequisite_task_ids,
});
if (created) navigate(`/admin/taskList/${taskListId}/tasks`);
@@ -221,6 +226,25 @@ export default function CreateTask() {
onCheckedChange={(v) => setForm({ ...form, is_required: v })}
/>
</div>
<div className="space-y-1">
<Label>
Prerequisite Tasks
<span className="ml-1.5 text-xs text-muted-foreground font-normal">
(optional)
</span>
</Label>
<TaskMultiSelect
value={form.prerequisite_task_ids}
onChange={(ids) => setForm({ ...form, prerequisite_task_ids: ids })}
tasks={siblingTasks}
disabled={loading}
placeholder="Select tasks that must be completed first…"
/>
<p className="text-xs text-muted-foreground">
This task stays locked until every selected task is complete. Leave empty to use the default order-based lock instead.
</p>
</div>
</CardContent>
</Card>
)}
@@ -267,6 +291,15 @@ export default function CreateTask() {
? <span className="inline-flex items-center gap-1"><Clock className="h-3.5 w-3.5" />{formattedDeadline}</span>
: 'No deadline'}
/>
<SummaryRow
label="Prerequisites"
value={form.prerequisite_task_ids.length
? siblingTasks
.filter((t) => form.prerequisite_task_ids.includes(t.task_id))
.map((t) => t.name)
.join(', ')
: null}
/>
</CardContent>
</Card>
@@ -6,6 +6,7 @@ import { useAdminTask } from '@/contexts/AdminTaskContext';
import RequirementBuilder from './RequirementBuilder';
import { taskSchema } from './task.schema';
import TaskMultiSelect from '@/components/generic/TaskMultiSelect';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
@@ -33,7 +34,7 @@ const STATUS_OPTIONS = [
export default function EditTask() {
const navigate = useNavigate();
const { taskListId, taskId } = useParams();
const { fetchTask, updateTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, fetchQuizzesFlat, loading } = useAdminTask();
const { fetchTask, updateTask, fetchTasksFlat, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, fetchQuizzesFlat, loading } = useAdminTask();
const [form, setForm] = useState(null);
const [errors, setErrors] = useState({});
@@ -41,6 +42,7 @@ export default function EditTask() {
const [units, setUnits] = useState([]);
const [lessons, setLessons] = useState([]);
const [quizzes, setQuizzes] = useState([]);
const [siblingTasks, setSiblingTasks] = useState([]);
const [confirmOpen, setConfirmOpen] = useState(false);
const initialRequirementsRef = useRef(null);
@@ -56,12 +58,14 @@ export default function EditTask() {
status: data.status ?? 'pending',
is_required: data.is_required ?? true,
requirements: reqs,
prerequisite_task_ids: (data.prerequisites ?? []).map((p) => p.task_id),
});
});
fetchCoursesFlat().then((d) => d && setCourses(d));
fetchUnitsFlat().then((d) => d && setUnits(d));
fetchLessonsFlat().then((d) => d && setLessons(d));
fetchQuizzesFlat().then((d) => d && setQuizzes(d));
fetchTasksFlat(taskListId).then((d) => d && setSiblingTasks(d.filter((t) => t.task_id !== taskId)));
}, [taskListId, taskId]);
const requirementsChanged = () =>
@@ -100,6 +104,7 @@ export default function EditTask() {
status: form.status,
is_required: form.is_required,
requirements,
prerequisite_task_ids: form.prerequisite_task_ids ?? [],
});
if (updated) navigate(`/admin/taskList/${taskListId}/tasks`);
};
@@ -222,6 +227,25 @@ export default function EditTask() {
onCheckedChange={(v) => setForm({ ...form, is_required: v })}
/>
</div>
<div className="space-y-1">
<Label>
Prerequisite Tasks
<span className="ml-1.5 text-xs text-muted-foreground font-normal">
(optional)
</span>
</Label>
<TaskMultiSelect
value={form.prerequisite_task_ids ?? []}
onChange={(ids) => setForm({ ...form, prerequisite_task_ids: ids })}
tasks={siblingTasks}
disabled={loading}
placeholder="Select tasks that must be completed first…"
/>
<p className="text-xs text-muted-foreground">
This task stays locked until every selected task is complete. Leave empty to use the default order-based lock instead.
</p>
</div>
</CardContent>
</Card>