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 type { GetTeamMembersResponse, GetTeamMemberResponse } from "../types/team.types"
import type { GetTeamMembersResponse, GetTeamMemberResponse, CreateTeamMemberRequest } from "../types/team.types"
export const getTeamMembers = (page?: number, pageSize?: number) =>
http.get<GetTeamMembersResponse>("/team/members", {
@ -11,3 +11,6 @@ export const getTeamMembers = (page?: number, pageSize?: number) =>
export const getTeamMemberById = (id: number) =>
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 { SettingsPage } from "../pages/SettingsPage"
import { TeamManagementPage } from "../pages/team/TeamManagementPage"
import { AddTeamMemberPage } from "../pages/team/AddTeamMemberPage"
import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage"
import { LoginPage } from "../pages/auth/LoginPage"
import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage"
@ -89,6 +90,7 @@ export function AppRoutes() {
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/team" element={<TeamManagementPage />} />
<Route path="/team/add" element={<AddTeamMemberPage />} />
<Route path="/team/:id" element={<TeamMemberDetailPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/settings" element={<SettingsPage />} />

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react"
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 { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
@ -15,7 +15,7 @@ import {
} from "../../components/ui/table"
import { Badge } from "../../components/ui/badge"
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 {
Dialog,
@ -41,11 +41,18 @@ export function AllCoursesPage() {
const [createOpen, setCreateOpen] = useState(false)
const [createCategoryId, setCreateCategoryId] = useState<string>("")
const [createSubCategoryId, setCreateSubCategoryId] = useState<string>("")
const [createTitle, setCreateTitle] = useState("")
const [createDescription, setCreateDescription] = useState("")
const [createThumbnail, setCreateThumbnail] = useState<File | null>(null)
const [createVideo, setCreateVideo] = useState<File | null>(null)
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 () => {
setLoading(true)
@ -92,9 +99,11 @@ export function AllCoursesPage() {
})
const handleCreateCourse = async () => {
if (!createCategoryId || !createTitle.trim() || !createDescription.trim()) {
const effectiveCategoryId = createSubCategoryId || createCategoryId
if (!effectiveCategoryId || !createTitle.trim() || !createDescription.trim()) {
toast.error("Missing fields", {
description: "Category, title, and description are required.",
description: "Category (or subcategory), title, and description are required.",
})
return
}
@ -102,7 +111,7 @@ export function AllCoursesPage() {
setCreating(true)
try {
await createCourse({
category_id: Number(createCategoryId),
category_id: Number(effectiveCategoryId),
title: createTitle.trim(),
description: createDescription.trim(),
})
@ -113,6 +122,7 @@ export function AllCoursesPage() {
setCreateOpen(false)
setCreateCategoryId("")
setCreateSubCategoryId("")
setCreateTitle("")
setCreateDescription("")
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) {
return (
<div className="flex flex-col items-center justify-center py-32">
<div className="rounded-2xl bg-brand-50/50 p-6">
<RefreshCw className="h-10 w-10 animate-spin text-brand-500" />
<div className="rounded-2xl bg-white shadow-sm p-6">
<RefreshCw className="h-10 w-10 animate-spin text-brand-600" />
</div>
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading all courses</p>
</div>
@ -263,19 +322,48 @@ export function AllCoursesPage() {
</Badge>
</TableCell>
<TableCell className="py-3.5 text-right">
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-xs text-brand-500 hover:bg-brand-50"
onClick={(e) => {
e.stopPropagation()
navigate(
`/content/category/${course.category_id}/courses/${course.id}/sub-courses`,
)
}}
>
Open
</Button>
<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
variant="ghost"
size="sm"
className="h-8 px-2 text-xs text-brand-500 hover:bg-brand-50"
onClick={(e) => {
e.stopPropagation()
navigate(
`/content/category/${course.category_id}/courses/${course.id}/sub-courses`,
)
}}
>
Open
</Button>
</div>
</TableCell>
</TableRow>
))}
@ -298,7 +386,7 @@ export function AllCoursesPage() {
{/* Create course dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="max-w-lg">
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create course</DialogTitle>
<DialogDescription>
@ -308,24 +396,48 @@ export function AllCoursesPage() {
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="sm:col-span-1">
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Category
</label>
<Select
value={createCategoryId}
onChange={(e) => setCreateCategoryId(e.target.value)}
onChange={(e) => {
setCreateCategoryId(e.target.value)
setCreateSubCategoryId("")
}}
>
<option value="">Select category</option>
{categories.map((cat) => (
<option key={cat.id} value={String(cat.id)}>
{cat.name}
</option>
))}
{categories
.filter((cat) => !cat.parent_id)
.map((cat) => (
<option key={cat.id} value={String(cat.id)}>
{cat.name}
</option>
))}
</Select>
</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">
Course title
</label>
@ -407,6 +519,65 @@ export function AllCoursesPage() {
</div>
</DialogContent>
</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>
)
}

View File

@ -64,6 +64,14 @@ export function ContentOverviewPage() {
const [category, setCategory] = useState<CourseCategory | null>(null)
const [sections, setSections] = useState<ContentSection[]>(() => [...contentSections])
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(() => {
const fetchCategory = async () => {
@ -81,6 +89,30 @@ export function ContentOverviewPage() {
}
}, [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
useEffect(() => {
try {
@ -242,6 +274,88 @@ export function ContentOverviewPage() {
)
})}
</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>
)
}

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 { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { Select } from "../../components/ui/select"
import {
Dialog,
DialogContent,
@ -22,6 +23,7 @@ export function CourseCategoryPage() {
const [createOpen, setCreateOpen] = useState(false)
const [newCategoryName, setNewCategoryName] = useState("")
const [creating, setCreating] = useState(false)
const [parentCategoryId, setParentCategoryId] = useState<number | null>(null)
const fetchCategories = async () => {
setLoading(true)
@ -159,7 +161,7 @@ export function CourseCategoryPage() {
<span>Create course category</span>
</DialogTitle>
<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>
</DialogHeader>
@ -174,6 +176,24 @@ export function CourseCategoryPage() {
onChange={(e) => setNewCategoryName(e.target.value)}
/>
</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 className="mt-5 flex items-center justify-end gap-2">
@ -194,11 +214,15 @@ export function CourseCategoryPage() {
if (!newCategoryName.trim()) return
setCreating(true)
try {
await createCourseCategory({ name: newCategoryName.trim() })
await createCourseCategory({
name: newCategoryName.trim(),
parent_id: parentCategoryId ?? null,
})
toast.success("Category created", {
description: `"${newCategoryName.trim()}" has been added.`,
})
setNewCategoryName("")
setParentCategoryId(null)
setCreateOpen(false)
fetchCategories()
} catch (err: any) {

View File

@ -1,15 +1,21 @@
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 { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { Select } from "../../components/ui/select"
import { Badge } from "../../components/ui/badge"
import { getCourseCategories, getCoursesByCategory } from "../../api/courses.api"
import type { Course, CourseCategory } from "../../types/course.types"
import { getCourseCategories } from "../../api/courses.api"
import type { CourseCategory } from "../../types/course.types"
import { cn } from "../../lib/utils"
type StepType = "lesson" | "practice" | "exam" | "feedback"
type StepType =
| "lesson"
| "practice"
| "exam"
| "feedback"
| "course"
| "speaking"
| "new_course"
type FlowStep = {
id: string
@ -18,13 +24,14 @@ type FlowStep = {
description?: string
}
type CourseWithCategory = Course & { category_name: string }
const STEP_LABELS: Record<StepType, string> = {
lesson: "Lesson",
practice: "Practice",
exam: "Exam",
feedback: "Feedback loop",
course: "Course",
speaking: "Speaking section",
new_course: "New course (category)",
}
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",
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",
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() {
const [categories, setCategories] = useState<CourseCategory[]>([])
const [courses, setCourses] = useState<CourseWithCategory[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedCourseId, setSelectedCourseId] = useState<string>("")
const [scope, setScope] = useState<"sub" | "parent">("sub")
const [selectedSubCategoryId, setSelectedSubCategoryId] = useState<string>("")
const [selectedParentCategoryId, setSelectedParentCategoryId] = useState<string>("")
const [steps, setSteps] = useState<FlowStep[]>([])
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(
() => courses.find((c) => String(c.id) === selectedCourseId),
[courses, selectedCourseId],
const parentCategories = useMemo(
() => categories.filter((c) => !c.parent_id),
[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(() => {
const fetchAll = async () => {
setLoading(true)
@ -58,22 +127,9 @@ export function CourseFlowBuilderPage() {
const catRes = await getCourseCategories()
const cats = catRes.data.data.categories ?? []
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) {
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 {
setLoading(false)
}
@ -82,13 +138,68 @@ export function CourseFlowBuilderPage() {
fetchAll()
}, [])
// Load flow for selected course from localStorage or build default
// Load parent category order from localStorage (after we have categories)
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([])
return
}
const key = `course_flow_${selectedCourseId}`
const key = `subcategory_flow_${selectedSubCategoryId}`
try {
const raw = window.localStorage.getItem(key)
if (raw) {
@ -100,42 +211,20 @@ export function CourseFlowBuilderPage() {
// ignore and fall through to default
}
// Default flow: Lesson -> Practice -> Exam -> Feedback
const defaults: FlowStep[] = [
{
id: `${selectedCourseId}-lesson`,
type: "lesson",
title: "Core lessons",
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.",
},
{ id: `${selectedSubCategoryId}-lesson`, type: "lesson", title: "Core lessons", description: "Main learning content for this sub category." },
{ id: `${selectedSubCategoryId}-practice`, type: "practice", title: "Practice sessions", description: "Speaking or practice activities to reinforce learning." },
{ id: `${selectedSubCategoryId}-exam`, type: "exam", title: "Exam / Assessment", description: "Formal evaluation of student understanding." },
{ id: `${selectedSubCategoryId}-feedback`, type: "feedback", title: "Feedback loop", description: "Collect feedback and share results with learners." },
]
setSteps(defaults)
}, [selectedCourseId])
}, [scope, selectedSubCategoryId])
// Persist flow when steps change
// Persist flow steps for selected sub category
useEffect(() => {
if (!selectedCourseId) return
const key = `course_flow_${selectedCourseId}`
window.localStorage.setItem(key, JSON.stringify(steps))
}, [steps, selectedCourseId])
if (scope !== "sub" || !selectedSubCategoryId) return
window.localStorage.setItem(`subcategory_flow_${selectedSubCategoryId}`, JSON.stringify(steps))
}, [steps, scope, selectedSubCategoryId])
const handleReorder = (targetId: string) => {
if (!dragStepId || dragStepId === targetId) return
@ -151,20 +240,63 @@ export function CourseFlowBuilderPage() {
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) => {
if (!selectedCourseId) return
const activeId = scope === "sub" ? selectedSubCategoryId : selectedParentCategoryId
if (!activeId) return
const newStep: FlowStep = {
id: `${selectedCourseId}-${type}-${Date.now()}`,
id: `${activeId}-${type}-${Date.now()}`,
type,
title: STEP_LABELS[type],
description:
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.",
description: getDefaultDescription(type),
}
setSteps((prev) => [...prev, newStep])
}
@ -180,8 +312,8 @@ export function CourseFlowBuilderPage() {
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-32">
<div className="rounded-2xl bg-brand-50/50 p-6">
<RefreshCw className="h-10 w-10 animate-spin text-brand-500" />
<div className="rounded-2xl bg-white shadow-sm p-6">
<RefreshCw className="h-10 w-10 animate-spin text-brand-600" />
</div>
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading course flows</p>
</div>
@ -208,72 +340,190 @@ export function CourseFlowBuilderPage() {
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">Course Flows</h1>
<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>
</div>
</div>
{/* Course selector */}
{/* Scope & selector */}
<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">
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-grayScale-400">
Select course
</p>
<div className="flex-1">
<Select
value={selectedCourseId}
onChange={(e) => setSelectedCourseId(e.target.value)}
<div className="flex flex-1 flex-col gap-3 md:flex-row md:items-center md:gap-4">
<div className="inline-flex rounded-full border border-grayScale-200 bg-grayScale-50 p-0.5 text-xs font-medium">
<button
type="button"
onClick={() => setScope("parent")}
className={cn(
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
scope === "parent"
? "bg-white text-brand-600 shadow-sm"
: "text-grayScale-500 hover:text-grayScale-700",
)}
>
<option value="">Choose a course</option>
{categories.map((cat) => (
<optgroup key={cat.id} label={cat.name}>
{courses
.filter((c) => c.category_id === cat.id)
.map((course) => (
<option key={course.id} value={String(course.id)}>
{course.title}
</option>
))}
</optgroup>
))}
</Select>
Parent categories
</button>
<button
type="button"
onClick={() => setScope("sub")}
className={cn(
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
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>
))}
</Select>
</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>
)}
</CardContent>
</Card>
{/* Builder */}
{selectedCourse ? (
<div className="grid gap-4 md:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
{/* Flow steps */}
{/* 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">
Flow sequence
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-3 pt-4">
{steps.length === 0 && (
<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 steps yet. Use the buttons on the right to add lessons, practice, exams, and
feedback loops.
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>
</Card>
<div className="space-y-2">
{steps.map((step, index) => (
{/* Sub category structure: flow steps (only when a sub category is selected) */}
{selectedSubCategory && (
<div className="grid gap-4 md:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
<Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-3">
<CardTitle className="text-base font-semibold text-grayScale-600">
Sub category structure
</CardTitle>
<p className="mt-1 text-xs text-grayScale-400">
Courses, questions, and feedback steps for {selectedSubCategory.name}.
</p>
</CardHeader>
<CardContent className="space-y-3 pt-4">
{steps.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 steps yet. Use the buttons on the right to add lessons, practice, exams, and
feedback loops.
</div>
)}
<div className="space-y-2">
{steps.map((step, index) => (
<div
key={step.id}
draggable
@ -328,7 +578,7 @@ export function CourseFlowBuilderPage() {
className="h-8 px-2 text-[11px]"
onClick={() => {
const feedbackStep: FlowStep = {
id: `${selectedCourseId}-feedback-${Date.now()}`,
id: `${selectedSubCategoryId}-feedback-${Date.now()}`,
type: "feedback",
title: "Feedback loop",
description: "Collect feedback after this step.",
@ -387,7 +637,16 @@ export function CourseFlowBuilderPage() {
onClick={() => handleAddStep("practice")}
>
<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
type="button"
@ -407,6 +666,24 @@ export function CourseFlowBuilderPage() {
<span className="h-2 w-2 rounded-full bg-rose-500" />
Feedback
</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>
<p className="text-[11px] leading-relaxed text-grayScale-400">
Drag steps in the sequence on the left to change their order. Add feedback loops
@ -426,17 +703,19 @@ export function CourseFlowBuilderPage() {
</CardContent>
</Card>
</div>
</div>
) : (
</div>
)}
</>
)}
{scope === "sub" && !selectedParentCategoryId && (
<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">
<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 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
feedback steps, and reorder them as needed. This works great on both desktop and
mobile layouts.
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.
</p>
</CardContent>
</Card>

View File

@ -245,8 +245,8 @@ export function CoursesPage() {
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-32">
<div className="rounded-2xl bg-brand-50/50 p-6">
<RefreshCw className="h-10 w-10 animate-spin text-brand-500" />
<div className="rounded-2xl bg-white shadow-sm p-6">
<RefreshCw className="h-10 w-10 animate-spin text-brand-600" />
</div>
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading courses...</p>
</div>
@ -421,7 +421,7 @@ export function CoursesPage() {
{/* Add Course Modal */}
{showModal && (
<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">
<h2 className="text-lg font-bold text-grayScale-700">Add New Course</h2>
<button

View File

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

View File

@ -35,6 +35,7 @@ import {
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog"
import { FileUpload } from "../../components/ui/file-upload"
import { cn } from "../../lib/utils"
import {
getNotifications,
@ -246,6 +247,8 @@ export function NotificationsPage() {
const [channelFilter, setChannelFilter] = useState<"all" | "push" | "sms">("all")
const [activeStatusTab, setActiveStatusTab] = useState<"all" | "read" | "unread">("all")
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 [composeAudience, setComposeAudience] = useState<"all" | "selected">("all")
@ -256,6 +259,7 @@ export function NotificationsPage() {
const [composeMessage, setComposeMessage] = useState("")
const [sending, setSending] = useState(false)
const [composeOpen, setComposeOpen] = useState(false)
const [composeImage, setComposeImage] = useState<File | null>(null)
const fetchData = useCallback(async (currentOffset: number) => {
setLoading(true)
@ -335,6 +339,8 @@ export function NotificationsPage() {
if (channelFilter !== "all" && n.delivery_channel !== channelFilter) return false
if (activeStatusTab === "read" && !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()) {
const q = searchTerm.toLowerCase()
const haystack = [
@ -371,6 +377,7 @@ export function NotificationsPage() {
setComposeAudience("all")
setComposeChannels(["push"])
setSelectedRecipientIds([])
setComposeImage(null)
setComposeOpen(false)
} finally {
setSending(false)
@ -586,6 +593,36 @@ export function NotificationsPage() {
<option value="sms">SMS</option>
</Select>
</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>
</CardContent>
</Card>
@ -901,27 +938,48 @@ export function NotificationsPage() {
</div>
</div>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">Title</label>
<Input
placeholder="Short headline for this notification"
value={composeTitle}
onChange={(e) => setComposeTitle(e.target.value)}
/>
<div className="grid gap-3 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1.2fr)]">
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">Title</label>
<Input
placeholder="Short headline for this notification"
value={composeTitle}
onChange={(e) => setComposeTitle(e.target.value)}
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
Message
</label>
<Textarea
rows={3}
placeholder={
composeChannels.includes("sms") && !composeChannels.includes("push")
? "Concise SMS body. Keep it clear and under 160 characters where possible."
: "Notification body shown inside the app."
}
value={composeMessage}
onChange={(e) => setComposeMessage(e.target.value)}
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">Message</label>
<Textarea
rows={3}
placeholder={
composeChannels.includes("sms") && !composeChannels.includes("push")
? "Concise SMS body. Keep it clear and under 160 characters where possible."
: "Notification body shown inside the app."
}
value={composeMessage}
onChange={(e) => setComposeMessage(e.target.value)}
<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>
@ -988,6 +1046,7 @@ export function NotificationsPage() {
setComposeAudience("all")
setComposeChannels(["push"])
setSelectedRecipientIds([])
setComposeImage(null)
}}
>
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.
</p>
</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" />
Add Team Member
</Button>

View File

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

View File

@ -17,6 +17,19 @@ export interface TeamMember {
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 {
total: number
total_pages: number