Compare commits
No commits in common. "95f5d37878cfeb9e88876fe6bfcc6f885317a6e0" and "28f7ac2dcd65a6e0db67b0eb57490f53dec01fe5" have entirely different histories.
95f5d37878
...
28f7ac2dcd
19
package-lock.json
generated
19
package-lock.json
generated
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 & 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>
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -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" },
|
||||||
|
|
|
||||||
|
|
@ -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 follow‑up surveys."
|
|
||||||
: step.type === "course"
|
|
||||||
? "Link or add an existing course to this flow."
|
|
||||||
: step.type === "speaking"
|
|
||||||
? "Speaking or oral practice section."
|
|
||||||
: step.type === "new_course"
|
|
||||||
? "Add a new course within this category."
|
|
||||||
: "Configure this step in the flow builder.")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
steps—order 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 category’s 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user