assets and tier plans revamp

This commit is contained in:
rgrgogu
2026-08-01 17:44:12 +08:00
parent 4f738a691d
commit a34c6feb84
45 changed files with 2172 additions and 273 deletions
+46 -9
View File
@@ -14,6 +14,8 @@ import { Spinner } from "@/components/ui/spinner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { PageMeta } from "@/contexts/MetadataContext";
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
import { UnitPicker } from "@/modules/admin/components/tiers/UnitPicker";
import { LessonPicker } from "@/modules/admin/components/tiers/LessonPicker";
import { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker";
import api from "@/utils/api.util";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
@@ -75,7 +77,7 @@ function SectionCard({ title, children }) {
const STEPS = [
{ label: "Category & Label", description: "Tier category, label & description" },
{ label: "Duration & Pricing", description: "Billing period, price & currency" },
{ label: "Assigned Courses", description: "Choose which courses this unlocks" },
{ label: "Bundles", description: "Choose which courses, units & lessons this unlocks" },
];
function StepIndicator({ steps, current, maxStepReached, onStepClick }) {
@@ -144,6 +146,10 @@ export default function AddPlan() {
const [currencies, setCurrencies] = useState([]);
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
const [courseConflicts, setCourseConflicts] = useState(0);
const [selectedUnitIds, setSelectedUnitIds] = useState(new Set());
const [unitConflicts, setUnitConflicts] = useState(0);
const [selectedLessonIds, setSelectedLessonIds] = useState(new Set());
const [lessonConflicts, setLessonConflicts] = useState(0);
useEffect(() => {
api.get("/admin/tiers/categories")
@@ -176,10 +182,11 @@ export default function AddPlan() {
insertFeature(index + 1, lines.slice(1).map((text) => ({ text })));
};
// selectedCourseIds lives outside the form — this is a create page so it
// always starts empty, meaning any selection is a genuine unsaved change.
// selectedCourseIds/selectedUnitIds/selectedLessonIds live outside the form
// — this is a create page so they always start empty, meaning any
// selection is a genuine unsaved change.
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(
isDirty || selectedCourseIds.size > 0
isDirty || selectedCourseIds.size > 0 || selectedUnitIds.size > 0 || selectedLessonIds.size > 0
);
const selectedCategoryId = watch("tier_category_id");
@@ -190,10 +197,14 @@ export default function AddPlan() {
return categories.find((c) => String(c.tier_category_id) === selectedCategoryId)?.slug ?? null;
}, [selectedCategoryId, categories]);
// Reset picker when category changes
// Reset pickers when category changes
useEffect(() => {
setSelectedCourseIds(new Set());
setCourseConflicts(0);
setSelectedUnitIds(new Set());
setUnitConflicts(0);
setSelectedLessonIds(new Set());
setLessonConflicts(0);
}, [categorySlug]);
const STEP_FIELDS = [
@@ -220,12 +231,22 @@ export default function AddPlan() {
const result = await createPlan(values);
if (!result) return;
// Sync selected courses
// Sync selected bundles
if (selectedCourseIds.size > 0) {
await api.post(`/admin/tiers/plans/${result.plan_id}/courses`, {
course_ids: [...selectedCourseIds].map(Number),
}).catch(() => {});
}
if (selectedUnitIds.size > 0) {
await api.post(`/admin/tiers/plans/${result.plan_id}/units`, {
unit_ids: [...selectedUnitIds].map(Number),
}).catch(() => {});
}
if (selectedLessonIds.size > 0) {
await api.post(`/admin/tiers/plans/${result.plan_id}/lessons`, {
lesson_ids: [...selectedLessonIds].map(Number),
}).catch(() => {});
}
bypassOnce();
navigate(`/admin/tiers/plans`);
@@ -401,15 +422,31 @@ export default function AddPlan() {
</SectionCard>
)}
{/* ── Step 2: Assigned Courses ── */}
{/* ── Step 2: Bundles ── */}
{currentStep === 2 && (
<SectionCard title="Assigned Courses" description="Choose which courses this plan unlocks.">
<SectionCard title="Bundles" description="Choose which courses, units, and lessons this plan unlocks.">
<CoursePicker
subscription={categorySlug}
selectedIds={selectedCourseIds}
onChange={setSelectedCourseIds}
onConflictsChange={setCourseConflicts}
/>
<div className="border-t pt-5">
<UnitPicker
subscription={categorySlug}
selectedIds={selectedUnitIds}
onChange={setSelectedUnitIds}
onConflictsChange={setUnitConflicts}
/>
</div>
<div className="border-t pt-5">
<LessonPicker
subscription={categorySlug}
selectedIds={selectedLessonIds}
onChange={setSelectedLessonIds}
onConflictsChange={setLessonConflicts}
/>
</div>
</SectionCard>
)}
@@ -432,7 +469,7 @@ export default function AddPlan() {
<Button
type="button"
onClick={handleSubmit(onSubmit)}
disabled={loading || catLoading || !selectedCategoryId || courseConflicts > 0}
disabled={loading || catLoading || !selectedCategoryId || courseConflicts > 0 || unitConflicts > 0 || lessonConflicts > 0}
>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Plan
+81 -2
View File
@@ -17,6 +17,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { PageMeta } from "@/contexts/MetadataContext";
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
import { UnitPicker } from "@/modules/admin/components/tiers/UnitPicker";
import { LessonPicker } from "@/modules/admin/components/tiers/LessonPicker";
import { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker";
import api from "@/utils/api.util";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
@@ -87,6 +89,12 @@ export default function EditPlan() {
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
const [coursesLoaded, setCoursesLoaded] = useState(false);
const [courseConflicts, setCourseConflicts] = useState(0);
const [selectedUnitIds, setSelectedUnitIds] = useState(new Set());
const [unitsLoaded, setUnitsLoaded] = useState(false);
const [unitConflicts, setUnitConflicts] = useState(0);
const [selectedLessonIds, setSelectedLessonIds] = useState(new Set());
const [lessonsLoaded, setLessonsLoaded] = useState(false);
const [lessonConflicts, setLessonConflicts] = useState(0);
const [currencies, setCurrencies] = useState([]);
const [impactDialog, setImpactDialog] = useState(false);
const [impactCount, setImpactCount] = useState(0);
@@ -150,6 +158,29 @@ export default function EditPlan() {
.catch(() => setCoursesLoaded(true));
}, [planId]);
// Load existing assigned units/lessons the same way
useEffect(() => {
if (!planId || unitsLoaded) return;
api.get(`/admin/tiers/${planId}/units`)
.then(({ data }) => {
const ids = (data.data ?? []).map((u) => String(u.unit_id));
setSelectedUnitIds(new Set(ids));
setUnitsLoaded(true);
})
.catch(() => setUnitsLoaded(true));
}, [planId]);
useEffect(() => {
if (!planId || lessonsLoaded) return;
api.get(`/admin/tiers/${planId}/lessons`)
.then(({ data }) => {
const ids = (data.data ?? []).map((l) => String(l.lesson_id));
setSelectedLessonIds(new Set(ids));
setLessonsLoaded(true);
})
.catch(() => setLessonsLoaded(true));
}, [planId]);
const durationChanged = (values) => {
if (!plan) return false;
const originalUnit = plan.duration_unit ?? "day";
@@ -166,6 +197,12 @@ export default function EditPlan() {
await api.post(`/admin/tiers/${planId}/courses`, {
course_ids: [...selectedCourseIds],
}).catch(() => {});
await api.post(`/admin/tiers/${planId}/units`, {
unit_ids: [...selectedUnitIds],
}).catch(() => {});
await api.post(`/admin/tiers/${planId}/lessons`, {
lesson_ids: [...selectedLessonIds],
}).catch(() => {});
bypassOnce();
navigate("/admin/tiers/plans");
};
@@ -339,7 +376,7 @@ export default function EditPlan() {
</SectionCard>
{plan?.tier && (
<SectionCard title="Assigned Courses">
<SectionCard title="Bundles" description="Choose which courses, units, and lessons this plan unlocks.">
{coursesLoaded ? (
<CoursePicker
subscription={plan.tier}
@@ -358,12 +395,54 @@ export default function EditPlan() {
<Skeleton className="h-8 w-52" />
</div>
)}
<div className="border-t pt-5">
{unitsLoaded ? (
<UnitPicker
subscription={plan.tier}
selectedIds={selectedUnitIds}
onChange={setSelectedUnitIds}
isPreloaded={true}
currentPlanId={planId}
onConflictsChange={setUnitConflicts}
/>
) : (
<div className="space-y-3">
<div className="flex gap-2">
<Skeleton className="h-8 w-36" />
<Skeleton className="h-8 w-40" />
</div>
<Skeleton className="h-8 w-52" />
</div>
)}
</div>
<div className="border-t pt-5">
{lessonsLoaded ? (
<LessonPicker
subscription={plan.tier}
selectedIds={selectedLessonIds}
onChange={setSelectedLessonIds}
isPreloaded={true}
currentPlanId={planId}
onConflictsChange={setLessonConflicts}
/>
) : (
<div className="space-y-3">
<div className="flex gap-2">
<Skeleton className="h-8 w-36" />
<Skeleton className="h-8 w-40" />
</div>
<Skeleton className="h-8 w-52" />
</div>
)}
</div>
</SectionCard>
)}
<div className="flex justify-end gap-3 pt-1">
<Button type="button" variant="outline" onClick={() => navigate("/admin/tiers/plans")} disabled={loading || impactLoading}>Cancel</Button>
<Button type="submit" disabled={loading || impactLoading || courseConflicts > 0}>
<Button type="submit" disabled={loading || impactLoading || courseConflicts > 0 || unitConflicts > 0 || lessonConflicts > 0}>
{(loading || impactLoading) && <Spinner className="h-4 w-4 mr-2" />}
Save Changes
</Button>
+82 -2
View File
@@ -2,7 +2,7 @@ import { useEffect, useState, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
ArrowLeft, CreditCard, Pencil, Tag, BadgeCheck, BookOpen, Clock,
ShieldCheck, Plus, Trash2, Loader2, Receipt, KeyRound, Users, Lock,
ShieldCheck, Plus, Trash2, Loader2, Receipt, KeyRound, Users, Lock, FileText,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -70,7 +70,7 @@ function formatCourseDuration(seconds = 0) {
// ─── Tab: Plan Details ─────────────────────────────────────────────────────────
function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoading }) {
function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoading, assignedUnits, unitsLoading, assignedLessons, lessonsLoading }) {
const { fmtDateTime } = useDateFormat();
if (loading && !plan) {
@@ -159,6 +159,68 @@ function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoadin
)}
</SectionCard>
<SectionCard icon={BookOpen} title="Assigned Units">
{unitsLoading ? (
<div className="space-y-2">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : assignedUnits.length === 0 ? (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<BookOpen className="size-4 shrink-0" />
No units assigned to this plan yet.
</div>
) : (
<div className="space-y-0 divide-y rounded-lg border overflow-hidden">
{assignedUnits.map((unit) => (
<div key={unit.unit_id} className="flex items-center justify-between gap-3 px-4 py-3 bg-card hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="h-8 w-8 rounded-md bg-muted flex items-center justify-center shrink-0">
<BookOpen className="size-4 text-muted-foreground" />
</div>
<p className="text-sm font-medium line-clamp-1">{unit.title}</p>
</div>
</div>
))}
</div>
)}
{!unitsLoading && (
<p className="text-xs text-muted-foreground">
{assignedUnits.length} unit{assignedUnits.length !== 1 ? "s" : ""} assigned
</p>
)}
</SectionCard>
<SectionCard icon={FileText} title="Assigned Lessons">
{lessonsLoading ? (
<div className="space-y-2">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : assignedLessons.length === 0 ? (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<FileText className="size-4 shrink-0" />
No lessons assigned to this plan yet.
</div>
) : (
<div className="space-y-0 divide-y rounded-lg border overflow-hidden">
{assignedLessons.map((lesson) => (
<div key={lesson.lesson_id} className="flex items-center justify-between gap-3 px-4 py-3 bg-card hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="h-8 w-8 rounded-md bg-muted flex items-center justify-center shrink-0">
<FileText className="size-4 text-muted-foreground" />
</div>
<p className="text-sm font-medium line-clamp-1">{lesson.title}</p>
</div>
</div>
))}
</div>
)}
{!lessonsLoading && (
<p className="text-xs text-muted-foreground">
{assignedLessons.length} lesson{assignedLessons.length !== 1 ? "s" : ""} assigned
</p>
)}
</SectionCard>
<SectionCard icon={BadgeCheck} title="Audit">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Created At">{plan.createdAt ? fmtDateTime(plan.createdAt) : "—"}</InfoRow>
@@ -750,6 +812,10 @@ export default function ViewPlan() {
const [assignedCourses, setAssignedCourses] = useState([]);
const [coursesLoading, setCoursesLoading] = useState(false);
const [assignedUnits, setAssignedUnits] = useState([]);
const [unitsLoading, setUnitsLoading] = useState(false);
const [assignedLessons, setAssignedLessons] = useState([]);
const [lessonsLoading, setLessonsLoading] = useState(false);
useEffect(() => {
fetchPlan(planId);
@@ -759,6 +825,16 @@ export default function ViewPlan() {
.then(({ data }) => setAssignedCourses(data.data ?? []))
.catch(() => {})
.finally(() => setCoursesLoading(false));
setUnitsLoading(true);
api.get(`/admin/tiers/${planId}/units`)
.then(({ data }) => setAssignedUnits(data.data ?? []))
.catch(() => {})
.finally(() => setUnitsLoading(false));
setLessonsLoading(true);
api.get(`/admin/tiers/${planId}/lessons`)
.then(({ data }) => setAssignedLessons(data.data ?? []))
.catch(() => {})
.finally(() => setLessonsLoading(false));
}, [planId]);
return (
@@ -835,6 +911,10 @@ export default function ViewPlan() {
tierMap={tierMap}
assignedCourses={assignedCourses}
coursesLoading={coursesLoading}
assignedUnits={assignedUnits}
unitsLoading={unitsLoading}
assignedLessons={assignedLessons}
lessonsLoading={lessonsLoading}
/>
)}
{activeTab === "access" && (