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
+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">