Compare commits

...

13 Commits

Author SHA1 Message Date
95f5d37878 merge confilci fixes with front branch 2026-02-28 07:00:34 -08:00
“kirukib”
63f0ff9157 Flow builder save buttons and category sub-creator
Made-with: Cursor
2026-02-27 20:58:56 +03:00
“kirukib”
089c1ac869 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
2026-02-27 20:39:19 +03:00
“kirukib”
46c0c78214 changes 2026-02-27 19:39:58 +03:00
“kirukib”
cd2ed66960 ui+plus 2026-02-27 19:31:41 +03:00
“kirukib”
e35defe48a ui-fixes 2026-02-27 19:03:47 +03:00
“kirukib”
c7447f68ad Merge branch 'production' into front 2026-02-27 17:20:40 +03:00
“kirukib”
d0e694bc07 t 2026-02-27 17:08:11 +03:00
“kirukib”
0403813b89 - 2026-02-27 16:58:05 +03:00
66c5adf6c2 Merge branch 'production' of https://gitea.yaltopia.com/Yimaru/Yimaru-Admin into production 2026-02-27 01:09:52 -08:00
Kerod-Fresenbet-Gebremedhin2660
8b405e015c Empty commit to trigger CI/CD - 2 2026-02-24 19:44:53 +03:00
Kerod-Fresenbet-Gebremedhin2660
9c6b5eef6d Empty commit to trigger CI/CD - 2 2026-02-24 19:15:55 +03:00
Kerod-Fresenbet-Gebremedhin2660
d02aff35fa Empty commit to trigger CI/CD - 1 2026-02-24 19:11:34 +03:00
25 changed files with 3616 additions and 544 deletions

19
package-lock.json generated
View File

@ -88,7 +88,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@ -2754,7 +2753,6 @@
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@ -2765,7 +2763,6 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -2776,7 +2773,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -2832,7 +2828,6 @@
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0",
@ -3084,7 +3079,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -3323,7 +3317,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -3900,7 +3893,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -5015,7 +5007,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -5063,7 +5054,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -5252,7 +5242,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -5262,7 +5251,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -5282,7 +5270,6 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@ -5488,8 +5475,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@ -5893,7 +5879,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -6061,7 +6046,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -6199,7 +6183,6 @@
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@ -37,11 +37,15 @@ import type {
CreateQuestionRequest,
CreateQuestionResponse,
CreateVimeoVideoRequest,
CreateCourseCategoryRequest,
} from "../types/course.types"
export const getCourseCategories = () =>
http.get<GetCourseCategoriesResponse>("/course-management/categories")
export const createCourseCategory = (data: CreateCourseCategoryRequest) =>
http.post("/course-management/categories", data)
export const getCoursesByCategory = (categoryId: number) =>
http.get<GetCoursesResponse>(`/course-management/categories/${categoryId}/courses`)

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

@ -14,3 +14,17 @@ export const getUserById = (id: number) =>
export const getMyProfile = () =>
http.get<UserProfileResponse>("/team/me");
// Best-guess API for creating a new user (admin-side).
// Adjust payload shape or endpoint if backend differs.
export interface CreateUserRequest {
first_name: string;
last_name: string;
email: string;
phone_number: string;
role: string;
notes?: string;
}
export const createUser = (payload: CreateUserRequest) =>
http.post("/users", payload);

View File

@ -4,6 +4,8 @@ import { DashboardPage } from "../pages/DashboardPage"
import { AnalyticsPage } from "../pages/analytics/AnalyticsPage"
import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout"
import { CourseCategoryPage } from "../pages/content-management/CourseCategoryPage"
import { AllCoursesPage } from "../pages/content-management/AllCoursesPage"
import { CourseFlowBuilderPage } from "../pages/content-management/CourseFlowBuilderPage"
import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage"
import { CoursesPage } from "../pages/content-management/CoursesPage"
import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuestionsPage"
@ -33,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"
@ -62,6 +65,8 @@ export function AppRoutes() {
<Route path="/content" element={<ContentManagementLayout />}>
<Route index element={<CourseCategoryPage />} />
<Route path="courses" element={<AllCoursesPage />} />
<Route path="flows" element={<CourseFlowBuilderPage />} />
<Route path="category/:categoryId" element={<ContentOverviewPage />} />
<Route path="category/:categoryId/courses" element={<CoursesPage />} />
{/* Course → Sub-course → Video/Practice */}
@ -85,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

@ -29,7 +29,7 @@ import {
import { StatCard } from "../components/dashboard/StatCard"
import alertSrc from "../assets/Alert.svg"
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
// import { cn } from "../lib/utils"
import { cn } from "../lib/utils"
import { getTeamMemberById } from "../api/team.api"
import { getDashboard } from "../api/analytics.api"
import { useEffect, useState } from "react"
@ -46,6 +46,7 @@ export function DashboardPage() {
const [userFirstName, setUserFirstName] = useState<string>("")
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true)
const [activeStatTab, setActiveStatTab] = useState<"primary" | "secondary">("primary")
useEffect(() => {
const fetchUser = async () => {
@ -123,8 +124,45 @@ export function DashboardPage() {
</div>
) : (
<>
{/* Stat tabs */}
<div className="mb-3 border-b border-grayScale-200">
<div className="-mb-px flex gap-6">
<button
type="button"
onClick={() => setActiveStatTab("primary")}
className={cn(
"relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all",
activeStatTab === "primary"
? "text-brand-600"
: "text-grayScale-400 hover:text-grayScale-700",
)}
>
Overview
{activeStatTab === "primary" && (
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
)}
</button>
<button
type="button"
onClick={() => setActiveStatTab("secondary")}
className={cn(
"relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all",
activeStatTab === "secondary"
? "text-brand-600"
: "text-grayScale-400 hover:text-grayScale-700",
)}
>
More metrics
{activeStatTab === "secondary" && (
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
)}
</button>
</div>
</div>
{/* Stat Cards */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{activeStatTab === "primary" && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={Users}
label="Total Users"
@ -154,9 +192,11 @@ export function DashboardPage() {
deltaPositive={dashboard.issues.resolution_rate > 0.5}
/>
</div>
)}
{/* Secondary Stats */}
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{activeStatTab === "secondary" && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={BookOpen}
label="Courses"
@ -186,6 +226,7 @@ export function DashboardPage() {
deltaPositive
/>
</div>
)}
{/* User Registrations Chart */}
<div className="mt-5 grid gap-4">

View File

@ -44,7 +44,7 @@ function formatDateTime(dateStr: string | null | undefined): string {
function LoadingSkeleton() {
return (
<div className="mx-auto w-full max-w-5xl space-y-8 px-4 py-10 sm:px-6">
<div className="mx-auto w-full max-w-6xl space-y-8 px-4 py-10 sm:px-6">
<div className="animate-pulse space-y-8">
{/* Hero skeleton */}
<div className="overflow-hidden rounded-2xl border border-grayScale-100">
@ -93,15 +93,15 @@ function InfoRow({
extra?: React.ReactNode;
}) {
return (
<div className="group flex items-center justify-between rounded-lg px-3 py-3 transition-colors hover:bg-grayScale-100/60">
<div className="group flex flex-col gap-1 rounded-lg px-3 py-3 transition-colors hover:bg-grayScale-100/60 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3 text-sm text-grayScale-400">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-grayScale-100 text-grayScale-400 transition-colors group-hover:bg-brand-100 group-hover:text-brand-500">
<Icon className="h-4 w-4" />
</div>
<span className="font-medium">{label}</span>
</div>
<div className="flex items-center gap-2 text-sm font-medium text-grayScale-600">
<span className="text-right">{value || "—"}</span>
<div className="flex items-center gap-2 text-sm font-medium text-grayScale-600 sm:justify-end min-w-0">
<span className="truncate text-right sm:text-left">{value || "—"}</span>
{extra}
</div>
</div>
@ -121,13 +121,13 @@ function VerifiedIcon({ verified }: { verified: boolean }) {
}
function ProgressRing({ percent }: { percent: number }) {
const radius = 18;
const radius = 14;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (percent / 100) * circumference;
return (
<div className="relative inline-flex items-center justify-center">
<svg className="h-11 w-11 -rotate-90" viewBox="0 0 44 44">
<svg className="h-8 w-8 -rotate-90" viewBox="0 0 44 44">
<circle
cx="22"
cy="22"
@ -150,7 +150,7 @@ function ProgressRing({ percent }: { percent: number }) {
className="text-brand-500 transition-all duration-700"
/>
</svg>
<span className="absolute text-[10px] font-bold text-brand-600">{percent}%</span>
<span className="absolute text-[9px] font-bold text-brand-600">{percent}%</span>
</div>
);
}
@ -179,7 +179,7 @@ export function ProfilePage() {
if (error || !profile) {
return (
<div className="mx-auto w-full max-w-5xl px-4 py-16 sm:px-6">
<div className="mx-auto w-full max-w-6xl px-4 py-16 sm:px-6">
<Card className="border-dashed">
<CardContent className="flex flex-col items-center gap-5 p-12">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-grayScale-100">
@ -203,228 +203,247 @@ export function ProfilePage() {
const initials = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}`.toUpperCase();
const completionPct = profile.profile_completion_percentage ?? 0;
const sectionCardIcons: Record<string, { icon: typeof User; color: string }> = {
personal: { icon: User, color: "from-brand-500 to-brand-600" },
contact: { icon: Mail, color: "from-brand-400 to-brand-500" },
account: { icon: Shield, color: "from-brand-600 to-brand-500" },
};
return (
<div className="mx-auto w-full max-w-5xl space-y-8 px-4 py-8 sm:px-6">
{/* Hero Card */}
<Card className="overflow-hidden border-0 shadow-lg">
{/* Banner gradient */}
<div className="relative h-36 bg-gradient-to-br from-brand-600 via-brand-500 to-brand-400 sm:h-40">
{/* Decorative pattern overlay */}
<div className="absolute inset-0 opacity-10">
<div
className="h-full w-full"
style={{
backgroundImage:
"radial-gradient(circle at 25% 50%, white 1px, transparent 1px), radial-gradient(circle at 75% 50%, white 1px, transparent 1px)",
backgroundSize: "40px 40px",
}}
/>
</div>
{/* Bottom fade */}
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-white/20 to-transparent" />
<div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6">
{/* Page header (no tabs) */}
<div className="mb-5">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">My Info</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-grayScale-800">Profile</h1>
</div>
<CardContent className="-mt-16 px-6 pb-8 pt-0 sm:px-10">
<div className="flex flex-col items-center text-center">
{/* Avatar */}
<Avatar className="h-28 w-28 ring-4 ring-white shadow-lg">
{/* Main profile layout card */}
<div className="rounded-2xl border border-grayScale-100 bg-white shadow-sm">
{/* Header strip */}
<div className="border-b border-grayScale-100 px-6 py-4 sm:px-8">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">Overview</p>
<p className="mt-1 text-sm text-grayScale-500">
Personal, job and account details for this team member.
</p>
</div>
</div>
</div>
<div className="px-6 py-6 sm:px-8 sm:py-7">
<div className="grid gap-8 md:grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)]">
{/* Left column: About & details */}
<div className="space-y-6">
{/* Identity */}
<div className="flex flex-col gap-4 sm:flex-row">
<Avatar className="h-16 w-16 sm:h-18 sm:w-18">
<AvatarImage src={profile.profile_picture_url || undefined} alt={fullName} />
<AvatarFallback className="bg-gradient-to-br from-brand-100 to-brand-200 text-2xl font-bold text-brand-600">
<AvatarFallback className="bg-grayScale-100 text-base font-semibold text-grayScale-600">
{initials}
</AvatarFallback>
</Avatar>
{/* Name */}
<h1 className="mt-4 text-2xl font-bold tracking-tight text-grayScale-600 sm:text-3xl">
{fullName}
</h1>
{/* Role badge */}
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight text-grayScale-800">{fullName}</h2>
<span className="rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500">
#{profile.id}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
<Badge
className={cn(
"mt-2.5 px-3 py-1",
"px-2.5 py-0.5 text-xs font-semibold",
profile.role === "ADMIN"
? "bg-brand-500/10 text-brand-600 border border-brand-500/20"
: "bg-grayScale-100 text-grayScale-600 border border-grayScale-200"
: "bg-grayScale-50 text-grayScale-600 border border-grayScale-200"
)}
>
<Shield className="h-3 w-3 mr-1.5" />
<Shield className="mr-1 h-3 w-3" />
{profile.role}
</Badge>
{/* Status pills */}
<div className="mt-6 flex flex-wrap items-center justify-center gap-2.5">
{/* Active status */}
<div
<span className="inline-flex items-center gap-1.5 rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500">
<Calendar className="h-3 w-3" />
Joined {formatDate(profile.created_at)}
</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<span
className={cn(
"flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors",
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold",
profile.status === "ACTIVE"
? "border-mint-300 bg-mint-100/60 text-mint-500"
: "border-destructive/20 bg-destructive/10 text-destructive"
? "bg-mint-50 text-mint-600"
: "bg-destructive/10 text-destructive"
)}
>
<span
className={cn(
"h-2 w-2 rounded-full",
profile.status === "ACTIVE" ? "bg-mint-500 animate-pulse" : "bg-destructive"
"h-1.5 w-1.5 rounded-full",
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive"
)}
/>
{profile.status}
</div>
{/* Email verification */}
<div
className={cn(
"flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors",
profile.email_verified
? "border-mint-300 bg-mint-100/60 text-mint-500"
: "border-grayScale-200 bg-grayScale-100/60 text-grayScale-400"
)}
>
{profile.email_verified ? (
<CheckCircle2 className="h-3 w-3" />
) : (
<XCircle className="h-3 w-3" />
)}
Email {profile.email_verified ? "Verified" : "Unverified"}
</div>
{/* Phone verification */}
<div
className={cn(
"flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors",
profile.phone_verified
? "border-mint-300 bg-mint-100/60 text-mint-500"
: "border-grayScale-200 bg-grayScale-100/60 text-grayScale-400"
)}
>
{profile.phone_verified ? (
<CheckCircle2 className="h-3 w-3" />
) : (
<XCircle className="h-3 w-3" />
)}
Phone {profile.phone_verified ? "Verified" : "Unverified"}
</div>
{/* Profile completion ring */}
<div className="flex items-center gap-2 rounded-full border border-brand-200 bg-brand-100/30 px-3 py-1 text-xs font-semibold text-brand-600">
</span>
<div className="inline-flex items-center gap-2 rounded-full border border-brand-100 bg-brand-50/60 px-2.5 py-0.5 text-xs font-semibold text-brand-600">
<ProgressRing percent={completionPct} />
<span>Profile Complete</span>
<span>Profile complete</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Info Cards */}
<div className="grid gap-6 md:grid-cols-3">
{/* Personal Information */}
<Card className="group overflow-hidden border border-grayScale-100 transition-all duration-200 hover:shadow-lg">
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-600" />
<CardHeader className="pb-3">
{/* About / Contact */}
<div>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
About
</h3>
<div className="space-y-1.5 rounded-xl border border-grayScale-100 bg-grayScale-50/60 px-3 py-3">
<InfoRow icon={Phone} label="Phone" value={profile.phone_number} extra={<VerifiedIcon verified={profile.phone_verified} />} />
<InfoRow icon={Mail} label="Email" value={profile.email} extra={<VerifiedIcon verified={profile.email_verified} />} />
<InfoRow
icon={MapPin}
label="Location"
value={[profile.region, profile.country].filter(Boolean).join(", ") || "—"}
/>
</div>
</div>
{/* Employee details */}
<div>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
Employee details
</h3>
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-xs sm:text-sm text-grayScale-500">
<div>
<dt className="text-grayScale-400">Date of birth</dt>
<dd className="mt-0.5 font-medium text-grayScale-700">
{formatDate(profile.birth_day)}
</dd>
</div>
<div>
<dt className="text-grayScale-400">Age</dt>
<dd className="mt-0.5 font-medium text-grayScale-700">
{profile.age ? `${profile.age} years` : "—"}
</dd>
</div>
<div>
<dt className="text-grayScale-400">Gender</dt>
<dd className="mt-0.5 font-medium text-grayScale-700">
{profile.gender || "Not specified"}
</dd>
</div>
<div>
<dt className="text-grayScale-400">Age group</dt>
<dd className="mt-0.5 font-medium text-grayScale-700">
{profile.age_group || "—"}
</dd>
</div>
<div>
<dt className="text-grayScale-400">Occupation</dt>
<dd className="mt-0.5 font-medium text-grayScale-700">
{profile.occupation || "—"}
</dd>
</div>
<div>
<dt className="text-grayScale-400">Preferred language</dt>
<dd className="mt-0.5 font-medium text-grayScale-700">
{profile.preferred_language || "—"}
</dd>
</div>
</dl>
</div>
</div>
{/* Right column: Activity & account summary */}
<div className="space-y-6">
{/* Activity */}
<div>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
Activity
</h3>
<Card className="shadow-none border-grayScale-100">
<CardContent className="space-y-4 p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-sm">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-brand-50 text-brand-600">
<Clock className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-grayScale-700">
Last login
</p>
<p className="text-xs text-grayScale-400">
{formatDateTime(profile.last_login)}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-grayScale-50 text-grayScale-500">
<User className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Personal Information
</CardTitle>
<div>
<p className="text-sm font-medium text-grayScale-700">
Account created
</p>
<p className="text-xs text-grayScale-400">
{formatDateTime(profile.created_at)}
</p>
</div>
</div>
</CardHeader>
<CardContent className="space-y-0.5 px-3 pb-4">
<InfoRow icon={User} label="Full Name" value={fullName} />
<InfoRow icon={User} label="Gender" value={profile.gender || "Not specified"} />
<InfoRow icon={Calendar} label="Birthday" value={formatDate(profile.birth_day)} />
<InfoRow icon={User} label="Age Group" value={profile.age_group || "—"} />
<InfoRow icon={Briefcase} label="Occupation" value={profile.occupation || "—"} />
</CardContent>
</Card>
</div>
{/* Contact & Location */}
<Card className="group overflow-hidden border border-grayScale-100 transition-all duration-200 hover:shadow-lg">
<div className="h-1 w-full bg-gradient-to-r from-brand-400 to-brand-500" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-400 to-brand-500 text-white shadow-sm">
<Mail className="h-4 w-4" />
{/* Account summary */}
<div>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
Account
</h3>
<Card className="shadow-none border-grayScale-100">
<CardContent className="space-y-3 p-4">
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Role</span>
<span className="font-medium text-grayScale-700">{profile.role}</span>
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Contact & Location
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-0.5 px-3 pb-4">
<InfoRow
icon={Mail}
label="Email"
value={profile.email}
extra={<VerifiedIcon verified={profile.email_verified} />}
/>
<InfoRow
icon={Phone}
label="Phone"
value={profile.phone_number}
extra={<VerifiedIcon verified={profile.phone_verified} />}
/>
<InfoRow icon={Globe} label="Country" value={profile.country || "—"} />
<InfoRow icon={MapPin} label="Region" value={profile.region || "—"} />
</CardContent>
</Card>
{/* Account Details */}
<Card className="group overflow-hidden border border-grayScale-100 transition-all duration-200 hover:shadow-lg">
<div className="h-1 w-full bg-gradient-to-r from-brand-600 to-brand-500" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-600 to-brand-500 text-white shadow-sm">
<Shield className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Account Details
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-0.5 px-3 pb-4">
<InfoRow icon={Shield} label="Role" value={profile.role} />
<InfoRow
icon={Languages}
label="Language"
value={profile.preferred_language || "—"}
/>
<InfoRow
icon={Clock}
label="Last Login"
value={formatDateTime(profile.last_login)}
/>
<InfoRow
icon={Calendar}
label="Member Since"
value={formatDate(profile.created_at)}
/>
<InfoRow
icon={CheckCircle2}
label="Status"
value={profile.status}
extra={
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Status</span>
<span
className={cn(
"h-2.5 w-2.5 rounded-full ring-2",
"inline-flex items-center gap-1.5 text-xs font-semibold",
profile.status === "ACTIVE"
? "bg-mint-500 ring-mint-100"
: "bg-destructive ring-destructive/20"
? "text-mint-600"
: "text-destructive"
)}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive"
)}
/>
}
/>
{profile.status}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Email</span>
<span className="flex items-center gap-1 text-grayScale-700">
<span className="truncate max-w-[140px] text-right text-xs sm:text-sm">
{profile.email}
</span>
<VerifiedIcon verified={profile.email_verified} />
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Phone</span>
<span className="flex items-center gap-1 text-grayScale-700">
<span className="truncate max-w-[120px] text-right text-xs sm:text-sm">
{profile.phone_number || "—"}
</span>
<VerifiedIcon verified={profile.phone_verified} />
</span>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -284,6 +284,7 @@ export function AnalyticsPage() {
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [activeSummaryTab, setActiveSummaryTab] = useState<"key" | "content" | "operations">("key")
const fetchData = async () => {
setLoading(true)
@ -390,7 +391,51 @@ export function AnalyticsPage() {
</div>
</div>
{/* Summary Tabs */}
<div className="mb-4 border-b border-grayScale-200">
<div className="-mb-px flex gap-6">
<button
onClick={() => setActiveSummaryTab("key")}
className={cn(
"relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all",
activeSummaryTab === "key" ? "text-brand-600" : "text-grayScale-400 hover:text-grayScale-700",
)}
>
Key Metrics
{activeSummaryTab === "key" && (
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
)}
</button>
<button
onClick={() => setActiveSummaryTab("content")}
className={cn(
"relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all",
activeSummaryTab === "content" ? "text-brand-600" : "text-grayScale-400 hover:text-grayScale-700",
)}
>
Content &amp; Platform
{activeSummaryTab === "content" && (
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
)}
</button>
<button
onClick={() => setActiveSummaryTab("operations")}
className={cn(
"relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all",
activeSummaryTab === "operations" ? "text-brand-600" : "text-grayScale-400 hover:text-grayScale-700",
)}
>
Operations
{activeSummaryTab === "operations" && (
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
)}
</button>
</div>
</div>
<div className="space-y-4">
{activeSummaryTab === "key" && (
<>
{/* ─── Key Metrics ─── */}
<Section title="Key Metrics" icon={TrendingUp} defaultOpen>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
@ -424,9 +469,18 @@ export function AnalyticsPage() {
/>
</div>
</Section>
</>
)}
{activeSummaryTab === "content" && (
<>
{/* ─── Content & Platform ─── */}
<Section title="Content & Platform" icon={BookOpen} count={courses.total_courses + content.total_questions} defaultOpen>
<Section
title="Content & Platform"
icon={BookOpen}
count={courses.total_courses + content.total_questions}
defaultOpen
>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<KpiCard
icon={FolderOpen}
@ -457,7 +511,11 @@ export function AnalyticsPage() {
/>
</div>
</Section>
</>
)}
{activeSummaryTab === "operations" && (
<>
{/* ─── Operations ─── */}
<Section title="Operations" icon={Bell} defaultOpen>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
@ -491,6 +549,8 @@ export function AnalyticsPage() {
/>
</div>
</Section>
</>
)}
{/* ─── User Analytics ─── */}
<Section title="User Analytics" icon={Users} count={users.total_users} defaultOpen>

View File

@ -1,6 +1,7 @@
import { useState } from "react"
import { useNavigate, useParams } from "react-router-dom"
import { ArrowLeft, Plus, X } from "lucide-react"
import { toast } from "sonner"
import { Button } from "../../components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Input } from "../../components/ui/input"
@ -105,32 +106,44 @@ export function AddQuestionPage() {
// Validation
if (!formData.question.trim()) {
alert("Please enter a question")
toast.error("Missing question", {
description: "Please enter a question before saving.",
})
return
}
if (formData.type === "multiple-choice" || formData.type === "true-false") {
if (!formData.correctAnswer) {
alert("Please select a correct answer")
toast.error("Missing correct answer", {
description: "Select the correct answer for this question.",
})
return
}
if (formData.type === "multiple-choice") {
const hasEmptyOptions = formData.options.some((opt) => !opt.trim())
if (hasEmptyOptions) {
alert("Please fill in all options")
toast.error("Incomplete options", {
description: "Fill in all answer options for this multiple choice question.",
})
return
}
}
} else if (formData.type === "short-answer") {
if (!formData.correctAnswer.trim()) {
alert("Please enter a correct answer")
toast.error("Missing correct answer", {
description: "Enter the expected correct answer.",
})
return
}
}
// In a real app, save the question here
console.log("Saving question:", formData)
alert(isEditing ? "Question updated successfully!" : "Question created successfully!")
toast.success(isEditing ? "Question updated" : "Question created", {
description: isEditing
? "The question has been updated successfully."
: "Your new question has been created.",
})
navigate("/content/questions")
}

View File

@ -0,0 +1,584 @@
import { useEffect, useState } from "react"
import { useNavigate } from "react-router-dom"
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"
import { Select } from "../../components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table"
import { Badge } from "../../components/ui/badge"
import { FileUpload } from "../../components/ui/file-upload"
import { getCourseCategories, getCoursesByCategory, createCourse, updateCourseStatus, updateCourse } from "../../api/courses.api"
import type { Course, CourseCategory } from "../../types/course.types"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog"
import { Textarea } from "../../components/ui/textarea"
import { toast } from "sonner"
type CourseWithCategory = Course & { category_name: string }
export function AllCoursesPage() {
const navigate = useNavigate()
const [courses, setCourses] = useState<CourseWithCategory[]>([])
const [categories, setCategories] = useState<CourseCategory[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState("")
const [categoryFilter, setCategoryFilter] = useState<"all" | string>("all")
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)
setError(null)
try {
const categoriesRes = await getCourseCategories()
const cats = categoriesRes.data.data.categories ?? []
setCategories(cats)
const allCourses: CourseWithCategory[] = []
for (const cat of cats) {
const res = await getCoursesByCategory(cat.id)
const catCourses = res.data.data.courses ?? []
allCourses.push(
...catCourses.map((c) => ({
...c,
category_name: cat.name,
})),
)
}
setCourses(allCourses)
} catch (err) {
console.error("Failed to load courses:", err)
setError("Failed to load courses")
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchAllCourses()
}, [])
const filteredCourses = courses.filter((course) => {
if (categoryFilter !== "all" && String(course.category_id) !== categoryFilter) {
return false
}
if (search.trim()) {
const q = search.toLowerCase()
const haystack = `${course.title} ${course.description} ${course.category_name}`.toLowerCase()
if (!haystack.includes(q)) return false
}
return true
})
const handleCreateCourse = async () => {
const effectiveCategoryId = createSubCategoryId || createCategoryId
if (!effectiveCategoryId || !createTitle.trim() || !createDescription.trim()) {
toast.error("Missing fields", {
description: "Category (or subcategory), title, and description are required.",
})
return
}
setCreating(true)
try {
await createCourse({
category_id: Number(effectiveCategoryId),
title: createTitle.trim(),
description: createDescription.trim(),
})
toast.success("Course created", {
description: `"${createTitle.trim()}" has been created.`,
})
setCreateOpen(false)
setCreateCategoryId("")
setCreateSubCategoryId("")
setCreateTitle("")
setCreateDescription("")
setCreateThumbnail(null)
setCreateVideo(null)
await fetchAllCourses()
} catch (err: any) {
console.error("Failed to create course:", err)
toast.error("Failed to create course", {
description: err?.response?.data?.message || "Please try again.",
})
} finally {
setCreating(false)
}
}
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-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>
)
}
if (error) {
return (
<div className="flex items-center justify-center py-32">
<div className="mx-4 flex w-full max-w-md items-center gap-3 rounded-2xl border border-red-100 bg-red-50 px-6 py-5 shadow-sm">
<div className="rounded-full bg-red-100 p-2">
<RefreshCw className="h-5 w-5 shrink-0 text-red-500" />
</div>
<p className="text-sm font-medium text-red-600">{error}</p>
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">All Courses</h1>
<p className="mt-1 text-sm text-grayScale-400">
View and manage courses across all categories.
</p>
</div>
<Button
className="gap-2 bg-brand-500 text-white hover:bg-brand-600"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Create Course
</Button>
</div>
<Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-4">
<CardTitle className="text-base font-semibold text-grayScale-600">
Course Management
</CardTitle>
</CardHeader>
<CardContent className="space-y-5 pt-5">
{/* Search / Filters */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-300" />
<Input
placeholder="Search by title, description, or category…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10 transition-colors focus:border-brand-300 focus:ring-brand-200"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<Select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value as typeof categoryFilter)}
>
<option value="all">All Categories</option>
{categories.map((cat) => (
<option key={cat.id} value={String(cat.id)}>
{cat.name}
</option>
))}
</Select>
</div>
</div>
<div className="text-xs font-medium text-grayScale-400">
Showing {filteredCourses.length} of {courses.length} courses
</div>
{/* Courses Table */}
{filteredCourses.length > 0 ? (
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
<Table>
<TableHeader>
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Course
</TableHead>
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Category
</TableHead>
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
Status
</TableHead>
<TableHead className="py-3 text-right text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCourses.map((course, index) => (
<TableRow
key={course.id}
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${
index % 2 === 0 ? "bg-white" : "bg-grayScale-100/40"
}`}
onClick={() =>
navigate(
`/content/category/${course.category_id}/courses/${course.id}/sub-courses`,
)
}
>
<TableCell className="max-w-md py-3.5">
<div className="truncate text-sm font-semibold text-grayScale-700">
{course.title}
</div>
{course.description && (
<div className="mt-1 truncate text-xs text-grayScale-400">
{course.description}
</div>
)}
</TableCell>
<TableCell className="py-3.5 text-sm text-grayScale-500">
{course.category_name}
</TableCell>
<TableCell className="hidden py-3.5 md:table-cell">
<Badge
variant={course.is_active ? "success" : "secondary"}
className="text-[11px] font-semibold"
>
{course.is_active ? "Active" : "Inactive"}
</Badge>
</TableCell>
<TableCell className="py-3.5 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700"
onClick={(e) => {
e.stopPropagation()
openEditDialog(course)
}}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700"
disabled={togglingId === course.id}
onClick={(e) => {
e.stopPropagation()
handleToggleStatus(course)
}}
>
{course.is_active ? (
<ToggleLeft className="h-4 w-4" />
) : (
<ToggleRight className="h-4 w-4" />
)}
</Button>
<Button
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>
))}
</TableBody>
</Table>
</div>
) : (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-20 text-center">
<div className="mb-4 grid h-16 w-16 place-items-center rounded-full bg-gradient-to-br from-grayScale-100 to-grayScale-200">
<BookOpen className="h-8 w-8 text-grayScale-400" />
</div>
<p className="text-base font-semibold text-grayScale-600">No courses found</p>
<p className="mt-1.5 max-w-sm text-sm leading-relaxed text-grayScale-400">
Try adjusting your search or category filter, or create a new course.
</p>
</div>
)}
</CardContent>
</Card>
{/* Create course dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create course</DialogTitle>
<DialogDescription>
Choose a category, add basic details, and optionally attach a thumbnail and intro
video.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<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)
setCreateSubCategoryId("")
}}
>
<option value="">Select category</option>
{categories
.filter((cat) => !cat.parent_id)
.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">
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>
<Input
placeholder="e.g. Beginner English A1"
value={createTitle}
onChange={(e) => setCreateTitle(e.target.value)}
/>
</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Description
</label>
<Textarea
rows={3}
placeholder="Short summary of what this course covers."
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Thumbnail image
</label>
<FileUpload
accept="image/*"
onFileSelect={setCreateThumbnail}
label="Upload thumbnail"
description="JPEG or PNG"
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Intro video
</label>
<FileUpload
accept="video/*"
onFileSelect={setCreateVideo}
label="Upload video"
description="Optional intro or overview"
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
</div>
</div>
<p className="text-[11px] text-grayScale-400">
File uploads are currently stored client-side only. Connect your storage/API layer to
persist thumbnails and videos.
</p>
</div>
<div className="mt-5 flex items-center justify-end gap-2">
<Button
variant="outline"
onClick={() => {
setCreateOpen(false)
setCreateCategoryId("")
setCreateTitle("")
setCreateDescription("")
setCreateThumbnail(null)
setCreateVideo(null)
}}
disabled={creating}
>
Cancel
</Button>
<Button
className="bg-brand-500 text-white hover:bg-brand-600"
disabled={creating}
onClick={handleCreateCourse}
>
{creating ? "Creating…" : "Create course"}
</Button>
</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

@ -4,6 +4,7 @@ import { cn } from "../../lib/utils"
const tabs = [
{ label: "Overview", to: "/content" },
{ label: "Courses", to: "/content/courses" },
{ label: "Flows", to: "/content/flows" },
{ label: "Speaking", to: "/content/speaking" },
{ label: "Practice", to: "/content/practices" },
{ label: "Questions", to: "/content/questions" },

View File

@ -57,9 +57,21 @@ const contentSections = [
},
] as const
type ContentSection = (typeof contentSections)[number]
export function ContentOverviewPage() {
const { categoryId } = useParams<{ categoryId: string }>()
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 () => {
@ -77,6 +89,75 @@ 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 {
const raw = window.localStorage.getItem("content_sections_order")
if (!raw) return
const savedKeys: string[] = JSON.parse(raw)
const byKey = new Map(contentSections.map((s) => [s.key, s]))
const reordered: ContentSection[] = []
savedKeys.forEach((k) => {
const item = byKey.get(k as ContentSection["key"])
if (item) {
reordered.push(item)
byKey.delete(k as ContentSection["key"])
}
})
// Append any new sections that weren't in saved order
byKey.forEach((item) => reordered.push(item))
if (reordered.length) {
setSections(reordered)
}
} catch {
// ignore corrupted localStorage
}
}, [])
// Persist order whenever it changes
useEffect(() => {
const keys = sections.map((s) => s.key)
window.localStorage.setItem("content_sections_order", JSON.stringify(keys))
}, [sections])
const handleDropOn = (targetKey: string) => {
if (!dragKey || dragKey === targetKey) return
setSections((prev) => {
const currentIndex = prev.findIndex((s) => s.key === dragKey)
const targetIndex = prev.findIndex((s) => s.key === targetKey)
if (currentIndex === -1 || targetIndex === -1) return prev
const copy = [...prev]
const [moved] = copy.splice(currentIndex, 1)
copy.splice(targetIndex, 0, moved)
return copy
})
setDragKey(null)
}
return (
<div className="space-y-8">
{/* Header & Breadcrumb */}
@ -120,18 +201,24 @@ export function ContentOverviewPage() {
</div>
</div>
{/* Cards Grid */}
{/* Cards Grid (course builder style draggable sections) */}
<div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
{contentSections.map((section) => {
{sections.map((section) => {
const Icon = section.icon
return (
<Link
<div
key={section.key}
to={section.pathFn(categoryId)}
className="group"
draggable
onDragStart={() => setDragKey(section.key)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => handleDropOn(section.key)}
>
<Link to={section.pathFn(categoryId)} className="block">
<Card
className={`relative h-full overflow-hidden border border-grayScale-100 bg-white transition-all duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-[1.02] ${section.accentBorder} group-hover:shadow-lg`}
className={`relative h-full overflow-hidden border border-grayScale-100 bg-white transition-all duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-[1.02] ${section.accentBorder} group-hover:shadow-lg ${
dragKey === section.key ? "ring-2 ring-brand-300" : ""
}`}
style={{
boxShadow: "0 8px 24px rgba(0,0,0,0.06)",
}}
@ -183,9 +270,92 @@ export function ContentOverviewPage() {
</CardContent>
</Card>
</Link>
</div>
)
})}
</div>
{/* Category flow sequence (if defined) */}
{flowSteps.length > 0 && (
<Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-3">
<div className="flex items-center justify-between gap-2">
<div>
<CardTitle className="text-base font-semibold text-grayScale-600">
Learning flow
</CardTitle>
<CardDescription className="mt-0.5 text-xs text-grayScale-400">
Sequence of lessons, practice, exams, and feedback for this category.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-4">
<div className="flex gap-3 overflow-x-auto pb-2 md:grid md:auto-cols-fr md:grid-flow-col md:overflow-visible">
{flowSteps.map((step, index) => (
<div
key={step.id}
className="flex min-w-[200px] flex-col justify-between rounded-xl border border-grayScale-100 bg-white p-3.5 shadow-sm"
>
<div className="flex items-center justify-between gap-2">
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold",
step.type === "lesson" && "bg-sky-50 text-sky-700 ring-1 ring-inset ring-sky-200",
step.type === "practice" &&
"bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200",
step.type === "exam" &&
"bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200",
step.type === "feedback" &&
"bg-rose-50 text-rose-700 ring-1 ring-inset ring-rose-200",
step.type === "course" &&
"bg-violet-50 text-violet-700 ring-1 ring-inset ring-violet-200",
step.type === "speaking" &&
"bg-teal-50 text-teal-700 ring-1 ring-inset ring-teal-200",
step.type === "new_course" &&
"bg-indigo-50 text-indigo-700 ring-1 ring-inset ring-indigo-200",
)}
>
{step.type === "lesson"
? "Lesson"
: step.type === "practice"
? "Practice"
: step.type === "exam"
? "Exam / Questions"
: step.type === "feedback"
? "Feedback"
: step.type === "course"
? "Course"
: step.type === "speaking"
? "Speaking section"
: "New course (category)"}
<span className="text-[10px] text-grayScale-400">#{index + 1}</span>
</span>
</div>
<div className="mt-2 space-y-1">
<p className="text-sm font-semibold text-grayScale-700 line-clamp-2">
{step.title}
</p>
<p className="text-xs text-grayScale-500 line-clamp-3">
{step.description ||
(step.type === "exam"
? "Place exams and question sets here."
: step.type === "feedback"
? "Collect feedback or run followup surveys."
: step.type === "course"
? "Link or add an existing course to this flow."
: step.type === "speaking"
? "Speaking or oral practice section."
: step.type === "new_course"
? "Add a new course within this category."
: "Configure this step in the flow builder.")}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@ -1,16 +1,33 @@
import { useEffect, useState } from "react"
import { Link } from "react-router-dom"
import { FolderOpen, RefreshCw, BookOpen } from "lucide-react"
import { FolderOpen, RefreshCw, BookOpen, Plus } from "lucide-react"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import alertSrc from "../../assets/Alert.svg"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { getCourseCategories } from "../../api/courses.api"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { Select } from "../../components/ui/select"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog"
import { getCourseCategories, createCourseCategory } from "../../api/courses.api"
import type { CourseCategory } from "../../types/course.types"
import { toast } from "sonner"
export function CourseCategoryPage() {
const [categories, setCategories] = useState<CourseCategory[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [newCategoryName, setNewCategoryName] = useState("")
const [creating, setCreating] = useState(false)
const [parentCategoryId, setParentCategoryId] = useState<number | null>(null)
const [newSubCategoryName, setNewSubCategoryName] = useState("")
const [pendingSubCategories, setPendingSubCategories] = useState<string[]>([])
const fetchCategories = async () => {
setLoading(true)
@ -65,12 +82,22 @@ export function CourseCategoryPage() {
return (
<div className="space-y-8">
{/* Page header */}
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-grayScale-600">Course Categories</h1>
<p className="mt-1 text-sm text-grayScale-400">
Browse and manage your course categories below
</p>
</div>
<Button
className="gap-2 bg-brand-500 text-white hover:bg-brand-600"
size="sm"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
New Category
</Button>
</div>
{categories.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-5 rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-24">
@ -123,6 +150,207 @@ export function CourseCategoryPage() {
))}
</div>
)}
{/* Create category dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-4 w-4 text-brand-500" />
<span>Create course category</span>
</DialogTitle>
<DialogDescription>
Add a new high-level bucket to organize your courses. You can also nest it under an existing parent category.
</DialogDescription>
</DialogHeader>
<div className="mt-4 grid gap-6 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1.4fr)]">
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Category name
</label>
<Input
placeholder="e.g. Beginner English, Exam Prep"
value={newCategoryName}
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>
<p className="mt-1 text-[11px] text-grayScale-400">
When left empty, this becomes a parent category. Any sub categories you add on the
right will be created under it.
</p>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-grayScale-600">
Sub categories for this category (optional)
</p>
{pendingSubCategories.length > 0 && (
<span className="text-[11px] text-grayScale-400">
{pendingSubCategories.length} sub categor
{pendingSubCategories.length === 1 ? "y" : "ies"} to create
</span>
)}
</div>
<div className="flex gap-2">
<Input
placeholder="e.g. Grammar basics, Speaking, Exam practice"
value={newSubCategoryName}
onChange={(e) => setNewSubCategoryName(e.target.value)}
className="h-9 text-sm"
/>
<Button
type="button"
size="sm"
className="h-9 px-3 text-xs"
onClick={() => {
const name = newSubCategoryName.trim()
if (!name) return
if (pendingSubCategories.includes(name)) {
setNewSubCategoryName("")
return
}
setPendingSubCategories((prev) => [...prev, name])
setNewSubCategoryName("")
}}
>
Add
</Button>
</div>
<div className="min-h-[3.5rem] rounded-lg border border-dashed border-grayScale-200 bg-grayScale-50/60 p-2">
{pendingSubCategories.length === 0 ? (
<p className="text-[11px] leading-relaxed text-grayScale-400">
Added sub categories will appear here so you can visually confirm the structure
before saving. This is optional.
</p>
) : (
<div className="flex flex-wrap gap-1.5">
{pendingSubCategories.map((name) => (
<button
key={name}
type="button"
className="group inline-flex items-center gap-1 rounded-full bg-white px-2 py-0.5 text-[11px] text-grayScale-600 shadow-sm ring-1 ring-grayScale-200 hover:bg-red-50 hover:text-red-600 hover:ring-red-200"
onClick={() =>
setPendingSubCategories((prev) =>
prev.filter((subName) => subName !== name),
)
}
>
<span className="max-w-[160px] truncate">{name}</span>
<span className="text-[10px] text-grayScale-300 group-hover:text-red-400">
×
</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
<div className="mt-5 flex items-center justify-end gap-2">
<Button
variant="outline"
onClick={() => {
setCreateOpen(false)
setNewCategoryName("")
setParentCategoryId(null)
setNewSubCategoryName("")
setPendingSubCategories([])
}}
disabled={creating}
>
Cancel
</Button>
<Button
className="bg-brand-500 text-white hover:bg-brand-600"
disabled={creating || !newCategoryName.trim()}
onClick={async () => {
if (!newCategoryName.trim()) return
setCreating(true)
try {
const name = newCategoryName.trim()
const parentPayloadId = parentCategoryId ?? null
const parentRes = await createCourseCategory({
name: newCategoryName.trim(),
parent_id: parentPayloadId,
})
let createdCategoryId: number | null = null
try {
const data: any = parentRes?.data
createdCategoryId =
data?.data?.category?.id ??
data?.data?.id ??
data?.category?.id ??
data?.id ??
null
} catch {
createdCategoryId = null
}
if (createdCategoryId && pendingSubCategories.length > 0) {
await Promise.all(
pendingSubCategories.map((subName) =>
createCourseCategory({
name: subName,
parent_id: createdCategoryId,
}),
),
)
}
toast.success("Category created", {
description:
pendingSubCategories.length > 0
? `"${name}" and ${pendingSubCategories.length} sub categor${
pendingSubCategories.length === 1 ? "y" : "ies"
} have been added.`
: `"${name}" has been added.`,
})
setNewCategoryName("")
setParentCategoryId(null)
setNewSubCategoryName("")
setPendingSubCategories([])
setCreateOpen(false)
fetchCategories()
} catch (err: any) {
const message =
err?.response?.data?.message ||
"Failed to create category. Please try again."
toast.error("Could not create category", {
description: message,
})
} finally {
setCreating(false)
}
}}
>
{creating ? "Creating..." : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -0,0 +1,802 @@
import { useEffect, useMemo, useState } from "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 { getCourseCategories } from "../../api/courses.api"
import type { CourseCategory } from "../../types/course.types"
import { cn } from "../../lib/utils"
type StepType =
| "lesson"
| "practice"
| "exam"
| "feedback"
| "course"
| "speaking"
| "new_course"
type FlowStep = {
id: string
type: StepType
title: string
description?: 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> = {
lesson: "bg-sky-50 text-sky-700 ring-1 ring-inset ring-sky-200",
practice: "bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200",
exam: "bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200",
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 [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
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 [parentOrderDirty, setParentOrderDirty] = useState(false)
const [subOrderDirty, setSubOrderDirty] = useState(false)
const [stepsDirty, setStepsDirty] = useState(false)
const parentCategories = useMemo(
() => categories.filter((c) => !c.parent_id),
[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)
setError(null)
try {
const catRes = await getCourseCategories()
const cats = catRes.data.data.categories ?? []
setCategories(cats)
} catch (err) {
console.error("Failed to load course flows data:", err)
setError("Failed to load categories. Please try again.")
} finally {
setLoading(false)
}
}
fetchAll()
}, [])
// Load parent category order from localStorage (after we have categories)
useEffect(() => {
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)
setParentOrderDirty(false)
return
}
}
} catch {
// ignore
}
setParentCategoryOrder(parentCategories.map((c) => String(c.id)))
setParentOrderDirty(false)
}, [parentCategories.length])
// 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)
setSubOrderDirty(false)
return
}
}
} catch {
// ignore
}
setSubCategoryOrder(subCategoriesForParent.map((c) => String(c.id)))
setSubOrderDirty(false)
}, [selectedParentCategoryId, subCategoriesForParent.length])
// Load flow steps for selected sub category only (sub category structure)
useEffect(() => {
if (scope !== "sub" || !selectedSubCategoryId) {
setSteps([])
setStepsDirty(false)
return
}
const key = `subcategory_flow_${selectedSubCategoryId}`
try {
const raw = window.localStorage.getItem(key)
if (raw) {
const parsed: FlowStep[] = JSON.parse(raw)
setSteps(parsed)
setStepsDirty(false)
return
}
} catch {
// ignore and fall through to default
}
const defaults: FlowStep[] = [
{
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)
setStepsDirty(true)
}, [scope, selectedSubCategoryId])
const handleReorder = (targetId: string) => {
if (!dragStepId || dragStepId === targetId) return
setSteps((prev) => {
const currentIndex = prev.findIndex((s) => s.id === dragStepId)
const targetIndex = prev.findIndex((s) => s.id === targetId)
if (currentIndex === -1 || targetIndex === -1) return prev
const copy = [...prev]
const [moved] = copy.splice(currentIndex, 1)
copy.splice(targetIndex, 0, moved)
return copy
})
setDragStepId(null)
setStepsDirty(true)
}
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)
setParentOrderDirty(true)
}
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)
setSubOrderDirty(true)
}
const handleSaveParentOrder = () => {
if (orderedParentCategories.length === 0 || parentCategoryOrder.length === 0) return
window.localStorage.setItem(PARENT_ORDER_KEY, JSON.stringify(parentCategoryOrder))
setParentOrderDirty(false)
}
const handleSaveSubOrder = () => {
if (!selectedParentCategoryId || subCategoryOrder.length === 0) return
window.localStorage.setItem(
`${SUB_ORDER_KEY_PREFIX}${selectedParentCategoryId}`,
JSON.stringify(subCategoryOrder),
)
setSubOrderDirty(false)
}
const handleSaveSteps = () => {
if (scope !== "sub" || !selectedSubCategoryId) return
window.localStorage.setItem(`subcategory_flow_${selectedSubCategoryId}`, JSON.stringify(steps))
setStepsDirty(false)
}
const getDefaultDescription = (type: StepType): string => {
switch (type) {
case "lesson":
return "Add the lessons or modules that introduce key concepts."
case "practice":
return "Connect speaking or practice activities after lessons."
case "exam":
return "Place exams or quizzes where you want to assess learners."
case "feedback":
return "Ask for feedback, NPS, or reflection after the exam or final lesson."
case "course":
return "Link or add an existing course to this flow."
case "speaking":
return "Speaking or oral practice section for this flow."
case "new_course":
return "Add a new course within this category."
default:
return ""
}
}
const handleAddStep = (type: StepType) => {
const activeId = scope === "sub" ? selectedSubCategoryId : selectedParentCategoryId
if (!activeId) return
const newStep: FlowStep = {
id: `${activeId}-${type}-${Date.now()}`,
type,
title: STEP_LABELS[type],
description: getDefaultDescription(type),
}
setSteps((prev) => [...prev, newStep])
setStepsDirty(true)
}
const handleUpdateStep = (id: string, changes: Partial<FlowStep>) => {
setSteps((prev) => prev.map((s) => (s.id === id ? { ...s, ...changes } : s)))
setStepsDirty(true)
}
const handleRemoveStep = (id: string) => {
setSteps((prev) => prev.filter((s) => s.id !== id))
setStepsDirty(true)
}
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-32">
<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>
)
}
if (error) {
return (
<div className="flex items-center justify-center py-32">
<div className="mx-4 flex w-full max-w-md items-center gap-3 rounded-2xl border border-red-100 bg-red-50 px-6 py-5 shadow-sm">
<div className="rounded-full bg-red-100 p-2">
<RefreshCw className="h-5 w-5 shrink-0 text-red-500" />
</div>
<p className="text-sm font-medium text-red-600">{error}</p>
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">Course Flows</h1>
<p className="mt-1 text-sm text-grayScale-400">
Arrange parent categories and sub categories into flows, including lessons, practice,
exams, and feedback steps.
</p>
</div>
</div>
{/* 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-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",
)}
>
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>
</CardContent>
</Card>
{/* Parent scope: sequence of parent categories only */}
{scope === "parent" && (
<Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<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>
</div>
<Button
type="button"
size="sm"
className="h-8 px-3 text-[11px]"
disabled={!parentOrderDirty || orderedParentCategories.length === 0}
onClick={handleSaveParentOrder}
>
Save
</Button>
</div>
</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">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-base font-semibold text-grayScale-600">
Sub category sequence
</CardTitle>
<p className="mt-1 text-xs text-grayScale-400">
Drag to reorder sub categories under this parent.
</p>
</div>
<Button
type="button"
size="sm"
className="h-8 px-3 text-[11px]"
disabled={!subOrderDirty || orderedSubCategories.length === 0}
onClick={handleSaveSubOrder}
>
Save
</Button>
</div>
</CardHeader>
<CardContent className="space-y-2 pt-4">
{orderedSubCategories.length === 0 ? (
<div className="rounded-lg border border-dashed border-grayScale-200 bg-grayScale-50/60 px-4 py-6 text-center text-xs text-grayScale-400">
No sub categories under this parent.
</div>
) : (
orderedSubCategories.map((sub, index) => (
<div
key={sub.id}
draggable
onDragStart={() => setDragCategoryId(String(sub.id))}
onDragOver={(e) => e.preventDefault()}
onDrop={() => handleReorderSubCategory(String(sub.id))}
className={cn(
"flex items-center gap-3 rounded-xl border border-grayScale-100 bg-white p-3 shadow-sm transition-colors",
dragCategoryId === String(sub.id) && "ring-2 ring-brand-300",
)}
>
<button type="button" className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600">
<GripVertical className="h-4 w-4" />
</button>
<span className="text-[11px] font-medium text-grayScale-400">#{index + 1}</span>
<span className="flex-1 text-sm font-medium text-grayScale-700">{sub.name}</span>
</div>
))
)}
</CardContent>
</Card>
{/* 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">
<div className="flex items-center justify-between gap-3">
<div>
<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>
</div>
<Button
type="button"
size="sm"
className="h-8 px-3 text-[11px]"
disabled={!stepsDirty || steps.length === 0}
onClick={handleSaveSteps}
>
Save
</Button>
</div>
</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
onDragStart={() => setDragStepId(step.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => handleReorder(step.id)}
className={cn(
"flex flex-col gap-2 rounded-xl border border-grayScale-100 bg-white p-3.5 shadow-sm transition-colors md:flex-row md:items-start",
dragStepId === step.id && "ring-2 ring-brand-300",
)}
>
<div className="flex items-center gap-2 md:flex-col md:items-start">
<button
type="button"
className="hidden h-8 w-8 items-center justify-center rounded-lg text-grayScale-300 hover:bg-grayScale-100 hover:text-grayScale-500 md:flex"
>
<GripVertical className="h-4 w-4" />
</button>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold",
STEP_BADGE[step.type],
)}
>
{STEP_LABELS[step.type]}
<span className="text-[10px] text-grayScale-400">#{index + 1}</span>
</span>
</div>
<div className="flex-1 space-y-1">
<Input
value={step.title}
onChange={(e) => handleUpdateStep(step.id, { title: e.target.value })}
className="h-8 text-sm"
/>
<Input
value={step.description ?? ""}
onChange={(e) =>
handleUpdateStep(step.id, { description: e.target.value })
}
placeholder="Optional description for this step"
className="h-8 text-xs text-grayScale-500"
/>
</div>
<div className="flex items-center justify-end gap-2 md:flex-col md:items-end">
{step.type !== "feedback" && (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 px-2 text-[11px]"
onClick={() => {
const feedbackStep: FlowStep = {
id: `${selectedSubCategoryId}-feedback-${Date.now()}`,
type: "feedback",
title: "Feedback loop",
description: "Collect feedback after this step.",
}
setSteps((prev) => {
const idx = prev.findIndex((s) => s.id === step.id)
if (idx === -1) return prev
const copy = [...prev]
copy.splice(idx + 1, 0, feedbackStep)
return copy
})
}}
>
+ Feedback
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-[11px] text-destructive hover:bg-red-50"
onClick={() => handleRemoveStep(step.id)}
>
Remove
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Palette / What this controls */}
<div className="space-y-4">
<Card className="shadow-soft">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold text-grayScale-600">
Add steps
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 pt-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-2">
<Button
type="button"
variant="outline"
className="h-10 justify-start gap-2 text-xs"
onClick={() => handleAddStep("lesson")}
>
<span className="h-2 w-2 rounded-full bg-sky-500" />
Lesson
</Button>
<Button
type="button"
variant="outline"
className="h-10 justify-start gap-2 text-xs"
onClick={() => handleAddStep("practice")}
>
<span className="h-2 w-2 rounded-full bg-emerald-500" />
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"
variant="outline"
className="h-10 justify-start gap-2 text-xs"
onClick={() => handleAddStep("exam")}
>
<span className="h-2 w-2 rounded-full bg-amber-500" />
Exam
</Button>
<Button
type="button"
variant="outline"
className="h-10 justify-start gap-2 text-xs"
onClick={() => handleAddStep("feedback")}
>
<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
after exams or any important milestone to keep learners engaged.
</p>
</CardContent>
</Card>
<Card className="shadow-none border border-dashed border-grayScale-200 bg-grayScale-50/50">
<CardContent className="space-y-2 p-4">
<p className="text-xs font-semibold text-grayScale-600">How this is used</p>
<p className="text-[11px] leading-relaxed text-grayScale-500">
This builder helps you map out the ideal learner journey. You can later connect
each step to actual lessons, speaking practices, exams, or surveys in your
backend. For now, flows are saved locally in your browser.
</p>
</CardContent>
</Card>
</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 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">
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>
)}
</div>
)
}

View File

@ -8,6 +8,7 @@ import alertSrc from "../../assets/Alert.svg"
import { Button } from "../../components/ui/button"
import { Badge } from "../../components/ui/badge"
import { Input } from "../../components/ui/input"
import { FileUpload } from "../../components/ui/file-upload"
import { getCoursesByCategory, getCourseCategories, createCourse, deleteCourse, updateCourseStatus, updateCourse } from "../../api/courses.api"
import type { Course, CourseCategory } from "../../types/course.types"
@ -41,6 +42,8 @@ export function CoursesPage() {
const [description, setDescription] = useState("")
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [newThumbnailFile, setNewThumbnailFile] = useState<File | null>(null)
const [newVideoFile, setNewVideoFile] = useState<File | null>(null)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [courseToDelete, setCourseToDelete] = useState<Course | null>(null)
@ -112,6 +115,8 @@ export function CoursesPage() {
setTitle("")
setDescription("")
setSaveError(null)
setNewThumbnailFile(null)
setNewVideoFile(null)
setShowModal(true)
}
@ -120,6 +125,8 @@ export function CoursesPage() {
setTitle("")
setDescription("")
setSaveError(null)
setNewThumbnailFile(null)
setNewVideoFile(null)
}
const handleSave = async () => {
@ -242,7 +249,10 @@ export function CoursesPage() {
return (
<div className="flex flex-col items-center justify-center py-32">
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading courses...</p>
{/* <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>
)
}
@ -411,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
@ -462,6 +472,29 @@ export function CoursesPage() {
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p className="mb-2 text-sm font-medium text-grayScale-600">Thumbnail image</p>
<FileUpload
accept="image/*"
onFileSelect={setNewThumbnailFile}
label="Upload thumbnail"
description="Optional course cover image"
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
</div>
<div>
<p className="mb-2 text-sm font-medium text-grayScale-600">Intro video</p>
<FileUpload
accept="video/*"
onFileSelect={setNewVideoFile}
label="Upload intro video"
description="Optional overview for this course"
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
</div>
</div>
<div className="rounded-lg bg-grayScale-50 px-3 py-2 text-xs text-grayScale-400">
Category: <span className="font-semibold text-grayScale-600">{category?.name}</span>
</div>

View File

@ -203,7 +203,10 @@ export function SubCoursesPage() {
return (
<div className="flex flex-col items-center justify-center py-24">
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading sub-courses...</p>
{/* <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

@ -20,6 +20,7 @@ import {
CheckCircle2,
XCircle,
ArrowUpCircle,
MessageCircle,
} from "lucide-react";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
@ -201,6 +202,12 @@ export function IssuesPage() {
// Status update
const [statusUpdating, setStatusUpdating] = useState<number | null>(null);
// Create issue dialog (admin-created)
const [createOpen, setCreateOpen] = useState(false);
const [createSubject, setCreateSubject] = useState("");
const [createType, setCreateType] = useState<string>("bug");
const [createDescription, setCreateDescription] = useState("");
const fetchIssues = useCallback(async () => {
setLoading(true);
try {
@ -345,6 +352,7 @@ export function IssuesPage() {
Review and manage user-reported issues across the platform.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="gap-2"
@ -356,6 +364,14 @@ export function IssuesPage() {
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
Refresh
</Button>
<Button
className="gap-2 bg-brand-500 text-white hover:bg-brand-600"
onClick={() => setCreateOpen(true)}
>
<MessageCircle className="h-4 w-4" />
New Issue
</Button>
</div>
</div>
{/* Stats cards */}
@ -512,7 +528,7 @@ export function IssuesPage() {
<TableRow key={issue.id} className="group">
<TableCell>
<div className="flex items-start gap-3 max-w-[300px]">
<div className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400 group-hover:bg-brand-50 group-hover:text-brand-500 transition-colors">
<div className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400 group-hover:bg-brand-500 group-hover:text-white transition-colors">
<TypeIcon className="h-4 w-4" />
</div>
<div className="min-w-0">
@ -840,6 +856,90 @@ export function IssuesPage() {
</DialogContent>
</Dialog>
{/* Create Issue Dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageCircle className="h-5 w-5 text-brand-500" />
<span>Create admin issue</span>
</DialogTitle>
<DialogDescription>
Log an issue directly from the admin panel so it can be tracked and resolved.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Subject
</label>
<Input
placeholder="Short summary of the issue"
value={createSubject}
onChange={(e) => setCreateSubject(e.target.value)}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Type
</label>
<select
value={createType}
onChange={(e) => setCreateType(e.target.value)}
className="h-10 w-full rounded-lg border bg-white px-3 text-sm text-grayScale-600 focus:outline-none focus:ring-2 focus:ring-ring"
>
{ISSUE_TYPES.map((t) => (
<option key={t} value={t}>
{getIssueTypeConfig(t).label}
</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Description
</label>
<textarea
className="min-h-[100px] w-full rounded-lg border bg-white px-3 py-2 text-sm text-grayScale-700 placeholder:text-grayScale-400 focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Describe what happened, steps to reproduce, and any context that might help."
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
/>
</div>
</div>
<div className="mt-5 flex items-center justify-end gap-2">
<Button
variant="outline"
onClick={() => {
setCreateOpen(false);
setCreateSubject("");
setCreateDescription("");
setCreateType("bug");
}}
>
Cancel
</Button>
<Button
className="bg-brand-500 text-white hover:bg-brand-600"
onClick={() => {
// Hook to create-issue API here; currently UI-only.
if (!createSubject.trim() || !createDescription.trim()) {
return;
}
setCreateOpen(false);
setCreateSubject("");
setCreateDescription("");
setCreateType("bug");
}}
>
Create Issue
</Button>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="max-w-sm">

View File

@ -19,10 +19,23 @@ import {
Mail,
CheckCheck,
MailX,
Search,
} from "lucide-react"
import { Card, CardContent } from "../../components/ui/card"
import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea"
import { Select } from "../../components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog"
import { FileUpload } from "../../components/ui/file-upload"
import { cn } from "../../lib/utils"
import {
getNotifications,
@ -32,7 +45,9 @@ import {
markAllRead,
markAllUnread,
} from "../../api/notifications.api"
import { getTeamMembers } from "../../api/team.api"
import type { Notification } from "../../types/notification.types"
import type { TeamMember } from "../../types/team.types"
const PAGE_SIZE = 10
@ -226,6 +241,25 @@ export function NotificationsPage() {
const [error, setError] = useState(false)
const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set())
const [bulkLoading, setBulkLoading] = useState(false)
const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
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")
const [teamRecipients, setTeamRecipients] = useState<TeamMember[]>([])
const [recipientsLoading, setRecipientsLoading] = useState(false)
const [selectedRecipientIds, setSelectedRecipientIds] = useState<number[]>([])
const [composeTitle, setComposeTitle] = useState("")
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)
@ -300,25 +334,97 @@ export function NotificationsPage() {
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
const currentPage = Math.floor(offset / PAGE_SIZE) + 1
const filteredNotifications = notifications.filter((n) => {
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 = [
n.payload.headline,
n.payload.message,
formatTypeLabel(n.type),
n.delivery_channel,
n.level,
]
.filter(Boolean)
.join(" ")
.toLowerCase()
if (!haystack.includes(q)) return false
}
return true
})
const handleOpenDetail = (notification: Notification) => {
setSelectedNotification(notification)
setDetailOpen(true)
}
const handleComposeSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!composeTitle.trim() || !composeMessage.trim()) return
if (composeChannels.length === 0) return
setSending(true)
try {
// Hook up to backend send API here when available.
// For now, we just reset the form after a short delay for UI feedback.
await new Promise((resolve) => setTimeout(resolve, 400))
setComposeTitle("")
setComposeMessage("")
setComposeAudience("all")
setComposeChannels(["push"])
setSelectedRecipientIds([])
setComposeImage(null)
setComposeOpen(false)
} finally {
setSending(false)
}
}
// Lazy-load users for recipient selection when compose dialog first opens
useEffect(() => {
if (!composeOpen || teamRecipients.length > 0 || recipientsLoading) return
setRecipientsLoading(true)
getTeamMembers(1, 50)
.then((res) => {
setTeamRecipients(res.data.data ?? [])
})
.catch(() => {
setTeamRecipients([])
})
.finally(() => {
setRecipientsLoading(false)
})
}, [composeOpen, teamRecipients.length, recipientsLoading])
return (
<div className="mx-auto w-full max-w-3xl">
<div className="mx-auto w-full max-w-6xl">
{/* Header */}
<div className="mb-5">
<div className="mb-1 text-sm font-semibold text-grayScale-500">Notifications</div>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">Notifications</h1>
{totalCount > 0 && (
<Badge variant="secondary">{totalCount}</Badge>
)}
{globalUnread > 0 && (
<Badge variant="default">{globalUnread} unread</Badge>
)}
{totalCount > 0 && <Badge variant="secondary">{totalCount}</Badge>}
{globalUnread > 0 && <Badge variant="default">{globalUnread} unread</Badge>}
</div>
{/* Bulk actions */}
{!loading && !error && notifications.length > 0 && (
{!loading && !error && (
<div className="flex items-center gap-2">
<Button
size="sm"
className="bg-brand-500 text-white hover:bg-brand-600"
onClick={() => setComposeOpen(true)}
>
<Megaphone className="mr-2 h-3.5 w-3.5" />
New notification
</Button>
{notifications.length > 0 && (
<>
{globalUnread > 0 ? (
<Button
variant="outline"
@ -348,11 +454,58 @@ export function NotificationsPage() {
Mark all unread
</Button>
)}
</>
)}
</div>
)}
</div>
</div>
{/* Summary cards */}
{!loading && !error && (
<div className="mb-5 grid gap-4 sm:grid-cols-3">
<Card className="shadow-none border border-grayScale-100">
<CardContent className="flex items-center justify-between gap-3 p-4">
<div>
<p className="text-xs font-medium text-grayScale-500">Total notifications</p>
<p className="mt-1 text-xl font-semibold text-grayScale-700">
{totalCount.toLocaleString()}
</p>
</div>
<div className="grid h-10 w-10 place-items-center rounded-xl bg-brand-500 text-white">
<Bell className="h-5 w-5" />
</div>
</CardContent>
</Card>
<Card className="shadow-none border border-grayScale-100">
<CardContent className="flex items-center justify-between gap-3 p-4">
<div>
<p className="text-xs font-medium text-grayScale-500">Unread</p>
<p className="mt-1 text-xl font-semibold text-grayScale-700">
{globalUnread.toLocaleString()}
</p>
</div>
<div className="grid h-10 w-10 place-items-center rounded-xl bg-amber-50 text-amber-600">
<BellOff className="h-5 w-5" />
</div>
</CardContent>
</Card>
<Card className="shadow-none border border-grayScale-100">
<CardContent className="flex items-center justify-between gap-3 p-4">
<div>
<p className="text-xs font-medium text-grayScale-500">Channels used</p>
<p className="mt-1 text-xl font-semibold text-grayScale-700">
{Array.from(new Set(notifications.map((n) => n.delivery_channel))).length || "—"}
</p>
</div>
<div className="grid h-10 w-10 place-items-center rounded-xl bg-grayScale-50 text-grayScale-500">
<MailOpen className="h-5 w-5" />
</div>
</CardContent>
</Card>
</div>
)}
{/* Loading */}
{loading && (
<div className="flex items-center justify-center py-20">
@ -381,26 +534,220 @@ export function NotificationsPage() {
<BellOff className="h-7 w-7 text-grayScale-400" />
</div>
<span className="text-sm font-medium text-grayScale-500">No notifications yet</span>
<span className="text-xs text-grayScale-400">When you receive notifications, they'll appear here.</span>
<span className="text-xs text-grayScale-400">
When you receive notifications, they'll appear here.
</span>
</CardContent>
</Card>
)}
{/* Notification list */}
{/* Filters + table */}
{!loading && !error && notifications.length > 0 && (
<>
<Card className="shadow-none">
<CardContent className="divide-y-0 p-2">
<div className="space-y-1">
{notifications.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onToggleRead={handleToggleRead}
toggling={togglingIds.has(n.id)}
/>
{/* Status tabs */}
<div className="mb-2 border-b border-grayScale-200">
<div className="-mb-px flex gap-6">
{(["all", "unread", "read"] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveStatusTab(tab)}
className={cn(
"relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all",
activeStatusTab === tab
? "text-brand-600"
: "text-grayScale-400 hover:text-grayScale-700",
)}
>
{tab === "all" ? "All" : tab === "unread" ? "Unread" : "Read"}
{activeStatusTab === tab && (
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
)}
</button>
))}
</div>
</div>
{/* Filters */}
<Card className="mb-3 shadow-none">
<CardContent className="flex flex-wrap items-center gap-3 p-4">
<div className="relative flex-1 min-w-[180px] max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
<Input
placeholder="Search by title, message, or type…"
className="pl-9"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-grayScale-500">Channel</span>
<Select
value={channelFilter}
onChange={(e) => setChannelFilter(e.target.value as typeof channelFilter)}
className="h-8 w-[130px] text-xs"
>
<option value="all">All</option>
<option value="push">Push</option>
<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>
<Card className="shadow-none">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Title</TableHead>
<TableHead className="hidden lg:table-cell">Message</TableHead>
<TableHead>Channel</TableHead>
<TableHead>Status</TableHead>
<TableHead className="hidden sm:table-cell">Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredNotifications.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center text-sm text-grayScale-400">
No notifications match your filters.
</TableCell>
</TableRow>
) : (
filteredNotifications.map((n) => {
const config = TYPE_CONFIG[n.type] ?? DEFAULT_TYPE_CONFIG
const Icon = config.icon
const isToggling = togglingIds.has(n.id)
return (
<TableRow
key={n.id}
className={cn(
"cursor-pointer",
!n.is_read && "bg-brand-50/40 hover:bg-brand-50/70",
)}
onClick={() => handleOpenDetail(n)}
>
<TableCell>
<div className="flex items-center gap-2">
<div
className={cn(
"grid h-8 w-8 place-items-center rounded-lg text-xs",
config.bg,
config.color,
)}
>
<Icon className="h-4 w-4" />
</div>
<span className="text-xs font-medium text-grayScale-600">
{formatTypeLabel(n.type)}
</span>
</div>
</TableCell>
<TableCell>
<p
className={cn(
"max-w-xs truncate text-sm font-medium",
n.is_read ? "text-grayScale-600" : "text-grayScale-800",
)}
>
{n.payload.headline}
</p>
</TableCell>
<TableCell className="hidden lg:table-cell">
<p className="max-w-sm truncate text-xs text-grayScale-500">
{n.payload.message}
</p>
</TableCell>
<TableCell>
<Badge variant="secondary" className="text-[10px] capitalize">
{n.delivery_channel}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={getLevelBadge(n.level)}
className="text-[10px] uppercase tracking-wide"
>
{n.is_read ? "Read" : "Unread"}
</Badge>
</TableCell>
<TableCell className="hidden sm:table-cell">
<span className="text-xs text-grayScale-400">
{formatTimestamp(n.timestamp)}
</span>
</TableCell>
<TableCell className="text-right">
<div
className="flex items-center justify-end gap-1"
onClick={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={isToggling}
onClick={() => handleToggleRead(n.id, n.is_read)}
title={n.is_read ? "Mark as unread" : "Mark as read"}
>
{isToggling ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-grayScale-400" />
) : n.is_read ? (
<Mail className="h-3.5 w-3.5 text-grayScale-400" />
) : (
<MailOpen className="h-3.5 w-3.5 text-brand-500" />
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
onClick={() => handleOpenDetail(n)}
>
View
</Button>
</div>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</CardContent>
</Card>
@ -435,6 +782,297 @@ export function NotificationsPage() {
)}
</>
)}
{/* Detail dialog */}
{selectedNotification && (
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-50 text-brand-600">
{(() => {
const Icon =
(TYPE_CONFIG[selectedNotification.type] ?? DEFAULT_TYPE_CONFIG).icon
return <Icon className="h-4 w-4" />
})()}
</span>
<span className="truncate text-base">
{selectedNotification.payload.headline}
</span>
</DialogTitle>
<DialogDescription>
Sent via {selectedNotification.delivery_channel} ·{" "}
{formatTimestamp(selectedNotification.timestamp)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-lg bg-grayScale-50 p-3">
<p className="text-sm text-grayScale-600">
{selectedNotification.payload.message}
</p>
</div>
<div className="grid gap-3 text-xs text-grayScale-500 sm:grid-cols-2">
<div>
<p className="text-grayScale-400">Type</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{formatTypeLabel(selectedNotification.type)}
</p>
</div>
<div>
<p className="text-grayScale-400">Level</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{selectedNotification.level}
</p>
</div>
<div>
<p className="text-grayScale-400">Channel</p>
<p className="mt-0.5 font-medium text-grayScale-700 capitalize">
{selectedNotification.delivery_channel}
</p>
</div>
<div>
<p className="text-grayScale-400">Delivery status</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{selectedNotification.delivery_status}
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)}
{/* Compose dialog */}
<Dialog open={composeOpen} onOpenChange={setComposeOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Megaphone className="h-5 w-5 text-brand-500" />
<span>Create notification</span>
</DialogTitle>
<DialogDescription>
Send a one-off push or SMS notification to your users.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleComposeSubmit} className="space-y-4">
<div className="grid gap-3 md:grid-cols-[minmax(0,1.2fr)_minmax(0,1.2fr)]">
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
Channel
</p>
<div className="inline-flex rounded-full border border-grayScale-200 bg-grayScale-50 p-0.5 text-xs font-medium">
<button
type="button"
onClick={() =>
setComposeChannels((prev) =>
prev.includes("push")
? prev.filter((c) => c !== "push")
: [...prev, "push"],
)
}
className={cn(
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
composeChannels.includes("push")
? "bg-white text-brand-600 shadow-sm"
: "text-grayScale-500 hover:text-grayScale-700",
)}
>
<Bell className="h-3.5 w-3.5" />
Push
</button>
<button
type="button"
onClick={() =>
setComposeChannels((prev) =>
prev.includes("sms")
? prev.filter((c) => c !== "sms")
: [...prev, "sms"],
)
}
className={cn(
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
composeChannels.includes("sms")
? "bg-white text-brand-600 shadow-sm"
: "text-grayScale-500 hover:text-grayScale-700",
)}
>
<Mail className="h-3.5 w-3.5" />
SMS
</button>
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
Audience
</p>
<div className="inline-flex rounded-full border border-grayScale-200 bg-grayScale-50 p-0.5 text-xs font-medium">
<button
type="button"
onClick={() => setComposeAudience("all")}
className={cn(
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
composeAudience === "all"
? "bg-white text-brand-600 shadow-sm"
: "text-grayScale-500 hover:text-grayScale-700",
)}
>
All users
</button>
<button
type="button"
onClick={() => setComposeAudience("selected")}
className={cn(
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
composeAudience === "selected"
? "bg-white text-brand-600 shadow-sm"
: "text-grayScale-500 hover:text-grayScale-700",
)}
>
Selected users
</button>
</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1.2fr)]">
<div className="space-y-3">
<div>
<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 className="space-y-2">
<p className="mb-1 block text-xs font-medium text-grayScale-500">
Image (push only)
</p>
<FileUpload
accept="image/*"
onFileSelect={setComposeImage}
label="Upload notification image"
description="Shown with push notification where supported"
className="min-h-[110px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
<p className="text-[10px] text-grayScale-400">
Image will be ignored for SMS-only sends. Connect your push provider to attach it
to real notifications.
</p>
</div>
</div>
{composeAudience === "selected" && (
<div className="space-y-2">
<p className="text-xs font-medium text-grayScale-500">Recipients</p>
<div className="max-h-48 space-y-1.5 overflow-y-auto rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-2">
{recipientsLoading && (
<div className="flex items-center justify-center py-6 text-xs text-grayScale-400">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading users
</div>
)}
{!recipientsLoading && teamRecipients.length === 0 && (
<div className="py-4 text-center text-xs text-grayScale-400">
No users available to select.
</div>
)}
{!recipientsLoading &&
teamRecipients.map((member) => (
<label
key={member.id}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs hover:bg-grayScale-100"
>
<input
type="checkbox"
className="h-3.5 w-3.5 rounded border-grayScale-300"
checked={selectedRecipientIds.includes(member.id)}
onChange={(e) => {
setSelectedRecipientIds((prev) =>
e.target.checked
? [...prev, member.id]
: prev.filter((id) => id !== member.id),
)
}}
/>
<span className="truncate">
{member.first_name} {member.last_name}
<span className="ml-1 text-[10px] text-grayScale-400">
· {member.email}
</span>
</span>
</label>
))}
</div>
<p className="text-[11px] text-grayScale-400">
Only the selected users will receive this notification.
</p>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3 pt-1">
<p className="text-[11px] text-grayScale-400">
This is a UI-only preview. Hook into your notification API to deliver messages.
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setComposeTitle("")
setComposeMessage("")
setComposeAudience("all")
setComposeChannels(["push"])
setSelectedRecipientIds([])
setComposeImage(null)
}}
>
Clear
</Button>
<Button
type="submit"
size="sm"
disabled={sending || !composeTitle.trim() || !composeMessage.trim()}
>
{sending ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Sending
</>
) : (
<>
<MailOpen className="mr-2 h-3.5 w-3.5" />
Send notification
</>
)}
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
</div>
)
}

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

@ -405,7 +405,7 @@ export function UserLogPage() {
<TableRow key={log.id} className="group">
<TableCell>
<div className="flex items-center gap-2.5">
<div className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400 group-hover:bg-brand-50 group-hover:text-brand-500 transition-colors">
<div className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400 group-hover:bg-brand-500 group-hover:text-white transition-colors">
<ActionIcon className="h-4 w-4" />
</div>
<span

View File

@ -1,14 +1,62 @@
import { useState } from "react"
import { ArrowLeft, FileText, Mail, Phone, Shield, User } from "lucide-react"
import { useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { Button } from "../../components/ui/button"
import { Card } from "../../components/ui/card"
import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea"
import { Select } from "../../components/ui/select"
import { createUser } from "../../api/users.api"
export function RegisterUserPage() {
const navigate = useNavigate()
const [firstName, setFirstName] = useState("")
const [lastName, setLastName] = useState("")
const [email, setEmail] = useState("")
const [phone, setPhone] = useState("")
const [role, setRole] = useState("")
const [notes, setNotes] = useState("")
const [submitting, setSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!firstName.trim() || !lastName.trim() || !email.trim() || !phone.trim() || !role) {
toast.error("Missing required fields", {
description: "Please fill in first name, last name, email, phone, and role.",
})
return
}
setSubmitting(true)
try {
await createUser({
first_name: firstName.trim(),
last_name: lastName.trim(),
email: email.trim(),
phone_number: phone.trim(),
role: role.toUpperCase(),
notes: notes.trim() || undefined,
})
toast.success("User registered", {
description: `${firstName} ${lastName} has been created successfully.`,
})
navigate("/users")
} catch (error: any) {
const message =
error?.response?.data?.message ||
"Failed to register user. Please check the details and try again."
toast.error("Registration failed", {
description: message,
})
} finally {
setSubmitting(false)
}
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
@ -22,21 +70,31 @@ export function RegisterUserPage() {
</div>
<Card className="mx-auto max-w-2xl p-6">
<form className="space-y-5">
<form className="space-y-5" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<User className="h-4 w-4" />
First Name
</label>
<Input placeholder="Enter first name" required />
<Input
placeholder="Enter first name"
required
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
</div>
<div>
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<User className="h-4 w-4" />
Last Name
</label>
<Input placeholder="Enter last name" required />
<Input
placeholder="Enter last name"
required
value={lastName}
onChange={(e) => setLastName(e.target.value)}
/>
</div>
</div>
@ -45,7 +103,13 @@ export function RegisterUserPage() {
<Mail className="h-4 w-4" />
Email
</label>
<Input type="email" placeholder="Enter email address" required />
<Input
type="email"
placeholder="Enter email address"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
@ -53,7 +117,13 @@ export function RegisterUserPage() {
<Phone className="h-4 w-4" />
Phone
</label>
<Input type="tel" placeholder="Enter phone number" required />
<Input
type="tel"
placeholder="Enter phone number"
required
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</div>
<div>
@ -61,10 +131,14 @@ export function RegisterUserPage() {
<Shield className="h-4 w-4" />
Role
</label>
<Select required>
<Select
required
value={role}
onChange={(e) => setRole(e.target.value)}
>
<option value="">Select role</option>
<option value="admin">Admin</option>
<option value="user">User</option>
<option value="ADMIN">Admin</option>
<option value="USER">User</option>
</Select>
</div>
@ -73,15 +147,30 @@ export function RegisterUserPage() {
<FileText className="h-4 w-4" />
Notes
</label>
<Textarea placeholder="Enter any additional notes" rows={3} />
<Textarea
placeholder="Enter any additional notes"
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
<Button variant="outline" className="w-full sm:w-auto" onClick={() => navigate("/users")}>
<Button
type="button"
variant="outline"
className="w-full sm:w-auto"
onClick={() => navigate("/users")}
disabled={submitting}
>
Cancel
</Button>
<Button type="submit" className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
Register User
<Button
type="submit"
className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto"
disabled={submitting}
>
{submitting ? "Registering..." : "Register User"}
</Button>
</div>
</form>

View File

@ -29,32 +29,32 @@ export function UserManagementDashboard() {
<Users className="h-6 w-6" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-grayScale-400">Total Users</p>
<p className="text-2xl font-bold text-grayScale-600">1,248</p>
<p className="text-sm font-medium text-white/80">Total Users</p>
<p className="text-2xl font-bold text-white">1,248</p>
</div>
</CardContent>
</Card>
<Card className="border-none bg-mint-50 shadow-sm">
<Card className="border-none bg-brand-50 shadow-sm">
<CardContent className="flex items-center gap-4 p-5">
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-mint-100 text-mint-600">
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
<UserCheck className="h-6 w-6" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-grayScale-400">Active Users</p>
<p className="text-2xl font-bold text-grayScale-600">1,180</p>
<p className="text-sm font-medium text-white/80">Active Users</p>
<p className="text-2xl font-bold text-white">1,180</p>
</div>
</CardContent>
</Card>
<Card className="border-none bg-gold-50 shadow-sm sm:col-span-2 lg:col-span-1">
<Card className="border-none bg-brand-50 shadow-sm sm:col-span-2 lg:col-span-1">
<CardContent className="flex items-center gap-4 p-5">
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-gold-100 text-gold-600">
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
<TrendingUp className="h-6 w-6" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-grayScale-400">New This Month</p>
<p className="text-2xl font-bold text-grayScale-600">64</p>
<p className="text-sm font-medium text-white/80">New This Month</p>
<p className="text-2xl font-bold text-white">64</p>
</div>
</CardContent>
</Card>

View File

@ -2,9 +2,15 @@ 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 {
message: string
data: {

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