Compare commits
No commits in common. "main" and "front" have entirely different histories.
5
.env
5
.env
|
|
@ -1,3 +1,2 @@
|
||||||
# VITE_API_BASE_URL=https://api.yimaru.yaltopia.com/api/v1
|
VITE_API_BASE_URL= https://api.yimaru.yaltopia.com/api/v1
|
||||||
VITE_API_BASE_URL=http://localhost:8432/api/v1
|
VITE_GOOGLE_CLIENT_ID=google_client_id
|
||||||
VITE_GOOGLE_CLIENT_ID=
|
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -12,9 +12,6 @@ dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Environment files
|
|
||||||
.env
|
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|
|
||||||
56
package-lock.json
generated
56
package-lock.json
generated
|
|
@ -8,9 +8,6 @@
|
||||||
"name": "yimaru-admin",
|
"name": "yimaru-admin",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
|
@ -342,59 +339,6 @@
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@dnd-kit/accessibility": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@dnd-kit/core": {
|
|
||||||
"version": "6.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
|
||||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@dnd-kit/accessibility": "^3.1.1",
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"tslib": "^2.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.8.0",
|
|
||||||
"react-dom": ">=16.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@dnd-kit/sortable": {
|
|
||||||
"version": "10.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
|
||||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"tslib": "^2.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@dnd-kit/core": "^6.3.0",
|
|
||||||
"react": ">=16.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@dnd-kit/utilities": {
|
|
||||||
"version": "3.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
|
||||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.2",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,6 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import type {
|
||||||
CreatePracticeRequest,
|
CreatePracticeRequest,
|
||||||
UpdatePracticeRequest,
|
UpdatePracticeRequest,
|
||||||
UpdatePracticeStatusRequest,
|
UpdatePracticeStatusRequest,
|
||||||
|
GetPracticeQuestionsResponse,
|
||||||
CreatePracticeQuestionRequest,
|
CreatePracticeQuestionRequest,
|
||||||
UpdatePracticeQuestionRequest,
|
UpdatePracticeQuestionRequest,
|
||||||
GetProgramsResponse,
|
GetProgramsResponse,
|
||||||
|
|
@ -30,27 +31,13 @@ import type {
|
||||||
UpdateModuleRequest,
|
UpdateModuleRequest,
|
||||||
UpdateModuleStatusRequest,
|
UpdateModuleStatusRequest,
|
||||||
GetQuestionSetsResponse,
|
GetQuestionSetsResponse,
|
||||||
GetQuestionSetsParams,
|
|
||||||
GetQuestionSetDetailResponse,
|
|
||||||
GetQuestionSetQuestionsResponse,
|
|
||||||
CreateQuestionSetRequest,
|
CreateQuestionSetRequest,
|
||||||
CreateQuestionSetResponse,
|
CreateQuestionSetResponse,
|
||||||
AddQuestionToSetRequest,
|
AddQuestionToSetRequest,
|
||||||
CreateQuestionRequest,
|
CreateQuestionRequest,
|
||||||
CreateQuestionResponse,
|
CreateQuestionResponse,
|
||||||
GetQuestionDetailResponse,
|
|
||||||
GetQuestionsParams,
|
|
||||||
GetQuestionsResponse,
|
|
||||||
CreateVimeoVideoRequest,
|
CreateVimeoVideoRequest,
|
||||||
CreateCourseCategoryRequest,
|
CreateCourseCategoryRequest,
|
||||||
GetSubCoursePrerequisitesResponse,
|
|
||||||
AddSubCoursePrerequisiteRequest,
|
|
||||||
GetLearningPathResponse,
|
|
||||||
GetSubCourseEntryAssessmentResponse,
|
|
||||||
ReorderItem,
|
|
||||||
GetRatingsResponse,
|
|
||||||
GetRatingsParams,
|
|
||||||
GetVimeoSampleResponse,
|
|
||||||
} from "../types/course.types"
|
} from "../types/course.types"
|
||||||
|
|
||||||
export const getCourseCategories = () =>
|
export const getCourseCategories = () =>
|
||||||
|
|
@ -104,11 +91,8 @@ export const deleteSubCourseVideo = (videoId: number) =>
|
||||||
http.delete(`/course-management/sub-course-videos/${videoId}`)
|
http.delete(`/course-management/sub-course-videos/${videoId}`)
|
||||||
|
|
||||||
// Practice APIs - for SubCourse practices (New Hierarchy)
|
// Practice APIs - for SubCourse practices (New Hierarchy)
|
||||||
// Practices are sourced from question sets by owner_type=SUB_COURSE.
|
|
||||||
export const getPracticesBySubCourse = (subCourseId: number) =>
|
export const getPracticesBySubCourse = (subCourseId: number) =>
|
||||||
http.get<GetQuestionSetsResponse>("/question-sets/by-owner", {
|
http.get<GetPracticesResponse>(`/course-management/sub-courses/${subCourseId}/practices`)
|
||||||
params: { owner_type: "SUB_COURSE", owner_id: subCourseId },
|
|
||||||
})
|
|
||||||
|
|
||||||
export const createPractice = (data: CreatePracticeRequest) =>
|
export const createPractice = (data: CreatePracticeRequest) =>
|
||||||
http.post("/course-management/practices", data)
|
http.post("/course-management/practices", data)
|
||||||
|
|
@ -124,7 +108,7 @@ export const deletePractice = (practiceId: number) =>
|
||||||
|
|
||||||
// Practice Questions APIs
|
// Practice Questions APIs
|
||||||
export const getPracticeQuestions = (practiceId: number) =>
|
export const getPracticeQuestions = (practiceId: number) =>
|
||||||
http.get<GetQuestionSetQuestionsResponse>(`/question-sets/${practiceId}/questions`)
|
http.get<GetPracticeQuestionsResponse>(`/course-management/practices/${practiceId}/questions`)
|
||||||
|
|
||||||
export const createPracticeQuestion = (data: CreatePracticeQuestionRequest) =>
|
export const createPracticeQuestion = (data: CreatePracticeQuestionRequest) =>
|
||||||
http.post("/course-management/practice-questions", data)
|
http.post("/course-management/practice-questions", data)
|
||||||
|
|
@ -192,20 +176,11 @@ export const getPracticesByModule = (moduleId: number) =>
|
||||||
http.get<GetPracticesResponse>(`/course-management/modules/${moduleId}/practices`)
|
http.get<GetPracticesResponse>(`/course-management/modules/${moduleId}/practices`)
|
||||||
|
|
||||||
// Question Sets API
|
// Question Sets API
|
||||||
export const getQuestionSets = (params?: GetQuestionSetsParams) =>
|
|
||||||
http.get<GetQuestionSetsResponse>("/question-sets", { params })
|
|
||||||
|
|
||||||
export const getQuestionSetsByOwner = (ownerType: string, ownerId: number) =>
|
export const getQuestionSetsByOwner = (ownerType: string, ownerId: number) =>
|
||||||
http.get<GetQuestionSetsResponse>("/question-sets/by-owner", {
|
http.get<GetQuestionSetsResponse>("/question-sets/by-owner", {
|
||||||
params: { owner_type: ownerType, owner_id: ownerId },
|
params: { owner_type: ownerType, owner_id: ownerId },
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getQuestionSetById = (questionSetId: number) =>
|
|
||||||
http.get<GetQuestionSetDetailResponse>(`/question-sets/${questionSetId}`)
|
|
||||||
|
|
||||||
export const getQuestionSetQuestions = (questionSetId: number) =>
|
|
||||||
http.get<GetQuestionSetQuestionsResponse>(`/question-sets/${questionSetId}/questions`)
|
|
||||||
|
|
||||||
export const createQuestionSet = (data: CreateQuestionSetRequest) =>
|
export const createQuestionSet = (data: CreateQuestionSetRequest) =>
|
||||||
http.post<CreateQuestionSetResponse>("/question-sets", data)
|
http.post<CreateQuestionSetResponse>("/question-sets", data)
|
||||||
|
|
||||||
|
|
@ -215,85 +190,8 @@ export const addQuestionToSet = (questionSetId: number, data: AddQuestionToSetRe
|
||||||
export const createQuestion = (data: CreateQuestionRequest) =>
|
export const createQuestion = (data: CreateQuestionRequest) =>
|
||||||
http.post<CreateQuestionResponse>("/questions", data)
|
http.post<CreateQuestionResponse>("/questions", data)
|
||||||
|
|
||||||
export const getQuestions = (params: GetQuestionsParams) =>
|
|
||||||
http.get<GetQuestionsResponse>("/questions", { params })
|
|
||||||
|
|
||||||
export const getQuestionById = (questionId: number) =>
|
|
||||||
http.get<GetQuestionDetailResponse>(`/questions/${questionId}`)
|
|
||||||
|
|
||||||
export const deleteQuestion = (questionId: number) =>
|
|
||||||
http.delete(`/questions/${questionId}`)
|
|
||||||
|
|
||||||
export const updateQuestion = (questionId: number, data: CreateQuestionRequest) =>
|
|
||||||
http.put(`/questions/${questionId}`, data)
|
|
||||||
|
|
||||||
export const deleteQuestionSet = (questionSetId: number) =>
|
export const deleteQuestionSet = (questionSetId: number) =>
|
||||||
http.delete(`/question-sets/${questionSetId}`)
|
http.delete(`/question-sets/${questionSetId}`)
|
||||||
|
|
||||||
export const createVimeoVideo = (data: CreateVimeoVideoRequest) =>
|
export const createVimeoVideo = (data: CreateVimeoVideoRequest) =>
|
||||||
http.post("/course-management/videos/vimeo", data)
|
http.post("/course-management/videos/vimeo", data)
|
||||||
|
|
||||||
// Sub-course Prerequisite APIs
|
|
||||||
export const getSubCoursePrerequisites = (subCourseId: number) =>
|
|
||||||
http.get<GetSubCoursePrerequisitesResponse>(`/course-management/sub-courses/${subCourseId}/prerequisites`)
|
|
||||||
|
|
||||||
export const addSubCoursePrerequisite = (subCourseId: number, data: AddSubCoursePrerequisiteRequest) =>
|
|
||||||
http.post(`/course-management/sub-courses/${subCourseId}/prerequisites`, data)
|
|
||||||
|
|
||||||
export const removeSubCoursePrerequisite = (subCourseId: number, prerequisiteId: number) =>
|
|
||||||
http.delete(`/course-management/sub-courses/${subCourseId}/prerequisites/${prerequisiteId}`)
|
|
||||||
|
|
||||||
// Learning Path APIs
|
|
||||||
export const getLearningPath = (courseId: number) =>
|
|
||||||
http.get<GetLearningPathResponse>(`/course-management/courses/${courseId}/learning-path`)
|
|
||||||
|
|
||||||
export const getSubCourseEntryAssessment = (subCourseId: number) =>
|
|
||||||
http.get<GetSubCourseEntryAssessmentResponse>(
|
|
||||||
`/question-sets/sub-courses/${subCourseId}/entry-assessment`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const buildReorderPayload = (items: ReorderItem[]) => {
|
|
||||||
const normalized = items.map((item, idx) => ({
|
|
||||||
id: Number(item.id),
|
|
||||||
position: Number(item.position ?? idx),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const hasInvalid = normalized.some(
|
|
||||||
(item) =>
|
|
||||||
Number.isNaN(item.id) ||
|
|
||||||
Number.isNaN(item.position) ||
|
|
||||||
!Number.isFinite(item.id) ||
|
|
||||||
!Number.isFinite(item.position),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (hasInvalid) {
|
|
||||||
throw new Error("Invalid reorder payload: ids/positions must be numeric.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return { items: normalized }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const reorderCategories = (items: ReorderItem[]) =>
|
|
||||||
http.put("/course-management/categories/reorder", buildReorderPayload(items))
|
|
||||||
|
|
||||||
export const reorderCourses = (items: ReorderItem[]) =>
|
|
||||||
http.put("/course-management/courses/reorder", buildReorderPayload(items))
|
|
||||||
|
|
||||||
export const reorderSubCourses = (items: ReorderItem[]) =>
|
|
||||||
http.put("/course-management/sub-courses/reorder", buildReorderPayload(items))
|
|
||||||
|
|
||||||
export const reorderVideos = (items: ReorderItem[]) =>
|
|
||||||
http.put("/course-management/videos/reorder", buildReorderPayload(items))
|
|
||||||
|
|
||||||
export const reorderPractices = (items: ReorderItem[]) =>
|
|
||||||
http.put("/course-management/practices/reorder", buildReorderPayload(items))
|
|
||||||
|
|
||||||
// Ratings
|
|
||||||
export const getRatings = (params: GetRatingsParams) =>
|
|
||||||
http.get<GetRatingsResponse>("/ratings", { params })
|
|
||||||
|
|
||||||
// Vimeo Sample Video
|
|
||||||
export const getVimeoSample = (videoId: string, width = 640, height = 360) =>
|
|
||||||
http.get<GetVimeoSampleResponse>("/vimeo/sample", {
|
|
||||||
params: { video_id: videoId, width, height },
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,6 @@ import type {
|
||||||
IssueFilters,
|
IssueFilters,
|
||||||
} from "../types/issue.types";
|
} from "../types/issue.types";
|
||||||
|
|
||||||
import type { CreateIssueRequest, CreateIssueResponse } from "../types/issue.types";
|
|
||||||
|
|
||||||
export const getIssues = (filters?: IssueFilters) =>
|
export const getIssues = (filters?: IssueFilters) =>
|
||||||
http.get<GetIssuesResponse>("/issues", {
|
http.get<GetIssuesResponse>("/issues", {
|
||||||
params: filters,
|
params: filters,
|
||||||
|
|
@ -20,9 +18,6 @@ export const getIssuesByUserId = (userId: number) =>
|
||||||
export const getIssueById = (id: number) =>
|
export const getIssueById = (id: number) =>
|
||||||
http.get<GetIssueResponse>(`/issues/${id}`);
|
http.get<GetIssueResponse>(`/issues/${id}`);
|
||||||
|
|
||||||
export const createIssue = (payload: CreateIssueRequest) =>
|
|
||||||
http.post<CreateIssueResponse>("/issues", payload);
|
|
||||||
|
|
||||||
export const updateIssueStatus = (id: number, status: string) =>
|
export const updateIssueStatus = (id: number, status: string) =>
|
||||||
http.patch<UpdateIssueStatusResponse>(`/issues/${id}/status`, { status });
|
http.patch<UpdateIssueStatusResponse>(`/issues/${id}/status`, { status });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,16 +20,3 @@ export const markAllRead = () =>
|
||||||
|
|
||||||
export const markAllUnread = () =>
|
export const markAllUnread = () =>
|
||||||
http.post("/notifications/mark-all-unread");
|
http.post("/notifications/mark-all-unread");
|
||||||
|
|
||||||
export const sendBulkSms = (data: { message: string; user_ids: number[]; scheduled_at?: string }) =>
|
|
||||||
http.post("/notifications/bulk-sms", data);
|
|
||||||
|
|
||||||
export const sendBulkEmail = (formData: FormData) =>
|
|
||||||
http.post("/notifications/bulk-email", formData, {
|
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
|
||||||
});
|
|
||||||
|
|
||||||
export const sendBulkPush = (formData: FormData) =>
|
|
||||||
http.post("/notifications/bulk-push", formData, {
|
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import http from "./http"
|
|
||||||
import type {
|
|
||||||
LearnerCourseProgressResponse,
|
|
||||||
LearnerCourseProgressSummaryResponse,
|
|
||||||
} from "../types/progress.types"
|
|
||||||
|
|
||||||
export const getAdminLearnerCourseProgress = (userId: number, courseId: number) =>
|
|
||||||
http.get<LearnerCourseProgressResponse>(`/admin/users/${userId}/progress/courses/${courseId}`)
|
|
||||||
|
|
||||||
export const getAdminLearnerCourseProgressSummary = (userId: number, courseId: number) =>
|
|
||||||
http.get<LearnerCourseProgressSummaryResponse>(
|
|
||||||
`/admin/users/${userId}/progress/courses/${courseId}/summary`,
|
|
||||||
)
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import http from "./http"
|
|
||||||
import type {
|
|
||||||
GetRolesResponse,
|
|
||||||
GetRoleDetailResponse,
|
|
||||||
GetRolesParams,
|
|
||||||
CreateRoleRequest,
|
|
||||||
CreateRoleResponse,
|
|
||||||
SetRolePermissionsRequest,
|
|
||||||
GetPermissionsResponse,
|
|
||||||
} from "../types/rbac.types"
|
|
||||||
|
|
||||||
export const getRoles = (params?: GetRolesParams) =>
|
|
||||||
http.get<GetRolesResponse>("/rbac/roles", { params })
|
|
||||||
|
|
||||||
export const getRoleDetail = (roleId: number) =>
|
|
||||||
http.get<GetRoleDetailResponse>(`/rbac/roles/${roleId}`)
|
|
||||||
|
|
||||||
export const createRole = (data: CreateRoleRequest) =>
|
|
||||||
http.post<CreateRoleResponse>("/rbac/roles", data)
|
|
||||||
|
|
||||||
export const updateRole = (roleId: number, data: CreateRoleRequest) =>
|
|
||||||
http.put<CreateRoleResponse>(`/rbac/roles/${roleId}`, data)
|
|
||||||
|
|
||||||
export const setRolePermissions = (roleId: number, data: SetRolePermissionsRequest) =>
|
|
||||||
http.put(`/rbac/roles/${roleId}/permissions`, data)
|
|
||||||
|
|
||||||
export const getAllPermissions = () =>
|
|
||||||
http.get<GetPermissionsResponse>("/rbac/permissions")
|
|
||||||
|
|
@ -14,6 +14,3 @@ export const getTeamMemberById = (id: number) =>
|
||||||
|
|
||||||
export const createTeamMember = (data: CreateTeamMemberRequest) =>
|
export const createTeamMember = (data: CreateTeamMemberRequest) =>
|
||||||
http.post("/team/register", data)
|
http.post("/team/register", data)
|
||||||
|
|
||||||
export const updateTeamMemberStatus = (id: number, status: string) =>
|
|
||||||
http.patch(`/team/members/${id}/status`, { status })
|
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,14 @@
|
||||||
import http from "./http";
|
import http from "./http";
|
||||||
import { type UserProfileResponse, type GetUsersResponse, type UpdateProfileRequest, type UserSummaryResponse } from "../types/user.types";
|
import { type UserProfileResponse, type GetUsersResponse } from "../types/user.types";
|
||||||
|
|
||||||
export const getUsers = (
|
export const getUsers = (page?: number, pageSize?: number) =>
|
||||||
page?: number,
|
|
||||||
pageSize?: number,
|
|
||||||
role?: string,
|
|
||||||
status?: string,
|
|
||||||
query?: string,
|
|
||||||
) =>
|
|
||||||
http.get<GetUsersResponse>("/users", {
|
http.get<GetUsersResponse>("/users", {
|
||||||
params: {
|
params: {
|
||||||
role,
|
|
||||||
status,
|
|
||||||
query,
|
|
||||||
page,
|
page,
|
||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UserStatus = "ACTIVE" | "DEACTIVATED" | "SUSPENDED" | "PENDING";
|
|
||||||
|
|
||||||
export interface UpdateUserStatusRequest {
|
|
||||||
user_id: number;
|
|
||||||
status: UserStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateUserStatus = (payload: UpdateUserStatusRequest) =>
|
|
||||||
http.patch("/user/status", payload);
|
|
||||||
|
|
||||||
export const getUserById = (id: number) =>
|
export const getUserById = (id: number) =>
|
||||||
http.get<UserProfileResponse>(`/user/single/${id}`);
|
http.get<UserProfileResponse>(`/user/single/${id}`);
|
||||||
|
|
||||||
|
|
@ -47,9 +28,3 @@ export interface CreateUserRequest {
|
||||||
|
|
||||||
export const createUser = (payload: CreateUserRequest) =>
|
export const createUser = (payload: CreateUserRequest) =>
|
||||||
http.post("/users", payload);
|
http.post("/users", payload);
|
||||||
|
|
||||||
export const updateProfile = (data: UpdateProfileRequest) =>
|
|
||||||
http.put<UserProfileResponse>("/user", data);
|
|
||||||
|
|
||||||
export const getUserSummary = () =>
|
|
||||||
http.get<UserSummaryResponse>("/users/summary");
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<svg width="114" height="114" viewBox="0 0 114 114" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M66.1428 99.75H47.8572C25.8626 99.75 14.8654 99.75 10.8127 92.596C6.76008 85.4425 12.385 75.9591 23.6348 56.9929L32.7777 41.5783C43.5841 23.3595 48.9872 14.25 57 14.25C65.0128 14.25 70.4159 23.3594 81.2222 41.5783L90.3654 56.9929C101.615 75.9591 107.24 85.4425 103.187 92.596C99.1344 99.75 88.1372 99.75 66.1428 99.75Z" stroke="#9E2891" stroke-width="7.125" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M57 42.75V64.125" stroke="#9E2891" stroke-width="7.125" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M57 80.713V80.7604" stroke="#9E2891" stroke-width="7.125" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 753 B |
|
|
@ -1,4 +0,0 @@
|
||||||
<svg width="114" height="115" viewBox="0 0 114 115" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M108.361 64.9546C111.363 65.9012 113.03 69.1024 112.083 72.1048L110.663 76.6103L108.759 81.2049L106.463 85.616L103.791 89.8103L100.764 93.7557L97.404 97.4222L93.7374 100.782L89.792 103.809L85.5977 106.481L81.4073 108.663C78.615 110.116 75.173 109.031 73.7194 106.239C72.2658 103.447 73.3511 100.005 76.1434 98.5509L79.8923 96.5994L83.247 94.4622L86.4027 92.0408L89.3353 89.3536L92.0225 86.4209L94.4439 83.2653L96.5811 79.9106L98.4177 76.3824L99.9399 72.7076L101.211 68.6767C102.157 65.6744 105.359 64.0079 108.361 64.9546Z" fill="#9E2891"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M108.851 53.7714C105.777 54.4527 102.733 52.5136 102.052 49.4402L101.137 45.3139L99.9412 41.5204L98.4191 37.8456L96.5824 34.3174L94.4452 30.9627L92.0238 27.807L89.3366 24.8744L86.404 22.1872L83.2483 19.7658L79.8936 17.6286L76.3654 15.7919L72.6906 14.2698L68.8971 13.0737L65.0138 12.2128L61.0702 11.6936L57.0963 11.5201L53.1225 11.6936L49.1789 12.2128L45.2956 13.0737L41.5021 14.2698L37.8272 15.792L34.2991 17.6286L30.9444 19.7658L27.7887 22.1872L24.8561 24.8744L22.1689 27.807L19.7475 30.9627L17.6103 34.3173L15.7736 37.8456L14.2515 41.5203L13.0554 45.3139L12.1945 49.1972L11.6753 53.1408L11.5018 57.1147L11.6753 61.0885L12.1945 65.0321L13.0554 68.9154L14.2515 72.7089L15.7736 76.3837L17.6103 79.912L19.7475 83.2666L22.1689 86.4223L24.8561 89.3549L27.7887 92.0421L30.9443 94.4635L34.2991 96.6008L37.8273 98.4374L41.502 99.9595L45.2956 101.156L49.1789 102.017L53.1225 102.536L57.345 102.72C60.49 102.857 62.9282 105.518 62.7909 108.663C62.6536 111.808 59.9927 114.247 56.8477 114.109L52.128 113.903L47.1975 113.254L42.3423 112.178L37.5993 110.682L33.0048 108.779L28.5936 106.483L24.3994 103.811L20.454 100.783L16.7874 97.4236L13.4277 93.7571L10.4002 89.8116L7.7282 85.6174L5.43189 81.2062L3.52878 76.6117L2.03334 71.8687L0.956956 67.0136L0.307837 62.083L0.0909175 57.1147L0.307857 52.1463L0.95697 47.2158L2.03335 42.3606L3.52876 37.6177L5.43188 33.0231L7.7282 28.6119L10.4002 24.4177L13.4277 20.4723L16.7874 16.8057L20.454 13.446L24.3994 10.4185L28.5936 7.7465L33.0048 5.45019L37.5993 3.54707L42.3423 2.05164L47.1975 0.975266L52.128 0.326146L57.0964 0.109224L62.0647 0.326146L66.9952 0.975265L71.8504 2.05164L76.5934 3.54707L81.1879 5.45019L85.5991 7.74651L89.7933 10.4185L93.7387 13.446L97.4053 16.8057L100.765 20.4723L103.792 24.4177L106.465 28.6119L108.761 33.0231L110.664 37.6177L112.159 42.3606L113.182 46.9728C113.863 50.0462 111.924 53.09 108.851 53.7714Z" fill="#9E2891" fill-opacity="0.2"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.6 KiB |
|
|
@ -1,8 +0,0 @@
|
||||||
<svg width="170" height="196" viewBox="0 0 170 196" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M117.067 182.177C115.877 197.212 110.275 195.264 85.2333 195.212C84.4448 195.21 75.1908 196.597 70.8903 191.049C65.6205 184.25 72.0278 153.148 66.6476 148.541C35.4189 121.795 4.51062 115.662 0.0823302 67.1799C-1.67884 47.8957 25.4175 31.9131 23.7554 61.6575C23.719 62.3058 22.9739 65.1635 25.0985 62.8067C29.2141 58.2412 41.0922 63.3382 31.2926 74.5349C28.6322 77.5748 24.8148 77.7486 29.7086 86.6516C31.7965 90.4499 32.1575 90.3838 32.4261 90.1526C41.4869 82.3661 45.6132 74.0124 53.4047 80.4715C54.0587 81.0137 84.7318 107.292 87.455 109.625C94.8854 115.991 95.9815 110.8 108.517 100.555C133.126 80.4434 138.456 71.3292 144.851 76.8954C171.122 99.7616 171.024 101.369 169.414 104.755C166.698 110.466 118.649 145.024 117.265 151.52C117.004 152.747 117.06 155.903 117.067 182.176V182.177Z" fill="#9E2891"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M95.9691 7.04231C107.402 12.0196 133.385 46.145 132.81 49.3317C132.545 50.7992 131.661 50.4639 115.134 43.4362C88.5694 32.1406 77.2952 56.2361 54.3034 49.0066C52.8236 48.5409 50.3187 46.5017 62.8984 31.6198C77.3155 14.5642 81.6895 4.59241 95.9691 7.04231Z" fill="#9E2891"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M104.13 80.7417C101.825 93.3925 78.112 87.9719 81.2646 79.2604C83.4853 73.1244 89.8626 86.8841 97.1122 80.5444C97.7996 79.9435 102.375 73.8938 104.13 80.7417Z" fill="#9E2891"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M67.3682 66.1875C67.5867 65.3843 69.8818 56.9538 76.5731 61.3381C85.3369 67.0807 70.2095 79.6629 67.3682 66.1875Z" fill="#9E2891"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M106.098 66.1791C106.299 65.3769 108.584 56.2798 115.233 61.3404C123.931 67.9593 108.563 79.161 106.098 66.1791Z" fill="#9E2891"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M89.5097 3.27682C89.8473 3.71664 89.8388 3.7058 90.174 4.14097C91.8303 6.28896 92.274 4.5622 94.3639 1.95891C96.0729 -0.16895 96.6436 -0.723371 98.6653 1.09553C100.365 2.62483 99.0602 3.86144 97.4946 5.84759C96.4128 7.21893 96.3423 7.28397 96.4213 7.96074C96.4988 8.62279 96.7775 8.4323 98.8574 9.274C105.076 11.7913 99.5535 16.8431 88.6765 15.9038C82.979 15.4121 79.9885 12.8584 81.4551 10.8831C83.1269 8.63131 86.2172 9.03241 86.4046 7.96926C86.5788 6.97811 85.8719 6.46241 85.0635 5.44804C82.9534 2.79983 82.852 2.17959 84.6577 0.764118C86.7856 -0.903788 87.5932 0.850068 89.5097 3.27682Z" fill="#9E2891"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.5 KiB |
|
|
@ -1,8 +0,0 @@
|
||||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M63.9919 0C60.8419 0 57.6937 1.20941 55.3022 3.60086L50.6045 8.29848C46.3616 12.5413 40.6045 14.9155 34.6041 14.9155H27.1926C20.4286 14.9155 14.902 20.4419 14.902 27.206V32.6496V34.6214C14.902 40.6217 12.5277 46.3747 8.28487 50.6175L3.58719 55.3152C-1.19573 60.0981 -1.19573 67.9196 3.58719 72.7025L8.28487 77.4001C12.5277 81.643 14.902 87.3919 14.902 93.3922V100.808C14.902 107.572 20.4286 113.098 27.1926 113.098H34.6041C40.6045 113.098 46.3616 115.476 50.6045 119.719L55.3022 124.413C60.0851 129.196 67.8988 129.196 72.6817 124.413L77.3834 119.719C81.6263 115.476 87.3795 113.098 93.3798 113.098H100.791C107.555 113.098 113.082 107.572 113.082 100.808V93.3922C113.082 87.3919 115.476 81.643 119.719 77.4001L124.413 72.7025C129.196 67.9196 129.196 60.0981 124.413 55.3152L119.719 50.6175C115.476 46.3746 113.082 40.6217 113.082 34.6214V27.206C113.082 20.4419 107.555 14.9155 100.791 14.9155H93.3798C87.3795 14.9155 81.6263 12.5414 77.3834 8.29848L72.6817 3.60086C70.2902 1.20941 67.142 7.80299e-06 63.9919 0Z" fill="#9E2891" fill-opacity="0.2"/>
|
|
||||||
<path d="M63.9929 8C61.2366 8 58.482 9.05824 56.3894 11.1508L52.2789 15.2612C48.5664 18.9737 43.5289 21.051 38.2786 21.051H31.7936C25.875 21.051 21.0393 25.8867 21.0393 31.8052V36.5684V38.2937C21.0393 43.544 18.9618 48.5778 15.2493 52.2903L11.1388 56.4008C6.95374 60.5858 6.95374 67.4297 11.1388 71.6147L15.2493 75.7251C18.9618 79.4376 21.0393 84.4679 21.0393 89.7182V96.2067C21.0393 102.125 25.875 106.961 31.7936 106.961H38.2786C43.5289 106.961 48.5664 109.042 52.2789 112.754L56.3894 116.861C60.5745 121.046 67.4115 121.046 71.5965 116.861L75.7105 112.754C79.423 109.042 84.457 106.961 89.7073 106.961H96.1924C102.111 106.961 106.947 102.125 106.947 96.2067V89.7182C106.947 84.4679 109.042 79.4376 112.754 75.7251L116.861 71.6147C121.046 67.4297 121.046 60.5859 116.861 56.4008L112.754 52.2903C109.042 48.5778 106.947 43.544 106.947 38.2937V31.8052C106.947 25.8867 102.111 21.051 96.1924 21.051H89.7073C84.457 21.051 79.423 18.9737 75.7105 15.2612L71.5965 11.1508C69.504 9.05823 66.7492 8.00001 63.9929 8Z" fill="#9E2891"/>
|
|
||||||
<path d="M56.417 54.2498C56.417 52.056 58.3571 49.9165 60.7503 49.9165C63.1436 49.9165 65.0837 51.6949 65.0837 53.8887C65.0837 54.6795 64.8317 55.4163 64.397 56.0353C63.102 57.8802 60.7503 59.6394 60.7503 61.8332" stroke="white" stroke-width="3.25" stroke-linecap="round"/>
|
|
||||||
<path d="M60.75 67.25H60.7695" stroke="white" stroke-width="3.25" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M55.333 80.25C57.6088 82.1344 60.3478 83.3349 63.4873 83.5407C65.9609 83.703 68.5434 83.7028 71.0121 83.5407C71.8621 83.4851 72.7888 83.2848 73.5867 82.9605C74.4744 82.5997 74.9186 82.4193 75.1444 82.4466C75.3699 82.4739 75.6973 82.7122 76.3521 83.1887C77.5065 84.0287 78.9607 84.6321 81.1174 84.5803C82.2079 84.5541 82.7533 84.5409 82.9972 84.1303C83.2414 83.7195 82.9374 83.151 82.3292 82.0139C81.486 80.4368 80.9517 78.6313 81.7614 77.1846C83.1558 75.1182 84.3401 72.6712 84.5132 70.0283C84.6062 68.6083 84.6062 67.1375 84.5132 65.7175C84.398 63.9564 83.9705 62.2825 83.2856 60.75" stroke="white" stroke-width="3.25" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M64.5127 74.8739C71.7633 74.3983 77.5387 68.6181 78.0139 61.3615C78.1068 59.9415 78.1068 58.4707 78.0139 57.0506C77.5387 49.794 71.7633 44.0137 64.5127 43.5381C62.0391 43.3758 59.4565 43.3761 56.988 43.5381C49.7374 44.0137 43.962 49.794 43.4867 57.0506C43.3937 58.4707 43.3937 59.9415 43.4867 61.3615C43.6598 64.0044 44.8443 66.4514 46.2387 68.5178C47.0483 69.9645 46.514 71.7699 45.6707 73.3471C45.0626 74.4841 44.7586 75.0527 45.0027 75.4635C45.2468 75.874 45.7921 75.8873 46.8827 75.9135C49.0393 75.9653 50.4937 75.3618 51.648 74.5218C52.3028 74.0454 52.6301 73.807 52.8558 73.7797C53.0814 73.7524 53.5254 73.9329 54.4133 74.2937C55.2113 74.618 56.1379 74.8182 56.988 74.8739C59.4565 75.036 62.0391 75.0362 64.5127 74.8739Z" stroke="white" stroke-width="3.25" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
|
|
@ -1,24 +0,0 @@
|
||||||
<svg width="524" height="173" viewBox="0 0 524 173" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g clip-path="url(#clip0_23_9014)">
|
|
||||||
<path d="M194.31 28.74L206.48 51L219.27 28.76H232.21L212.29 62.86V83H200.67V62.84L180.75 28.74H194.31Z" fill="white"/>
|
|
||||||
<path d="M247.79 32.69C247.809 33.51 247.661 34.3253 247.353 35.0855C247.045 35.8457 246.584 36.5347 246 37.11C245.412 37.6963 244.71 38.1565 243.938 38.4625C243.166 38.7686 242.34 38.914 241.51 38.89C240.677 38.907 239.85 38.7584 239.076 38.4528C238.302 38.1472 237.596 37.6908 237 37.11C236.41 36.5373 235.944 35.8494 235.631 35.0891C235.317 34.3287 235.164 33.5122 235.18 32.69C235.159 31.8731 235.31 31.0609 235.623 30.3062C235.937 29.5516 236.406 28.8714 237 28.31C237.604 27.7351 238.317 27.2854 239.096 26.9867C239.875 26.6881 240.706 26.5465 241.54 26.57C242.362 26.5477 243.18 26.6902 243.946 26.989C244.712 27.2879 245.41 27.737 246 28.31C246.586 28.8756 247.048 29.5572 247.356 30.3113C247.665 31.0655 247.812 31.8756 247.79 32.69ZM246.93 43.15V83H236V43.15H246.93Z" fill="white"/>
|
|
||||||
<path d="M316.92 61.13V83.07H306V60.74C306 54.7 303.7 51.68 299.1 51.68C298.017 51.641 296.939 51.8473 295.947 52.2837C294.954 52.7201 294.074 53.3752 293.37 54.2C291.819 56.1915 291.048 58.6803 291.2 61.2V83.07H280.26V60.74C280.26 54.7 277.927 51.68 273.26 51.68C272.176 51.6412 271.098 51.855 270.11 52.3045C269.123 52.754 268.253 53.4269 267.57 54.27C266.061 56.2816 265.304 58.7583 265.43 61.27V83.07H254.53V43.15H264.06L265.06 48.15C266.341 46.4745 267.981 45.1076 269.86 44.15C272.055 43.1452 274.447 42.643 276.86 42.68C282.54 42.68 286.494 44.9267 288.72 49.42C290.134 47.2728 292.099 45.5458 294.41 44.42C296.907 43.2298 299.645 42.6342 302.41 42.68C304.364 42.6151 306.308 42.9668 308.115 43.7116C309.922 44.4565 311.55 45.5774 312.89 47C315.57 49.8067 316.914 54.5167 316.92 61.13Z" fill="white"/>
|
|
||||||
<path d="M363 83H357.42C352.4 83 349.944 80.83 350.05 76.49C348.731 78.6384 346.895 80.4226 344.71 81.68C342.347 82.9271 339.701 83.5403 337.03 83.46C332.59 83.46 329.014 82.44 326.3 80.4C324.968 79.4019 323.9 78.0925 323.19 76.5864C322.481 75.0802 322.151 73.4231 322.23 71.76C322.153 69.8652 322.549 67.9809 323.382 66.2771C324.214 64.5733 325.458 63.1034 327 62C330.12 59.7 334.654 58.55 340.6 58.55H348.51V56.56C348.542 55.736 348.377 54.9164 348.029 54.1687C347.681 53.4211 347.161 52.7668 346.51 52.26C344.913 51.1085 342.966 50.5465 341 50.67C339.238 50.5873 337.492 51.0481 336 51.99C335.383 52.3861 334.857 52.9079 334.455 53.5213C334.054 54.1348 333.786 54.8261 333.67 55.55H323.19C323.3 53.6561 323.848 51.8134 324.79 50.1668C325.732 48.5201 327.043 47.1144 328.62 46.06C331.874 43.8133 336.187 42.69 341.56 42.69C347.187 42.69 351.54 43.93 354.62 46.41C357.7 48.89 359.237 52.48 359.23 57.18V71.18C359.19 71.5381 359.224 71.9007 359.331 72.2449C359.438 72.5891 359.614 72.9075 359.85 73.18C360.408 73.5936 361.098 73.7892 361.79 73.73H363V83ZM340.47 65.78C338.602 65.6742 336.747 66.1493 335.16 67.14C334.544 67.5708 334.048 68.1509 333.717 68.8261C333.386 69.5012 333.233 70.2492 333.27 71C333.247 71.6411 333.379 72.2783 333.654 72.8578C333.929 73.4374 334.339 73.9423 334.85 74.33C336.106 75.2174 337.625 75.6544 339.16 75.57C340.39 75.6344 341.62 75.4517 342.777 75.0326C343.935 74.6135 344.997 73.9666 345.9 73.13C346.741 72.2633 347.401 71.2376 347.84 70.1129C348.28 68.9882 348.49 67.7872 348.46 66.58V65.8L340.47 65.78Z" fill="white"/>
|
|
||||||
<path d="M391.54 53.07H387.2C384.1 53.07 381.827 54 380.38 55.86C378.827 58.0794 378.063 60.7553 378.21 63.46V83H367.29V43.15H377.21L378.21 49.15C379.277 47.3096 380.799 45.7739 382.63 44.69C384.812 43.5903 387.239 43.0703 389.68 43.18H391.54V53.07Z" fill="white"/>
|
|
||||||
<path d="M433.94 83H424.25L423.25 78.19C421.853 79.9283 420.065 81.3117 418.032 82.2275C415.999 83.1434 413.778 83.5657 411.55 83.46C409.459 83.5413 407.373 83.2027 405.415 82.4642C403.457 81.7256 401.667 80.6021 400.15 79.16C397.21 76.2867 395.74 71.4967 395.74 64.79V43.15H406.66V63.46C406.66 67.08 407.3 69.8 408.56 71.63C409.218 72.552 410.101 73.2899 411.125 73.7732C412.149 74.2564 413.28 74.4688 414.41 74.39C415.642 74.4479 416.869 74.1975 417.979 73.6614C419.09 73.1253 420.049 72.3206 420.77 71.32C422.257 69.28 423 66.4267 423 62.76V43.15H433.93L433.94 83Z" fill="white"/>
|
|
||||||
<path d="M200.9 89.43H214.85L234.54 143.68H222.06L217.87 131.82H197.26L193.07 143.68H181.07L200.9 89.43ZM214.7 122.43L207.49 101.82L200.36 122.43H214.7Z" fill="white"/>
|
|
||||||
<path d="M255.77 144.15C251.999 144.235 248.268 143.369 244.92 141.63C241.873 140.003 239.377 137.507 237.75 134.46C236.09 131.132 235.226 127.464 235.226 123.745C235.226 120.026 236.09 116.358 237.75 113.03C239.403 109.984 241.927 107.501 245 105.9C248.362 104.162 252.107 103.296 255.89 103.38C261.224 103.38 265.557 104.737 268.89 107.45C272.315 110.281 274.532 114.312 275.09 118.72H263.75C263.384 116.884 262.417 115.223 261 114C259.536 112.814 257.693 112.197 255.81 112.26C254.504 112.217 253.205 112.482 252.02 113.035C250.835 113.587 249.797 114.411 248.99 115.44C247.323 117.885 246.431 120.776 246.431 123.735C246.431 126.694 247.323 129.585 248.99 132.03C249.799 133.056 250.838 133.877 252.023 134.427C253.207 134.978 254.505 135.243 255.81 135.2C257.737 135.266 259.623 134.634 261.12 133.42C262.579 132.164 263.552 130.438 263.87 128.54H275.11C274.597 133.016 272.376 137.121 268.91 140C265.517 142.76 261.137 144.143 255.77 144.15Z" fill="white"/>
|
|
||||||
<path d="M320.17 143.68H314.59C309.583 143.68 307.13 141.51 307.23 137.17C305.907 139.319 304.068 141.103 301.88 142.36C299.521 143.609 296.878 144.226 294.21 144.15C289.76 144.15 286.21 143.15 283.47 141.08C282.137 140.082 281.069 138.773 280.36 137.266C279.65 135.76 279.321 134.103 279.4 132.44C279.31 130.546 279.691 128.659 280.51 126.949C281.328 125.239 282.558 123.758 284.09 122.64C287.223 120.34 291.76 119.19 297.7 119.19H305.6V117.25C305.632 116.426 305.467 115.606 305.119 114.858C304.772 114.11 304.251 113.456 303.6 112.95C302.059 111.957 300.275 111.407 298.442 111.359C296.61 111.312 294.8 111.768 293.21 112.68C292.595 113.078 292.071 113.6 291.671 114.213C291.271 114.827 291.005 115.517 290.89 116.24H280.33C280.442 114.347 280.99 112.505 281.932 110.858C282.874 109.212 284.184 107.806 285.76 106.75C289.013 104.503 293.326 103.38 298.7 103.38C304.34 103.38 308.693 104.62 311.76 107.1C314.826 109.58 316.363 113.17 316.37 117.87V131.87C316.332 132.228 316.367 132.59 316.474 132.934C316.58 133.278 316.756 133.596 316.99 133.87C317.552 134.274 318.239 134.466 318.93 134.41H320.17V143.68ZM297.62 126.47C295.751 126.36 293.895 126.836 292.31 127.83C291.694 128.257 291.197 128.833 290.865 129.505C290.532 130.177 290.376 130.921 290.41 131.67C290.388 132.312 290.521 132.95 290.798 133.529C291.074 134.109 291.487 134.613 292 135C293.252 135.887 294.767 136.324 296.3 136.24C297.523 136.301 298.746 136.117 299.897 135.698C301.047 135.279 302.102 134.634 303 133.8C303.844 132.936 304.506 131.911 304.946 130.785C305.386 129.66 305.594 128.458 305.56 127.25V126.47H297.62Z" fill="white"/>
|
|
||||||
<path d="M364.27 143.68H354.73L353.65 138.18C352.225 140.111 350.352 141.666 348.191 142.712C346.031 143.757 343.649 144.261 341.25 144.18C337.925 144.23 334.65 143.358 331.79 141.66C328.968 139.944 326.695 137.456 325.24 134.49C323.61 131.134 322.802 127.44 322.88 123.71C322.795 120.002 323.604 116.329 325.24 113C326.711 110.043 328.998 107.569 331.83 105.87C334.706 104.174 337.992 103.302 341.33 103.35C346.49 103.35 350.49 105.157 353.33 108.77V89.43H364.26L364.27 143.68ZM353.5 123.84C353.645 120.825 352.708 117.857 350.86 115.47C350.01 114.432 348.931 113.605 347.708 113.054C346.485 112.502 345.151 112.241 343.81 112.29C342.464 112.241 341.124 112.503 339.895 113.054C338.665 113.605 337.579 114.432 336.72 115.47C334.978 117.885 334.041 120.787 334.041 123.765C334.041 126.743 334.978 129.645 336.72 132.06C337.581 133.095 338.668 133.919 339.897 134.468C341.126 135.018 342.465 135.278 343.81 135.23C345.147 135.283 346.479 135.027 347.701 134.482C348.924 133.938 350.005 133.119 350.86 132.09C352.705 129.748 353.642 126.818 353.5 123.84Z" fill="white"/>
|
|
||||||
<path d="M379.88 105.9C383.105 104.17 386.722 103.301 390.38 103.38C394.058 103.311 397.698 104.12 401 105.74C403.986 107.237 406.486 109.55 408.21 112.41C410.559 116.613 411.392 121.496 410.57 126.24H381.7V126.55C381.805 129.079 382.792 131.492 384.49 133.37C385.356 134.219 386.389 134.878 387.524 135.305C388.658 135.732 389.87 135.917 391.08 135.85C393.027 135.923 394.952 135.426 396.62 134.42C398.103 133.446 399.147 131.932 399.53 130.2H410.3C409.913 132.787 408.881 135.236 407.3 137.32C405.634 139.494 403.446 141.214 400.94 142.32C398.09 143.586 394.998 144.21 391.88 144.15C387.901 144.251 383.955 143.401 380.37 141.67C377.234 140.103 374.642 137.629 372.93 134.57C371.14 131.282 370.243 127.583 370.33 123.84C370.23 120.073 371.09 116.342 372.83 113C374.447 110.003 376.895 107.538 379.88 105.9ZM397 113.49C395.244 112.124 393.064 111.416 390.84 111.49C388.664 111.409 386.532 112.119 384.84 113.49C383.196 114.894 382.157 116.879 381.94 119.03H400C399.746 116.868 398.672 114.885 397 113.49Z" fill="white"/>
|
|
||||||
<path d="M479.28 121.82V143.76H468.35V121.44C468.35 115.393 466.05 112.37 461.45 112.37C460.366 112.329 459.288 112.535 458.295 112.972C457.303 113.408 456.422 114.064 455.72 114.89C454.167 116.881 453.392 119.369 453.54 121.89V143.75H442.62V121.44C442.62 115.393 440.286 112.37 435.62 112.37C434.534 112.33 433.453 112.543 432.464 112.992C431.475 113.442 430.604 114.115 429.92 114.96C428.413 116.973 427.659 119.449 427.79 121.96V143.74H416.89V103.84H426.42L427.42 108.84C428.7 107.164 430.341 105.798 432.22 104.84C434.415 103.835 436.806 103.333 439.22 103.37C444.9 103.37 448.85 105.617 451.07 110.11C452.49 107.965 454.458 106.238 456.77 105.11C459.266 103.92 462.004 103.324 464.77 103.37C466.717 103.299 468.657 103.642 470.462 104.376C472.267 105.111 473.895 106.22 475.24 107.63C477.926 110.477 479.273 115.207 479.28 121.82Z" fill="white"/>
|
|
||||||
<path d="M502.21 132.44L511.67 103.84H523.37L505.7 149.34C504.985 151.312 504.102 153.219 503.06 155.04C502.312 156.331 501.231 157.398 499.93 158.13C498.401 158.889 496.706 159.25 495 159.18H484.39V150H490.39C491.439 150.081 492.486 149.836 493.39 149.3C494.197 148.556 494.808 147.625 495.17 146.59L496.17 143.95L480.9 103.84H492.52L502.21 132.44Z" fill="white"/>
|
|
||||||
<path d="M121.07 26.53L98.76 39.42L76.03 52.54L53.26 39.39L53.24 39.38L30.96 26.52L31.41 25.76L53.24 13.15L53.26 13.13L76.02 0L98.76 13.12L120.6 25.74L121.07 26.53Z" fill="white"/>
|
|
||||||
<path d="M53.26 172.41H98.76V110.9L121.51 97.78L137.93 88.29L152.02 80.15L129.27 40.75L98.76 58.36L92.43 62.02L76 71.49L59.6 62.03L53.26 58.37L53.24 58.36L22.75 40.75L0 80.16L14.09 88.3L30.5 97.78L53.24 110.9L53.26 110.91V137.18V172.41Z" fill="white"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clip0_23_9014">
|
|
||||||
<rect width="523.37" height="172.42" fill="white"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 11 KiB |
|
|
@ -1,5 +0,0 @@
|
||||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M63.9919 0C60.8419 0 57.6937 1.20941 55.3022 3.60086L50.6045 8.29848C46.3616 12.5413 40.6045 14.9155 34.6041 14.9155H27.1926C20.4286 14.9155 14.902 20.4419 14.902 27.206V32.6496V34.6214C14.902 40.6217 12.5277 46.3747 8.28487 50.6175L3.58719 55.3152C-1.19573 60.0981 -1.19573 67.9196 3.58719 72.7025L8.28487 77.4001C12.5277 81.643 14.902 87.3919 14.902 93.3922V100.808C14.902 107.572 20.4286 113.098 27.1926 113.098H34.6041C40.6045 113.098 46.3616 115.476 50.6045 119.719L55.3022 124.413C60.0851 129.196 67.8988 129.196 72.6817 124.413L77.3834 119.719C81.6263 115.476 87.3795 113.098 93.3798 113.098H100.791C107.555 113.098 113.082 107.572 113.082 100.808V93.3922C113.082 87.3919 115.476 81.643 119.719 77.4001L124.413 72.7025C129.196 67.9196 129.196 60.0981 124.413 55.3152L119.719 50.6175C115.476 46.3746 113.082 40.6217 113.082 34.6214V27.206C113.082 20.4419 107.555 14.9155 100.791 14.9155H93.3798C87.3795 14.9155 81.6263 12.5414 77.3834 8.29848L72.6817 3.60086C70.2902 1.20941 67.142 7.80299e-06 63.9919 0Z" fill="#9E2891" fill-opacity="0.2"/>
|
|
||||||
<path d="M63.9929 8.00006C61.2366 8.00006 58.482 9.0583 56.3894 11.1508L52.2789 15.2612C48.5664 18.9737 43.5289 21.0511 38.2786 21.0511H31.7936C25.875 21.0511 21.0393 25.8867 21.0393 31.8053V36.5684V38.2938C21.0393 43.5441 18.9618 48.5779 15.2493 52.2904L11.1388 56.4009C6.95374 60.5859 6.95374 67.4297 11.1388 71.6148L15.2493 75.7252C18.9618 79.4377 21.0393 84.468 21.0393 89.7183V96.2068C21.0393 102.125 25.875 106.961 31.7936 106.961H38.2786C43.5289 106.961 48.5664 109.042 52.2789 112.754L56.3894 116.861C60.5745 121.046 67.4115 121.046 71.5965 116.861L75.7105 112.754C79.423 109.042 84.457 106.961 89.7073 106.961H96.1924C102.111 106.961 106.947 102.125 106.947 96.2068V89.7183C106.947 84.468 109.042 79.4377 112.754 75.7252L116.861 71.6148C121.046 67.4297 121.046 60.5859 116.861 56.4009L112.754 52.2904C109.042 48.5779 106.947 43.5441 106.947 38.2938V31.8053C106.947 25.8867 102.111 21.0511 96.1924 21.0511H89.7073C84.457 21.0511 79.423 18.9738 75.7105 15.2612L71.5965 11.1508C69.504 9.05829 66.7492 8.00007 63.9929 8.00006Z" fill="#9E2891"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M81.9362 50.3204C82.8406 51.1118 82.9227 52.4904 82.1182 53.3832L59.4729 78.5182C58.6643 79.4157 57.2758 79.4727 56.3963 78.6448L43.2922 66.3084C42.475 65.5391 42.3968 64.2571 43.0979 63.3807C43.8716 62.4136 45.3123 62.2805 46.233 63.1087L56.4 72.2537C57.2836 73.0485 58.6439 72.9774 59.4399 72.095L78.9182 50.4986C79.7063 49.6246 81.0506 49.5454 81.9362 50.3204Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.6 KiB |
|
|
@ -1,17 +1,15 @@
|
||||||
import logoSrc from "../../assets/logo.svg";
|
export function BrandLogo() {
|
||||||
|
|
||||||
export function BrandLogo({
|
|
||||||
className = "h-10",
|
|
||||||
variant = "dark",
|
|
||||||
}: {
|
|
||||||
className?: string;
|
|
||||||
variant?: "light" | "dark";
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<img
|
<div className="flex items-center gap-2">
|
||||||
src={logoSrc}
|
<div className="grid h-9 w-9 place-items-center rounded-lg bg-brand-500 text-white">
|
||||||
alt="Yimaru Academy"
|
<div className="h-4 w-4 rotate-45 rounded-[3px] bg-white/90" />
|
||||||
className={`${className} ${variant === "dark" ? "brightness-0" : ""}`}
|
</div>
|
||||||
/>
|
<div className="leading-tight">
|
||||||
);
|
<div className="text-sm font-semibold text-brand-600">Yimaru</div>
|
||||||
|
<div className="text-xs font-medium text-brand-400">Academy</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@ import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Bell,
|
Bell,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
|
@ -41,12 +39,10 @@ const navItems: NavItem[] = [
|
||||||
|
|
||||||
type SidebarProps = {
|
type SidebarProps = {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
isCollapsed: boolean
|
|
||||||
onToggleCollapse: () => void
|
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: SidebarProps) {
|
export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||||
const [unreadCount, setUnreadCount] = useState(0)
|
const [unreadCount, setUnreadCount] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -80,31 +76,12 @@ export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: Side
|
||||||
{/* Sidebar panel */}
|
{/* Sidebar panel */}
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"group fixed left-0 top-0 z-50 flex h-screen flex-col border-r bg-grayScale-50 py-5 transition-all duration-300",
|
"fixed left-0 top-0 z-50 flex h-screen w-[264px] flex-col border-r bg-grayScale-50 px-4 py-5 transition-transform duration-300 lg:translate-x-0",
|
||||||
"w-[264px] px-4 lg:translate-x-0",
|
|
||||||
isCollapsed && "lg:w-[88px] lg:px-2",
|
|
||||||
isOpen ? "translate-x-0" : "-translate-x-full",
|
isOpen ? "translate-x-0" : "-translate-x-full",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn("flex items-center justify-between px-2", isCollapsed && "justify-center")}>
|
<div className="flex items-center justify-between px-2">
|
||||||
{isCollapsed ? (
|
|
||||||
<span className="h-10 w-10 overflow-hidden">
|
|
||||||
<BrandLogo className="h-10 w-auto max-w-none" />
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<BrandLogo />
|
<BrandLogo />
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"hidden h-8 w-8 place-items-center rounded-lg text-grayScale-500 transition-opacity hover:bg-grayScale-100 hover:text-brand-600 lg:grid lg:opacity-0 lg:pointer-events-none lg:group-hover:opacity-100 lg:group-hover:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto",
|
|
||||||
isCollapsed && "translate-x-2",
|
|
||||||
)}
|
|
||||||
onClick={onToggleCollapse}
|
|
||||||
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
||||||
>
|
|
||||||
{isCollapsed ? <ChevronRight className="h-5 w-5" /> : <ChevronLeft className="h-5 w-5" />}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-500 hover:bg-grayScale-100 hover:text-brand-600 lg:hidden"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-500 hover:bg-grayScale-100 hover:text-brand-600 lg:hidden"
|
||||||
|
|
@ -126,37 +103,32 @@ export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: Side
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
"group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-grayScale-600 transition",
|
"group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-grayScale-600 transition",
|
||||||
isCollapsed && "justify-center px-2",
|
|
||||||
"hover:bg-grayScale-100 hover:text-brand-600",
|
"hover:bg-grayScale-100 hover:text-brand-600",
|
||||||
isActive &&
|
isActive &&
|
||||||
"bg-brand-100/40 text-brand-600 shadow-[0_1px_0_rgba(0,0,0,0.02)] ring-1 ring-brand-100",
|
"bg-brand-100/40 text-brand-600 shadow-[0_1px_0_rgba(0,0,0,0.02)] ring-1 ring-brand-100",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
title={isCollapsed ? item.label : undefined}
|
|
||||||
>
|
>
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
<>
|
<>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600",
|
"grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600",
|
||||||
isActive && "bg-brand-500/90 text-white",
|
isActive && "bg-brand-500 text-white",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
{isCollapsed && item.to === "/notifications" && unreadCount > 0 && (
|
|
||||||
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-destructive" />
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
{!isCollapsed && <span className="truncate">{item.label}</span>}
|
<span className="truncate">{item.label}</span>
|
||||||
{!isCollapsed && item.to === "/notifications" && unreadCount > 0 && (
|
{item.to === "/notifications" && unreadCount > 0 && (
|
||||||
<span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-bold text-white">
|
<span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-bold text-white">
|
||||||
{unreadCount > 99 ? "99+" : unreadCount}
|
{unreadCount > 99 ? "99+" : unreadCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!isCollapsed && item.to !== "/notifications" && isActive ? (
|
{item.to !== "/notifications" && isActive ? (
|
||||||
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
|
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500" />
|
||||||
) : !isCollapsed && item.to === "/notifications" && unreadCount === 0 && isActive ? (
|
) : item.to === "/notifications" && unreadCount === 0 && isActive ? (
|
||||||
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
|
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500" />
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -172,14 +144,10 @@ export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: Side
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
window.location.href = "/login"
|
window.location.href = "/login"
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-grayScale-500 hover:bg-grayScale-100 hover:text-brand-600"
|
||||||
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-grayScale-500 hover:bg-grayScale-100 hover:text-brand-600",
|
|
||||||
isCollapsed && "justify-center px-2",
|
|
||||||
)}
|
|
||||||
title={isCollapsed ? "Logout" : undefined}
|
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
{!isCollapsed && "Logout"}
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import {
|
||||||
import { Badge } from "../ui/badge"
|
import { Badge } from "../ui/badge"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { useNotifications } from "../../hooks/useNotifications"
|
import { useNotifications } from "../../hooks/useNotifications"
|
||||||
import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types"
|
import type { Notification } from "../../types/notification.types"
|
||||||
|
|
||||||
const TYPE_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
|
const TYPE_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
|
||||||
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
|
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
|
||||||
|
|
@ -105,10 +105,10 @@ function NotificationItem({
|
||||||
!notification.is_read && "font-semibold"
|
!notification.is_read && "font-semibold"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{getNotificationTitle(notification)}
|
{notification.payload.headline}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 line-clamp-2 text-xs text-grayScale-500">
|
<p className="mt-0.5 line-clamp-2 text-xs text-grayScale-500">
|
||||||
{getNotificationMessage(notification)}
|
{notification.payload.message}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-[11px] text-grayScale-400">
|
<p className="mt-1 text-[11px] text-grayScale-400">
|
||||||
{formatTimestamp(notification.timestamp)}
|
{formatTimestamp(notification.timestamp)}
|
||||||
|
|
@ -170,14 +170,11 @@ export function NotificationDropdown() {
|
||||||
{/* Bell button */}
|
{/* Bell button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="relative inline-flex h-10 items-center gap-2 rounded-full border bg-white px-3 text-grayScale-500 transition-colors hover:text-brand-600"
|
className="relative grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 transition-colors hover:text-brand-600"
|
||||||
aria-label="Notifications"
|
aria-label="Notifications"
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
>
|
>
|
||||||
<Bell className="h-5 w-5" />
|
<Bell className="h-5 w-5" />
|
||||||
<span className="hidden text-xs font-medium text-grayScale-600 sm:inline">
|
|
||||||
Notifications
|
|
||||||
</span>
|
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-white">
|
<span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-white">
|
||||||
{unreadCount > 99 ? "99+" : unreadCount}
|
{unreadCount > 99 ? "99+" : unreadCount}
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ import { cn } from "../../lib/utils"
|
||||||
import { NotificationDropdown } from "./NotificationDropdown"
|
import { NotificationDropdown } from "./NotificationDropdown"
|
||||||
|
|
||||||
type TopbarProps = {
|
type TopbarProps = {
|
||||||
onSidebarToggle: () => void
|
onMenuClick: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Topbar({ onSidebarToggle }: TopbarProps) {
|
export function Topbar({ onMenuClick }: TopbarProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [shortName, setShortName] = useState("AA")
|
const [shortName, setShortName] = useState("AA")
|
||||||
|
|
||||||
|
|
@ -46,11 +46,11 @@ export function Topbar({ onSidebarToggle }: TopbarProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-10 flex h-16 items-center justify-between gap-3 border-b bg-grayScale-50/85 px-4 backdrop-blur lg:justify-end lg:px-6">
|
<header className="sticky top-0 z-10 flex h-16 items-center justify-between gap-3 border-b bg-grayScale-50/85 px-4 backdrop-blur lg:justify-end lg:px-6">
|
||||||
{/* Sidebar toggle */}
|
{/* Mobile hamburger */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 transition-colors hover:text-brand-600 lg:hidden"
|
className="grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 transition-colors hover:text-brand-600 lg:hidden"
|
||||||
onClick={onSidebarToggle}
|
onClick={onMenuClick}
|
||||||
aria-label="Open menu"
|
aria-label="Open menu"
|
||||||
>
|
>
|
||||||
<Menu className="h-5 w-5" />
|
<Menu className="h-5 w-5" />
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,14 @@ import { Topbar } from "../components/topbar/Topbar"
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
|
||||||
|
|
||||||
const token = localStorage.getItem("access_token")
|
const token = localStorage.getItem("access_token")
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return <Navigate to="/login" replace />
|
return <Navigate to="/login" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSidebarToggle = useCallback(() => {
|
const handleMenuClick = useCallback(() => {
|
||||||
setSidebarOpen((prev) => !prev)
|
setSidebarOpen(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleSidebarClose = useCallback(() => {
|
const handleSidebarClose = useCallback(() => {
|
||||||
|
|
@ -22,18 +21,9 @@ export function AppLayout() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-grayScale-100">
|
<div className="flex min-h-screen bg-grayScale-100">
|
||||||
<Sidebar
|
<Sidebar isOpen={sidebarOpen} onClose={handleSidebarClose} />
|
||||||
isOpen={sidebarOpen}
|
<div className="flex min-w-0 flex-1 flex-col lg:ml-[264px]">
|
||||||
isCollapsed={sidebarCollapsed}
|
<Topbar onMenuClick={handleMenuClick} />
|
||||||
onToggleCollapse={() => setSidebarCollapsed((prev) => !prev)}
|
|
||||||
onClose={handleSidebarClose}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`flex min-w-0 flex-1 flex-col transition-[margin] duration-300 ${
|
|
||||||
sidebarCollapsed ? "lg:ml-[88px]" : "lg:ml-[264px]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Topbar onSidebarToggle={handleSidebarToggle} />
|
|
||||||
<main className="min-w-0 flex-1 overflow-y-auto px-4 pb-8 pt-4 lg:px-6">
|
<main className="min-w-0 flex-1 overflow-y-auto px-4 pb-8 pt-4 lg:px-6">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -41,12 +31,12 @@ export function AppLayout() {
|
||||||
<div className="flex items-center justify-center gap-1.5 text-xs text-grayScale-400">
|
<div className="flex items-center justify-center gap-1.5 text-xs text-grayScale-400">
|
||||||
<span>Powered by</span>
|
<span>Powered by</span>
|
||||||
<a
|
<a
|
||||||
href="https://tech.yaltopia.com"
|
href="https://yaltopia.com"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-semibold text-brand-500 transition-colors hover:text-brand-600"
|
className="font-semibold text-brand-500 transition-colors hover:text-brand-600"
|
||||||
>
|
>
|
||||||
Yaltopia Tech
|
Yaltopia
|
||||||
</a>
|
</a>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>© {new Date().getFullYear()}</span>
|
<span>© {new Date().getFullYear()}</span>
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,12 @@ import {
|
||||||
// Coins,
|
// Coins,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
MessageSquare,
|
|
||||||
Star,
|
|
||||||
TicketCheck,
|
TicketCheck,
|
||||||
// TrendingUp,
|
// TrendingUp,
|
||||||
Users,
|
Users,
|
||||||
Bell,
|
Bell,
|
||||||
UsersRound,
|
UsersRound,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import spinnerSrc from "../assets/Circular-indeterminate progress indicator.svg"
|
|
||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
AreaChart,
|
AreaChart,
|
||||||
|
|
@ -29,15 +26,12 @@ import {
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts"
|
} from "recharts"
|
||||||
import { StatCard } from "../components/dashboard/StatCard"
|
import { StatCard } from "../components/dashboard/StatCard"
|
||||||
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 { getRatings } from "../api/courses.api"
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import type { DashboardData } from "../types/analytics.types"
|
import type { DashboardData } from "../types/analytics.types"
|
||||||
import type { Rating } from "../types/course.types"
|
|
||||||
|
|
||||||
const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444"]
|
const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444"]
|
||||||
|
|
||||||
|
|
@ -51,8 +45,6 @@ export function DashboardPage() {
|
||||||
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")
|
const [activeStatTab, setActiveStatTab] = useState<"primary" | "secondary">("primary")
|
||||||
const [appRatings, setAppRatings] = useState<Rating[]>([])
|
|
||||||
const [appRatingsLoading, setAppRatingsLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUser = async () => {
|
const fetchUser = async () => {
|
||||||
|
|
@ -81,20 +73,8 @@ export function DashboardPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchAppRatings = async () => {
|
|
||||||
try {
|
|
||||||
const res = await getRatings({ target_type: "app", target_id: 1, limit: 5 })
|
|
||||||
setAppRatings(res.data.data)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
} finally {
|
|
||||||
setAppRatingsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchUser()
|
fetchUser()
|
||||||
fetchDashboard()
|
fetchDashboard()
|
||||||
fetchAppRatings()
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const registrationData =
|
const registrationData =
|
||||||
|
|
@ -131,15 +111,9 @@ export function DashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-20">
|
<div className="flex items-center justify-center py-20 text-grayScale-500">Loading dashboard…</div>
|
||||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
|
||||||
<span className="text-sm font-medium text-grayScale-400">Loading dashboard…</span>
|
|
||||||
</div>
|
|
||||||
) : !dashboard ? (
|
) : !dashboard ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-20">
|
<div className="flex items-center justify-center py-20 text-destructive">Failed to load dashboard data.</div>
|
||||||
<img src={alertSrc} alt="" className="h-12 w-12" />
|
|
||||||
<span className="text-sm font-medium text-destructive">Failed to load dashboard data.</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Stat tabs */}
|
{/* Stat tabs */}
|
||||||
|
|
@ -427,90 +401,6 @@ export function DashboardPage() {
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* App Ratings */}
|
|
||||||
<Card className="shadow-none">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<MessageSquare className="h-5 w-5 text-brand-500" />
|
|
||||||
<CardTitle>Recent App Reviews</CardTitle>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-6 pt-2">
|
|
||||||
{appRatingsLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-10">
|
|
||||||
<img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : appRatings.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center py-10 text-sm text-grayScale-400">
|
|
||||||
No app reviews yet
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="mb-4 flex items-center gap-3 rounded-lg bg-grayScale-50 px-4 py-3">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<Star
|
|
||||||
key={i}
|
|
||||||
className={cn(
|
|
||||||
"h-4 w-4",
|
|
||||||
i <
|
|
||||||
Math.round(
|
|
||||||
appRatings.reduce((sum, r) => sum + r.stars, 0) / appRatings.length,
|
|
||||||
)
|
|
||||||
? "fill-amber-400 text-amber-400"
|
|
||||||
: "fill-grayScale-200 text-grayScale-200",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-semibold text-grayScale-600">
|
|
||||||
{(appRatings.reduce((sum, r) => sum + r.stars, 0) / appRatings.length).toFixed(1)}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-grayScale-400">
|
|
||||||
({appRatings.length} {appRatings.length === 1 ? "review" : "reviews"})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{appRatings.map((rating) => (
|
|
||||||
<div key={rating.id} className="flex gap-3">
|
|
||||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-50 text-xs font-semibold text-brand-600">
|
|
||||||
U{rating.user_id}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-sm font-semibold text-grayScale-600">
|
|
||||||
User #{rating.user_id}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-xs text-grayScale-400">
|
|
||||||
{formatDate(rating.created_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 flex items-center gap-0.5">
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<Star
|
|
||||||
key={i}
|
|
||||||
className={cn(
|
|
||||||
"h-3.5 w-3.5",
|
|
||||||
i < rating.stars
|
|
||||||
? "fill-amber-400 text-amber-400"
|
|
||||||
: "fill-grayScale-200 text-grayScale-200",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{rating.review && (
|
|
||||||
<p className="mt-1 text-sm text-grayScale-500">{rating.review}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -4,33 +4,23 @@ import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
Globe,
|
Globe,
|
||||||
Loader2,
|
// GraduationCap,
|
||||||
|
Languages,
|
||||||
Mail,
|
Mail,
|
||||||
MapPin,
|
MapPin,
|
||||||
Pencil,
|
|
||||||
Phone,
|
Phone,
|
||||||
Save,
|
|
||||||
Shield,
|
Shield,
|
||||||
User,
|
User,
|
||||||
X,
|
|
||||||
XCircle,
|
XCircle,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
BookOpen,
|
// RefreshCw,
|
||||||
Target,
|
|
||||||
Languages,
|
|
||||||
Heart,
|
|
||||||
MessageCircle,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Badge } from "../components/ui/badge";
|
import { Badge } from "../components/ui/badge";
|
||||||
import { Button } from "../components/ui/button";
|
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||||
import { Card, CardContent } from "../components/ui/card";
|
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
|
||||||
import { Input } from "../components/ui/input";
|
|
||||||
import { Select } from "../components/ui/select";
|
|
||||||
|
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { getMyProfile, updateProfile } from "../api/users.api";
|
import { getMyProfile } from "../api/users.api";
|
||||||
import type { UserProfileData, UpdateProfileRequest } from "../types/user.types";
|
import type { UserProfileData } from "../types/user.types";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
function formatDate(dateStr: string | null | undefined): string {
|
function formatDate(dateStr: string | null | undefined): string {
|
||||||
if (!dateStr) return "—";
|
if (!dateStr) return "—";
|
||||||
|
|
@ -54,10 +44,11 @@ function formatDateTime(dateStr: string | null | undefined): string {
|
||||||
|
|
||||||
function LoadingSkeleton() {
|
function LoadingSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-8 py-10">
|
<div className="mx-auto w-full max-w-6xl space-y-8 px-4 py-10 sm:px-6">
|
||||||
<div className="animate-pulse space-y-8">
|
<div className="animate-pulse space-y-8">
|
||||||
|
{/* Hero skeleton */}
|
||||||
<div className="overflow-hidden rounded-2xl border border-grayScale-100">
|
<div className="overflow-hidden rounded-2xl border border-grayScale-100">
|
||||||
<div className="h-40 bg-gradient-to-r from-grayScale-100 via-grayScale-200/60 to-grayScale-100" />
|
<div className="h-36 bg-gradient-to-r from-grayScale-100 via-grayScale-200/60 to-grayScale-100" />
|
||||||
<div className="flex flex-col items-center px-8 pb-8">
|
<div className="flex flex-col items-center px-8 pb-8">
|
||||||
<div className="-mt-14 h-28 w-28 rounded-full bg-grayScale-100 ring-4 ring-white" />
|
<div className="-mt-14 h-28 w-28 rounded-full bg-grayScale-100 ring-4 ring-white" />
|
||||||
<div className="mt-4 h-6 w-48 rounded-lg bg-grayScale-100" />
|
<div className="mt-4 h-6 w-48 rounded-lg bg-grayScale-100" />
|
||||||
|
|
@ -69,6 +60,7 @@ function LoadingSkeleton() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Info cards skeleton */}
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<div key={i} className="rounded-2xl border border-grayScale-100 p-6">
|
<div key={i} className="rounded-2xl border border-grayScale-100 p-6">
|
||||||
|
|
@ -89,6 +81,33 @@ function LoadingSkeleton() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InfoRow({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
extra,
|
||||||
|
}: {
|
||||||
|
icon: typeof User;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
extra?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
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="flex items-center gap-3 text-sm text-grayScale-400">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-grayScale-100 text-grayScale-400 transition-colors group-hover:bg-brand-100 group-hover:text-brand-500">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-grayScale-600 sm:justify-end min-w-0">
|
||||||
|
<span className="truncate text-right sm:text-left">{value || "—"}</span>
|
||||||
|
{extra}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function VerifiedIcon({ verified }: { verified: boolean }) {
|
function VerifiedIcon({ verified }: { verified: boolean }) {
|
||||||
return verified ? (
|
return verified ? (
|
||||||
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-mint-100">
|
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-mint-100">
|
||||||
|
|
@ -102,20 +121,20 @@ function VerifiedIcon({ verified }: { verified: boolean }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProgressRing({ percent }: { percent: number }) {
|
function ProgressRing({ percent }: { percent: number }) {
|
||||||
const radius = 18;
|
const radius = 14;
|
||||||
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-12 w-12 -rotate-90" viewBox="0 0 44 44">
|
<svg className="h-8 w-8 -rotate-90" viewBox="0 0 44 44">
|
||||||
<circle
|
<circle
|
||||||
cx="22"
|
cx="22"
|
||||||
cy="22"
|
cy="22"
|
||||||
r={radius}
|
r={radius}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="2.5"
|
strokeWidth="3"
|
||||||
className="text-grayScale-200"
|
className="text-grayScale-200"
|
||||||
/>
|
/>
|
||||||
<circle
|
<circle
|
||||||
|
|
@ -124,51 +143,14 @@ function ProgressRing({ percent }: { percent: number }) {
|
||||||
r={radius}
|
r={radius}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="2.5"
|
strokeWidth="3"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeDasharray={circumference}
|
strokeDasharray={circumference}
|
||||||
strokeDashoffset={offset}
|
strokeDashoffset={offset}
|
||||||
className="text-brand-500 transition-all duration-700"
|
className="text-brand-500 transition-all duration-700"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="absolute text-[10px] font-bold text-brand-600">{percent}%</span>
|
<span className="absolute text-[9px] font-bold text-brand-600">{percent}%</span>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DetailItem({
|
|
||||||
icon: Icon,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
extra,
|
|
||||||
editNode,
|
|
||||||
editing,
|
|
||||||
}: {
|
|
||||||
icon: typeof User;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
extra?: React.ReactNode;
|
|
||||||
editNode?: React.ReactNode;
|
|
||||||
editing?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="group flex items-start gap-3 rounded-xl px-3 py-3 transition-colors hover:bg-grayScale-50/80">
|
|
||||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-grayScale-100/80 text-grayScale-400 transition-colors group-hover:bg-brand-500/90 group-hover:text-white">
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-[11px] font-medium uppercase tracking-wider text-grayScale-400">
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
{editing && editNode ? (
|
|
||||||
<div className="mt-1">{editNode}</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-0.5 flex items-center gap-2">
|
|
||||||
<p className="truncate text-sm font-medium text-grayScale-700">{value || "—"}</p>
|
|
||||||
{extra}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -177,9 +159,6 @@ export function ProfilePage() {
|
||||||
const [profile, setProfile] = useState<UserProfileData | null>(null);
|
const [profile, setProfile] = useState<UserProfileData | null>(null);
|
||||||
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 [editing, setEditing] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [editForm, setEditForm] = useState<UpdateProfileRequest>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchProfile = async () => {
|
const fetchProfile = async () => {
|
||||||
|
|
@ -196,59 +175,11 @@ export function ProfilePage() {
|
||||||
fetchProfile();
|
fetchProfile();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startEditing = () => {
|
|
||||||
if (!profile) return;
|
|
||||||
setEditForm({
|
|
||||||
first_name: profile.first_name ?? "",
|
|
||||||
last_name: profile.last_name ?? "",
|
|
||||||
nick_name: profile.nick_name ?? "",
|
|
||||||
gender: profile.gender ?? "",
|
|
||||||
birth_day: profile.birth_day ?? "",
|
|
||||||
age_group: profile.age_group ?? "",
|
|
||||||
education_level: profile.education_level ?? "",
|
|
||||||
country: profile.country ?? "",
|
|
||||||
region: profile.region ?? "",
|
|
||||||
occupation: profile.occupation ?? "",
|
|
||||||
learning_goal: profile.learning_goal ?? "",
|
|
||||||
language_goal: profile.language_goal ?? "",
|
|
||||||
language_challange: profile.language_challange ?? "",
|
|
||||||
favoutite_topic: profile.favoutite_topic ?? "",
|
|
||||||
preferred_language: profile.preferred_language ?? "",
|
|
||||||
});
|
|
||||||
setEditing(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelEditing = () => {
|
|
||||||
setEditing(false);
|
|
||||||
setEditForm({});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await updateProfile(editForm);
|
|
||||||
const res = await getMyProfile();
|
|
||||||
setProfile(res.data.data);
|
|
||||||
setEditing(false);
|
|
||||||
setEditForm({});
|
|
||||||
toast.success("Profile updated successfully");
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to update profile", err);
|
|
||||||
toast.error("Failed to update profile. Please try again.");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateField = (field: keyof UpdateProfileRequest, value: string) => {
|
|
||||||
setEditForm((prev) => ({ ...prev, [field]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <LoadingSkeleton />;
|
if (loading) return <LoadingSkeleton />;
|
||||||
|
|
||||||
if (error || !profile) {
|
if (error || !profile) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full py-16">
|
<div className="mx-auto w-full max-w-6xl 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">
|
||||||
|
|
@ -269,396 +200,220 @@ export function ProfilePage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullName = `${profile.first_name} ${profile.last_name}`;
|
const fullName = `${profile.first_name} ${profile.last_name}`;
|
||||||
|
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;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-7xl space-y-6 pb-8">
|
<div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6">
|
||||||
{/* ─── Hero Card ─── */}
|
{/* Page header (no tabs) */}
|
||||||
<div className="relative overflow-hidden rounded-3xl border border-grayScale-100 bg-white shadow-sm ring-1 ring-black/5">
|
<div className="mb-5">
|
||||||
{/* Tall dark gradient banner with content inside */}
|
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">My Info</p>
|
||||||
<div className="relative flex min-h-[220px] flex-col justify-between bg-gradient-to-br from-[#1a1f4e] via-[#2d2b6b] to-[#3b3480] px-6 py-8 sm:px-8">
|
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-grayScale-800">Profile</h1>
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(255,255,255,0.08),transparent_60%)]" />
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10 space-y-2">
|
{/* Main profile layout card */}
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white sm:text-3xl">
|
<div className="rounded-2xl border border-grayScale-100 bg-white shadow-sm">
|
||||||
Hello {profile.first_name}
|
{/* Header strip */}
|
||||||
</h2>
|
<div className="border-b border-grayScale-100 px-6 py-4 sm:px-8">
|
||||||
<p className="max-w-2xl text-sm leading-relaxed text-white/70">
|
<div className="flex items-center justify-between">
|
||||||
Track your account status, keep profile details up to date, and manage your learning preferences from one place.
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">Overview</p>
|
||||||
|
<p className="mt-1 text-sm text-grayScale-500">
|
||||||
|
Personal, job and account details for this team member.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
</div>
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/20 bg-white/10 px-2.5 py-1 text-xs font-medium text-white/90">
|
|
||||||
<Shield className="h-3.5 w-3.5" />
|
|
||||||
{profile.role}
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/20 bg-white/10 px-2.5 py-1 text-xs font-medium text-white/90">
|
|
||||||
<Clock className="h-3.5 w-3.5" />
|
|
||||||
Last login {formatDate(profile.last_login)}
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/20 bg-white/10 px-2.5 py-1 text-xs font-medium text-white/90">
|
|
||||||
<Target className="h-3.5 w-3.5" />
|
|
||||||
{completionPct}% complete
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10 mt-6">
|
<div className="px-6 py-6 sm:px-8 sm:py-7">
|
||||||
{!editing ? (
|
<div className="grid gap-8 md:grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)]">
|
||||||
<Button
|
{/* Left column: About & details */}
|
||||||
variant="outline"
|
<div className="space-y-6">
|
||||||
size="sm"
|
{/* Identity */}
|
||||||
className="h-8 gap-1.5 border-white/30 bg-white/10 px-3 text-xs font-medium text-white shadow-sm backdrop-blur-sm hover:bg-white/20 hover:text-white"
|
<div className="flex flex-col gap-4 sm:flex-row">
|
||||||
onClick={startEditing}
|
<Avatar className="h-16 w-16 sm:h-18 sm:w-18">
|
||||||
>
|
<AvatarImage src={profile.profile_picture_url || undefined} alt={fullName} />
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<AvatarFallback className="bg-grayScale-100 text-base font-semibold text-grayScale-600">
|
||||||
Edit Profile
|
{initials}
|
||||||
</Button>
|
</AvatarFallback>
|
||||||
) : (
|
</Avatar>
|
||||||
<div className="flex items-center gap-2">
|
<div className="min-w-0">
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 border-white/30 bg-white/10 px-3 text-xs text-white backdrop-blur-sm hover:bg-white/20 hover:text-white"
|
|
||||||
onClick={cancelEditing}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 bg-white text-xs text-[#1a1f4e] hover:bg-white/90"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
{saving ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
{saving ? "Saving…" : "Save Changes"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Identity info below banner */}
|
|
||||||
<div className="bg-gradient-to-b from-white to-grayScale-50/40 px-6 py-5 sm:px-8">
|
|
||||||
{editing ? (
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Input
|
<h2 className="text-lg font-semibold tracking-tight text-grayScale-800">{fullName}</h2>
|
||||||
className="h-9 w-40 text-sm font-semibold"
|
|
||||||
value={editForm.first_name ?? ""}
|
|
||||||
onChange={(e) => updateField("first_name", e.target.value)}
|
|
||||||
placeholder="First name"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
className="h-9 w-40 text-sm font-semibold"
|
|
||||||
value={editForm.last_name ?? ""}
|
|
||||||
onChange={(e) => updateField("last_name", e.target.value)}
|
|
||||||
placeholder="Last name"
|
|
||||||
/>
|
|
||||||
<span className="rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500">
|
<span className="rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500">
|
||||||
#{profile.id}
|
#{profile.id}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
<div className="flex flex-wrap items-center gap-2.5">
|
|
||||||
<h2 className="text-xl font-bold tracking-tight text-grayScale-800 sm:text-2xl">
|
|
||||||
{fullName}
|
|
||||||
</h2>
|
|
||||||
{profile.nick_name && (
|
|
||||||
<span className="text-sm font-medium text-grayScale-400">
|
|
||||||
@{profile.nick_name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500">
|
|
||||||
#{profile.id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Badges row */}
|
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
||||||
<Badge
|
<Badge
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2.5 py-0.5 text-xs font-semibold",
|
"px-2.5 py-0.5 text-xs font-semibold",
|
||||||
profile.role === "ADMIN"
|
profile.role === "ADMIN"
|
||||||
? "bg-brand-500/10 text-brand-600 border border-brand-500/20"
|
? "bg-brand-500/10 text-brand-600 border border-brand-500/20"
|
||||||
: "bg-grayScale-50 text-grayScale-600 border border-grayScale-200",
|
: "bg-grayScale-50 text-grayScale-600 border border-grayScale-200"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Shield className="mr-1 h-3 w-3" />
|
<Shield className="mr-1 h-3 w-3" />
|
||||||
{profile.role}
|
{profile.role}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold",
|
|
||||||
profile.status === "ACTIVE"
|
|
||||||
? "bg-mint-50 text-mint-600"
|
|
||||||
: "bg-destructive/10 text-destructive",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"h-1.5 w-1.5 rounded-full",
|
|
||||||
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{profile.status}
|
|
||||||
</span>
|
|
||||||
<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">
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500">
|
||||||
<Calendar className="h-3 w-3" />
|
<Calendar className="h-3 w-3" />
|
||||||
Joined {formatDate(profile.created_at)}
|
Joined {formatDate(profile.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold",
|
||||||
|
profile.status === "ACTIVE"
|
||||||
|
? "bg-mint-50 text-mint-600"
|
||||||
|
: "bg-destructive/10 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 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>
|
</div>
|
||||||
|
|
||||||
{/* ─── Detail Cards Grid ─── */}
|
{/* About / Contact */}
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div>
|
||||||
{/* ── Contact & Personal ── */}
|
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
|
||||||
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md lg:col-span-2">
|
About
|
||||||
<div className="h-1 bg-gradient-to-r from-brand-500 to-brand-400" />
|
</h3>
|
||||||
<CardContent className="p-0">
|
<div className="space-y-1.5 rounded-xl border border-grayScale-100 bg-grayScale-50/60 px-3 py-3">
|
||||||
<div className="grid divide-y divide-grayScale-100 sm:grid-cols-2 sm:divide-x sm:divide-y-0">
|
<InfoRow icon={Phone} label="Phone" value={profile.phone_number} extra={<VerifiedIcon verified={profile.phone_verified} />} />
|
||||||
{/* Contact */}
|
<InfoRow icon={Mail} label="Email" value={profile.email} extra={<VerifiedIcon verified={profile.email_verified} />} />
|
||||||
<div className="p-5">
|
<InfoRow
|
||||||
<p className="mb-3 text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
|
|
||||||
Contact
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<DetailItem
|
|
||||||
icon={Mail}
|
|
||||||
label="Email"
|
|
||||||
value={profile.email}
|
|
||||||
extra={<VerifiedIcon verified={profile.email_verified} />}
|
|
||||||
/>
|
|
||||||
<DetailItem
|
|
||||||
icon={Phone}
|
|
||||||
label="Phone"
|
|
||||||
value={profile.phone_number}
|
|
||||||
extra={<VerifiedIcon verified={profile.phone_verified} />}
|
|
||||||
/>
|
|
||||||
<DetailItem
|
|
||||||
icon={MapPin}
|
icon={MapPin}
|
||||||
label="Location"
|
label="Location"
|
||||||
value={[profile.region, profile.country].filter(Boolean).join(", ") || "—"}
|
value={[profile.region, profile.country].filter(Boolean).join(", ") || "—"}
|
||||||
editing={editing}
|
|
||||||
editNode={
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
className="h-8 text-sm"
|
|
||||||
value={editForm.region ?? ""}
|
|
||||||
onChange={(e) => updateField("region", e.target.value)}
|
|
||||||
placeholder="Region"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
className="h-8 text-sm"
|
|
||||||
value={editForm.country ?? ""}
|
|
||||||
onChange={(e) => updateField("country", e.target.value)}
|
|
||||||
placeholder="Country"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DetailItem
|
|
||||||
icon={Globe}
|
|
||||||
label="Preferred Language"
|
|
||||||
value={
|
|
||||||
{ en: "English", am: "Amharic", or: "Oromiffa", ti: "Tigrinya" }[
|
|
||||||
profile.preferred_language
|
|
||||||
] ?? profile.preferred_language ?? "—"
|
|
||||||
}
|
|
||||||
editing={editing}
|
|
||||||
editNode={
|
|
||||||
<Select
|
|
||||||
className="h-8 text-sm"
|
|
||||||
value={editForm.preferred_language ?? ""}
|
|
||||||
onChange={(e) => updateField("preferred_language", e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">Select</option>
|
|
||||||
<option value="en">English</option>
|
|
||||||
<option value="am">Amharic</option>
|
|
||||||
<option value="or">Oromiffa</option>
|
|
||||||
<option value="ti">Tigrinya</option>
|
|
||||||
</Select>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Personal */}
|
{/* Employee details */}
|
||||||
<div className="p-5">
|
|
||||||
<p className="mb-3 text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
|
|
||||||
Personal
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<DetailItem
|
|
||||||
icon={Calendar}
|
|
||||||
label="Date of Birth"
|
|
||||||
value={formatDate(profile.birth_day)}
|
|
||||||
editing={editing}
|
|
||||||
editNode={
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
className="h-8 text-sm"
|
|
||||||
value={editForm.birth_day ?? ""}
|
|
||||||
onChange={(e) => updateField("birth_day", e.target.value)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DetailItem
|
|
||||||
icon={User}
|
|
||||||
label="Gender"
|
|
||||||
value={profile.gender || "Not specified"}
|
|
||||||
editing={editing}
|
|
||||||
editNode={
|
|
||||||
<Select
|
|
||||||
className="h-8 text-sm"
|
|
||||||
value={editForm.gender ?? ""}
|
|
||||||
onChange={(e) => updateField("gender", e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">Select</option>
|
|
||||||
<option value="Male">Male</option>
|
|
||||||
<option value="Female">Female</option>
|
|
||||||
<option value="Other">Other</option>
|
|
||||||
</Select>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DetailItem
|
|
||||||
icon={User}
|
|
||||||
label="Age Group"
|
|
||||||
value={profile.age_group?.replace("_", "–") || "—"}
|
|
||||||
editing={editing}
|
|
||||||
editNode={
|
|
||||||
<Select
|
|
||||||
className="h-8 text-sm"
|
|
||||||
value={editForm.age_group ?? ""}
|
|
||||||
onChange={(e) => updateField("age_group", e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">Select</option>
|
|
||||||
<option value="18_24">18–24</option>
|
|
||||||
<option value="25_34">25–34</option>
|
|
||||||
<option value="35_44">35–44</option>
|
|
||||||
<option value="45_54">45–54</option>
|
|
||||||
<option value="55_64">55–64</option>
|
|
||||||
<option value="65+">65+</option>
|
|
||||||
</Select>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DetailItem
|
|
||||||
icon={Briefcase}
|
|
||||||
label="Occupation"
|
|
||||||
value={profile.occupation || "—"}
|
|
||||||
editing={editing}
|
|
||||||
editNode={
|
|
||||||
<Input
|
|
||||||
className="h-8 text-sm"
|
|
||||||
value={editForm.occupation ?? ""}
|
|
||||||
onChange={(e) => updateField("occupation", e.target.value)}
|
|
||||||
placeholder="Occupation"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DetailItem
|
|
||||||
icon={BookOpen}
|
|
||||||
label="Education"
|
|
||||||
value={profile.education_level || "—"}
|
|
||||||
editing={editing}
|
|
||||||
editNode={
|
|
||||||
<Input
|
|
||||||
className="h-8 text-sm"
|
|
||||||
value={editForm.education_level ?? ""}
|
|
||||||
onChange={(e) => updateField("education_level", e.target.value)}
|
|
||||||
placeholder="Education level"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ── Right Sidebar ── */}
|
|
||||||
<div className="space-y-6 lg:sticky lg:top-24 lg:self-start">
|
|
||||||
{/* Profile Completion */}
|
|
||||||
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md">
|
|
||||||
<div className="h-1 bg-gradient-to-r from-brand-400 to-mint-400" />
|
|
||||||
<CardContent className="flex items-center gap-4 p-5">
|
|
||||||
<ProgressRing percent={completionPct} />
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-grayScale-700">Profile Completion</p>
|
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
|
||||||
<p className="mt-0.5 text-xs text-grayScale-400">
|
Employee details
|
||||||
{completionPct === 100 ? "All set!" : "Complete your profile for the best experience."}
|
</h3>
|
||||||
</p>
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-xs sm:text-sm text-grayScale-500">
|
||||||
|
<div>
|
||||||
|
<dt className="text-grayScale-400">Date of birth</dt>
|
||||||
|
<dd className="mt-0.5 font-medium text-grayScale-700">
|
||||||
|
{formatDate(profile.birth_day)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-grayScale-400">Age</dt>
|
||||||
|
<dd className="mt-0.5 font-medium text-grayScale-700">
|
||||||
|
{profile.age ? `${profile.age} years` : "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-grayScale-400">Gender</dt>
|
||||||
|
<dd className="mt-0.5 font-medium text-grayScale-700">
|
||||||
|
{profile.gender || "Not specified"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-grayScale-400">Age group</dt>
|
||||||
|
<dd className="mt-0.5 font-medium text-grayScale-700">
|
||||||
|
{profile.age_group || "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-grayScale-400">Occupation</dt>
|
||||||
|
<dd className="mt-0.5 font-medium text-grayScale-700">
|
||||||
|
{profile.occupation || "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-grayScale-400">Preferred language</dt>
|
||||||
|
<dd className="mt-0.5 font-medium text-grayScale-700">
|
||||||
|
{profile.preferred_language || "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
|
{/* Right column: Activity & account summary */}
|
||||||
|
<div className="space-y-6">
|
||||||
{/* Activity */}
|
{/* Activity */}
|
||||||
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md">
|
<div>
|
||||||
<div className="h-1 bg-gradient-to-r from-grayScale-300 to-grayScale-200" />
|
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
|
||||||
<CardContent className="space-y-4 p-5">
|
|
||||||
<p className="text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
|
|
||||||
Activity
|
Activity
|
||||||
</p>
|
</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 items-center gap-3">
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-brand-50 text-brand-500">
|
<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" />
|
<Clock className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-grayScale-600">Last Login</p>
|
<p className="text-sm font-medium text-grayScale-700">
|
||||||
<p className="text-[11px] text-grayScale-400">{formatDateTime(profile.last_login)}</p>
|
Last login
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-grayScale-400">
|
||||||
|
{formatDateTime(profile.last_login)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<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-400">
|
<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" />
|
<User className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-grayScale-600">Account Created</p>
|
<p className="text-sm font-medium text-grayScale-700">
|
||||||
<p className="text-[11px] text-grayScale-400">{formatDateTime(profile.created_at)}</p>
|
Account created
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-grayScale-400">
|
||||||
|
{formatDateTime(profile.created_at)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Quick Account Info */}
|
{/* Account summary */}
|
||||||
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md">
|
<div>
|
||||||
<div className="h-1 bg-gradient-to-r from-brand-500 to-brand-600" />
|
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
|
||||||
<CardContent className="space-y-3 p-5">
|
|
||||||
<p className="text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
|
|
||||||
Account
|
Account
|
||||||
</p>
|
</h3>
|
||||||
<div className="space-y-2.5">
|
<Card className="shadow-none border-grayScale-100">
|
||||||
|
<CardContent className="space-y-3 p-4">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-grayScale-400">Role</span>
|
<span className="text-grayScale-400">Role</span>
|
||||||
<Badge
|
<span className="font-medium text-grayScale-700">{profile.role}</span>
|
||||||
className={cn(
|
|
||||||
"text-[10px] font-semibold",
|
|
||||||
profile.role === "ADMIN"
|
|
||||||
? "bg-brand-500/10 text-brand-600 border border-brand-500/20"
|
|
||||||
: "bg-grayScale-50 text-grayScale-600 border border-grayScale-200",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{profile.role}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-grayScale-400">Status</span>
|
<span className="text-grayScale-400">Status</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1.5 text-xs font-semibold",
|
"inline-flex items-center gap-1.5 text-xs font-semibold",
|
||||||
profile.status === "ACTIVE" ? "text-mint-600" : "text-destructive",
|
profile.status === "ACTIVE"
|
||||||
|
? "text-mint-600"
|
||||||
|
: "text-destructive"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-1.5 w-1.5 rounded-full",
|
"h-1.5 w-1.5 rounded-full",
|
||||||
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive",
|
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{profile.status}
|
{profile.status}
|
||||||
|
|
@ -666,8 +421,8 @@ export function ProfilePage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-grayScale-400">Email</span>
|
<span className="text-grayScale-400">Email</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1 text-grayScale-700">
|
||||||
<span className="max-w-[130px] truncate text-xs text-grayScale-600">
|
<span className="truncate max-w-[140px] text-right text-xs sm:text-sm">
|
||||||
{profile.email}
|
{profile.email}
|
||||||
</span>
|
</span>
|
||||||
<VerifiedIcon verified={profile.email_verified} />
|
<VerifiedIcon verified={profile.email_verified} />
|
||||||
|
|
@ -675,96 +430,20 @@ export function ProfilePage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-grayScale-400">Phone</span>
|
<span className="text-grayScale-400">Phone</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1 text-grayScale-700">
|
||||||
<span className="max-w-[110px] truncate text-xs text-grayScale-600">
|
<span className="truncate max-w-[120px] text-right text-xs sm:text-sm">
|
||||||
{profile.phone_number || "—"}
|
{profile.phone_number || "—"}
|
||||||
</span>
|
</span>
|
||||||
<VerifiedIcon verified={profile.phone_verified} />
|
<VerifiedIcon verified={profile.phone_verified} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─── Learning & Goals Card ─── */}
|
|
||||||
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md">
|
|
||||||
<div className="h-1 bg-gradient-to-r from-brand-600 via-brand-500 to-brand-400" />
|
|
||||||
<div className="border-b border-grayScale-100 px-5 py-3">
|
|
||||||
<p className="text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
|
|
||||||
Learning & Preferences
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="grid divide-y divide-grayScale-100 sm:grid-cols-2 sm:divide-x sm:divide-y-0 lg:grid-cols-4 lg:divide-x lg:divide-y-0">
|
|
||||||
<div className="p-5">
|
|
||||||
<DetailItem
|
|
||||||
icon={Target}
|
|
||||||
label="Learning Goal"
|
|
||||||
value={profile.learning_goal || "—"}
|
|
||||||
editing={editing}
|
|
||||||
editNode={
|
|
||||||
<Input
|
|
||||||
className="h-8 text-sm"
|
|
||||||
value={editForm.learning_goal ?? ""}
|
|
||||||
onChange={(e) => updateField("learning_goal", e.target.value)}
|
|
||||||
placeholder="Your learning goal"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="p-5">
|
|
||||||
<DetailItem
|
|
||||||
icon={Languages}
|
|
||||||
label="Language Goal"
|
|
||||||
value={profile.language_goal || "—"}
|
|
||||||
editing={editing}
|
|
||||||
editNode={
|
|
||||||
<Input
|
|
||||||
className="h-8 text-sm"
|
|
||||||
value={editForm.language_goal ?? ""}
|
|
||||||
onChange={(e) => updateField("language_goal", e.target.value)}
|
|
||||||
placeholder="Language goal"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="p-5">
|
|
||||||
<DetailItem
|
|
||||||
icon={MessageCircle}
|
|
||||||
label="Language Challenge"
|
|
||||||
value={profile.language_challange || "—"}
|
|
||||||
editing={editing}
|
|
||||||
editNode={
|
|
||||||
<Input
|
|
||||||
className="h-8 text-sm"
|
|
||||||
value={editForm.language_challange ?? ""}
|
|
||||||
onChange={(e) => updateField("language_challange", e.target.value)}
|
|
||||||
placeholder="Language challenge"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="p-5">
|
|
||||||
<DetailItem
|
|
||||||
icon={Heart}
|
|
||||||
label="Favourite Topic"
|
|
||||||
value={profile.favoutite_topic || "—"}
|
|
||||||
editing={editing}
|
|
||||||
editNode={
|
|
||||||
<Input
|
|
||||||
className="h-8 text-sm"
|
|
||||||
value={editForm.favoutite_topic ?? ""}
|
|
||||||
onChange={(e) => updateField("favoutite_topic", e.target.value)}
|
|
||||||
placeholder="Favourite topic"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import { Button } from "../components/ui/button";
|
||||||
import { Select } from "../components/ui/select";
|
import { Select } from "../components/ui/select";
|
||||||
import { Separator } from "../components/ui/separator";
|
import { Separator } from "../components/ui/separator";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { getMyProfile, updateProfile } from "../api/users.api";
|
import { getMyProfile } from "../api/users.api";
|
||||||
import type { UserProfileData } from "../types/user.types";
|
import type { UserProfileData } from "../types/user.types";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
|
@ -127,15 +127,9 @@ function ProfileTab({ profile }: { profile: UserProfileData }) {
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await updateProfile({
|
// placeholder — wire up to API when endpoint is ready
|
||||||
first_name: firstName,
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
last_name: lastName,
|
|
||||||
nick_name: nickName,
|
|
||||||
preferred_language: language,
|
|
||||||
});
|
|
||||||
toast.success("Profile settings saved");
|
toast.success("Profile settings saved");
|
||||||
} catch {
|
|
||||||
toast.error("Failed to save profile settings.");
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
|
||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
AreaChart,
|
AreaChart,
|
||||||
|
|
@ -15,7 +14,6 @@ import {
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts"
|
} from "recharts"
|
||||||
import alertSrc from "../../assets/Alert.svg"
|
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
|
|
@ -307,10 +305,7 @@ export function AnalyticsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-6xl">
|
<div className="mx-auto w-full max-w-6xl">
|
||||||
<div className="mb-4 text-sm font-semibold text-grayScale-500">Analytics</div>
|
<div className="mb-4 text-sm font-semibold text-grayScale-500">Analytics</div>
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-20">
|
<div className="flex items-center justify-center py-20 text-grayScale-500">Loading analytics…</div>
|
||||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
|
||||||
<span className="text-sm font-medium text-grayScale-400">Loading analytics…</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -320,7 +315,6 @@ export function AnalyticsPage() {
|
||||||
<div className="mx-auto w-full max-w-6xl">
|
<div className="mx-auto w-full max-w-6xl">
|
||||||
<div className="mb-4 text-sm font-semibold text-grayScale-500">Analytics</div>
|
<div className="mb-4 text-sm font-semibold text-grayScale-500">Analytics</div>
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-20">
|
<div className="flex flex-col items-center justify-center gap-3 py-20">
|
||||||
<img src={alertSrc} alt="" className="h-12 w-12" />
|
|
||||||
<span className="text-sm text-destructive">Failed to load analytics data.</span>
|
<span className="text-sm text-destructive">Failed to load analytics data.</span>
|
||||||
<Button variant="outline" size="sm" onClick={fetchData}>
|
<Button variant="outline" size="sm" onClick={fetchData}>
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
import { ArrowLeft } from "lucide-react"
|
import { ArrowLeft, Mail } from "lucide-react"
|
||||||
import successSrc from "../../assets/success.svg"
|
|
||||||
|
|
||||||
import { BrandLogo } from "../../components/brand/BrandLogo"
|
import { BrandLogo } from "../../components/brand/BrandLogo"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
|
|
@ -31,8 +30,13 @@ export function ForgotPasswordPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10 max-w-md px-12 text-center">
|
<div className="relative z-10 max-w-md px-12 text-center">
|
||||||
{/* Brand logo */}
|
{/* Large brand icon */}
|
||||||
<BrandLogo variant="light" className="mx-auto mb-8 h-16" />
|
<div className="mx-auto mb-8 grid h-20 w-20 place-items-center rounded-2xl bg-white/15 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="h-9 w-9 rotate-45 rounded-lg bg-white/90" />
|
||||||
|
</div>
|
||||||
|
<h2 className="mb-4 text-3xl font-bold tracking-tight text-white">
|
||||||
|
Yimaru Academy
|
||||||
|
</h2>
|
||||||
<p className="text-base leading-relaxed text-white/70">
|
<p className="text-base leading-relaxed text-white/70">
|
||||||
Manage your academy, track student progress, and streamline
|
Manage your academy, track student progress, and streamline
|
||||||
operations — all from one powerful dashboard.
|
operations — all from one powerful dashboard.
|
||||||
|
|
@ -60,7 +64,9 @@ export function ForgotPasswordPage() {
|
||||||
{submitted ? (
|
{submitted ? (
|
||||||
/* Success state */
|
/* Success state */
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<img src={successSrc} alt="" className="mx-auto mb-6 h-20 w-20" />
|
<div className="mx-auto mb-6 grid h-16 w-16 place-items-center rounded-full bg-brand-100/60">
|
||||||
|
<Mail className="h-7 w-7 text-brand-500" />
|
||||||
|
</div>
|
||||||
<h1 className="mb-2 text-2xl font-bold tracking-tight text-grayScale-600">
|
<h1 className="mb-2 text-2xl font-bold tracking-tight text-grayScale-600">
|
||||||
Check your email
|
Check your email
|
||||||
</h1>
|
</h1>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { Link, Navigate, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
|
|
||||||
import { BrandLogo } from "../../components/brand/BrandLogo";
|
import { BrandLogo } from "../../components/brand/BrandLogo";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
|
|
||||||
|
|
||||||
import { login, loginWithGoogle } from "../../api/auth.api";
|
import { login, loginWithGoogle } from "../../api/auth.api";
|
||||||
import type { LoginRequest } from "../../types/auth.types";
|
import type { LoginRequest } from "../../types/auth.types";
|
||||||
import type { LoginResult } from "../../api/auth.api";
|
import type { LoginResult } from "../../api/auth.api";
|
||||||
|
|
@ -65,11 +64,6 @@ function GoogleIcon({ className }: { className?: string }) {
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const token = localStorage.getItem("access_token");
|
|
||||||
if (token) {
|
|
||||||
return <Navigate to="/dashboard" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
|
@ -217,8 +211,13 @@ export function LoginPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10 max-w-md px-12 text-center">
|
<div className="relative z-10 max-w-md px-12 text-center">
|
||||||
{/* Brand logo */}
|
{/* Large brand icon */}
|
||||||
<BrandLogo variant="light" className="mx-auto mb-8 h-16" />
|
<div className="mx-auto mb-8 grid h-20 w-20 place-items-center rounded-2xl bg-white/15 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="h-9 w-9 rotate-45 rounded-lg bg-white/90" />
|
||||||
|
</div>
|
||||||
|
<h2 className="mb-4 text-3xl font-bold tracking-tight text-white">
|
||||||
|
Yimaru Academy Test Mode
|
||||||
|
</h2>
|
||||||
<p className="text-base leading-relaxed text-white/70">
|
<p className="text-base leading-relaxed text-white/70">
|
||||||
Manage your academy, track student progress, and streamline
|
Manage your academy, track student progress, and streamline
|
||||||
operations — all from one powerful dashboard.
|
operations — all from one powerful dashboard.
|
||||||
|
|
@ -404,15 +403,13 @@ export function LoginPage() {
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="mt-10 text-center text-xs text-grayScale-400">
|
<div className="mt-10 text-center text-xs text-grayScale-400">
|
||||||
<p>© {new Date().getFullYear()} Yimaru Academy · All rights reserved</p>
|
<p>© {new Date().getFullYear()} Yimaru Academy · All rights reserved</p>
|
||||||
</div>
|
<p className="mt-1 font-mono text-[10px] text-grayScale-300">
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Version badge */}
|
|
||||||
<p className="fixed bottom-3 right-4 font-mono text-[10px] text-grayScale-300">
|
|
||||||
v{__BUILD_HASH__} · {new Date(__BUILD_TIME__).toLocaleDateString()}
|
v{__BUILD_HASH__} · {new Date(__BUILD_TIME__).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -889,7 +889,7 @@ export function AddNewPracticePage() {
|
||||||
className="w-full bg-brand-500 hover:bg-brand-600"
|
className="w-full bg-brand-500 hover:bg-brand-600"
|
||||||
onClick={() => navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`)}
|
onClick={() => navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`)}
|
||||||
>
|
>
|
||||||
Go back to Course
|
Go back to Sub-course
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, 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 { toast } from "sonner"
|
||||||
|
|
@ -7,41 +7,30 @@ import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/ca
|
||||||
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 { createQuestion, getQuestionById, updateQuestion } from "../../api/courses.api"
|
|
||||||
|
|
||||||
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
|
type QuestionType = "multiple-choice" | "short-answer" | "true-false"
|
||||||
type Difficulty = "EASY" | "MEDIUM" | "HARD"
|
|
||||||
type QuestionStatus = "DRAFT" | "PUBLISHED" | "INACTIVE"
|
|
||||||
|
|
||||||
interface Question {
|
interface Question {
|
||||||
id?: number
|
id: string
|
||||||
question: string
|
question: string
|
||||||
type: QuestionType
|
type: QuestionType
|
||||||
options: string[]
|
options: string[]
|
||||||
correctAnswer: string
|
correctAnswer: string
|
||||||
points: number
|
points: number
|
||||||
difficulty: Difficulty
|
category?: string
|
||||||
status: QuestionStatus
|
difficulty?: string
|
||||||
tips: string
|
|
||||||
explanation: string
|
|
||||||
voicePrompt: string
|
|
||||||
sampleAnswerVoicePrompt: string
|
|
||||||
audioCorrectAnswerText: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialForm: Question = {
|
// Mock data for editing
|
||||||
|
const mockQuestion: Question = {
|
||||||
|
id: "1",
|
||||||
question: "",
|
question: "",
|
||||||
type: "MCQ",
|
type: "multiple-choice",
|
||||||
options: ["", "", "", ""],
|
options: ["", "", "", ""],
|
||||||
correctAnswer: "",
|
correctAnswer: "",
|
||||||
points: 1,
|
points: 10,
|
||||||
difficulty: "EASY",
|
category: "",
|
||||||
status: "PUBLISHED",
|
difficulty: "",
|
||||||
tips: "",
|
|
||||||
explanation: "",
|
|
||||||
voicePrompt: "",
|
|
||||||
sampleAnswerVoicePrompt: "",
|
|
||||||
audioCorrectAnswerText: "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddQuestionPage() {
|
export function AddQuestionPage() {
|
||||||
|
|
@ -49,83 +38,36 @@ export function AddQuestionPage() {
|
||||||
const { id } = useParams<{ id?: string }>()
|
const { id } = useParams<{ id?: string }>()
|
||||||
const isEditing = !!id
|
const isEditing = !!id
|
||||||
|
|
||||||
const [formData, setFormData] = useState<Question>(initialForm)
|
const [formData, setFormData] = useState<Question>(
|
||||||
const [loading, setLoading] = useState(false)
|
isEditing
|
||||||
const [submitting, setSubmitting] = useState(false)
|
? mockQuestion // In a real app, fetch the question by id
|
||||||
|
: {
|
||||||
useEffect(() => {
|
id: Date.now().toString(),
|
||||||
const loadQuestion = async () => {
|
question: "",
|
||||||
if (!isEditing || !id) return
|
type: "multiple-choice",
|
||||||
setLoading(true)
|
options: ["", "", "", ""],
|
||||||
try {
|
correctAnswer: "",
|
||||||
const res = await getQuestionById(Number(id))
|
points: 10,
|
||||||
const q = res.data.data
|
category: "",
|
||||||
const mappedType: QuestionType =
|
difficulty: "",
|
||||||
q.question_type === "MCQ" ||
|
},
|
||||||
q.question_type === "TRUE_FALSE" ||
|
)
|
||||||
q.question_type === "SHORT_ANSWER" ||
|
|
||||||
q.question_type === "AUDIO"
|
|
||||||
? q.question_type
|
|
||||||
: "MCQ"
|
|
||||||
const shortAnswer = Array.isArray(q.short_answers) && q.short_answers.length > 0
|
|
||||||
? typeof q.short_answers[0] === "string"
|
|
||||||
? String(q.short_answers[0] || "")
|
|
||||||
: String((q.short_answers[0] as { acceptable_answer?: string }).acceptable_answer || "")
|
|
||||||
: ""
|
|
||||||
setFormData({
|
|
||||||
id: q.id,
|
|
||||||
question: q.question_text || "",
|
|
||||||
type: mappedType,
|
|
||||||
options: (q.options ?? [])
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.option_order - b.option_order)
|
|
||||||
.map((o) => o.option_text) || ["", "", "", ""],
|
|
||||||
correctAnswer:
|
|
||||||
mappedType === "SHORT_ANSWER"
|
|
||||||
? shortAnswer
|
|
||||||
: mappedType === "AUDIO"
|
|
||||||
? q.audio_correct_answer_text || ""
|
|
||||||
: (q.options ?? []).find((o) => o.is_correct)?.option_text || "",
|
|
||||||
points: q.points ?? 1,
|
|
||||||
difficulty:
|
|
||||||
q.difficulty_level === "EASY" || q.difficulty_level === "MEDIUM" || q.difficulty_level === "HARD"
|
|
||||||
? q.difficulty_level
|
|
||||||
: "EASY",
|
|
||||||
status:
|
|
||||||
q.status === "DRAFT" || q.status === "PUBLISHED" || q.status === "INACTIVE"
|
|
||||||
? q.status
|
|
||||||
: "PUBLISHED",
|
|
||||||
tips: q.tips || "",
|
|
||||||
explanation: q.explanation || "",
|
|
||||||
voicePrompt: q.voice_prompt || "",
|
|
||||||
sampleAnswerVoicePrompt: q.sample_answer_voice_prompt || "",
|
|
||||||
audioCorrectAnswerText: q.audio_correct_answer_text || "",
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load question:", error)
|
|
||||||
toast.error("Failed to load question details")
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadQuestion()
|
|
||||||
}, [isEditing, id])
|
|
||||||
|
|
||||||
const handleTypeChange = (type: QuestionType) => {
|
const handleTypeChange = (type: QuestionType) => {
|
||||||
setFormData((prev) => {
|
setFormData((prev) => {
|
||||||
if (type === "TRUE_FALSE") {
|
if (type === "true-false") {
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
type,
|
type,
|
||||||
options: ["True", "False"],
|
options: ["True", "False"],
|
||||||
correctAnswer: prev.correctAnswer === "True" || prev.correctAnswer === "False" ? prev.correctAnswer : "",
|
correctAnswer: prev.correctAnswer === "True" || prev.correctAnswer === "False" ? prev.correctAnswer : "",
|
||||||
}
|
}
|
||||||
} else if (type === "SHORT_ANSWER" || type === "AUDIO") {
|
} else if (type === "short-answer") {
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
type,
|
type,
|
||||||
options: [],
|
options: [],
|
||||||
correctAnswer: type === "AUDIO" ? prev.audioCorrectAnswerText : prev.correctAnswer,
|
correctAnswer: "",
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
|
|
@ -159,7 +101,7 @@ export function AddQuestionPage() {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
|
|
@ -170,14 +112,14 @@ export function AddQuestionPage() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.type === "MCQ" || formData.type === "TRUE_FALSE") {
|
if (formData.type === "multiple-choice" || formData.type === "true-false") {
|
||||||
if (!formData.correctAnswer) {
|
if (!formData.correctAnswer) {
|
||||||
toast.error("Missing correct answer", {
|
toast.error("Missing correct answer", {
|
||||||
description: "Select the correct answer for this question.",
|
description: "Select the correct answer for this question.",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (formData.type === "MCQ") {
|
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", {
|
toast.error("Incomplete options", {
|
||||||
|
|
@ -186,74 +128,23 @@ export function AddQuestionPage() {
|
||||||
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", {
|
toast.error("Missing correct answer", {
|
||||||
description: "Enter the expected correct answer.",
|
description: "Enter the expected correct answer.",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else if (formData.type === "AUDIO") {
|
|
||||||
if (!formData.voicePrompt.trim() || !formData.sampleAnswerVoicePrompt.trim() || !formData.audioCorrectAnswerText.trim()) {
|
|
||||||
toast.error("Missing audio fields", {
|
|
||||||
description: "Voice prompt, sample answer voice prompt, and audio correct answer text are required for AUDIO questions.",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true)
|
// In a real app, save the question here
|
||||||
try {
|
console.log("Saving question:", formData)
|
||||||
const optionsPayload =
|
|
||||||
formData.type === "MCQ" || formData.type === "TRUE_FALSE"
|
|
||||||
? formData.options
|
|
||||||
.filter((o) => o.trim())
|
|
||||||
.map((optionText, index) => ({
|
|
||||||
option_text: optionText.trim(),
|
|
||||||
option_order: index + 1,
|
|
||||||
is_correct: optionText === formData.correctAnswer,
|
|
||||||
}))
|
|
||||||
: undefined
|
|
||||||
const shortAnswersPayload =
|
|
||||||
formData.type === "SHORT_ANSWER"
|
|
||||||
? [
|
|
||||||
{ acceptable_answer: formData.correctAnswer.trim(), match_type: "EXACT" as const },
|
|
||||||
{ acceptable_answer: formData.correctAnswer.trim(), match_type: "CASE_INSENSITIVE" as const },
|
|
||||||
]
|
|
||||||
: undefined
|
|
||||||
const payload = {
|
|
||||||
question_text: formData.question,
|
|
||||||
question_type: formData.type,
|
|
||||||
status: formData.status,
|
|
||||||
difficulty_level: formData.difficulty,
|
|
||||||
points: formData.points,
|
|
||||||
tips: formData.tips || undefined,
|
|
||||||
explanation: formData.explanation || undefined,
|
|
||||||
options: optionsPayload,
|
|
||||||
short_answers: shortAnswersPayload,
|
|
||||||
voice_prompt: formData.type === "AUDIO" ? formData.voicePrompt : formData.voicePrompt || undefined,
|
|
||||||
sample_answer_voice_prompt:
|
|
||||||
formData.type === "AUDIO" ? formData.sampleAnswerVoicePrompt : formData.sampleAnswerVoicePrompt || undefined,
|
|
||||||
audio_correct_answer_text:
|
|
||||||
formData.type === "AUDIO" ? formData.audioCorrectAnswerText : undefined,
|
|
||||||
}
|
|
||||||
if (isEditing && id) {
|
|
||||||
await updateQuestion(Number(id), payload)
|
|
||||||
} else {
|
|
||||||
await createQuestion(payload)
|
|
||||||
}
|
|
||||||
toast.success(isEditing ? "Question updated" : "Question created", {
|
toast.success(isEditing ? "Question updated" : "Question created", {
|
||||||
description: isEditing
|
description: isEditing
|
||||||
? "The question has been updated successfully."
|
? "The question has been updated successfully."
|
||||||
: "Your new question has been created.",
|
: "Your new question has been created.",
|
||||||
})
|
})
|
||||||
navigate("/content/questions")
|
navigate("/content/questions")
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save question:", error)
|
|
||||||
toast.error("Failed to save question")
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -279,11 +170,6 @@ export function AddQuestionPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
{loading && (
|
|
||||||
<Card className="mb-4 border border-grayScale-200">
|
|
||||||
<CardContent className="py-4 text-sm text-grayScale-500">Loading question details...</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<Card className="shadow-sm border border-grayScale-100 rounded-xl">
|
<Card className="shadow-sm border border-grayScale-100 rounded-xl">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
|
|
@ -299,10 +185,9 @@ export function AddQuestionPage() {
|
||||||
value={formData.type}
|
value={formData.type}
|
||||||
onChange={(e) => handleTypeChange(e.target.value as QuestionType)}
|
onChange={(e) => handleTypeChange(e.target.value as QuestionType)}
|
||||||
>
|
>
|
||||||
<option value="MCQ">Multiple Choice</option>
|
<option value="multiple-choice">Multiple Choice</option>
|
||||||
<option value="TRUE_FALSE">True/False</option>
|
<option value="short-answer">Short Answer</option>
|
||||||
<option value="SHORT_ANSWER">Short Answer</option>
|
<option value="true-false">True/False</option>
|
||||||
<option value="AUDIO">Audio</option>
|
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -324,7 +209,7 @@ export function AddQuestionPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Options for Multiple Choice */}
|
{/* Options for Multiple Choice */}
|
||||||
{(formData.type === "MCQ" || formData.type === "TRUE_FALSE") && (
|
{(formData.type === "multiple-choice" || formData.type === "true-false") && (
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Options
|
Options
|
||||||
|
|
@ -339,10 +224,10 @@ export function AddQuestionPage() {
|
||||||
value={option}
|
value={option}
|
||||||
onChange={(e) => handleOptionChange(index, e.target.value)}
|
onChange={(e) => handleOptionChange(index, e.target.value)}
|
||||||
placeholder={`Option ${index + 1}`}
|
placeholder={`Option ${index + 1}`}
|
||||||
disabled={formData.type === "TRUE_FALSE"}
|
disabled={formData.type === "true-false"}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{formData.type === "MCQ" && formData.options.length > 2 && (
|
{formData.type === "multiple-choice" && formData.options.length > 2 && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -355,7 +240,7 @@ export function AddQuestionPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{formData.type === "MCQ" && (
|
{formData.type === "multiple-choice" && (
|
||||||
<Button type="button" variant="outline" onClick={addOption} className="w-full mt-1 border-dashed border-grayScale-200 text-grayScale-400 hover:text-brand-500 hover:border-brand-500/30">
|
<Button type="button" variant="outline" onClick={addOption} className="w-full mt-1 border-dashed border-grayScale-200 text-grayScale-400 hover:text-brand-500 hover:border-brand-500/30">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Add Option
|
Add Option
|
||||||
|
|
@ -370,9 +255,9 @@ export function AddQuestionPage() {
|
||||||
{/* Correct Answer */}
|
{/* Correct Answer */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
{formData.type === "AUDIO" ? "Audio Correct Answer Text" : "Correct Answer"}
|
Correct Answer
|
||||||
</label>
|
</label>
|
||||||
{formData.type === "MCQ" || formData.type === "TRUE_FALSE" ? (
|
{formData.type === "multiple-choice" || formData.type === "true-false" ? (
|
||||||
<Select
|
<Select
|
||||||
value={formData.correctAnswer}
|
value={formData.correctAnswer}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
|
|
@ -389,14 +274,10 @@ export function AddQuestionPage() {
|
||||||
</Select>
|
</Select>
|
||||||
) : (
|
) : (
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder={formData.type === "AUDIO" ? "Enter audio correct answer text..." : "Enter the correct answer..."}
|
placeholder="Enter the correct answer..."
|
||||||
value={formData.type === "AUDIO" ? formData.audioCorrectAnswerText : formData.correctAnswer}
|
value={formData.correctAnswer}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData((prev) =>
|
setFormData((prev) => ({ ...prev, correctAnswer: e.target.value }))
|
||||||
formData.type === "AUDIO"
|
|
||||||
? { ...prev, audioCorrectAnswerText: e.target.value }
|
|
||||||
: { ...prev, correctAnswer: e.target.value },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
rows={2}
|
rows={2}
|
||||||
required
|
required
|
||||||
|
|
@ -419,7 +300,7 @@ export function AddQuestionPage() {
|
||||||
min="1"
|
min="1"
|
||||||
value={formData.points}
|
value={formData.points}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData((prev) => ({ ...prev, points: parseInt(e.target.value) || 1 }))
|
setFormData((prev) => ({ ...prev, points: parseInt(e.target.value) || 0 }))
|
||||||
}
|
}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
@ -431,74 +312,27 @@ export function AddQuestionPage() {
|
||||||
Difficulty (Optional)
|
Difficulty (Optional)
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.difficulty}
|
value={formData.difficulty || ""}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, difficulty: e.target.value as Difficulty }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, difficulty: e.target.value }))}
|
||||||
>
|
>
|
||||||
<option value="EASY">Easy</option>
|
<option value="">Select difficulty</option>
|
||||||
<option value="MEDIUM">Medium</option>
|
<option value="Easy">Easy</option>
|
||||||
<option value="HARD">Hard</option>
|
<option value="Medium">Medium</option>
|
||||||
|
<option value="Hard">Hard</option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status */}
|
{/* Category */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
<label htmlFor="category" className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
Status
|
Category (Optional)
|
||||||
</label>
|
</label>
|
||||||
<Select
|
|
||||||
value={formData.status}
|
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, status: e.target.value as QuestionStatus }))}
|
|
||||||
>
|
|
||||||
<option value="DRAFT">Draft</option>
|
|
||||||
<option value="PUBLISHED">Published</option>
|
|
||||||
<option value="INACTIVE">Inactive</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(formData.type === "AUDIO" || formData.type === "SHORT_ANSWER") && (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
|
||||||
Voice Prompt{formData.type === "AUDIO" ? "" : " (Optional)"}
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={formData.voicePrompt}
|
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, voicePrompt: e.target.value }))}
|
|
||||||
rows={2}
|
|
||||||
placeholder="Please say your answer..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
|
||||||
Sample Answer Voice Prompt{formData.type === "AUDIO" ? "" : " (Optional)"}
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={formData.sampleAnswerVoicePrompt}
|
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, sampleAnswerVoicePrompt: e.target.value }))}
|
|
||||||
rows={2}
|
|
||||||
placeholder="Sample spoken answer..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">Tips (Optional)</label>
|
|
||||||
<Input
|
<Input
|
||||||
value={formData.tips}
|
id="category"
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, tips: e.target.value }))}
|
placeholder="e.g., Programming, Geography"
|
||||||
placeholder="Helpful tip for learners"
|
value={formData.category || ""}
|
||||||
/>
|
onChange={(e) => setFormData((prev) => ({ ...prev, category: e.target.value }))}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">Explanation (Optional)</label>
|
|
||||||
<Textarea
|
|
||||||
value={formData.explanation}
|
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, explanation: e.target.value }))}
|
|
||||||
rows={2}
|
|
||||||
placeholder="Explain why the answer is correct"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -507,7 +341,7 @@ export function AddQuestionPage() {
|
||||||
<Button type="button" variant="outline" onClick={() => navigate("/content/questions")} className="w-full sm:w-auto hover:bg-grayScale-50">
|
<Button type="button" variant="outline" onClick={() => navigate("/content/questions")} className="w-full sm:w-auto hover:bg-grayScale-50">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={submitting || loading} className="bg-brand-500 hover:bg-brand-600 text-white w-full sm:w-auto shadow-sm hover:shadow-md transition-all">
|
<Button type="submit" className="bg-brand-500 hover:bg-brand-600 text-white w-full sm:w-auto shadow-sm hover:shadow-md transition-all">
|
||||||
{isEditing ? "Update Question" : "Create Question"}
|
{isEditing ? "Update Question" : "Create Question"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ export function AllCoursesPage() {
|
||||||
setCourses(allCourses)
|
setCourses(allCourses)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load courses:", err)
|
console.error("Failed to load courses:", err)
|
||||||
setError("Failed to load sub-categories")
|
setError("Failed to load courses")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -116,7 +116,7 @@ export function AllCoursesPage() {
|
||||||
description: createDescription.trim(),
|
description: createDescription.trim(),
|
||||||
})
|
})
|
||||||
|
|
||||||
toast.success("Sub-category created", {
|
toast.success("Course created", {
|
||||||
description: `"${createTitle.trim()}" has been created.`,
|
description: `"${createTitle.trim()}" has been created.`,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -130,7 +130,7 @@ export function AllCoursesPage() {
|
||||||
await fetchAllCourses()
|
await fetchAllCourses()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Failed to create course:", err)
|
console.error("Failed to create course:", err)
|
||||||
toast.error("Failed to create sub-category", {
|
toast.error("Failed to create course", {
|
||||||
description: err?.response?.data?.message || "Please try again.",
|
description: err?.response?.data?.message || "Please try again.",
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -145,7 +145,7 @@ export function AllCoursesPage() {
|
||||||
await fetchAllCourses()
|
await fetchAllCourses()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to update course status:", err)
|
console.error("Failed to update course status:", err)
|
||||||
toast.error("Failed to update sub-category status")
|
toast.error("Failed to update course status")
|
||||||
} finally {
|
} finally {
|
||||||
setTogglingId(null)
|
setTogglingId(null)
|
||||||
}
|
}
|
||||||
|
|
@ -173,13 +173,13 @@ export function AllCoursesPage() {
|
||||||
title: editTitle.trim(),
|
title: editTitle.trim(),
|
||||||
description: editDescription.trim(),
|
description: editDescription.trim(),
|
||||||
})
|
})
|
||||||
toast.success("Sub-category updated")
|
toast.success("Course updated")
|
||||||
setEditOpen(false)
|
setEditOpen(false)
|
||||||
setCourseToEdit(null)
|
setCourseToEdit(null)
|
||||||
await fetchAllCourses()
|
await fetchAllCourses()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Failed to update course:", err)
|
console.error("Failed to update course:", err)
|
||||||
toast.error("Failed to update sub-category", {
|
toast.error("Failed to update course", {
|
||||||
description: err?.response?.data?.message || "Please try again.",
|
description: err?.response?.data?.message || "Please try again.",
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -193,7 +193,7 @@ export function AllCoursesPage() {
|
||||||
<div className="rounded-2xl bg-white shadow-sm p-6">
|
<div className="rounded-2xl bg-white shadow-sm p-6">
|
||||||
<RefreshCw className="h-10 w-10 animate-spin text-brand-600" />
|
<RefreshCw className="h-10 w-10 animate-spin text-brand-600" />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading all sub-categories…</p>
|
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading all courses…</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -216,9 +216,9 @@ export function AllCoursesPage() {
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">All Sub-categories</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">All Courses</h1>
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
<p className="mt-1 text-sm text-grayScale-400">
|
||||||
View and manage sub-categories across all categories.
|
View and manage courses across all categories.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -226,14 +226,14 @@ export function AllCoursesPage() {
|
||||||
onClick={() => setCreateOpen(true)}
|
onClick={() => setCreateOpen(true)}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Create Sub-category
|
Create Course
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="shadow-soft">
|
<Card className="shadow-soft">
|
||||||
<CardHeader className="border-b border-grayScale-200 pb-4">
|
<CardHeader className="border-b border-grayScale-200 pb-4">
|
||||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||||
Sub-category Management
|
Course Management
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5 pt-5">
|
<CardContent className="space-y-5 pt-5">
|
||||||
|
|
@ -375,9 +375,9 @@ export function AllCoursesPage() {
|
||||||
<div className="mb-4 grid h-16 w-16 place-items-center rounded-full bg-gradient-to-br from-grayScale-100 to-grayScale-200">
|
<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" />
|
<BookOpen className="h-8 w-8 text-grayScale-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base font-semibold text-grayScale-600">No sub-categories found</p>
|
<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">
|
<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 sub-category.
|
Try adjusting your search or category filter, or create a new course.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -388,7 +388,7 @@ export function AllCoursesPage() {
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create sub-category</DialogTitle>
|
<DialogTitle>Create course</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Choose a category, add basic details, and optionally attach a thumbnail and intro
|
Choose a category, add basic details, and optionally attach a thumbnail and intro
|
||||||
video.
|
video.
|
||||||
|
|
@ -439,7 +439,7 @@ export function AllCoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-1">
|
<div className="sm:col-span-1">
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||||
Sub-category title
|
Course title
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="e.g. Beginner English A1"
|
placeholder="e.g. Beginner English A1"
|
||||||
|
|
@ -455,7 +455,7 @@ export function AllCoursesPage() {
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Short summary of what this sub-category covers."
|
placeholder="Short summary of what this course covers."
|
||||||
value={createDescription}
|
value={createDescription}
|
||||||
onChange={(e) => setCreateDescription(e.target.value)}
|
onChange={(e) => setCreateDescription(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -514,7 +514,7 @@ export function AllCoursesPage() {
|
||||||
disabled={creating}
|
disabled={creating}
|
||||||
onClick={handleCreateCourse}
|
onClick={handleCreateCourse}
|
||||||
>
|
>
|
||||||
{creating ? "Creating…" : "Create sub-category"}
|
{creating ? "Creating…" : "Create course"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
@ -524,9 +524,9 @@ export function AllCoursesPage() {
|
||||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent className="max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit sub-category</DialogTitle>
|
<DialogTitle>Edit course</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update the title and description for this sub-category. Status can be toggled from the
|
Update the title and description for this course. Status can be toggled from the
|
||||||
table.
|
table.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
@ -534,12 +534,12 @@ export function AllCoursesPage() {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||||
Sub-category title
|
Course title
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={editTitle}
|
value={editTitle}
|
||||||
onChange={(e) => setEditTitle(e.target.value)}
|
onChange={(e) => setEditTitle(e.target.value)}
|
||||||
placeholder="Enter sub-category title"
|
placeholder="Enter course title"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -550,7 +550,7 @@ export function AllCoursesPage() {
|
||||||
rows={3}
|
rows={3}
|
||||||
value={editDescription}
|
value={editDescription}
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
onChange={(e) => setEditDescription(e.target.value)}
|
||||||
placeholder="Short summary of this sub-category."
|
placeholder="Short summary of this course."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ const contentSections = [
|
||||||
pathFn: (categoryId: string | undefined) => `/content/category/${categoryId}/courses`,
|
pathFn: (categoryId: string | undefined) => `/content/category/${categoryId}/courses`,
|
||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
title: "Courses",
|
title: "Courses",
|
||||||
description: "Manage sub-categories, course videos and educational content",
|
description: "Manage course videos and educational content",
|
||||||
action: "Manage Courses",
|
action: "Manage Courses",
|
||||||
count: 12,
|
count: 12,
|
||||||
countLabel: "courses",
|
countLabel: "courses",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
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, AlertCircle, BookOpen, Plus } from "lucide-react"
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
|
||||||
import alertSrc from "../../assets/Alert.svg"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
|
|
@ -50,7 +48,10 @@ export function CourseCategoryPage() {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 py-24">
|
<div className="flex flex-col items-center justify-center gap-4 py-24">
|
||||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
<div className="relative">
|
||||||
|
<div className="h-12 w-12 rounded-full border-4 border-brand-100" />
|
||||||
|
<div className="absolute inset-0 h-12 w-12 animate-spin rounded-full border-4 border-transparent border-t-brand-500" />
|
||||||
|
</div>
|
||||||
<span className="text-sm font-medium text-grayScale-400">Loading categories…</span>
|
<span className="text-sm font-medium text-grayScale-400">Loading categories…</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -60,7 +61,9 @@ export function CourseCategoryPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-24">
|
<div className="flex items-center justify-center py-24">
|
||||||
<div className="flex flex-col items-center gap-4 rounded-2xl border border-red-100 bg-red-50/60 px-10 py-8 text-center shadow-sm">
|
<div className="flex flex-col items-center gap-4 rounded-2xl border border-red-100 bg-red-50/60 px-10 py-8 text-center shadow-sm">
|
||||||
<img src={alertSrc} alt="" className="h-12 w-12" />
|
<div className="grid h-12 w-12 place-items-center rounded-full bg-red-100">
|
||||||
|
<AlertCircle className="h-6 w-6 text-red-500" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-red-700">{error}</p>
|
<p className="text-sm font-semibold text-red-700">{error}</p>
|
||||||
<p className="mt-1 text-xs text-red-400">
|
<p className="mt-1 text-xs text-red-400">
|
||||||
|
|
@ -139,7 +142,7 @@ export function CourseCategoryPage() {
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
|
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
|
||||||
View Sub-categories
|
View Courses
|
||||||
<span className="inline-block transition-transform duration-300 group-hover:translate-x-1">
|
<span className="inline-block transition-transform duration-300 group-hover:translate-x-1">
|
||||||
→
|
→
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -291,12 +294,15 @@ export function CourseCategoryPage() {
|
||||||
setCreating(true)
|
setCreating(true)
|
||||||
try {
|
try {
|
||||||
const name = newCategoryName.trim()
|
const name = newCategoryName.trim()
|
||||||
const parentRes = await createCourseCategory({ name })
|
const parentPayloadId = parentCategoryId ?? null
|
||||||
|
const parentRes = await createCourseCategory({
|
||||||
|
name: newCategoryName.trim(),
|
||||||
|
parent_id: parentPayloadId,
|
||||||
|
})
|
||||||
let createdCategoryId: number | null = null
|
let createdCategoryId: number | null = null
|
||||||
try {
|
try {
|
||||||
const data: any = parentRes?.data
|
const data: any = parentRes?.data
|
||||||
createdCategoryId =
|
createdCategoryId =
|
||||||
data?.data?.id ??
|
|
||||||
data?.data?.category?.id ??
|
data?.data?.category?.id ??
|
||||||
data?.data?.id ??
|
data?.data?.id ??
|
||||||
data?.category?.id ??
|
data?.category?.id ??
|
||||||
|
|
@ -309,7 +315,10 @@ export function CourseCategoryPage() {
|
||||||
if (createdCategoryId && pendingSubCategories.length > 0) {
|
if (createdCategoryId && pendingSubCategories.length > 0) {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
pendingSubCategories.map((subName) =>
|
pendingSubCategories.map((subName) =>
|
||||||
createCourseCategory({ name: subName }),
|
createCourseCategory({
|
||||||
|
name: subName,
|
||||||
|
parent_id: createdCategoryId,
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,23 +1,30 @@
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState, useRef } from "react"
|
||||||
import { Link, useParams, useNavigate } from "react-router-dom"
|
import { Link, useParams, useNavigate } from "react-router-dom"
|
||||||
import { Plus, ArrowLeft, ToggleLeft, ToggleRight, X, Trash2, Edit, AlertCircle, Star, MessageSquare } from "lucide-react"
|
import { Plus, ArrowLeft, BookOpen, ToggleLeft, ToggleRight, X, Trash2, MoreVertical, Edit, RefreshCw, AlertCircle } from "lucide-react"
|
||||||
import practiceSrc from "../../assets/Practice.svg"
|
import { Card, CardContent } from "../../components/ui/card"
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
|
||||||
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 {
|
import { FileUpload } from "../../components/ui/file-upload"
|
||||||
Table,
|
import { getCoursesByCategory, getCourseCategories, createCourse, deleteCourse, updateCourseStatus, updateCourse } from "../../api/courses.api"
|
||||||
TableBody,
|
import type { Course, CourseCategory } from "../../types/course.types"
|
||||||
TableCell,
|
|
||||||
TableHead,
|
function CourseThumbnail({ src, alt, gradient }: { src?: string; alt: string; gradient: string }) {
|
||||||
TableHeader,
|
const [imgError, setImgError] = useState(false)
|
||||||
TableRow,
|
|
||||||
} from "../../components/ui/table"
|
if (!src || imgError) {
|
||||||
import { getCoursesByCategory, getCourseCategories, createCourse, deleteCourse, updateCourseStatus, updateCourse, getRatings } from "../../api/courses.api"
|
return <div className={`h-full w-full rounded-t-lg ${gradient}`} />
|
||||||
import type { Course, CourseCategory, Rating } from "../../types/course.types"
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className="h-full w-full object-cover rounded-t-lg"
|
||||||
|
onError={() => setImgError(true)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function CoursesPage() {
|
export function CoursesPage() {
|
||||||
const { categoryId } = useParams<{ categoryId: string }>()
|
const { categoryId } = useParams<{ categoryId: string }>()
|
||||||
|
|
@ -32,10 +39,16 @@ 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)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||||
|
const [openMenuId, setOpenMenuId] = useState<number | null>(null)
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const [showEditModal, setShowEditModal] = useState(false)
|
const [showEditModal, setShowEditModal] = useState(false)
|
||||||
const [courseToEdit, setCourseToEdit] = useState<Course | null>(null)
|
const [courseToEdit, setCourseToEdit] = useState<Course | null>(null)
|
||||||
const [editTitle, setEditTitle] = useState("")
|
const [editTitle, setEditTitle] = useState("")
|
||||||
|
|
@ -43,10 +56,19 @@ export function CoursesPage() {
|
||||||
const [editThumbnail, setEditThumbnail] = useState("")
|
const [editThumbnail, setEditThumbnail] = useState("")
|
||||||
const [updating, setUpdating] = useState(false)
|
const [updating, setUpdating] = useState(false)
|
||||||
const [updateError, setUpdateError] = useState<string | null>(null)
|
const [updateError, setUpdateError] = useState<string | null>(null)
|
||||||
const [showRatingsModal, setShowRatingsModal] = useState(false)
|
|
||||||
const [ratingsCourseId, setRatingsCourseId] = useState<number | null>(null)
|
useEffect(() => {
|
||||||
const [courseRatings, setCourseRatings] = useState<Rating[]>([])
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
const [courseRatingsLoading, setCourseRatingsLoading] = useState(false)
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||||
|
setOpenMenuId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openMenuId !== null) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside)
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||||
|
}, [openMenuId])
|
||||||
|
|
||||||
const fetchCourses = async () => {
|
const fetchCourses = async () => {
|
||||||
if (!categoryId) return
|
if (!categoryId) return
|
||||||
|
|
@ -70,14 +92,14 @@ export function CoursesPage() {
|
||||||
getCourseCategories(),
|
getCourseCategories(),
|
||||||
])
|
])
|
||||||
|
|
||||||
setCourses(coursesRes.data.data.courses ?? [])
|
setCourses(coursesRes.data.data.courses)
|
||||||
const foundCategory = categoriesRes.data.data.categories.find(
|
const foundCategory = categoriesRes.data.data.categories.find(
|
||||||
(c) => c.id === Number(categoryId)
|
(c) => c.id === Number(categoryId)
|
||||||
)
|
)
|
||||||
setCategory(foundCategory ?? null)
|
setCategory(foundCategory ?? null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch courses:", err)
|
console.error("Failed to fetch courses:", err)
|
||||||
setError("Failed to load sub-categories")
|
setError("Failed to load courses")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -90,6 +112,8 @@ export function CoursesPage() {
|
||||||
setTitle("")
|
setTitle("")
|
||||||
setDescription("")
|
setDescription("")
|
||||||
setSaveError(null)
|
setSaveError(null)
|
||||||
|
setNewThumbnailFile(null)
|
||||||
|
setNewVideoFile(null)
|
||||||
setShowModal(true)
|
setShowModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,6 +122,8 @@ export function CoursesPage() {
|
||||||
setTitle("")
|
setTitle("")
|
||||||
setDescription("")
|
setDescription("")
|
||||||
setSaveError(null)
|
setSaveError(null)
|
||||||
|
setNewThumbnailFile(null)
|
||||||
|
setNewVideoFile(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
|
@ -123,7 +149,7 @@ export function CoursesPage() {
|
||||||
await fetchCourses()
|
await fetchCourses()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Failed to create course:", err)
|
console.error("Failed to create course:", err)
|
||||||
setSaveError(err.response?.data?.message || "Failed to create sub-category")
|
setSaveError(err.response?.data?.message || "Failed to create course")
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
|
|
@ -206,7 +232,7 @@ export function CoursesPage() {
|
||||||
await fetchCourses()
|
await fetchCourses()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Failed to update course:", err)
|
console.error("Failed to update course:", err)
|
||||||
setUpdateError(err.response?.data?.message || "Failed to update sub-category")
|
setUpdateError(err.response?.data?.message || "Failed to update course")
|
||||||
} finally {
|
} finally {
|
||||||
setUpdating(false)
|
setUpdating(false)
|
||||||
}
|
}
|
||||||
|
|
@ -216,28 +242,13 @@ export function CoursesPage() {
|
||||||
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses`)
|
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleViewRatings = async (courseId: number) => {
|
|
||||||
setRatingsCourseId(courseId)
|
|
||||||
setShowRatingsModal(true)
|
|
||||||
setCourseRatingsLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await getRatings({ target_type: "course", target_id: courseId, limit: 10 })
|
|
||||||
setCourseRatings(res.data.data ?? [])
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to fetch ratings:", err)
|
|
||||||
} finally {
|
|
||||||
setCourseRatingsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
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" />
|
<div className="rounded-2xl bg-white shadow-sm p-6">
|
||||||
{/* <div className="rounded-2xl bg-white shadow-sm p-6">
|
|
||||||
<RefreshCw className="h-10 w-10 animate-spin text-brand-600" />
|
<RefreshCw className="h-10 w-10 animate-spin text-brand-600" />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading courses...</p> */}
|
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading courses...</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -246,7 +257,9 @@ export function CoursesPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-32">
|
<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="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">
|
||||||
<img src={alertSrc} alt="" className="h-10 w-10 shrink-0" />
|
<div className="rounded-full bg-red-100 p-2">
|
||||||
|
<AlertCircle className="h-5 w-5 shrink-0 text-red-500" />
|
||||||
|
</div>
|
||||||
<p className="text-sm font-medium text-red-600">{error}</p>
|
<p className="text-sm font-medium text-red-600">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -267,155 +280,150 @@ export function CoursesPage() {
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-grayScale-700 sm:text-2xl">
|
<h1 className="text-xl font-bold text-grayScale-700 sm:text-2xl">
|
||||||
{category?.name} Sub-categories
|
{category?.name} Courses
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-0.5 text-sm text-grayScale-400">
|
<p className="mt-0.5 text-sm text-grayScale-400">
|
||||||
<span className="font-medium text-grayScale-500">{courses.length}</span> sub-categories available
|
<span className="font-medium text-grayScale-500">{courses.length}</span> courses available
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button className="w-full bg-brand-500 shadow-sm transition-all hover:bg-brand-600 hover:shadow-md sm:w-auto" onClick={handleOpenModal}>
|
<Button className="w-full bg-brand-500 shadow-sm transition-all hover:bg-brand-600 hover:shadow-md sm:w-auto" onClick={handleOpenModal}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add New Sub-category
|
Add New Course
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Course table or empty state */}
|
{/* Course grid or empty state */}
|
||||||
<Card className="shadow-soft">
|
|
||||||
<CardHeader className="border-b border-grayScale-200 pb-3">
|
|
||||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
|
||||||
Sub-category Management
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-4">
|
|
||||||
{courses.length === 0 ? (
|
{courses.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-16 text-center">
|
<Card className="border-dashed border-grayScale-200 shadow-none">
|
||||||
<img src={practiceSrc} alt="" className="h-16 w-16" />
|
<CardContent className="flex flex-col items-center justify-center py-20">
|
||||||
<h3 className="mt-4 text-base font-semibold text-grayScale-600">No sub-categories yet</h3>
|
<div className="rounded-2xl bg-grayScale-50 p-5">
|
||||||
<p className="mt-1.5 text-sm text-grayScale-400">
|
<BookOpen className="h-14 w-14 text-grayScale-300" />
|
||||||
No sub-categories found in this category.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="mt-5 border-brand-200 text-brand-600 transition-colors hover:bg-brand-50"
|
|
||||||
onClick={handleOpenModal}
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Add your first sub-category
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h3 className="mt-5 text-base font-semibold text-grayScale-600">No courses yet</h3>
|
||||||
|
<p className="mt-1.5 text-sm text-grayScale-400">No courses found in this category</p>
|
||||||
|
<Button variant="outline" className="mt-6 border-brand-200 text-brand-600 transition-colors hover:bg-brand-50" onClick={handleOpenModal}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add your first course
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
<Table>
|
{courses.map((course, index) => {
|
||||||
<TableHeader>
|
const gradients = [
|
||||||
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
|
"bg-gradient-to-br from-blue-100 to-blue-200",
|
||||||
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
"bg-gradient-to-br from-purple-100 to-purple-200",
|
||||||
Sub-category
|
"bg-gradient-to-br from-green-100 to-green-200",
|
||||||
</TableHead>
|
"bg-gradient-to-br from-yellow-100 to-yellow-200",
|
||||||
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
|
]
|
||||||
Status
|
return (
|
||||||
</TableHead>
|
<Card
|
||||||
<TableHead className="py-3 text-right text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
|
||||||
Actions
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{courses.map((course, index) => (
|
|
||||||
<TableRow
|
|
||||||
key={course.id}
|
key={course.id}
|
||||||
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${
|
className="group cursor-pointer overflow-hidden border border-grayScale-100 bg-white shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-1 hover:border-grayScale-200"
|
||||||
index % 2 === 0 ? "bg-white" : "bg-grayScale-100/40"
|
|
||||||
}`}
|
|
||||||
onClick={() => handleCourseClick(course.id)}
|
onClick={() => handleCourseClick(course.id)}
|
||||||
>
|
>
|
||||||
<TableCell className="max-w-md py-3.5">
|
{/* Thumbnail */}
|
||||||
<div className="truncate text-sm font-semibold text-grayScale-700">
|
<div className="relative aspect-video w-full overflow-hidden">
|
||||||
{course.title}
|
<CourseThumbnail
|
||||||
|
src={course.thumbnail}
|
||||||
|
alt={course.title}
|
||||||
|
gradient={gradients[index % gradients.length]}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
|
||||||
</div>
|
</div>
|
||||||
{course.description && (
|
|
||||||
<div className="mt-1 truncate text-xs text-grayScale-400">
|
{/* Content */}
|
||||||
{course.description}
|
<div className="space-y-3 border-t border-grayScale-50 p-4">
|
||||||
|
{/* Status and menu */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge
|
||||||
|
className={`rounded-full px-2.5 py-0.5 text-[11px] font-semibold tracking-wide ${
|
||||||
|
course.is_active
|
||||||
|
? "border-0 bg-emerald-50 text-emerald-700"
|
||||||
|
: "border-0 bg-grayScale-100 text-grayScale-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${course.is_active ? "bg-emerald-500" : "bg-grayScale-400"}`} />
|
||||||
|
{course.is_active ? "ACTIVE" : "INACTIVE"}
|
||||||
|
</Badge>
|
||||||
|
<div className="relative" ref={openMenuId === course.id ? menuRef : undefined} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpenMenuId(openMenuId === course.id ? null : course.id)}
|
||||||
|
className="grid h-7 w-7 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{openMenuId === course.id && (
|
||||||
|
<div className="absolute right-0 top-full z-10 mt-1.5 w-44 animate-in fade-in slide-in-from-top-1 rounded-xl border border-grayScale-100 bg-white py-1.5 shadow-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleToggleStatus(course)
|
||||||
|
setOpenMenuId(null)
|
||||||
|
}}
|
||||||
|
disabled={togglingId === course.id}
|
||||||
|
className="flex w-full items-center gap-2.5 px-4 py-2 text-sm text-grayScale-600 transition-colors hover:bg-grayScale-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{course.is_active ? (
|
||||||
|
<>
|
||||||
|
<ToggleLeft className="h-4 w-4" />
|
||||||
|
Deactivate
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ToggleRight className="h-4 w-4" />
|
||||||
|
Activate
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="mx-3 my-1 border-t border-grayScale-100" />
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleDeleteClick(course)
|
||||||
|
setOpenMenuId(null)
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2.5 px-4 py-2 text-sm text-red-500 transition-colors hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell className="hidden py-3.5 md:table-cell">
|
</div>
|
||||||
<Badge
|
|
||||||
variant={course.is_active ? "success" : "secondary"}
|
{/* Title */}
|
||||||
className="text-[11px] font-semibold"
|
<h3 className="font-semibold text-grayScale-700 line-clamp-1">{course.title}</h3>
|
||||||
>
|
<p className="text-sm leading-relaxed text-grayScale-400 line-clamp-2">
|
||||||
{course.is_active ? "Active" : "Inactive"}
|
{course.description || "No description available"}
|
||||||
</Badge>
|
</p>
|
||||||
</TableCell>
|
|
||||||
<TableCell className="py-3.5 text-right">
|
{/* Edit button */}
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="icon"
|
className="w-full border-grayScale-200 text-grayScale-600 transition-all hover:border-brand-200 hover:bg-brand-50 hover:text-brand-600"
|
||||||
className="h-8 w-8 text-amber-400 hover:bg-amber-50 hover:text-amber-500"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleViewRatings(course.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Star 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"
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleEditClick(course)
|
handleEditClick(course)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
</Button>
|
Edit
|
||||||
<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="icon"
|
|
||||||
className="h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-600"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleDeleteClick(course)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 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-2xl animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
|
<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 Sub-category</h2>
|
<h2 className="text-lg font-bold text-grayScale-700">Add New Course</h2>
|
||||||
<button
|
<button
|
||||||
onClick={handleCloseModal}
|
onClick={handleCloseModal}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
|
@ -441,7 +449,7 @@ export function CoursesPage() {
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="course-title"
|
id="course-title"
|
||||||
placeholder="Enter sub-category title"
|
placeholder="Enter course title"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -456,7 +464,7 @@ export function CoursesPage() {
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="course-description"
|
id="course-description"
|
||||||
placeholder="Enter sub-category description"
|
placeholder="Enter course description"
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
|
|
@ -464,6 +472,29 @@ 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>
|
||||||
|
|
@ -478,7 +509,7 @@ export function CoursesPage() {
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
{saving ? "Saving..." : "Save Sub-category"}
|
{saving ? "Saving..." : "Save Course"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -490,7 +521,7 @@ export function CoursesPage() {
|
||||||
<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-md 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">Edit Sub-category</h2>
|
<h2 className="text-lg font-bold text-grayScale-700">Edit Course</h2>
|
||||||
<button
|
<button
|
||||||
onClick={handleCloseEditModal}
|
onClick={handleCloseEditModal}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
|
@ -516,7 +547,7 @@ export function CoursesPage() {
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="edit-course-title"
|
id="edit-course-title"
|
||||||
placeholder="Enter sub-category title"
|
placeholder="Enter course title"
|
||||||
value={editTitle}
|
value={editTitle}
|
||||||
onChange={(e) => setEditTitle(e.target.value)}
|
onChange={(e) => setEditTitle(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -531,7 +562,7 @@ export function CoursesPage() {
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="edit-course-description"
|
id="edit-course-description"
|
||||||
placeholder="Enter sub-category description"
|
placeholder="Enter course description"
|
||||||
value={editDescription}
|
value={editDescription}
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
onChange={(e) => setEditDescription(e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
|
|
@ -564,133 +595,19 @@ export function CoursesPage() {
|
||||||
onClick={handleUpdate}
|
onClick={handleUpdate}
|
||||||
disabled={updating}
|
disabled={updating}
|
||||||
>
|
>
|
||||||
{updating ? "Updating..." : "Update Sub-category"}
|
{updating ? "Updating..." : "Update Course"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Ratings Modal */}
|
|
||||||
{showRatingsModal && (
|
|
||||||
<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-lg animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
|
|
||||||
<h2 className="text-lg font-bold text-grayScale-700">Sub-category Ratings</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowRatingsModal(false)
|
|
||||||
setRatingsCourseId(null)
|
|
||||||
setCourseRatings([])
|
|
||||||
}}
|
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-h-[70vh] overflow-y-auto px-6 py-6">
|
|
||||||
{courseRatingsLoading ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16">
|
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
|
|
||||||
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading ratings…</p>
|
|
||||||
</div>
|
|
||||||
) : courseRatings.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-16">
|
|
||||||
<div className="rounded-full bg-amber-50 p-4">
|
|
||||||
<Star className="h-8 w-8 text-amber-400" />
|
|
||||||
</div>
|
|
||||||
<p className="mt-4 text-sm font-semibold text-grayScale-700">No ratings yet</p>
|
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
|
||||||
Ratings will appear here once learners start reviewing this sub-category.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Summary bar */}
|
|
||||||
<div className="flex flex-wrap items-center gap-6 rounded-xl border border-grayScale-200 px-5 py-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Star className="h-5 w-5 text-amber-400" fill="currentColor" />
|
|
||||||
<span className="text-lg font-bold text-grayScale-800">
|
|
||||||
{(courseRatings.reduce((sum, r) => sum + r.stars, 0) / courseRatings.length).toFixed(1)}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-grayScale-400">/ 5</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-5 w-px bg-grayScale-200" />
|
|
||||||
<span className="text-sm text-grayScale-500">
|
|
||||||
{courseRatings.length} review{courseRatings.length !== 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rating cards */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{courseRatings.map((rating) => (
|
|
||||||
<div
|
|
||||||
key={rating.id}
|
|
||||||
className="rounded-xl border border-grayScale-200 bg-white p-5 space-y-3"
|
|
||||||
>
|
|
||||||
{/* Header row */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-brand-50 text-sm font-bold text-brand-600">
|
|
||||||
U{rating.user_id}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold text-grayScale-700">User #{rating.user_id}</p>
|
|
||||||
<p className="text-[11px] text-grayScale-400">
|
|
||||||
{new Date(rating.created_at).toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
})}
|
|
||||||
{rating.updated_at !== rating.created_at && (
|
|
||||||
<span className="ml-1.5 text-grayScale-300">· edited</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stars */}
|
|
||||||
<div className="flex items-center gap-0.5">
|
|
||||||
{[1, 2, 3, 4, 5].map((s) => (
|
|
||||||
<Star
|
|
||||||
key={s}
|
|
||||||
className={`h-4 w-4 ${
|
|
||||||
s <= rating.stars
|
|
||||||
? "text-amber-400"
|
|
||||||
: "text-grayScale-200"
|
|
||||||
}`}
|
|
||||||
fill={s <= rating.stars ? "currentColor" : "none"}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Review text */}
|
|
||||||
{rating.review && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<MessageSquare className="mt-0.5 h-3.5 w-3.5 shrink-0 text-grayScale-300" />
|
|
||||||
<p className="text-sm leading-relaxed text-grayScale-600">
|
|
||||||
{rating.review}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete Course Modal */}
|
{/* Delete Course Modal */}
|
||||||
{showDeleteModal && courseToDelete && (
|
{showDeleteModal && courseToDelete && (
|
||||||
<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-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
<div className="mx-4 w-full max-w-sm 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">Delete Sub-category</h2>
|
<h2 className="text-lg font-bold text-grayScale-700">Delete Course</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeleteModal(false)}
|
onClick={() => setShowDeleteModal(false)}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
|
|
||||||
|
|
@ -1,267 +1,299 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useState } from "react"
|
||||||
import { RefreshCw } from "lucide-react"
|
import { Plus, Edit, Trash2 } from "lucide-react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
|
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 { Select } from "../../components/ui/select"
|
import { Select } from "../../components/ui/select"
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "../../components/ui/dialog"
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "../../components/ui/table"
|
|
||||||
import { Badge } from "../../components/ui/badge"
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../../components/ui/dialog"
|
|
||||||
import { getQuestionSetById, getQuestionSets } from "../../api/courses.api"
|
|
||||||
import type { QuestionSet, QuestionSetDetail } from "../../types/course.types"
|
|
||||||
|
|
||||||
const statusColor: Record<string, string> = {
|
const mockLeaders = [
|
||||||
PUBLISHED: "bg-green-100 text-green-700",
|
{ id: "1", name: "John Doe", role: "CEO" },
|
||||||
DRAFT: "bg-amber-100 text-amber-700",
|
{ id: "2", name: "Jane Smith", role: "COO" },
|
||||||
ARCHIVED: "bg-grayScale-200 text-grayScale-600",
|
]
|
||||||
}
|
|
||||||
|
const mockMembers = [
|
||||||
|
{ id: "1", name: "John Doe", role: "Member" },
|
||||||
|
{ id: "2", name: "Jane Smith", role: "Member" },
|
||||||
|
]
|
||||||
|
|
||||||
export function PracticeDetailsPage() {
|
export function PracticeDetailsPage() {
|
||||||
const [practices, setPractices] = useState<QuestionSet[]>([])
|
const [isMemberModalOpen, setIsMemberModalOpen] = useState(false)
|
||||||
const [selectedPracticeId, setSelectedPracticeId] = useState<number | null>(null)
|
const [isLeaderModalOpen, setIsLeaderModalOpen] = useState(false)
|
||||||
const [selectedPracticeDetail, setSelectedPracticeDetail] = useState<QuestionSetDetail | null>(null)
|
const [memberName, setMemberName] = useState("")
|
||||||
const [detailOpen, setDetailOpen] = useState(false)
|
const [memberRole, setMemberRole] = useState("")
|
||||||
const [loadingList, setLoadingList] = useState(false)
|
const [leaderName, setLeaderName] = useState("")
|
||||||
const [loadingDetail, setLoadingDetail] = useState(false)
|
const [leaderRole, setLeaderRole] = useState("")
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
|
||||||
const [statusFilter, setStatusFilter] = useState("all")
|
|
||||||
const [ownerTypeFilter, setOwnerTypeFilter] = useState("all")
|
|
||||||
|
|
||||||
const fetchPractices = useCallback(async () => {
|
const [formData, setFormData] = useState({
|
||||||
setLoadingList(true)
|
name: "",
|
||||||
try {
|
description: "",
|
||||||
const res = await getQuestionSets({ set_type: "PRACTICE" })
|
type: "",
|
||||||
const payload = res.data?.data as unknown
|
street: "",
|
||||||
let sets: QuestionSet[] = []
|
city: "",
|
||||||
if (Array.isArray(payload)) {
|
state: "",
|
||||||
sets = payload as QuestionSet[]
|
zipCode: "",
|
||||||
} else if (
|
|
||||||
payload &&
|
|
||||||
typeof payload === "object" &&
|
|
||||||
Array.isArray((payload as { question_sets?: unknown[] }).question_sets)
|
|
||||||
) {
|
|
||||||
sets = (payload as { question_sets: QuestionSet[] }).question_sets
|
|
||||||
}
|
|
||||||
setPractices(sets)
|
|
||||||
if (sets.length > 0) {
|
|
||||||
setSelectedPracticeId((prev) => prev ?? sets[0].id)
|
|
||||||
} else {
|
|
||||||
setSelectedPracticeId(null)
|
|
||||||
setSelectedPracticeDetail(null)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch practices:", error)
|
|
||||||
setPractices([])
|
|
||||||
setSelectedPracticeId(null)
|
|
||||||
setSelectedPracticeDetail(null)
|
|
||||||
} finally {
|
|
||||||
setLoadingList(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const fetchPracticeDetail = useCallback(async (practiceId: number) => {
|
|
||||||
setLoadingDetail(true)
|
|
||||||
try {
|
|
||||||
const res = await getQuestionSetById(practiceId)
|
|
||||||
setSelectedPracticeDetail(res.data?.data ?? null)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch practice detail:", error)
|
|
||||||
setSelectedPracticeDetail(null)
|
|
||||||
} finally {
|
|
||||||
setLoadingDetail(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchPractices()
|
|
||||||
}, [fetchPractices])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedPracticeId) {
|
|
||||||
fetchPracticeDetail(selectedPracticeId)
|
|
||||||
}
|
|
||||||
}, [selectedPracticeId, fetchPracticeDetail])
|
|
||||||
|
|
||||||
const filteredPractices = useMemo(() => {
|
|
||||||
return practices.filter((practice) => {
|
|
||||||
const matchesSearch =
|
|
||||||
!searchQuery.trim() ||
|
|
||||||
practice.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
(practice.description || "").toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
String(practice.id).includes(searchQuery) ||
|
|
||||||
String(practice.owner_id).includes(searchQuery)
|
|
||||||
const matchesStatus = statusFilter === "all" || practice.status === statusFilter
|
|
||||||
const matchesOwnerType = ownerTypeFilter === "all" || practice.owner_type === ownerTypeFilter
|
|
||||||
return matchesSearch && matchesStatus && matchesOwnerType
|
|
||||||
})
|
})
|
||||||
}, [practices, searchQuery, statusFilter, ownerTypeFilter])
|
|
||||||
|
|
||||||
const totalCount = useMemo(() => filteredPractices.length, [filteredPractices])
|
const handleAddMember = () => {
|
||||||
|
console.log("Add member:", { memberName, memberRole })
|
||||||
|
setIsMemberModalOpen(false)
|
||||||
|
setMemberName("")
|
||||||
|
setMemberRole("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddLeader = () => {
|
||||||
|
console.log("Add leader:", { leaderName, leaderRole })
|
||||||
|
setIsLeaderModalOpen(false)
|
||||||
|
setLeaderName("")
|
||||||
|
setLeaderRole("")
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
{/* Page Header */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Practice Management</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Practice Management</h1>
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
<p className="mt-1 text-sm text-grayScale-400">Manage your practice details, leadership, and members</p>
|
||||||
Browse all practice question sets and view their details.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" onClick={fetchPractices} disabled={loadingList}>
|
|
||||||
<RefreshCw className={`h-4 w-4 ${loadingList ? "animate-spin" : ""}`} />
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="shadow-soft">
|
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2">
|
||||||
<CardHeader className="border-b border-grayScale-200 pb-4">
|
{/* Practice Leadership */}
|
||||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
<Card className="border-grayScale-200 p-6 shadow-sm">
|
||||||
Practices ({totalCount})
|
<div className="mb-5 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
</CardTitle>
|
<h2 className="text-lg font-semibold tracking-tight text-grayScale-600">Practice Leadership</h2>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-5">
|
|
||||||
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-center">
|
|
||||||
<div className="flex-1">
|
|
||||||
<Input
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder="Search by title, description, practice ID, or owner ID..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
|
||||||
<option value="all">All Statuses</option>
|
|
||||||
<option value="PUBLISHED">PUBLISHED</option>
|
|
||||||
<option value="DRAFT">DRAFT</option>
|
|
||||||
<option value="ARCHIVED">ARCHIVED</option>
|
|
||||||
</Select>
|
|
||||||
<Select value={ownerTypeFilter} onChange={(e) => setOwnerTypeFilter(e.target.value)}>
|
|
||||||
<option value="all">All Owner Types</option>
|
|
||||||
<option value="SUB_COURSE">SUB_COURSE</option>
|
|
||||||
<option value="COURSE">COURSE</option>
|
|
||||||
</Select>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => setIsLeaderModalOpen(true)}
|
||||||
setSearchQuery("")
|
className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors w-full sm:w-auto"
|
||||||
setStatusFilter("all")
|
|
||||||
setOwnerTypeFilter("all")
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Clear
|
<Plus className="h-4 w-4" />
|
||||||
|
Add New Leader
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{mockLeaders.map((leader) => (
|
||||||
|
<div
|
||||||
|
key={leader.id}
|
||||||
|
className="group flex items-center justify-between rounded-xl border border-grayScale-200 p-3.5 transition-all hover:border-grayScale-300 hover:bg-grayScale-50/50 hover:shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="grid h-9 w-9 place-items-center rounded-full bg-brand-100 text-sm font-semibold text-brand-600">
|
||||||
|
{leader.name[0]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-grayScale-600">{leader.name}</p>
|
||||||
|
<p className="text-xs text-grayScale-400">{leader.role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-grayScale-400 hover:text-grayScale-600">
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-grayScale-400 hover:text-destructive">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loadingList ? (
|
|
||||||
<div className="py-16 text-center text-sm text-grayScale-500">Loading practices...</div>
|
|
||||||
) : filteredPractices.length === 0 ? (
|
|
||||||
<div className="rounded-lg border-2 border-dashed border-grayScale-200 py-16 text-center text-sm text-grayScale-500">
|
|
||||||
No practice sets found.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<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">Title</TableHead>
|
|
||||||
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">Owner</TableHead>
|
|
||||||
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">Status</TableHead>
|
|
||||||
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">Created</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredPractices.map((practice, index) => (
|
|
||||||
<TableRow
|
|
||||||
key={practice.id}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedPracticeId(practice.id)
|
|
||||||
setDetailOpen(true)
|
|
||||||
}}
|
|
||||||
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${
|
|
||||||
selectedPracticeId === practice.id
|
|
||||||
? "bg-brand-100/40"
|
|
||||||
: index % 2 === 0
|
|
||||||
? "bg-white"
|
|
||||||
: "bg-grayScale-100/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<TableCell className="max-w-md py-3.5">
|
|
||||||
<p className="truncate text-sm font-medium text-grayScale-700">{practice.title}</p>
|
|
||||||
<p className="mt-1 truncate text-xs text-grayScale-500">{practice.description || "—"}</p>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="hidden py-3.5 text-sm text-grayScale-500 md:table-cell">
|
|
||||||
{practice.owner_type} #{practice.owner_id}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="py-3.5">
|
|
||||||
<Badge className={statusColor[practice.status] || "bg-grayScale-200 text-grayScale-600"}>
|
|
||||||
{practice.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="hidden py-3.5 text-sm text-grayScale-500 md:table-cell">
|
|
||||||
{practice.created_at}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
{/* Practice Details */}
|
||||||
<DialogContent className="sm:max-w-2xl">
|
<Card className="border-grayScale-200 p-6 shadow-sm">
|
||||||
|
<h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">Practice Details</h2>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
|
Practice Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="Enter practice name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
|
Practice Description
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="Enter practice description"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
|
Practice Type
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={formData.type}
|
||||||
|
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Select practice type</option>
|
||||||
|
<option value="online">Online</option>
|
||||||
|
<option value="offline">Offline</option>
|
||||||
|
<option value="hybrid">Hybrid</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
|
Practice Address
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
value={formData.street}
|
||||||
|
onChange={(e) => setFormData({ ...formData, street: e.target.value })}
|
||||||
|
placeholder="Street"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<Input
|
||||||
|
value={formData.city}
|
||||||
|
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
|
||||||
|
placeholder="City"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={formData.state}
|
||||||
|
onChange={(e) => setFormData({ ...formData, state: e.target.value })}
|
||||||
|
placeholder="State"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={formData.zipCode}
|
||||||
|
onChange={(e) => setFormData({ ...formData, zipCode: e.target.value })}
|
||||||
|
placeholder="Zip Code"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button className="w-full bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors">
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Practice Members */}
|
||||||
|
<Card className="border-grayScale-200 p-6 shadow-sm">
|
||||||
|
<div className="mb-5 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight text-grayScale-600">Practice Members</h2>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsMemberModalOpen(true)}
|
||||||
|
className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add New Member
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{mockMembers.map((member) => (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className="group flex items-center justify-between rounded-xl border border-grayScale-200 p-3.5 transition-all hover:border-grayScale-300 hover:bg-grayScale-50/50 hover:shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="grid h-9 w-9 place-items-center rounded-full bg-brand-100 text-sm font-semibold text-brand-600">
|
||||||
|
{member.name[0]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-grayScale-600">{member.name}</p>
|
||||||
|
<p className="text-xs text-grayScale-400">{member.role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-grayScale-400 hover:text-grayScale-600">
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-grayScale-400 hover:text-destructive">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Add Member Modal */}
|
||||||
|
<Dialog open={isMemberModalOpen} onOpenChange={setIsMemberModalOpen}>
|
||||||
|
<DialogContent className="sm:rounded-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Practice Detail</DialogTitle>
|
<DialogTitle>Add New Member</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{!selectedPracticeId ? (
|
<div className="space-y-5 py-2">
|
||||||
<p className="text-sm text-grayScale-500">Select a practice from the list to view details.</p>
|
<div>
|
||||||
) : loadingDetail ? (
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
<p className="text-sm text-grayScale-500">Loading detail...</p>
|
Member Name
|
||||||
) : !selectedPracticeDetail ? (
|
</label>
|
||||||
<p className="text-sm text-grayScale-500">Failed to load practice detail.</p>
|
<Input
|
||||||
) : (
|
value={memberName}
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
onChange={(e) => setMemberName(e.target.value)}
|
||||||
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
|
placeholder="Enter member name"
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Title</p>
|
/>
|
||||||
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.title}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Set Type</p>
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.set_type}</p>
|
Member Role
|
||||||
</div>
|
</label>
|
||||||
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3 sm:col-span-2">
|
<Input
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Description</p>
|
value={memberRole}
|
||||||
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.description || "—"}</p>
|
onChange={(e) => setMemberRole(e.target.value)}
|
||||||
</div>
|
placeholder="Enter member role"
|
||||||
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
|
/>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Owner</p>
|
|
||||||
<p className="mt-1 text-sm text-grayScale-700">
|
|
||||||
{selectedPracticeDetail.owner_type} #{selectedPracticeDetail.owner_id}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Status</p>
|
|
||||||
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.status}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Question Count</p>
|
|
||||||
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.question_count ?? 0}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Created At</p>
|
|
||||||
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.created_at}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsMemberModalOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAddMember} className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors">
|
||||||
|
Add Member
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Add Leader Modal */}
|
||||||
|
<Dialog open={isLeaderModalOpen} onOpenChange={setIsLeaderModalOpen}>
|
||||||
|
<DialogContent className="sm:rounded-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add New Leader</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-5 py-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
|
Leader Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={leaderName}
|
||||||
|
onChange={(e) => setLeaderName(e.target.value)}
|
||||||
|
placeholder="Enter leader name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||||
|
Leader Role
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={leaderRole}
|
||||||
|
onChange={(e) => setLeaderRole(e.target.value)}
|
||||||
|
placeholder="Enter leader role"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsLeaderModalOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAddLeader} className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors">
|
||||||
|
Add Leader
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,11 +1,10 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useState } from "react"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
import { Plus, Search, Edit, Trash2, HelpCircle, X } from "lucide-react"
|
import { Plus, Search, Edit, Trash2, HelpCircle } from "lucide-react"
|
||||||
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"
|
||||||
import { Select } from "../../components/ui/select"
|
import { Select } from "../../components/ui/select"
|
||||||
import { Textarea } from "../../components/ui/textarea"
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -15,294 +14,117 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table"
|
} from "../../components/ui/table"
|
||||||
import { Badge } from "../../components/ui/badge"
|
import { Badge } from "../../components/ui/badge"
|
||||||
import { deleteQuestion, getQuestionById, getQuestions, updateQuestion } from "../../api/courses.api"
|
|
||||||
import type { QuestionDetail } from "../../types/course.types"
|
|
||||||
|
|
||||||
type QuestionTypeFilter = "all" | "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
|
type QuestionType = "multiple-choice" | "short-answer" | "true-false"
|
||||||
type DifficultyFilter = "all" | "EASY" | "MEDIUM" | "HARD"
|
|
||||||
type StatusFilter = "all" | "DRAFT" | "PUBLISHED" | "INACTIVE"
|
|
||||||
type QuestionTypeEdit = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
|
|
||||||
|
|
||||||
interface EditOption {
|
interface Question {
|
||||||
option_text: string
|
id: string
|
||||||
option_order: number
|
question: string
|
||||||
is_correct: boolean
|
type: QuestionType
|
||||||
|
options: string[]
|
||||||
|
correctAnswer: string
|
||||||
|
points: number
|
||||||
|
category?: string
|
||||||
|
difficulty?: string
|
||||||
|
createdAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeLabels: Record<string, string> = {
|
// Mock data
|
||||||
MCQ: "Multiple Choice",
|
const mockQuestions: Question[] = [
|
||||||
TRUE_FALSE: "True/False",
|
{
|
||||||
SHORT_ANSWER: "Short Answer",
|
id: "1",
|
||||||
SHORT: "Short Answer",
|
question: "What is the capital of France?",
|
||||||
AUDIO: "Audio",
|
type: "multiple-choice",
|
||||||
|
options: ["London", "Berlin", "Paris", "Madrid"],
|
||||||
|
correctAnswer: "Paris",
|
||||||
|
points: 10,
|
||||||
|
category: "Geography",
|
||||||
|
difficulty: "Easy",
|
||||||
|
createdAt: "2024-01-15",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
question: "Explain the concept of React hooks in your own words.",
|
||||||
|
type: "short-answer",
|
||||||
|
options: [],
|
||||||
|
correctAnswer: "React hooks are functions that let you use state and other React features in functional components.",
|
||||||
|
points: 20,
|
||||||
|
category: "Programming",
|
||||||
|
difficulty: "Medium",
|
||||||
|
createdAt: "2024-01-16",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
question: "JavaScript is a compiled language.",
|
||||||
|
type: "true-false",
|
||||||
|
options: ["True", "False"],
|
||||||
|
correctAnswer: "False",
|
||||||
|
points: 5,
|
||||||
|
category: "Programming",
|
||||||
|
difficulty: "Easy",
|
||||||
|
createdAt: "2024-01-17",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
question: "Which of the following is a CSS preprocessor?",
|
||||||
|
type: "multiple-choice",
|
||||||
|
options: ["SASS", "HTML", "JavaScript", "Python"],
|
||||||
|
correctAnswer: "SASS",
|
||||||
|
points: 15,
|
||||||
|
category: "Web Development",
|
||||||
|
difficulty: "Medium",
|
||||||
|
createdAt: "2024-01-18",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5",
|
||||||
|
question: "TypeScript is a superset of JavaScript.",
|
||||||
|
type: "true-false",
|
||||||
|
options: ["True", "False"],
|
||||||
|
correctAnswer: "True",
|
||||||
|
points: 10,
|
||||||
|
category: "Programming",
|
||||||
|
difficulty: "Easy",
|
||||||
|
createdAt: "2024-01-19",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const typeLabels: Record<QuestionType, string> = {
|
||||||
|
"multiple-choice": "Multiple Choice",
|
||||||
|
"short-answer": "Short Answer",
|
||||||
|
"true-false": "True/False",
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeColors: Record<string, string> = {
|
const typeColors: Record<QuestionType, string> = {
|
||||||
MCQ: "bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-200",
|
"multiple-choice": "bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-200",
|
||||||
TRUE_FALSE: "bg-brand-100 text-brand-600 ring-1 ring-inset ring-brand-200",
|
"short-answer": "bg-mint-100 text-green-700 ring-1 ring-inset ring-green-200",
|
||||||
SHORT_ANSWER: "bg-mint-100 text-green-700 ring-1 ring-inset ring-green-200",
|
"true-false": "bg-brand-100 text-brand-600 ring-1 ring-inset ring-brand-200",
|
||||||
SHORT: "bg-mint-100 text-green-700 ring-1 ring-inset ring-green-200",
|
|
||||||
AUDIO: "bg-purple-100 text-purple-700 ring-1 ring-inset ring-purple-200",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuestionsPage() {
|
export function QuestionsPage() {
|
||||||
const [questions, setQuestions] = useState<QuestionDetail[]>([])
|
const [questions, setQuestions] = useState<Question[]>(mockQuestions)
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [deleting, setDeleting] = useState(false)
|
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
const [typeFilter, setTypeFilter] = useState<QuestionTypeFilter>("all")
|
const [typeFilter, setTypeFilter] = useState<string>("all")
|
||||||
const [difficultyFilter, setDifficultyFilter] = useState<DifficultyFilter>("all")
|
const [categoryFilter, setCategoryFilter] = useState<string>("all")
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [pageSize, setPageSize] = useState(10)
|
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([])
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
|
||||||
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[]>([])
|
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
|
||||||
const [activeQuestionId, setActiveQuestionId] = useState<number | null>(null)
|
|
||||||
const [detailLoading, setDetailLoading] = useState(false)
|
|
||||||
const [detailData, setDetailData] = useState<QuestionDetail | null>(null)
|
|
||||||
const [savingEdit, setSavingEdit] = useState(false)
|
|
||||||
const [editQuestionText, setEditQuestionText] = useState("")
|
|
||||||
const [editQuestionType, setEditQuestionType] = useState<QuestionTypeEdit>("MCQ")
|
|
||||||
const [editDifficulty, setEditDifficulty] = useState("EASY")
|
|
||||||
const [editPoints, setEditPoints] = useState(1)
|
|
||||||
const [editStatus, setEditStatus] = useState("PUBLISHED")
|
|
||||||
const [editTips, setEditTips] = useState("")
|
|
||||||
const [editExplanation, setEditExplanation] = useState("")
|
|
||||||
const [editVoicePrompt, setEditVoicePrompt] = useState("")
|
|
||||||
const [editSampleAnswerVoicePrompt, setEditSampleAnswerVoicePrompt] = useState("")
|
|
||||||
const [editShortAnswer, setEditShortAnswer] = useState("")
|
|
||||||
const [editOptions, setEditOptions] = useState<EditOption[]>([
|
|
||||||
{ option_text: "", option_order: 1, is_correct: true },
|
|
||||||
{ option_text: "", option_order: 2, is_correct: false },
|
|
||||||
])
|
|
||||||
|
|
||||||
const fetchQuestions = useCallback(async () => {
|
const filteredQuestions = questions.filter((q) => {
|
||||||
setLoading(true)
|
const matchesSearch = q.question.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
try {
|
const matchesType = typeFilter === "all" || q.type === typeFilter
|
||||||
const batchSize = 100
|
const matchesCategory = categoryFilter === "all" || q.category === categoryFilter
|
||||||
let nextOffset = 0
|
const matchesDifficulty = difficultyFilter === "all" || q.difficulty === difficultyFilter
|
||||||
let allRows: QuestionDetail[] = []
|
|
||||||
let expectedTotal = Number.POSITIVE_INFINITY
|
|
||||||
|
|
||||||
while (allRows.length < expectedTotal) {
|
return matchesSearch && matchesType && matchesCategory && matchesDifficulty
|
||||||
const res = await getQuestions({
|
|
||||||
question_type: typeFilter === "all" ? undefined : typeFilter,
|
|
||||||
difficulty: difficultyFilter === "all" ? undefined : difficultyFilter,
|
|
||||||
status: statusFilter === "all" ? undefined : statusFilter,
|
|
||||||
limit: batchSize,
|
|
||||||
offset: nextOffset,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const payload = res.data?.data as unknown
|
const categories = Array.from(new Set(questions.map((q) => q.category).filter(Boolean)))
|
||||||
const meta = res.data?.metadata as { total_count?: number } | null | undefined
|
const difficulties = Array.from(new Set(questions.map((q) => q.difficulty).filter(Boolean)))
|
||||||
|
|
||||||
let chunk: QuestionDetail[] = []
|
const handleDelete = (id: string) => {
|
||||||
let chunkTotal: number | undefined
|
if (window.confirm("Are you sure you want to delete this question?")) {
|
||||||
|
setQuestions(questions.filter((q) => q.id !== id))
|
||||||
if (Array.isArray(payload)) {
|
|
||||||
chunk = payload as QuestionDetail[]
|
|
||||||
chunkTotal = meta?.total_count
|
|
||||||
} else if (
|
|
||||||
payload &&
|
|
||||||
typeof payload === "object" &&
|
|
||||||
Array.isArray((payload as { questions?: unknown[] }).questions)
|
|
||||||
) {
|
|
||||||
const data = payload as { questions: QuestionDetail[]; total_count?: number }
|
|
||||||
chunk = data.questions
|
|
||||||
chunkTotal = data.total_count ?? meta?.total_count
|
|
||||||
}
|
|
||||||
|
|
||||||
allRows = [...allRows, ...chunk]
|
|
||||||
if (typeof chunkTotal === "number" && Number.isFinite(chunkTotal)) {
|
|
||||||
expectedTotal = chunkTotal
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chunk.length < batchSize) break
|
|
||||||
nextOffset += chunk.length
|
|
||||||
}
|
|
||||||
|
|
||||||
setQuestions(allRows)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch questions:", error)
|
|
||||||
setQuestions([])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [typeFilter, difficultyFilter, statusFilter])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchQuestions()
|
|
||||||
}, [fetchQuestions])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPage(1)
|
|
||||||
setSelectedIds([])
|
|
||||||
}, [searchQuery, pageSize, typeFilter, difficultyFilter, statusFilter])
|
|
||||||
|
|
||||||
const filteredQuestions = useMemo(() => {
|
|
||||||
if (!searchQuery.trim()) return questions
|
|
||||||
return questions.filter((q) =>
|
|
||||||
q.question_text.toLowerCase().includes(searchQuery.toLowerCase()),
|
|
||||||
)
|
|
||||||
}, [questions, searchQuery])
|
|
||||||
|
|
||||||
const paginatedQuestions = useMemo(() => {
|
|
||||||
const start = (page - 1) * pageSize
|
|
||||||
return filteredQuestions.slice(start, start + pageSize)
|
|
||||||
}, [filteredQuestions, page, pageSize])
|
|
||||||
|
|
||||||
const handleDeleteRequest = (ids: number[]) => {
|
|
||||||
setPendingDeleteIds(ids)
|
|
||||||
setDeleteDialogOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteConfirm = async () => {
|
|
||||||
if (pendingDeleteIds.length === 0) return
|
|
||||||
setDeleting(true)
|
|
||||||
try {
|
|
||||||
await Promise.all(pendingDeleteIds.map((id) => deleteQuestion(id)))
|
|
||||||
setDeleteDialogOpen(false)
|
|
||||||
setPendingDeleteIds([])
|
|
||||||
setSelectedIds((prev) => prev.filter((id) => !pendingDeleteIds.includes(id)))
|
|
||||||
await fetchQuestions()
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete question(s):", error)
|
|
||||||
} finally {
|
|
||||||
setDeleting(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openDetails = async (id: number) => {
|
|
||||||
setDetailsOpen(true)
|
|
||||||
setDetailLoading(true)
|
|
||||||
setDetailData(null)
|
|
||||||
try {
|
|
||||||
const res = await getQuestionById(id)
|
|
||||||
setDetailData(res.data.data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch question details:", error)
|
|
||||||
} finally {
|
|
||||||
setDetailLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openEdit = async (id: number) => {
|
|
||||||
setEditOpen(true)
|
|
||||||
setDetailLoading(true)
|
|
||||||
setActiveQuestionId(id)
|
|
||||||
try {
|
|
||||||
const res = await getQuestionById(id)
|
|
||||||
const q = res.data.data
|
|
||||||
setDetailData(q)
|
|
||||||
setEditQuestionText(q.question_text || "")
|
|
||||||
setEditQuestionType((q.question_type as QuestionTypeEdit) || "MCQ")
|
|
||||||
setEditDifficulty((q.difficulty_level as string) || "EASY")
|
|
||||||
setEditPoints(q.points ?? 1)
|
|
||||||
setEditStatus(q.status || "PUBLISHED")
|
|
||||||
setEditTips(q.tips || "")
|
|
||||||
setEditExplanation(q.explanation || "")
|
|
||||||
setEditVoicePrompt(q.voice_prompt || "")
|
|
||||||
setEditSampleAnswerVoicePrompt(q.sample_answer_voice_prompt || "")
|
|
||||||
const incomingShort = Array.isArray(q.short_answers) && q.short_answers.length > 0
|
|
||||||
? typeof q.short_answers[0] === "string"
|
|
||||||
? String(q.short_answers[0] || "")
|
|
||||||
: String((q.short_answers[0] as { acceptable_answer?: string }).acceptable_answer || "")
|
|
||||||
: ""
|
|
||||||
setEditShortAnswer(incomingShort)
|
|
||||||
const mappedOptions =
|
|
||||||
(q.options ?? [])
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.option_order - b.option_order)
|
|
||||||
.map((opt) => ({
|
|
||||||
option_text: opt.option_text,
|
|
||||||
option_order: opt.option_order,
|
|
||||||
is_correct: opt.is_correct,
|
|
||||||
})) || []
|
|
||||||
setEditOptions(
|
|
||||||
mappedOptions.length > 0
|
|
||||||
? mappedOptions
|
|
||||||
: [
|
|
||||||
{ option_text: "", option_order: 1, is_correct: true },
|
|
||||||
{ option_text: "", option_order: 2, is_correct: false },
|
|
||||||
],
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch question for edit:", error)
|
|
||||||
} finally {
|
|
||||||
setDetailLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveEdit = async () => {
|
|
||||||
if (!activeQuestionId) return
|
|
||||||
setSavingEdit(true)
|
|
||||||
try {
|
|
||||||
const normalizedOptions = editOptions
|
|
||||||
.filter((o) => o.option_text.trim())
|
|
||||||
.map((o, idx) => ({
|
|
||||||
option_text: o.option_text.trim(),
|
|
||||||
option_order: idx + 1,
|
|
||||||
is_correct: o.is_correct,
|
|
||||||
}))
|
|
||||||
await updateQuestion(activeQuestionId, {
|
|
||||||
question_text: editQuestionText,
|
|
||||||
question_type: editQuestionType,
|
|
||||||
difficulty_level: editDifficulty,
|
|
||||||
points: editPoints,
|
|
||||||
status: editStatus,
|
|
||||||
tips: editTips || undefined,
|
|
||||||
explanation: editExplanation || undefined,
|
|
||||||
voice_prompt: editVoicePrompt || undefined,
|
|
||||||
sample_answer_voice_prompt: editSampleAnswerVoicePrompt || undefined,
|
|
||||||
options:
|
|
||||||
editQuestionType === "SHORT_ANSWER"
|
|
||||||
? undefined
|
|
||||||
: normalizedOptions,
|
|
||||||
short_answers:
|
|
||||||
editQuestionType === "SHORT_ANSWER"
|
|
||||||
? [
|
|
||||||
{ acceptable_answer: editShortAnswer, match_type: "EXACT" },
|
|
||||||
{ acceptable_answer: editShortAnswer, match_type: "CASE_INSENSITIVE" },
|
|
||||||
]
|
|
||||||
: undefined,
|
|
||||||
})
|
|
||||||
setEditOpen(false)
|
|
||||||
await fetchQuestions()
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to update question:", error)
|
|
||||||
} finally {
|
|
||||||
setSavingEdit(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleOne = (id: number) => {
|
|
||||||
setSelectedIds((prev) =>
|
|
||||||
prev.includes(id) ? prev.filter((selectedId) => selectedId !== id) : [...prev, id],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPageIds = paginatedQuestions.map((q) => q.id)
|
|
||||||
const isAllCurrentPageSelected =
|
|
||||||
currentPageIds.length > 0 && currentPageIds.every((id) => selectedIds.includes(id))
|
|
||||||
|
|
||||||
const toggleSelectAllCurrentPage = () => {
|
|
||||||
setSelectedIds((prev) => {
|
|
||||||
if (isAllCurrentPageSelected) {
|
|
||||||
return prev.filter((id) => !currentPageIds.includes(id))
|
|
||||||
}
|
|
||||||
const merged = new Set([...prev, ...currentPageIds])
|
|
||||||
return Array.from(merged)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalCount = filteredQuestions.length
|
|
||||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
|
|
||||||
const canGoPrev = page > 1
|
|
||||||
const canGoNext = page < totalPages
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
|
|
@ -315,16 +137,6 @@ export function QuestionsPage() {
|
||||||
Create and manage your question bank
|
Create and manage your question bank
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
disabled={selectedIds.length === 0}
|
|
||||||
onClick={() => handleDeleteRequest(selectedIds)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
Delete Selected ({selectedIds.length})
|
|
||||||
</Button>
|
|
||||||
<Link to="/content/questions/add" className="w-full sm:w-auto">
|
<Link to="/content/questions/add" className="w-full sm:w-auto">
|
||||||
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
|
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
|
|
@ -332,7 +144,6 @@ export function QuestionsPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="shadow-soft">
|
<Card className="shadow-soft">
|
||||||
<CardHeader className="border-b border-grayScale-200 pb-4">
|
<CardHeader className="border-b border-grayScale-200 pb-4">
|
||||||
|
|
@ -354,78 +165,51 @@ export function QuestionsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Select
|
<Select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}>
|
||||||
value={typeFilter}
|
|
||||||
onChange={(e) => {
|
|
||||||
setTypeFilter(e.target.value as QuestionTypeFilter)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="all">All Types</option>
|
<option value="all">All Types</option>
|
||||||
<option value="MCQ">Multiple Choice</option>
|
<option value="multiple-choice">Multiple Choice</option>
|
||||||
<option value="TRUE_FALSE">True/False</option>
|
<option value="short-answer">Short Answer</option>
|
||||||
<option value="SHORT_ANSWER">Short Answer</option>
|
<option value="true-false">True/False</option>
|
||||||
<option value="AUDIO">Audio</option>
|
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
{categories.length > 0 && (
|
||||||
|
<Select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)}>
|
||||||
|
<option value="all">All Categories</option>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<option key={cat} value={cat}>
|
||||||
|
{cat}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{difficulties.length > 0 && (
|
||||||
<Select
|
<Select
|
||||||
value={difficultyFilter}
|
value={difficultyFilter}
|
||||||
onChange={(e) => {
|
onChange={(e) => setDifficultyFilter(e.target.value)}
|
||||||
setDifficultyFilter(e.target.value as DifficultyFilter)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<option value="all">All Difficulties</option>
|
<option value="all">All Difficulties</option>
|
||||||
<option value="EASY">Easy</option>
|
{difficulties.map((diff) => (
|
||||||
<option value="MEDIUM">Medium</option>
|
<option key={diff} value={diff}>
|
||||||
<option value="HARD">Hard</option>
|
{diff}
|
||||||
</Select>
|
</option>
|
||||||
<Select
|
))}
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => {
|
|
||||||
setStatusFilter(e.target.value as StatusFilter)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="all">All Statuses</option>
|
|
||||||
<option value="DRAFT">Draft</option>
|
|
||||||
<option value="PUBLISHED">Published</option>
|
|
||||||
<option value="INACTIVE">Inactive</option>
|
|
||||||
</Select>
|
|
||||||
<Select
|
|
||||||
value={String(pageSize)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = Number(e.target.value)
|
|
||||||
setPageSize(next)
|
|
||||||
setPage(1)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="10">10 / page</option>
|
|
||||||
<option value="20">20 / page</option>
|
|
||||||
<option value="50">50 / page</option>
|
|
||||||
</Select>
|
</Select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results count */}
|
{/* Results count */}
|
||||||
<div className="text-xs font-medium text-grayScale-400">
|
<div className="text-xs font-medium text-grayScale-400">
|
||||||
Showing {paginatedQuestions.length} of {totalCount} questions
|
Showing {filteredQuestions.length} of {questions.length} questions
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Questions Table */}
|
{/* Questions Table */}
|
||||||
{loading ? (
|
{filteredQuestions.length > 0 ? (
|
||||||
<div className="flex items-center justify-center rounded-lg border border-grayScale-200 py-16">
|
|
||||||
<p className="text-sm text-grayScale-500">Loading questions...</p>
|
|
||||||
</div>
|
|
||||||
) : filteredQuestions.length > 0 ? (
|
|
||||||
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
|
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
|
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
|
||||||
<TableHead className="w-10 py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isAllCurrentPageSelected}
|
|
||||||
onChange={toggleSelectAllCurrentPage}
|
|
||||||
aria-label="Select all questions on current page"
|
|
||||||
/>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||||
Question
|
Question
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
|
@ -433,10 +217,10 @@ export function QuestionsPage() {
|
||||||
Type
|
Type
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
|
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
|
||||||
Difficulty
|
Category
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
|
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
|
||||||
Status
|
Difficulty
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||||
Points
|
Points
|
||||||
|
|
@ -447,80 +231,65 @@ export function QuestionsPage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{paginatedQuestions.map((question, index) => (
|
{filteredQuestions.map((question, index) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={question.id}
|
key={question.id}
|
||||||
onClick={() => openDetails(question.id)}
|
className={`transition-colors hover:bg-brand-100/30 ${
|
||||||
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${
|
|
||||||
index % 2 === 0 ? "bg-white" : "bg-grayScale-100/50"
|
index % 2 === 0 ? "bg-white" : "bg-grayScale-100/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<TableCell className="py-3.5">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedIds.includes(question.id)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onChange={() => toggleOne(question.id)}
|
|
||||||
aria-label={`Select question ${question.id}`}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="max-w-md py-3.5">
|
<TableCell className="max-w-md py-3.5">
|
||||||
<div className="truncate text-sm font-medium text-grayScale-600">
|
<div className="truncate text-sm font-medium text-grayScale-600">
|
||||||
{question.question_text}
|
{question.question}
|
||||||
</div>
|
</div>
|
||||||
{question.question_type === "MCQ" && (question.options?.length ?? 0) > 0 && (
|
{question.type === "multiple-choice" && question.options.length > 0 && (
|
||||||
<div className="mt-1 truncate text-xs text-grayScale-400">
|
<div className="mt-1 truncate text-xs text-grayScale-400">
|
||||||
Options: {question.options?.map((opt) => opt.option_text).join(", ")}
|
Options: {question.options.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="py-3.5">
|
<TableCell className="py-3.5">
|
||||||
<Badge className={`text-xs font-medium ${typeColors[question.question_type] || "bg-grayScale-100 text-grayScale-600"}`}>
|
<Badge className={`text-xs font-medium ${typeColors[question.type]}`}>
|
||||||
{typeLabels[question.question_type] || question.question_type}
|
{typeLabels[question.type]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="hidden py-3.5 text-sm text-grayScale-500 md:table-cell">
|
||||||
|
{question.category || "—"}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="hidden py-3.5 md:table-cell">
|
<TableCell className="hidden py-3.5 md:table-cell">
|
||||||
{question.difficulty_level && (
|
{question.difficulty && (
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
question.difficulty_level === "EASY"
|
question.difficulty === "Easy"
|
||||||
? "default"
|
? "default"
|
||||||
: question.difficulty_level === "MEDIUM"
|
: question.difficulty === "Medium"
|
||||||
? "secondary"
|
? "secondary"
|
||||||
: "destructive"
|
: "destructive"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{question.difficulty_level}
|
{question.difficulty}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden py-3.5 text-sm text-grayScale-500 md:table-cell">
|
|
||||||
{question.status || "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="py-3.5 text-sm font-semibold text-grayScale-600">
|
<TableCell className="py-3.5 text-sm font-semibold text-grayScale-600">
|
||||||
{question.points ?? 0}
|
{question.points}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="py-3.5 text-right">
|
<TableCell className="py-3.5 text-right">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Link to={`/content/questions/edit/${question.id}`}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-grayScale-400 hover:bg-brand-100/50 hover:text-brand-500"
|
className="h-8 w-8 text-grayScale-400 hover:bg-brand-100/50 hover:text-brand-500"
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
openEdit(question.id)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-grayScale-400 hover:bg-red-50 hover:text-destructive"
|
className="h-8 w-8 text-grayScale-400 hover:bg-red-50 hover:text-destructive"
|
||||||
onClick={(e) => {
|
onClick={() => handleDelete(question.id)}
|
||||||
e.stopPropagation()
|
|
||||||
handleDeleteRequest([question.id])
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -545,225 +314,8 @@ export function QuestionsPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 border-t border-grayScale-200 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<p className="text-xs text-grayScale-500">
|
|
||||||
Page {page} of {totalPages}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={!canGoPrev}
|
|
||||||
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={!canGoNext}
|
|
||||||
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{deleteDialogOpen && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
|
||||||
<div className="mx-4 w-full max-w-md rounded-xl bg-white shadow-2xl">
|
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">
|
|
||||||
Delete {pendingDeleteIds.length > 1 ? "Questions" : "Question"}
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteDialogOpen(false)}
|
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-6">
|
|
||||||
<p className="text-sm leading-relaxed text-grayScale-600">
|
|
||||||
Are you sure you want to delete{" "}
|
|
||||||
<span className="font-semibold text-grayScale-800">
|
|
||||||
{pendingDeleteIds.length} question{pendingDeleteIds.length > 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
? This action cannot be undone.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
|
||||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={deleting}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button className="bg-red-500 hover:bg-red-600" onClick={handleDeleteConfirm} disabled={deleting}>
|
|
||||||
{deleting ? "Deleting..." : "Delete"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detailsOpen && (
|
|
||||||
<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 rounded-xl bg-white shadow-2xl">
|
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Question Details</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => setDetailsOpen(false)}
|
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4 px-6 py-6">
|
|
||||||
{detailLoading || !detailData ? (
|
|
||||||
<p className="text-sm text-grayScale-500">Loading details...</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Question</p>
|
|
||||||
<p className="mt-1 text-sm text-grayScale-700">{detailData.question_text}</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
||||||
<p><span className="font-medium">Type:</span> {typeLabels[detailData.question_type] || detailData.question_type}</p>
|
|
||||||
<p><span className="font-medium">Difficulty:</span> {detailData.difficulty_level || "—"}</p>
|
|
||||||
<p><span className="font-medium">Points:</span> {detailData.points ?? 0}</p>
|
|
||||||
<p><span className="font-medium">Status:</span> {detailData.status || "—"}</p>
|
|
||||||
</div>
|
|
||||||
{(detailData.options ?? []).length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Options</p>
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
{(detailData.options ?? [])
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.option_order - b.option_order)
|
|
||||||
.map((opt) => (
|
|
||||||
<div
|
|
||||||
key={`${opt.option_order}-${opt.option_text}`}
|
|
||||||
className={`rounded-md border px-3 py-2 text-sm ${
|
|
||||||
opt.is_correct ? "border-green-200 bg-green-50 text-green-700" : "border-grayScale-200 bg-grayScale-50 text-grayScale-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{opt.option_order}. {opt.option_text}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editOpen && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
|
||||||
<div className="mx-4 max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-white shadow-2xl">
|
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Edit Question</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => setEditOpen(false)}
|
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4 px-6 py-6">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-sm font-medium text-grayScale-600">Question Text</label>
|
|
||||||
<Textarea value={editQuestionText} onChange={(e) => setEditQuestionText(e.target.value)} rows={3} />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
|
|
||||||
<Select value={editQuestionType} onChange={(e) => setEditQuestionType(e.target.value as QuestionTypeEdit)}>
|
|
||||||
<option value="MCQ">Multiple Choice</option>
|
|
||||||
<option value="TRUE_FALSE">True/False</option>
|
|
||||||
<option value="SHORT_ANSWER">Short Answer</option>
|
|
||||||
<option value="AUDIO">Audio</option>
|
|
||||||
</Select>
|
|
||||||
<Select value={editDifficulty} onChange={(e) => setEditDifficulty(e.target.value)}>
|
|
||||||
<option value="EASY">Easy</option>
|
|
||||||
<option value="MEDIUM">Medium</option>
|
|
||||||
<option value="HARD">Hard</option>
|
|
||||||
</Select>
|
|
||||||
<Input type="number" min={1} value={editPoints} onChange={(e) => setEditPoints(Number(e.target.value) || 1)} />
|
|
||||||
<Select value={editStatus} onChange={(e) => setEditStatus(e.target.value)}>
|
|
||||||
<option value="DRAFT">Draft</option>
|
|
||||||
<option value="PUBLISHED">Published</option>
|
|
||||||
<option value="INACTIVE">Inactive</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{editQuestionType !== "SHORT_ANSWER" && editQuestionType !== "AUDIO" && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-600">Options</label>
|
|
||||||
{editOptions.map((opt, idx) => (
|
|
||||||
<div key={idx} className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
checked={opt.is_correct}
|
|
||||||
onChange={() =>
|
|
||||||
setEditOptions((prev) =>
|
|
||||||
prev.map((item, i) => ({ ...item, is_correct: i === idx })),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={opt.option_text}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditOptions((prev) =>
|
|
||||||
prev.map((item, i) =>
|
|
||||||
i === idx ? { ...item, option_text: e.target.value } : item,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder={`Option ${idx + 1}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
setEditOptions((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ option_text: "", option_order: prev.length + 1, is_correct: false },
|
|
||||||
])
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Add Option
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{editQuestionType === "SHORT_ANSWER" && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-sm font-medium text-grayScale-600">Short Answer</label>
|
|
||||||
<Input value={editShortAnswer} onChange={(e) => setEditShortAnswer(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<Input value={editTips} onChange={(e) => setEditTips(e.target.value)} placeholder="Tips (optional)" />
|
|
||||||
<Input value={editExplanation} onChange={(e) => setEditExplanation(e.target.value)} placeholder="Explanation (optional)" />
|
|
||||||
<Input value={editVoicePrompt} onChange={(e) => setEditVoicePrompt(e.target.value)} placeholder="Voice prompt (optional)" />
|
|
||||||
<Input value={editSampleAnswerVoicePrompt} onChange={(e) => setEditSampleAnswerVoicePrompt(e.target.value)} placeholder="Sample answer voice prompt (optional)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
|
||||||
<Button variant="outline" onClick={() => setEditOpen(false)} disabled={savingEdit}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button className="bg-brand-500 hover:bg-brand-600" onClick={saveEdit} disabled={savingEdit}>
|
|
||||||
{savingEdit ? "Saving..." : "Save Changes"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,164 +1,9 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { Link } from "react-router-dom"
|
||||||
import { Plus, Mic, X } from "lucide-react"
|
import { Plus, Mic } from "lucide-react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
import { Card, CardContent } from "../../components/ui/card"
|
||||||
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 {
|
|
||||||
addQuestionToSet,
|
|
||||||
createQuestion,
|
|
||||||
createQuestionSet,
|
|
||||||
getQuestions,
|
|
||||||
} from "../../api/courses.api"
|
|
||||||
import type { QuestionDetail } from "../../types/course.types"
|
|
||||||
|
|
||||||
export function SpeakingPage() {
|
export function SpeakingPage() {
|
||||||
const [audioQuestions, setAudioQuestions] = useState<QuestionDetail[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [openCreate, setOpenCreate] = useState(false)
|
|
||||||
|
|
||||||
const [setTitle, setSetTitle] = useState("")
|
|
||||||
const [setDescription, setSetDescription] = useState("")
|
|
||||||
const [ownerType, setOwnerType] = useState<"SUB_COURSE" | "COURSE">("SUB_COURSE")
|
|
||||||
const [ownerId, setOwnerId] = useState("")
|
|
||||||
const [setStatus, setSetStatus] = useState<"DRAFT" | "PUBLISHED">("PUBLISHED")
|
|
||||||
|
|
||||||
const [questionText, setQuestionText] = useState("")
|
|
||||||
const [difficulty, setDifficulty] = useState("EASY")
|
|
||||||
const [points, setPoints] = useState(1)
|
|
||||||
const [voicePrompt, setVoicePrompt] = useState("")
|
|
||||||
const [sampleAnswerVoicePrompt, setSampleAnswerVoicePrompt] = useState("")
|
|
||||||
const [audioCorrectAnswerText, setAudioCorrectAnswerText] = useState("")
|
|
||||||
|
|
||||||
const fetchAudioQuestions = useCallback(async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const batchSize = 100
|
|
||||||
let nextOffset = 0
|
|
||||||
let expectedTotal = Number.POSITIVE_INFINITY
|
|
||||||
let allRows: QuestionDetail[] = []
|
|
||||||
|
|
||||||
while (allRows.length < expectedTotal) {
|
|
||||||
const res = await getQuestions({
|
|
||||||
question_type: "AUDIO",
|
|
||||||
limit: batchSize,
|
|
||||||
offset: nextOffset,
|
|
||||||
})
|
|
||||||
const payload = res.data?.data as unknown
|
|
||||||
const meta = res.data?.metadata as { total_count?: number } | null | undefined
|
|
||||||
|
|
||||||
let chunk: QuestionDetail[] = []
|
|
||||||
let chunkTotal: number | undefined
|
|
||||||
if (Array.isArray(payload)) {
|
|
||||||
chunk = payload as QuestionDetail[]
|
|
||||||
chunkTotal = meta?.total_count
|
|
||||||
} else if (
|
|
||||||
payload &&
|
|
||||||
typeof payload === "object" &&
|
|
||||||
Array.isArray((payload as { questions?: unknown[] }).questions)
|
|
||||||
) {
|
|
||||||
const data = payload as { questions: QuestionDetail[]; total_count?: number }
|
|
||||||
chunk = data.questions
|
|
||||||
chunkTotal = data.total_count ?? meta?.total_count
|
|
||||||
}
|
|
||||||
|
|
||||||
allRows = [...allRows, ...chunk]
|
|
||||||
if (typeof chunkTotal === "number" && Number.isFinite(chunkTotal)) {
|
|
||||||
expectedTotal = chunkTotal
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chunk.length < batchSize) break
|
|
||||||
nextOffset += chunk.length
|
|
||||||
}
|
|
||||||
|
|
||||||
setAudioQuestions(allRows)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch audio questions:", error)
|
|
||||||
setAudioQuestions([])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAudioQuestions()
|
|
||||||
}, [fetchAudioQuestions])
|
|
||||||
|
|
||||||
const resetCreateForm = () => {
|
|
||||||
setSetTitle("")
|
|
||||||
setSetDescription("")
|
|
||||||
setOwnerType("SUB_COURSE")
|
|
||||||
setOwnerId("")
|
|
||||||
setSetStatus("PUBLISHED")
|
|
||||||
setQuestionText("")
|
|
||||||
setDifficulty("EASY")
|
|
||||||
setPoints(1)
|
|
||||||
setVoicePrompt("")
|
|
||||||
setSampleAnswerVoicePrompt("")
|
|
||||||
setAudioCorrectAnswerText("")
|
|
||||||
}
|
|
||||||
|
|
||||||
const canCreate = useMemo(() => {
|
|
||||||
return (
|
|
||||||
setTitle.trim().length > 0 &&
|
|
||||||
ownerId.trim().length > 0 &&
|
|
||||||
questionText.trim().length > 0 &&
|
|
||||||
voicePrompt.trim().length > 0 &&
|
|
||||||
sampleAnswerVoicePrompt.trim().length > 0 &&
|
|
||||||
audioCorrectAnswerText.trim().length > 0
|
|
||||||
)
|
|
||||||
}, [setTitle, ownerId, questionText, voicePrompt, sampleAnswerVoicePrompt, audioCorrectAnswerText])
|
|
||||||
|
|
||||||
const handleCreateSpeakingPractice = async () => {
|
|
||||||
if (!canCreate) return
|
|
||||||
const parsedOwnerId = Number(ownerId)
|
|
||||||
if (!Number.isFinite(parsedOwnerId) || parsedOwnerId <= 0) return
|
|
||||||
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
const setRes = await createQuestionSet({
|
|
||||||
title: setTitle.trim(),
|
|
||||||
description: setDescription.trim(),
|
|
||||||
set_type: "PRACTICE",
|
|
||||||
owner_type: ownerType,
|
|
||||||
owner_id: parsedOwnerId,
|
|
||||||
status: setStatus,
|
|
||||||
})
|
|
||||||
|
|
||||||
const setId = setRes.data?.data?.id
|
|
||||||
if (!setId) throw new Error("Question set creation failed: missing set ID")
|
|
||||||
|
|
||||||
const questionRes = await createQuestion({
|
|
||||||
question_text: questionText.trim(),
|
|
||||||
question_type: "AUDIO",
|
|
||||||
status: "PUBLISHED",
|
|
||||||
difficulty_level: difficulty,
|
|
||||||
points,
|
|
||||||
voice_prompt: voicePrompt.trim(),
|
|
||||||
sample_answer_voice_prompt: sampleAnswerVoicePrompt.trim(),
|
|
||||||
audio_correct_answer_text: audioCorrectAnswerText.trim(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const questionId = questionRes.data?.data?.id
|
|
||||||
if (!questionId) throw new Error("Question creation failed: missing question ID")
|
|
||||||
|
|
||||||
await addQuestionToSet(setId, {
|
|
||||||
question_id: questionId,
|
|
||||||
display_order: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
setOpenCreate(false)
|
|
||||||
resetCreateForm()
|
|
||||||
await fetchAudioQuestions()
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to create speaking practice:", error)
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
|
@ -170,157 +15,35 @@ export function SpeakingPage() {
|
||||||
Create and manage speaking practice sessions for your learners.
|
Create and manage speaking practice sessions for your learners.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto" onClick={() => setOpenCreate(true)}>
|
<Link to="/content/speaking/add-practice" className="w-full sm:w-auto">
|
||||||
|
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Add New Speaking Practice
|
Add New Practice
|
||||||
</Button>
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="shadow-soft">
|
<Card className="border-2 border-dashed border-grayScale-200 shadow-none">
|
||||||
<CardHeader className="border-b border-grayScale-200 pb-4">
|
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
|
||||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
<div className="mb-6 grid h-20 w-20 place-items-center rounded-full bg-gradient-to-br from-brand-100 to-brand-200">
|
||||||
AUDIO Questions
|
<Mic className="h-10 w-10 text-brand-500" />
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-5">
|
|
||||||
{loading ? (
|
|
||||||
<div className="py-14 text-center text-sm text-grayScale-500">Loading audio questions...</div>
|
|
||||||
) : audioQuestions.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-14 text-center">
|
|
||||||
<div className="mb-6 grid h-16 w-16 place-items-center rounded-full bg-gradient-to-br from-brand-100 to-brand-200">
|
|
||||||
<Mic className="h-8 w-8 text-brand-500" />
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-base font-semibold text-grayScale-600">No audio questions yet</h3>
|
<h3 className="text-lg font-semibold text-grayScale-600">
|
||||||
|
No speaking practices yet
|
||||||
|
</h3>
|
||||||
<p className="mt-2 max-w-md text-sm leading-relaxed text-grayScale-400">
|
<p className="mt-2 max-w-md text-sm leading-relaxed text-grayScale-400">
|
||||||
Create a speaking practice to automatically create and attach an AUDIO question.
|
Get started by adding your first speaking practice session. Your
|
||||||
|
learners will be able to practice pronunciation and conversation
|
||||||
|
skills.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<Link to="/content/speaking/add-practice" className="mt-8">
|
||||||
) : (
|
<Button className="bg-brand-500 px-6 hover:bg-brand-600">
|
||||||
<div className="space-y-3">
|
<Plus className="h-4 w-4" />
|
||||||
{audioQuestions.map((question, idx) => (
|
Create Your First Practice
|
||||||
<div
|
</Button>
|
||||||
key={question.id}
|
</Link>
|
||||||
className={`rounded-lg border px-4 py-3 ${
|
|
||||||
idx % 2 === 0 ? "border-grayScale-200 bg-white" : "border-grayScale-100 bg-grayScale-50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p className="text-sm font-medium text-grayScale-700">{question.question_text}</p>
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
|
||||||
<span className="rounded-md bg-purple-100 px-2 py-1 text-purple-700">AUDIO</span>
|
|
||||||
<span className="rounded-md bg-grayScale-100 px-2 py-1 text-grayScale-600">
|
|
||||||
Difficulty: {question.difficulty_level || "—"}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-md bg-grayScale-100 px-2 py-1 text-grayScale-600">
|
|
||||||
Points: {question.points ?? 0}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-md bg-grayScale-100 px-2 py-1 text-grayScale-600">
|
|
||||||
Status: {question.status || "—"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{openCreate && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
|
||||||
<div className="mx-4 max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-white shadow-2xl">
|
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Create Speaking Practice</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => setOpenCreate(false)}
|
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-5 px-6 py-6">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-1.5 sm:col-span-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-600">Practice Title</label>
|
|
||||||
<Input value={setTitle} onChange={(e) => setSetTitle(e.target.value)} placeholder="Speaking practice title" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5 sm:col-span-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-600">Practice Description (Optional)</label>
|
|
||||||
<Textarea value={setDescription} onChange={(e) => setSetDescription(e.target.value)} rows={2} placeholder="Brief description" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-sm font-medium text-grayScale-600">Owner Type</label>
|
|
||||||
<Select value={ownerType} onChange={(e) => setOwnerType(e.target.value as "SUB_COURSE" | "COURSE")}>
|
|
||||||
<option value="SUB_COURSE">SUB_COURSE</option>
|
|
||||||
<option value="COURSE">COURSE</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-sm font-medium text-grayScale-600">Owner ID</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={ownerId}
|
|
||||||
onChange={(e) => setOwnerId(e.target.value)}
|
|
||||||
placeholder="e.g. 12"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-sm font-medium text-grayScale-600">Set Status</label>
|
|
||||||
<Select value={setStatus} onChange={(e) => setSetStatus(e.target.value as "DRAFT" | "PUBLISHED")}>
|
|
||||||
<option value="PUBLISHED">PUBLISHED</option>
|
|
||||||
<option value="DRAFT">DRAFT</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50/50 p-4">
|
|
||||||
<p className="mb-3 text-sm font-semibold text-grayScale-700">AUDIO Question</p>
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-1.5 sm:col-span-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-600">Question Text</label>
|
|
||||||
<Textarea value={questionText} onChange={(e) => setQuestionText(e.target.value)} rows={2} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-sm font-medium text-grayScale-600">Difficulty</label>
|
|
||||||
<Select value={difficulty} onChange={(e) => setDifficulty(e.target.value)}>
|
|
||||||
<option value="EASY">EASY</option>
|
|
||||||
<option value="MEDIUM">MEDIUM</option>
|
|
||||||
<option value="HARD">HARD</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-sm font-medium text-grayScale-600">Points</label>
|
|
||||||
<Input type="number" min={1} value={points} onChange={(e) => setPoints(Number(e.target.value) || 1)} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5 sm:col-span-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-600">Voice Prompt</label>
|
|
||||||
<Textarea value={voicePrompt} onChange={(e) => setVoicePrompt(e.target.value)} rows={2} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5 sm:col-span-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-600">Sample Answer Voice Prompt</label>
|
|
||||||
<Textarea value={sampleAnswerVoicePrompt} onChange={(e) => setSampleAnswerVoicePrompt(e.target.value)} rows={2} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5 sm:col-span-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-600">Audio Correct Answer Text</label>
|
|
||||||
<Textarea value={audioCorrectAnswerText} onChange={(e) => setAudioCorrectAnswerText(e.target.value)} rows={2} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
|
||||||
<Button variant="outline" onClick={() => setOpenCreate(false)} disabled={saving}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="bg-brand-500 hover:bg-brand-600"
|
|
||||||
disabled={!canCreate || saving}
|
|
||||||
onClick={handleCreateSpeakingPractice}
|
|
||||||
>
|
|
||||||
{saving ? "Creating..." : "Create Practice + Audio Question"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Link, useParams, useNavigate } from "react-router-dom"
|
import { Link, useParams, useNavigate } from "react-router-dom"
|
||||||
import { ArrowLeft, Plus, FileText, Layers, Edit, Trash2, X, Video, MoreVertical, Star, ChevronLeft, ChevronRight, MessageSquare, Play, Loader2 } from "lucide-react"
|
import { ArrowLeft, Plus, FileText, Layers, Edit, Trash2, X, Video, MoreVertical } from "lucide-react"
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
|
||||||
import { Card } from "../../components/ui/card"
|
import { Card } from "../../components/ui/card"
|
||||||
import alertSrc from "../../assets/Alert.svg"
|
|
||||||
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 { Input } from "../../components/ui/input"
|
||||||
|
|
@ -15,14 +13,11 @@ import {
|
||||||
deleteQuestionSet,
|
deleteQuestionSet,
|
||||||
createVimeoVideo,
|
createVimeoVideo,
|
||||||
updateSubCourseVideo,
|
updateSubCourseVideo,
|
||||||
deleteSubCourseVideo,
|
deleteSubCourseVideo
|
||||||
getRatings,
|
|
||||||
getVimeoSample,
|
|
||||||
} from "../../api/courses.api"
|
} from "../../api/courses.api"
|
||||||
import type { SubCourse, QuestionSet, SubCourseVideo, Rating, VimeoSampleVideo } from "../../types/course.types"
|
import type { SubCourse, QuestionSet, SubCourseVideo } from "../../types/course.types"
|
||||||
import { Select } from "../../components/ui/select"
|
|
||||||
|
|
||||||
type TabType = "video" | "practice" | "ratings"
|
type TabType = "video" | "practice"
|
||||||
type StatusFilter = "all" | "published" | "draft" | "archived"
|
type StatusFilter = "all" | "published" | "draft" | "archived"
|
||||||
|
|
||||||
export function SubCourseContentPage() {
|
export function SubCourseContentPage() {
|
||||||
|
|
@ -65,27 +60,12 @@ export function SubCourseContentPage() {
|
||||||
const [deletingVideo, setDeletingVideo] = useState(false)
|
const [deletingVideo, setDeletingVideo] = useState(false)
|
||||||
const [openVideoMenuId, setOpenVideoMenuId] = useState<number | null>(null)
|
const [openVideoMenuId, setOpenVideoMenuId] = useState<number | null>(null)
|
||||||
|
|
||||||
// Ratings state
|
|
||||||
const [ratings, setRatings] = useState<Rating[]>([])
|
|
||||||
const [ratingsLoading, setRatingsLoading] = useState(false)
|
|
||||||
const [ratingsPage, setRatingsPage] = useState(0)
|
|
||||||
const [ratingsPageSize] = useState(10)
|
|
||||||
|
|
||||||
const [videoTitle, setVideoTitle] = useState("")
|
const [videoTitle, setVideoTitle] = useState("")
|
||||||
const [videoDescription, setVideoDescription] = useState("")
|
const [videoDescription, setVideoDescription] = useState("")
|
||||||
const [videoUrl, setVideoUrl] = useState("")
|
const [videoUrl, setVideoUrl] = useState("")
|
||||||
const [videoFileSize, setVideoFileSize] = useState<number>(0)
|
const [videoFileSize, setVideoFileSize] = useState<number>(0)
|
||||||
const [videoDuration, setVideoDuration] = useState<number>(0)
|
const [videoDuration, setVideoDuration] = useState<number>(0)
|
||||||
|
|
||||||
// Vimeo preview state
|
|
||||||
const [showPreviewModal, setShowPreviewModal] = useState(false)
|
|
||||||
const [previewIframe, setPreviewIframe] = useState("")
|
|
||||||
const [previewVideo, setPreviewVideo] = useState<VimeoSampleVideo | null>(null)
|
|
||||||
const [previewLoading, setPreviewLoading] = useState(false)
|
|
||||||
const [sampleVideoId, setSampleVideoId] = useState("")
|
|
||||||
const [modalPreviewIframe, setModalPreviewIframe] = useState("")
|
|
||||||
const [modalPreviewLoading, setModalPreviewLoading] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
if (!subCourseId || !courseId) return
|
if (!subCourseId || !courseId) return
|
||||||
|
|
@ -97,8 +77,8 @@ export function SubCourseContentPage() {
|
||||||
)
|
)
|
||||||
setSubCourse(foundSubCourse ?? null)
|
setSubCourse(foundSubCourse ?? null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch course data:", err)
|
console.error("Failed to fetch sub-course data:", err)
|
||||||
setError("Failed to load course")
|
setError("Failed to load sub-course")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -133,40 +113,14 @@ export function SubCourseContentPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchRatings = async (offset = 0) => {
|
|
||||||
if (!subCourseId) return
|
|
||||||
setRatingsLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await getRatings({
|
|
||||||
target_type: "sub_course",
|
|
||||||
target_id: Number(subCourseId),
|
|
||||||
limit: ratingsPageSize,
|
|
||||||
offset,
|
|
||||||
})
|
|
||||||
setRatings(res.data.data ?? [])
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to fetch ratings:", err)
|
|
||||||
} finally {
|
|
||||||
setRatingsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === "practice") {
|
if (activeTab === "practice") {
|
||||||
fetchPractices()
|
fetchPractices()
|
||||||
} else if (activeTab === "video") {
|
} else {
|
||||||
fetchVideos()
|
fetchVideos()
|
||||||
} else if (activeTab === "ratings") {
|
|
||||||
fetchRatings(ratingsPage * ratingsPageSize)
|
|
||||||
}
|
}
|
||||||
}, [activeTab, subCourseId])
|
}, [activeTab, subCourseId])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab === "ratings") {
|
|
||||||
fetchRatings(ratingsPage * ratingsPageSize)
|
|
||||||
}
|
|
||||||
}, [ratingsPage])
|
|
||||||
|
|
||||||
const handleAddPractice = () => {
|
const handleAddPractice = () => {
|
||||||
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}/add-practice`)
|
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}/add-practice`)
|
||||||
}
|
}
|
||||||
|
|
@ -321,47 +275,6 @@ export function SubCourseContentPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preview a video card via Vimeo sample API
|
|
||||||
const handlePreviewVideo = async (video: SubCourseVideo) => {
|
|
||||||
const idMatch = video.video_url?.match(/(\d{5,})/)
|
|
||||||
const vimeoId = idMatch?.[1] ?? "76979871" // fallback to Big Buck Bunny
|
|
||||||
setShowPreviewModal(true)
|
|
||||||
setPreviewLoading(true)
|
|
||||||
setPreviewIframe("")
|
|
||||||
setPreviewVideo(null)
|
|
||||||
try {
|
|
||||||
const res = await getVimeoSample(vimeoId)
|
|
||||||
setPreviewIframe(res.data.data.iframe)
|
|
||||||
setPreviewVideo(res.data.data.video)
|
|
||||||
} catch {
|
|
||||||
setPreviewIframe("")
|
|
||||||
} finally {
|
|
||||||
setPreviewLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preview inside add/edit modal from a sample vimeo ID picker
|
|
||||||
const handleModalPreview = async (vimeoId: string) => {
|
|
||||||
if (!vimeoId) {
|
|
||||||
setModalPreviewIframe("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setModalPreviewLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await getVimeoSample(vimeoId)
|
|
||||||
setModalPreviewIframe(res.data.data.iframe)
|
|
||||||
// Auto-fill fields from vimeo metadata
|
|
||||||
const v = res.data.data.video
|
|
||||||
if (!videoTitle) setVideoTitle(v.name)
|
|
||||||
if (!videoDescription) setVideoDescription(v.description?.slice(0, 200) ?? "")
|
|
||||||
if (!videoDuration) setVideoDuration(v.duration)
|
|
||||||
} catch {
|
|
||||||
setModalPreviewIframe("")
|
|
||||||
} finally {
|
|
||||||
setModalPreviewLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredPractices = practices.filter((practice) => {
|
const filteredPractices = practices.filter((practice) => {
|
||||||
if (statusFilter === "all") return true
|
if (statusFilter === "all") return true
|
||||||
if (statusFilter === "published") return practice.status === "PUBLISHED"
|
if (statusFilter === "published") return practice.status === "PUBLISHED"
|
||||||
|
|
@ -373,8 +286,8 @@ export function SubCourseContentPage() {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-20">
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
<img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
|
||||||
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading course…</p>
|
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading sub-course…</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -382,7 +295,9 @@ export function SubCourseContentPage() {
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-20">
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
<img src={alertSrc} alt="" className="h-12 w-12" />
|
<div className="rounded-full bg-red-50 p-3">
|
||||||
|
<X className="h-6 w-6 text-red-500" />
|
||||||
|
</div>
|
||||||
<p className="mt-3 text-sm font-medium text-red-600">{error}</p>
|
<p className="mt-3 text-sm font-medium text-red-600">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -396,7 +311,7 @@ export function SubCourseContentPage() {
|
||||||
className="group inline-flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm font-medium text-grayScale-500 transition-all hover:bg-grayScale-50 hover:text-grayScale-900"
|
className="group inline-flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm font-medium text-grayScale-500 transition-all hover:bg-grayScale-50 hover:text-grayScale-900"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
|
<ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
|
||||||
Back to Courses
|
Back to Sub-courses
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* SubCourse Header */}
|
{/* SubCourse Header */}
|
||||||
|
|
@ -459,19 +374,6 @@ export function SubCourseContentPage() {
|
||||||
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
|
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("ratings")}
|
|
||||||
className={`relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all ${
|
|
||||||
activeTab === "ratings"
|
|
||||||
? "text-brand-600"
|
|
||||||
: "text-grayScale-400 hover:text-grayScale-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Ratings
|
|
||||||
{activeTab === "ratings" && (
|
|
||||||
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -667,25 +569,15 @@ export function SubCourseContentPage() {
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h3 className="font-semibold leading-snug text-grayScale-900 line-clamp-2">{video.title}</h3>
|
<h3 className="font-semibold leading-snug text-grayScale-900 line-clamp-2">{video.title}</h3>
|
||||||
|
|
||||||
{/* Edit / Preview buttons */}
|
{/* Edit button */}
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex-1 border-grayScale-200 text-grayScale-700 transition-colors hover:border-grayScale-300 hover:bg-grayScale-50"
|
className="w-full border-grayScale-200 text-grayScale-700 transition-colors hover:border-grayScale-300 hover:bg-grayScale-50"
|
||||||
onClick={() => handleEditVideoClick(video)}
|
onClick={() => handleEditVideoClick(video)}
|
||||||
>
|
>
|
||||||
<Edit className="mr-1.5 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1 border-brand-200 text-brand-600 transition-colors hover:border-brand-300 hover:bg-brand-50"
|
|
||||||
onClick={() => handlePreviewVideo(video)}
|
|
||||||
>
|
|
||||||
<Play className="mr-1.5 h-4 w-4" />
|
|
||||||
Preview
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Publish button */}
|
{/* Publish button */}
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -706,135 +598,6 @@ export function SubCourseContentPage() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Ratings Tab */}
|
|
||||||
{activeTab === "ratings" && (
|
|
||||||
<>
|
|
||||||
{ratingsLoading ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-20">
|
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
|
|
||||||
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading ratings…</p>
|
|
||||||
</div>
|
|
||||||
) : ratings.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-16">
|
|
||||||
<div className="rounded-full bg-amber-50 p-4">
|
|
||||||
<Star className="h-8 w-8 text-amber-400" />
|
|
||||||
</div>
|
|
||||||
<p className="mt-4 text-sm font-semibold text-grayScale-700">No ratings yet</p>
|
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
|
||||||
Ratings will appear here once learners start reviewing this course.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Summary bar */}
|
|
||||||
<Card className="overflow-hidden border-grayScale-200">
|
|
||||||
<div className="flex flex-wrap items-center gap-6 px-5 py-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Star className="h-5 w-5 text-amber-400" fill="currentColor" />
|
|
||||||
<span className="text-lg font-bold text-grayScale-800">
|
|
||||||
{(ratings.reduce((sum, r) => sum + r.stars, 0) / ratings.length).toFixed(1)}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-grayScale-400">
|
|
||||||
/ 5
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-5 w-px bg-grayScale-200" />
|
|
||||||
<span className="text-sm text-grayScale-500">
|
|
||||||
{ratings.length} review{ratings.length !== 1 ? "s" : ""} on this page
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Rating cards */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{ratings.map((rating) => (
|
|
||||||
<Card
|
|
||||||
key={rating.id}
|
|
||||||
className="overflow-hidden border-grayScale-200 bg-white"
|
|
||||||
>
|
|
||||||
<div className="p-5 space-y-3">
|
|
||||||
{/* Header row */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-brand-50 text-sm font-bold text-brand-600">
|
|
||||||
U{rating.user_id}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold text-grayScale-700">User #{rating.user_id}</p>
|
|
||||||
<p className="text-[11px] text-grayScale-400">
|
|
||||||
{new Date(rating.created_at).toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
})}
|
|
||||||
{rating.updated_at !== rating.created_at && (
|
|
||||||
<span className="ml-1.5 text-grayScale-300">· edited</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stars */}
|
|
||||||
<div className="flex items-center gap-0.5">
|
|
||||||
{[1, 2, 3, 4, 5].map((s) => (
|
|
||||||
<Star
|
|
||||||
key={s}
|
|
||||||
className={`h-4 w-4 ${
|
|
||||||
s <= rating.stars
|
|
||||||
? "text-amber-400"
|
|
||||||
: "text-grayScale-200"
|
|
||||||
}`}
|
|
||||||
fill={s <= rating.stars ? "currentColor" : "none"}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Review text */}
|
|
||||||
{rating.review && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<MessageSquare className="mt-0.5 h-3.5 w-3.5 shrink-0 text-grayScale-300" />
|
|
||||||
<p className="text-sm leading-relaxed text-grayScale-600">
|
|
||||||
{rating.review}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
<div className="flex items-center justify-between border-t border-grayScale-100 pt-4">
|
|
||||||
<p className="text-xs text-grayScale-400">
|
|
||||||
Page {ratingsPage + 1}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
disabled={ratingsPage <= 0}
|
|
||||||
onClick={() => setRatingsPage((p) => p - 1)}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
disabled={ratings.length < ratingsPageSize}
|
|
||||||
onClick={() => setRatingsPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete Modal */}
|
{/* Delete Modal */}
|
||||||
{showDeleteModal && practiceToDelete && (
|
{showDeleteModal && practiceToDelete && (
|
||||||
<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">
|
||||||
|
|
@ -927,48 +690,17 @@ export function SubCourseContentPage() {
|
||||||
{/* Add Video Modal */}
|
{/* Add Video Modal */}
|
||||||
{showAddVideoModal && (
|
{showAddVideoModal && (
|
||||||
<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-lg rounded-2xl bg-white shadow-2xl">
|
<div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Add Video</h2>
|
<h2 className="text-lg font-semibold text-grayScale-900">Add Video</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowAddVideoModal(false); setSampleVideoId(""); setModalPreviewIframe("") }}
|
onClick={() => setShowAddVideoModal(false)}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[70vh] space-y-5 overflow-y-auto px-6 py-6">
|
<div className="space-y-5 px-6 py-6">
|
||||||
{/* Sample Vimeo picker */}
|
|
||||||
<div className="rounded-xl border border-brand-100 bg-brand-50/40 p-4 space-y-3">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-brand-600">
|
|
||||||
Try a sample Vimeo video
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Select
|
|
||||||
className="flex-1 text-sm"
|
|
||||||
value={sampleVideoId}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSampleVideoId(e.target.value)
|
|
||||||
handleModalPreview(e.target.value)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">Select a sample video…</option>
|
|
||||||
<option value="76979871">Big Buck Bunny</option>
|
|
||||||
<option value="1084537">Big Buck Bunny (alt)</option>
|
|
||||||
<option value="253989945">Vimeo Staff Pick</option>
|
|
||||||
<option value="305727901">Big Buck Bunny (4K)</option>
|
|
||||||
<option value="148751763">GoPro Footage</option>
|
|
||||||
</Select>
|
|
||||||
{modalPreviewLoading && <Loader2 className="h-4 w-4 shrink-0 animate-spin text-brand-500" />}
|
|
||||||
</div>
|
|
||||||
{modalPreviewIframe && (
|
|
||||||
<div
|
|
||||||
className="aspect-video w-full overflow-hidden rounded-lg"
|
|
||||||
dangerouslySetInnerHTML={{ __html: modalPreviewIframe }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Title</label>
|
<label className="text-sm font-medium text-grayScale-700">Title</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -1123,48 +855,6 @@ export function SubCourseContentPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Video Preview Modal */}
|
|
||||||
{showPreviewModal && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
|
||||||
<div className="mx-4 w-full max-w-2xl rounded-2xl bg-white shadow-2xl">
|
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">
|
|
||||||
{previewVideo?.name ?? "Video Preview"}
|
|
||||||
</h2>
|
|
||||||
{previewVideo && (
|
|
||||||
<p className="mt-0.5 text-xs text-grayScale-400">
|
|
||||||
{Math.floor(previewVideo.duration / 60)}:{(previewVideo.duration % 60).toString().padStart(2, "0")} • {previewVideo.width}×{previewVideo.height}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowPreviewModal(false)}
|
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="p-6">
|
|
||||||
{previewLoading ? (
|
|
||||||
<div className="flex aspect-video items-center justify-center rounded-xl bg-grayScale-50">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-brand-500" />
|
|
||||||
</div>
|
|
||||||
) : previewIframe ? (
|
|
||||||
<div
|
|
||||||
className="aspect-video w-full overflow-hidden rounded-xl"
|
|
||||||
dangerouslySetInnerHTML={{ __html: previewIframe }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex aspect-video items-center justify-center rounded-xl bg-grayScale-50">
|
|
||||||
<p className="text-sm text-grayScale-400">Failed to load preview.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,425 +1,223 @@
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react"
|
||||||
import { Link, useParams, useNavigate } from "react-router-dom";
|
import { Link, useParams, useNavigate } from "react-router-dom"
|
||||||
import {
|
import { ArrowLeft, Layers, ToggleLeft, ToggleRight, MoreVertical, X, Trash2, RefreshCw, AlertCircle, Edit } from "lucide-react"
|
||||||
ArrowLeft,
|
import { Card, CardContent } from "../../components/ui/card"
|
||||||
ToggleLeft,
|
import { Badge } from "../../components/ui/badge"
|
||||||
ToggleRight,
|
import { Button } from "../../components/ui/button"
|
||||||
MoreVertical,
|
import { getSubCoursesByCourse, getCoursesByCategory, getCourseCategories, createSubCourse, updateSubCourse, updateSubCourseStatus, deleteSubCourse } from "../../api/courses.api"
|
||||||
X,
|
import { Input } from "../../components/ui/input"
|
||||||
Trash2,
|
import type { SubCourse, Course, CourseCategory } from "../../types/course.types"
|
||||||
AlertCircle,
|
|
||||||
Edit,
|
|
||||||
Link2,
|
|
||||||
Plus,
|
|
||||||
Loader2,
|
|
||||||
LayoutGrid,
|
|
||||||
GitBranch,
|
|
||||||
ChevronDown,
|
|
||||||
Lock,
|
|
||||||
ArrowRight,
|
|
||||||
} from "lucide-react";
|
|
||||||
import practiceSrc from "../../assets/Practice.svg";
|
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
|
|
||||||
import { Card, CardContent } from "../../components/ui/card";
|
|
||||||
import alertSrc from "../../assets/Alert.svg";
|
|
||||||
import { Badge } from "../../components/ui/badge";
|
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
import {
|
|
||||||
getSubCoursesByCourse,
|
|
||||||
getCoursesByCategory,
|
|
||||||
getCourseCategories,
|
|
||||||
createSubCourse,
|
|
||||||
updateSubCourse,
|
|
||||||
updateSubCourseStatus,
|
|
||||||
deleteSubCourse,
|
|
||||||
getSubCoursePrerequisites,
|
|
||||||
addSubCoursePrerequisite,
|
|
||||||
removeSubCoursePrerequisite,
|
|
||||||
} from "../../api/courses.api";
|
|
||||||
import { Input } from "../../components/ui/input";
|
|
||||||
import type {
|
|
||||||
SubCourse,
|
|
||||||
Course,
|
|
||||||
CourseCategory,
|
|
||||||
SubCoursePrerequisite,
|
|
||||||
} from "../../types/course.types";
|
|
||||||
|
|
||||||
export function SubCoursesPage() {
|
export function SubCoursesPage() {
|
||||||
const { categoryId, courseId } = useParams<{
|
const { categoryId, courseId } = useParams<{ categoryId: string; courseId: string }>()
|
||||||
categoryId: string;
|
const navigate = useNavigate()
|
||||||
courseId: string;
|
const [subCourses, setSubCourses] = useState<SubCourse[]>([])
|
||||||
}>();
|
const [course, setCourse] = useState<Course | null>(null)
|
||||||
const navigate = useNavigate();
|
const [category, setCategory] = useState<CourseCategory | null>(null)
|
||||||
const [subCourses, setSubCourses] = useState<SubCourse[]>([]);
|
const [loading, setLoading] = useState(true)
|
||||||
const [course, setCourse] = useState<Course | null>(null);
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [category, setCategory] = useState<CourseCategory | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [openMenuId, setOpenMenuId] = useState<number | null>(null);
|
const [openMenuId, setOpenMenuId] = useState<number | null>(null)
|
||||||
const [togglingId, setTogglingId] = useState<number | null>(null);
|
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||||
const [subCourseToDelete, setSubCourseToDelete] = useState<SubCourse | null>(
|
const [subCourseToDelete, setSubCourseToDelete] = useState<SubCourse | null>(null)
|
||||||
null,
|
const [deleting, setDeleting] = useState(false)
|
||||||
);
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const [deleting, setDeleting] = useState(false);
|
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const [showAddModal, setShowAddModal] = useState(false);
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false)
|
||||||
const [subCourseToEdit, setSubCourseToEdit] = useState<SubCourse | null>(
|
const [subCourseToEdit, setSubCourseToEdit] = useState<SubCourse | null>(null)
|
||||||
null,
|
const [title, setTitle] = useState("")
|
||||||
);
|
const [description, setDescription] = useState("")
|
||||||
const [title, setTitle] = useState("");
|
const [level, setLevel] = useState("")
|
||||||
const [description, setDescription] = useState("");
|
const [saving, setSaving] = useState(false)
|
||||||
const [level, setLevel] = useState("");
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// View mode
|
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "flow">("grid");
|
|
||||||
|
|
||||||
// All prerequisites map: subCourseId -> prerequisites[]
|
|
||||||
const [allPrereqMap, setAllPrereqMap] = useState<
|
|
||||||
Record<number, SubCoursePrerequisite[]>
|
|
||||||
>({});
|
|
||||||
const [allPrereqLoading, setAllPrereqLoading] = useState(false);
|
|
||||||
|
|
||||||
// Prerequisites state
|
|
||||||
const [showPrereqModal, setShowPrereqModal] = useState(false);
|
|
||||||
const [prereqSubCourse, setPrereqSubCourse] = useState<SubCourse | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [prerequisites, setPrerequisites] = useState<SubCoursePrerequisite[]>(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const [prereqLoading, setPrereqLoading] = useState(false);
|
|
||||||
const [prereqAdding, setPrereqAdding] = useState(false);
|
|
||||||
const [prereqRemoving, setPrereqRemoving] = useState<number | null>(null);
|
|
||||||
const [selectedPrereqId, setSelectedPrereqId] = useState<number | 0>(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||||
setOpenMenuId(null);
|
setOpenMenuId(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
if (openMenuId !== null) {
|
if (openMenuId !== null) {
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside)
|
||||||
}
|
}
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||||
}, [openMenuId]);
|
}, [openMenuId])
|
||||||
|
|
||||||
const fetchSubCourses = async () => {
|
const fetchSubCourses = async () => {
|
||||||
if (!courseId) return;
|
if (!courseId) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const subCoursesRes = await getSubCoursesByCourse(Number(courseId));
|
const subCoursesRes = await getSubCoursesByCourse(Number(courseId))
|
||||||
setSubCourses(subCoursesRes.data.data.sub_courses ?? []);
|
setSubCourses(subCoursesRes.data.data.sub_courses ?? [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch sub-courses:", err);
|
console.error("Failed to fetch sub-courses:", err)
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const fetchAllPrerequisites = async (scs: SubCourse[]) => {
|
|
||||||
if (scs.length === 0) return;
|
|
||||||
setAllPrereqLoading(true);
|
|
||||||
try {
|
|
||||||
const results = await Promise.all(
|
|
||||||
scs.map((sc) =>
|
|
||||||
getSubCoursePrerequisites(sc.id).then((res) => ({
|
|
||||||
id: sc.id,
|
|
||||||
data: res.data.data ?? [],
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const map: Record<number, SubCoursePrerequisite[]> = {};
|
|
||||||
for (const r of results) {
|
|
||||||
map[r.id] = r.data;
|
|
||||||
}
|
}
|
||||||
setAllPrereqMap(map);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to fetch all prerequisites:", err);
|
|
||||||
} finally {
|
|
||||||
setAllPrereqLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
if (!courseId || !categoryId) return;
|
if (!courseId || !categoryId) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [subCoursesRes, coursesRes, categoriesRes] = await Promise.all([
|
const [subCoursesRes, coursesRes, categoriesRes] = await Promise.all([
|
||||||
getSubCoursesByCourse(Number(courseId)),
|
getSubCoursesByCourse(Number(courseId)),
|
||||||
getCoursesByCategory(Number(categoryId)),
|
getCoursesByCategory(Number(categoryId)),
|
||||||
getCourseCategories(),
|
getCourseCategories(),
|
||||||
]);
|
])
|
||||||
|
|
||||||
setSubCourses(subCoursesRes.data.data.sub_courses ?? []);
|
setSubCourses(subCoursesRes.data.data.sub_courses ?? [])
|
||||||
|
|
||||||
const foundCourse = coursesRes.data.data.courses?.find(
|
const foundCourse = coursesRes.data.data.courses?.find(
|
||||||
(c) => c.id === Number(courseId),
|
(c) => c.id === Number(courseId)
|
||||||
);
|
)
|
||||||
setCourse(foundCourse ?? null);
|
setCourse(foundCourse ?? null)
|
||||||
|
|
||||||
const foundCategory = categoriesRes.data.data.categories?.find(
|
const foundCategory = categoriesRes.data.data.categories?.find(
|
||||||
(c) => c.id === Number(categoryId),
|
(c) => c.id === Number(categoryId)
|
||||||
);
|
)
|
||||||
setCategory(foundCategory ?? null);
|
setCategory(foundCategory ?? null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch sub-courses:", err);
|
console.error("Failed to fetch sub-courses:", err)
|
||||||
setError("Failed to load courses");
|
setError("Failed to load sub-courses")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false)
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, [courseId, categoryId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (subCourses.length > 0) {
|
|
||||||
fetchAllPrerequisites(subCourses);
|
|
||||||
}
|
}
|
||||||
}, [subCourses]);
|
|
||||||
|
fetchData()
|
||||||
|
}, [courseId, categoryId])
|
||||||
|
|
||||||
const handleToggleStatus = async (subCourse: SubCourse) => {
|
const handleToggleStatus = async (subCourse: SubCourse) => {
|
||||||
setTogglingId(subCourse.id);
|
setTogglingId(subCourse.id)
|
||||||
try {
|
try {
|
||||||
await updateSubCourseStatus(subCourse.id, {
|
await updateSubCourseStatus(subCourse.id, {
|
||||||
is_active: !subCourse.is_active,
|
is_active: !subCourse.is_active,
|
||||||
level: subCourse.level,
|
level: subCourse.level,
|
||||||
title: subCourse.title,
|
title: subCourse.title,
|
||||||
});
|
})
|
||||||
await fetchSubCourses();
|
await fetchSubCourses()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to update sub-course status:", err);
|
console.error("Failed to update sub-course status:", err)
|
||||||
} finally {
|
} finally {
|
||||||
setTogglingId(null);
|
setTogglingId(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteClick = (subCourse: SubCourse) => {
|
const handleDeleteClick = (subCourse: SubCourse) => {
|
||||||
setSubCourseToDelete(subCourse);
|
setSubCourseToDelete(subCourse)
|
||||||
setShowDeleteModal(true);
|
setShowDeleteModal(true)
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleConfirmDelete = async () => {
|
const handleConfirmDelete = async () => {
|
||||||
if (!subCourseToDelete) return;
|
if (!subCourseToDelete) return
|
||||||
|
|
||||||
setDeleting(true);
|
setDeleting(true)
|
||||||
try {
|
try {
|
||||||
await deleteSubCourse(subCourseToDelete.id);
|
await deleteSubCourse(subCourseToDelete.id)
|
||||||
setShowDeleteModal(false);
|
setShowDeleteModal(false)
|
||||||
setSubCourseToDelete(null);
|
setSubCourseToDelete(null)
|
||||||
await fetchSubCourses();
|
await fetchSubCourses()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to delete sub-course:", err);
|
console.error("Failed to delete sub-course:", err)
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddSubCourse = () => {
|
const handleAddSubCourse = () => {
|
||||||
setTitle("");
|
setTitle("")
|
||||||
setDescription("");
|
setDescription("")
|
||||||
setLevel("");
|
setLevel("")
|
||||||
setSaveError(null);
|
setSaveError(null)
|
||||||
setShowAddModal(true);
|
setShowAddModal(true)
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleSaveNewSubCourse = async () => {
|
const handleSaveNewSubCourse = async () => {
|
||||||
if (!courseId) return;
|
if (!courseId) return
|
||||||
setSaving(true);
|
setSaving(true)
|
||||||
setSaveError(null);
|
setSaveError(null)
|
||||||
try {
|
try {
|
||||||
await createSubCourse({
|
await createSubCourse({
|
||||||
course_id: Number(courseId),
|
course_id: Number(courseId),
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
level,
|
level,
|
||||||
});
|
})
|
||||||
setShowAddModal(false);
|
setShowAddModal(false)
|
||||||
setTitle("");
|
setTitle("")
|
||||||
setDescription("");
|
setDescription("")
|
||||||
setLevel("");
|
setLevel("")
|
||||||
await fetchSubCourses();
|
await fetchSubCourses()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to create sub-course:", err);
|
console.error("Failed to create sub-course:", err)
|
||||||
setSaveError("Failed to create course");
|
setSaveError("Failed to create sub-course")
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditClick = (subCourse: SubCourse) => {
|
const handleEditClick = (subCourse: SubCourse) => {
|
||||||
setSubCourseToEdit(subCourse);
|
setSubCourseToEdit(subCourse)
|
||||||
setTitle(subCourse.title);
|
setTitle(subCourse.title)
|
||||||
setDescription(subCourse.description);
|
setDescription(subCourse.description)
|
||||||
setLevel(subCourse.level);
|
setLevel(subCourse.level)
|
||||||
setSaveError(null);
|
setSaveError(null)
|
||||||
setShowEditModal(true);
|
setShowEditModal(true)
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleSaveEditSubCourse = async () => {
|
const handleSaveEditSubCourse = async () => {
|
||||||
if (!subCourseToEdit) return;
|
if (!subCourseToEdit) return
|
||||||
setSaving(true);
|
setSaving(true)
|
||||||
setSaveError(null);
|
setSaveError(null)
|
||||||
try {
|
try {
|
||||||
await updateSubCourse(subCourseToEdit.id, {
|
await updateSubCourse(subCourseToEdit.id, {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
level,
|
level,
|
||||||
});
|
})
|
||||||
setShowEditModal(false);
|
setShowEditModal(false)
|
||||||
setSubCourseToEdit(null);
|
setSubCourseToEdit(null)
|
||||||
setTitle("");
|
setTitle("")
|
||||||
setDescription("");
|
setDescription("")
|
||||||
setLevel("");
|
setLevel("")
|
||||||
await fetchSubCourses();
|
await fetchSubCourses()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to update sub-course:", err);
|
console.error("Failed to update sub-course:", err)
|
||||||
setSaveError("Failed to update course");
|
setSaveError("Failed to update sub-course")
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubCourseClick = (subCourseId: number) => {
|
const handleSubCourseClick = (subCourseId: number) => {
|
||||||
navigate(
|
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`)
|
||||||
`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrereqClick = async (subCourse: SubCourse) => {
|
|
||||||
setPrereqSubCourse(subCourse);
|
|
||||||
setShowPrereqModal(true);
|
|
||||||
setPrereqLoading(true);
|
|
||||||
setSelectedPrereqId(0);
|
|
||||||
try {
|
|
||||||
const res = await getSubCoursePrerequisites(subCourse.id);
|
|
||||||
setPrerequisites(res.data.data ?? []);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to fetch prerequisites:", err);
|
|
||||||
setPrerequisites([]);
|
|
||||||
} finally {
|
|
||||||
setPrereqLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddPrerequisite = async () => {
|
|
||||||
if (!prereqSubCourse || !selectedPrereqId) return;
|
|
||||||
setPrereqAdding(true);
|
|
||||||
try {
|
|
||||||
await addSubCoursePrerequisite(prereqSubCourse.id, {
|
|
||||||
prerequisite_sub_course_id: selectedPrereqId,
|
|
||||||
});
|
|
||||||
const res = await getSubCoursePrerequisites(prereqSubCourse.id);
|
|
||||||
setPrerequisites(res.data.data ?? []);
|
|
||||||
setSelectedPrereqId(0);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to add prerequisite:", err);
|
|
||||||
} finally {
|
|
||||||
setPrereqAdding(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemovePrerequisite = async (prereqId: number) => {
|
|
||||||
if (!prereqSubCourse) return;
|
|
||||||
setPrereqRemoving(prereqId);
|
|
||||||
try {
|
|
||||||
await removeSubCoursePrerequisite(prereqSubCourse.id, prereqId);
|
|
||||||
const res = await getSubCoursePrerequisites(prereqSubCourse.id);
|
|
||||||
setPrerequisites(res.data.data ?? []);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to remove prerequisite:", err);
|
|
||||||
} finally {
|
|
||||||
setPrereqRemoving(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build flow layers using topological sort
|
|
||||||
const flowLayers = (() => {
|
|
||||||
if (subCourses.length === 0) return [];
|
|
||||||
|
|
||||||
// Find sub-courses with no prerequisites (roots)
|
|
||||||
const hasPrereqs = new Set<number>();
|
|
||||||
const isPrereqOf = new Map<number, number[]>(); // prereqId -> [subCourseIds that depend on it]
|
|
||||||
|
|
||||||
for (const sc of subCourses) {
|
|
||||||
const prereqs = allPrereqMap[sc.id] ?? [];
|
|
||||||
if (prereqs.length > 0) {
|
|
||||||
hasPrereqs.add(sc.id);
|
|
||||||
}
|
|
||||||
for (const p of prereqs) {
|
|
||||||
const dependents = isPrereqOf.get(p.prerequisite_sub_course_id) ?? [];
|
|
||||||
dependents.push(sc.id);
|
|
||||||
isPrereqOf.set(p.prerequisite_sub_course_id, dependents);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// BFS-based layering
|
|
||||||
const layers: SubCourse[][] = [];
|
|
||||||
const placed = new Set<number>();
|
|
||||||
|
|
||||||
// Layer 0: no prerequisites
|
|
||||||
const roots = subCourses.filter((sc) => !hasPrereqs.has(sc.id));
|
|
||||||
if (roots.length > 0) {
|
|
||||||
layers.push(roots);
|
|
||||||
roots.forEach((sc) => placed.add(sc.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subsequent layers: all prereqs already placed
|
|
||||||
let maxIterations = subCourses.length;
|
|
||||||
while (placed.size < subCourses.length && maxIterations-- > 0) {
|
|
||||||
const nextLayer = subCourses.filter((sc) => {
|
|
||||||
if (placed.has(sc.id)) return false;
|
|
||||||
const prereqs = allPrereqMap[sc.id] ?? [];
|
|
||||||
return prereqs.every((p) => placed.has(p.prerequisite_sub_course_id));
|
|
||||||
});
|
|
||||||
if (nextLayer.length === 0) {
|
|
||||||
// Remaining have circular deps or missing prereqs — just add them
|
|
||||||
const remaining = subCourses.filter((sc) => !placed.has(sc.id));
|
|
||||||
if (remaining.length > 0) layers.push(remaining);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
layers.push(nextLayer);
|
|
||||||
nextLayer.forEach((sc) => placed.add(sc.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
return layers;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const availablePrerequisites = subCourses.filter(
|
|
||||||
(sc) =>
|
|
||||||
prereqSubCourse &&
|
|
||||||
sc.id !== prereqSubCourse.id &&
|
|
||||||
!prerequisites.some((p) => p.prerequisite_sub_course_id === sc.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
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" />
|
<div className="rounded-full bg-white shadow-sm p-4">
|
||||||
{/* <div className="rounded-full bg-white shadow-sm p-4">
|
|
||||||
<RefreshCw className="h-8 w-8 animate-spin text-brand-600" />
|
<RefreshCw className="h-8 w-8 animate-spin text-brand-600" />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading sub-courses...</p> */}
|
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading sub-courses...</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-24">
|
<div className="flex items-center justify-center py-24">
|
||||||
<div className="mx-4 flex w-full max-w-md items-center gap-3 rounded-xl border border-red-100 bg-red-50 px-5 py-4 shadow-md">
|
<div className="mx-4 flex w-full max-w-md items-center gap-3 rounded-xl border border-red-100 bg-red-50 px-5 py-4 shadow-md">
|
||||||
<img src={alertSrc} alt="" className="h-10 w-10 shrink-0" />
|
<div className="rounded-full bg-red-100 p-2">
|
||||||
|
<AlertCircle className="h-5 w-5 shrink-0 text-red-500" />
|
||||||
|
</div>
|
||||||
<p className="text-sm font-medium text-red-600">{error}</p>
|
<p className="text-sm font-medium text-red-600">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -435,75 +233,30 @@ export function SubCoursesPage() {
|
||||||
</Link>
|
</Link>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-grayScale-400">
|
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-grayScale-400">
|
||||||
<span className="truncate max-w-[100px] rounded bg-grayScale-50 px-1.5 py-0.5 sm:max-w-none">
|
<span className="truncate max-w-[100px] rounded bg-grayScale-50 px-1.5 py-0.5 sm:max-w-none">{category?.name}</span>
|
||||||
{category?.name}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-grayScale-300">→</span>
|
<span className="shrink-0 text-grayScale-300">→</span>
|
||||||
<span className="truncate max-w-[100px] rounded bg-grayScale-50 px-1.5 py-0.5 sm:max-w-none">
|
<span className="truncate max-w-[100px] rounded bg-grayScale-50 px-1.5 py-0.5 sm:max-w-none">{course?.title}</span>
|
||||||
{course?.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">Sub-courses</h1>
|
||||||
Courses
|
<p className="mt-0.5 text-sm text-grayScale-400">{subCourses.length} sub-course{subCourses.length !== 1 ? "s" : ""} available</p>
|
||||||
</h1>
|
|
||||||
<p className="mt-0.5 text-sm text-grayScale-400">
|
|
||||||
{subCourses.length} course{subCourses.length !== 1 ? "s" : ""}{" "}
|
|
||||||
available
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<Button className="w-full rounded-xl bg-brand-500 px-5 shadow-sm transition-all hover:bg-brand-600 hover:shadow-md sm:w-auto" onClick={handleAddSubCourse}>
|
||||||
{subCourses.length > 0 && (
|
Add New Sub-course
|
||||||
<div className="flex rounded-xl border border-grayScale-200 bg-white p-0.5 shadow-sm">
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode("grid")}
|
|
||||||
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
|
||||||
viewMode === "grid"
|
|
||||||
? "bg-brand-500 text-white shadow-sm"
|
|
||||||
: "text-grayScale-500 hover:text-grayScale-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<LayoutGrid className="h-3.5 w-3.5" />
|
|
||||||
Grid
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode("flow")}
|
|
||||||
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
|
||||||
viewMode === "flow"
|
|
||||||
? "bg-brand-500 text-white shadow-sm"
|
|
||||||
: "text-grayScale-500 hover:text-grayScale-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<GitBranch className="h-3.5 w-3.5" />
|
|
||||||
Flow
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
className="w-full rounded-xl bg-brand-500 px-5 shadow-sm transition-all hover:bg-brand-600 hover:shadow-md sm:w-auto"
|
|
||||||
onClick={handleAddSubCourse}
|
|
||||||
>
|
|
||||||
Add New Course
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sub-course grid or empty state */}
|
{/* Sub-course grid or empty state */}
|
||||||
{subCourses.length === 0 ? (
|
{subCourses.length === 0 ? (
|
||||||
<Card className="border border-dashed border-grayScale-200 shadow-none">
|
<Card className="border border-dashed border-grayScale-200 shadow-none">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||||
<img src={practiceSrc} alt="" className="h-20 w-20" />
|
<div className="rounded-2xl bg-brand-50 p-5">
|
||||||
<h3 className="mt-5 text-base font-semibold text-grayScale-600">
|
<Layers className="h-10 w-10 text-brand-400" />
|
||||||
No courses yet
|
</div>
|
||||||
</h3>
|
<h3 className="mt-5 text-base font-semibold text-grayScale-600">No sub-courses yet</h3>
|
||||||
<p className="mt-1.5 max-w-xs text-center text-sm text-grayScale-400">
|
<p className="mt-1.5 max-w-xs text-center text-sm text-grayScale-400">Get started by adding your first sub-course to this course</p>
|
||||||
Get started by adding your first course to this sub-category
|
<Button className="mt-5 rounded-xl bg-brand-500 px-5 shadow-sm hover:bg-brand-600 hover:shadow-md" onClick={handleAddSubCourse}>
|
||||||
</p>
|
Add your first sub-course
|
||||||
<Button
|
|
||||||
className="mt-5 rounded-xl bg-brand-500 px-5 shadow-sm hover:bg-brand-600 hover:shadow-md"
|
|
||||||
onClick={handleAddSubCourse}
|
|
||||||
>
|
|
||||||
Add your first course
|
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -515,7 +268,7 @@ export function SubCoursesPage() {
|
||||||
"bg-gradient-to-br from-purple-100 to-purple-200",
|
"bg-gradient-to-br from-purple-100 to-purple-200",
|
||||||
"bg-gradient-to-br from-green-100 to-green-200",
|
"bg-gradient-to-br from-green-100 to-green-200",
|
||||||
"bg-gradient-to-br from-yellow-100 to-yellow-200",
|
"bg-gradient-to-br from-yellow-100 to-yellow-200",
|
||||||
];
|
]
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={subCourse.id}
|
key={subCourse.id}
|
||||||
|
|
@ -531,9 +284,7 @@ export function SubCoursesPage() {
|
||||||
className="h-full w-full object-cover rounded-t-lg transition-transform duration-300 group-hover:scale-105"
|
className="h-full w-full object-cover rounded-t-lg transition-transform duration-300 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className={`h-full w-full rounded-t-lg transition-transform duration-300 group-hover:scale-105 ${gradients[index % gradients.length]}`} />
|
||||||
className={`h-full w-full rounded-t-lg transition-transform duration-300 group-hover:scale-105 ${gradients[index % gradients.length]}`}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{subCourse.level && (
|
{subCourse.level && (
|
||||||
<div className="absolute bottom-2.5 right-2.5 rounded-md bg-brand-600/90 px-2.5 py-1 text-xs font-semibold tracking-wide text-white shadow-sm backdrop-blur-sm">
|
<div className="absolute bottom-2.5 right-2.5 rounded-md bg-brand-600/90 px-2.5 py-1 text-xs font-semibold tracking-wide text-white shadow-sm backdrop-blur-sm">
|
||||||
|
|
@ -553,9 +304,7 @@ export function SubCoursesPage() {
|
||||||
: "border border-grayScale-200 bg-grayScale-50 text-grayScale-500"
|
: "border border-grayScale-200 bg-grayScale-50 text-grayScale-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${subCourse.is_active ? "bg-green-500" : "bg-grayScale-400"}`} />
|
||||||
className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${subCourse.is_active ? "bg-green-500" : "bg-grayScale-400"}`}
|
|
||||||
/>
|
|
||||||
{subCourse.is_active ? "Active" : "Inactive"}
|
{subCourse.is_active ? "Active" : "Inactive"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div
|
<div
|
||||||
|
|
@ -564,11 +313,7 @@ export function SubCoursesPage() {
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() => setOpenMenuId(openMenuId === subCourse.id ? null : subCourse.id)}
|
||||||
setOpenMenuId(
|
|
||||||
openMenuId === subCourse.id ? null : subCourse.id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="grid h-7 w-7 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-7 w-7 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
<MoreVertical className="h-4 w-4" />
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
|
@ -577,19 +322,8 @@ export function SubCoursesPage() {
|
||||||
<div className="absolute right-0 top-full z-10 mt-1.5 w-44 overflow-hidden rounded-xl border border-grayScale-100 bg-white py-1 shadow-lg">
|
<div className="absolute right-0 top-full z-10 mt-1.5 w-44 overflow-hidden rounded-xl border border-grayScale-100 bg-white py-1 shadow-lg">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handlePrereqClick(subCourse);
|
handleToggleStatus(subCourse)
|
||||||
setOpenMenuId(null);
|
setOpenMenuId(null)
|
||||||
}}
|
|
||||||
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-grayScale-600 transition-colors hover:bg-grayScale-50"
|
|
||||||
>
|
|
||||||
<Link2 className="h-4 w-4" />
|
|
||||||
Prerequisites
|
|
||||||
</button>
|
|
||||||
<div className="mx-3 border-t border-grayScale-100" />
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
handleToggleStatus(subCourse);
|
|
||||||
setOpenMenuId(null);
|
|
||||||
}}
|
}}
|
||||||
disabled={togglingId === subCourse.id}
|
disabled={togglingId === subCourse.id}
|
||||||
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-grayScale-600 transition-colors hover:bg-grayScale-50 disabled:opacity-50"
|
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-grayScale-600 transition-colors hover:bg-grayScale-50 disabled:opacity-50"
|
||||||
|
|
@ -609,8 +343,8 @@ export function SubCoursesPage() {
|
||||||
<div className="mx-3 border-t border-grayScale-100" />
|
<div className="mx-3 border-t border-grayScale-100" />
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleDeleteClick(subCourse);
|
handleDeleteClick(subCourse)
|
||||||
setOpenMenuId(null);
|
setOpenMenuId(null)
|
||||||
}}
|
}}
|
||||||
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-red-500 transition-colors hover:bg-red-50"
|
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-red-500 transition-colors hover:bg-red-50"
|
||||||
>
|
>
|
||||||
|
|
@ -624,9 +358,7 @@ export function SubCoursesPage() {
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-grayScale-700 group-hover:text-brand-600 transition-colors">
|
<h3 className="font-semibold text-grayScale-700 group-hover:text-brand-600 transition-colors">{subCourse.title}</h3>
|
||||||
{subCourse.title}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-sm leading-relaxed text-grayScale-400 line-clamp-2">
|
<p className="mt-1 text-sm leading-relaxed text-grayScale-400 line-clamp-2">
|
||||||
{subCourse.description || "No description available"}
|
{subCourse.description || "No description available"}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -637,8 +369,8 @@ export function SubCoursesPage() {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="mt-auto w-full rounded-lg border-grayScale-200 text-grayScale-500 transition-all hover:border-brand-200 hover:bg-brand-50 hover:text-brand-600"
|
className="mt-auto w-full rounded-lg border-grayScale-200 text-grayScale-500 transition-all hover:border-brand-200 hover:bg-brand-50 hover:text-brand-600"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation()
|
||||||
handleEditClick(subCourse);
|
handleEditClick(subCourse)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Edit className="mr-2 h-3.5 w-3.5" />
|
<Edit className="mr-2 h-3.5 w-3.5" />
|
||||||
|
|
@ -646,7 +378,7 @@ export function SubCoursesPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -656,9 +388,7 @@ export function SubCoursesPage() {
|
||||||
<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-sm rounded-2xl bg-white shadow-2xl">
|
<div className="mx-4 w-full max-w-sm rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-700">
|
<h2 className="text-lg font-semibold text-grayScale-700">Delete Sub-course</h2>
|
||||||
Delete Course
|
|
||||||
</h2>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeleteModal(false)}
|
onClick={() => setShowDeleteModal(false)}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
|
@ -673,10 +403,8 @@ export function SubCoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-sm leading-relaxed text-grayScale-600">
|
<p className="text-center text-sm leading-relaxed text-grayScale-600">
|
||||||
Are you sure you want to delete{" "}
|
Are you sure you want to delete{" "}
|
||||||
<span className="font-semibold text-grayScale-700">
|
<span className="font-semibold text-grayScale-700">{subCourseToDelete.title}</span>? This action cannot
|
||||||
{subCourseToDelete.title}
|
be undone.
|
||||||
</span>
|
|
||||||
? This action cannot be undone.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -706,9 +434,7 @@ export function SubCoursesPage() {
|
||||||
<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-md rounded-2xl bg-white shadow-2xl">
|
<div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-700">
|
<h2 className="text-lg font-semibold text-grayScale-700">Add New Sub-course</h2>
|
||||||
Add New Course
|
|
||||||
</h2>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddModal(false)}
|
onClick={() => setShowAddModal(false)}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
|
@ -719,31 +445,25 @@ export function SubCoursesPage() {
|
||||||
|
|
||||||
<div className="space-y-5 px-6 py-6">
|
<div className="space-y-5 px-6 py-6">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-semibold text-grayScale-600">
|
<label className="text-sm font-semibold text-grayScale-600">Title</label>
|
||||||
Title
|
|
||||||
</label>
|
|
||||||
<Input
|
<Input
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Enter course title"
|
placeholder="Enter sub-course title"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-semibold text-grayScale-600">
|
<label className="text-sm font-semibold text-grayScale-600">Description</label>
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="Enter course description"
|
placeholder="Enter sub-course description"
|
||||||
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-semibold text-grayScale-600">
|
<label className="text-sm font-semibold text-grayScale-600">Level</label>
|
||||||
Level
|
|
||||||
</label>
|
|
||||||
<Input
|
<Input
|
||||||
value={level}
|
value={level}
|
||||||
onChange={(e) => setLevel(e.target.value)}
|
onChange={(e) => setLevel(e.target.value)}
|
||||||
|
|
@ -779,149 +499,12 @@ export function SubCoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Prerequisites Modal */}
|
|
||||||
{showPrereqModal && prereqSubCourse && (
|
|
||||||
<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-lg rounded-2xl bg-white shadow-2xl">
|
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-grayScale-700">
|
|
||||||
Prerequisites
|
|
||||||
</h2>
|
|
||||||
<p className="mt-0.5 text-sm text-grayScale-400">
|
|
||||||
Manage prerequisites for{" "}
|
|
||||||
<span className="font-medium text-grayScale-600">
|
|
||||||
{prereqSubCourse.title}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowPrereqModal(false)}
|
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-6 py-5">
|
|
||||||
{/* Add prerequisite */}
|
|
||||||
{availablePrerequisites.length > 0 && (
|
|
||||||
<div className="mb-5">
|
|
||||||
<label className="mb-1.5 block text-sm font-semibold text-grayScale-600">
|
|
||||||
Add Prerequisite
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<select
|
|
||||||
value={selectedPrereqId}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelectedPrereqId(Number(e.target.value))
|
|
||||||
}
|
|
||||||
className="flex-1 rounded-lg border border-grayScale-200 bg-white px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
|
|
||||||
>
|
|
||||||
<option value={0}>Select a course...</option>
|
|
||||||
{availablePrerequisites.map((sc) => (
|
|
||||||
<option key={sc.id} value={sc.id}>
|
|
||||||
{sc.title} {sc.level ? `(${sc.level})` : ""}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<Button
|
|
||||||
className="shrink-0 rounded-lg bg-brand-500 px-4 shadow-sm hover:bg-brand-600"
|
|
||||||
onClick={handleAddPrerequisite}
|
|
||||||
disabled={prereqAdding || !selectedPrereqId}
|
|
||||||
>
|
|
||||||
{prereqAdding ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Current prerequisites list */}
|
|
||||||
{prereqLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<img
|
|
||||||
src={spinnerSrc}
|
|
||||||
alt=""
|
|
||||||
className="h-8 w-8 animate-spin"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : prerequisites.length === 0 ? (
|
|
||||||
<div className="rounded-xl border border-dashed border-grayScale-200 px-4 py-8 text-center">
|
|
||||||
<Link2 className="mx-auto h-8 w-8 text-grayScale-300" />
|
|
||||||
<p className="mt-2 text-sm font-medium text-grayScale-500">
|
|
||||||
No prerequisites
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 text-xs text-grayScale-400">
|
|
||||||
This course is accessible without completing others first
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-semibold text-grayScale-600">
|
|
||||||
Current Prerequisites ({prerequisites.length})
|
|
||||||
</p>
|
|
||||||
{prerequisites.map((prereq) => (
|
|
||||||
<div
|
|
||||||
key={prereq.id}
|
|
||||||
className="flex items-center justify-between rounded-xl border border-grayScale-100 bg-grayScale-25 px-4 py-3 transition-colors hover:border-grayScale-200"
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-sm font-medium text-grayScale-700 truncate">
|
|
||||||
{prereq.prerequisite_title}
|
|
||||||
</p>
|
|
||||||
<div className="mt-0.5 flex items-center gap-2">
|
|
||||||
{prereq.prerequisite_level && (
|
|
||||||
<span className="rounded bg-brand-50 px-1.5 py-0.5 text-[11px] font-medium text-brand-600">
|
|
||||||
{prereq.prerequisite_level}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-[11px] text-grayScale-400">
|
|
||||||
Order: {prereq.prerequisite_display_order}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRemovePrerequisite(prereq.id)}
|
|
||||||
disabled={prereqRemoving === prereq.id}
|
|
||||||
className="ml-3 grid h-8 w-8 shrink-0 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-red-50 hover:text-red-500 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{prereqRemoving === prereq.id ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end border-t border-grayScale-100 px-6 py-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowPrereqModal(false)}
|
|
||||||
className="rounded-lg"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Edit Sub-course Modal */}
|
{/* Edit Sub-course Modal */}
|
||||||
{showEditModal && subCourseToEdit && (
|
{showEditModal && subCourseToEdit && (
|
||||||
<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-md rounded-2xl bg-white shadow-2xl">
|
<div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-700">
|
<h2 className="text-lg font-semibold text-grayScale-700">Edit Sub-course</h2>
|
||||||
Edit Course
|
|
||||||
</h2>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowEditModal(false)}
|
onClick={() => setShowEditModal(false)}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
|
@ -932,31 +515,25 @@ export function SubCoursesPage() {
|
||||||
|
|
||||||
<div className="space-y-5 px-6 py-6">
|
<div className="space-y-5 px-6 py-6">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-semibold text-grayScale-600">
|
<label className="text-sm font-semibold text-grayScale-600">Title</label>
|
||||||
Title
|
|
||||||
</label>
|
|
||||||
<Input
|
<Input
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Enter course title"
|
placeholder="Enter sub-course title"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-semibold text-grayScale-600">
|
<label className="text-sm font-semibold text-grayScale-600">Description</label>
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="Enter course description"
|
placeholder="Enter sub-course description"
|
||||||
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-semibold text-grayScale-600">
|
<label className="text-sm font-semibold text-grayScale-600">Level</label>
|
||||||
Level
|
|
||||||
</label>
|
|
||||||
<Input
|
<Input
|
||||||
value={level}
|
value={level}
|
||||||
onChange={(e) => setLevel(e.target.value)}
|
onChange={(e) => setLevel(e.target.value)}
|
||||||
|
|
@ -992,5 +569,5 @@ export function SubCoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,6 @@ import {
|
||||||
getIssueById,
|
getIssueById,
|
||||||
updateIssueStatus,
|
updateIssueStatus,
|
||||||
deleteIssue,
|
deleteIssue,
|
||||||
createIssue,
|
|
||||||
} from "../../api/issues.api";
|
} from "../../api/issues.api";
|
||||||
import type { Issue, IssueFilters } from "../../types/issue.types";
|
import type { Issue, IssueFilters } from "../../types/issue.types";
|
||||||
|
|
||||||
|
|
@ -208,9 +207,6 @@ export function IssuesPage() {
|
||||||
const [createSubject, setCreateSubject] = useState("");
|
const [createSubject, setCreateSubject] = useState("");
|
||||||
const [createType, setCreateType] = useState<string>("bug");
|
const [createType, setCreateType] = useState<string>("bug");
|
||||||
const [createDescription, setCreateDescription] = useState("");
|
const [createDescription, setCreateDescription] = useState("");
|
||||||
const [createDevice, setCreateDevice] = useState("");
|
|
||||||
const [createBrowser, setCreateBrowser] = useState("");
|
|
||||||
const [createSubmitting, setCreateSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const fetchIssues = useCallback(async () => {
|
const fetchIssues = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -526,6 +522,7 @@ export function IssuesPage() {
|
||||||
const typeConfig = getIssueTypeConfig(issue.issue_type);
|
const typeConfig = getIssueTypeConfig(issue.issue_type);
|
||||||
const statusConfig = getStatusConfig(issue.status);
|
const statusConfig = getStatusConfig(issue.status);
|
||||||
const TypeIcon = typeConfig.icon;
|
const TypeIcon = typeConfig.icon;
|
||||||
|
const StatusIcon = statusConfig.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={issue.id} className="group">
|
<TableRow key={issue.id} className="group">
|
||||||
|
|
@ -910,29 +907,6 @@ export function IssuesPage() {
|
||||||
onChange={(e) => setCreateDescription(e.target.value)}
|
onChange={(e) => setCreateDescription(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
|
||||||
Device (optional)
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
placeholder="e.g. iPhone 14"
|
|
||||||
value={createDevice}
|
|
||||||
onChange={(e) => setCreateDevice(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
|
||||||
Browser (optional)
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
placeholder="e.g. Safari 17"
|
|
||||||
value={createBrowser}
|
|
||||||
onChange={(e) => setCreateBrowser(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 flex items-center justify-end gap-2">
|
<div className="mt-5 flex items-center justify-end gap-2">
|
||||||
|
|
@ -943,49 +917,24 @@ export function IssuesPage() {
|
||||||
setCreateSubject("");
|
setCreateSubject("");
|
||||||
setCreateDescription("");
|
setCreateDescription("");
|
||||||
setCreateType("bug");
|
setCreateType("bug");
|
||||||
setCreateDevice("");
|
|
||||||
setCreateBrowser("");
|
|
||||||
}}
|
}}
|
||||||
disabled={createSubmitting}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="bg-brand-500 text-white hover:bg-brand-600"
|
className="bg-brand-500 text-white hover:bg-brand-600"
|
||||||
disabled={createSubmitting || !createSubject.trim() || !createDescription.trim()}
|
onClick={() => {
|
||||||
onClick={async () => {
|
// Hook to create-issue API here; currently UI-only.
|
||||||
if (!createSubject.trim() || !createDescription.trim()) return;
|
if (!createSubject.trim() || !createDescription.trim()) {
|
||||||
setCreateSubmitting(true);
|
return;
|
||||||
try {
|
|
||||||
const payload: any = {
|
|
||||||
subject: createSubject.trim(),
|
|
||||||
description: createDescription.trim(),
|
|
||||||
issue_type: createType,
|
|
||||||
};
|
|
||||||
const metadata: Record<string, string> = {};
|
|
||||||
if (createDevice.trim()) metadata.device = createDevice.trim();
|
|
||||||
if (createBrowser.trim()) metadata.browser = createBrowser.trim();
|
|
||||||
if (Object.keys(metadata).length > 0) {
|
|
||||||
payload.metadata = metadata;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await createIssue(payload);
|
|
||||||
|
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
setCreateSubject("");
|
setCreateSubject("");
|
||||||
setCreateDescription("");
|
setCreateDescription("");
|
||||||
setCreateType("bug");
|
setCreateType("bug");
|
||||||
setCreateDevice("");
|
|
||||||
setCreateBrowser("");
|
|
||||||
fetchIssues();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to create issue:", error);
|
|
||||||
} finally {
|
|
||||||
setCreateSubmitting(false);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{createSubmitting ? "Creating..." : "Create Issue"}
|
Create Issue
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useState, useCallback, useMemo } from "react"
|
import { useEffect, useState, useCallback } from "react"
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
BellOff,
|
BellOff,
|
||||||
|
|
@ -20,9 +20,6 @@ import {
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
MailX,
|
MailX,
|
||||||
Search,
|
Search,
|
||||||
ChevronDown,
|
|
||||||
Calendar,
|
|
||||||
Clock3,
|
|
||||||
} 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"
|
||||||
|
|
@ -38,17 +35,6 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "../../components/ui/dialog"
|
} from "../../components/ui/dialog"
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "../../components/ui/dropdown-menu"
|
|
||||||
import { FileUpload } from "../../components/ui/file-upload"
|
import { FileUpload } from "../../components/ui/file-upload"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import {
|
import {
|
||||||
|
|
@ -58,18 +44,10 @@ import {
|
||||||
markAsUnread,
|
markAsUnread,
|
||||||
markAllRead,
|
markAllRead,
|
||||||
markAllUnread,
|
markAllUnread,
|
||||||
sendBulkSms,
|
|
||||||
sendBulkEmail,
|
|
||||||
sendBulkPush,
|
|
||||||
} from "../../api/notifications.api"
|
} from "../../api/notifications.api"
|
||||||
import { getRoles } from "../../api/rbac.api"
|
|
||||||
import { getTeamMembers } from "../../api/team.api"
|
import { getTeamMembers } from "../../api/team.api"
|
||||||
import { getUsers } from "../../api/users.api"
|
import type { Notification } from "../../types/notification.types"
|
||||||
import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types"
|
|
||||||
import type { Role } from "../../types/rbac.types"
|
|
||||||
import type { TeamMember } from "../../types/team.types"
|
import type { TeamMember } from "../../types/team.types"
|
||||||
import type { UserApiDTO } from "../../types/user.types"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
|
|
@ -135,10 +113,6 @@ function formatTypeLabel(type: string) {
|
||||||
.join(" ")
|
.join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
function digitsOnly(value: string, maxLength: number) {
|
|
||||||
return value.replace(/\D/g, "").slice(0, maxLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotificationItem({
|
function NotificationItem({
|
||||||
notification,
|
notification,
|
||||||
onToggleRead,
|
onToggleRead,
|
||||||
|
|
@ -187,7 +161,7 @@ function NotificationItem({
|
||||||
notification.is_read ? "text-grayScale-600" : "text-grayScale-800",
|
notification.is_read ? "text-grayScale-600" : "text-grayScale-800",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{getNotificationTitle(notification)}
|
{notification.payload.headline}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant={getLevelBadge(notification.level)} className="text-[10px] px-1.5 py-0">
|
<Badge variant={getLevelBadge(notification.level)} className="text-[10px] px-1.5 py-0">
|
||||||
{notification.level}
|
{notification.level}
|
||||||
|
|
@ -199,7 +173,7 @@ function NotificationItem({
|
||||||
notification.is_read ? "text-grayScale-400" : "text-grayScale-600",
|
notification.is_read ? "text-grayScale-400" : "text-grayScale-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{getNotificationMessage(notification)}
|
{notification.payload.message}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -287,152 +261,6 @@ export function NotificationsPage() {
|
||||||
const [composeOpen, setComposeOpen] = useState(false)
|
const [composeOpen, setComposeOpen] = useState(false)
|
||||||
const [composeImage, setComposeImage] = useState<File | null>(null)
|
const [composeImage, setComposeImage] = useState<File | null>(null)
|
||||||
|
|
||||||
const [bulkOpen, setBulkOpen] = useState(false)
|
|
||||||
const [bulkChannel, setBulkChannel] = useState<"sms" | "email" | "push">("sms")
|
|
||||||
const [bulkTitle, setBulkTitle] = useState("")
|
|
||||||
const [bulkMessage, setBulkMessage] = useState("")
|
|
||||||
const [bulkRole, setBulkRole] = useState("")
|
|
||||||
const [bulkUserIds, setBulkUserIds] = useState<number[]>([])
|
|
||||||
const [bulkScheduledAt, setBulkScheduledAt] = useState("")
|
|
||||||
const [bulkFile, setBulkFile] = useState<File | null>(null)
|
|
||||||
const [bulkSending, setBulkSending] = useState(false)
|
|
||||||
const [bulkRoles, setBulkRoles] = useState<Role[]>([])
|
|
||||||
const [bulkUsers, setBulkUsers] = useState<UserApiDTO[]>([])
|
|
||||||
const [bulkRolesLoading, setBulkRolesLoading] = useState(false)
|
|
||||||
const [bulkUsersLoading, setBulkUsersLoading] = useState(false)
|
|
||||||
const [scheduleMenuOpen, setScheduleMenuOpen] = useState(false)
|
|
||||||
const [scheduleYear, setScheduleYear] = useState("")
|
|
||||||
const [scheduleMonth, setScheduleMonth] = useState("")
|
|
||||||
const [scheduleDay, setScheduleDay] = useState("")
|
|
||||||
const [scheduleHour, setScheduleHour] = useState("")
|
|
||||||
const [scheduleMinute, setScheduleMinute] = useState("")
|
|
||||||
|
|
||||||
const filteredBulkUsers = useMemo(() => {
|
|
||||||
if (!bulkRole.trim()) return bulkUsers
|
|
||||||
const selectedRole = bulkRole.trim().toLowerCase()
|
|
||||||
return bulkUsers.filter((user) => user.role?.toLowerCase() === selectedRole)
|
|
||||||
}, [bulkUsers, bulkRole])
|
|
||||||
|
|
||||||
const scheduledAtLabel = useMemo(() => {
|
|
||||||
if (!bulkScheduledAt) return "Set date & time"
|
|
||||||
const parsed = new Date(bulkScheduledAt)
|
|
||||||
if (Number.isNaN(parsed.getTime())) return bulkScheduledAt
|
|
||||||
return parsed.toLocaleString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})
|
|
||||||
}, [bulkScheduledAt])
|
|
||||||
|
|
||||||
const loadBulkOptions = useCallback(async () => {
|
|
||||||
if (!bulkOpen) return
|
|
||||||
|
|
||||||
const needsRoles = bulkRoles.length === 0
|
|
||||||
const needsUsers = bulkUsers.length === 0
|
|
||||||
if (!needsRoles && !needsUsers) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (needsRoles) setBulkRolesLoading(true)
|
|
||||||
if (needsUsers) setBulkUsersLoading(true)
|
|
||||||
|
|
||||||
const tasks: Promise<unknown>[] = []
|
|
||||||
if (needsRoles) {
|
|
||||||
tasks.push(
|
|
||||||
getRoles({ page: 1, page_size: 20 })
|
|
||||||
.then(async (res) => {
|
|
||||||
const firstBatch = res.data?.data?.roles ?? []
|
|
||||||
const total = res.data?.data?.total ?? firstBatch.length
|
|
||||||
const pageSize = 20
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
|
||||||
if (totalPages <= 1) {
|
|
||||||
setBulkRoles(firstBatch)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const remainingRequests: Array<ReturnType<typeof getRoles>> = []
|
|
||||||
for (let page = 2; page <= totalPages; page += 1) {
|
|
||||||
remainingRequests.push(getRoles({ page, page_size: pageSize }))
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const responses = await Promise.all(remainingRequests)
|
|
||||||
const rest = responses.flatMap((r) => r.data?.data?.roles ?? [])
|
|
||||||
setBulkRoles([...firstBatch, ...rest])
|
|
||||||
} catch {
|
|
||||||
setBulkRoles(firstBatch)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setBulkRoles([])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (needsUsers) {
|
|
||||||
tasks.push(
|
|
||||||
getUsers(1, 20)
|
|
||||||
.then(async (res) => {
|
|
||||||
const firstBatch = res.data?.data?.users ?? []
|
|
||||||
const total = res.data?.data?.total ?? firstBatch.length
|
|
||||||
const pageSize = 20
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
|
||||||
if (totalPages <= 1) {
|
|
||||||
setBulkUsers(firstBatch)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const remainingRequests: Array<ReturnType<typeof getUsers>> = []
|
|
||||||
for (let page = 2; page <= totalPages; page += 1) {
|
|
||||||
remainingRequests.push(getUsers(page, pageSize))
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const responses = await Promise.all(remainingRequests)
|
|
||||||
const rest = responses.flatMap((r) => r.data?.data?.users ?? [])
|
|
||||||
setBulkUsers([...firstBatch, ...rest])
|
|
||||||
} catch {
|
|
||||||
setBulkUsers(firstBatch)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setBulkUsers([])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(tasks)
|
|
||||||
} finally {
|
|
||||||
setBulkRolesLoading(false)
|
|
||||||
setBulkUsersLoading(false)
|
|
||||||
}
|
|
||||||
}, [bulkOpen, bulkRoles.length, bulkUsers.length])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadBulkOptions()
|
|
||||||
}, [loadBulkOptions])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!scheduleMenuOpen) return
|
|
||||||
if (!bulkScheduledAt) {
|
|
||||||
setScheduleYear("")
|
|
||||||
setScheduleMonth("")
|
|
||||||
setScheduleDay("")
|
|
||||||
setScheduleHour("")
|
|
||||||
setScheduleMinute("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const [datePart = "", timePart = ""] = bulkScheduledAt.split("T")
|
|
||||||
const [y = "", m = "", d = ""] = datePart.split("-")
|
|
||||||
const [hh = "", mm = ""] = timePart.split(":")
|
|
||||||
setScheduleYear(y)
|
|
||||||
setScheduleMonth(m)
|
|
||||||
setScheduleDay(d)
|
|
||||||
setScheduleHour(hh)
|
|
||||||
setScheduleMinute(mm.slice(0, 2))
|
|
||||||
}, [scheduleMenuOpen, bulkScheduledAt])
|
|
||||||
|
|
||||||
const fetchData = useCallback(async (currentOffset: number) => {
|
const fetchData = useCallback(async (currentOffset: number) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(false)
|
setError(false)
|
||||||
|
|
@ -516,8 +344,8 @@ export function NotificationsPage() {
|
||||||
if (searchTerm.trim()) {
|
if (searchTerm.trim()) {
|
||||||
const q = searchTerm.toLowerCase()
|
const q = searchTerm.toLowerCase()
|
||||||
const haystack = [
|
const haystack = [
|
||||||
getNotificationTitle(n),
|
n.payload.headline,
|
||||||
getNotificationMessage(n),
|
n.payload.message,
|
||||||
formatTypeLabel(n.type),
|
formatTypeLabel(n.type),
|
||||||
n.delivery_channel,
|
n.delivery_channel,
|
||||||
n.level,
|
n.level,
|
||||||
|
|
@ -590,10 +418,10 @@ export function NotificationsPage() {
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-brand-500 text-white hover:bg-brand-600"
|
className="bg-brand-500 text-white hover:bg-brand-600"
|
||||||
onClick={() => setBulkOpen(true)}
|
onClick={() => setComposeOpen(true)}
|
||||||
>
|
>
|
||||||
<Mail className="mr-2 h-3.5 w-3.5" />
|
<Megaphone className="mr-2 h-3.5 w-3.5" />
|
||||||
Send notification
|
New notification
|
||||||
</Button>
|
</Button>
|
||||||
{notifications.length > 0 && (
|
{notifications.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -644,7 +472,7 @@ export function NotificationsPage() {
|
||||||
{totalCount.toLocaleString()}
|
{totalCount.toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid h-10 w-10 place-items-center rounded-xl bg-brand-500/90 text-white">
|
<div className="grid h-10 w-10 place-items-center rounded-xl bg-brand-500 text-white">
|
||||||
<Bell className="h-5 w-5" />
|
<Bell className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -857,12 +685,12 @@ export function NotificationsPage() {
|
||||||
n.is_read ? "text-grayScale-600" : "text-grayScale-800",
|
n.is_read ? "text-grayScale-600" : "text-grayScale-800",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{getNotificationTitle(n)}
|
{n.payload.headline}
|
||||||
</p>
|
</p>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden lg:table-cell">
|
<TableCell className="hidden lg:table-cell">
|
||||||
<p className="max-w-sm truncate text-xs text-grayScale-500">
|
<p className="max-w-sm truncate text-xs text-grayScale-500">
|
||||||
{getNotificationMessage(n)}
|
{n.payload.message}
|
||||||
</p>
|
</p>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|
@ -969,7 +797,7 @@ export function NotificationsPage() {
|
||||||
})()}
|
})()}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate text-base">
|
<span className="truncate text-base">
|
||||||
{getNotificationTitle(selectedNotification)}
|
{selectedNotification.payload.headline}
|
||||||
</span>
|
</span>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|
@ -981,7 +809,7 @@ export function NotificationsPage() {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-lg bg-grayScale-50 p-3">
|
<div className="rounded-lg bg-grayScale-50 p-3">
|
||||||
<p className="text-sm text-grayScale-600">
|
<p className="text-sm text-grayScale-600">
|
||||||
{getNotificationMessage(selectedNotification)}
|
{selectedNotification.payload.message}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1245,487 +1073,6 @@ export function NotificationsPage() {
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Bulk send dialog */}
|
|
||||||
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
|
|
||||||
<DialogContent className="max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<Megaphone className="h-5 w-5 text-brand-500" />
|
|
||||||
<span>Send notification</span>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Send a bulk SMS, email, or push notification to users.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form
|
|
||||||
className="space-y-4"
|
|
||||||
onSubmit={async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!bulkMessage.trim()) {
|
|
||||||
toast.error("Message is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const userIds = bulkUserIds
|
|
||||||
|
|
||||||
try {
|
|
||||||
setBulkSending(true)
|
|
||||||
|
|
||||||
if (bulkChannel === "sms") {
|
|
||||||
if (userIds.length === 0) {
|
|
||||||
toast.error("User IDs are required for bulk SMS")
|
|
||||||
setBulkSending(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await sendBulkSms({
|
|
||||||
message: bulkMessage.trim(),
|
|
||||||
user_ids: userIds,
|
|
||||||
...(bulkScheduledAt ? { scheduled_at: bulkScheduledAt } : {}),
|
|
||||||
})
|
|
||||||
} else if (bulkChannel === "email") {
|
|
||||||
const form = new FormData()
|
|
||||||
if (!bulkTitle.trim()) {
|
|
||||||
toast.error("Subject is required for bulk email")
|
|
||||||
setBulkSending(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
form.append("subject", bulkTitle.trim())
|
|
||||||
form.append("message", bulkMessage.trim())
|
|
||||||
if (bulkRole.trim()) form.append("role", bulkRole.trim())
|
|
||||||
if (userIds.length > 0) {
|
|
||||||
form.append("user_ids", JSON.stringify(userIds))
|
|
||||||
}
|
|
||||||
if (bulkScheduledAt) form.append("scheduled_at", bulkScheduledAt)
|
|
||||||
if (bulkFile) form.append("file", bulkFile)
|
|
||||||
await sendBulkEmail(form)
|
|
||||||
} else {
|
|
||||||
const form = new FormData()
|
|
||||||
if (!bulkTitle.trim()) {
|
|
||||||
toast.error("Title is required for bulk push")
|
|
||||||
setBulkSending(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
form.append("title", bulkTitle.trim())
|
|
||||||
form.append("message", bulkMessage.trim())
|
|
||||||
if (bulkRole.trim()) form.append("role", bulkRole.trim())
|
|
||||||
if (userIds.length > 0) {
|
|
||||||
form.append("user_ids", JSON.stringify(userIds))
|
|
||||||
}
|
|
||||||
if (bulkScheduledAt) form.append("scheduled_at", bulkScheduledAt)
|
|
||||||
if (bulkFile) form.append("file", bulkFile)
|
|
||||||
await sendBulkPush(form)
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("Notification scheduled", {
|
|
||||||
description: bulkScheduledAt
|
|
||||||
? "Notification has been scheduled successfully."
|
|
||||||
: "Notification has been sent successfully.",
|
|
||||||
})
|
|
||||||
|
|
||||||
setBulkTitle("")
|
|
||||||
setBulkMessage("")
|
|
||||||
setBulkRole("")
|
|
||||||
setBulkUserIds([])
|
|
||||||
setBulkScheduledAt("")
|
|
||||||
setBulkFile(null)
|
|
||||||
setBulkChannel("sms")
|
|
||||||
setBulkOpen(false)
|
|
||||||
} catch (err: any) {
|
|
||||||
const msg =
|
|
||||||
err?.response?.data?.message ||
|
|
||||||
"Failed to send notification. Please try again."
|
|
||||||
toast.error("Failed to send notification", { description: msg })
|
|
||||||
} finally {
|
|
||||||
setBulkSending(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1.3fr)]">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
|
||||||
Channel
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={bulkChannel}
|
|
||||||
onChange={(e) => setBulkChannel(e.target.value as typeof bulkChannel)}
|
|
||||||
>
|
|
||||||
<option value="sms">Bulk SMS</option>
|
|
||||||
<option value="email">Bulk email</option>
|
|
||||||
<option value="push">Bulk push</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
|
||||||
{bulkChannel === "email" ? "Subject" : "Title (push only)"}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
placeholder={
|
|
||||||
bulkChannel === "email"
|
|
||||||
? `e.g. "System Update"`
|
|
||||||
: `e.g. "System Update"`
|
|
||||||
}
|
|
||||||
value={bulkTitle}
|
|
||||||
onChange={(e) => setBulkTitle(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
|
||||||
Message
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
rows={3}
|
|
||||||
placeholder={
|
|
||||||
bulkChannel === "sms"
|
|
||||||
? "Text body to send by SMS."
|
|
||||||
: "Notification body for email or push."
|
|
||||||
}
|
|
||||||
value={bulkMessage}
|
|
||||||
onChange={(e) => setBulkMessage(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
|
||||||
Role (optional)
|
|
||||||
</label>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={bulkRolesLoading}
|
|
||||||
className={cn(
|
|
||||||
"flex h-10 w-full items-center justify-between rounded-lg border bg-white px-3 text-sm",
|
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
||||||
bulkRolesLoading && "cursor-not-allowed opacity-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="truncate text-left">
|
|
||||||
{bulkRolesLoading ? "Loading roles..." : bulkRole || "All roles"}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 text-grayScale-400" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="w-[220px]">
|
|
||||||
<DropdownMenuLabel>Roles</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuRadioGroup
|
|
||||||
value={bulkRole}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setBulkRole(value)
|
|
||||||
setBulkUserIds([])
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuRadioItem value="">All roles</DropdownMenuRadioItem>
|
|
||||||
{bulkRoles.map((role) => (
|
|
||||||
<DropdownMenuRadioItem key={role.id} value={role.name}>
|
|
||||||
{role.name}
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuRadioGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
|
||||||
Users (optional)
|
|
||||||
</label>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={bulkUsersLoading}
|
|
||||||
className={cn(
|
|
||||||
"flex h-10 w-full items-center justify-between rounded-lg border bg-white px-3 text-sm",
|
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
||||||
bulkUsersLoading && "cursor-not-allowed opacity-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="truncate text-left">
|
|
||||||
{bulkUsersLoading
|
|
||||||
? "Loading users..."
|
|
||||||
: bulkUserIds.length === 0
|
|
||||||
? "Select users"
|
|
||||||
: `${bulkUserIds.length} user(s) selected`}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 text-grayScale-400" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="w-[320px]">
|
|
||||||
<DropdownMenuLabel>Users</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setBulkUserIds(filteredBulkUsers.map((u) => u.id))
|
|
||||||
}}
|
|
||||||
disabled={filteredBulkUsers.length === 0}
|
|
||||||
>
|
|
||||||
Select all
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setBulkUserIds([])
|
|
||||||
}}
|
|
||||||
disabled={bulkUserIds.length === 0}
|
|
||||||
>
|
|
||||||
Deselect all
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<div className="max-h-64 overflow-y-auto">
|
|
||||||
{filteredBulkUsers.length === 0 ? (
|
|
||||||
<p className="px-2 py-2 text-xs text-grayScale-400">No users available</p>
|
|
||||||
) : (
|
|
||||||
filteredBulkUsers.map((user) => {
|
|
||||||
const isChecked = bulkUserIds.includes(user.id)
|
|
||||||
return (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={user.id}
|
|
||||||
checked={isChecked}
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
setBulkUserIds((prev) => {
|
|
||||||
if (checked) return prev.includes(user.id) ? prev : [...prev, user.id]
|
|
||||||
return prev.filter((id) => id !== user.id)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user.first_name} {user.last_name} ({user.id})
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-grayScale-400">Choose one or more users from the dropdown list.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1.2fr)_minmax(0,1.2fr)]">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
|
||||||
File attachment (optional)
|
|
||||||
</label>
|
|
||||||
<FileUpload
|
|
||||||
accept="image/*"
|
|
||||||
onFileSelect={setBulkFile}
|
|
||||||
label="Upload image or file"
|
|
||||||
description="Optional image or asset to attach"
|
|
||||||
className="min-h-[110px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
|
||||||
Scheduled at (optional)
|
|
||||||
</label>
|
|
||||||
<DropdownMenu open={scheduleMenuOpen} onOpenChange={setScheduleMenuOpen}>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"flex h-11 w-full items-center justify-between rounded-xl border border-grayScale-200 bg-grayScale-50/70 px-3 text-sm text-grayScale-700 shadow-sm transition-all",
|
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-100",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="truncate text-left">{scheduledAtLabel}</span>
|
|
||||||
<span className="ml-2 inline-flex items-center gap-1 rounded-md border border-grayScale-200 bg-white px-2 py-1 text-[11px] text-grayScale-500">
|
|
||||||
<Calendar className="h-3.5 w-3.5" />
|
|
||||||
<Clock3 className="h-3.5 w-3.5" />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="w-[320px] p-3">
|
|
||||||
<p className="mb-2 text-xs font-semibold text-grayScale-500">Schedule notification</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-[11px] font-medium text-grayScale-500">Date</label>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="YYYY"
|
|
||||||
value={scheduleYear}
|
|
||||||
onChange={(e) => setScheduleYear(digitsOnly(e.target.value, 4))}
|
|
||||||
inputMode="numeric"
|
|
||||||
maxLength={4}
|
|
||||||
className="h-9 rounded-lg border-grayScale-200 bg-white text-center text-sm"
|
|
||||||
/>
|
|
||||||
<span className="text-grayScale-400">-</span>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="MM"
|
|
||||||
value={scheduleMonth}
|
|
||||||
onChange={(e) => setScheduleMonth(digitsOnly(e.target.value, 2))}
|
|
||||||
inputMode="numeric"
|
|
||||||
maxLength={2}
|
|
||||||
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
|
|
||||||
/>
|
|
||||||
<span className="text-grayScale-400">-</span>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="DD"
|
|
||||||
value={scheduleDay}
|
|
||||||
onChange={(e) => setScheduleDay(digitsOnly(e.target.value, 2))}
|
|
||||||
inputMode="numeric"
|
|
||||||
maxLength={2}
|
|
||||||
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-[11px] font-medium text-grayScale-500">Time</label>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="HH"
|
|
||||||
value={scheduleHour}
|
|
||||||
onChange={(e) => setScheduleHour(digitsOnly(e.target.value, 2))}
|
|
||||||
inputMode="numeric"
|
|
||||||
maxLength={2}
|
|
||||||
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
|
|
||||||
/>
|
|
||||||
<span className="text-grayScale-400">:</span>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="MM"
|
|
||||||
value={scheduleMinute}
|
|
||||||
onChange={(e) => setScheduleMinute(digitsOnly(e.target.value, 2))}
|
|
||||||
inputMode="numeric"
|
|
||||||
maxLength={2}
|
|
||||||
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8"
|
|
||||||
onClick={() => {
|
|
||||||
const now = new Date()
|
|
||||||
setScheduleYear(String(now.getFullYear()))
|
|
||||||
setScheduleMonth(String(now.getMonth() + 1).padStart(2, "0"))
|
|
||||||
setScheduleDay(String(now.getDate()).padStart(2, "0"))
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Today
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8"
|
|
||||||
onClick={() => {
|
|
||||||
setScheduleYear("")
|
|
||||||
setScheduleMonth("")
|
|
||||||
setScheduleDay("")
|
|
||||||
setScheduleHour("")
|
|
||||||
setScheduleMinute("")
|
|
||||||
setBulkScheduledAt("")
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
className="h-8"
|
|
||||||
onClick={() => {
|
|
||||||
const year = Number(scheduleYear)
|
|
||||||
const month = Number(scheduleMonth)
|
|
||||||
const day = Number(scheduleDay)
|
|
||||||
const hour = Number(scheduleHour)
|
|
||||||
const minute = Number(scheduleMinute)
|
|
||||||
|
|
||||||
const formatOk =
|
|
||||||
scheduleYear.length === 4 &&
|
|
||||||
scheduleMonth.length === 2 &&
|
|
||||||
scheduleDay.length === 2 &&
|
|
||||||
scheduleHour.length === 2 &&
|
|
||||||
scheduleMinute.length === 2
|
|
||||||
const dateValue = new Date(year, month - 1, day)
|
|
||||||
const dateOk =
|
|
||||||
formatOk &&
|
|
||||||
month >= 1 &&
|
|
||||||
month <= 12 &&
|
|
||||||
day >= 1 &&
|
|
||||||
day <= 31 &&
|
|
||||||
dateValue.getFullYear() === year &&
|
|
||||||
dateValue.getMonth() === month - 1 &&
|
|
||||||
dateValue.getDate() === day
|
|
||||||
const timeOk = hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59
|
|
||||||
|
|
||||||
if (!dateOk || !timeOk) {
|
|
||||||
toast.error("Use valid date/time format", {
|
|
||||||
description: "Date: YYYY-MM-DD, Time: HH:MM (24h).",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setBulkScheduledAt(
|
|
||||||
`${scheduleYear}-${scheduleMonth}-${scheduleDay}T${scheduleHour}:${scheduleMinute}`,
|
|
||||||
)
|
|
||||||
setScheduleMenuOpen(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<p className="text-[11px] text-grayScale-400">
|
|
||||||
Leave empty to send immediately. When set, the notification is stored in{" "}
|
|
||||||
<code>scheduled_notifications</code> and sent at the specified time.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 pt-1">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setBulkTitle("")
|
|
||||||
setBulkMessage("")
|
|
||||||
setBulkRole("")
|
|
||||||
setBulkUserIds([])
|
|
||||||
setBulkScheduledAt("")
|
|
||||||
setBulkFile(null)
|
|
||||||
setBulkChannel("sms")
|
|
||||||
setBulkOpen(false)
|
|
||||||
}}
|
|
||||||
disabled={bulkSending}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" size="sm" disabled={bulkSending || !bulkMessage.trim()}>
|
|
||||||
{bulkSending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
|
||||||
Sending…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<MailOpen className="mr-2 h-3.5 w-3.5" />
|
|
||||||
Send
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,337 +1,104 @@
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useState } from "react"
|
||||||
import { ArrowLeft, Loader2, Search, X, Check } from "lucide-react"
|
import { ArrowLeft } from "lucide-react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } 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 { Badge } from "../../components/ui/badge"
|
|
||||||
import { createRole, setRolePermissions, getAllPermissions } from "../../api/rbac.api"
|
const permissions = [
|
||||||
import type { RolePermission } from "../../types/rbac.types"
|
"View Dashboard",
|
||||||
import { cn } from "../../lib/utils"
|
"Manage Users",
|
||||||
import { toast } from "sonner"
|
"Manage Roles",
|
||||||
|
"Manage Practices",
|
||||||
|
"View Reports",
|
||||||
|
"Manage Content",
|
||||||
|
"Manage Settings",
|
||||||
|
]
|
||||||
|
|
||||||
export function AddRolePage() {
|
export function AddRolePage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [roleName, setRoleName] = useState("")
|
const [roleName, setRoleName] = useState("")
|
||||||
const [roleDescription, setRoleDescription] = useState("")
|
const [roleDescription, setRoleDescription] = useState("")
|
||||||
const [selectedPermissionIds, setSelectedPermissionIds] = useState<Set<number>>(new Set())
|
const [selectedPermissions, setSelectedPermissions] = useState<string[]>([])
|
||||||
|
|
||||||
// Permissions from API (already grouped by group_name)
|
const togglePermission = (permission: string) => {
|
||||||
const [permissionsMap, setPermissionsMap] = useState<Record<string, RolePermission[]>>({})
|
setSelectedPermissions((prev) =>
|
||||||
const [permLoading, setPermLoading] = useState(true)
|
prev.includes(permission)
|
||||||
const [permSearch, setPermSearch] = useState("")
|
? prev.filter((p) => p !== permission)
|
||||||
|
: [...prev, permission],
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
|
|
||||||
// Load all available permissions
|
|
||||||
useEffect(() => {
|
|
||||||
const fetch = async () => {
|
|
||||||
setPermLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await getAllPermissions()
|
|
||||||
setPermissionsMap(res.data.data ?? {})
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to load permissions.")
|
|
||||||
} finally {
|
|
||||||
setPermLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetch()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Flat list of all permissions (for select-all / count)
|
|
||||||
const allPermissions = useMemo(
|
|
||||||
() => Object.values(permissionsMap).flat(),
|
|
||||||
[permissionsMap],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Filtered & sorted groups
|
|
||||||
const permissionGroups = useMemo(() => {
|
|
||||||
const q = permSearch.toLowerCase()
|
|
||||||
const entries: [string, RolePermission[]][] = []
|
|
||||||
|
|
||||||
for (const [groupName, perms] of Object.entries(permissionsMap)) {
|
|
||||||
const filtered = q
|
|
||||||
? perms.filter(
|
|
||||||
(p) =>
|
|
||||||
p.name.toLowerCase().includes(q) ||
|
|
||||||
p.key.toLowerCase().includes(q) ||
|
|
||||||
groupName.toLowerCase().includes(q),
|
|
||||||
)
|
|
||||||
: perms
|
|
||||||
if (filtered.length > 0) entries.push([groupName, filtered])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries.sort(([a], [b]) => a.localeCompare(b))
|
const handleSubmit = () => {
|
||||||
}, [permissionsMap, permSearch])
|
console.log("Add role:", { roleName, roleDescription, selectedPermissions })
|
||||||
|
|
||||||
const togglePermission = (id: number) => {
|
|
||||||
setSelectedPermissionIds((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (next.has(id)) next.delete(id)
|
|
||||||
else next.add(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleGroup = (perms: RolePermission[]) => {
|
|
||||||
const allSelected = perms.every((p) => selectedPermissionIds.has(p.id))
|
|
||||||
setSelectedPermissionIds((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
for (const p of perms) {
|
|
||||||
if (allSelected) next.delete(p.id)
|
|
||||||
else next.add(p.id)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectAll = () => {
|
|
||||||
setSelectedPermissionIds(new Set(allPermissions.map((p) => p.id)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearAll = () => {
|
|
||||||
setSelectedPermissionIds(new Set())
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!roleName.trim()) {
|
|
||||||
toast.error("Role name is required.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
// 1. Create the role
|
|
||||||
const res = await createRole({
|
|
||||||
name: roleName.trim(),
|
|
||||||
description: roleDescription.trim(),
|
|
||||||
})
|
|
||||||
const newRoleId = res.data.data.id
|
|
||||||
|
|
||||||
// 2. Assign permissions if any selected
|
|
||||||
if (selectedPermissionIds.size > 0) {
|
|
||||||
await setRolePermissions(newRoleId, {
|
|
||||||
permission_ids: Array.from(selectedPermissionIds),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(`Role "${res.data.data.name}" created successfully.`)
|
|
||||||
navigate("/roles")
|
navigate("/roles")
|
||||||
} catch (err: unknown) {
|
|
||||||
const message =
|
|
||||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
|
||||||
"Failed to create role."
|
|
||||||
toast.error(message)
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate("/roles")} className="h-8 w-8">
|
<Button variant="ghost" size="icon" onClick={() => navigate("/roles")} className="h-8 w-8">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<h1 className="text-xl font-semibold text-grayScale-900">Add New Role</h1>
|
||||||
<h1 className="text-xl font-semibold text-grayScale-700">Add New Role</h1>
|
|
||||||
<p className="text-xs text-grayScale-400">Create a role and assign permissions.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)]">
|
<Card className="p-6">
|
||||||
{/* Left – Role info */}
|
<div className="space-y-6">
|
||||||
<Card className="h-fit shadow-soft">
|
|
||||||
<CardHeader className="border-b border-grayScale-200 pb-3">
|
|
||||||
<CardTitle className="text-sm font-semibold text-grayScale-600">
|
|
||||||
Role Information
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4 pt-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs font-medium text-grayScale-500">
|
<label className="mb-2 block text-sm font-medium text-grayScale-700">Role Name</label>
|
||||||
Role Name
|
|
||||||
</label>
|
|
||||||
<Input
|
<Input
|
||||||
value={roleName}
|
value={roleName}
|
||||||
onChange={(e) => setRoleName(e.target.value)}
|
onChange={(e) => setRoleName(e.target.value)}
|
||||||
placeholder="e.g. CONTENT_MANAGER"
|
placeholder="Enter role name"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs font-medium text-grayScale-500">
|
<label className="mb-2 block text-sm font-medium text-grayScale-700">
|
||||||
Description
|
Role Description
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={roleDescription}
|
value={roleDescription}
|
||||||
onChange={(e) => setRoleDescription(e.target.value)}
|
onChange={(e) => setRoleDescription(e.target.value)}
|
||||||
placeholder="Describe what this role can do…"
|
placeholder="Enter role description"
|
||||||
rows={3}
|
rows={3}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-grayScale-100 pt-4">
|
<div>
|
||||||
<div className="flex items-center justify-between text-xs text-grayScale-400">
|
<label className="mb-4 block text-sm font-medium text-grayScale-700">
|
||||||
<span>{selectedPermissionIds.size} permission{selectedPermissionIds.size !== 1 ? "s" : ""} selected</span>
|
|
||||||
<span>{allPermissions.length} available</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={saving || !roleName.trim()}
|
|
||||||
className="w-full bg-brand-500 hover:bg-brand-600"
|
|
||||||
>
|
|
||||||
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
||||||
{saving ? "Creating…" : "Create Role"}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Right – Permissions picker */}
|
|
||||||
<Card className="shadow-soft">
|
|
||||||
<CardHeader className="border-b border-grayScale-200 pb-3">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<CardTitle className="text-sm font-semibold text-grayScale-600">
|
|
||||||
Permissions
|
Permissions
|
||||||
</CardTitle>
|
</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-2">
|
||||||
<Button
|
{permissions.map((permission) => (
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-[11px]"
|
|
||||||
onClick={selectAll}
|
|
||||||
>
|
|
||||||
Select all
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-[11px]"
|
|
||||||
onClick={clearAll}
|
|
||||||
disabled={selectedPermissionIds.size === 0}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-4 space-y-4">
|
|
||||||
{/* Search */}
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
|
||||||
<Input
|
|
||||||
value={permSearch}
|
|
||||||
onChange={(e) => setPermSearch(e.target.value)}
|
|
||||||
placeholder="Filter permissions…"
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
{permSearch && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPermSearch("")}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Loading */}
|
|
||||||
{permLoading && (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-brand-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Permission groups */}
|
|
||||||
{!permLoading && (
|
|
||||||
<div className="max-h-[500px] space-y-5 overflow-y-auto pr-1">
|
|
||||||
{permissionGroups.length === 0 ? (
|
|
||||||
<p className="py-8 text-center text-xs text-grayScale-400">
|
|
||||||
{permSearch ? "No permissions match your search." : "No permissions available."}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
permissionGroups.map(([groupName, perms]) => {
|
|
||||||
const allSelected = perms.every((p) => selectedPermissionIds.has(p.id))
|
|
||||||
const someSelected = perms.some((p) => selectedPermissionIds.has(p.id))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={groupName}>
|
|
||||||
{/* Group header */}
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleGroup(perms)}
|
|
||||||
className={cn(
|
|
||||||
"flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors",
|
|
||||||
allSelected
|
|
||||||
? "border-brand-500 bg-brand-500 text-white"
|
|
||||||
: someSelected
|
|
||||||
? "border-brand-300 bg-brand-50"
|
|
||||||
: "border-grayScale-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{allSelected && <Check className="h-3 w-3" />}
|
|
||||||
{someSelected && !allSelected && (
|
|
||||||
<div className="h-1.5 w-1.5 rounded-sm bg-brand-500" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
|
||||||
{groupName}
|
|
||||||
</span>
|
|
||||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
|
||||||
{perms.filter((p) => selectedPermissionIds.has(p.id)).length}/{perms.length}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Permission items */}
|
|
||||||
<div className="ml-6 grid gap-1">
|
|
||||||
{perms.map((perm) => {
|
|
||||||
const isSelected = selectedPermissionIds.has(perm.id)
|
|
||||||
return (
|
|
||||||
<label
|
<label
|
||||||
key={perm.id}
|
key={permission}
|
||||||
className={cn(
|
className="flex items-center gap-2 rounded-lg border p-3 hover:bg-grayScale-50 cursor-pointer"
|
||||||
"flex cursor-pointer items-center gap-2.5 rounded-lg border px-3 py-2 transition-colors",
|
|
||||||
isSelected
|
|
||||||
? "border-brand-200 bg-brand-50/50"
|
|
||||||
: "border-grayScale-100 hover:bg-grayScale-50",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isSelected}
|
checked={selectedPermissions.includes(permission)}
|
||||||
onChange={() => togglePermission(perm.id)}
|
onChange={() => togglePermission(permission)}
|
||||||
className="h-3.5 w-3.5 rounded border-grayScale-300 text-brand-500 focus:ring-brand-500"
|
className="h-4 w-4 rounded border-grayScale-300 text-brand-500 focus:ring-brand-500"
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<span className="text-sm text-grayScale-700">{permission}</span>
|
||||||
<p className="truncate text-xs font-medium text-grayScale-700">
|
|
||||||
{perm.name}
|
|
||||||
</p>
|
|
||||||
<p className="truncate text-[10px] text-grayScale-400">
|
|
||||||
{perm.key}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
</label>
|
||||||
)
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
})
|
<div className="flex justify-end">
|
||||||
)}
|
<Button onClick={handleSubmit} className="bg-brand-500 hover:bg-brand-600">
|
||||||
|
Add Role
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,242 +1,19 @@
|
||||||
import { useEffect, useMemo, useState } from "react"
|
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import {
|
import { Plus, Edit } from "lucide-react"
|
||||||
Plus, Search, Shield, ShieldCheck, ChevronLeft, ChevronRight,
|
|
||||||
Loader2, AlertCircle, Eye, X, Pencil, Check,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Card, CardContent } from "../../components/ui/card"
|
import { Card, CardContent } from "../../components/ui/card"
|
||||||
import { Badge } from "../../components/ui/badge"
|
|
||||||
import { Input } from "../../components/ui/input"
|
const mockRoles = [
|
||||||
import { Textarea } from "../../components/ui/textarea"
|
{ id: "1", name: "Admin", userCount: 10 },
|
||||||
import {
|
{ id: "2", name: "User", userCount: 5 },
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
]
|
||||||
} from "../../components/ui/dialog"
|
|
||||||
import { getRoles, getRoleDetail, getAllPermissions, setRolePermissions, updateRole } from "../../api/rbac.api"
|
|
||||||
import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
|
|
||||||
import { cn } from "../../lib/utils"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
export function RolesListPage() {
|
export function RolesListPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
// List state
|
|
||||||
const [roles, setRoles] = useState<Role[]>([])
|
|
||||||
const [total, setTotal] = useState(0)
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [pageSize] = useState(20)
|
|
||||||
const [query, setQuery] = useState("")
|
|
||||||
const [debouncedQuery, setDebouncedQuery] = useState("")
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// Detail modal state
|
|
||||||
const [selectedRole, setSelectedRole] = useState<RoleDetail | null>(null)
|
|
||||||
const [detailOpen, setDetailOpen] = useState(false)
|
|
||||||
const [detailLoading, setDetailLoading] = useState(false)
|
|
||||||
|
|
||||||
// Role info editing state
|
|
||||||
const [editingRole, setEditingRole] = useState(false)
|
|
||||||
const [editName, setEditName] = useState("")
|
|
||||||
const [editDescription, setEditDescription] = useState("")
|
|
||||||
const [savingRole, setSavingRole] = useState(false)
|
|
||||||
|
|
||||||
// Permissions editing state
|
|
||||||
const [editingPermissions, setEditingPermissions] = useState(false)
|
|
||||||
const [allPermissionsMap, setAllPermissionsMap] = useState<Record<string, RolePermission[]>>({})
|
|
||||||
const [permLoading, setPermLoading] = useState(false)
|
|
||||||
const [selectedPermissionIds, setSelectedPermissionIds] = useState<Set<number>>(new Set())
|
|
||||||
const [permSearch, setPermSearch] = useState("")
|
|
||||||
const [savingPermissions, setSavingPermissions] = useState(false)
|
|
||||||
|
|
||||||
// Debounce search query
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setDebouncedQuery(query)
|
|
||||||
setPage(1)
|
|
||||||
}, 400)
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}, [query])
|
|
||||||
|
|
||||||
// Fetch roles
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchRoles = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const res = await getRoles({
|
|
||||||
query: debouncedQuery || undefined,
|
|
||||||
page,
|
|
||||||
page_size: pageSize,
|
|
||||||
})
|
|
||||||
setRoles(res.data.data.roles ?? [])
|
|
||||||
setTotal(res.data.data.total ?? 0)
|
|
||||||
} catch {
|
|
||||||
setError("Failed to load roles.")
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchRoles()
|
|
||||||
}, [debouncedQuery, page, pageSize])
|
|
||||||
|
|
||||||
// Open role detail
|
|
||||||
const handleViewRole = async (roleId: number) => {
|
|
||||||
setDetailOpen(true)
|
|
||||||
setDetailLoading(true)
|
|
||||||
setSelectedRole(null)
|
|
||||||
try {
|
|
||||||
const res = await getRoleDetail(roleId)
|
|
||||||
setSelectedRole(res.data.data)
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to load role details.")
|
|
||||||
setDetailOpen(false)
|
|
||||||
} finally {
|
|
||||||
setDetailLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter role info edit mode
|
|
||||||
const handleEditRole = () => {
|
|
||||||
if (!selectedRole) return
|
|
||||||
setEditName(selectedRole.name)
|
|
||||||
setEditDescription(selectedRole.description)
|
|
||||||
setEditingRole(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCancelEditRole = () => {
|
|
||||||
setEditingRole(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveRole = async () => {
|
|
||||||
if (!selectedRole || !editName.trim()) return
|
|
||||||
setSavingRole(true)
|
|
||||||
try {
|
|
||||||
await updateRole(selectedRole.id, {
|
|
||||||
name: editName.trim(),
|
|
||||||
description: editDescription.trim(),
|
|
||||||
})
|
|
||||||
const res = await getRoleDetail(selectedRole.id)
|
|
||||||
setSelectedRole(res.data.data)
|
|
||||||
setEditingRole(false)
|
|
||||||
toast.success("Role updated successfully.")
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message =
|
|
||||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
|
||||||
"Failed to update role."
|
|
||||||
toast.error(message)
|
|
||||||
} finally {
|
|
||||||
setSavingRole(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter edit mode – fetch all permissions
|
|
||||||
const handleEditPermissions = async () => {
|
|
||||||
setEditingPermissions(true)
|
|
||||||
setPermSearch("")
|
|
||||||
setSelectedPermissionIds(new Set(selectedRole?.permissions.map((p) => p.id) ?? []))
|
|
||||||
|
|
||||||
if (Object.keys(allPermissionsMap).length === 0) {
|
|
||||||
setPermLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await getAllPermissions()
|
|
||||||
setAllPermissionsMap(res.data.data ?? {})
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to load permissions.")
|
|
||||||
setEditingPermissions(false)
|
|
||||||
} finally {
|
|
||||||
setPermLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCancelEdit = () => {
|
|
||||||
setEditingPermissions(false)
|
|
||||||
setPermSearch("")
|
|
||||||
}
|
|
||||||
|
|
||||||
const togglePermission = (id: number) => {
|
|
||||||
setSelectedPermissionIds((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (next.has(id)) next.delete(id)
|
|
||||||
else next.add(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleGroup = (perms: RolePermission[]) => {
|
|
||||||
const allSelected = perms.every((p) => selectedPermissionIds.has(p.id))
|
|
||||||
setSelectedPermissionIds((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
for (const p of perms) {
|
|
||||||
if (allSelected) next.delete(p.id)
|
|
||||||
else next.add(p.id)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSavePermissions = async () => {
|
|
||||||
if (!selectedRole) return
|
|
||||||
setSavingPermissions(true)
|
|
||||||
try {
|
|
||||||
await setRolePermissions(selectedRole.id, {
|
|
||||||
permission_ids: Array.from(selectedPermissionIds),
|
|
||||||
})
|
|
||||||
// Refresh role detail
|
|
||||||
const res = await getRoleDetail(selectedRole.id)
|
|
||||||
setSelectedRole(res.data.data)
|
|
||||||
setEditingPermissions(false)
|
|
||||||
toast.success("Permissions updated successfully.")
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to update permissions.")
|
|
||||||
} finally {
|
|
||||||
setSavingPermissions(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group permissions by group_name
|
|
||||||
const permissionGroups = useMemo(() => {
|
|
||||||
if (!selectedRole?.permissions) return []
|
|
||||||
const map = new Map<string, RolePermission[]>()
|
|
||||||
for (const p of selectedRole.permissions) {
|
|
||||||
const group = map.get(p.group_name) ?? []
|
|
||||||
group.push(p)
|
|
||||||
map.set(p.group_name, group)
|
|
||||||
}
|
|
||||||
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b))
|
|
||||||
}, [selectedRole])
|
|
||||||
|
|
||||||
// Filtered permission groups for edit mode
|
|
||||||
const editPermissionGroups = useMemo(() => {
|
|
||||||
const q = permSearch.toLowerCase()
|
|
||||||
const entries: [string, RolePermission[]][] = []
|
|
||||||
for (const [groupName, perms] of Object.entries(allPermissionsMap)) {
|
|
||||||
const filtered = q
|
|
||||||
? perms.filter(
|
|
||||||
(p) =>
|
|
||||||
p.name.toLowerCase().includes(q) ||
|
|
||||||
p.key.toLowerCase().includes(q) ||
|
|
||||||
groupName.toLowerCase().includes(q),
|
|
||||||
)
|
|
||||||
: perms
|
|
||||||
if (filtered.length > 0) entries.push([groupName, filtered])
|
|
||||||
}
|
|
||||||
return entries.sort(([a], [b]) => a.localeCompare(b))
|
|
||||||
}, [allPermissionsMap, permSearch])
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<h1 className="text-xl font-semibold text-grayScale-900">Role Management</h1>
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">Role Management</h1>
|
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
|
||||||
Manage roles and their permissions.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate("/roles/add")}
|
onClick={() => navigate("/roles/add")}
|
||||||
className="bg-brand-500 hover:bg-brand-600"
|
className="bg-brand-500 hover:bg-brand-600"
|
||||||
|
|
@ -246,448 +23,22 @@ export function RolesListPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="relative max-w-sm">
|
{mockRoles.map((role) => (
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
<Card key={role.id} className="overflow-hidden shadow-sm">
|
||||||
<Input
|
<div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" />
|
||||||
value={query}
|
<CardContent className="p-6">
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
<h3 className="mb-2 text-lg font-semibold text-grayScale-900">{role.name}</h3>
|
||||||
placeholder="Search roles…"
|
<p className="mb-4 text-sm text-grayScale-600">{role.userCount} Users</p>
|
||||||
className="pl-9"
|
<Button variant="outline" className="w-full">
|
||||||
/>
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
{query && (
|
Edit Role
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setQuery("")}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
|
||||||
<div className="flex items-center gap-3 rounded-2xl border border-red-100 bg-red-50 px-5 py-4">
|
|
||||||
<AlertCircle className="h-5 w-5 shrink-0 text-red-500" />
|
|
||||||
<p className="text-sm font-medium text-red-600">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loading */}
|
|
||||||
{loading && (
|
|
||||||
<div className="flex items-center justify-center py-20">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-brand-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Roles grid */}
|
|
||||||
{!loading && !error && (
|
|
||||||
<>
|
|
||||||
{roles.length === 0 ? (
|
|
||||||
<Card className="shadow-none border border-dashed border-grayScale-200 bg-grayScale-50/60">
|
|
||||||
<CardContent className="flex flex-col items-center justify-center gap-2 py-16 text-center">
|
|
||||||
<Shield className="h-10 w-10 text-grayScale-300" />
|
|
||||||
<p className="text-sm font-semibold text-grayScale-600">No roles found.</p>
|
|
||||||
<p className="text-xs text-grayScale-400">
|
|
||||||
{debouncedQuery
|
|
||||||
? `No roles match "${debouncedQuery}".`
|
|
||||||
: "Create a new role to get started."}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{roles.map((role) => (
|
|
||||||
<Card
|
|
||||||
key={role.id}
|
|
||||||
className="overflow-hidden shadow-sm transition-shadow hover:shadow-md"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"h-1.5",
|
|
||||||
role.is_system
|
|
||||||
? "bg-gradient-to-r from-brand-400 to-brand-600"
|
|
||||||
: "bg-gradient-to-r from-brand-500 to-brand-600",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex h-9 w-9 items-center justify-center rounded-lg",
|
|
||||||
role.is_system
|
|
||||||
? "bg-brand-100 text-brand-600"
|
|
||||||
: "bg-brand-50 text-brand-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{role.is_system ? (
|
|
||||||
<ShieldCheck className="h-4.5 w-4.5" />
|
|
||||||
) : (
|
|
||||||
<Shield className="h-4.5 w-4.5" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-grayScale-700">{role.name}</h3>
|
|
||||||
<p className="mt-0.5 text-xs text-grayScale-400 line-clamp-1">
|
|
||||||
{role.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{role.is_system && (
|
|
||||||
<Badge variant="warning" className="shrink-0 text-[10px]">
|
|
||||||
System
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-between">
|
|
||||||
<span className="text-[11px] text-grayScale-400">
|
|
||||||
Created {new Date(role.created_at).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 text-xs"
|
|
||||||
onClick={() => handleViewRole(role.id)}
|
|
||||||
>
|
|
||||||
<Eye className="h-3.5 w-3.5" />
|
|
||||||
View
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="flex items-center justify-between border-t border-grayScale-100 pt-4">
|
|
||||||
<p className="text-xs text-grayScale-400">
|
|
||||||
Showing {(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of {total} roles
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
disabled={page <= 1}
|
|
||||||
onClick={() => setPage((p) => p - 1)}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<span className="px-3 text-xs font-medium text-grayScale-600">
|
|
||||||
{page} / {totalPages}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Role detail dialog */}
|
|
||||||
<Dialog open={detailOpen} onOpenChange={(open) => {
|
|
||||||
setDetailOpen(open)
|
|
||||||
if (!open) {
|
|
||||||
setEditingPermissions(false)
|
|
||||||
setEditingRole(false)
|
|
||||||
setPermSearch("")
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
{!editingRole ? (
|
|
||||||
<>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
{selectedRole?.is_system ? (
|
|
||||||
<ShieldCheck className="h-5 w-5 text-brand-500" />
|
|
||||||
) : (
|
|
||||||
<Shield className="h-5 w-5 text-brand-500" />
|
|
||||||
)}
|
|
||||||
{selectedRole?.name ?? "Role Details"}
|
|
||||||
{selectedRole && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="ml-auto h-7 w-7"
|
|
||||||
onClick={handleEditRole}
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{selectedRole?.description}
|
|
||||||
</DialogDescription>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<DialogTitle>Edit Role</DialogTitle>
|
|
||||||
<DialogDescription>Update the role name and description.</DialogDescription>
|
|
||||||
<div className="mt-3 space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-xs font-medium text-grayScale-500">
|
|
||||||
Role Name
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={editName}
|
|
||||||
onChange={(e) => setEditName(e.target.value)}
|
|
||||||
placeholder="e.g. CONTENT_MANAGER"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-xs font-medium text-grayScale-500">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={editDescription}
|
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
|
||||||
placeholder="Describe what this role can do…"
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
onClick={handleCancelEditRole}
|
|
||||||
disabled={savingRole}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 bg-brand-500 text-xs hover:bg-brand-600"
|
|
||||||
onClick={handleSaveRole}
|
|
||||||
disabled={savingRole || !editName.trim()}
|
|
||||||
>
|
|
||||||
{savingRole && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
|
||||||
{savingRole ? "Saving…" : "Save"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{detailLoading && (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-brand-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!detailLoading && selectedRole && (
|
|
||||||
<div className="space-y-5">
|
|
||||||
{/* Meta row */}
|
|
||||||
<div className="flex flex-wrap items-center gap-3 text-xs text-grayScale-400">
|
|
||||||
{selectedRole.is_system && (
|
|
||||||
<Badge variant="warning" className="text-[10px]">System Role</Badge>
|
|
||||||
)}
|
|
||||||
<span>
|
|
||||||
Created {new Date(selectedRole.created_at).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{selectedRole.permissions.length} permission{selectedRole.permissions.length !== 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Permissions section */}
|
|
||||||
<div>
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<h4 className="text-sm font-semibold text-grayScale-600">Permissions</h4>
|
|
||||||
{!editingPermissions && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 gap-1.5 text-xs"
|
|
||||||
onClick={handleEditPermissions}
|
|
||||||
>
|
|
||||||
<Pencil className="h-3 w-3" />
|
|
||||||
Edit Permissions
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* VIEW mode */}
|
|
||||||
{!editingPermissions && (
|
|
||||||
<>
|
|
||||||
{permissionGroups.length === 0 ? (
|
|
||||||
<p className="text-xs italic text-grayScale-400">No permissions assigned.</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{permissionGroups.map(([groupName, perms]) => (
|
|
||||||
<div key={groupName}>
|
|
||||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-400">
|
|
||||||
{groupName}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{perms.map((p) => (
|
|
||||||
<span
|
|
||||||
key={p.id}
|
|
||||||
title={`${p.key} — ${p.description}`}
|
|
||||||
className="inline-flex items-center rounded-md border border-grayScale-200 bg-grayScale-50 px-2 py-0.5 text-[11px] font-medium text-grayScale-600"
|
|
||||||
>
|
|
||||||
{p.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* EDIT mode */}
|
|
||||||
{editingPermissions && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Search & actions bar */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
|
||||||
<Input
|
|
||||||
value={permSearch}
|
|
||||||
onChange={(e) => setPermSearch(e.target.value)}
|
|
||||||
placeholder="Filter permissions…"
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
{permSearch && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPermSearch("")}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="shrink-0 text-xs text-grayScale-400">
|
|
||||||
{selectedPermissionIds.size} selected
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{permLoading && (
|
|
||||||
<div className="flex items-center justify-center py-10">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-brand-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!permLoading && (
|
|
||||||
<div className="max-h-[400px] space-y-5 overflow-y-auto pr-1">
|
|
||||||
{editPermissionGroups.length === 0 ? (
|
|
||||||
<p className="py-6 text-center text-xs text-grayScale-400">
|
|
||||||
{permSearch ? "No permissions match your search." : "No permissions available."}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
editPermissionGroups.map(([groupName, perms]) => {
|
|
||||||
const allSelected = perms.every((p) => selectedPermissionIds.has(p.id))
|
|
||||||
const someSelected = perms.some((p) => selectedPermissionIds.has(p.id))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={groupName}>
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleGroup(perms)}
|
|
||||||
className={cn(
|
|
||||||
"flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors",
|
|
||||||
allSelected
|
|
||||||
? "border-brand-500 bg-brand-500 text-white"
|
|
||||||
: someSelected
|
|
||||||
? "border-brand-300 bg-brand-50"
|
|
||||||
: "border-grayScale-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{allSelected && <Check className="h-3 w-3" />}
|
|
||||||
{someSelected && !allSelected && (
|
|
||||||
<div className="h-1.5 w-1.5 rounded-sm bg-brand-500" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
|
||||||
{groupName}
|
|
||||||
</span>
|
|
||||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
|
||||||
{perms.filter((p) => selectedPermissionIds.has(p.id)).length}/{perms.length}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="ml-6 grid gap-1">
|
|
||||||
{perms.map((perm) => {
|
|
||||||
const isSelected = selectedPermissionIds.has(perm.id)
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={perm.id}
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-pointer items-center gap-2.5 rounded-lg border px-3 py-2 transition-colors",
|
|
||||||
isSelected
|
|
||||||
? "border-brand-200 bg-brand-50/50"
|
|
||||||
: "border-grayScale-100 hover:bg-grayScale-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={() => togglePermission(perm.id)}
|
|
||||||
className="h-3.5 w-3.5 rounded border-grayScale-300 text-brand-500 focus:ring-brand-500"
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-xs font-medium text-grayScale-700">
|
|
||||||
{perm.name}
|
|
||||||
</p>
|
|
||||||
<p className="truncate text-[10px] text-grayScale-400">
|
|
||||||
{perm.key}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Save / Cancel */}
|
|
||||||
<div className="flex items-center justify-end gap-2 border-t border-grayScale-100 pt-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
onClick={handleCancelEdit}
|
|
||||||
disabled={savingPermissions}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 bg-brand-500 text-xs hover:bg-brand-600"
|
|
||||||
onClick={handleSavePermissions}
|
|
||||||
disabled={savingPermissions}
|
|
||||||
>
|
|
||||||
{savingPermissions && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
|
||||||
{savingPermissions ? "Saving…" : "Save Permissions"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import {
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
X,
|
|
||||||
} 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";
|
||||||
|
|
@ -21,9 +20,8 @@ import {
|
||||||
} from "../../components/ui/table";
|
} from "../../components/ui/table";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { getTeamMembers, updateTeamMemberStatus } from "../../api/team.api";
|
import { getTeamMembers } from "../../api/team.api";
|
||||||
import type { TeamMember } from "../../types/team.types";
|
import type { TeamMember } from "../../types/team.types";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||||
|
|
@ -90,8 +88,6 @@ export function TeamManagementPage() {
|
||||||
const [roleFilter, setRoleFilter] = useState("");
|
const [roleFilter, setRoleFilter] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("");
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
const [toggledStatuses, setToggledStatuses] = useState<Record<number, boolean>>({});
|
const [toggledStatuses, setToggledStatuses] = useState<Record<number, boolean>>({});
|
||||||
const [confirmDialog, setConfirmDialog] = useState<{ id: number; name: string; newStatus: string } | null>(null);
|
|
||||||
const [updating, setUpdating] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchMembers = async () => {
|
const fetchMembers = async () => {
|
||||||
|
|
@ -137,36 +133,7 @@ export function TeamManagementPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggle = (id: number) => {
|
const handleToggle = (id: number) => {
|
||||||
const member = members.find((m) => m.id === id);
|
setToggledStatuses((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||||
if (!member) return;
|
|
||||||
const currentlyActive = toggledStatuses[id] ?? false;
|
|
||||||
const newStatus = currentlyActive ? "inactive" : "active";
|
|
||||||
setConfirmDialog({ id, name: `${member.first_name} ${member.last_name}`, newStatus });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirmStatusUpdate = async () => {
|
|
||||||
if (!confirmDialog) return;
|
|
||||||
const { id, newStatus, name } = confirmDialog;
|
|
||||||
const previousActive = toggledStatuses[id] ?? false;
|
|
||||||
setUpdating(true);
|
|
||||||
setToggledStatuses((prev) => ({ ...prev, [id]: newStatus === "active" }));
|
|
||||||
try {
|
|
||||||
await updateTeamMemberStatus(id, newStatus);
|
|
||||||
toast.success(
|
|
||||||
`${name || "Team member"} ${newStatus === "active" ? "activated" : "deactivated"} successfully`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to update member status:", error);
|
|
||||||
setToggledStatuses((prev) => ({ ...prev, [id]: previousActive }));
|
|
||||||
toast.error("Failed to update team member status. Please try again.");
|
|
||||||
} finally {
|
|
||||||
setUpdating(false);
|
|
||||||
handleCancelConfirm();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelConfirm = () => {
|
|
||||||
setConfirmDialog(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -242,8 +209,6 @@ export function TeamManagementPage() {
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>USER</TableHead>
|
<TableHead>USER</TableHead>
|
||||||
<TableHead>ROLE</TableHead>
|
<TableHead>ROLE</TableHead>
|
||||||
<TableHead className="hidden md:table-cell">DEPARTMENT</TableHead>
|
|
||||||
<TableHead className="hidden lg:table-cell">JOB TITLE</TableHead>
|
|
||||||
<TableHead className="hidden sm:table-cell">LAST LOGIN</TableHead>
|
<TableHead className="hidden sm:table-cell">LAST LOGIN</TableHead>
|
||||||
<TableHead>STATUS</TableHead>
|
<TableHead>STATUS</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -252,7 +217,7 @@ export function TeamManagementPage() {
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{members.length === 0 ? (
|
{members.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="text-center text-grayScale-400">
|
<TableCell colSpan={4} className="text-center text-grayScale-400">
|
||||||
No team members found
|
No team members found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -296,12 +261,6 @@ export function TeamManagementPage() {
|
||||||
{formatRoleLabel(member.team_role)}
|
{formatRoleLabel(member.team_role)}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden md:table-cell text-sm text-grayScale-600">
|
|
||||||
{member.department || "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="hidden lg:table-cell text-sm text-grayScale-600">
|
|
||||||
{member.job_title || "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="hidden sm:table-cell">
|
<TableCell className="hidden sm:table-cell">
|
||||||
{member.last_login ? (
|
{member.last_login ? (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -324,16 +283,13 @@ export function TeamManagementPage() {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleToggle(member.id)}
|
onClick={() => handleToggle(member.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex h-7 w-12 shrink-0 cursor-pointer items-center rounded-full border p-0.5 transition-all duration-200",
|
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-300 focus-visible:ring-offset-1",
|
isActive ? "bg-brand-500" : "bg-grayScale-200"
|
||||||
isActive
|
|
||||||
? "border-brand-500 bg-brand-500 shadow-[0_6px_16px_rgba(168,85,247,0.35)]"
|
|
||||||
: "border-grayScale-300 bg-grayScale-200 hover:bg-grayScale-300/80"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-md ring-0 transition-transform duration-200 ease-out",
|
"pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-sm ring-0 transition-transform",
|
||||||
isActive ? "translate-x-5" : "translate-x-0"
|
isActive ? "translate-x-5" : "translate-x-0"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -416,42 +372,6 @@ export function TeamManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Update Confirmation Modal */}
|
|
||||||
{confirmDialog && (
|
|
||||||
<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-sm rounded-xl bg-white shadow-2xl">
|
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Confirm Status Change</h2>
|
|
||||||
<button
|
|
||||||
onClick={handleCancelConfirm}
|
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-6">
|
|
||||||
<p className="text-sm leading-relaxed text-grayScale-600">
|
|
||||||
Are you sure you want to change the status of{" "}
|
|
||||||
<span className="font-semibold">{confirmDialog.name}</span> to{" "}
|
|
||||||
<span className="font-semibold capitalize">{confirmDialog.newStatus}</span>?
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
|
||||||
<Button variant="outline" onClick={handleCancelConfirm} disabled={updating}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="bg-brand-600 hover:bg-brand-500 text-white"
|
|
||||||
onClick={handleConfirmStatusUpdate}
|
|
||||||
disabled={updating}
|
|
||||||
>
|
|
||||||
{updating ? "Updating..." : "Confirm"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,47 @@ import { useEffect, useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
Briefcase,
|
||||||
|
Building2,
|
||||||
|
Calendar,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Globe,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
MessageCircle,
|
Mail,
|
||||||
|
Phone,
|
||||||
Shield,
|
Shield,
|
||||||
User,
|
User,
|
||||||
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
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 { Separator } from "../../components/ui/separator";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { getTeamMemberById } from "../../api/team.api";
|
import { getTeamMemberById } from "../../api/team.api";
|
||||||
import type { TeamMember } from "../../types/team.types";
|
import type { TeamMember } from "../../types/team.types";
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null | undefined): string {
|
||||||
|
if (!dateStr) return "—";
|
||||||
|
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(dateStr: string | null | undefined): string {
|
||||||
|
if (!dateStr) return "—";
|
||||||
|
return new Date(dateStr).toLocaleString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function formatRoleLabel(role: string): string {
|
function formatRoleLabel(role: string): string {
|
||||||
return role
|
return role
|
||||||
.split("_")
|
.split("_")
|
||||||
|
|
@ -52,28 +80,16 @@ function getRoleBadgeClasses(role: string): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReadOnlyField({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-grayScale-400">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 px-3 py-2.5 text-sm text-grayScale-600">
|
|
||||||
{value || "—"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadingSkeleton() {
|
function LoadingSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="h-5 w-32 animate-pulse rounded bg-grayScale-100" />
|
<div className="h-5 w-32 animate-pulse rounded bg-grayScale-100" />
|
||||||
<div className="animate-pulse">
|
<div className="animate-pulse">
|
||||||
<div className="rounded-2xl bg-grayScale-100 h-[200px]" />
|
<div className="rounded-2xl bg-grayScale-100 h-64" />
|
||||||
<div className="mt-6 grid gap-6 lg:grid-cols-3">
|
<div className="mt-6 grid gap-6 lg:grid-cols-3">
|
||||||
<div className="lg:col-span-2 rounded-2xl bg-grayScale-100 h-96" />
|
<div className="rounded-2xl bg-grayScale-100 h-52" />
|
||||||
<div className="rounded-2xl bg-grayScale-100 h-96" />
|
<div className="rounded-2xl bg-grayScale-100 h-52" />
|
||||||
|
<div className="rounded-2xl bg-grayScale-100 h-52" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -141,153 +157,33 @@ export function TeamMemberDetailPage() {
|
||||||
Back to Team
|
Back to Team
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Hero Banner */}
|
<Card className="overflow-hidden">
|
||||||
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-r from-[#1a1f4e] via-[#2d2b6b] to-[#3b3480] px-6 py-12 sm:px-10 sm:py-14">
|
<div className="h-28 bg-gradient-to-r from-brand-600 via-brand-400 to-mint-500" />
|
||||||
<div className="relative z-10 max-w-2xl">
|
<CardContent className="-mt-12 px-4 sm:px-8 pb-4 sm:pb-8 pt-0">
|
||||||
<h1 className="text-3xl font-bold text-white sm:text-4xl">
|
<div className="flex flex-col items-start gap-5 sm:flex-row sm:items-end">
|
||||||
Hello {member.first_name}
|
<Avatar className="h-24 w-24 ring-4 ring-white shadow-soft">
|
||||||
</h1>
|
|
||||||
<p className="mt-3 text-sm leading-relaxed text-white/70">
|
|
||||||
This is the profile page. You can see the progress made with their
|
|
||||||
work and manage their projects or assigned tasks
|
|
||||||
</p>
|
|
||||||
<Button className="mt-5 rounded-full bg-brand-600 px-6 hover:bg-brand-500">
|
|
||||||
Edit profile
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/* Decorative circles */}
|
|
||||||
<div className="pointer-events-none absolute -right-10 -top-10 h-52 w-52 rounded-full bg-white/5" />
|
|
||||||
<div className="pointer-events-none absolute -bottom-16 right-20 h-40 w-40 rounded-full bg-white/5" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Two-column layout */}
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
|
||||||
{/* Left: My Account Card */}
|
|
||||||
<Card className="lg:col-span-2">
|
|
||||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
|
||||||
<CardTitle className="text-lg">My account</CardTitle>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="rounded-full bg-brand-600 px-5 hover:bg-brand-500"
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* User Information */}
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-4 text-xs font-semibold uppercase tracking-wider text-grayScale-400">
|
|
||||||
User Information
|
|
||||||
</h4>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<ReadOnlyField label="Username" value={member.email} />
|
|
||||||
<ReadOnlyField label="Email address" value={member.email} />
|
|
||||||
<ReadOnlyField label="First name" value={member.first_name} />
|
|
||||||
<ReadOnlyField label="Last name" value={member.last_name} />
|
|
||||||
<ReadOnlyField label="Job Title" value={member.job_title} />
|
|
||||||
<ReadOnlyField label="Department" value={member.department} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact Information */}
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-4 text-xs font-semibold uppercase tracking-wider text-grayScale-400">
|
|
||||||
Contact Information
|
|
||||||
</h4>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<ReadOnlyField label="Phone" value={member.phone_number} />
|
|
||||||
<ReadOnlyField
|
|
||||||
label="Employment Type"
|
|
||||||
value={formatEmploymentType(member.employment_type)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* About Me */}
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-4 text-xs font-semibold uppercase tracking-wider text-grayScale-400">
|
|
||||||
About Me
|
|
||||||
</h4>
|
|
||||||
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 px-3 py-3 text-sm leading-relaxed text-grayScale-600">
|
|
||||||
{member.bio || "—"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Permissions */}
|
|
||||||
{member.permissions.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-grayScale-400">
|
|
||||||
<KeyRound className="h-3.5 w-3.5" />
|
|
||||||
Permissions
|
|
||||||
</h4>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{member.permissions.map((perm) => (
|
|
||||||
<Badge
|
|
||||||
key={perm}
|
|
||||||
className="bg-grayScale-100 text-grayScale-600 border border-grayScale-200 font-mono text-xs"
|
|
||||||
>
|
|
||||||
{perm}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Right: Profile Card */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center p-6">
|
|
||||||
{/* Avatar with gradient ring */}
|
|
||||||
<div className="relative mt-2">
|
|
||||||
<div className="rounded-full bg-gradient-to-br from-brand-400 via-brand-600 to-mint-500 p-1">
|
|
||||||
<Avatar className="h-28 w-28 ring-4 ring-white">
|
|
||||||
<AvatarImage src={undefined} alt={fullName} />
|
<AvatarImage src={undefined} alt={fullName} />
|
||||||
<AvatarFallback className="bg-brand-100 text-brand-600 text-3xl font-bold">
|
<AvatarFallback className="bg-brand-100 text-brand-600 text-2xl font-bold">
|
||||||
{initials}
|
{initials}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="mt-4 text-lg font-bold text-grayScale-600">
|
<div className="flex-1 pb-1">
|
||||||
{fullName}
|
<h1 className="text-2xl font-bold text-grayScale-600">{fullName}</h1>
|
||||||
</h3>
|
<p className="mt-0.5 text-sm text-grayScale-400">{member.job_title} · {member.department}</p>
|
||||||
<p className="text-sm text-grayScale-400">{member.job_title}</p>
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div className="mt-5 flex w-full gap-3">
|
|
||||||
<Button className="flex-1 rounded-full bg-mint-500 text-white hover:bg-mint-300">
|
|
||||||
Connect
|
|
||||||
</Button>
|
|
||||||
<Button className="flex-1 rounded-full bg-grayScale-600 text-white hover:bg-grayScale-500">
|
|
||||||
<MessageCircle className="h-4 w-4" />
|
|
||||||
Message
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats row */}
|
|
||||||
<div className="mt-6 grid w-full grid-cols-3 divide-x divide-grayScale-200 text-center">
|
|
||||||
<div className="px-2">
|
|
||||||
<p className="text-xs font-medium text-grayScale-400">Role</p>
|
|
||||||
<p className="mt-1">
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold",
|
"inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold",
|
||||||
getRoleBadgeClasses(member.team_role)
|
getRoleBadgeClasses(member.team_role)
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Shield className="h-3 w-3" />
|
<Shield className="h-3 w-3" />
|
||||||
{formatRoleLabel(member.team_role)}
|
{formatRoleLabel(member.team_role)}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="px-2">
|
|
||||||
<p className="text-xs font-medium text-grayScale-400">Status</p>
|
|
||||||
<p className="mt-1">
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium",
|
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium",
|
||||||
member.status === "active"
|
member.status === "active"
|
||||||
? "bg-mint-100 text-mint-500"
|
? "bg-mint-100 text-mint-500"
|
||||||
: "bg-destructive/10 text-destructive"
|
: "bg-destructive/10 text-destructive"
|
||||||
|
|
@ -301,17 +197,136 @@ export function TeamMemberDetailPage() {
|
||||||
/>
|
/>
|
||||||
{member.status === "active" ? "Active" : "Inactive"}
|
{member.status === "active" ? "Active" : "Inactive"}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
<span className="inline-flex items-center gap-1 rounded-full bg-grayScale-100 px-2.5 py-0.5 text-xs font-medium text-grayScale-500">
|
||||||
</div>
|
|
||||||
<div className="px-2">
|
|
||||||
<p className="text-xs font-medium text-grayScale-400">Type</p>
|
|
||||||
<p className="mt-1 text-xs font-medium text-grayScale-600">
|
|
||||||
{formatEmploymentType(member.employment_type)}
|
{formatEmploymentType(member.employment_type)}
|
||||||
</p>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{member.bio && (
|
||||||
|
<div className="mt-5 rounded-xl bg-grayScale-100 p-4 text-sm leading-relaxed text-grayScale-600">
|
||||||
|
{member.bio}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-100/50">
|
||||||
|
<User className="h-4 w-4 text-brand-600" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Work Details</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-0">
|
||||||
|
<DetailRow icon={Briefcase} label="Job Title" value={member.job_title} />
|
||||||
|
<Separator />
|
||||||
|
<DetailRow icon={Building2} label="Department" value={member.department} />
|
||||||
|
<Separator />
|
||||||
|
<DetailRow icon={Globe} label="Employment" value={formatEmploymentType(member.employment_type)} />
|
||||||
|
<Separator />
|
||||||
|
<DetailRow icon={Calendar} label="Hire Date" value={formatDate(member.hire_date)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-mint-100/60">
|
||||||
|
<Mail className="h-4 w-4 text-mint-500" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Contact</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-0">
|
||||||
|
<DetailRow
|
||||||
|
icon={Mail}
|
||||||
|
label="Email"
|
||||||
|
value={member.email}
|
||||||
|
extra={
|
||||||
|
member.email_verified ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-mint-500" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-4 w-4 text-grayScale-300" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<DetailRow icon={Phone} label="Phone" value={member.phone_number} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gold-100/60">
|
||||||
|
<Shield className="h-4 w-4 text-gold-600" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Account</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-0">
|
||||||
|
<DetailRow icon={Shield} label="Role" value={formatRoleLabel(member.team_role)} />
|
||||||
|
<Separator />
|
||||||
|
<DetailRow icon={Clock} label="Last Login" value={formatDateTime(member.last_login)} />
|
||||||
|
<Separator />
|
||||||
|
<DetailRow icon={Calendar} label="Member Since" value={formatDate(member.created_at)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{member.permissions.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-100/50">
|
||||||
|
<KeyRound className="h-4 w-4 text-brand-600" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Permissions</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{member.permissions.map((perm) => (
|
||||||
|
<Badge
|
||||||
|
key={perm}
|
||||||
|
className="bg-grayScale-100 text-grayScale-600 border border-grayScale-200 font-mono text-xs"
|
||||||
|
>
|
||||||
|
{perm}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
extra,
|
||||||
|
}: {
|
||||||
|
icon: typeof User;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
extra?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-3">
|
||||||
|
<div className="flex items-center gap-3 text-sm text-grayScale-400">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-grayScale-600">
|
||||||
|
<span>{value || "—"}</span>
|
||||||
|
{extra}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
|
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
|
@ -377,7 +376,7 @@ export function UserLogPage() {
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="text-center py-12">
|
<TableCell colSpan={6} className="text-center py-12">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<img src={spinnerSrc} alt="" className="h-6 w-6 animate-spin" />
|
<RefreshCw className="h-6 w-6 animate-spin text-grayScale-300" />
|
||||||
<span className="text-sm text-grayScale-400">Loading activity logs...</span>
|
<span className="text-sm text-grayScale-400">Loading activity logs...</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
BarChart3,
|
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Calendar,
|
Calendar,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Globe,
|
Globe,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
Lock,
|
|
||||||
Mail,
|
Mail,
|
||||||
MapPin,
|
MapPin,
|
||||||
Phone,
|
Phone,
|
||||||
|
|
@ -25,22 +23,6 @@ import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { useUsersStore } from "../../zustand/userStore";
|
import { useUsersStore } from "../../zustand/userStore";
|
||||||
import { getUserById } from "../../api/users.api";
|
import { getUserById } from "../../api/users.api";
|
||||||
import { getCourseCategories, getCoursesByCategory } from "../../api/courses.api";
|
|
||||||
import {
|
|
||||||
getAdminLearnerCourseProgress,
|
|
||||||
getAdminLearnerCourseProgressSummary,
|
|
||||||
} from "../../api/progress.api";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "../../components/ui/table";
|
|
||||||
import { Select } from "../../components/ui/select";
|
|
||||||
import type { LearnerCourseProgressItem, LearnerCourseProgressSummary } from "../../types/progress.types";
|
|
||||||
import type { Course } from "../../types/course.types";
|
|
||||||
|
|
||||||
const activityIcons: Record<string, typeof CheckCircle2> = {
|
const activityIcons: Record<string, typeof CheckCircle2> = {
|
||||||
completed: CheckCircle2,
|
completed: CheckCircle2,
|
||||||
|
|
@ -48,19 +30,10 @@ const activityIcons: Record<string, typeof CheckCircle2> = {
|
||||||
joined: UserPlus,
|
joined: UserPlus,
|
||||||
};
|
};
|
||||||
|
|
||||||
type CourseOption = Course & { category_name: string };
|
|
||||||
|
|
||||||
export function UserDetailPage() {
|
export function UserDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const userProfile = useUsersStore((s) => s.userProfile);
|
const userProfile = useUsersStore((s) => s.userProfile);
|
||||||
const setUserProfile = useUsersStore((s) => s.setUserProfile);
|
const setUserProfile = useUsersStore((s) => s.setUserProfile);
|
||||||
const [courseOptions, setCourseOptions] = useState<CourseOption[]>([]);
|
|
||||||
const [loadingCourseOptions, setLoadingCourseOptions] = useState(false);
|
|
||||||
const [selectedProgressCourseId, setSelectedProgressCourseId] = useState<number | null>(null);
|
|
||||||
const [progressItems, setProgressItems] = useState<LearnerCourseProgressItem[]>([]);
|
|
||||||
const [progressSummary, setProgressSummary] = useState<LearnerCourseProgressSummary | null>(null);
|
|
||||||
const [loadingProgress, setLoadingProgress] = useState(false);
|
|
||||||
const [progressError, setProgressError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
@ -76,103 +49,6 @@ export function UserDetailPage() {
|
||||||
fetchUser();
|
fetchUser();
|
||||||
}, [id, setUserProfile]);
|
}, [id, setUserProfile]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadCourseOptions = async () => {
|
|
||||||
setLoadingCourseOptions(true);
|
|
||||||
try {
|
|
||||||
const categoriesRes = await getCourseCategories();
|
|
||||||
const categories = categoriesRes.data?.data?.categories ?? [];
|
|
||||||
const options: CourseOption[] = [];
|
|
||||||
|
|
||||||
for (const category of categories) {
|
|
||||||
const coursesRes = await getCoursesByCategory(category.id);
|
|
||||||
const courses = coursesRes.data?.data?.courses ?? [];
|
|
||||||
options.push(
|
|
||||||
...courses.map((course) => ({
|
|
||||||
...course,
|
|
||||||
category_name: category.name,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setCourseOptions(options);
|
|
||||||
if (options.length > 0 && !selectedProgressCourseId) {
|
|
||||||
setSelectedProgressCourseId(options[0].id);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setCourseOptions([]);
|
|
||||||
} finally {
|
|
||||||
setLoadingCourseOptions(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadCourseOptions();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!id || !selectedProgressCourseId) return;
|
|
||||||
|
|
||||||
const userId = Number(id);
|
|
||||||
if (Number.isNaN(userId)) return;
|
|
||||||
|
|
||||||
const loadProgress = async () => {
|
|
||||||
setLoadingProgress(true);
|
|
||||||
setProgressError(null);
|
|
||||||
try {
|
|
||||||
const [summaryRes, detailRes] = await Promise.all([
|
|
||||||
getAdminLearnerCourseProgressSummary(userId, selectedProgressCourseId),
|
|
||||||
getAdminLearnerCourseProgress(userId, selectedProgressCourseId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
setProgressSummary(summaryRes.data?.data ?? null);
|
|
||||||
const ordered = [...(detailRes.data?.data ?? [])].sort(
|
|
||||||
(a, b) => a.display_order - b.display_order || a.sub_course_id - b.sub_course_id,
|
|
||||||
);
|
|
||||||
setProgressItems(ordered);
|
|
||||||
} catch (err: any) {
|
|
||||||
setProgressSummary(null);
|
|
||||||
setProgressItems([]);
|
|
||||||
const status = err?.response?.status;
|
|
||||||
if (status === 403) {
|
|
||||||
setProgressError("Missing permission: progress.get_any_user");
|
|
||||||
} else if (status === 400) {
|
|
||||||
setProgressError("Invalid learner or course selection.");
|
|
||||||
} else {
|
|
||||||
setProgressError(err?.response?.data?.message || "Failed to load learner progress.");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoadingProgress(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadProgress();
|
|
||||||
}, [id, selectedProgressCourseId]);
|
|
||||||
|
|
||||||
const progressMetrics = useMemo(() => {
|
|
||||||
if (progressSummary) {
|
|
||||||
return {
|
|
||||||
total: progressSummary.total_sub_courses ?? 0,
|
|
||||||
completed: progressSummary.completed_sub_courses ?? 0,
|
|
||||||
inProgress: progressSummary.in_progress_sub_courses ?? 0,
|
|
||||||
locked: progressSummary.locked_sub_courses ?? 0,
|
|
||||||
averageProgress: Math.round(progressSummary.overall_progress_percentage ?? 0),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = progressItems.length;
|
|
||||||
const completed = progressItems.filter((item) => item.progress_status === "COMPLETED").length;
|
|
||||||
const inProgress = progressItems.filter((item) => item.progress_status === "IN_PROGRESS").length;
|
|
||||||
const locked = progressItems.filter((item) => item.is_locked).length;
|
|
||||||
const averageProgress =
|
|
||||||
total === 0
|
|
||||||
? 0
|
|
||||||
: Math.round(
|
|
||||||
progressItems.reduce((sum, item) => sum + Number(item.progress_percentage || 0), 0) / total,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { total, completed, inProgress, locked, averageProgress };
|
|
||||||
}, [progressItems, progressSummary]);
|
|
||||||
|
|
||||||
if (!userProfile) {
|
if (!userProfile) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-3xl space-y-4 py-12">
|
<div className="mx-auto w-full max-w-3xl space-y-4 py-12">
|
||||||
|
|
@ -211,30 +87,11 @@ export function UserDetailPage() {
|
||||||
{ icon: MapPin, label: "Region", value: user.region },
|
{ icon: MapPin, label: "Region", value: user.region },
|
||||||
];
|
];
|
||||||
|
|
||||||
const statusVariant = (status: LearnerCourseProgressItem["progress_status"]) => {
|
|
||||||
if (status === "COMPLETED") return "success" as const;
|
|
||||||
if (status === "IN_PROGRESS") return "warning" as const;
|
|
||||||
return "secondary" as const;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDateTime = (value?: string | null) => {
|
|
||||||
if (!value) return "—";
|
|
||||||
const parsed = new Date(value);
|
|
||||||
if (Number.isNaN(parsed.getTime())) return "—";
|
|
||||||
return parsed.toLocaleString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Link
|
<Link
|
||||||
to="/users/list"
|
to="/users"
|
||||||
className="inline-flex items-center gap-2 text-sm font-medium text-grayScale-500 transition-colors hover:text-brand-600"
|
className="inline-flex items-center gap-2 text-sm font-medium text-grayScale-500 transition-colors hover:text-brand-600"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
|
@ -428,132 +285,6 @@ export function UserDetailPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Learner course progress */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-sky-100/70">
|
|
||||||
<BarChart3 className="h-4 w-4 text-sky-600" />
|
|
||||||
</div>
|
|
||||||
<CardTitle>Learner Course Progress</CardTitle>
|
|
||||||
</div>
|
|
||||||
<div className="w-full sm:w-72">
|
|
||||||
<Select
|
|
||||||
value={selectedProgressCourseId ? String(selectedProgressCourseId) : ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelectedProgressCourseId(e.target.value ? Number(e.target.value) : null)
|
|
||||||
}
|
|
||||||
disabled={loadingCourseOptions || courseOptions.length === 0}
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
{loadingCourseOptions ? "Loading course sub-categories..." : "Select course sub-category..."}
|
|
||||||
</option>
|
|
||||||
{courseOptions.map((course) => (
|
|
||||||
<option key={course.id} value={course.id}>
|
|
||||||
{course.title} ({course.category_name})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-5">
|
|
||||||
<Metric label="Total Sub-courses" value={progressMetrics.total} />
|
|
||||||
<Metric label="Completed" value={progressMetrics.completed} />
|
|
||||||
<Metric label="In Progress" value={progressMetrics.inProgress} />
|
|
||||||
<Metric label="Locked" value={progressMetrics.locked} />
|
|
||||||
<Metric label="Avg Progress" value={`${progressMetrics.averageProgress}%`} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{progressError && (
|
|
||||||
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
|
||||||
{progressError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!progressError && loadingProgress && (
|
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-grayScale-200 bg-grayScale-100 px-3 py-2 text-xs text-grayScale-500">
|
|
||||||
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
Loading learner progress...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!progressError && !loadingProgress && selectedProgressCourseId && progressItems.length === 0 && (
|
|
||||||
<div className="rounded-lg border border-dashed border-grayScale-200 px-3 py-5 text-center text-xs text-grayScale-400">
|
|
||||||
No learner progress records found for this course sub-category.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!progressError && !loadingProgress && progressItems.length > 0 && (
|
|
||||||
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="bg-grayScale-100/70">
|
|
||||||
<TableHead>Course</TableHead>
|
|
||||||
<TableHead>Level</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Progress</TableHead>
|
|
||||||
<TableHead>Started</TableHead>
|
|
||||||
<TableHead>Completed</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{progressItems.map((item) => (
|
|
||||||
<TableRow key={item.sub_course_id}>
|
|
||||||
<TableCell className="min-w-[220px]">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
{item.is_locked && <Lock className="mt-0.5 h-3.5 w-3.5 text-gold-600" />}
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-grayScale-700">{item.title}</p>
|
|
||||||
{item.description && (
|
|
||||||
<p className="mt-0.5 line-clamp-1 text-xs text-grayScale-400">{item.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="secondary">{item.level}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={statusVariant(item.progress_status)}>{item.progress_status}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="min-w-[170px]">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="h-2 w-full rounded-full bg-grayScale-200">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"h-2 rounded-full transition-all",
|
|
||||||
item.progress_status === "COMPLETED"
|
|
||||||
? "bg-mint-500"
|
|
||||||
: item.progress_status === "IN_PROGRESS"
|
|
||||||
? "bg-gold-600"
|
|
||||||
: "bg-grayScale-300",
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
width: `${Math.min(100, Math.max(0, item.progress_percentage || 0))}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-grayScale-500">{item.progress_percentage}%</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-grayScale-500">
|
|
||||||
{formatDateTime(item.started_at)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-grayScale-500">
|
|
||||||
{formatDateTime(item.completed_at)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Recent activity */}
|
{/* Recent activity */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
|
|
@ -615,15 +346,6 @@ function InfoItem({ label, value }: { label: string; value: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Metric({ label, value }: { label: string; value: string | number }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 px-3 py-2">
|
|
||||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-grayScale-400">{label}</div>
|
|
||||||
<div className="mt-1 text-sm font-semibold text-grayScale-700">{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TagItem({ label, value }: { label: string; value: string }) {
|
function TagItem({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
|
|
@ -8,35 +7,10 @@ import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
List,
|
List,
|
||||||
UsersRound,
|
UsersRound,
|
||||||
Loader2,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
import { getDashboard } from "../../api/analytics.api"
|
|
||||||
import type { DashboardUsers } from "../../types/analytics.types"
|
|
||||||
|
|
||||||
export function UserManagementDashboard() {
|
export function UserManagementDashboard() {
|
||||||
const [stats, setStats] = useState<DashboardUsers | null>(null)
|
|
||||||
const [statsLoading, setStatsLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchStats = async () => {
|
|
||||||
try {
|
|
||||||
const res = await getDashboard()
|
|
||||||
const usersData = (res.data as any)?.users ?? (res.data as any)?.data?.users ?? null
|
|
||||||
setStats(usersData)
|
|
||||||
} catch {
|
|
||||||
// silently fail — cards will show "—"
|
|
||||||
} finally {
|
|
||||||
setStatsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchStats()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const formatNum = (n: number) => n.toLocaleString()
|
|
||||||
const activeUsers =
|
|
||||||
stats?.by_status?.find((item) => item.label?.toUpperCase() === "ACTIVE")?.count ?? null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
|
|
@ -56,9 +30,7 @@ export function UserManagementDashboard() {
|
||||||
</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-white/80">Total Users</p>
|
||||||
<p className="text-2xl font-bold text-white">
|
<p className="text-2xl font-bold text-white">1,248</p>
|
||||||
{statsLoading ? <Loader2 className="h-5 w-5 animate-spin text-white" /> : stats ? formatNum(stats.total_users) : "—"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -70,15 +42,7 @@ export function UserManagementDashboard() {
|
||||||
</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-white/80">Active Users</p>
|
||||||
<p className="text-2xl font-bold text-white">
|
<p className="text-2xl font-bold text-white">1,180</p>
|
||||||
{statsLoading ? (
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-white" />
|
|
||||||
) : activeUsers !== null ? (
|
|
||||||
formatNum(activeUsers)
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -90,15 +54,7 @@ export function UserManagementDashboard() {
|
||||||
</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-white/80">New This Month</p>
|
||||||
<p className="text-2xl font-bold text-white">
|
<p className="text-2xl font-bold text-white">64</p>
|
||||||
{statsLoading ? (
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-white" />
|
|
||||||
) : stats ? (
|
|
||||||
formatNum(stats.new_month)
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,13 @@
|
||||||
import { ChevronDown, ChevronLeft, ChevronRight, Search, Users, X } from "lucide-react"
|
import { ChevronDown, ChevronLeft, ChevronRight, Search, Users } from "lucide-react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"
|
||||||
import { Button } from "../../components/ui/button"
|
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { getUsers, updateUserStatus, type UserStatus } from "../../api/users.api"
|
import { getUsers } from "../../api/users.api"
|
||||||
import { mapUserApiToUser } from "../../types/user.types"
|
import { mapUserApiToUser } from "../../types/user.types"
|
||||||
import { useUsersStore } from "../../zustand/userStore"
|
import { useUsersStore } from "../../zustand/userStore"
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
export function UsersListPage() {
|
export function UsersListPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -28,25 +26,14 @@ export function UsersListPage() {
|
||||||
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||||
const [toggledStatuses, setToggledStatuses] = useState<Record<number, boolean>>({})
|
const [toggledStatuses, setToggledStatuses] = useState<Record<number, boolean>>({})
|
||||||
const [updatingStatusIds, setUpdatingStatusIds] = useState<Set<number>>(new Set())
|
const [countryFilter, setCountryFilter] = useState("")
|
||||||
const [confirmDialog, setConfirmDialog] = useState<{
|
const [regionFilter, setRegionFilter] = useState("")
|
||||||
id: number
|
const [subscriptionFilter, setSubscriptionFilter] = useState("")
|
||||||
name: string
|
|
||||||
nextStatus: UserStatus
|
|
||||||
} | null>(null)
|
|
||||||
const [roleFilter, setRoleFilter] = useState("")
|
|
||||||
const [statusFilter, setStatusFilter] = useState("")
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getUsers(
|
const res = await getUsers(page, pageSize)
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
roleFilter || undefined,
|
|
||||||
statusFilter || undefined,
|
|
||||||
search || undefined,
|
|
||||||
)
|
|
||||||
const apiUsers = res.data.data.users
|
const apiUsers = res.data.data.users
|
||||||
|
|
||||||
const mapped = apiUsers.map(mapUserApiToUser)
|
const mapped = apiUsers.map(mapUserApiToUser)
|
||||||
|
|
@ -55,7 +42,7 @@ export function UsersListPage() {
|
||||||
|
|
||||||
const initialStatuses: Record<number, boolean> = {}
|
const initialStatuses: Record<number, boolean> = {}
|
||||||
mapped.forEach((u) => {
|
mapped.forEach((u) => {
|
||||||
initialStatuses[u.id] = u.status === "ACTIVE"
|
initialStatuses[u.id] = true
|
||||||
})
|
})
|
||||||
setToggledStatuses((prev) => ({ ...prev, ...initialStatuses }))
|
setToggledStatuses((prev) => ({ ...prev, ...initialStatuses }))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -66,7 +53,7 @@ export function UsersListPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchUsers()
|
fetchUsers()
|
||||||
}, [page, pageSize, roleFilter, statusFilter, search, setUsers, setTotal])
|
}, [page, pageSize, setUsers, setTotal])
|
||||||
|
|
||||||
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||||
const safePage = Math.min(page, pageCount)
|
const safePage = Math.min(page, pageCount)
|
||||||
|
|
@ -115,46 +102,7 @@ export function UsersListPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggle = (id: number) => {
|
const handleToggle = (id: number) => {
|
||||||
if (updatingStatusIds.has(id)) return
|
setToggledStatuses((prev) => ({ ...prev, [id]: !prev[id] }))
|
||||||
const user = users.find((u) => u.id === id)
|
|
||||||
if (!user) return
|
|
||||||
|
|
||||||
const isCurrentlyActive = toggledStatuses[id] ?? false
|
|
||||||
const nextStatus: UserStatus = isCurrentlyActive ? "DEACTIVATED" : "ACTIVE"
|
|
||||||
setConfirmDialog({
|
|
||||||
id,
|
|
||||||
name: `${user.firstName} ${user.lastName}`.trim(),
|
|
||||||
nextStatus,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleConfirmStatusUpdate = async () => {
|
|
||||||
if (!confirmDialog) return
|
|
||||||
const { id, nextStatus } = confirmDialog
|
|
||||||
const nextActive = nextStatus === "ACTIVE"
|
|
||||||
const previousActive = toggledStatuses[id] ?? false
|
|
||||||
|
|
||||||
setToggledStatuses((prev) => ({ ...prev, [id]: nextActive }))
|
|
||||||
setUpdatingStatusIds((prev) => new Set(prev).add(id))
|
|
||||||
try {
|
|
||||||
await updateUserStatus({ user_id: id, status: nextStatus })
|
|
||||||
setUsers(
|
|
||||||
users.map((user) => (user.id === id ? { ...user, status: nextStatus } : user)),
|
|
||||||
)
|
|
||||||
toast.success(`User ${nextActive ? "activated" : "deactivated"} successfully`)
|
|
||||||
} catch (err: any) {
|
|
||||||
setToggledStatuses((prev) => ({ ...prev, [id]: previousActive }))
|
|
||||||
toast.error("Failed to update user status", {
|
|
||||||
description: err?.response?.data?.message || "Please try again.",
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setUpdatingStatusIds((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
next.delete(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
setConfirmDialog(null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRowClick = (userId: number) => {
|
const handleRowClick = (userId: number) => {
|
||||||
|
|
@ -186,29 +134,45 @@ export function UsersListPage() {
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="relative w-full sm:w-auto">
|
<div className="relative w-full sm:w-auto">
|
||||||
<select
|
<select
|
||||||
value={roleFilter}
|
value={countryFilter}
|
||||||
onChange={(e) => setRoleFilter(e.target.value)}
|
onChange={(e) => setCountryFilter(e.target.value)}
|
||||||
className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
>
|
>
|
||||||
<option value="">All roles</option>
|
<option value="">Country</option>
|
||||||
<option value="STUDENT">Student</option>
|
<option value="USA">USA</option>
|
||||||
<option value="TEACHER">Teacher</option>
|
<option value="UK">UK</option>
|
||||||
<option value="ADMIN">Admin</option>
|
<option value="Canada">Canada</option>
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative w-full sm:w-auto">
|
<div className="relative w-full sm:w-auto">
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={regionFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
onChange={(e) => setRegionFilter(e.target.value)}
|
||||||
className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
>
|
>
|
||||||
<option value="">All statuses</option>
|
<option value="">Region</option>
|
||||||
<option value="ACTIVE">Active</option>
|
<option value="North">North</option>
|
||||||
<option value="DEACTIVATED">Deactivated</option>
|
<option value="South">South</option>
|
||||||
<option value="SUSPENDED">Suspended</option>
|
<option value="East">East</option>
|
||||||
<option value="PENDING">Pending</option>
|
<option value="West">West</option>
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full sm:w-auto">
|
||||||
|
<select
|
||||||
|
value={subscriptionFilter}
|
||||||
|
onChange={(e) => setSubscriptionFilter(e.target.value)}
|
||||||
|
className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
|
>
|
||||||
|
<option value="">Subscription</option>
|
||||||
|
<option value="Monthly">Monthly</option>
|
||||||
|
<option value="Free">Free</option>
|
||||||
|
<option value="3-Month">3-Month</option>
|
||||||
|
<option value="6-Month">6-Month</option>
|
||||||
|
<option value="Expired">Expired</option>
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -229,7 +193,6 @@ export function UsersListPage() {
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>USER</TableHead>
|
<TableHead>USER</TableHead>
|
||||||
<TableHead className="hidden md:table-cell">Role</TableHead>
|
|
||||||
<TableHead className="hidden md:table-cell">Phone</TableHead>
|
<TableHead className="hidden md:table-cell">Phone</TableHead>
|
||||||
<TableHead className="hidden md:table-cell">Country</TableHead>
|
<TableHead className="hidden md:table-cell">Country</TableHead>
|
||||||
<TableHead className="hidden md:table-cell">Region</TableHead>
|
<TableHead className="hidden md:table-cell">Region</TableHead>
|
||||||
|
|
@ -240,7 +203,7 @@ export function UsersListPage() {
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{users.length === 0 ? (
|
{users.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="py-16 text-center">
|
<TableCell colSpan={6} className="py-16 text-center">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-grayScale-100">
|
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-grayScale-100">
|
||||||
<Users className="h-7 w-7 text-grayScale-400" />
|
<Users className="h-7 w-7 text-grayScale-400" />
|
||||||
|
|
@ -255,7 +218,6 @@ export function UsersListPage() {
|
||||||
) : (
|
) : (
|
||||||
users.map((u) => {
|
users.map((u) => {
|
||||||
const isActive = toggledStatuses[u.id] ?? false
|
const isActive = toggledStatuses[u.id] ?? false
|
||||||
const isUpdatingStatus = updatingStatusIds.has(u.id)
|
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={u.id}
|
key={u.id}
|
||||||
|
|
@ -284,7 +246,6 @@ export function UsersListPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden md:table-cell text-grayScale-500">{u.role || "-"}</TableCell>
|
|
||||||
<TableCell className="hidden md:table-cell text-grayScale-500">{u.phoneNumber || "-"}</TableCell>
|
<TableCell className="hidden md:table-cell text-grayScale-500">{u.phoneNumber || "-"}</TableCell>
|
||||||
<TableCell className="hidden md:table-cell text-grayScale-500">{u.country || "-"}</TableCell>
|
<TableCell className="hidden md:table-cell text-grayScale-500">{u.country || "-"}</TableCell>
|
||||||
<TableCell className="hidden md:table-cell text-grayScale-500">{u.region || "-"}</TableCell>
|
<TableCell className="hidden md:table-cell text-grayScale-500">{u.region || "-"}</TableCell>
|
||||||
|
|
@ -292,19 +253,14 @@ export function UsersListPage() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleToggle(u.id)}
|
onClick={() => handleToggle(u.id)}
|
||||||
disabled={isUpdatingStatus}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex h-7 w-12 shrink-0 cursor-pointer items-center rounded-full border p-0.5 transition-all duration-200",
|
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-300 focus-visible:ring-offset-1",
|
isActive ? "bg-brand-500" : "bg-grayScale-200"
|
||||||
isActive
|
|
||||||
? "border-brand-500 bg-brand-500 shadow-[0_6px_16px_rgba(168,85,247,0.35)]"
|
|
||||||
: "border-grayScale-300 bg-grayScale-200 hover:bg-grayScale-300/80",
|
|
||||||
isUpdatingStatus && "cursor-not-allowed opacity-60",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-md ring-0 transition-transform duration-200 ease-out",
|
"pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-sm ring-0 transition-transform",
|
||||||
isActive ? "translate-x-5" : "translate-x-0"
|
isActive ? "translate-x-5" : "translate-x-0"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -388,41 +344,6 @@ export function UsersListPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{confirmDialog && (
|
|
||||||
<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-sm rounded-xl bg-white shadow-2xl">
|
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Confirm Status Change</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => setConfirmDialog(null)}
|
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-6">
|
|
||||||
<p className="text-sm leading-relaxed text-grayScale-600">
|
|
||||||
Are you sure you want to change the status of{" "}
|
|
||||||
<span className="font-semibold">{confirmDialog.name || "this user"}</span> to{" "}
|
|
||||||
<span className="font-semibold capitalize">{confirmDialog.nextStatus.toLowerCase()}</span>?
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
|
||||||
<Button variant="outline" onClick={() => setConfirmDialog(null)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="bg-brand-600 text-white hover:bg-brand-500"
|
|
||||||
onClick={handleConfirmStatusUpdate}
|
|
||||||
disabled={updatingStatusIds.has(confirmDialog.id)}
|
|
||||||
>
|
|
||||||
{updatingStatusIds.has(confirmDialog.id) ? "Updating..." : "Confirm"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -318,8 +318,6 @@ export interface PracticeQuestion {
|
||||||
id: number
|
id: number
|
||||||
practice_id: number
|
practice_id: number
|
||||||
question: string
|
question: string
|
||||||
points?: number
|
|
||||||
difficulty_level?: string
|
|
||||||
question_voice_prompt: string
|
question_voice_prompt: string
|
||||||
sample_answer_voice_prompt: string
|
sample_answer_voice_prompt: string
|
||||||
sample_answer: string
|
sample_answer: string
|
||||||
|
|
@ -345,11 +343,6 @@ export interface CreatePracticeQuestionRequest {
|
||||||
sample_answer_voice_prompt?: string
|
sample_answer_voice_prompt?: string
|
||||||
sample_answer: string
|
sample_answer: string
|
||||||
tips?: string
|
tips?: string
|
||||||
explanation?: string
|
|
||||||
difficulty_level?: string
|
|
||||||
points?: number
|
|
||||||
options?: QuestionOption[]
|
|
||||||
short_answers?: string[]
|
|
||||||
type: "MCQ" | "TRUE_FALSE" | "SHORT"
|
type: "MCQ" | "TRUE_FALSE" | "SHORT"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -359,11 +352,6 @@ export interface UpdatePracticeQuestionRequest {
|
||||||
sample_answer_voice_prompt?: string
|
sample_answer_voice_prompt?: string
|
||||||
sample_answer: string
|
sample_answer: string
|
||||||
tips?: string
|
tips?: string
|
||||||
explanation?: string
|
|
||||||
difficulty_level?: string
|
|
||||||
points?: number
|
|
||||||
options?: QuestionOption[]
|
|
||||||
short_answers?: string[]
|
|
||||||
type: "MCQ" | "TRUE_FALSE" | "SHORT"
|
type: "MCQ" | "TRUE_FALSE" | "SHORT"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -387,65 +375,7 @@ export interface QuestionSet {
|
||||||
|
|
||||||
export interface GetQuestionSetsResponse {
|
export interface GetQuestionSetsResponse {
|
||||||
message: string
|
message: string
|
||||||
data: QuestionSet[] | { question_sets: QuestionSet[]; total_count?: number }
|
data: QuestionSet[]
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetQuestionSetsParams {
|
|
||||||
set_type?: "PRACTICE" | "INITIAL_ASSESSMENT" | "EXAM" | string
|
|
||||||
owner_type?: "SUB_COURSE" | "COURSE" | string
|
|
||||||
owner_id?: number
|
|
||||||
status?: "DRAFT" | "PUBLISHED" | "ARCHIVED" | string
|
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuestionSetDetail {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
set_type: string
|
|
||||||
owner_type: string
|
|
||||||
owner_id: number
|
|
||||||
banner_image?: string | null
|
|
||||||
persona?: string | null
|
|
||||||
time_limit_minutes?: number | null
|
|
||||||
passing_score?: number | null
|
|
||||||
shuffle_questions?: boolean
|
|
||||||
status: string
|
|
||||||
sub_course_video_id?: number | null
|
|
||||||
created_at: string
|
|
||||||
question_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetQuestionSetDetailResponse {
|
|
||||||
message: string
|
|
||||||
data: QuestionSetDetail
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuestionSetQuestion {
|
|
||||||
id: number
|
|
||||||
set_id: number
|
|
||||||
question_id: number
|
|
||||||
display_order: number
|
|
||||||
question_text: string
|
|
||||||
question_type: "MCQ" | "TRUE_FALSE" | "SHORT" | string
|
|
||||||
difficulty_level?: string | null
|
|
||||||
points?: number
|
|
||||||
explanation?: string | null
|
|
||||||
tips?: string | null
|
|
||||||
voice_prompt?: string | null
|
|
||||||
question_status?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetQuestionSetQuestionsResponse {
|
|
||||||
message: string
|
|
||||||
data: QuestionSetQuestion[]
|
|
||||||
success: boolean
|
success: boolean
|
||||||
status_code: number
|
status_code: number
|
||||||
metadata: unknown
|
metadata: unknown
|
||||||
|
|
@ -466,7 +396,7 @@ export interface CreateQuestionSetRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddQuestionToSetRequest {
|
export interface AddQuestionToSetRequest {
|
||||||
display_order?: number
|
display_order: number
|
||||||
question_id: number
|
question_id: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -479,16 +409,15 @@ export interface QuestionOption {
|
||||||
export interface CreateQuestionRequest {
|
export interface CreateQuestionRequest {
|
||||||
question_text: string
|
question_text: string
|
||||||
question_type: string
|
question_type: string
|
||||||
difficulty_level?: string
|
difficulty_level: string
|
||||||
points?: number
|
points: number
|
||||||
tips?: string
|
tips?: string
|
||||||
explanation?: string
|
explanation?: string
|
||||||
status?: string
|
status?: string
|
||||||
options?: QuestionOption[]
|
options?: QuestionOption[]
|
||||||
voice_prompt?: string
|
voice_prompt?: string
|
||||||
sample_answer_voice_prompt?: string
|
sample_answer_voice_prompt?: string
|
||||||
audio_correct_answer_text?: string
|
short_answers?: string[]
|
||||||
short_answers?: string[] | { acceptable_answer: string; match_type: "EXACT" | "CASE_INSENSITIVE" }[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateQuestionResponse {
|
export interface CreateQuestionResponse {
|
||||||
|
|
@ -501,52 +430,6 @@ export interface CreateQuestionResponse {
|
||||||
metadata: unknown
|
metadata: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuestionShortAnswer {
|
|
||||||
acceptable_answer: string
|
|
||||||
match_type?: "EXACT" | "CASE_INSENSITIVE" | string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuestionDetail {
|
|
||||||
id: number
|
|
||||||
question_text: string
|
|
||||||
question_type: "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "SHORT" | string
|
|
||||||
difficulty_level?: string | null
|
|
||||||
points?: number | null
|
|
||||||
status?: string
|
|
||||||
created_at?: string
|
|
||||||
options?: ({ id?: number } & QuestionOption)[]
|
|
||||||
short_answers?: string[] | QuestionShortAnswer[]
|
|
||||||
tips?: string | null
|
|
||||||
explanation?: string | null
|
|
||||||
voice_prompt?: string | null
|
|
||||||
sample_answer_voice_prompt?: string | null
|
|
||||||
audio_correct_answer_text?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetQuestionDetailResponse {
|
|
||||||
message: string
|
|
||||||
data: QuestionDetail
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetQuestionsParams {
|
|
||||||
question_type?: "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO" | string
|
|
||||||
difficulty?: "EASY" | "MEDIUM" | "HARD" | string
|
|
||||||
status?: "DRAFT" | "PUBLISHED" | "INACTIVE" | string
|
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetQuestionsResponse {
|
|
||||||
message: string
|
|
||||||
data: QuestionDetail[] | { questions: QuestionDetail[]; total_count?: number }
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateQuestionSetResponse {
|
export interface CreateQuestionSetResponse {
|
||||||
message: string
|
message: string
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -556,145 +439,3 @@ export interface CreateQuestionSetResponse {
|
||||||
status_code: number
|
status_code: number
|
||||||
metadata: unknown
|
metadata: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sub-course Prerequisites
|
|
||||||
export interface SubCoursePrerequisite {
|
|
||||||
id: number
|
|
||||||
sub_course_id: number
|
|
||||||
prerequisite_sub_course_id: number
|
|
||||||
prerequisite_title: string
|
|
||||||
prerequisite_level: string
|
|
||||||
prerequisite_display_order: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetSubCoursePrerequisitesResponse {
|
|
||||||
message: string
|
|
||||||
data: SubCoursePrerequisite[]
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AddSubCoursePrerequisiteRequest {
|
|
||||||
prerequisite_sub_course_id: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Learning Path (full tree from GET /courses/:courseId/learning-path)
|
|
||||||
export interface LearningPathSubCourse {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
thumbnail: string
|
|
||||||
display_order: number
|
|
||||||
level: string
|
|
||||||
sub_level?: string
|
|
||||||
prerequisite_count: number
|
|
||||||
video_count: number
|
|
||||||
practice_count: number
|
|
||||||
prerequisites: { sub_course_id: number; title: string; level: string }[]
|
|
||||||
videos: LearningPathVideo[]
|
|
||||||
practices: LearningPathPractice[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LearningPathVideo {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
display_order: number
|
|
||||||
duration: number
|
|
||||||
video_url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LearningPathPractice {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
status: string
|
|
||||||
question_count: number
|
|
||||||
display_order?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LearningPath {
|
|
||||||
course_id: number
|
|
||||||
course_title: string
|
|
||||||
description: string
|
|
||||||
thumbnail: string
|
|
||||||
intro_video_url: string
|
|
||||||
category_id: number
|
|
||||||
category_name: string
|
|
||||||
sub_courses: LearningPathSubCourse[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetLearningPathResponse {
|
|
||||||
message: string
|
|
||||||
data: LearningPath
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetSubCourseEntryAssessmentResponse {
|
|
||||||
message: string
|
|
||||||
data: QuestionSet | null
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReorderItem {
|
|
||||||
id: number
|
|
||||||
position: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ratings
|
|
||||||
export interface Rating {
|
|
||||||
id: number
|
|
||||||
user_id: number
|
|
||||||
target_type: string
|
|
||||||
target_id: number
|
|
||||||
stars: number
|
|
||||||
review: string
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetRatingsParams {
|
|
||||||
target_type: string
|
|
||||||
target_id: number
|
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetRatingsResponse {
|
|
||||||
message: string
|
|
||||||
data: Rating[]
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vimeo Sample Video
|
|
||||||
export interface VimeoSampleVideo {
|
|
||||||
vimeo_id: string
|
|
||||||
uri: string
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
duration: number
|
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
link: string
|
|
||||||
embed_url: string
|
|
||||||
embed_html: string
|
|
||||||
thumbnail_url: string
|
|
||||||
status: string
|
|
||||||
transcode_status: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetVimeoSampleResponse {
|
|
||||||
message: string
|
|
||||||
data: {
|
|
||||||
video: VimeoSampleVideo
|
|
||||||
iframe: string
|
|
||||||
}
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -32,21 +32,6 @@ export interface GetIssueResponse {
|
||||||
metadata: null;
|
metadata: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateIssueRequest {
|
|
||||||
subject: string;
|
|
||||||
description: string;
|
|
||||||
issue_type: string;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateIssueResponse {
|
|
||||||
message: string;
|
|
||||||
data: Issue;
|
|
||||||
success: boolean;
|
|
||||||
status_code: number;
|
|
||||||
metadata: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateIssueStatusResponse {
|
export interface UpdateIssueStatusResponse {
|
||||||
message: string;
|
message: string;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
export interface NotificationPayload {
|
export interface NotificationPayload {
|
||||||
headline?: string
|
headline: string
|
||||||
title?: string
|
message: string
|
||||||
message?: string
|
|
||||||
body?: string
|
|
||||||
tags: string[] | null
|
tags: string[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,28 +20,6 @@ export interface Notification {
|
||||||
image: string
|
image: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNotificationTitle(notification: Notification): string {
|
|
||||||
const payload: any = notification?.payload ?? {}
|
|
||||||
return (
|
|
||||||
payload.headline ??
|
|
||||||
payload.title ??
|
|
||||||
(notification as any)?.headline ??
|
|
||||||
(notification as any)?.title ??
|
|
||||||
""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getNotificationMessage(notification: Notification): string {
|
|
||||||
const payload: any = notification?.payload ?? {}
|
|
||||||
return (
|
|
||||||
payload.message ??
|
|
||||||
payload.body ??
|
|
||||||
(notification as any)?.message ??
|
|
||||||
(notification as any)?.body ??
|
|
||||||
""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetNotificationsResponse {
|
export interface GetNotificationsResponse {
|
||||||
notifications: Notification[]
|
notifications: Notification[]
|
||||||
total_count: number
|
total_count: number
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
export type LearnerCourseProgressStatus = "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED"
|
|
||||||
|
|
||||||
export interface LearnerCourseProgressItem {
|
|
||||||
sub_course_id: number
|
|
||||||
title: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
display_order: number
|
|
||||||
level: string
|
|
||||||
progress_status: LearnerCourseProgressStatus
|
|
||||||
progress_percentage: number
|
|
||||||
started_at?: string | null
|
|
||||||
completed_at?: string | null
|
|
||||||
is_locked: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LearnerCourseProgressResponse {
|
|
||||||
message: string
|
|
||||||
data: LearnerCourseProgressItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LearnerCourseProgressSummary {
|
|
||||||
course_id: number
|
|
||||||
learner_user_id: number
|
|
||||||
overall_progress_percentage: number
|
|
||||||
total_sub_courses: number
|
|
||||||
completed_sub_courses: number
|
|
||||||
in_progress_sub_courses: number
|
|
||||||
not_started_sub_courses: number
|
|
||||||
locked_sub_courses: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LearnerCourseProgressSummaryResponse {
|
|
||||||
message: string
|
|
||||||
data: LearnerCourseProgressSummary
|
|
||||||
}
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
export interface Role {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
is_system: boolean
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RolePermission {
|
|
||||||
id: number
|
|
||||||
key: string
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
group_name: string
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoleDetail extends Role {
|
|
||||||
permissions: RolePermission[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetRolesResponse {
|
|
||||||
message: string
|
|
||||||
data: {
|
|
||||||
roles: Role[]
|
|
||||||
total: number
|
|
||||||
page: number
|
|
||||||
page_size: number
|
|
||||||
}
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetRoleDetailResponse {
|
|
||||||
message: string
|
|
||||||
data: RoleDetail
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetRolesParams {
|
|
||||||
query?: string
|
|
||||||
is_system?: boolean
|
|
||||||
page?: number
|
|
||||||
page_size?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateRoleRequest {
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateRoleResponse {
|
|
||||||
message: string
|
|
||||||
data: Role
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SetRolePermissionsRequest {
|
|
||||||
permission_ids: number[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetPermissionsResponse {
|
|
||||||
message: string
|
|
||||||
data: Record<string, RolePermission[]>
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
@ -50,11 +50,9 @@ export interface User {
|
||||||
nickName: string
|
nickName: string
|
||||||
email: string
|
email: string
|
||||||
phoneNumber: string
|
phoneNumber: string
|
||||||
role: string
|
|
||||||
region: string
|
region: string
|
||||||
country: string
|
country: string
|
||||||
lastLogin: string | null
|
lastLogin: string | null
|
||||||
status: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mapUserApiToUser = (u: UserApiDTO): User => ({
|
export const mapUserApiToUser = (u: UserApiDTO): User => ({
|
||||||
|
|
@ -64,11 +62,9 @@ export const mapUserApiToUser = (u: UserApiDTO): User => ({
|
||||||
nickName: u.nick_name,
|
nickName: u.nick_name,
|
||||||
email: u.email,
|
email: u.email,
|
||||||
phoneNumber: u.phone_number ?? "",
|
phoneNumber: u.phone_number ?? "",
|
||||||
role: u.role,
|
|
||||||
region: u.region,
|
region: u.region,
|
||||||
country: u.country,
|
country: u.country,
|
||||||
lastLogin: null,
|
lastLogin: null,
|
||||||
status: u.status,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export interface UserProfileData {
|
export interface UserProfileData {
|
||||||
|
|
@ -114,35 +110,3 @@ export interface UserProfileResponse {
|
||||||
data: UserProfileData
|
data: UserProfileData
|
||||||
timestamp: string
|
timestamp: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserSummary {
|
|
||||||
total_users: number
|
|
||||||
active_users: number
|
|
||||||
joined_this_month: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserSummaryResponse {
|
|
||||||
message: string
|
|
||||||
data: UserSummary
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateProfileRequest {
|
|
||||||
first_name?: string
|
|
||||||
last_name?: string
|
|
||||||
gender?: string
|
|
||||||
birth_day?: string
|
|
||||||
age_group?: string
|
|
||||||
education_level?: string
|
|
||||||
country?: string
|
|
||||||
region?: string
|
|
||||||
nick_name?: string
|
|
||||||
occupation?: string
|
|
||||||
learning_goal?: string
|
|
||||||
language_goal?: string
|
|
||||||
language_challange?: string
|
|
||||||
favoutite_topic?: string
|
|
||||||
profile_picture_url?: string
|
|
||||||
preferred_language?: string
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user