Compare commits

..

No commits in common. "95f5d37878cfeb9e88876fe6bfcc6f885317a6e0" and "28f7ac2dcd65a6e0db67b0eb57490f53dec01fe5" have entirely different histories.

25 changed files with 544 additions and 3616 deletions

19
package-lock.json generated
View File

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

View File

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

View File

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

View File

@ -14,17 +14,3 @@ export const getUserById = (id: number) =>
export const getMyProfile = () => export const getMyProfile = () =>
http.get<UserProfileResponse>("/team/me"); 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,8 +4,6 @@ import { DashboardPage } from "../pages/DashboardPage"
import { AnalyticsPage } from "../pages/analytics/AnalyticsPage" import { AnalyticsPage } from "../pages/analytics/AnalyticsPage"
import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout" import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout"
import { CourseCategoryPage } from "../pages/content-management/CourseCategoryPage" 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 { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage"
import { CoursesPage } from "../pages/content-management/CoursesPage" import { CoursesPage } from "../pages/content-management/CoursesPage"
import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuestionsPage" import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuestionsPage"
@ -35,7 +33,6 @@ import { IssuesPage } from "../pages/issues/IssuesPage"
import { ProfilePage } from "../pages/ProfilePage" import { ProfilePage } from "../pages/ProfilePage"
import { SettingsPage } from "../pages/SettingsPage" import { SettingsPage } from "../pages/SettingsPage"
import { TeamManagementPage } from "../pages/team/TeamManagementPage" import { TeamManagementPage } from "../pages/team/TeamManagementPage"
import { AddTeamMemberPage } from "../pages/team/AddTeamMemberPage"
import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage" import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage"
import { LoginPage } from "../pages/auth/LoginPage" import { LoginPage } from "../pages/auth/LoginPage"
import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage" import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage"
@ -65,8 +62,6 @@ export function AppRoutes() {
<Route path="/content" element={<ContentManagementLayout />}> <Route path="/content" element={<ContentManagementLayout />}>
<Route index element={<CourseCategoryPage />} /> <Route index element={<CourseCategoryPage />} />
<Route path="courses" element={<AllCoursesPage />} />
<Route path="flows" element={<CourseFlowBuilderPage />} />
<Route path="category/:categoryId" element={<ContentOverviewPage />} /> <Route path="category/:categoryId" element={<ContentOverviewPage />} />
<Route path="category/:categoryId/courses" element={<CoursesPage />} /> <Route path="category/:categoryId/courses" element={<CoursesPage />} />
{/* Course → Sub-course → Video/Practice */} {/* Course → Sub-course → Video/Practice */}
@ -90,7 +85,6 @@ export function AppRoutes() {
<Route path="/analytics" element={<AnalyticsPage />} /> <Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/team" element={<TeamManagementPage />} /> <Route path="/team" element={<TeamManagementPage />} />
<Route path="/team/add" element={<AddTeamMemberPage />} />
<Route path="/team/:id" element={<TeamMemberDetailPage />} /> <Route path="/team/:id" element={<TeamMemberDetailPage />} />
<Route path="/profile" element={<ProfilePage />} /> <Route path="/profile" element={<ProfilePage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />

View File

@ -29,7 +29,7 @@ import {
import { StatCard } from "../components/dashboard/StatCard" import { StatCard } from "../components/dashboard/StatCard"
import alertSrc from "../assets/Alert.svg" import alertSrc from "../assets/Alert.svg"
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card" 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 { getTeamMemberById } from "../api/team.api"
import { getDashboard } from "../api/analytics.api" import { getDashboard } from "../api/analytics.api"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
@ -46,7 +46,6 @@ export function DashboardPage() {
const [userFirstName, setUserFirstName] = useState<string>("") const [userFirstName, setUserFirstName] = useState<string>("")
const [dashboard, setDashboard] = useState<DashboardData | null>(null) const [dashboard, setDashboard] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [activeStatTab, setActiveStatTab] = useState<"primary" | "secondary">("primary")
useEffect(() => { useEffect(() => {
const fetchUser = async () => { const fetchUser = async () => {
@ -124,109 +123,69 @@ export function DashboardPage() {
</div> </div>
) : ( ) : (
<> <>
{/* Stat tabs */} {/* Stat Cards */}
<div className="mb-3 border-b border-grayScale-200"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="-mb-px flex gap-6"> <StatCard
<button icon={Users}
type="button" label="Total Users"
onClick={() => setActiveStatTab("primary")} value={dashboard.users.total_users.toLocaleString()}
className={cn( deltaLabel={`+${dashboard.users.new_month} this month`}
"relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all", deltaPositive={dashboard.users.new_month > 0}
activeStatTab === "primary" />
? "text-brand-600" <StatCard
: "text-grayScale-400 hover:text-grayScale-700", icon={BadgeCheck}
)} label="Active Subscribers"
> value={dashboard.subscriptions.active_subscriptions.toLocaleString()}
Overview deltaLabel={`+${dashboard.subscriptions.new_month} this month`}
{activeStatTab === "primary" && ( deltaPositive={dashboard.subscriptions.new_month > 0}
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" /> />
)} <StatCard
</button> icon={DollarSign}
<button label="Total Revenue (ETB)"
type="button" value={dashboard.payments.total_revenue.toLocaleString()}
onClick={() => setActiveStatTab("secondary")} deltaLabel={`${dashboard.payments.total_payments} payments`}
className={cn( deltaPositive={dashboard.payments.total_revenue > 0}
"relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all", />
activeStatTab === "secondary" <StatCard
? "text-brand-600" icon={TicketCheck}
: "text-grayScale-400 hover:text-grayScale-700", label="Issues"
)} value={`${dashboard.issues.resolved_issues}/${dashboard.issues.total_issues}`}
> deltaLabel={`${(dashboard.issues.resolution_rate * 100).toFixed(1)}% resolved`}
More metrics deltaPositive={dashboard.issues.resolution_rate > 0.5}
{activeStatTab === "secondary" && ( />
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
)}
</button>
</div>
</div> </div>
{/* Stat Cards */}
{activeStatTab === "primary" && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={Users}
label="Total Users"
value={dashboard.users.total_users.toLocaleString()}
deltaLabel={`+${dashboard.users.new_month} this month`}
deltaPositive={dashboard.users.new_month > 0}
/>
<StatCard
icon={BadgeCheck}
label="Active Subscribers"
value={dashboard.subscriptions.active_subscriptions.toLocaleString()}
deltaLabel={`+${dashboard.subscriptions.new_month} this month`}
deltaPositive={dashboard.subscriptions.new_month > 0}
/>
<StatCard
icon={DollarSign}
label="Total Revenue (ETB)"
value={dashboard.payments.total_revenue.toLocaleString()}
deltaLabel={`${dashboard.payments.total_payments} payments`}
deltaPositive={dashboard.payments.total_revenue > 0}
/>
<StatCard
icon={TicketCheck}
label="Issues"
value={`${dashboard.issues.resolved_issues}/${dashboard.issues.total_issues}`}
deltaLabel={`${(dashboard.issues.resolution_rate * 100).toFixed(1)}% resolved`}
deltaPositive={dashboard.issues.resolution_rate > 0.5}
/>
</div>
)}
{/* Secondary Stats */} {/* Secondary Stats */}
{activeStatTab === "secondary" && ( <div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <StatCard
<StatCard icon={BookOpen}
icon={BookOpen} label="Courses"
label="Courses" value={dashboard.courses.total_courses.toLocaleString()}
value={dashboard.courses.total_courses.toLocaleString()} deltaLabel={`${dashboard.courses.total_sub_courses} sub-courses, ${dashboard.courses.total_videos} videos`}
deltaLabel={`${dashboard.courses.total_sub_courses} sub-courses, ${dashboard.courses.total_videos} videos`} deltaPositive
deltaPositive />
/> <StatCard
<StatCard icon={HelpCircle}
icon={HelpCircle} label="Questions"
label="Questions" value={dashboard.content.total_questions.toLocaleString()}
value={dashboard.content.total_questions.toLocaleString()} deltaLabel={`${dashboard.content.total_question_sets} question sets`}
deltaLabel={`${dashboard.content.total_question_sets} question sets`} deltaPositive
deltaPositive />
/> <StatCard
<StatCard icon={Bell}
icon={Bell} label="Notifications"
label="Notifications" value={dashboard.notifications.total_sent.toLocaleString()}
value={dashboard.notifications.total_sent.toLocaleString()} deltaLabel={`${dashboard.notifications.unread_count} unread`}
deltaLabel={`${dashboard.notifications.unread_count} unread`} deltaPositive={dashboard.notifications.unread_count === 0}
deltaPositive={dashboard.notifications.unread_count === 0} />
/> <StatCard
<StatCard icon={UsersRound}
icon={UsersRound} label="Team Members"
label="Team Members" value={dashboard.team.total_members.toLocaleString()}
value={dashboard.team.total_members.toLocaleString()} deltaLabel={`${dashboard.team.by_role.length} roles`}
deltaLabel={`${dashboard.team.by_role.length} roles`} deltaPositive
deltaPositive />
/> </div>
</div>
)}
{/* User Registrations Chart */} {/* User Registrations Chart */}
<div className="mt-5 grid gap-4"> <div className="mt-5 grid gap-4">

View File

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

View File

@ -284,7 +284,6 @@ export function AnalyticsPage() {
const [dashboard, setDashboard] = useState<DashboardData | null>(null) const [dashboard, setDashboard] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(false) const [error, setError] = useState(false)
const [activeSummaryTab, setActiveSummaryTab] = useState<"key" | "content" | "operations">("key")
const fetchData = async () => { const fetchData = async () => {
setLoading(true) setLoading(true)
@ -391,166 +390,107 @@ export function AnalyticsPage() {
</div> </div>
</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"> <div className="space-y-4">
{activeSummaryTab === "key" && ( {/* ─── Key Metrics ─── */}
<> <Section title="Key Metrics" icon={TrendingUp} defaultOpen>
{/* ─── Key Metrics ─── */} <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Section title="Key Metrics" icon={TrendingUp} defaultOpen> <KpiCard
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> icon={Users}
<KpiCard label="Total Users"
icon={Users} value={formatNumber(users.total_users)}
label="Total Users" sub={`+${users.new_today} today · +${users.new_week} this week · +${users.new_month} this month`}
value={formatNumber(users.total_users)} trend={users.new_month > 0 ? "up" : "neutral"}
sub={`+${users.new_today} today · +${users.new_week} this week · +${users.new_month} this month`} />
trend={users.new_month > 0 ? "up" : "neutral"} <KpiCard
/> icon={BadgeCheck}
<KpiCard label="Active Subscriptions"
icon={BadgeCheck} value={formatNumber(subscriptions.active_subscriptions)}
label="Active Subscriptions" sub={`${subscriptions.total_subscriptions} total · +${subscriptions.new_month} this month`}
value={formatNumber(subscriptions.active_subscriptions)} trend={subscriptions.new_month > 0 ? "up" : "neutral"}
sub={`${subscriptions.total_subscriptions} total · +${subscriptions.new_month} this month`} />
trend={subscriptions.new_month > 0 ? "up" : "neutral"} <KpiCard
/> icon={DollarSign}
<KpiCard label="Total Revenue"
icon={DollarSign} value={`ETB ${formatNumber(payments.total_revenue)}`}
label="Total Revenue" sub={`${payments.successful_payments}/${payments.total_payments} successful · Avg ETB ${payments.avg_transaction_value.toLocaleString()}`}
value={`ETB ${formatNumber(payments.total_revenue)}`} trend={payments.total_revenue > 0 ? "up" : "neutral"}
sub={`${payments.successful_payments}/${payments.total_payments} successful · Avg ETB ${payments.avg_transaction_value.toLocaleString()}`} />
trend={payments.total_revenue > 0 ? "up" : "neutral"} <KpiCard
/> icon={TicketCheck}
<KpiCard label="Issue Resolution"
icon={TicketCheck} value={`${(issues.resolution_rate * 100).toFixed(1)}%`}
label="Issue Resolution" sub={`${issues.resolved_issues} resolved of ${issues.total_issues} total`}
value={`${(issues.resolution_rate * 100).toFixed(1)}%`} trend={issues.resolution_rate >= 0.5 ? "up" : "down"}
sub={`${issues.resolved_issues} resolved of ${issues.total_issues} total`} />
trend={issues.resolution_rate >= 0.5 ? "up" : "down"} </div>
/> </Section>
</div>
</Section>
</>
)}
{activeSummaryTab === "content" && ( {/* ─── Content & Platform ─── */}
<> <Section title="Content & Platform" icon={BookOpen} count={courses.total_courses + content.total_questions} defaultOpen>
{/* ─── Content & Platform ─── */} <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Section <KpiCard
title="Content & Platform" icon={FolderOpen}
label="Categories"
value={courses.total_categories.toLocaleString()}
sub={`${courses.total_courses} courses`}
trend="neutral"
/>
<KpiCard
icon={BookOpen} icon={BookOpen}
count={courses.total_courses + content.total_questions} label="Sub-Courses"
defaultOpen value={courses.total_sub_courses.toLocaleString()}
> sub={`across ${courses.total_courses} courses`}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> trend="neutral"
<KpiCard />
icon={FolderOpen} <KpiCard
label="Categories" icon={Video}
value={courses.total_categories.toLocaleString()} label="Videos"
sub={`${courses.total_courses} courses`} value={courses.total_videos.toLocaleString()}
trend="neutral" trend="neutral"
/> />
<KpiCard <KpiCard
icon={BookOpen} icon={HelpCircle}
label="Sub-Courses" label="Questions"
value={courses.total_sub_courses.toLocaleString()} value={content.total_questions.toLocaleString()}
sub={`across ${courses.total_courses} courses`} sub={`${content.total_question_sets} question sets`}
trend="neutral" trend="neutral"
/> />
<KpiCard </div>
icon={Video} </Section>
label="Videos"
value={courses.total_videos.toLocaleString()}
trend="neutral"
/>
<KpiCard
icon={HelpCircle}
label="Questions"
value={content.total_questions.toLocaleString()}
sub={`${content.total_question_sets} question sets`}
trend="neutral"
/>
</div>
</Section>
</>
)}
{activeSummaryTab === "operations" && ( {/* ─── Operations ─── */}
<> <Section title="Operations" icon={Bell} defaultOpen>
{/* ─── Operations ─── */} <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Section title="Operations" icon={Bell} defaultOpen> <KpiCard
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> icon={Bell}
<KpiCard label="Notifications Sent"
icon={Bell} value={formatNumber(notifications.total_sent)}
label="Notifications Sent" sub={`${notifications.read_count} read · ${notifications.unread_count} unread`}
value={formatNumber(notifications.total_sent)} trend={notifications.unread_count === 0 ? "up" : "neutral"}
sub={`${notifications.read_count} read · ${notifications.unread_count} unread`} />
trend={notifications.unread_count === 0 ? "up" : "neutral"} <KpiCard
/> icon={UsersRound}
<KpiCard label="Team Members"
icon={UsersRound} value={team.total_members.toLocaleString()}
label="Team Members" sub={`${team.by_role.length} roles`}
value={team.total_members.toLocaleString()} trend="neutral"
sub={`${team.by_role.length} roles`} />
trend="neutral" <KpiCard
/> icon={CreditCard}
<KpiCard label="Payments"
icon={CreditCard} value={payments.total_payments.toLocaleString()}
label="Payments" sub={`${payments.successful_payments} successful`}
value={payments.total_payments.toLocaleString()} trend={payments.successful_payments > 0 ? "up" : "neutral"}
sub={`${payments.successful_payments} successful`} />
trend={payments.successful_payments > 0 ? "up" : "neutral"} <KpiCard
/> icon={Layers}
<KpiCard label="Question Sets"
icon={Layers} value={content.total_question_sets.toLocaleString()}
label="Question Sets" sub={content.question_sets_by_type.map((q) => `${q.count} ${q.label.toLowerCase()}`).join(" · ")}
value={content.total_question_sets.toLocaleString()} trend="neutral"
sub={content.question_sets_by_type.map((q) => `${q.count} ${q.label.toLowerCase()}`).join(" · ")} />
trend="neutral" </div>
/> </Section>
</div>
</Section>
</>
)}
{/* ─── User Analytics ─── */} {/* ─── User Analytics ─── */}
<Section title="User Analytics" icon={Users} count={users.total_users} defaultOpen> <Section title="User Analytics" icon={Users} count={users.total_users} defaultOpen>

View File

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

View File

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

View File

@ -57,21 +57,9 @@ const contentSections = [
}, },
] as const ] as const
type ContentSection = (typeof contentSections)[number]
export function ContentOverviewPage() { export function ContentOverviewPage() {
const { categoryId } = useParams<{ categoryId: string }>() const { categoryId } = useParams<{ categoryId: string }>()
const [category, setCategory] = useState<CourseCategory | null>(null) 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(() => { useEffect(() => {
const fetchCategory = async () => { const fetchCategory = async () => {
@ -89,75 +77,6 @@ export function ContentOverviewPage() {
} }
}, [categoryId]) }, [categoryId])
// Load category-level flow sequence (if any) from localStorage
useEffect(() => {
if (!categoryId) {
setFlowSteps([])
return
}
const key = `category_flow_${categoryId}`
try {
const raw = window.localStorage.getItem(key)
if (raw) {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) {
setFlowSteps(parsed)
return
}
}
} catch {
// ignore and fall back to default
}
// No explicit flow saved; fall back to an empty sequence
setFlowSteps([])
}, [categoryId])
// Load persisted section order from localStorage
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 ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Header & Breadcrumb */} {/* Header & Breadcrumb */}
@ -201,161 +120,72 @@ export function ContentOverviewPage() {
</div> </div>
</div> </div>
{/* Cards Grid (course builder style draggable sections) */} {/* Cards Grid */}
<div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
{sections.map((section) => { {contentSections.map((section) => {
const Icon = section.icon const Icon = section.icon
return ( return (
<div <Link
key={section.key} key={section.key}
to={section.pathFn(categoryId)}
className="group" className="group"
draggable
onDragStart={() => setDragKey(section.key)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => handleDropOn(section.key)}
> >
<Link to={section.pathFn(categoryId)} className="block"> <Card
<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 ${ style={{
dragKey === section.key ? "ring-2 ring-brand-300" : "" boxShadow: "0 8px 24px rgba(0,0,0,0.06)",
}`} }}
style={{ >
boxShadow: "0 8px 24px rgba(0,0,0,0.06)", {/* Subtle gradient background on icon area */}
}} <div
> className={`absolute inset-x-0 top-0 h-28 bg-gradient-to-b ${section.gradient} pointer-events-none`}
{/* Subtle gradient background on icon area */} />
<div
className={`absolute inset-x-0 top-0 h-28 bg-gradient-to-b ${section.gradient} pointer-events-none`}
/>
<CardHeader className="relative pb-2"> <CardHeader className="relative pb-2">
<div className="mb-4 flex items-start justify-between"> <div className="mb-4 flex items-start justify-between">
{/* Icon with gradient ring */} {/* Icon with gradient ring */}
<div className="relative"> <div className="relative">
<div <div
className="grid h-12 w-12 place-items-center rounded-xl bg-white text-brand-600 shadow-sm ring-1 ring-grayScale-100 transition-all duration-300 group-hover:ring-brand-300 group-hover:shadow-md" className="grid h-12 w-12 place-items-center rounded-xl bg-white text-brand-600 shadow-sm ring-1 ring-grayScale-100 transition-all duration-300 group-hover:ring-brand-300 group-hover:shadow-md"
style={{ style={{
background: background:
"linear-gradient(135deg, rgba(158,40,145,0.08) 0%, rgba(106,27,154,0.04) 100%)", "linear-gradient(135deg, rgba(158,40,145,0.08) 0%, rgba(106,27,154,0.04) 100%)",
}} }}
> >
<Icon className="h-5.5 w-5.5 transition-transform duration-300 group-hover:scale-110" /> <Icon className="h-5.5 w-5.5 transition-transform duration-300 group-hover:scale-110" />
</div>
{/* Decorative dot */}
<div className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-brand-400 opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
</div> </div>
{/* Decorative dot */}
{/* Count Badge */} <div className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-brand-400 opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
<span className="inline-flex items-center gap-1 rounded-full bg-grayScale-50 px-2.5 py-1 text-xs font-medium text-grayScale-500 ring-1 ring-inset ring-grayScale-100 transition-all duration-300 group-hover:bg-brand-50 group-hover:text-brand-600 group-hover:ring-brand-200">
{section.count} {section.countLabel}
</span>
</div> </div>
<CardTitle className="text-[15px] font-semibold text-grayScale-700 transition-colors duration-200 group-hover:text-brand-600"> {/* Count Badge */}
{section.title} <span className="inline-flex items-center gap-1 rounded-full bg-grayScale-50 px-2.5 py-1 text-xs font-medium text-grayScale-500 ring-1 ring-inset ring-grayScale-100 transition-all duration-300 group-hover:bg-brand-50 group-hover:text-brand-600 group-hover:ring-brand-200">
</CardTitle> {section.count} {section.countLabel}
<CardDescription className="mt-1 text-[13px] leading-relaxed text-grayScale-400">
{section.description}
</CardDescription>
</CardHeader>
<CardContent className="relative pt-0">
{/* Thin separator */}
<div className="mb-3 h-px w-full bg-gradient-to-r from-transparent via-grayScale-100 to-transparent" />
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors duration-200 group-hover:text-brand-600">
{section.action}
<ArrowRight className="h-4 w-4 transition-transform duration-300 group-hover:translate-x-1.5" />
</span> </span>
</CardContent> </div>
</Card>
</Link> <CardTitle className="text-[15px] font-semibold text-grayScale-700 transition-colors duration-200 group-hover:text-brand-600">
</div> {section.title}
</CardTitle>
<CardDescription className="mt-1 text-[13px] leading-relaxed text-grayScale-400">
{section.description}
</CardDescription>
</CardHeader>
<CardContent className="relative pt-0">
{/* Thin separator */}
<div className="mb-3 h-px w-full bg-gradient-to-r from-transparent via-grayScale-100 to-transparent" />
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors duration-200 group-hover:text-brand-600">
{section.action}
<ArrowRight className="h-4 w-4 transition-transform duration-300 group-hover:translate-x-1.5" />
</span>
</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> </div>
) )
} }

View File

@ -1,33 +1,16 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { FolderOpen, RefreshCw, BookOpen, Plus } from "lucide-react" import { FolderOpen, RefreshCw, BookOpen } from "lucide-react"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg" import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import alertSrc from "../../assets/Alert.svg" import alertSrc from "../../assets/Alert.svg"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button" import { getCourseCategories } from "../../api/courses.api"
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 type { CourseCategory } from "../../types/course.types"
import { toast } from "sonner"
export function CourseCategoryPage() { export function CourseCategoryPage() {
const [categories, setCategories] = useState<CourseCategory[]>([]) const [categories, setCategories] = useState<CourseCategory[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [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 () => { const fetchCategories = async () => {
setLoading(true) setLoading(true)
@ -82,21 +65,11 @@ export function CourseCategoryPage() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Page header */} {/* Page header */}
<div className="flex items-center justify-between gap-4"> <div>
<div> <h1 className="text-xl font-semibold text-grayScale-600">Course Categories</h1>
<h1 className="text-xl font-semibold text-grayScale-600">Course Categories</h1> <p className="mt-1 text-sm text-grayScale-400">
<p className="mt-1 text-sm text-grayScale-400"> Browse and manage your course categories below
Browse and manage your course categories below </p>
</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> </div>
{categories.length === 0 ? ( {categories.length === 0 ? (
@ -150,207 +123,6 @@ export function CourseCategoryPage() {
))} ))}
</div> </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> </div>
) )
} }

View File

@ -1,802 +0,0 @@
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,7 +8,6 @@ import alertSrc from "../../assets/Alert.svg"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Badge } from "../../components/ui/badge" import { Badge } from "../../components/ui/badge"
import { Input } from "../../components/ui/input" 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 { getCoursesByCategory, getCourseCategories, createCourse, deleteCourse, updateCourseStatus, updateCourse } from "../../api/courses.api"
import type { Course, CourseCategory } from "../../types/course.types" import type { Course, CourseCategory } from "../../types/course.types"
@ -42,8 +41,6 @@ export function CoursesPage() {
const [description, setDescription] = useState("") const [description, setDescription] = useState("")
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null) 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 [showDeleteModal, setShowDeleteModal] = useState(false)
const [courseToDelete, setCourseToDelete] = useState<Course | null>(null) const [courseToDelete, setCourseToDelete] = useState<Course | null>(null)
@ -115,8 +112,6 @@ export function CoursesPage() {
setTitle("") setTitle("")
setDescription("") setDescription("")
setSaveError(null) setSaveError(null)
setNewThumbnailFile(null)
setNewVideoFile(null)
setShowModal(true) setShowModal(true)
} }
@ -125,8 +120,6 @@ export function CoursesPage() {
setTitle("") setTitle("")
setDescription("") setDescription("")
setSaveError(null) setSaveError(null)
setNewThumbnailFile(null)
setNewVideoFile(null)
} }
const handleSave = async () => { const handleSave = async () => {
@ -249,10 +242,7 @@ export function CoursesPage() {
return ( return (
<div className="flex flex-col items-center justify-center py-32"> <div className="flex flex-col items-center justify-center py-32">
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" /> <img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
{/* <div className="rounded-2xl bg-white shadow-sm p-6"> <p className="mt-4 text-sm font-medium text-grayScale-400">Loading courses...</p>
<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> </div>
) )
} }
@ -421,7 +411,7 @@ export function CoursesPage() {
{/* Add Course Modal */} {/* Add Course Modal */}
{showModal && ( {showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-2xl animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl"> <div className="mx-4 w-full max-w-md animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
<h2 className="text-lg font-bold text-grayScale-700">Add New Course</h2> <h2 className="text-lg font-bold text-grayScale-700">Add New Course</h2>
<button <button
@ -472,29 +462,6 @@ export function CoursesPage() {
/> />
</div> </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"> <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> Category: <span className="font-semibold text-grayScale-600">{category?.name}</span>
</div> </div>

View File

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

View File

@ -20,7 +20,6 @@ import {
CheckCircle2, CheckCircle2,
XCircle, XCircle,
ArrowUpCircle, ArrowUpCircle,
MessageCircle,
} from "lucide-react"; } from "lucide-react";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
@ -202,12 +201,6 @@ export function IssuesPage() {
// Status update // Status update
const [statusUpdating, setStatusUpdating] = useState<number | null>(null); 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 () => { const fetchIssues = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
@ -352,26 +345,17 @@ export function IssuesPage() {
Review and manage user-reported issues across the platform. Review and manage user-reported issues across the platform.
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <Button
<Button variant="outline"
variant="outline" className="gap-2"
className="gap-2" onClick={() => {
onClick={() => { setPage(1);
setPage(1); fetchIssues();
fetchIssues(); }}
}} >
> <RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} /> Refresh
Refresh </Button>
</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> </div>
{/* Stats cards */} {/* Stats cards */}
@ -528,7 +512,7 @@ export function IssuesPage() {
<TableRow key={issue.id} className="group"> <TableRow key={issue.id} className="group">
<TableCell> <TableCell>
<div className="flex items-start gap-3 max-w-[300px]"> <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-500 group-hover:text-white 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-50 group-hover:text-brand-500 transition-colors">
<TypeIcon className="h-4 w-4" /> <TypeIcon className="h-4 w-4" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
@ -856,90 +840,6 @@ export function IssuesPage() {
</DialogContent> </DialogContent>
</Dialog> </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 */} {/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="max-w-sm"> <DialogContent className="max-w-sm">

View File

@ -19,23 +19,10 @@ import {
Mail, Mail,
CheckCheck, CheckCheck,
MailX, MailX,
Search,
} from "lucide-react" } from "lucide-react"
import { Card, CardContent } from "../../components/ui/card" import { Card, CardContent } from "../../components/ui/card"
import { Badge } from "../../components/ui/badge" import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button" 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 { cn } from "../../lib/utils"
import { import {
getNotifications, getNotifications,
@ -45,9 +32,7 @@ import {
markAllRead, markAllRead,
markAllUnread, markAllUnread,
} from "../../api/notifications.api" } from "../../api/notifications.api"
import { getTeamMembers } from "../../api/team.api"
import type { Notification } from "../../types/notification.types" import type { Notification } from "../../types/notification.types"
import type { TeamMember } from "../../types/team.types"
const PAGE_SIZE = 10 const PAGE_SIZE = 10
@ -241,25 +226,6 @@ export function NotificationsPage() {
const [error, setError] = useState(false) const [error, setError] = useState(false)
const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set()) const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set())
const [bulkLoading, setBulkLoading] = useState(false) 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) => { const fetchData = useCallback(async (currentOffset: number) => {
setLoading(true) setLoading(true)
@ -334,178 +300,59 @@ export function NotificationsPage() {
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
const currentPage = Math.floor(offset / PAGE_SIZE) + 1 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 ( return (
<div className="mx-auto w-full max-w-6xl"> <div className="mx-auto w-full max-w-3xl">
{/* Header */} {/* Header */}
<div className="mb-5"> <div className="mb-5">
<div className="mb-1 text-sm font-semibold text-grayScale-500">Notifications</div> <div className="mb-1 text-sm font-semibold text-grayScale-500">Notifications</div>
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">Notifications</h1> <h1 className="text-2xl font-semibold tracking-tight">Notifications</h1>
{totalCount > 0 && <Badge variant="secondary">{totalCount}</Badge>} {totalCount > 0 && (
{globalUnread > 0 && <Badge variant="default">{globalUnread} unread</Badge>} <Badge variant="secondary">{totalCount}</Badge>
)}
{globalUnread > 0 && (
<Badge variant="default">{globalUnread} unread</Badge>
)}
</div> </div>
{/* Bulk actions */} {/* Bulk actions */}
{!loading && !error && ( {!loading && !error && notifications.length > 0 && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button {globalUnread > 0 ? (
size="sm" <Button
className="bg-brand-500 text-white hover:bg-brand-600" variant="outline"
onClick={() => setComposeOpen(true)} size="sm"
> disabled={bulkLoading}
<Megaphone className="mr-2 h-3.5 w-3.5" /> onClick={handleMarkAllRead}
New notification >
</Button> {bulkLoading ? (
{notifications.length > 0 && ( <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
<>
{globalUnread > 0 ? (
<Button
variant="outline"
size="sm"
disabled={bulkLoading}
onClick={handleMarkAllRead}
>
{bulkLoading ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<CheckCheck className="mr-2 h-3.5 w-3.5" />
)}
Mark all read
</Button>
) : ( ) : (
<Button <CheckCheck className="mr-2 h-3.5 w-3.5" />
variant="outline"
size="sm"
disabled={bulkLoading}
onClick={handleMarkAllUnread}
>
{bulkLoading ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<MailX className="mr-2 h-3.5 w-3.5" />
)}
Mark all unread
</Button>
)} )}
</> Mark all read
</Button>
) : (
<Button
variant="outline"
size="sm"
disabled={bulkLoading}
onClick={handleMarkAllUnread}
>
{bulkLoading ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<MailX className="mr-2 h-3.5 w-3.5" />
)}
Mark all unread
</Button>
)} )}
</div> </div>
)} )}
</div> </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 */}
{loading && ( {loading && (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
@ -534,220 +381,26 @@ export function NotificationsPage() {
<BellOff className="h-7 w-7 text-grayScale-400" /> <BellOff className="h-7 w-7 text-grayScale-400" />
</div> </div>
<span className="text-sm font-medium text-grayScale-500">No notifications yet</span> <span className="text-sm font-medium text-grayScale-500">No notifications yet</span>
<span className="text-xs text-grayScale-400"> <span className="text-xs text-grayScale-400">When you receive notifications, they'll appear here.</span>
When you receive notifications, they'll appear here.
</span>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Filters + table */} {/* Notification list */}
{!loading && !error && notifications.length > 0 && ( {!loading && !error && notifications.length > 0 && (
<> <>
{/* 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"> <Card className="shadow-none">
<CardContent className="p-0"> <CardContent className="divide-y-0 p-2">
<Table> <div className="space-y-1">
<TableHeader> {notifications.map((n) => (
<TableRow> <NotificationItem
<TableHead>Type</TableHead> key={n.id}
<TableHead>Title</TableHead> notification={n}
<TableHead className="hidden lg:table-cell">Message</TableHead> onToggleRead={handleToggleRead}
<TableHead>Channel</TableHead> toggling={togglingIds.has(n.id)}
<TableHead>Status</TableHead> />
<TableHead className="hidden sm:table-cell">Created</TableHead> ))}
<TableHead className="text-right">Actions</TableHead> </div>
</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> </CardContent>
</Card> </Card>
@ -782,297 +435,6 @@ 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> </div>
) )
} }

View File

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

View File

@ -405,7 +405,7 @@ export function UserLogPage() {
<TableRow key={log.id} className="group"> <TableRow key={log.id} className="group">
<TableCell> <TableCell>
<div className="flex items-center gap-2.5"> <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-500 group-hover:text-white 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-50 group-hover:text-brand-500 transition-colors">
<ActionIcon className="h-4 w-4" /> <ActionIcon className="h-4 w-4" />
</div> </div>
<span <span

View File

@ -1,62 +1,14 @@
import { useState } from "react"
import { ArrowLeft, FileText, Mail, Phone, Shield, User } from "lucide-react" import { ArrowLeft, FileText, Mail, Phone, Shield, User } from "lucide-react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Card } from "../../components/ui/card" import { Card } from "../../components/ui/card"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea" import { Textarea } from "../../components/ui/textarea"
import { Select } from "../../components/ui/select" import { Select } from "../../components/ui/select"
import { createUser } from "../../api/users.api"
export function RegisterUserPage() { export function RegisterUserPage() {
const navigate = useNavigate() 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -70,31 +22,21 @@ export function RegisterUserPage() {
</div> </div>
<Card className="mx-auto max-w-2xl p-6"> <Card className="mx-auto max-w-2xl p-6">
<form className="space-y-5" onSubmit={handleSubmit}> <form className="space-y-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div> <div>
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"> <label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<User className="h-4 w-4" /> <User className="h-4 w-4" />
First Name First Name
</label> </label>
<Input <Input placeholder="Enter first name" required />
placeholder="Enter first name"
required
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
</div> </div>
<div> <div>
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"> <label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<User className="h-4 w-4" /> <User className="h-4 w-4" />
Last Name Last Name
</label> </label>
<Input <Input placeholder="Enter last name" required />
placeholder="Enter last name"
required
value={lastName}
onChange={(e) => setLastName(e.target.value)}
/>
</div> </div>
</div> </div>
@ -103,13 +45,7 @@ export function RegisterUserPage() {
<Mail className="h-4 w-4" /> <Mail className="h-4 w-4" />
Email Email
</label> </label>
<Input <Input type="email" placeholder="Enter email address" required />
type="email"
placeholder="Enter email address"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div> </div>
<div> <div>
@ -117,13 +53,7 @@ export function RegisterUserPage() {
<Phone className="h-4 w-4" /> <Phone className="h-4 w-4" />
Phone Phone
</label> </label>
<Input <Input type="tel" placeholder="Enter phone number" required />
type="tel"
placeholder="Enter phone number"
required
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</div> </div>
<div> <div>
@ -131,14 +61,10 @@ export function RegisterUserPage() {
<Shield className="h-4 w-4" /> <Shield className="h-4 w-4" />
Role Role
</label> </label>
<Select <Select required>
required
value={role}
onChange={(e) => setRole(e.target.value)}
>
<option value="">Select role</option> <option value="">Select role</option>
<option value="ADMIN">Admin</option> <option value="admin">Admin</option>
<option value="USER">User</option> <option value="user">User</option>
</Select> </Select>
</div> </div>
@ -147,30 +73,15 @@ export function RegisterUserPage() {
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
Notes Notes
</label> </label>
<Textarea <Textarea placeholder="Enter any additional notes" rows={3} />
placeholder="Enter any additional notes"
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div> </div>
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end"> <div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
<Button <Button variant="outline" className="w-full sm:w-auto" onClick={() => navigate("/users")}>
type="button"
variant="outline"
className="w-full sm:w-auto"
onClick={() => navigate("/users")}
disabled={submitting}
>
Cancel Cancel
</Button> </Button>
<Button <Button type="submit" className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
type="submit" Register User
className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto"
disabled={submitting}
>
{submitting ? "Registering..." : "Register User"}
</Button> </Button>
</div> </div>
</form> </form>

View File

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

View File

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

View File

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