mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
units,lesson as standalone
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
// modules/admin/components/courses/CreateLessonDialog.jsx
|
||||
// Lightweight "create a brand-new lesson and attach it to this unit" dialog —
|
||||
// the create-new counterpart to AttachLessonsDialog's attach-existing flow.
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
description: z.string().optional(),
|
||||
order: z.coerce.number().min(0).default(0),
|
||||
objectives: z.array(z.object({ value: z.string().min(1, "Objective cannot be empty.") })).default([]),
|
||||
});
|
||||
|
||||
export default function CreateLessonDialog({ open, onOpenChange, courseId, unitId, nextOrder = 0, onCreated, draftMode = false }) {
|
||||
const { createLesson, loading } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { register, handleSubmit, reset, control, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", order: nextOrder, objectives: [] },
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({ control, name: "objectives" });
|
||||
|
||||
useEffect(() => {
|
||||
if (open) reset({ title: "", description: "", order: nextOrder, objectives: [] });
|
||||
}, [open, nextOrder, reset]);
|
||||
|
||||
const onValid = async (values) => {
|
||||
const objectives = values.objectives.map((o) => o.value);
|
||||
|
||||
if (draftMode) {
|
||||
onCreated?.({
|
||||
lesson_id: null,
|
||||
key: crypto.randomUUID(),
|
||||
title: values.title,
|
||||
description: values.description,
|
||||
order_index: values.order,
|
||||
objectives,
|
||||
});
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await createLesson(courseId, unitId, {
|
||||
...values,
|
||||
objectives,
|
||||
createdBy: user?.user_id,
|
||||
});
|
||||
const lesson = result?.data?.data ?? null;
|
||||
if (!lesson) return;
|
||||
onCreated?.(lesson);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Lesson</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onValid)} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lesson_title">
|
||||
Title <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input id="lesson_title" placeholder="e.g. Welcome to the Course" {...register("title")} />
|
||||
{errors.title && <p className="text-sm text-destructive">{errors.title.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lesson_description">Description</Label>
|
||||
<Textarea id="lesson_description" placeholder="Optional lesson description" rows={3} {...register("description")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-w-[120px]">
|
||||
<Label htmlFor="lesson_order">Order</Label>
|
||||
<Input id="lesson_order" type="number" min={0} {...register("order")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">Objectives</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => append({ value: "" })}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input placeholder={`Objective ${index + 1}`} {...register(`objectives.${index}.value`)} />
|
||||
{errors.objectives?.[index]?.value && (
|
||||
<p className="text-xs text-destructive">{errors.objectives[index].value.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button" variant="ghost" size="icon"
|
||||
className="mt-0.5 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline" disabled={loading}>Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Create Lesson
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// modules/admin/components/courses/CreateUnitDialog.jsx
|
||||
// Lightweight "create a brand-new unit and attach it to this course" dialog —
|
||||
// the create-new counterpart to AttachUnitsDialog's attach-existing flow.
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
description: z.string().optional(),
|
||||
order: z.coerce.number().min(0).default(0),
|
||||
});
|
||||
|
||||
export default function CreateUnitDialog({ open, onOpenChange, courseId, nextOrder = 0, onCreated, draftMode = false }) {
|
||||
const { createUnit, loading } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", order: nextOrder },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) reset({ title: "", description: "", order: nextOrder });
|
||||
}, [open, nextOrder, reset]);
|
||||
|
||||
const onValid = async (values) => {
|
||||
if (draftMode) {
|
||||
onCreated?.({
|
||||
unit_id: null,
|
||||
key: crypto.randomUUID(),
|
||||
title: values.title,
|
||||
description: values.description,
|
||||
order_index: values.order,
|
||||
lessons: [],
|
||||
});
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await createUnit(courseId, { ...values, createdBy: user?.user_id });
|
||||
const unit = result?.data?.data ?? null;
|
||||
if (!unit) return;
|
||||
onCreated?.(unit);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Unit</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onValid)} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="unit_title">
|
||||
Title <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input id="unit_title" placeholder="e.g. Getting Started" {...register("title")} />
|
||||
{errors.title && <p className="text-sm text-destructive">{errors.title.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="unit_description">Description</Label>
|
||||
<Textarea id="unit_description" placeholder="Optional unit description" rows={3} {...register("description")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-w-[120px]">
|
||||
<Label htmlFor="unit_order">Order</Label>
|
||||
<Input id="unit_order" type="number" min={0} {...register("order")} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline" disabled={loading}>Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Create Unit
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// modules/admin/components/courses/RoadmapBuilder.jsx
|
||||
// The "Roadmap" step content for AddCourse: compose a course's units — and
|
||||
// each unit's lessons — by attaching existing library content or creating
|
||||
// new items inline. Fully controlled/draft: nothing here ever calls the API.
|
||||
// All picks just mutate the `units` array the parent wizard holds in memory;
|
||||
// the whole roadmap is written in one shot when the wizard is finished.
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
ChevronRight, ChevronDown, Plus, Link2, Trash2, BookOpen, AlertTriangle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import AttachUnitsDialog from "../library/AttachUnitsDialog";
|
||||
import AttachLessonsDialog from "../library/AttachLessonsDialog";
|
||||
import CreateUnitDialog from "./CreateUnitDialog";
|
||||
import CreateLessonDialog from "./CreateLessonDialog";
|
||||
|
||||
export default function RoadmapBuilder({ units, onUnitsChange }) {
|
||||
const { unitsFlat, lessonsFlat } = useLibrary();
|
||||
|
||||
const [expanded, setExpanded] = useState(() => new Set());
|
||||
|
||||
const [createUnitOpen, setCreateUnitOpen] = useState(false);
|
||||
const [attachUnitOpen, setAttachUnitOpen] = useState(false);
|
||||
const [createLessonUnitKey, setCreateLessonUnitKey] = useState(null);
|
||||
const [attachLessonUnitKey, setAttachLessonUnitKey] = useState(null);
|
||||
const [removeUnitTarget, setRemoveUnitTarget] = useState(null);
|
||||
const [removeLessonTarget, setRemoveLessonTarget] = useState(null);
|
||||
|
||||
const toggleExpand = (key) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const attachedUnitIds = useMemo(
|
||||
() => units.map((u) => u.unit_id).filter(Boolean),
|
||||
[units]
|
||||
);
|
||||
|
||||
const handleAttachUnits = (unitIds) => {
|
||||
const picked = unitIds
|
||||
.map((id) => unitsFlat.find((u) => u.unit_id === id))
|
||||
.filter(Boolean)
|
||||
.map((u) => ({
|
||||
key: crypto.randomUUID(),
|
||||
unit_id: u.unit_id,
|
||||
title: u.title,
|
||||
description: u.description ?? "",
|
||||
existing_lesson_count: u.lesson_count ?? 0,
|
||||
lessons: [],
|
||||
}));
|
||||
onUnitsChange([...units, ...picked]);
|
||||
};
|
||||
|
||||
const handleUnitCreated = (draftUnit) => {
|
||||
onUnitsChange([...units, draftUnit]);
|
||||
setExpanded((prev) => new Set(prev).add(draftUnit.key));
|
||||
};
|
||||
|
||||
const handleAttachLessons = (unitKey, lessonIds) => {
|
||||
const picked = lessonIds
|
||||
.map((id) => lessonsFlat.find((l) => l.lesson_id === id))
|
||||
.filter(Boolean)
|
||||
.map((l) => ({
|
||||
key: crypto.randomUUID(),
|
||||
lesson_id: l.lesson_id,
|
||||
title: l.title,
|
||||
description: l.description ?? "",
|
||||
objectives: [],
|
||||
}));
|
||||
onUnitsChange(units.map((u) =>
|
||||
u.key === unitKey ? { ...u, lessons: [...u.lessons, ...picked] } : u
|
||||
));
|
||||
};
|
||||
|
||||
const handleLessonCreated = (unitKey, draftLesson) => {
|
||||
onUnitsChange(units.map((u) =>
|
||||
u.key === unitKey ? { ...u, lessons: [...u.lessons, draftLesson] } : u
|
||||
));
|
||||
};
|
||||
|
||||
const confirmRemoveUnit = () => {
|
||||
if (!removeUnitTarget) return;
|
||||
onUnitsChange(units.filter((u) => u.key !== removeUnitTarget.key));
|
||||
setRemoveUnitTarget(null);
|
||||
};
|
||||
|
||||
const confirmRemoveLesson = () => {
|
||||
if (!removeLessonTarget) return;
|
||||
const { unitKey, lesson } = removeLessonTarget;
|
||||
onUnitsChange(units.map((u) =>
|
||||
u.key === unitKey ? { ...u, lessons: u.lessons.filter((l) => l.key !== lesson.key) } : u
|
||||
));
|
||||
setRemoveLessonTarget(null);
|
||||
};
|
||||
|
||||
const attachLessonUnit = units.find((u) => u.key === attachLessonUnitKey);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
<div className="flex items-start justify-between gap-3 pb-1 border-b">
|
||||
<div className="space-y-0.5">
|
||||
<h2 className="text-sm font-semibold">Course Roadmap</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add units to structure this course. Attach existing library units to reuse content, or create new ones from scratch.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setCreateUnitOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" /> New Unit
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setAttachUnitOpen(true)}>
|
||||
<Link2 className="h-3.5 w-3.5 mr-1.5" /> Attach
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{units.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-10 text-center">
|
||||
<BookOpen className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">No units yet</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" size="sm" onClick={() => setCreateUnitOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" /> New Unit
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setAttachUnitOpen(true)}>
|
||||
<Link2 className="h-3.5 w-3.5 mr-1.5" /> Attach Existing
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{units.map((unit, index) => {
|
||||
const isOpen = expanded.has(unit.key);
|
||||
const isAttached = !!unit.unit_id;
|
||||
return (
|
||||
<div key={unit.key} className="rounded-md border">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(unit.key)}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground shrink-0">{index + 1}</span>
|
||||
<span className="text-sm font-medium truncate">{unit.title}</span>
|
||||
{isAttached ? (
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
{unit.existing_lesson_count} existing lesson{unit.existing_lesson_count === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">new</Badge>
|
||||
)}
|
||||
{unit.lessons.length > 0 && (
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
+{unit.lessons.length} added
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive shrink-0"
|
||||
onClick={() => setRemoveUnitTarget(unit)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="border-t bg-muted/30 px-3 py-3 space-y-2">
|
||||
{isAttached && (
|
||||
<p className="text-xs text-muted-foreground py-1">
|
||||
This unit already has {unit.existing_lesson_count} lesson{unit.existing_lesson_count === 1 ? "" : "s"} in the library. Any lessons you add below are appended on top.
|
||||
</p>
|
||||
)}
|
||||
{unit.lessons.length === 0 && !isAttached && (
|
||||
<p className="text-xs text-muted-foreground py-1">No lessons in this unit yet.</p>
|
||||
)}
|
||||
{unit.lessons.map((lesson, lIndex) => (
|
||||
<div key={lesson.key} className="flex items-center gap-2 pl-1">
|
||||
<span className="text-xs text-muted-foreground w-4 shrink-0">{lIndex + 1}.</span>
|
||||
<span className="text-sm flex-1 truncate">{lesson.title}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive shrink-0"
|
||||
onClick={() => setRemoveLessonTarget({ unitKey: unit.key, lesson })}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="h-7 text-xs"
|
||||
onClick={() => setCreateLessonUnitKey(unit.key)}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" /> New Lesson
|
||||
</Button>
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="h-7 text-xs"
|
||||
onClick={() => setAttachLessonUnitKey(unit.key)}
|
||||
>
|
||||
<Link2 className="h-3 w-3 mr-1" /> Attach Existing Lesson
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{units.length === 0 && (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-700 dark:text-amber-400">
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||
Add at least one unit so learners have content to see. You can still continue and add units later.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Create / Attach dialogs (draft mode — nothing here hits the API) ── */}
|
||||
<CreateUnitDialog
|
||||
open={createUnitOpen}
|
||||
onOpenChange={setCreateUnitOpen}
|
||||
nextOrder={units.length}
|
||||
onCreated={handleUnitCreated}
|
||||
draftMode
|
||||
/>
|
||||
<AttachUnitsDialog
|
||||
open={attachUnitOpen}
|
||||
onOpenChange={setAttachUnitOpen}
|
||||
attachedUnitIds={attachedUnitIds}
|
||||
onAttach={handleAttachUnits}
|
||||
/>
|
||||
<CreateLessonDialog
|
||||
open={!!createLessonUnitKey}
|
||||
onOpenChange={(v) => !v && setCreateLessonUnitKey(null)}
|
||||
nextOrder={(units.find((u) => u.key === createLessonUnitKey)?.lessons ?? []).length}
|
||||
onCreated={(lesson) => handleLessonCreated(createLessonUnitKey, lesson)}
|
||||
draftMode
|
||||
/>
|
||||
<AttachLessonsDialog
|
||||
open={!!attachLessonUnitKey}
|
||||
onOpenChange={(v) => !v && setAttachLessonUnitKey(null)}
|
||||
attachedLessonIds={(attachLessonUnit?.lessons ?? []).map((l) => l.lesson_id).filter(Boolean)}
|
||||
onAttach={(lessonIds) => handleAttachLessons(attachLessonUnitKey, lessonIds)}
|
||||
/>
|
||||
|
||||
{/* ── Remove confirmations ── */}
|
||||
<AlertDialog open={!!removeUnitTarget} onOpenChange={(v) => !v && setRemoveUnitTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove unit from roadmap</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Remove <span className="font-medium text-foreground">{removeUnitTarget?.title}</span> from this course's
|
||||
roadmap? Nothing has been saved yet, so this just drops it from the draft.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmRemoveUnit}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={!!removeLessonTarget} onOpenChange={(v) => !v && setRemoveLessonTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove lesson from unit</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Remove <span className="font-medium text-foreground">{removeLessonTarget?.lesson?.title}</span> from this
|
||||
unit? Nothing has been saved yet, so this just drops it from the draft.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmRemoveLesson}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,10 +42,12 @@ export default function AttachUnitsDialog({ open, onOpenChange, attachedUnitIds
|
||||
.filter((u) => !q || u.title?.toLowerCase().includes(q));
|
||||
}, [unitsFlat, attachedSet, query]);
|
||||
|
||||
const toggle = (unitId) =>
|
||||
const toggle = (unitId, blocked) => {
|
||||
if (blocked) return;
|
||||
setSelected((prev) =>
|
||||
prev.includes(unitId) ? prev.filter((id) => id !== unitId) : [...prev, unitId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleAttach = async () => {
|
||||
if (!selected.length) return;
|
||||
@@ -86,30 +88,38 @@ export default function AttachUnitsDialog({ open, onOpenChange, attachedUnitIds
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{candidates.map((u) => (
|
||||
<label
|
||||
key={u.unit_id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 hover:bg-muted/60 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected.includes(u.unit_id)}
|
||||
onCheckedChange={() => toggle(u.unit_id)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{u.title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{u.lesson_count ?? 0} lesson{(u.lesson_count ?? 0) === 1 ? "" : "s"} · {formatDuration(u.duration_seconds ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
{Number(u.course_count) > 0 ? (
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
in {u.course_count} course{Number(u.course_count) === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs shrink-0 text-muted-foreground">standalone</Badge>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{candidates.map((u) => {
|
||||
const blocked = Number(u.course_count) > 0;
|
||||
return (
|
||||
<label
|
||||
key={u.unit_id}
|
||||
title={blocked ? "Already attached to another course — a unit can only belong to one course at a time." : undefined}
|
||||
className={[
|
||||
"flex items-center gap-3 px-3 py-2.5",
|
||||
blocked ? "opacity-60 cursor-not-allowed" : "hover:bg-muted/60 cursor-pointer",
|
||||
].join(" ")}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected.includes(u.unit_id)}
|
||||
disabled={blocked}
|
||||
onCheckedChange={() => toggle(u.unit_id, blocked)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{u.title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{u.lesson_count ?? 0} lesson{(u.lesson_count ?? 0) === 1 ? "" : "s"} · {formatDuration(u.duration_seconds ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
{blocked ? (
|
||||
<Badge variant="secondary" className="text-xs shrink-0 bg-amber-100 text-amber-700 border-amber-300 dark:bg-amber-950/40 dark:text-amber-400 dark:border-amber-700">
|
||||
already in a course
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs shrink-0 text-muted-foreground">standalone</Badge>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
+10
-10
@@ -48,7 +48,7 @@ export default function ArchivedNotificationBroadcastsTable() {
|
||||
allData: broadcasts,
|
||||
attributes,
|
||||
filename: `${getTimestamp()}_ArchivedNotificationBroadcasts`,
|
||||
sheetName: "Archived Notifications",
|
||||
sheetName: "Archived Announcements",
|
||||
};
|
||||
|
||||
const rowActions = buildRowActions({
|
||||
@@ -97,7 +97,7 @@ export default function ArchivedNotificationBroadcastsTable() {
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
title="Archived Notifications"
|
||||
title="Archived Announcements"
|
||||
data={broadcasts}
|
||||
columns={columns}
|
||||
attributes={attributes}
|
||||
@@ -120,8 +120,8 @@ export default function ArchivedNotificationBroadcastsTable() {
|
||||
columnPinning={columnPinning}
|
||||
toolbarActions={toolbarActions}
|
||||
selectionActions={selectionActions}
|
||||
recordLabel="archived notification"
|
||||
emptyMessage="No archived notifications found."
|
||||
recordLabel="archived announcement"
|
||||
emptyMessage="No archived announcements found."
|
||||
/>
|
||||
|
||||
{/* ── Single restore ── */}
|
||||
@@ -129,8 +129,8 @@ export default function ArchivedNotificationBroadcastsTable() {
|
||||
open={!!restoreTarget}
|
||||
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
||||
entity={restoreTarget}
|
||||
entityLabel="Notification"
|
||||
getName={(b) => b?.title ?? "this notification"}
|
||||
entityLabel="Announcement"
|
||||
getName={(b) => b?.title ?? "this announcement"}
|
||||
onRestore={(b) => restoreBroadcast(b?.broadcast_id)}
|
||||
loading={loading}
|
||||
onSuccess={handleRestoreSuccess}
|
||||
@@ -141,7 +141,7 @@ export default function ArchivedNotificationBroadcastsTable() {
|
||||
open={!!restoreIds}
|
||||
onOpenChange={(v) => !v && setRestoreIds(null)}
|
||||
ids={restoreIds ?? []}
|
||||
entityLabel="Notification"
|
||||
entityLabel="Announcement"
|
||||
onRestore={(ids) => restoreBroadcasts(ids)}
|
||||
loading={loading}
|
||||
onSuccess={handleRestoreSuccess}
|
||||
@@ -152,8 +152,8 @@ export default function ArchivedNotificationBroadcastsTable() {
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(v) => !v && setDeleteTarget(null)}
|
||||
entity={deleteTarget}
|
||||
entityLabel="Notification"
|
||||
getName={(b) => b?.title ?? "this notification"}
|
||||
entityLabel="Announcement"
|
||||
getName={(b) => b?.title ?? "this announcement"}
|
||||
onDelete={(b) => permanentlyDeleteBroadcast(b?.broadcast_id)}
|
||||
loading={loading}
|
||||
onSuccess={handleDeleteSuccess}
|
||||
@@ -164,7 +164,7 @@ export default function ArchivedNotificationBroadcastsTable() {
|
||||
open={!!deleteIds}
|
||||
onOpenChange={(v) => !v && setDeleteIds(null)}
|
||||
ids={deleteIds ?? []}
|
||||
entityLabel="Notification"
|
||||
entityLabel="Announcement"
|
||||
onDelete={(ids) => permanentlyDeleteBroadcasts(ids)}
|
||||
loading={loading}
|
||||
onSuccess={handleDeleteSuccess}
|
||||
|
||||
@@ -35,6 +35,12 @@ const cellOverrides = {
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">standalone</Badge>
|
||||
);
|
||||
},
|
||||
course_bound: (info) =>
|
||||
info.getValue() ? (
|
||||
<Badge variant="default" className="text-xs">In a course</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">Not in a course</Badge>
|
||||
),
|
||||
};
|
||||
|
||||
export function buildDataColumns(attributes, rowActions) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Eye, Archive, ArchiveRestore, Info, NotebookPen } from "lucide-react";
|
||||
import { Eye, Archive, ArchiveRestore, Info, NotebookPen, ArrowUp, ArrowDown } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
|
||||
export function buildRowActions({ navigate, onArchive, onRestore, onMoveUp, onMoveDown, showArchived, tasks = [] }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
@@ -16,6 +16,23 @@ export function buildRowActions({ navigate, onArchive, onRestore, showArchived }
|
||||
separator: true,
|
||||
className: "text-sky-600",
|
||||
},
|
||||
{
|
||||
key: "move-up",
|
||||
label: "Move Up",
|
||||
icon: <ArrowUp className="size-4" />,
|
||||
onClick: (row) => onMoveUp(row),
|
||||
hidden: () => showArchived,
|
||||
disabled: (row) => tasks.findIndex((t) => t.task_id === row.task_id) <= 0,
|
||||
},
|
||||
{
|
||||
key: "move-down",
|
||||
label: "Move Down",
|
||||
icon: <ArrowDown className="size-4" />,
|
||||
onClick: (row) => onMoveDown(row),
|
||||
hidden: () => showArchived,
|
||||
separator: true,
|
||||
disabled: (row) => tasks.findIndex((t) => t.task_id === row.task_id) >= tasks.length - 1,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
label: "Archive",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// config/task_completion/rowActions.config.jsx
|
||||
import { Eye, Archive, RotateCcw } from "lucide-react";
|
||||
import { Eye, Archive, RotateCcw, ShieldCheck } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
|
||||
export function buildRowActions({ navigate, onArchive, onRestore, onReview, showArchived }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
@@ -9,6 +9,13 @@ export function buildRowActions({ navigate, onArchive, onRestore, showArchived }
|
||||
icon: <Eye className="size-4" />,
|
||||
onClick: (row) => navigate(`${row.completion_id}/view`),
|
||||
},
|
||||
{
|
||||
key: "review",
|
||||
label: "Review",
|
||||
icon: <ShieldCheck className="size-4" />,
|
||||
onClick: (row) => onReview(row),
|
||||
hidden: (row) => showArchived || row.status !== "submitted",
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
label: "Archive",
|
||||
|
||||
@@ -8,11 +8,12 @@ import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
||||
import { downloadAsset } from "@/utils/media.util";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AudioBlock } from "@/components/generic/Blocks/Admin/AudioBlock";
|
||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||
import AssetPageLoader from "@/components/generic/AssetLoader";
|
||||
|
||||
// ─── Shared meta row (same pattern as other viewers) ─────────────────────────
|
||||
|
||||
@@ -30,7 +31,7 @@ function MetaRow({ label, value }) {
|
||||
|
||||
export default function ViewAudioAsset() {
|
||||
const { assetId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigate();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||
@@ -41,7 +42,7 @@ export default function ViewAudioAsset() {
|
||||
}, [assetId]);
|
||||
|
||||
if (loading) {
|
||||
return <MediaFallback className="h-96 rounded-lg" />;
|
||||
return <AssetPageLoader />;
|
||||
}
|
||||
|
||||
if (!selectedAsset) {
|
||||
@@ -56,11 +57,11 @@ export default function ViewAudioAsset() {
|
||||
const a = selectedAsset;
|
||||
|
||||
const audioContent = {
|
||||
url: streamUrl,
|
||||
title: a.display_name ?? a.original_name,
|
||||
artist: a.description ?? "",
|
||||
url: streamUrl,
|
||||
title: a.display_name ?? a.original_name,
|
||||
artist: a.description ?? "",
|
||||
thumbnail: thumbnailUrl ?? a.thumbnail_url ?? null,
|
||||
tag: a.extension?.toUpperCase() ?? "",
|
||||
tag: a.extension?.toUpperCase() ?? "",
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -87,10 +88,7 @@ export default function ViewAudioAsset() {
|
||||
{streamUrl ? (
|
||||
<AudioBlock content={audioContent} />
|
||||
) : (
|
||||
<div className="rounded-lg border bg-muted/30 flex flex-col items-center justify-center gap-4 py-20">
|
||||
<Music2 className="h-16 w-16 text-muted-foreground/40" />
|
||||
<p className="text-muted-foreground text-sm">Audio file URL not available.</p>
|
||||
</div>
|
||||
<MediaFallback className="size-full" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -99,30 +97,30 @@ export default function ViewAudioAsset() {
|
||||
<div className="rounded-lg border bg-card p-4 space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">File Info</p>
|
||||
<MetaRow label="Original Name" value={a.original_name} />
|
||||
<MetaRow label="Extension" value={a.extension} />
|
||||
<MetaRow label="File Size" value={a.file_size ? `${(a.file_size / 1024).toFixed(1)} KB` : null} />
|
||||
<MetaRow label="MIME Type" value={a.mime_type} />
|
||||
<MetaRow label="Extension" value={a.extension} />
|
||||
<MetaRow label="File Size" value={a.file_size ? `${(a.file_size / 1024).toFixed(1)} KB` : null} />
|
||||
<MetaRow label="MIME Type" value={a.mime_type} />
|
||||
<Separator className="my-2" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
|
||||
<MetaRow label="Provider" value={a.storage_provider} />
|
||||
<MetaRow label="Bucket" value={a.storage_bucket} />
|
||||
<MetaRow label="Key" value={a.storage_key} />
|
||||
<MetaRow label="Provider" value={a.storage_provider} />
|
||||
<MetaRow label="Bucket" value={a.storage_bucket} />
|
||||
<MetaRow label="Key" value={a.storage_key} />
|
||||
<Separator className="my-2" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Access</p>
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
{a.is_public
|
||||
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
|
||||
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
||||
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
||||
}
|
||||
</div>
|
||||
<MetaRow label="Access Level" value={a.access_level} />
|
||||
<MetaRow label="Owner Type" value={a.owner_type} />
|
||||
<MetaRow label="Owner ID" value={a.owner_id} />
|
||||
<MetaRow label="Access Level" value={a.access_level} />
|
||||
<MetaRow label="Owner Type" value={a.owner_type} />
|
||||
<MetaRow label="Owner ID" value={a.owner_id} />
|
||||
<Separator className="my-2" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
|
||||
<MetaRow label="Created By" value={a.createdBy} />
|
||||
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
|
||||
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
||||
<MetaRow label="Created By" value={a.createdBy} />
|
||||
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
|
||||
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
||||
</div>
|
||||
|
||||
{a.description && (
|
||||
|
||||
@@ -12,6 +12,8 @@ import { Spinner } from "@/components/ui/spinner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||
import AssetPageLoader from "@/components/generic/AssetLoader";
|
||||
|
||||
function MetaRow({ label, value }) {
|
||||
if (!value && value !== 0) return null;
|
||||
@@ -38,11 +40,7 @@ export default function ViewDocumentAsset() {
|
||||
}, [assetId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<Spinner className="size-8" />
|
||||
</div>
|
||||
);
|
||||
return <AssetPageLoader />;
|
||||
}
|
||||
|
||||
if (!selectedAsset) {
|
||||
|
||||
@@ -8,10 +8,12 @@ import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
||||
import { downloadAsset } from "@/utils/media.util";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||
import AssetPageLoader from "@/components/generic/AssetLoader";
|
||||
|
||||
|
||||
function MetaRow({ label, value }) {
|
||||
if (!value && value !== 0) return null;
|
||||
@@ -25,7 +27,7 @@ function MetaRow({ label, value }) {
|
||||
|
||||
export default function ViewImageAsset() {
|
||||
const { assetId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigate();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||
@@ -36,7 +38,7 @@ export default function ViewImageAsset() {
|
||||
}, [assetId]);
|
||||
|
||||
if (loading) {
|
||||
return <MediaFallback className="h-96 rounded-lg" />;
|
||||
return <AssetPageLoader />;
|
||||
}
|
||||
|
||||
if (!selectedAsset) {
|
||||
@@ -80,7 +82,7 @@ export default function ViewImageAsset() {
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">No preview available.</p>
|
||||
<MediaFallback className="size-full" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -89,31 +91,31 @@ export default function ViewImageAsset() {
|
||||
<div className="rounded-lg border bg-card p-4 space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">File Info</p>
|
||||
<MetaRow label="Original Name" value={a.original_name} />
|
||||
<MetaRow label="Extension" value={a.extension} />
|
||||
<MetaRow label="File Size" value={a.file_size ? `${(a.file_size / 1024).toFixed(1)} KB` : null} />
|
||||
<MetaRow label="Resolution" value={a.resolution} />
|
||||
<MetaRow label="Dimensions" value={a.width && a.height ? `${a.width} × ${a.height}` : null} />
|
||||
<MetaRow label="Extension" value={a.extension} />
|
||||
<MetaRow label="File Size" value={a.file_size ? `${(a.file_size / 1024).toFixed(1)} KB` : null} />
|
||||
<MetaRow label="Resolution" value={a.resolution} />
|
||||
<MetaRow label="Dimensions" value={a.width && a.height ? `${a.width} × ${a.height}` : null} />
|
||||
<Separator className="my-2" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
|
||||
<MetaRow label="Provider" value={a.storage_provider} />
|
||||
<MetaRow label="Bucket" value={a.storage_bucket} />
|
||||
<MetaRow label="Key" value={a.storage_key} />
|
||||
<MetaRow label="Provider" value={a.storage_provider} />
|
||||
<MetaRow label="Bucket" value={a.storage_bucket} />
|
||||
<MetaRow label="Key" value={a.storage_key} />
|
||||
<Separator className="my-2" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Access</p>
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
{a.is_public
|
||||
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
|
||||
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
||||
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
||||
}
|
||||
</div>
|
||||
<MetaRow label="Access Level" value={a.access_level} />
|
||||
<MetaRow label="Owner Type" value={a.owner_type} />
|
||||
<MetaRow label="Owner ID" value={a.owner_id} />
|
||||
<MetaRow label="Access Level" value={a.access_level} />
|
||||
<MetaRow label="Owner Type" value={a.owner_type} />
|
||||
<MetaRow label="Owner ID" value={a.owner_id} />
|
||||
<Separator className="my-2" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
|
||||
<MetaRow label="Created By" value={a.createdBy} />
|
||||
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
|
||||
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
||||
<MetaRow label="Created By" value={a.createdBy} />
|
||||
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
|
||||
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
||||
</div>
|
||||
|
||||
{a.description && (
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||
import AssetPageLoader from "@/components/generic/AssetLoader";
|
||||
|
||||
function MetaRow({ label, value }) {
|
||||
if (!value && value !== 0) return null;
|
||||
@@ -46,7 +47,7 @@ export default function ViewVideoAsset() {
|
||||
}, [assetId]);
|
||||
|
||||
if (loading) {
|
||||
return <MediaFallback className="h-96 rounded-lg" />;
|
||||
return <AssetPageLoader />;
|
||||
}
|
||||
|
||||
if (!selectedAsset) {
|
||||
@@ -96,7 +97,7 @@ export default function ViewVideoAsset() {
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">No video source available.</p>
|
||||
<MediaFallback className="size-full" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "@/components/ui/command";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
|
||||
import RoadmapBuilder from "@/modules/admin/components/courses/RoadmapBuilder";
|
||||
import { TIER_COLOR_OPTIONS } from "@/utils/tierColors";
|
||||
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||
|
||||
@@ -38,30 +39,21 @@ import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||
const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
description: z.string().optional(),
|
||||
course_code: z.string().optional(),
|
||||
course_code: z.string().min(1, "Course Code is required."),
|
||||
order_index: z.coerce.number().min(0).default(0),
|
||||
level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
|
||||
subscription: z.string().min(1, "Subscription is required.").default("free"),
|
||||
objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") })).default([]),
|
||||
objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") }))
|
||||
.min(1, "At least one learning objective is required."),
|
||||
achievement_keys: z.array(z.string()).max(1).default([]),
|
||||
|
||||
unit_title: z.string().min(1, "Unit title is required."),
|
||||
unit_description: z.string().optional(),
|
||||
unit_order: z.coerce.number().min(0).default(0),
|
||||
|
||||
lesson_title: z.string().min(1, "Lesson title is required."),
|
||||
lesson_description: z.string().optional(),
|
||||
lesson_order: z.coerce.number().min(0).default(0),
|
||||
lesson_objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") })).default([]),
|
||||
});
|
||||
|
||||
// ─── Steps config ─────────────────────────────────────────────────────────────
|
||||
|
||||
const STEPS = [
|
||||
{ label: "Basic Info", description: "Title, level & objectives" },
|
||||
{ label: "First Unit", description: "The course's first unit" },
|
||||
{ label: "First Lesson", description: "A lesson inside that unit" },
|
||||
{ label: "Rewards", description: "Badge & achievements" },
|
||||
{ label: "Basic Info", description: "Title, level & objectives" },
|
||||
{ label: "Roadmap", description: "Units & lessons" },
|
||||
{ label: "Rewards", description: "Badge & achievements" },
|
||||
];
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -139,10 +131,11 @@ function StepIndicator({ steps, current, onStepClick }) {
|
||||
|
||||
export default function AddCourse() {
|
||||
const navigate = useNavigate();
|
||||
const { createCourse, createUnit, createLesson, loading } = useCourses();
|
||||
const { createCourseFull, loading } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [roadmapUnits, setRoadmapUnits] = useState([]);
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
useEffect(() => {
|
||||
api.get("/admin/tiers/categories")
|
||||
@@ -159,9 +152,10 @@ export default function AddCourse() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
trigger,
|
||||
control,
|
||||
setValue,
|
||||
getValues,
|
||||
trigger,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -174,22 +168,12 @@ export default function AddCourse() {
|
||||
subscription: "free",
|
||||
objectives: [],
|
||||
achievement_keys: [],
|
||||
unit_title: "",
|
||||
unit_description: "",
|
||||
unit_order: 0,
|
||||
lesson_title: "",
|
||||
lesson_description: "",
|
||||
lesson_order: 0,
|
||||
lesson_objectives: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields: objectiveFields, append: appendObjective, remove: removeObjective } =
|
||||
useFieldArray({ control, name: "objectives" });
|
||||
|
||||
const { fields: lessonObjectiveFields, append: appendLessonObjective, remove: removeLessonObjective } =
|
||||
useFieldArray({ control, name: "lesson_objectives" });
|
||||
|
||||
const currentAchKeys = useWatch({ control, name: "achievement_keys" });
|
||||
const watchedTitle = useWatch({ control, name: "title" });
|
||||
const watchedLevel = useWatch({ control, name: "level" });
|
||||
@@ -210,29 +194,31 @@ export default function AddCourse() {
|
||||
}
|
||||
};
|
||||
|
||||
const STEP_FIELDS = [
|
||||
["title", "subscription", "objectives"],
|
||||
["unit_title", "unit_order"],
|
||||
["lesson_title", "lesson_order", "lesson_objectives"],
|
||||
[],
|
||||
];
|
||||
|
||||
const handleNext = async (e) => {
|
||||
// The Next button occupies the same DOM position as the eventual
|
||||
// type="submit" Create Course button. Advancing the step re-renders
|
||||
// that node's type attribute in place *before* the browser evaluates
|
||||
// this click's default action, which would otherwise submit the form.
|
||||
e.preventDefault();
|
||||
const fields = STEP_FIELDS[currentStep];
|
||||
if (fields?.length) {
|
||||
const valid = await trigger(fields);
|
||||
if (!valid) return;
|
||||
}
|
||||
// Nothing here writes to the API until Finish — Basic Info just validates
|
||||
// and advances, Roadmap is held as local draft state (see roadmapUnits),
|
||||
// and Finish fires the whole course + roadmap + rewards in one request.
|
||||
const onBasicInfoSubmit = () => {
|
||||
setCurrentStep((s) => s + 1);
|
||||
};
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const coursePayload = {
|
||||
// Guards the step indicator: jumping straight to Roadmap/Rewards must not
|
||||
// bypass the Basic Info required fields, so re-validate before honoring
|
||||
// any click that leaves step 0.
|
||||
const goToStep = async (target) => {
|
||||
if (target > 0) {
|
||||
const valid = await trigger(["title", "course_code", "objectives"]);
|
||||
if (!valid) {
|
||||
setCurrentStep(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setCurrentStep(target);
|
||||
};
|
||||
|
||||
const handleFinish = async () => {
|
||||
const values = getValues();
|
||||
|
||||
const result = await createCourseFull({
|
||||
title: values.title,
|
||||
description: values.description,
|
||||
course_code: values.course_code || null,
|
||||
@@ -240,37 +226,26 @@ export default function AddCourse() {
|
||||
level: values.level || null,
|
||||
subscription: values.subscription,
|
||||
objectives: values.objectives.map((o) => o.text),
|
||||
achievement_keys: values.achievement_keys,
|
||||
achievement_keys: currentAchKeys,
|
||||
badge_color: badgeColor,
|
||||
badge_asset_id: badgeAssetId ?? null,
|
||||
badge_image_url: badgeImageUrl ?? null,
|
||||
units: roadmapUnits.map((u) => ({
|
||||
unit_id: u.unit_id,
|
||||
title: u.title,
|
||||
description: u.description,
|
||||
lessons: u.lessons.map((l) => ({
|
||||
lesson_id: l.lesson_id,
|
||||
title: l.title,
|
||||
description: l.description,
|
||||
objectives: l.objectives ?? [],
|
||||
})),
|
||||
})),
|
||||
createdBy: user?.user_id ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const courseResult = await createCourse(coursePayload);
|
||||
const newCourse = courseResult?.data?.data ?? null;
|
||||
const newCourse = result?.data?.data ?? null;
|
||||
if (!newCourse) return;
|
||||
|
||||
const unitResult = await createUnit(newCourse.course_id, {
|
||||
title: values.unit_title,
|
||||
description: values.unit_description,
|
||||
order: values.unit_order,
|
||||
createdBy: user?.user_id,
|
||||
});
|
||||
const newUnit = unitResult?.data?.data ?? null;
|
||||
if (!newUnit) {
|
||||
navigate(`/admin/courses/${newCourse.course_id}/view`);
|
||||
return;
|
||||
}
|
||||
|
||||
await createLesson(newCourse.course_id, newUnit.unit_id, {
|
||||
title: values.lesson_title,
|
||||
description: values.lesson_description,
|
||||
order: values.lesson_order,
|
||||
objectives: values.lesson_objectives.map((o) => o.text),
|
||||
createdBy: user?.user_id,
|
||||
});
|
||||
|
||||
navigate(`/admin/courses/${newCourse.course_id}/view`);
|
||||
};
|
||||
|
||||
@@ -303,9 +278,9 @@ export default function AddCourse() {
|
||||
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
|
||||
<StepIndicator steps={STEPS} current={currentStep} onStepClick={setCurrentStep} />
|
||||
<StepIndicator steps={STEPS} current={currentStep} onStepClick={goToStep} />
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
<form onSubmit={handleSubmit(onBasicInfoSubmit)} className="space-y-5">
|
||||
|
||||
{/* ── Step 0: Basic Info ── */}
|
||||
{currentStep === 0 && (
|
||||
@@ -326,7 +301,9 @@ export default function AddCourse() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="course_code">Course Code</Label>
|
||||
<Label htmlFor="course_code">
|
||||
Course Code <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input id="course_code" placeholder="e.g. RE-101" {...register("course_code")} />
|
||||
<FieldError message={errors.course_code?.message} />
|
||||
</div>
|
||||
@@ -382,9 +359,10 @@ export default function AddCourse() {
|
||||
|
||||
<SectionCard
|
||||
title="Learning Objectives"
|
||||
description="What will learners be able to do after completing this course?"
|
||||
description="What will learners be able to do after completing this course? At least one is required."
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<FieldError message={errors.objectives?.message} />
|
||||
{objectiveFields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
@@ -421,95 +399,13 @@ export default function AddCourse() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Step 1: First Unit ── */}
|
||||
{/* ── Step 1: Roadmap ── */}
|
||||
{currentStep === 1 && (
|
||||
<SectionCard title="First Unit" description="Every course needs at least one unit to hold its lessons.">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="unit_title">
|
||||
Title <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input id="unit_title" placeholder="e.g. Getting Started" {...register("unit_title")} />
|
||||
<FieldError message={errors.unit_title?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="unit_description">Description</Label>
|
||||
<Textarea id="unit_description" placeholder="Optional unit description" rows={3} {...register("unit_description")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-w-[120px]">
|
||||
<Label htmlFor="unit_order">Order</Label>
|
||||
<Input id="unit_order" type="number" min={0} {...register("unit_order")} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
<RoadmapBuilder units={roadmapUnits} onUnitsChange={setRoadmapUnits} />
|
||||
)}
|
||||
|
||||
{/* ── Step 2: First Lesson ── */}
|
||||
{/* ── Step 2: Rewards ── */}
|
||||
{currentStep === 2 && (
|
||||
<>
|
||||
<SectionCard title="First Lesson" description="Add the first lesson inside that unit.">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lesson_title">
|
||||
Title <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input id="lesson_title" placeholder="e.g. Welcome to the Course" {...register("lesson_title")} />
|
||||
<FieldError message={errors.lesson_title?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lesson_description">Description</Label>
|
||||
<Textarea id="lesson_description" placeholder="Optional lesson description" rows={3} {...register("lesson_description")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-w-[120px]">
|
||||
<Label htmlFor="lesson_order">Order</Label>
|
||||
<Input id="lesson_order" type="number" min={0} {...register("lesson_order")} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Lesson Objectives"
|
||||
description="What will learners be able to do after this lesson?"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{lessonObjectiveFields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
placeholder={`Objective ${index + 1}`}
|
||||
{...register(`lesson_objectives.${index}.text`)}
|
||||
/>
|
||||
<FieldError message={errors.lesson_objectives?.[index]?.text?.message} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-0.5 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeLessonObjective(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full mt-1"
|
||||
onClick={() => appendLessonObjective({ text: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Objective
|
||||
</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Rewards ── */}
|
||||
{currentStep === 3 && (
|
||||
<SectionCard
|
||||
title="Rewards"
|
||||
description="Badge and achievements awarded to learners who complete this course."
|
||||
@@ -716,15 +612,21 @@ export default function AddCourse() {
|
||||
{currentStep === 0 ? "Cancel" : "Back"}
|
||||
</Button>
|
||||
|
||||
{currentStep < STEPS.length - 1 ? (
|
||||
<Button type="button" onClick={handleNext}>
|
||||
{currentStep === 0 ? (
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Next
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
) : currentStep < STEPS.length - 1 ? (
|
||||
<Button type="button" onClick={() => setCurrentStep((s) => s + 1)}>
|
||||
Next
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" disabled={loading}>
|
||||
<Button type="button" disabled={loading} onClick={handleFinish}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Create Course
|
||||
Finish
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
@@ -7,15 +8,20 @@ import { ArrowLeft } from "lucide-react";
|
||||
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
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";
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
description: z.string().optional(),
|
||||
subscription: z.string().optional(),
|
||||
});
|
||||
|
||||
function FieldError({ message }) {
|
||||
@@ -28,13 +34,22 @@ export default function AddLibraryUnit() {
|
||||
const { createUnit, loading } = useLibrary();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm({
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
useEffect(() => {
|
||||
api.get("/admin/tiers/categories")
|
||||
.then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const { register, handleSubmit, control, setValue, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "" },
|
||||
defaultValues: { title: "", description: "", subscription: "" },
|
||||
});
|
||||
|
||||
const watchedSubscr = useWatch({ control, name: "subscription" });
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const result = await createUnit({ ...data, createdBy: user?.user_id });
|
||||
const result = await createUnit({ ...data, subscription: data.subscription || null, createdBy: user?.user_id });
|
||||
if (!result) return;
|
||||
navigate("/admin/units");
|
||||
};
|
||||
@@ -71,6 +86,29 @@ export default function AddLibraryUnit() {
|
||||
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Subscription</Label>
|
||||
<Select
|
||||
value={watchedSubscr || "__open"}
|
||||
onValueChange={(val) => setValue("subscription", val === "__open" ? "" : val, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No tier gate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__open">No tier gate (open)</SelectItem>
|
||||
{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 any course it may later be attached to.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
@@ -8,15 +8,20 @@ import { ArrowLeft } from "lucide-react";
|
||||
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
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";
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
description: z.string().optional(),
|
||||
subscription: z.string().optional(),
|
||||
});
|
||||
|
||||
function FieldError({ message }) {
|
||||
@@ -30,23 +35,32 @@ export default function EditLibraryUnit() {
|
||||
const { fetchUnit, updateUnit, unit, loading } = useLibrary();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors } } = useForm({
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
useEffect(() => {
|
||||
api.get("/admin/tiers/categories")
|
||||
.then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const { register, handleSubmit, reset, control, setValue, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "" },
|
||||
defaultValues: { title: "", description: "", subscription: "" },
|
||||
});
|
||||
|
||||
const watchedSubscr = useWatch({ control, name: "subscription" });
|
||||
|
||||
useEffect(() => {
|
||||
fetchUnit(unitId);
|
||||
}, [unitId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (unit && String(unit.unit_id) === String(unitId)) {
|
||||
reset({ title: unit.title ?? "", description: unit.description ?? "" });
|
||||
reset({ title: unit.title ?? "", description: unit.description ?? "", subscription: unit.subscription ?? "" });
|
||||
}
|
||||
}, [unit, unitId, reset]);
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const result = await updateUnit(unitId, { ...data, updatedBy: user?.user_id });
|
||||
const result = await updateUnit(unitId, { ...data, subscription: data.subscription || null, updatedBy: user?.user_id });
|
||||
if (!result) return;
|
||||
navigate(`/admin/units/${unitId}/view`);
|
||||
};
|
||||
@@ -83,6 +97,29 @@ export default function EditLibraryUnit() {
|
||||
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Subscription</Label>
|
||||
<Select
|
||||
value={watchedSubscr || "__open"}
|
||||
onValueChange={(val) => setValue("subscription", val === "__open" ? "" : val, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No tier gate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__open">No tier gate (open)</SelectItem>
|
||||
{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 any course it may later be attached to.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAuth } from "@/contexts/AuthContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -26,6 +27,8 @@ const schema = z.object({
|
||||
message: z.string().min(1, "Message is required."),
|
||||
target_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { required_error: "Target is required." }),
|
||||
target_id: z.string().nullable().optional(),
|
||||
show_in_sticky: z.boolean().optional(),
|
||||
show_in_notifications: z.boolean().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
|
||||
ctx.addIssue({
|
||||
@@ -34,6 +37,14 @@ const schema = z.object({
|
||||
path: ["target_id"],
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.show_in_sticky && !data.show_in_notifications) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Select where to show this notification (Sticky or Notifications).",
|
||||
path: ["show_in_sticky"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -77,16 +88,20 @@ export default function AddNotificationBroadcast() {
|
||||
message: "",
|
||||
target_type: undefined,
|
||||
target_id: null,
|
||||
show_in_sticky: false,
|
||||
show_in_notifications: true,
|
||||
},
|
||||
});
|
||||
|
||||
const targetType = watch("target_type");
|
||||
const targetId = watch("target_id");
|
||||
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
|
||||
const showInSticky = watch("show_in_sticky");
|
||||
const showInNotifications = watch("show_in_notifications");
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Notifications", to: "/admin/notifications" },
|
||||
{ label: "Announcements", to: "/admin/announcements" },
|
||||
{ label: "New" },
|
||||
];
|
||||
|
||||
@@ -94,11 +109,13 @@ export default function AddNotificationBroadcast() {
|
||||
const payload = {
|
||||
...values,
|
||||
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
|
||||
show_in_sticky: values.show_in_sticky ?? false,
|
||||
show_in_notifications: values.show_in_notifications ?? true,
|
||||
createdBy: user?.user_id ?? null,
|
||||
};
|
||||
|
||||
const res = await createBroadcast(payload);
|
||||
if (res) navigate("/admin/notifications");
|
||||
if (res) navigate("/admin/announcements");
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -109,7 +126,7 @@ export default function AddNotificationBroadcast() {
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-2xl pb-10">
|
||||
<h1 className="text-2xl font-semibold tracking-tight mb-1">New notification</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight mb-1">New announcement</h1>
|
||||
<p className="text-sm text-muted-foreground mb-6">Compose an announcement. It's saved as a draft until you send it.</p>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
@@ -127,7 +144,7 @@ export default function AddNotificationBroadcast() {
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Target" description="Who receives this notification when it's sent.">
|
||||
<SectionCard title="Target" description="Who receives this announcement when it's sent.">
|
||||
<div>
|
||||
<Select
|
||||
value={targetType}
|
||||
@@ -165,6 +182,32 @@ export default function AddNotificationBroadcast() {
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Display" description="Where clients/admins can see this announcement.">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id="show_in_sticky"
|
||||
checked={showInSticky === true}
|
||||
onCheckedChange={(v) => setValue("show_in_sticky", v === true, { shouldValidate: true })}
|
||||
/>
|
||||
<Label htmlFor="show_in_sticky" className="cursor-pointer">
|
||||
Show in Sticky Announcements
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id="show_in_notifications"
|
||||
checked={showInNotifications === true}
|
||||
onCheckedChange={(v) => setValue("show_in_notifications", v === true, { shouldValidate: true })}
|
||||
/>
|
||||
<Label htmlFor="show_in_notifications" className="cursor-pointer">
|
||||
Show in Notifications
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
||||
Cancel
|
||||
|
||||
@@ -6,7 +6,7 @@ import ArchivedNotificationBroadcastsTable from "../../components/notifications/
|
||||
export default function ArchivedNotificationBroadcastList() {
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
|
||||
{ label: "Notifications", to: `/admin/notifications` },
|
||||
{ label: "Announcements", to: `/admin/announcements` },
|
||||
{ label: "Archived" },
|
||||
];
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useAuth } from "@/contexts/AuthContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -27,6 +28,8 @@ const schema = z.object({
|
||||
message: z.string().min(1, "Message is required."),
|
||||
target_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { required_error: "Target is required." }),
|
||||
target_id: z.string().nullable().optional(),
|
||||
show_in_sticky: z.boolean().optional(),
|
||||
show_in_notifications: z.boolean().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
|
||||
ctx.addIssue({
|
||||
@@ -35,6 +38,14 @@ const schema = z.object({
|
||||
path: ["target_id"],
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.show_in_sticky && !data.show_in_notifications) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Select where to show this notification (Sticky or Notifications).",
|
||||
path: ["show_in_sticky"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -80,16 +91,20 @@ export default function EditNotificationBroadcast() {
|
||||
message: "",
|
||||
target_type: undefined,
|
||||
target_id: null,
|
||||
show_in_sticky: false,
|
||||
show_in_notifications: true,
|
||||
},
|
||||
});
|
||||
|
||||
const targetType = watch("target_type");
|
||||
const targetId = watch("target_id");
|
||||
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
|
||||
const showInSticky = watch("show_in_sticky");
|
||||
const showInNotifications = watch("show_in_notifications");
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Notifications", to: "/admin/notifications" },
|
||||
{ label: "Announcements", to: "/admin/announcements" },
|
||||
{ label: "Edit" },
|
||||
];
|
||||
|
||||
@@ -105,6 +120,8 @@ export default function EditNotificationBroadcast() {
|
||||
message: b.message ?? "",
|
||||
target_type: b.target_type ?? undefined,
|
||||
target_id: b.target_id ?? null,
|
||||
show_in_sticky: b.show_in_sticky ?? false,
|
||||
show_in_notifications: b.show_in_notifications ?? true,
|
||||
});
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -114,11 +131,13 @@ export default function EditNotificationBroadcast() {
|
||||
const payload = {
|
||||
...values,
|
||||
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
|
||||
show_in_sticky: values.show_in_sticky ?? false,
|
||||
show_in_notifications: values.show_in_notifications ?? true,
|
||||
updatedBy: user?.user_id ?? null,
|
||||
};
|
||||
|
||||
const res = await updateBroadcast(broadcastId, payload);
|
||||
if (res) navigate("/admin/notifications");
|
||||
if (res) navigate("/admin/announcements");
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -129,8 +148,8 @@ export default function EditNotificationBroadcast() {
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-2xl pb-10">
|
||||
<h1 className="text-2xl font-semibold tracking-tight mb-1">Edit notification</h1>
|
||||
<p className="text-sm text-muted-foreground mb-6">Only draft notifications can be edited.</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight mb-1">Edit announcement</h1>
|
||||
<p className="text-sm text-muted-foreground mb-6">Only draft announcements can be edited.</p>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
|
||||
@@ -147,7 +166,7 @@ export default function EditNotificationBroadcast() {
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Target" description="Who receives this notification when it's sent.">
|
||||
<SectionCard title="Target" description="Who receives this announcement when it's sent.">
|
||||
<div>
|
||||
<Select
|
||||
value={targetType}
|
||||
@@ -185,6 +204,32 @@ export default function EditNotificationBroadcast() {
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Display" description="Where clients/admins can see this announcement.">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id="show_in_sticky"
|
||||
checked={showInSticky === true}
|
||||
onCheckedChange={(v) => setValue("show_in_sticky", v === true, { shouldValidate: true })}
|
||||
/>
|
||||
<Label htmlFor="show_in_sticky" className="cursor-pointer">
|
||||
Show in Sticky Announcements
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id="show_in_notifications"
|
||||
checked={showInNotifications === true}
|
||||
onCheckedChange={(v) => setValue("show_in_notifications", v === true, { shouldValidate: true })}
|
||||
/>
|
||||
<Label htmlFor="show_in_notifications" className="cursor-pointer">
|
||||
Show in Notifications
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
||||
Cancel
|
||||
|
||||
@@ -77,20 +77,20 @@ function EditNotificationTemplateInner() {
|
||||
message: message.trim(),
|
||||
publish,
|
||||
});
|
||||
if (result) navigate("/admin/notification-templates");
|
||||
if (result) navigate("/admin/announcement-templates");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title="Edit Notification Template - STARR" />
|
||||
<PageMeta title="Edit Announcement Template - STARR" />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
<AppBreadcrumb items={[
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Notifications", to: "/admin/notifications" },
|
||||
{ label: "Templates", to: "/admin/notification-templates" },
|
||||
{ label: "Announcements", to: "/admin/announcements" },
|
||||
{ label: "Templates", to: "/admin/announcement-templates" },
|
||||
{ label: template?.label ?? "Edit" },
|
||||
]} />
|
||||
</div>
|
||||
@@ -101,14 +101,14 @@ function EditNotificationTemplateInner() {
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-xl font-semibold">Edit Notification Template</h1>
|
||||
<h1 className="text-xl font-semibold">Edit Announcement Template</h1>
|
||||
{template && (
|
||||
<Badge variant="outline" className={cn("gap-1", status.badgeClass)}>
|
||||
<Send className="h-3 w-3" /> {status.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Update this notification's title and message.</p>
|
||||
<p className="text-sm text-muted-foreground">Update this template's title and message.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function NotificationBroadcastList() {
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Notifications" },
|
||||
{ label: "Announcements" },
|
||||
];
|
||||
|
||||
const total = pagination?.totalRecords ?? broadcasts.length;
|
||||
@@ -68,25 +68,25 @@ export default function NotificationBroadcastList() {
|
||||
{/* ── Header ─────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Notifications</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Announcements</h1>
|
||||
<p className="text-sm text-muted-foreground">Compose and send announcements to admins and users</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => navigate("/admin/notification-templates")}>
|
||||
<Button variant="outline" onClick={() => navigate("/admin/announcement-templates")}>
|
||||
<FileText className="size-4" />
|
||||
Templates
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate("/admin/notifications/settings")}>
|
||||
<Button variant="outline" onClick={() => navigate("/admin/announcements/settings")}>
|
||||
<Settings className="size-4" />
|
||||
Settings
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate("/admin/notifications/archived")}>
|
||||
<Button variant="outline" onClick={() => navigate("/admin/announcements/archived")}>
|
||||
<Archive className="size-4" />
|
||||
Archived
|
||||
</Button>
|
||||
<Button onClick={() => navigate("/admin/notifications/add")}>
|
||||
<Button onClick={() => navigate("/admin/announcements/add")}>
|
||||
<Plus className="size-4" />
|
||||
New notification
|
||||
New announcement
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,7 +115,7 @@ export default function NotificationBroadcastList() {
|
||||
<div className="relative flex-1 min-w-[160px]">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search notifications..."
|
||||
placeholder="Search announcements..."
|
||||
className="pl-8 bg-background"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
@@ -129,7 +129,7 @@ export default function NotificationBroadcastList() {
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : broadcasts.length === 0 ? (
|
||||
<EmptyState onCreate={() => navigate("/admin/notifications/add")} />
|
||||
<EmptyState onCreate={() => navigate("/admin/announcements/add")} />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@@ -137,8 +137,8 @@ export default function NotificationBroadcastList() {
|
||||
<BroadcastCard
|
||||
key={b.broadcast_id}
|
||||
broadcast={b}
|
||||
onView={() => navigate(`/admin/notifications/${b.broadcast_id}/view`)}
|
||||
onEdit={() => navigate(`/admin/notifications/${b.broadcast_id}/edit`)}
|
||||
onView={() => navigate(`/admin/announcements/${b.broadcast_id}/view`)}
|
||||
onEdit={() => navigate(`/admin/announcements/${b.broadcast_id}/edit`)}
|
||||
onSend={() => handleSend(b.broadcast_id)}
|
||||
onArchive={() => handleArchive(b.broadcast_id)}
|
||||
/>
|
||||
@@ -272,12 +272,12 @@ function EmptyState({ onCreate }) {
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center border rounded-lg bg-background">
|
||||
<Megaphone className="size-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">No notifications yet</p>
|
||||
<p className="font-medium">No announcements yet</p>
|
||||
<p className="text-sm text-muted-foreground">Compose your first announcement to admins or users.</p>
|
||||
</div>
|
||||
<Button onClick={onCreate}>
|
||||
<Plus className="size-4" />
|
||||
New notification
|
||||
New announcement
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -31,14 +31,14 @@ export default function NotificationSettings() {
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Notifications", to: "/admin/notifications" },
|
||||
{ label: "Announcements", to: "/admin/announcements" },
|
||||
{ label: "Settings" },
|
||||
];
|
||||
|
||||
async function fetchSettings() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get("/admin/notification-settings");
|
||||
const { data } = await api.get("/admin/announcement-settings");
|
||||
setSettings(data?.data ?? []);
|
||||
} catch (err) {
|
||||
toast(err?.response?.data?.message ?? "Failed to load notification settings.");
|
||||
@@ -52,7 +52,7 @@ export default function NotificationSettings() {
|
||||
async function handleToggle(jobName, enabled) {
|
||||
setSavingJob(jobName);
|
||||
try {
|
||||
const { data } = await api.patch(`/admin/notification-settings/${jobName}`, {
|
||||
const { data } = await api.patch(`/admin/announcement-settings/${jobName}`, {
|
||||
enabled,
|
||||
updatedBy: user?.user_id ?? null,
|
||||
});
|
||||
@@ -71,7 +71,7 @@ export default function NotificationSettings() {
|
||||
async function handlePresetChange(jobName, preset) {
|
||||
setSavingJob(jobName);
|
||||
try {
|
||||
const { data } = await api.patch(`/admin/notification-settings/${jobName}`, {
|
||||
const { data } = await api.patch(`/admin/announcement-settings/${jobName}`, {
|
||||
preset,
|
||||
updatedBy: user?.user_id ?? null,
|
||||
});
|
||||
@@ -94,11 +94,11 @@ export default function NotificationSettings() {
|
||||
|
||||
<div className="w-full max-w-2xl pb-10 space-y-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/notifications")} aria-label="Back">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/announcements")} aria-label="Back">
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Notification Settings</h1>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Announcement Settings</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Toggle and reschedule automatic notifications without a deploy.
|
||||
</p>
|
||||
|
||||
@@ -85,21 +85,21 @@ function NotificationTemplatesInner() {
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title="Notification Templates - STARR" />
|
||||
<PageMeta title="Announcement Templates - STARR" />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
||||
<div className="w-full max-w-6xl mx-auto">
|
||||
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
<AppBreadcrumb items={[
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Notifications", to: "/admin/notifications" },
|
||||
{ label: "Announcements", to: "/admin/announcements" },
|
||||
{ label: "Templates" },
|
||||
]} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Notification Templates</h1>
|
||||
<h1 className="text-xl font-semibold">Announcement Templates</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Title and message wording for every automated notification STARR sends.
|
||||
</p>
|
||||
@@ -179,7 +179,7 @@ function NotificationTemplatesInner() {
|
||||
<TemplateCard
|
||||
key={item.notification_template_id}
|
||||
item={item}
|
||||
onEdit={(t) => navigate(`/admin/notification-templates/${t.notification_template_id}/edit`)}
|
||||
onEdit={(t) => navigate(`/admin/announcement-templates/${t.notification_template_id}/edit`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -121,7 +121,7 @@ export default function ViewNotificationBroadcast() {
|
||||
if (!broadcast) {
|
||||
return (
|
||||
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
|
||||
<p className="text-sm text-muted-foreground">Notification not found.</p>
|
||||
<p className="text-sm text-muted-foreground">Announcement not found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -150,13 +150,13 @@ export default function ViewNotificationBroadcast() {
|
||||
>
|
||||
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/notifications")}>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/announcements")}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
|
||||
<Megaphone className="h-5 w-5 text-muted-foreground" />
|
||||
{broadcast.title || "Untitled notification"}
|
||||
{broadcast.title || "Untitled announcement"}
|
||||
</h1>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<Badge variant={broadcast.status === "sent" ? "default" : "secondary"}>
|
||||
@@ -180,9 +180,9 @@ export default function ViewNotificationBroadcast() {
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Send this notification?</AlertDialogTitle>
|
||||
<AlertDialogTitle>Send this announcement?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{broadcast.title || "This notification"}" will be delivered to {targetText?.toLowerCase() ?? broadcast.target_type} immediately. This cannot be undone.
|
||||
"{broadcast.title || "This announcement"}" will be delivered to {targetText?.toLowerCase() ?? broadcast.target_type} immediately. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
@@ -191,7 +191,7 @@ export default function ViewNotificationBroadcast() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<Button size="sm" onClick={() => navigate(`/admin/notifications/${broadcastId}/edit`)}>
|
||||
<Button size="sm" onClick={() => navigate(`/admin/announcements/${broadcastId}/edit`)}>
|
||||
<Edit className="size-4" />
|
||||
Edit
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"
|
||||
import { House, FileText, List, ShieldCheck } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function ResourceList() {
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
|
||||
{ label: "Resources" },
|
||||
]
|
||||
|
||||
const handleNavigate = useNavigate();
|
||||
|
||||
return (
|
||||
|
||||
<div className="flex lg:items-center lg:container lg:mx-auto flex-col xs:px-6 lg:px-0">
|
||||
|
||||
<div className="max-w-lg h-full w-full lg:mt-4 space-y-4">
|
||||
<div className="flex flex-col gap-2 mt-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
</div>
|
||||
|
||||
{/* This page will have tiles to redirect for Assets and Tier Plans (at the moment) */}
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-medium">Resources</h1>
|
||||
<p className="text-sm text-muted-foreground">Manage content..</p>
|
||||
</div>
|
||||
|
||||
<div className="grid xs:grid-cols-1 sm:grid-cols-2 h-fit w-full gap-4">
|
||||
<motion.div
|
||||
onClick={(e) => handleNavigate('/admin/assets')}
|
||||
className="group bg-card hover:bg-primary border rounded-lg relative overflow-hidden flex flex-col h-54 cursor-pointer transition-colors duration-200 ease-out"
|
||||
whileHover={{ y: -6, scale: 1.02 }}
|
||||
transition={{
|
||||
y: { type: "spring", stiffness: 300, damping: 20 },
|
||||
scale: { type: "spring", stiffness: 300, damping: 20 },
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className="w-full flex justify-end"
|
||||
whileHover={{ rotate: -18, scale: 1.05 }}
|
||||
transition={{ type: "spring", stiffness: 200 }}
|
||||
>
|
||||
<FileText className="size-32 opacity-50 -rotate-24 text-blue-500 transition-colors duration-200 group-hover:text-white group-hover:opacity-100" />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="p-4 font-medium mt-auto text-foreground transition-colors duration-200 ease-out group-hover:text-white"
|
||||
whileHover={{ y: -2 }}
|
||||
>
|
||||
Assets
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
onClick={(e) => handleNavigate('/admin/tiers/plans')}
|
||||
className="group bg-card hover:bg-primary border rounded-lg relative overflow-hidden flex flex-col h-54 cursor-pointer transition-colors duration-200 ease-out"
|
||||
whileHover={{ y: -6, scale: 1.02 }}
|
||||
transition={{
|
||||
y: { type: "spring", stiffness: 300, damping: 20 },
|
||||
scale: { type: "spring", stiffness: 300, damping: 20 },
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className="w-full flex justify-end"
|
||||
whileHover={{ rotate: -18, scale: 1.05 }}
|
||||
transition={{ type: "spring", stiffness: 200 }}
|
||||
>
|
||||
<List className="size-32 opacity-50 -rotate-24 text-blue-500 transition-colors duration-200 group-hover:text-white group-hover:opacity-100" />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="p-4 font-medium mt-auto text-foreground transition-colors duration-200 ease-out group-hover:text-white"
|
||||
whileHover={{ y: -2 }}
|
||||
>
|
||||
Tier Plans
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { ArrowLeft, ChevronRight, ChevronLeft, Check, FileText, ListChecks, ClipboardList, Link as LinkIcon, Clock } from 'lucide-react';
|
||||
import DeadlinePicker from '@/components/generic/DeadlinePicker';
|
||||
|
||||
@@ -38,24 +39,27 @@ function SummaryRow({ label, value }) {
|
||||
export default function CreateTask() {
|
||||
const navigate = useNavigate();
|
||||
const { taskListId } = useParams();
|
||||
const { createTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, loading } = useAdminTask();
|
||||
const { createTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, fetchQuizzesFlat, loading } = useAdminTask();
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
deadline: '',
|
||||
is_required: true,
|
||||
requirements: [],
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [courses, setCourses] = useState([]);
|
||||
const [units, setUnits] = useState([]);
|
||||
const [lessons, setLessons] = useState([]);
|
||||
const [quizzes, setQuizzes] = 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));
|
||||
}, []);
|
||||
|
||||
const validateStep = (s) => {
|
||||
@@ -101,6 +105,7 @@ export default function CreateTask() {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || null,
|
||||
deadline: form.deadline || null,
|
||||
is_required: form.is_required,
|
||||
// use result.data — it carries the schema's transforms (e.g. link_url scheme defaulting)
|
||||
// strip duration_seconds — it's only used for local validation
|
||||
requirements: result.data.requirements.map((r) => {
|
||||
@@ -203,6 +208,19 @@ export default function CreateTask() {
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-md border px-3 py-2.5">
|
||||
<div>
|
||||
<Label>Required</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Later tasks in this list stay locked until this one is complete. Turn off for optional tasks.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.is_required}
|
||||
onCheckedChange={(v) => setForm({ ...form, is_required: v })}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -223,6 +241,7 @@ export default function CreateTask() {
|
||||
courses={courses}
|
||||
units={units}
|
||||
lessons={lessons}
|
||||
quizzes={quizzes}
|
||||
/>
|
||||
{errors.requirements && (
|
||||
<p className="text-xs text-destructive pt-1">{errors.requirements}</p>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel,
|
||||
@@ -31,13 +32,14 @@ const STATUS_OPTIONS = [
|
||||
export default function EditTask() {
|
||||
const navigate = useNavigate();
|
||||
const { taskListId, taskId } = useParams();
|
||||
const { fetchTask, updateTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, loading } = useAdminTask();
|
||||
const { fetchTask, updateTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, fetchQuizzesFlat, loading } = useAdminTask();
|
||||
|
||||
const [form, setForm] = useState(null);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [courses, setCourses] = useState([]);
|
||||
const [units, setUnits] = useState([]);
|
||||
const [lessons, setLessons] = useState([]);
|
||||
const [quizzes, setQuizzes] = useState([]);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const initialRequirementsRef = useRef(null);
|
||||
|
||||
@@ -53,12 +55,14 @@ export default function EditTask() {
|
||||
? new Date(data.deadline).toISOString().slice(0, 16)
|
||||
: '',
|
||||
status: data.status ?? 'pending',
|
||||
is_required: data.is_required ?? true,
|
||||
requirements: reqs,
|
||||
});
|
||||
});
|
||||
fetchCoursesFlat().then((d) => d && setCourses(d));
|
||||
fetchUnitsFlat().then((d) => d && setUnits(d));
|
||||
fetchLessonsFlat().then((d) => d && setLessons(d));
|
||||
fetchQuizzesFlat().then((d) => d && setQuizzes(d));
|
||||
}, [taskListId, taskId]);
|
||||
|
||||
const requirementsChanged = () =>
|
||||
@@ -95,6 +99,7 @@ export default function EditTask() {
|
||||
description: form.description.trim() || null,
|
||||
deadline: form.deadline || null,
|
||||
status: form.status,
|
||||
is_required: form.is_required,
|
||||
requirements,
|
||||
});
|
||||
if (updated) navigate(`/admin/taskList/${taskListId}/tasks`);
|
||||
@@ -207,6 +212,19 @@ export default function EditTask() {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-md border px-3 py-2.5">
|
||||
<div>
|
||||
<Label>Required</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Later tasks in this list stay locked until this one is complete. Turn off for optional tasks.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!!form.is_required}
|
||||
onCheckedChange={(v) => setForm({ ...form, is_required: v })}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -222,6 +240,7 @@ export default function EditTask() {
|
||||
courses={courses}
|
||||
units={units}
|
||||
lessons={lessons}
|
||||
quizzes={quizzes}
|
||||
/>
|
||||
{errors.requirements && (
|
||||
<p className="text-xs text-destructive pt-1">{errors.requirements}</p>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText, ChevronsUpDown, Tag, Lock, Clock, Search, AlertTriangle } from 'lucide-react';
|
||||
import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText, ChevronsUpDown, Tag, Lock, Clock, Search, AlertTriangle, PenLine, ClipboardCheck, ShieldCheck } from 'lucide-react';
|
||||
|
||||
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 { Switch } from '@/components/ui/switch';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -16,9 +18,11 @@ import api from '@/utils/api.util';
|
||||
const REQUIREMENT_TYPES = [
|
||||
{ value: 'visit_link', label: 'Visit a Link', icon: Link, category: 'Action' },
|
||||
{ value: 'upload_file', label: 'Upload a File', icon: Upload, category: 'Action' },
|
||||
{ value: 'submit_text', label: 'Submit a Response', icon: PenLine, category: 'Action' },
|
||||
{ value: 'read_course', label: 'Read a Course', icon: BookOpen, category: 'Content' },
|
||||
{ value: 'read_unit', label: 'Read a Unit', icon: Layers, category: 'Content' },
|
||||
{ value: 'read_lesson', label: 'Read a Lesson', icon: FileText, category: 'Content' },
|
||||
{ value: 'pass_quiz', label: 'Pass a Quiz', icon: ClipboardCheck, category: 'Content' },
|
||||
];
|
||||
|
||||
const TYPE_MAP = Object.fromEntries(REQUIREMENT_TYPES.map((t) => [t.value, t]));
|
||||
@@ -134,11 +138,13 @@ function createRequirement(type = 'visit_link') {
|
||||
max_file_count: 1,
|
||||
reference_id: '',
|
||||
reference_label: '',
|
||||
prompt: '',
|
||||
requires_review: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── RequirementBuilder ───────────────────────────────────────────────────────
|
||||
export default function RequirementBuilder({ value = [], onChange, courses = [], units = [], lessons = [] }) {
|
||||
export default function RequirementBuilder({ value = [], onChange, courses = [], units = [], lessons = [], quizzes = [] }) {
|
||||
const [items, setItems] = useState(
|
||||
value.length > 0
|
||||
? value.map((r) => ({ _key: crypto.randomUUID(), ...r }))
|
||||
@@ -302,6 +308,37 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── submit_text fields ── */}
|
||||
{item.type === 'submit_text' && (
|
||||
<div className="pl-7 space-y-1">
|
||||
<Label className="text-xs">Prompt / Instructions</Label>
|
||||
<Textarea
|
||||
placeholder="What should the learner write about?"
|
||||
rows={3}
|
||||
value={item.prompt}
|
||||
onChange={(e) => updateItem(item._key, { prompt: e.target.value })}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── requires_review toggle (upload_file / submit_text) ── */}
|
||||
{['upload_file', 'submit_text'].includes(item.type) && (
|
||||
<div className="pl-7 flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<div>
|
||||
<Label className="text-xs">Requires Admin Review</Label>
|
||||
<p className="text-xs text-muted-foreground">Submission only counts as complete once approved.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!!item.requires_review}
|
||||
onCheckedChange={(v) => updateItem(item._key, { requires_review: v })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── read_course / read_unit / read_lesson fields ── */}
|
||||
{['read_course', 'read_unit', 'read_lesson'].includes(item.type) && (
|
||||
<div className="space-y-1">
|
||||
@@ -444,6 +481,52 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── pass_quiz fields ── */}
|
||||
{item.type === 'pass_quiz' && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Quiz</Label>
|
||||
<ContentPicker
|
||||
value={item.reference_id}
|
||||
options={quizzes}
|
||||
idKey="uuid"
|
||||
labelKey="title"
|
||||
searchKey="_search"
|
||||
placeholder="Select a quiz"
|
||||
onSelect={(q) => handleContentSelect(item._key, q, 'quiz')}
|
||||
renderTrigger={(q) => (
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<TierBadge subscription={q.subscription} tierMap={tierMap} />
|
||||
<div className="flex flex-col items-start flex-1 min-w-0">
|
||||
<span className="text-xs leading-tight text-muted-foreground truncate">
|
||||
{q.course_title ? `${q.course_title} | ` : ''}{q.unit_title}
|
||||
</span>
|
||||
<span className="text-sm leading-tight truncate">{q.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
renderItem={(q) => (
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<TierBadge subscription={q.subscription} tierMap={tierMap} />
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="text-sm leading-tight text-muted-foreground truncate">
|
||||
{q.course_title ? `${q.course_title} | ` : ''}{q.unit_title}
|
||||
</span>
|
||||
<span className="text-sm leading-tight truncate">{q.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* ── inline no-content error ── */}
|
||||
{item.reference_id && (item.duration_seconds ?? -1) === 0 && (
|
||||
<p className="flex items-center gap-1.5 text-xs text-destructive pl-7 pt-1">
|
||||
<AlertTriangle className="size-3 shrink-0" />
|
||||
This quiz has no questions yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,13 @@ import { FilterSheet } from '@/components/generic/Sheet/FilterSheet';
|
||||
import { ArchiveDialog } from '@/components/generic/Dialogs/ArchiveDialog';
|
||||
import { RestoreDialog } from '@/components/generic/Dialogs/RestoreDialog';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { House, NotebookPen, Users, Paperclip } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { House, NotebookPen, Users, Paperclip, Check, X } from 'lucide-react';
|
||||
import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
|
||||
import { formatDate } from '@/utils/table.util';
|
||||
import { getTimestamp } from '@/utils/timestamp.util';
|
||||
@@ -46,7 +52,7 @@ export default function TaskCompletions() {
|
||||
completions, completionPagination, setCompletionPagination, completionLoading,
|
||||
completionAttributes,
|
||||
fetchTask, fetchTaskList,
|
||||
fetchCompletions,
|
||||
fetchCompletions, reviewSubmission,
|
||||
archiveCompletion, restoreCompletion,
|
||||
bulkArchiveCompletions, bulkRestoreCompletions,
|
||||
} = useAdminTask();
|
||||
@@ -56,6 +62,8 @@ export default function TaskCompletions() {
|
||||
const [restoreTarget, setRestoreTarget] = useState(null);
|
||||
const [bulkArchiveIds, setBulkArchiveIds] = useState(null);
|
||||
const [bulkRestoreIds, setBulkRestoreIds] = useState(null);
|
||||
const [reviewTarget, setReviewTarget] = useState(null);
|
||||
const [reviewNote, setReviewNote] = useState('');
|
||||
|
||||
const tableRefsRef = useRef({
|
||||
getFilters: () => [], getSort: () => [], resetSelection: () => {}, tableInstance: null,
|
||||
@@ -118,9 +126,21 @@ export default function TaskCompletions() {
|
||||
navigate,
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
onRestore: (row) => setRestoreTarget(row),
|
||||
onReview: (row) => { setReviewTarget(row); setReviewNote(''); },
|
||||
showArchived,
|
||||
});
|
||||
|
||||
const handleReview = async (status) => {
|
||||
if (!reviewTarget) return;
|
||||
const result = await reviewSubmission(taskListId, taskId, reviewTarget.completion_id, {
|
||||
status, review_note: reviewNote || null,
|
||||
});
|
||||
if (result) {
|
||||
setReviewTarget(null);
|
||||
afterMutation();
|
||||
}
|
||||
};
|
||||
|
||||
const toolbarActions = buildToolbarActions({
|
||||
fetchCompletions,
|
||||
taskListId,
|
||||
@@ -262,6 +282,44 @@ export default function TaskCompletions() {
|
||||
onSuccess={afterMutation}
|
||||
/>
|
||||
|
||||
{/* ── Review submission ────────────────────────────────────────────── */}
|
||||
<AlertDialog open={!!reviewTarget} onOpenChange={(v) => !v && setReviewTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Review Submission</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{reviewTarget?.user?.name ?? 'This learner'}'s submission requires review before it counts as complete.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{reviewTarget?.note && (
|
||||
<p className="text-sm text-muted-foreground border rounded-md p-3 bg-muted/40">{reviewTarget.note}</p>
|
||||
)}
|
||||
{reviewTarget?.response_text && (
|
||||
<p className="text-sm border rounded-md p-3 whitespace-pre-wrap">{reviewTarget.response_text}</p>
|
||||
)}
|
||||
<Textarea
|
||||
placeholder="Optional note for the learner..."
|
||||
value={reviewNote}
|
||||
onChange={(e) => setReviewNote(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={completionLoading}>Cancel</AlertDialogCancel>
|
||||
<Button
|
||||
type="button" variant="outline"
|
||||
className="text-destructive border-destructive/50 hover:bg-destructive/5"
|
||||
disabled={completionLoading}
|
||||
onClick={() => handleReview('rejected')}
|
||||
>
|
||||
<X className="size-4 mr-1.5" /> Reject
|
||||
</Button>
|
||||
<AlertDialogAction disabled={completionLoading} onClick={() => handleReview('approved')}>
|
||||
<Check className="size-4 mr-1.5" /> Approve
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export default function Tasks() {
|
||||
taskList, tasks, attributes, pagination, loading,
|
||||
fetchTaskList, fetchTasks, fetchArchivedTasks,
|
||||
archiveTask, restoreTask, fetchTaskFieldValues,
|
||||
bulkArchiveTasks, bulkRestoreTasks,
|
||||
bulkArchiveTasks, bulkRestoreTasks, reorderTasks,
|
||||
} = useAdminTask();
|
||||
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
@@ -96,11 +96,28 @@ export default function Tasks() {
|
||||
sheetName: "Tasks",
|
||||
}), [tasks, attributes]);
|
||||
|
||||
// ── Reorder — swaps this row with its neighbor in the currently displayed
|
||||
// (unfiltered/default-sorted) list, then persists the new order_index set.
|
||||
const handleMove = async (row, direction) => {
|
||||
const idx = tasks.findIndex((t) => t.task_id === row.task_id);
|
||||
const swapIdx = idx + direction;
|
||||
if (idx < 0 || swapIdx < 0 || swapIdx >= tasks.length) return;
|
||||
|
||||
const reordered = [...tasks];
|
||||
[reordered[idx], reordered[swapIdx]] = [reordered[swapIdx], reordered[idx]];
|
||||
|
||||
const ok = await reorderTasks(taskListId, reordered.map((t) => t.task_id));
|
||||
if (ok) afterMutation();
|
||||
};
|
||||
|
||||
const rowActions = buildRowActions({
|
||||
navigate,
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
onRestore: (row) => setRestoreTarget(row),
|
||||
onMoveUp: (row) => handleMove(row, -1),
|
||||
onMoveDown: (row) => handleMove(row, 1),
|
||||
showArchived,
|
||||
tasks,
|
||||
});
|
||||
|
||||
const toolbarActions = buildToolbarActions({
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
import { FileText, Link as LinkIcon, Upload, BookOpen, Layers } from 'lucide-react';
|
||||
import { FileText, Link as LinkIcon, Upload, BookOpen, Layers, PenLine, ClipboardCheck } from 'lucide-react';
|
||||
|
||||
// ── Requirement validation ────────────────────────────────────────────────────
|
||||
const READ_TYPES = ['read_course', 'read_unit', 'read_lesson'];
|
||||
// pass_quiz reuses the same "picked a reference, and it has content" check as
|
||||
// the read_* types — its duration_seconds slot carries question_count instead.
|
||||
const READ_TYPES = ['read_course', 'read_unit', 'read_lesson', 'pass_quiz'];
|
||||
|
||||
// Users commonly type bare domains ("google.com") — default the scheme to https
|
||||
// so the link is actually clickable/navigable once the task is saved.
|
||||
@@ -36,18 +38,23 @@ export const taskSchema = z.object({
|
||||
|
||||
// ── Requirement type labels/icons for review/summary displays ────────────────
|
||||
export const REQUIREMENT_TYPE_META = {
|
||||
visit_link: { label: 'Visit a Link', icon: LinkIcon },
|
||||
upload_file: { label: 'Upload a File', icon: Upload },
|
||||
read_course: { label: 'Read a Course', icon: BookOpen },
|
||||
read_unit: { label: 'Read a Unit', icon: Layers },
|
||||
read_lesson: { label: 'Read a Lesson', icon: FileText },
|
||||
visit_link: { label: 'Visit a Link', icon: LinkIcon },
|
||||
upload_file: { label: 'Upload a File', icon: Upload },
|
||||
submit_text: { label: 'Submit a Response', icon: PenLine },
|
||||
read_course: { label: 'Read a Course', icon: BookOpen },
|
||||
read_unit: { label: 'Read a Unit', icon: Layers },
|
||||
read_lesson: { label: 'Read a Lesson', icon: FileText },
|
||||
pass_quiz: { label: 'Pass a Quiz', icon: ClipboardCheck },
|
||||
};
|
||||
|
||||
export function requirementSummaryText(req) {
|
||||
if (req.type === 'visit_link') return req.link_url || '—';
|
||||
if (req.type === 'upload_file') {
|
||||
const types = (req.allowed_file_types ?? []).join(', ').toUpperCase();
|
||||
return `${types || 'Any type'} · max ${req.max_file_count ?? 1} file(s)`;
|
||||
return `${types || 'Any type'} · max ${req.max_file_count ?? 1} file(s)${req.requires_review ? ' · reviewed' : ''}`;
|
||||
}
|
||||
if (req.type === 'submit_text') {
|
||||
return `${req.prompt ? req.prompt.slice(0, 60) : 'Free-text response'}${req.requires_review ? ' · reviewed' : ''}`;
|
||||
}
|
||||
return req.reference_label || '—';
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, ArrowRight, Check, House } from "lucide-react";
|
||||
import { ArrowLeft, ArrowRight, Check, House, Plus, Trash2 } from "lucide-react";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -38,6 +38,7 @@ const schema = z.object({
|
||||
tier_category_id: z.string().min(1, "Tier category is required."),
|
||||
label: z.string().min(1, "Label is required."),
|
||||
description: z.string().optional(),
|
||||
features: z.array(z.object({ text: z.string().min(1, "Feature cannot be empty.") })).default([]),
|
||||
duration_value: z.coerce.number().min(1, "Duration must be at least 1."),
|
||||
duration_unit: z.string().min(1),
|
||||
price: z.coerce.number().min(0.01, "Price must be greater than 0."),
|
||||
@@ -153,11 +154,14 @@ export default function AddPlan() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const { register, handleSubmit, trigger, setValue, watch, formState: { errors } } = useForm({
|
||||
const { register, handleSubmit, trigger, setValue, watch, control, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { tier_category_id: "", label: "", description: "", duration_value: 30, duration_unit: "day", price: "", currency: "USD" },
|
||||
defaultValues: { tier_category_id: "", label: "", description: "", features: [], duration_value: 30, duration_unit: "day", price: "", currency: "USD" },
|
||||
});
|
||||
|
||||
const { fields: featureFields, append: appendFeature, remove: removeFeature } =
|
||||
useFieldArray({ control, name: "features" });
|
||||
|
||||
const selectedCategoryId = watch("tier_category_id");
|
||||
|
||||
// Derive the subscription slug from the chosen category
|
||||
@@ -173,7 +177,7 @@ export default function AddPlan() {
|
||||
}, [categorySlug]);
|
||||
|
||||
const STEP_FIELDS = [
|
||||
["tier_category_id", "label", "description"],
|
||||
["tier_category_id", "label", "description", "features"],
|
||||
["duration_value", "duration_unit", "price", "currency"],
|
||||
[],
|
||||
];
|
||||
@@ -286,6 +290,43 @@ export default function AddPlan() {
|
||||
/>
|
||||
<FieldError message={errors.description?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>What's included</Label>
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
Bullet points shown on the plans page and the comparison table.
|
||||
</p>
|
||||
{featureFields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
placeholder={`e.g. Access to all Premium courses`}
|
||||
{...register(`features.${index}.text`)}
|
||||
/>
|
||||
<FieldError message={errors.features?.[index]?.text?.message} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-0.5 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeFeature(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => appendFeature({ text: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Feature
|
||||
</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, House, TriangleAlert } from "lucide-react";
|
||||
import { ArrowLeft, House, TriangleAlert, Plus, Trash2 } from "lucide-react";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -45,6 +45,7 @@ const DURATION_UNIT_LIMITS = {
|
||||
const schema = z.object({
|
||||
label: z.string().min(1, "Label is required."),
|
||||
description: z.string().optional(),
|
||||
features: z.array(z.object({ text: z.string().min(1, "Feature cannot be empty.") })).default([]),
|
||||
duration_value: z.coerce.number().min(1, "Duration must be at least 1."),
|
||||
duration_unit: z.string().min(1),
|
||||
price: z.coerce.number().min(0.01, "Price must be greater than 0."),
|
||||
@@ -91,10 +92,13 @@ export default function EditPlan() {
|
||||
const [impactLoading, setImpactLoading] = useState(false);
|
||||
const [pendingValues, setPendingValues] = useState(null);
|
||||
|
||||
const { register, handleSubmit, setValue, watch, reset, formState: { errors } } = useForm({
|
||||
const { register, handleSubmit, setValue, watch, reset, control, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
const { fields: featureFields, append: appendFeature, remove: removeFeature } =
|
||||
useFieldArray({ control, name: "features" });
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlan(planId);
|
||||
api.get("/admin/tiers/currencies")
|
||||
@@ -108,6 +112,7 @@ export default function EditPlan() {
|
||||
reset({
|
||||
label: plan.label,
|
||||
description: plan.description ?? "",
|
||||
features: (plan.features ?? []).map((f) => (typeof f === "string" ? { text: f } : f)),
|
||||
duration_value: durationDaysToValue(plan.duration_days, unit),
|
||||
duration_unit: unit,
|
||||
price: plan.price,
|
||||
@@ -223,6 +228,43 @@ export default function EditPlan() {
|
||||
<FieldError message={errors.description?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>What's included</Label>
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
Bullet points shown on the plans page and the comparison table.
|
||||
</p>
|
||||
{featureFields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
placeholder="e.g. Access to all Premium courses"
|
||||
{...register(`features.${index}.text`)}
|
||||
/>
|
||||
<FieldError message={errors.features?.[index]?.text?.message} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-0.5 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeFeature(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => appendFeature({ text: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Feature
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Duration</Label>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -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,
|
||||
ShieldCheck, Plus, Trash2, Loader2, Receipt, KeyRound, Users, Lock,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -481,6 +481,241 @@ function PaymentPolicyTab({ planId, plan }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tab: Access Rules ─────────────────────────────────────────────────────────
|
||||
// Configures plan_policies.access_rules — evaluateCourseAccess (utils/accessPolicy.util.js)
|
||||
// reads these instead of falling back to plain tier-rank comparison once any
|
||||
// rule exists here. Empty (default) = unchanged rank-comparison behavior.
|
||||
|
||||
const RULE_TYPES = [
|
||||
{ value: "course_subscription_access", label: "Allowed subscription levels", icon: Tag,
|
||||
description: "Only grant access to courses at these subscription levels." },
|
||||
{ value: "required_active_tier", label: "Required active tier", icon: KeyRound,
|
||||
description: "User's active tier must be at least this rank." },
|
||||
{ value: "group_restriction", label: "Group restriction", icon: Users,
|
||||
description: "User must belong to at least one of these groups." },
|
||||
];
|
||||
|
||||
function ruleSummary(rule, tierCategories, groups) {
|
||||
if (rule.type === "course_subscription_access") {
|
||||
const names = (rule.levels ?? []).map((slug) => tierCategories.find((c) => c.slug === slug)?.name ?? slug);
|
||||
return `Allowed levels: ${names.join(", ") || "—"}`;
|
||||
}
|
||||
if (rule.type === "required_active_tier") {
|
||||
return `Requires active tier: ${tierCategories.find((c) => c.slug === rule.tier)?.name ?? rule.tier}`;
|
||||
}
|
||||
if (rule.type === "group_restriction") {
|
||||
const names = (rule.group_ids ?? []).map((id) => groups.find((g) => String(g.group_id) === String(id))?.name ?? id);
|
||||
return `Restricted to groups: ${names.join(", ") || "—"}`;
|
||||
}
|
||||
return rule.type;
|
||||
}
|
||||
|
||||
function AccessRulesTab({ planId }) {
|
||||
const [rules, setRules] = useState([]);
|
||||
const [rulesLoading, setRulesLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newType, setNewType] = useState("course_subscription_access");
|
||||
const [newLevels, setNewLevels] = useState([]);
|
||||
const [newTier, setNewTier] = useState("");
|
||||
const [newGroupIds, setNewGroupIds] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
setRulesLoading(true);
|
||||
api.get(`/admin/tier-policies/plans/${planId}/policy`)
|
||||
.then(({ data }) => setRules(data.data?.access_rules ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setRulesLoading(false));
|
||||
|
||||
api.get("/admin/tiers/categories").then(({ data }) => setTierCategories(data.data ?? [])).catch(() => {});
|
||||
api.get("/admin/groups", { params: { limit: 100 } })
|
||||
.then(({ data }) => setGroups(data.data?.data ?? []))
|
||||
.catch(() => {});
|
||||
}, [planId]);
|
||||
|
||||
const handleSave = async (next) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.put(`/admin/tier-policies/plans/${planId}/policy`, { access_rules: next });
|
||||
setRules(next);
|
||||
toast("Access rules saved.");
|
||||
} catch (err) {
|
||||
toast(err?.response?.data?.message ?? "Could not save access rules.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetAddForm = () => {
|
||||
setShowAdd(false);
|
||||
setNewType("course_subscription_access");
|
||||
setNewLevels([]);
|
||||
setNewTier("");
|
||||
setNewGroupIds([]);
|
||||
};
|
||||
|
||||
const handleAddRule = () => {
|
||||
let rule;
|
||||
if (newType === "course_subscription_access") {
|
||||
if (!newLevels.length) { toast("Select at least one subscription level."); return; }
|
||||
rule = { type: newType, levels: newLevels };
|
||||
} else if (newType === "required_active_tier") {
|
||||
if (!newTier) { toast("Select a required tier."); return; }
|
||||
rule = { type: newType, tier: newTier };
|
||||
} else {
|
||||
if (!newGroupIds.length) { toast("Select at least one group."); return; }
|
||||
rule = { type: newType, group_ids: newGroupIds.map(Number) };
|
||||
}
|
||||
handleSave([...rules, rule]);
|
||||
resetAddForm();
|
||||
};
|
||||
|
||||
const handleRemoveRule = (index) => {
|
||||
handleSave(rules.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
if (rulesLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[...Array(2)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<SectionCard
|
||||
icon={Lock}
|
||||
title="Access Rules"
|
||||
description="Overrides the default rank-comparison access check for this plan. Leave empty to use plain tier-rank comparison."
|
||||
>
|
||||
{rules.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{rules.map((rule, i) => {
|
||||
const meta = RULE_TYPES.find((t) => t.value === rule.type);
|
||||
const Icon = meta?.icon ?? Lock;
|
||||
return (
|
||||
<div key={i} className="flex items-center justify-between gap-3 rounded-lg border bg-muted/30 px-4 py-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Icon className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm">{ruleSummary(rule, tierCategories, groups)}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
className="text-destructive hover:text-destructive shrink-0"
|
||||
disabled={saving}
|
||||
onClick={() => handleRemoveRule(i)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||
<Lock className="size-4 shrink-0" />
|
||||
No access rules configured — falls back to plain tier-rank comparison.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd ? (
|
||||
<div className="rounded-lg border bg-muted/20 p-4 space-y-4">
|
||||
<p className="text-sm font-medium">New Access Rule</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Rule Type</Label>
|
||||
<Select value={newType} onValueChange={setNewType}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{RULE_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{RULE_TYPES.find((t) => t.value === newType)?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{newType === "course_subscription_access" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Allowed Levels</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tierCategories.map((c) => (
|
||||
<Badge
|
||||
key={c.slug}
|
||||
variant={newLevels.includes(c.slug) ? "default" : "outline"}
|
||||
className="cursor-pointer select-none"
|
||||
onClick={() => setNewLevels((prev) =>
|
||||
prev.includes(c.slug) ? prev.filter((s) => s !== c.slug) : [...prev, c.slug]
|
||||
)}
|
||||
>
|
||||
{c.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newType === "required_active_tier" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Required Tier</Label>
|
||||
<Select value={newTier} onValueChange={setNewTier}>
|
||||
<SelectTrigger><SelectValue placeholder="Select a tier" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{tierCategories.map((c) => (
|
||||
<SelectItem key={c.slug} value={c.slug}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newType === "group_restriction" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Groups</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{groups.map((g) => (
|
||||
<Badge
|
||||
key={g.group_id}
|
||||
variant={newGroupIds.includes(String(g.group_id)) ? "default" : "outline"}
|
||||
className="cursor-pointer select-none"
|
||||
onClick={() => setNewGroupIds((prev) =>
|
||||
prev.includes(String(g.group_id))
|
||||
? prev.filter((id) => id !== String(g.group_id))
|
||||
: [...prev, String(g.group_id)]
|
||||
)}
|
||||
>
|
||||
{g.name}
|
||||
</Badge>
|
||||
))}
|
||||
{groups.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">No groups found.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end pt-1">
|
||||
<Button variant="outline" size="sm" onClick={resetAddForm}>Cancel</Button>
|
||||
<Button size="sm" onClick={handleAddRule} disabled={saving}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAdd(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Add Access Rule
|
||||
</Button>
|
||||
)}
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tab: Payments ─────────────────────────────────────────────────────────────
|
||||
|
||||
function PaymentsTab({ planId }) {
|
||||
@@ -497,6 +732,7 @@ function PaymentsTab({ planId }) {
|
||||
|
||||
const TABS = [
|
||||
{ key: "details", label: "Plan Details", icon: CreditCard },
|
||||
{ key: "access", label: "Access Rules", icon: Lock },
|
||||
{ key: "policy", label: "Payment Policy", icon: ShieldCheck },
|
||||
{ key: "payments", label: "Payments", icon: Receipt },
|
||||
];
|
||||
@@ -601,6 +837,9 @@ export default function ViewPlan() {
|
||||
coursesLoading={coursesLoading}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "access" && (
|
||||
<AccessRulesTab planId={planId} />
|
||||
)}
|
||||
{activeTab === "policy" && (
|
||||
<PaymentPolicyTab planId={planId} plan={plan} />
|
||||
)}
|
||||
|
||||
@@ -93,17 +93,17 @@ import AddCategory from '../pages/categories/AddCategory';
|
||||
import EditCategory from '../pages/categories/EditCategory';
|
||||
|
||||
// Tiers
|
||||
import PlanList from '../pages/tiers/PlanList';
|
||||
import AddPlan from '../pages/tiers/AddPlan';
|
||||
import ViewPlan from '../pages/tiers/ViewPlan';
|
||||
import EditPlan from '../pages/tiers/EditPlan';
|
||||
import SystemBadges from '../pages/tiers/SystemBadges';
|
||||
import UserTierList from '../pages/tiers/UserTierList';
|
||||
import PaymentList from '../pages/tiers/PaymentList';
|
||||
import ViewPayment from '../pages/tiers/ViewPayment';
|
||||
import PlanList from '../pages/tiers/PlanList';
|
||||
import AddPlan from '../pages/tiers/AddPlan';
|
||||
import ViewPlan from '../pages/tiers/ViewPlan';
|
||||
import EditPlan from '../pages/tiers/EditPlan';
|
||||
import SystemBadges from '../pages/tiers/SystemBadges';
|
||||
import UserTierList from '../pages/tiers/UserTierList';
|
||||
import PaymentList from '../pages/tiers/PaymentList';
|
||||
import ViewPayment from '../pages/tiers/ViewPayment';
|
||||
import TierCategories from '../pages/tiers/TierCategories';
|
||||
import ArchivedPlanList from '../pages/tiers/ArchivedPlanList';
|
||||
import PaymentPolicy from '../pages/tiers/PaymentPolicy';
|
||||
import PaymentPolicy from '../pages/tiers/PaymentPolicy';
|
||||
import { AddTierCategory, EditTierCategory } from '../pages/tiers/EditTierCategory';
|
||||
import TaskSubmissions from '../pages/task_list/task/TaskCompletion'
|
||||
import ViewTaskCompletion from '../pages/task_list/task/ViewTaskCompletion'
|
||||
@@ -130,8 +130,9 @@ import EditNotificationTemplate from '../pages/notifications/EditNotificationTem
|
||||
import ArchivedNotificationBroadcastList from '../pages/notifications/ArchivedNotificationBroadcastList'
|
||||
|
||||
// Activity
|
||||
import ActivityFeed from '../pages/activity/ActivityFeed'
|
||||
import ActivityFeed from '../pages/activity/ActivityFeed'
|
||||
import UserActivityPage from '../pages/activity/UserActivityPage'
|
||||
import ResourceList from '../pages/resources/ResourceList'
|
||||
|
||||
|
||||
|
||||
@@ -186,6 +187,14 @@ export const AdminRoutes = {
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
path: 'resources',
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <ResourceList /> },
|
||||
],
|
||||
},
|
||||
|
||||
// Courses
|
||||
{
|
||||
path: 'courses',
|
||||
@@ -314,8 +323,8 @@ export const AdminRoutes = {
|
||||
{ index: true, element: <PlanList /> },
|
||||
{ path: 'add', element: <AddPlan /> },
|
||||
{ path: 'archived', element: <ArchivedPlanList /> },
|
||||
{ path: ':planId/view', element: <ViewPlan /> },
|
||||
{ path: ':planId/edit', element: <EditPlan /> },
|
||||
{ path: ':planId/view', element: <ViewPlan /> },
|
||||
{ path: ':planId/edit', element: <EditPlan /> },
|
||||
{ path: ':planId/payment-policy', element: <PaymentPolicy /> },
|
||||
]
|
||||
},
|
||||
@@ -324,8 +333,8 @@ export const AdminRoutes = {
|
||||
path: 'categories',
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <TierCategories /> },
|
||||
{ path: 'add', element: <AddTierCategory /> },
|
||||
{ index: true, element: <TierCategories /> },
|
||||
{ path: 'add', element: <AddTierCategory /> },
|
||||
{ path: ':id/edit', element: <EditTierCategory /> },
|
||||
],
|
||||
},
|
||||
@@ -364,14 +373,35 @@ export const AdminRoutes = {
|
||||
path: 'achievements',
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <Achievements /> },
|
||||
{ path: 'add', element: <AddAchievement /> },
|
||||
{ index: true, element: <Achievements /> },
|
||||
{ path: 'add', element: <AddAchievement /> },
|
||||
{ path: ':id/edit', element: <EditAchievement /> },
|
||||
]
|
||||
},
|
||||
|
||||
// Notifications
|
||||
// Announcements (admin-authored broadcasts)
|
||||
{
|
||||
path: 'announcements',
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <NotificationBroadcastList /> },
|
||||
{ path: 'archived', element: <ArchivedNotificationBroadcastList /> },
|
||||
{ path: 'add', element: <AddNotificationBroadcast /> },
|
||||
{ path: 'settings', element: <NotificationSettings /> },
|
||||
{ path: ':broadcastId/view', element: <ViewNotificationBroadcast /> },
|
||||
{ path: ':broadcastId/edit', element: <EditNotificationBroadcast /> },
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'announcement-templates',
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <NotificationTemplates /> },
|
||||
{ path: ':id/edit', element: <EditNotificationTemplate /> },
|
||||
]
|
||||
},
|
||||
|
||||
// Backwards-compatible aliases (keep old URLs working)
|
||||
{
|
||||
path: 'notifications',
|
||||
element: <Outlet />,
|
||||
@@ -388,7 +418,7 @@ export const AdminRoutes = {
|
||||
path: 'notification-templates',
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <NotificationTemplates /> },
|
||||
{ index: true, element: <NotificationTemplates /> },
|
||||
{ path: ':id/edit', element: <EditNotificationTemplate /> },
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user