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:
parent
46c0c78214
commit
089c1ac869
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 follow‑up 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 steps—order 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 category’s 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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
259
src/pages/team/AddTeamMemberPage.tsx
Normal file
259
src/pages/team/AddTeamMemberPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user