Content flows: parent/sub sequence, sub structure, loading icon fix, AddTeamMember

- Flows: Parent category sequence (reorder all parents), sub category sequence per parent, sub category structure (courses/questions/feedback)
- Fix Flow page white screen (useEffect deps: selectedSubCategoryId/selectedParentCategoryId)
- Loading state: visible refresh icon (white card, brand-600) on AllCourses, Courses, CourseFlowBuilder, SubCourses
- Add team member page, team API and types, route /team/add

Made-with: Cursor
This commit is contained in:
“kirukib” 2026-02-27 20:39:19 +03:00
parent 46c0c78214
commit 089c1ac869
13 changed files with 1114 additions and 185 deletions

View File

@ -1,5 +1,5 @@
import http from "./http" import http from "./http"
import type { GetTeamMembersResponse, GetTeamMemberResponse } from "../types/team.types" import type { GetTeamMembersResponse, GetTeamMemberResponse, CreateTeamMemberRequest } from "../types/team.types"
export const getTeamMembers = (page?: number, pageSize?: number) => export const getTeamMembers = (page?: number, pageSize?: number) =>
http.get<GetTeamMembersResponse>("/team/members", { http.get<GetTeamMembersResponse>("/team/members", {
@ -11,3 +11,6 @@ export const getTeamMembers = (page?: number, pageSize?: number) =>
export const getTeamMemberById = (id: number) => export const getTeamMemberById = (id: number) =>
http.get<GetTeamMemberResponse>(`/team/members/${id}`) http.get<GetTeamMemberResponse>(`/team/members/${id}`)
export const createTeamMember = (data: CreateTeamMemberRequest) =>
http.post("/team/register", data)

View File

@ -35,6 +35,7 @@ import { IssuesPage } from "../pages/issues/IssuesPage"
import { ProfilePage } from "../pages/ProfilePage" import { ProfilePage } from "../pages/ProfilePage"
import { SettingsPage } from "../pages/SettingsPage" import { SettingsPage } from "../pages/SettingsPage"
import { TeamManagementPage } from "../pages/team/TeamManagementPage" import { TeamManagementPage } from "../pages/team/TeamManagementPage"
import { AddTeamMemberPage } from "../pages/team/AddTeamMemberPage"
import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage" import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage"
import { LoginPage } from "../pages/auth/LoginPage" import { LoginPage } from "../pages/auth/LoginPage"
import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage" import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage"
@ -89,6 +90,7 @@ export function AppRoutes() {
<Route path="/analytics" element={<AnalyticsPage />} /> <Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/team" element={<TeamManagementPage />} /> <Route path="/team" element={<TeamManagementPage />} />
<Route path="/team/add" element={<AddTeamMemberPage />} />
<Route path="/team/:id" element={<TeamMemberDetailPage />} /> <Route path="/team/:id" element={<TeamMemberDetailPage />} />
<Route path="/profile" element={<ProfilePage />} /> <Route path="/profile" element={<ProfilePage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { Search, Plus, RefreshCw } from "lucide-react" import { Search, Plus, RefreshCw, Edit2, ToggleLeft, ToggleRight } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
@ -15,7 +15,7 @@ import {
} from "../../components/ui/table" } from "../../components/ui/table"
import { Badge } from "../../components/ui/badge" import { Badge } from "../../components/ui/badge"
import { FileUpload } from "../../components/ui/file-upload" import { FileUpload } from "../../components/ui/file-upload"
import { getCourseCategories, getCoursesByCategory, createCourse } from "../../api/courses.api" import { getCourseCategories, getCoursesByCategory, createCourse, updateCourseStatus, updateCourse } from "../../api/courses.api"
import type { Course, CourseCategory } from "../../types/course.types" import type { Course, CourseCategory } from "../../types/course.types"
import { import {
Dialog, Dialog,
@ -41,11 +41,18 @@ export function AllCoursesPage() {
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [createCategoryId, setCreateCategoryId] = useState<string>("") const [createCategoryId, setCreateCategoryId] = useState<string>("")
const [createSubCategoryId, setCreateSubCategoryId] = useState<string>("")
const [createTitle, setCreateTitle] = useState("") const [createTitle, setCreateTitle] = useState("")
const [createDescription, setCreateDescription] = useState("") const [createDescription, setCreateDescription] = useState("")
const [createThumbnail, setCreateThumbnail] = useState<File | null>(null) const [createThumbnail, setCreateThumbnail] = useState<File | null>(null)
const [createVideo, setCreateVideo] = useState<File | null>(null) const [createVideo, setCreateVideo] = useState<File | null>(null)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
const [editOpen, setEditOpen] = useState(false)
const [courseToEdit, setCourseToEdit] = useState<CourseWithCategory | null>(null)
const [editTitle, setEditTitle] = useState("")
const [editDescription, setEditDescription] = useState("")
const [updating, setUpdating] = useState(false)
const fetchAllCourses = async () => { const fetchAllCourses = async () => {
setLoading(true) setLoading(true)
@ -92,9 +99,11 @@ export function AllCoursesPage() {
}) })
const handleCreateCourse = async () => { const handleCreateCourse = async () => {
if (!createCategoryId || !createTitle.trim() || !createDescription.trim()) { const effectiveCategoryId = createSubCategoryId || createCategoryId
if (!effectiveCategoryId || !createTitle.trim() || !createDescription.trim()) {
toast.error("Missing fields", { toast.error("Missing fields", {
description: "Category, title, and description are required.", description: "Category (or subcategory), title, and description are required.",
}) })
return return
} }
@ -102,7 +111,7 @@ export function AllCoursesPage() {
setCreating(true) setCreating(true)
try { try {
await createCourse({ await createCourse({
category_id: Number(createCategoryId), category_id: Number(effectiveCategoryId),
title: createTitle.trim(), title: createTitle.trim(),
description: createDescription.trim(), description: createDescription.trim(),
}) })
@ -113,6 +122,7 @@ export function AllCoursesPage() {
setCreateOpen(false) setCreateOpen(false)
setCreateCategoryId("") setCreateCategoryId("")
setCreateSubCategoryId("")
setCreateTitle("") setCreateTitle("")
setCreateDescription("") setCreateDescription("")
setCreateThumbnail(null) setCreateThumbnail(null)
@ -128,11 +138,60 @@ export function AllCoursesPage() {
} }
} }
const handleToggleStatus = async (course: CourseWithCategory) => {
setTogglingId(course.id)
try {
await updateCourseStatus(course.id, !course.is_active)
await fetchAllCourses()
} catch (err) {
console.error("Failed to update course status:", err)
toast.error("Failed to update course status")
} finally {
setTogglingId(null)
}
}
const openEditDialog = (course: CourseWithCategory) => {
setCourseToEdit(course)
setEditTitle(course.title)
setEditDescription(course.description || "")
setEditOpen(true)
}
const handleUpdateCourse = async () => {
if (!courseToEdit) return
if (!editTitle.trim() || !editDescription.trim()) {
toast.error("Missing fields", {
description: "Title and description are required.",
})
return
}
setUpdating(true)
try {
await updateCourse(courseToEdit.id, {
title: editTitle.trim(),
description: editDescription.trim(),
})
toast.success("Course updated")
setEditOpen(false)
setCourseToEdit(null)
await fetchAllCourses()
} catch (err: any) {
console.error("Failed to update course:", err)
toast.error("Failed to update course", {
description: err?.response?.data?.message || "Please try again.",
})
} finally {
setUpdating(false)
}
}
if (loading) { if (loading) {
return ( return (
<div className="flex flex-col items-center justify-center py-32"> <div className="flex flex-col items-center justify-center py-32">
<div className="rounded-2xl bg-brand-50/50 p-6"> <div className="rounded-2xl bg-white shadow-sm p-6">
<RefreshCw className="h-10 w-10 animate-spin text-brand-500" /> <RefreshCw className="h-10 w-10 animate-spin text-brand-600" />
</div> </div>
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading all courses</p> <p className="mt-4 text-sm font-medium text-grayScale-400">Loading all courses</p>
</div> </div>
@ -263,6 +322,34 @@ export function AllCoursesPage() {
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="py-3.5 text-right"> <TableCell className="py-3.5 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700"
onClick={(e) => {
e.stopPropagation()
openEditDialog(course)
}}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700"
disabled={togglingId === course.id}
onClick={(e) => {
e.stopPropagation()
handleToggleStatus(course)
}}
>
{course.is_active ? (
<ToggleLeft className="h-4 w-4" />
) : (
<ToggleRight className="h-4 w-4" />
)}
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -276,6 +363,7 @@ export function AllCoursesPage() {
> >
Open Open
</Button> </Button>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@ -298,7 +386,7 @@ export function AllCoursesPage() {
{/* Create course dialog */} {/* Create course dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}> <Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Create course</DialogTitle> <DialogTitle>Create course</DialogTitle>
<DialogDescription> <DialogDescription>
@ -308,24 +396,48 @@ export function AllCoursesPage() {
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-3">
<div> <div className="sm:col-span-1">
<label className="mb-1.5 block text-sm font-medium text-grayScale-600"> <label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Category Category
</label> </label>
<Select <Select
value={createCategoryId} value={createCategoryId}
onChange={(e) => setCreateCategoryId(e.target.value)} onChange={(e) => {
setCreateCategoryId(e.target.value)
setCreateSubCategoryId("")
}}
> >
<option value="">Select category</option> <option value="">Select category</option>
{categories.map((cat) => ( {categories
.filter((cat) => !cat.parent_id)
.map((cat) => (
<option key={cat.id} value={String(cat.id)}> <option key={cat.id} value={String(cat.id)}>
{cat.name} {cat.name}
</option> </option>
))} ))}
</Select> </Select>
</div> </div>
<div> <div className="sm:col-span-1">
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Subcategory (optional)
</label>
<Select
value={createSubCategoryId}
onChange={(e) => setCreateSubCategoryId(e.target.value)}
disabled={!createCategoryId}
>
<option value="">No subcategory</option>
{categories
.filter((cat) => cat.parent_id && String(cat.parent_id) === createCategoryId)
.map((cat) => (
<option key={cat.id} value={String(cat.id)}>
{cat.name}
</option>
))}
</Select>
</div>
<div className="sm:col-span-1">
<label className="mb-1.5 block text-sm font-medium text-grayScale-600"> <label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Course title Course title
</label> </label>
@ -407,6 +519,65 @@ export function AllCoursesPage() {
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Edit course dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Edit course</DialogTitle>
<DialogDescription>
Update the title and description for this course. Status can be toggled from the
table.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Course title
</label>
<Input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
placeholder="Enter course title"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Description
</label>
<Textarea
rows={3}
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Short summary of this course."
/>
</div>
</div>
<div className="mt-5 flex items-center justify-end gap-2">
<Button
variant="outline"
onClick={() => {
setEditOpen(false)
setCourseToEdit(null)
setEditTitle("")
setEditDescription("")
}}
disabled={updating}
>
Cancel
</Button>
<Button
className="bg-brand-500 text-white hover:bg-brand-600"
disabled={updating}
onClick={handleUpdateCourse}
>
{updating ? "Saving…" : "Save changes"}
</Button>
</div>
</DialogContent>
</Dialog>
</div> </div>
) )
} }

View File

@ -64,6 +64,14 @@ export function ContentOverviewPage() {
const [category, setCategory] = useState<CourseCategory | null>(null) const [category, setCategory] = useState<CourseCategory | null>(null)
const [sections, setSections] = useState<ContentSection[]>(() => [...contentSections]) const [sections, setSections] = useState<ContentSection[]>(() => [...contentSections])
const [dragKey, setDragKey] = useState<string | null>(null) const [dragKey, setDragKey] = useState<string | null>(null)
const [flowSteps, setFlowSteps] = useState<
{
id: string
type: "lesson" | "practice" | "exam" | "feedback" | "course" | "speaking" | "new_course"
title: string
description?: string
}[]
>([])
useEffect(() => { useEffect(() => {
const fetchCategory = async () => { const fetchCategory = async () => {
@ -81,6 +89,30 @@ export function ContentOverviewPage() {
} }
}, [categoryId]) }, [categoryId])
// Load category-level flow sequence (if any) from localStorage
useEffect(() => {
if (!categoryId) {
setFlowSteps([])
return
}
const key = `category_flow_${categoryId}`
try {
const raw = window.localStorage.getItem(key)
if (raw) {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) {
setFlowSteps(parsed)
return
}
}
} catch {
// ignore and fall back to default
}
// No explicit flow saved; fall back to an empty sequence
setFlowSteps([])
}, [categoryId])
// Load persisted section order from localStorage // Load persisted section order from localStorage
useEffect(() => { useEffect(() => {
try { try {
@ -242,6 +274,88 @@ export function ContentOverviewPage() {
) )
})} })}
</div> </div>
{/* Category flow sequence (if defined) */}
{flowSteps.length > 0 && (
<Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-3">
<div className="flex items-center justify-between gap-2">
<div>
<CardTitle className="text-base font-semibold text-grayScale-600">
Learning flow
</CardTitle>
<CardDescription className="mt-0.5 text-xs text-grayScale-400">
Sequence of lessons, practice, exams, and feedback for this category.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-4">
<div className="flex gap-3 overflow-x-auto pb-2 md:grid md:auto-cols-fr md:grid-flow-col md:overflow-visible">
{flowSteps.map((step, index) => (
<div
key={step.id}
className="flex min-w-[200px] flex-col justify-between rounded-xl border border-grayScale-100 bg-white p-3.5 shadow-sm"
>
<div className="flex items-center justify-between gap-2">
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold",
step.type === "lesson" && "bg-sky-50 text-sky-700 ring-1 ring-inset ring-sky-200",
step.type === "practice" &&
"bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200",
step.type === "exam" &&
"bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200",
step.type === "feedback" &&
"bg-rose-50 text-rose-700 ring-1 ring-inset ring-rose-200",
step.type === "course" &&
"bg-violet-50 text-violet-700 ring-1 ring-inset ring-violet-200",
step.type === "speaking" &&
"bg-teal-50 text-teal-700 ring-1 ring-inset ring-teal-200",
step.type === "new_course" &&
"bg-indigo-50 text-indigo-700 ring-1 ring-inset ring-indigo-200",
)}
>
{step.type === "lesson"
? "Lesson"
: step.type === "practice"
? "Practice"
: step.type === "exam"
? "Exam / Questions"
: step.type === "feedback"
? "Feedback"
: step.type === "course"
? "Course"
: step.type === "speaking"
? "Speaking section"
: "New course (category)"}
<span className="text-[10px] text-grayScale-400">#{index + 1}</span>
</span>
</div>
<div className="mt-2 space-y-1">
<p className="text-sm font-semibold text-grayScale-700 line-clamp-2">
{step.title}
</p>
<p className="text-xs text-grayScale-500 line-clamp-3">
{step.description ||
(step.type === "exam"
? "Place exams and question sets here."
: step.type === "feedback"
? "Collect feedback or run followup surveys."
: step.type === "course"
? "Link or add an existing course to this flow."
: step.type === "speaking"
? "Speaking or oral practice section."
: step.type === "new_course"
? "Add a new course within this category."
: "Configure this step in the flow builder.")}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div> </div>
) )
} }

View File

@ -4,6 +4,7 @@ import { FolderOpen, RefreshCw, AlertCircle, BookOpen, Plus } from "lucide-react
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { Select } from "../../components/ui/select"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -22,6 +23,7 @@ export function CourseCategoryPage() {
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [newCategoryName, setNewCategoryName] = useState("") const [newCategoryName, setNewCategoryName] = useState("")
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [parentCategoryId, setParentCategoryId] = useState<number | null>(null)
const fetchCategories = async () => { const fetchCategories = async () => {
setLoading(true) setLoading(true)
@ -159,7 +161,7 @@ export function CourseCategoryPage() {
<span>Create course category</span> <span>Create course category</span>
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Add a new high-level bucket to organize your courses. Add a new high-level bucket to organize your courses. You can also nest it under an existing parent category.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -174,6 +176,24 @@ export function CourseCategoryPage() {
onChange={(e) => setNewCategoryName(e.target.value)} onChange={(e) => setNewCategoryName(e.target.value)}
/> />
</div> </div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Parent category (optional)
</label>
<Select
value={parentCategoryId ?? ""}
onChange={(e) =>
setParentCategoryId(e.target.value ? Number(e.target.value) : null)
}
>
<option value="">No parent (top level)</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</Select>
</div>
</div> </div>
<div className="mt-5 flex items-center justify-end gap-2"> <div className="mt-5 flex items-center justify-end gap-2">
@ -194,11 +214,15 @@ export function CourseCategoryPage() {
if (!newCategoryName.trim()) return if (!newCategoryName.trim()) return
setCreating(true) setCreating(true)
try { try {
await createCourseCategory({ name: newCategoryName.trim() }) await createCourseCategory({
name: newCategoryName.trim(),
parent_id: parentCategoryId ?? null,
})
toast.success("Category created", { toast.success("Category created", {
description: `"${newCategoryName.trim()}" has been added.`, description: `"${newCategoryName.trim()}" has been added.`,
}) })
setNewCategoryName("") setNewCategoryName("")
setParentCategoryId(null)
setCreateOpen(false) setCreateOpen(false)
fetchCategories() fetchCategories()
} catch (err: any) { } catch (err: any) {

View File

@ -1,15 +1,21 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { ChevronRight, GripVertical, Plus, RefreshCw } from "lucide-react" import { GripVertical, RefreshCw } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { Select } from "../../components/ui/select" import { Select } from "../../components/ui/select"
import { Badge } from "../../components/ui/badge" import { getCourseCategories } from "../../api/courses.api"
import { getCourseCategories, getCoursesByCategory } from "../../api/courses.api" import type { CourseCategory } from "../../types/course.types"
import type { Course, CourseCategory } from "../../types/course.types"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
type StepType = "lesson" | "practice" | "exam" | "feedback" type StepType =
| "lesson"
| "practice"
| "exam"
| "feedback"
| "course"
| "speaking"
| "new_course"
type FlowStep = { type FlowStep = {
id: string id: string
@ -18,13 +24,14 @@ type FlowStep = {
description?: string description?: string
} }
type CourseWithCategory = Course & { category_name: string }
const STEP_LABELS: Record<StepType, string> = { const STEP_LABELS: Record<StepType, string> = {
lesson: "Lesson", lesson: "Lesson",
practice: "Practice", practice: "Practice",
exam: "Exam", exam: "Exam",
feedback: "Feedback loop", feedback: "Feedback loop",
course: "Course",
speaking: "Speaking section",
new_course: "New course (category)",
} }
const STEP_BADGE: Record<StepType, string> = { const STEP_BADGE: Record<StepType, string> = {
@ -32,24 +39,86 @@ const STEP_BADGE: Record<StepType, string> = {
practice: "bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200", practice: "bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200",
exam: "bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200", exam: "bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200",
feedback: "bg-rose-50 text-rose-700 ring-1 ring-inset ring-rose-200", feedback: "bg-rose-50 text-rose-700 ring-1 ring-inset ring-rose-200",
course: "bg-violet-50 text-violet-700 ring-1 ring-inset ring-violet-200",
speaking: "bg-teal-50 text-teal-700 ring-1 ring-inset ring-teal-200",
new_course: "bg-indigo-50 text-indigo-700 ring-1 ring-inset ring-indigo-200",
} }
const PARENT_ORDER_KEY = "parent_categories_order"
const SUB_ORDER_KEY_PREFIX = "sub_categories_order_"
export function CourseFlowBuilderPage() { export function CourseFlowBuilderPage() {
const [categories, setCategories] = useState<CourseCategory[]>([]) const [categories, setCategories] = useState<CourseCategory[]>([])
const [courses, setCourses] = useState<CourseWithCategory[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [scope, setScope] = useState<"sub" | "parent">("sub")
const [selectedCourseId, setSelectedCourseId] = useState<string>("") const [selectedSubCategoryId, setSelectedSubCategoryId] = useState<string>("")
const [selectedParentCategoryId, setSelectedParentCategoryId] = useState<string>("")
const [steps, setSteps] = useState<FlowStep[]>([]) const [steps, setSteps] = useState<FlowStep[]>([])
const [dragStepId, setDragStepId] = useState<string | null>(null) const [dragStepId, setDragStepId] = useState<string | null>(null)
// Order of parent category ids (scope = parent)
const [parentCategoryOrder, setParentCategoryOrder] = useState<string[]>([])
// Order of sub category ids for the selected parent (scope = sub)
const [subCategoryOrder, setSubCategoryOrder] = useState<string[]>([])
const [dragCategoryId, setDragCategoryId] = useState<string | null>(null)
const selectedCourse = useMemo( const parentCategories = useMemo(
() => courses.find((c) => String(c.id) === selectedCourseId), () => categories.filter((c) => !c.parent_id),
[courses, selectedCourseId], [categories],
) )
// Load courses and categories const selectedParentCategory = useMemo(
() => (scope === "sub" ? categories.find((c) => String(c.id) === selectedParentCategoryId) : undefined),
[categories, selectedParentCategoryId, scope],
)
const subCategoriesForParent = useMemo(() => {
if (!selectedParentCategoryId) return []
return categories.filter((c) => String(c.parent_id) === selectedParentCategoryId)
}, [categories, selectedParentCategoryId])
const selectedSubCategory = useMemo(
() => (scope === "sub" ? categories.find((c) => String(c.id) === selectedSubCategoryId) : undefined),
[categories, selectedSubCategoryId, scope],
)
// Ordered parent list: use saved order, merge in any new parents from API
const orderedParentCategories = useMemo(() => {
const byId = new Map(parentCategories.map((c) => [String(c.id), c]))
const ordered: CourseCategory[] = []
const seen = new Set<string>()
for (const id of parentCategoryOrder) {
const cat = byId.get(id)
if (cat) {
ordered.push(cat)
seen.add(id)
}
}
for (const c of parentCategories) {
if (!seen.has(String(c.id))) ordered.push(c)
}
return ordered
}, [parentCategories, parentCategoryOrder])
// Ordered sub list for selected parent
const orderedSubCategories = useMemo(() => {
const byId = new Map(subCategoriesForParent.map((c) => [String(c.id), c]))
const ordered: CourseCategory[] = []
const seen = new Set<string>()
for (const id of subCategoryOrder) {
const cat = byId.get(id)
if (cat) {
ordered.push(cat)
seen.add(id)
}
}
for (const c of subCategoriesForParent) {
if (!seen.has(String(c.id))) ordered.push(c)
}
return ordered
}, [subCategoriesForParent, subCategoryOrder])
// Load categories
useEffect(() => { useEffect(() => {
const fetchAll = async () => { const fetchAll = async () => {
setLoading(true) setLoading(true)
@ -58,22 +127,9 @@ export function CourseFlowBuilderPage() {
const catRes = await getCourseCategories() const catRes = await getCourseCategories()
const cats = catRes.data.data.categories ?? [] const cats = catRes.data.data.categories ?? []
setCategories(cats) setCategories(cats)
const all: CourseWithCategory[] = []
for (const cat of cats) {
const res = await getCoursesByCategory(cat.id)
const catCourses = res.data.data.courses ?? []
all.push(
...catCourses.map((c) => ({
...c,
category_name: cat.name,
})),
)
}
setCourses(all)
} catch (err) { } catch (err) {
console.error("Failed to load course flows data:", err) console.error("Failed to load course flows data:", err)
setError("Failed to load courses. Please try again.") setError("Failed to load categories. Please try again.")
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -82,13 +138,68 @@ export function CourseFlowBuilderPage() {
fetchAll() fetchAll()
}, []) }, [])
// Load flow for selected course from localStorage or build default // Load parent category order from localStorage (after we have categories)
useEffect(() => { useEffect(() => {
if (!selectedCourseId) { if (parentCategories.length === 0) return
try {
const raw = window.localStorage.getItem(PARENT_ORDER_KEY)
if (raw) {
const parsed: string[] = JSON.parse(raw)
if (Array.isArray(parsed) && parsed.length > 0) {
setParentCategoryOrder(parsed)
return
}
}
} catch {
// ignore
}
setParentCategoryOrder(parentCategories.map((c) => String(c.id)))
}, [parentCategories.length])
// Persist parent category order
useEffect(() => {
if (parentCategoryOrder.length === 0) return
window.localStorage.setItem(PARENT_ORDER_KEY, JSON.stringify(parentCategoryOrder))
}, [parentCategoryOrder])
// Load sub category order for selected parent
useEffect(() => {
if (!selectedParentCategoryId || subCategoriesForParent.length === 0) {
setSubCategoryOrder([])
return
}
const key = `${SUB_ORDER_KEY_PREFIX}${selectedParentCategoryId}`
try {
const raw = window.localStorage.getItem(key)
if (raw) {
const parsed: string[] = JSON.parse(raw)
if (Array.isArray(parsed) && parsed.length > 0) {
setSubCategoryOrder(parsed)
return
}
}
} catch {
// ignore
}
setSubCategoryOrder(subCategoriesForParent.map((c) => String(c.id)))
}, [selectedParentCategoryId, subCategoriesForParent.length])
// Persist sub category order for selected parent
useEffect(() => {
if (!selectedParentCategoryId || subCategoryOrder.length === 0) return
window.localStorage.setItem(
`${SUB_ORDER_KEY_PREFIX}${selectedParentCategoryId}`,
JSON.stringify(subCategoryOrder),
)
}, [selectedParentCategoryId, subCategoryOrder])
// Load flow steps for selected sub category only (sub category structure)
useEffect(() => {
if (scope !== "sub" || !selectedSubCategoryId) {
setSteps([]) setSteps([])
return return
} }
const key = `course_flow_${selectedCourseId}` const key = `subcategory_flow_${selectedSubCategoryId}`
try { try {
const raw = window.localStorage.getItem(key) const raw = window.localStorage.getItem(key)
if (raw) { if (raw) {
@ -100,42 +211,20 @@ export function CourseFlowBuilderPage() {
// ignore and fall through to default // ignore and fall through to default
} }
// Default flow: Lesson -> Practice -> Exam -> Feedback
const defaults: FlowStep[] = [ const defaults: FlowStep[] = [
{ { id: `${selectedSubCategoryId}-lesson`, type: "lesson", title: "Core lessons", description: "Main learning content for this sub category." },
id: `${selectedCourseId}-lesson`, { id: `${selectedSubCategoryId}-practice`, type: "practice", title: "Practice sessions", description: "Speaking or practice activities to reinforce learning." },
type: "lesson", { id: `${selectedSubCategoryId}-exam`, type: "exam", title: "Exam / Assessment", description: "Formal evaluation of student understanding." },
title: "Core lessons", { id: `${selectedSubCategoryId}-feedback`, type: "feedback", title: "Feedback loop", description: "Collect feedback and share results with learners." },
description: "Main learning content for this course.",
},
{
id: `${selectedCourseId}-practice`,
type: "practice",
title: "Practice sessions",
description: "Speaking or practice activities to reinforce learning.",
},
{
id: `${selectedCourseId}-exam`,
type: "exam",
title: "Exam / Assessment",
description: "Formal evaluation of student understanding.",
},
{
id: `${selectedCourseId}-feedback`,
type: "feedback",
title: "Feedback loop",
description: "Collect feedback and share results with learners.",
},
] ]
setSteps(defaults) setSteps(defaults)
}, [selectedCourseId]) }, [scope, selectedSubCategoryId])
// Persist flow when steps change // Persist flow steps for selected sub category
useEffect(() => { useEffect(() => {
if (!selectedCourseId) return if (scope !== "sub" || !selectedSubCategoryId) return
const key = `course_flow_${selectedCourseId}` window.localStorage.setItem(`subcategory_flow_${selectedSubCategoryId}`, JSON.stringify(steps))
window.localStorage.setItem(key, JSON.stringify(steps)) }, [steps, scope, selectedSubCategoryId])
}, [steps, selectedCourseId])
const handleReorder = (targetId: string) => { const handleReorder = (targetId: string) => {
if (!dragStepId || dragStepId === targetId) return if (!dragStepId || dragStepId === targetId) return
@ -151,20 +240,63 @@ export function CourseFlowBuilderPage() {
setDragStepId(null) setDragStepId(null)
} }
const handleReorderParentCategory = (targetId: string) => {
if (!dragCategoryId || dragCategoryId === targetId) return
setParentCategoryOrder((prev) => {
const currentIndex = prev.indexOf(dragCategoryId)
const targetIndex = prev.indexOf(targetId)
if (currentIndex === -1 || targetIndex === -1) return prev
const copy = [...prev]
const [moved] = copy.splice(currentIndex, 1)
copy.splice(targetIndex, 0, moved)
return copy
})
setDragCategoryId(null)
}
const handleReorderSubCategory = (targetId: string) => {
if (!dragCategoryId || dragCategoryId === targetId) return
setSubCategoryOrder((prev) => {
const currentIndex = prev.indexOf(dragCategoryId)
const targetIndex = prev.indexOf(targetId)
if (currentIndex === -1 || targetIndex === -1) return prev
const copy = [...prev]
const [moved] = copy.splice(currentIndex, 1)
copy.splice(targetIndex, 0, moved)
return copy
})
setDragCategoryId(null)
}
const getDefaultDescription = (type: StepType): string => {
switch (type) {
case "lesson":
return "Add the lessons or modules that introduce key concepts."
case "practice":
return "Connect speaking or practice activities after lessons."
case "exam":
return "Place exams or quizzes where you want to assess learners."
case "feedback":
return "Ask for feedback, NPS, or reflection after the exam or final lesson."
case "course":
return "Link or add an existing course to this flow."
case "speaking":
return "Speaking or oral practice section for this flow."
case "new_course":
return "Add a new course within this category."
default:
return ""
}
}
const handleAddStep = (type: StepType) => { const handleAddStep = (type: StepType) => {
if (!selectedCourseId) return const activeId = scope === "sub" ? selectedSubCategoryId : selectedParentCategoryId
if (!activeId) return
const newStep: FlowStep = { const newStep: FlowStep = {
id: `${selectedCourseId}-${type}-${Date.now()}`, id: `${activeId}-${type}-${Date.now()}`,
type, type,
title: STEP_LABELS[type], title: STEP_LABELS[type],
description: description: getDefaultDescription(type),
type === "lesson"
? "Add the lessons or modules that introduce key concepts."
: type === "practice"
? "Connect speaking or practice activities after lessons."
: type === "exam"
? "Place exams or quizzes where you want to assess learners."
: "Ask for feedback, NPS, or reflection after the exam or final lesson.",
} }
setSteps((prev) => [...prev, newStep]) setSteps((prev) => [...prev, newStep])
} }
@ -180,8 +312,8 @@ export function CourseFlowBuilderPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex flex-col items-center justify-center py-32"> <div className="flex flex-col items-center justify-center py-32">
<div className="rounded-2xl bg-brand-50/50 p-6"> <div className="rounded-2xl bg-white shadow-sm p-6">
<RefreshCw className="h-10 w-10 animate-spin text-brand-500" /> <RefreshCw className="h-10 w-10 animate-spin text-brand-600" />
</div> </div>
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading course flows</p> <p className="mt-4 text-sm font-medium text-grayScale-400">Loading course flows</p>
</div> </div>
@ -208,61 +340,179 @@ export function CourseFlowBuilderPage() {
<div> <div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">Course Flows</h1> <h1 className="text-2xl font-bold tracking-tight text-grayScale-700">Course Flows</h1>
<p className="mt-1 text-sm text-grayScale-400"> <p className="mt-1 text-sm text-grayScale-400">
Define the sequence of lessons, practice, exams, and feedback for each course. Arrange parent categories and sub categories into flows, including lessons, practice,
exams, and feedback steps.
</p> </p>
</div> </div>
</div> </div>
{/* Course selector */} {/* Scope & selector */}
<Card className="shadow-none border border-grayScale-200"> <Card className="shadow-none border border-grayScale-200">
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between"> <CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center"> <div className="flex flex-1 flex-col gap-3 md:flex-row md:items-center md:gap-4">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-grayScale-400"> <div className="inline-flex rounded-full border border-grayScale-200 bg-grayScale-50 p-0.5 text-xs font-medium">
Select course <button
</p> type="button"
<div className="flex-1"> onClick={() => setScope("parent")}
<Select className={cn(
value={selectedCourseId} "flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
onChange={(e) => setSelectedCourseId(e.target.value)} scope === "parent"
? "bg-white text-brand-600 shadow-sm"
: "text-grayScale-500 hover:text-grayScale-700",
)}
> >
<option value="">Choose a course</option> Parent categories
{categories.map((cat) => ( </button>
<optgroup key={cat.id} label={cat.name}> <button
{courses type="button"
.filter((c) => c.category_id === cat.id) onClick={() => setScope("sub")}
.map((course) => ( className={cn(
<option key={course.id} value={String(course.id)}> "flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
{course.title} scope === "sub"
? "bg-white text-brand-600 shadow-sm"
: "text-grayScale-500 hover:text-grayScale-700",
)}
>
Sub categories
</button>
</div>
{scope === "sub" && (
<>
<div className="flex-1 min-w-0">
<p className="mb-1 text-xs font-medium uppercase tracking-[0.14em] text-grayScale-400">
Parent category
</p>
<Select
value={selectedParentCategoryId}
onChange={(e) => {
setSelectedParentCategoryId(e.target.value)
setSelectedSubCategoryId("")
}}
>
<option value="">Choose parent</option>
{parentCategories.map((cat) => (
<option key={cat.id} value={String(cat.id)}>
{cat.name}
</option> </option>
))} ))}
</optgroup>
))}
</Select> </Select>
</div> </div>
<div className="flex-1 min-w-0">
<p className="mb-1 text-xs font-medium uppercase tracking-[0.14em] text-grayScale-400">
Sub category (structure)
</p>
<Select
value={selectedSubCategoryId}
onChange={(e) => setSelectedSubCategoryId(e.target.value)}
>
<option value="">Choose sub category</option>
{subCategoriesForParent.map((child) => (
<option key={child.id} value={String(child.id)}>
{child.name}
</option>
))}
</Select>
</div> </div>
{selectedCourse && ( </>
<div className="mt-2 flex items-center gap-2 text-xs text-grayScale-400 sm:mt-0"> )}
<Badge variant="secondary" className="text-[11px]">
{selectedCourse.category_name}
</Badge>
<ChevronRight className="h-3.5 w-3.5 text-grayScale-300" />
<span className="truncate max-w-[180px] text-grayScale-500">
{selectedCourse.title}
</span>
</div> </div>
</CardContent>
</Card>
{/* Parent scope: sequence of parent categories only */}
{scope === "parent" && (
<Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-3">
<CardTitle className="text-base font-semibold text-grayScale-600">
Parent category sequence
</CardTitle>
<p className="mt-1 text-xs text-grayScale-400">
Drag to reorder the sequence in which parent categories appear. No courses or stepsorder only.
</p>
</CardHeader>
<CardContent className="space-y-2 pt-4">
{orderedParentCategories.length === 0 ? (
<div className="rounded-lg border border-dashed border-grayScale-200 bg-grayScale-50/60 px-4 py-6 text-center text-xs text-grayScale-400">
No parent categories. Add categories in Content Management first.
</div>
) : (
orderedParentCategories.map((cat, index) => (
<div
key={cat.id}
draggable
onDragStart={() => setDragCategoryId(String(cat.id))}
onDragOver={(e) => e.preventDefault()}
onDrop={() => handleReorderParentCategory(String(cat.id))}
className={cn(
"flex items-center gap-3 rounded-xl border border-grayScale-100 bg-white p-3 shadow-sm transition-colors",
dragCategoryId === String(cat.id) && "ring-2 ring-brand-300",
)}
>
<button type="button" className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600">
<GripVertical className="h-4 w-4" />
</button>
<span className="text-[11px] font-medium text-grayScale-400">#{index + 1}</span>
<span className="flex-1 text-sm font-medium text-grayScale-700">{cat.name}</span>
</div>
))
)}
</CardContent>
</Card>
)}
{/* Sub scope: sub category sequence then structure */}
{scope === "sub" && selectedParentCategoryId && (
<>
<Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-3">
<CardTitle className="text-base font-semibold text-grayScale-600">
Sub category sequence
</CardTitle>
<p className="mt-1 text-xs text-grayScale-400">
Drag to reorder sub categories under this parent.
</p>
</CardHeader>
<CardContent className="space-y-2 pt-4">
{orderedSubCategories.length === 0 ? (
<div className="rounded-lg border border-dashed border-grayScale-200 bg-grayScale-50/60 px-4 py-6 text-center text-xs text-grayScale-400">
No sub categories under this parent.
</div>
) : (
orderedSubCategories.map((sub, index) => (
<div
key={sub.id}
draggable
onDragStart={() => setDragCategoryId(String(sub.id))}
onDragOver={(e) => e.preventDefault()}
onDrop={() => handleReorderSubCategory(String(sub.id))}
className={cn(
"flex items-center gap-3 rounded-xl border border-grayScale-100 bg-white p-3 shadow-sm transition-colors",
dragCategoryId === String(sub.id) && "ring-2 ring-brand-300",
)}
>
<button type="button" className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600">
<GripVertical className="h-4 w-4" />
</button>
<span className="text-[11px] font-medium text-grayScale-400">#{index + 1}</span>
<span className="flex-1 text-sm font-medium text-grayScale-700">{sub.name}</span>
</div>
))
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Builder */} {/* Sub category structure: flow steps (only when a sub category is selected) */}
{selectedCourse ? ( {selectedSubCategory && (
<div className="grid gap-4 md:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]"> <div className="grid gap-4 md:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
{/* Flow steps */}
<Card className="shadow-soft"> <Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-3"> <CardHeader className="border-b border-grayScale-200 pb-3">
<CardTitle className="text-base font-semibold text-grayScale-600"> <CardTitle className="text-base font-semibold text-grayScale-600">
Flow sequence Sub category structure
</CardTitle> </CardTitle>
<p className="mt-1 text-xs text-grayScale-400">
Courses, questions, and feedback steps for {selectedSubCategory.name}.
</p>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 pt-4"> <CardContent className="space-y-3 pt-4">
{steps.length === 0 && ( {steps.length === 0 && (
@ -328,7 +578,7 @@ export function CourseFlowBuilderPage() {
className="h-8 px-2 text-[11px]" className="h-8 px-2 text-[11px]"
onClick={() => { onClick={() => {
const feedbackStep: FlowStep = { const feedbackStep: FlowStep = {
id: `${selectedCourseId}-feedback-${Date.now()}`, id: `${selectedSubCategoryId}-feedback-${Date.now()}`,
type: "feedback", type: "feedback",
title: "Feedback loop", title: "Feedback loop",
description: "Collect feedback after this step.", description: "Collect feedback after this step.",
@ -387,7 +637,16 @@ export function CourseFlowBuilderPage() {
onClick={() => handleAddStep("practice")} onClick={() => handleAddStep("practice")}
> >
<span className="h-2 w-2 rounded-full bg-emerald-500" /> <span className="h-2 w-2 rounded-full bg-emerald-500" />
Practice Practice section
</Button>
<Button
type="button"
variant="outline"
className="h-10 justify-start gap-2 text-xs"
onClick={() => handleAddStep("speaking")}
>
<span className="h-2 w-2 rounded-full bg-teal-500" />
Speaking section
</Button> </Button>
<Button <Button
type="button" type="button"
@ -407,6 +666,24 @@ export function CourseFlowBuilderPage() {
<span className="h-2 w-2 rounded-full bg-rose-500" /> <span className="h-2 w-2 rounded-full bg-rose-500" />
Feedback Feedback
</Button> </Button>
<Button
type="button"
variant="outline"
className="h-10 justify-start gap-2 text-xs"
onClick={() => handleAddStep("course")}
>
<span className="h-2 w-2 rounded-full bg-violet-500" />
Course
</Button>
<Button
type="button"
variant="outline"
className="h-10 col-span-2 justify-start gap-2 text-xs sm:col-span-2"
onClick={() => handleAddStep("new_course")}
>
<span className="h-2 w-2 rounded-full bg-indigo-500" />
New course to category
</Button>
</div> </div>
<p className="text-[11px] leading-relaxed text-grayScale-400"> <p className="text-[11px] leading-relaxed text-grayScale-400">
Drag steps in the sequence on the left to change their order. Add feedback loops Drag steps in the sequence on the left to change their order. Add feedback loops
@ -427,16 +704,18 @@ export function CourseFlowBuilderPage() {
</Card> </Card>
</div> </div>
</div> </div>
) : ( )}
</>
)}
{scope === "sub" && !selectedParentCategoryId && (
<Card className="shadow-none border border-dashed border-grayScale-200 bg-grayScale-50/60"> <Card className="shadow-none border border-dashed border-grayScale-200 bg-grayScale-50/60">
<CardContent className="flex flex-col items-center justify-center gap-3 py-16 text-center"> <CardContent className="flex flex-col items-center justify-center gap-3 py-16 text-center">
<p className="text-sm font-semibold text-grayScale-600"> <p className="text-sm font-semibold text-grayScale-600">
Select a course to start structuring its flow. Select a parent category to reorder sub categories and edit a sub categorys structure.
</p> </p>
<p className="max-w-sm text-xs leading-relaxed text-grayScale-400"> <p className="max-w-sm text-xs leading-relaxed text-grayScale-400">
Once a course is selected, you can define the order of lessons, practice, exams, and Use the dropdowns above to choose a parent, then reorder its sub categories and pick a sub category to define courses, questions, and feedback steps.
feedback steps, and reorder them as needed. This works great on both desktop and
mobile layouts.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -245,8 +245,8 @@ export function CoursesPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex flex-col items-center justify-center py-32"> <div className="flex flex-col items-center justify-center py-32">
<div className="rounded-2xl bg-brand-50/50 p-6"> <div className="rounded-2xl bg-white shadow-sm p-6">
<RefreshCw className="h-10 w-10 animate-spin text-brand-500" /> <RefreshCw className="h-10 w-10 animate-spin text-brand-600" />
</div> </div>
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading courses...</p> <p className="mt-4 text-sm font-medium text-grayScale-400">Loading courses...</p>
</div> </div>
@ -421,7 +421,7 @@ export function CoursesPage() {
{/* Add Course Modal */} {/* Add Course Modal */}
{showModal && ( {showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-md animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl"> <div className="mx-4 w-full max-w-2xl animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
<h2 className="text-lg font-bold text-grayScale-700">Add New Course</h2> <h2 className="text-lg font-bold text-grayScale-700">Add New Course</h2>
<button <button

View File

@ -199,8 +199,8 @@ export function SubCoursesPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex flex-col items-center justify-center py-24"> <div className="flex flex-col items-center justify-center py-24">
<div className="rounded-full bg-brand-50 p-4"> <div className="rounded-full bg-white shadow-sm p-4">
<RefreshCw className="h-8 w-8 animate-spin text-brand-500" /> <RefreshCw className="h-8 w-8 animate-spin text-brand-600" />
</div> </div>
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading sub-courses...</p> <p className="mt-4 text-sm font-medium text-grayScale-400">Loading sub-courses...</p>
</div> </div>

View File

@ -35,6 +35,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "../../components/ui/dialog" } from "../../components/ui/dialog"
import { FileUpload } from "../../components/ui/file-upload"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { import {
getNotifications, getNotifications,
@ -246,6 +247,8 @@ export function NotificationsPage() {
const [channelFilter, setChannelFilter] = useState<"all" | "push" | "sms">("all") const [channelFilter, setChannelFilter] = useState<"all" | "push" | "sms">("all")
const [activeStatusTab, setActiveStatusTab] = useState<"all" | "read" | "unread">("all") const [activeStatusTab, setActiveStatusTab] = useState<"all" | "read" | "unread">("all")
const [searchTerm, setSearchTerm] = useState("") const [searchTerm, setSearchTerm] = useState("")
const [typeFilter, setTypeFilter] = useState<"all" | string>("all")
const [levelFilter, setLevelFilter] = useState<"all" | string>("all")
const [composeChannels, setComposeChannels] = useState<Array<"push" | "sms">>(["push"]) const [composeChannels, setComposeChannels] = useState<Array<"push" | "sms">>(["push"])
const [composeAudience, setComposeAudience] = useState<"all" | "selected">("all") const [composeAudience, setComposeAudience] = useState<"all" | "selected">("all")
@ -256,6 +259,7 @@ export function NotificationsPage() {
const [composeMessage, setComposeMessage] = useState("") const [composeMessage, setComposeMessage] = useState("")
const [sending, setSending] = useState(false) const [sending, setSending] = useState(false)
const [composeOpen, setComposeOpen] = useState(false) const [composeOpen, setComposeOpen] = useState(false)
const [composeImage, setComposeImage] = useState<File | null>(null)
const fetchData = useCallback(async (currentOffset: number) => { const fetchData = useCallback(async (currentOffset: number) => {
setLoading(true) setLoading(true)
@ -335,6 +339,8 @@ export function NotificationsPage() {
if (channelFilter !== "all" && n.delivery_channel !== channelFilter) return false if (channelFilter !== "all" && n.delivery_channel !== channelFilter) return false
if (activeStatusTab === "read" && !n.is_read) return false if (activeStatusTab === "read" && !n.is_read) return false
if (activeStatusTab === "unread" && n.is_read) return false if (activeStatusTab === "unread" && n.is_read) return false
if (typeFilter !== "all" && n.type !== typeFilter) return false
if (levelFilter !== "all" && n.level !== levelFilter) return false
if (searchTerm.trim()) { if (searchTerm.trim()) {
const q = searchTerm.toLowerCase() const q = searchTerm.toLowerCase()
const haystack = [ const haystack = [
@ -371,6 +377,7 @@ export function NotificationsPage() {
setComposeAudience("all") setComposeAudience("all")
setComposeChannels(["push"]) setComposeChannels(["push"])
setSelectedRecipientIds([]) setSelectedRecipientIds([])
setComposeImage(null)
setComposeOpen(false) setComposeOpen(false)
} finally { } finally {
setSending(false) setSending(false)
@ -586,6 +593,36 @@ export function NotificationsPage() {
<option value="sms">SMS</option> <option value="sms">SMS</option>
</Select> </Select>
</div> </div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-grayScale-500">Type</span>
<Select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="h-8 w-[150px] text-xs"
>
<option value="all">All types</option>
{Array.from(new Set(notifications.map((n) => n.type))).map((t) => (
<option key={t} value={t}>
{formatTypeLabel(t)}
</option>
))}
</Select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-grayScale-500">Level</span>
<Select
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
className="h-8 w-[130px] text-xs"
>
<option value="all">All levels</option>
{Array.from(new Set(notifications.map((n) => n.level))).map((lvl) => (
<option key={lvl} value={lvl}>
{lvl}
</option>
))}
</Select>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -901,6 +938,7 @@ export function NotificationsPage() {
</div> </div>
</div> </div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1.2fr)]">
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">Title</label> <label className="mb-1 block text-xs font-medium text-grayScale-500">Title</label>
@ -911,7 +949,9 @@ export function NotificationsPage() {
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">Message</label> <label className="mb-1 block text-xs font-medium text-grayScale-500">
Message
</label>
<Textarea <Textarea
rows={3} rows={3}
placeholder={ placeholder={
@ -925,6 +965,24 @@ export function NotificationsPage() {
</div> </div>
</div> </div>
<div className="space-y-2">
<p className="mb-1 block text-xs font-medium text-grayScale-500">
Image (push only)
</p>
<FileUpload
accept="image/*"
onFileSelect={setComposeImage}
label="Upload notification image"
description="Shown with push notification where supported"
className="min-h-[110px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
<p className="text-[10px] text-grayScale-400">
Image will be ignored for SMS-only sends. Connect your push provider to attach it
to real notifications.
</p>
</div>
</div>
{composeAudience === "selected" && ( {composeAudience === "selected" && (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-medium text-grayScale-500">Recipients</p> <p className="text-xs font-medium text-grayScale-500">Recipients</p>
@ -988,6 +1046,7 @@ export function NotificationsPage() {
setComposeAudience("all") setComposeAudience("all")
setComposeChannels(["push"]) setComposeChannels(["push"])
setSelectedRecipientIds([]) setSelectedRecipientIds([])
setComposeImage(null)
}} }}
> >
Clear Clear

View File

@ -0,0 +1,259 @@
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { ArrowLeft, Briefcase, Mail, Phone, Shield, User, Building2, Calendar } from "lucide-react"
import { Button } from "../../components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Input } from "../../components/ui/input"
import { Select } from "../../components/ui/select"
import { Textarea } from "../../components/ui/textarea"
import { createTeamMember } from "../../api/team.api"
import { toast } from "sonner"
export function AddTeamMemberPage() {
const navigate = useNavigate()
const [firstName, setFirstName] = useState("")
const [lastName, setLastName] = useState("")
const [email, setEmail] = useState("")
const [phone, setPhone] = useState("")
const [role, setRole] = useState("")
const [department, setDepartment] = useState("")
const [jobTitle, setJobTitle] = useState("")
const [employmentType, setEmploymentType] = useState("")
const [hireDate, setHireDate] = useState("")
const [bio, setBio] = useState("")
const [submitting, setSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!firstName.trim() || !lastName.trim() || !email.trim() || !phone.trim() || !role || !department || !jobTitle || !employmentType || !hireDate) {
toast.error("Missing required fields", {
description: "First name, last name, email, phone, role, department, job title, employment type, and hire date are required.",
})
return
}
setSubmitting(true)
try {
await createTeamMember({
first_name: firstName.trim(),
last_name: lastName.trim(),
email: email.trim(),
phone_number: phone.trim(),
team_role: role,
department,
job_title: jobTitle,
employment_type: employmentType,
hire_date: hireDate,
bio: bio.trim() || undefined,
})
toast.success("Team member added", {
description: `${firstName} ${lastName} has been created successfully.`,
})
navigate("/team")
} catch (err: any) {
const message =
err?.response?.data?.message ||
"Failed to create team member. Please check the details and try again."
toast.error("Creation failed", {
description: message,
})
} finally {
setSubmitting(false)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
className="h-9 w-9 rounded-lg border border-grayScale-200 bg-white shadow-sm hover:bg-grayScale-50"
onClick={() => navigate("/team")}
>
<ArrowLeft className="h-4 w-4 text-grayScale-500" />
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">Add Team Member</h1>
<p className="mt-0.5 text-sm text-grayScale-400">
Create a new admin/team account with the right role and permissions.
</p>
</div>
</div>
</div>
<Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-4">
<CardTitle className="text-base font-semibold text-grayScale-600">
Team member details
</CardTitle>
</CardHeader>
<CardContent className="pt-5">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic info */}
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-grayScale-600">
<User className="h-3.5 w-3.5" />
First name
</label>
<Input
placeholder="e.g. Sarah"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
</div>
<div>
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-grayScale-600">
<User className="h-3.5 w-3.5" />
Last name
</label>
<Input
placeholder="e.g. Ahmed"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-grayScale-600">
<Mail className="h-3.5 w-3.5" />
Email
</label>
<Input
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-grayScale-600">
<Phone className="h-3.5 w-3.5" />
Phone number
</label>
<Input
type="tel"
placeholder="+251..."
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</div>
</div>
{/* Role & org */}
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-grayScale-600">
<Shield className="h-3.5 w-3.5" />
Role
</label>
<Select value={role} onChange={(e) => setRole(e.target.value)}>
<option value="">Select role</option>
<option value="super_admin">Super Admin</option>
<option value="admin">Admin</option>
<option value="content_manager">Content Manager</option>
<option value="instructor">Instructor</option>
<option value="support_agent">Support Agent</option>
<option value="finance">Finance</option>
<option value="hr">HR</option>
<option value="analyst">Analyst</option>
</Select>
</div>
<div>
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-grayScale-600">
<Building2 className="h-3.5 w-3.5" />
Department
</label>
<Input
placeholder="e.g. Operations"
value={department}
onChange={(e) => setDepartment(e.target.value)}
/>
</div>
<div>
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-grayScale-600">
<Briefcase className="h-3.5 w-3.5" />
Job title
</label>
<Input
placeholder="e.g. Content Lead"
value={jobTitle}
onChange={(e) => setJobTitle(e.target.value)}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-grayScale-600">
<Briefcase className="h-3.5 w-3.5" />
Employment type
</label>
<Select
value={employmentType}
onChange={(e) => setEmploymentType(e.target.value)}
>
<option value="">Select type</option>
<option value="full_time">Full-time</option>
<option value="part_time">Part-time</option>
<option value="contractor">Contractor</option>
<option value="intern">Intern</option>
</Select>
</div>
<div>
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-grayScale-600">
<Calendar className="h-3.5 w-3.5" />
Hire date
</label>
<Input
type="date"
value={hireDate}
onChange={(e) => setHireDate(e.target.value)}
/>
</div>
</div>
<div>
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-grayScale-600">
<ArrowLeft className="h-3.5 w-3.5" />
Bio / notes (optional)
</label>
<Textarea
rows={3}
placeholder="Short description, responsibilities, or notes about this team member."
value={bio}
onChange={(e) => setBio(e.target.value)}
/>
</div>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
className="w-full sm:w-auto"
onClick={() => navigate("/team")}
disabled={submitting}
>
Cancel
</Button>
<Button
type="submit"
className="w-full bg-brand-500 text-white shadow-sm hover:bg-brand-600 sm:w-auto"
disabled={submitting}
>
{submitting ? "Creating…" : "Create team member"}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@ -145,7 +145,10 @@ export function TeamManagementPage() {
Manage user access, roles, and platform permissions. Manage user access, roles, and platform permissions.
</p> </p>
</div> </div>
<Button className="bg-brand-600 hover:bg-brand-500 text-white w-full sm:w-auto"> <Button
className="bg-brand-600 hover:bg-brand-500 text-white w-full sm:w-auto"
onClick={() => navigate("/team/add")}
>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add Team Member Add Team Member
</Button> </Button>

View File

@ -2,11 +2,13 @@ export interface CourseCategory {
id: number id: number
name: string name: string
is_active: boolean is_active: boolean
parent_id?: number | null
created_at: string created_at: string
} }
export interface CreateCourseCategoryRequest { export interface CreateCourseCategoryRequest {
name: string name: string
parent_id?: number | null
} }
export interface GetCourseCategoriesResponse { export interface GetCourseCategoriesResponse {

View File

@ -17,6 +17,19 @@ export interface TeamMember {
created_at: string created_at: string
} }
export interface CreateTeamMemberRequest {
first_name: string
last_name: string
email: string
phone_number: string
team_role: string
department: string
job_title: string
employment_type: string
hire_date: string
bio?: string
}
export interface TeamMembersMetadata { export interface TeamMembersMetadata {
total: number total: number
total_pages: number total_pages: number