Compare commits

..

No commits in common. "main" and "front" have entirely different histories.
main ... front

57 changed files with 2624 additions and 8301 deletions

5
.env
View File

@ -1,3 +1,2 @@
# VITE_API_BASE_URL=https://api.yimaru.yaltopia.com/api/v1
VITE_API_BASE_URL=http://localhost:8432/api/v1
VITE_GOOGLE_CLIENT_ID=
VITE_API_BASE_URL= https://api.yimaru.yaltopia.com/api/v1
VITE_GOOGLE_CLIENT_ID=google_client_id

3
.gitignore vendored
View File

@ -12,9 +12,6 @@ dist
dist-ssr
*.local
# Environment files
.env
# Editor directories and files
.vscode/*
!.vscode/extensions.json

56
package-lock.json generated
View File

@ -8,9 +8,6 @@
"name": "yimaru-admin",
"version": "0.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/inter": "^5.2.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
@ -342,59 +339,6 @@
"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": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",

View File

@ -10,9 +10,6 @@
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/inter": "^5.2.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",

View File

@ -15,6 +15,7 @@ import type {
CreatePracticeRequest,
UpdatePracticeRequest,
UpdatePracticeStatusRequest,
GetPracticeQuestionsResponse,
CreatePracticeQuestionRequest,
UpdatePracticeQuestionRequest,
GetProgramsResponse,
@ -30,27 +31,13 @@ import type {
UpdateModuleRequest,
UpdateModuleStatusRequest,
GetQuestionSetsResponse,
GetQuestionSetsParams,
GetQuestionSetDetailResponse,
GetQuestionSetQuestionsResponse,
CreateQuestionSetRequest,
CreateQuestionSetResponse,
AddQuestionToSetRequest,
CreateQuestionRequest,
CreateQuestionResponse,
GetQuestionDetailResponse,
GetQuestionsParams,
GetQuestionsResponse,
CreateVimeoVideoRequest,
CreateCourseCategoryRequest,
GetSubCoursePrerequisitesResponse,
AddSubCoursePrerequisiteRequest,
GetLearningPathResponse,
GetSubCourseEntryAssessmentResponse,
ReorderItem,
GetRatingsResponse,
GetRatingsParams,
GetVimeoSampleResponse,
} from "../types/course.types"
export const getCourseCategories = () =>
@ -104,11 +91,8 @@ export const deleteSubCourseVideo = (videoId: number) =>
http.delete(`/course-management/sub-course-videos/${videoId}`)
// Practice APIs - for SubCourse practices (New Hierarchy)
// Practices are sourced from question sets by owner_type=SUB_COURSE.
export const getPracticesBySubCourse = (subCourseId: number) =>
http.get<GetQuestionSetsResponse>("/question-sets/by-owner", {
params: { owner_type: "SUB_COURSE", owner_id: subCourseId },
})
http.get<GetPracticesResponse>(`/course-management/sub-courses/${subCourseId}/practices`)
export const createPractice = (data: CreatePracticeRequest) =>
http.post("/course-management/practices", data)
@ -124,7 +108,7 @@ export const deletePractice = (practiceId: number) =>
// Practice Questions APIs
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) =>
http.post("/course-management/practice-questions", data)
@ -192,20 +176,11 @@ export const getPracticesByModule = (moduleId: number) =>
http.get<GetPracticesResponse>(`/course-management/modules/${moduleId}/practices`)
// Question Sets API
export const getQuestionSets = (params?: GetQuestionSetsParams) =>
http.get<GetQuestionSetsResponse>("/question-sets", { params })
export const getQuestionSetsByOwner = (ownerType: string, ownerId: number) =>
http.get<GetQuestionSetsResponse>("/question-sets/by-owner", {
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) =>
http.post<CreateQuestionSetResponse>("/question-sets", data)
@ -215,85 +190,8 @@ export const addQuestionToSet = (questionSetId: number, data: AddQuestionToSetRe
export const createQuestion = (data: CreateQuestionRequest) =>
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) =>
http.delete(`/question-sets/${questionSetId}`)
export const createVimeoVideo = (data: CreateVimeoVideoRequest) =>
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 },
})

View File

@ -7,8 +7,6 @@ import type {
IssueFilters,
} from "../types/issue.types";
import type { CreateIssueRequest, CreateIssueResponse } from "../types/issue.types";
export const getIssues = (filters?: IssueFilters) =>
http.get<GetIssuesResponse>("/issues", {
params: filters,
@ -20,9 +18,6 @@ export const getIssuesByUserId = (userId: number) =>
export const getIssueById = (id: number) =>
http.get<GetIssueResponse>(`/issues/${id}`);
export const createIssue = (payload: CreateIssueRequest) =>
http.post<CreateIssueResponse>("/issues", payload);
export const updateIssueStatus = (id: number, status: string) =>
http.patch<UpdateIssueStatusResponse>(`/issues/${id}/status`, { status });

View File

@ -20,16 +20,3 @@ export const markAllRead = () =>
export const markAllUnread = () =>
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" },
});

View File

@ -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`,
)

View File

@ -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")

View File

@ -14,6 +14,3 @@ export const getTeamMemberById = (id: number) =>
export const createTeamMember = (data: CreateTeamMemberRequest) =>
http.post("/team/register", data)
export const updateTeamMemberStatus = (id: number, status: string) =>
http.patch(`/team/members/${id}/status`, { status })

View File

@ -1,33 +1,14 @@
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 = (
page?: number,
pageSize?: number,
role?: string,
status?: string,
query?: string,
) =>
export const getUsers = (page?: number, pageSize?: number) =>
http.get<GetUsersResponse>("/users", {
params: {
role,
status,
query,
page,
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) =>
http.get<UserProfileResponse>(`/user/single/${id}`);
@ -47,9 +28,3 @@ export interface CreateUserRequest {
export const createUser = (payload: CreateUserRequest) =>
http.post("/users", payload);
export const updateProfile = (data: UpdateProfileRequest) =>
http.put<UserProfileResponse>("/user", data);
export const getUserSummary = () =>
http.get<UserSummaryResponse>("/users/summary");

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1,17 +1,15 @@
import logoSrc from "../../assets/logo.svg";
export function BrandLogo({
className = "h-10",
variant = "dark",
}: {
className?: string;
variant?: "light" | "dark";
}) {
export function BrandLogo() {
return (
<img
src={logoSrc}
alt="Yimaru Academy"
className={`${className} ${variant === "dark" ? "brightness-0" : ""}`}
/>
);
<div className="flex items-center gap-2">
<div className="grid h-9 w-9 place-items-center rounded-lg bg-brand-500 text-white">
<div className="h-4 w-4 rotate-45 rounded-[3px] bg-white/90" />
</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>
)
}

View File

@ -2,8 +2,6 @@ import {
BarChart3,
Bell,
BookOpen,
ChevronLeft,
ChevronRight,
CircleAlert,
ClipboardList,
LayoutDashboard,
@ -41,12 +39,10 @@ const navItems: NavItem[] = [
type SidebarProps = {
isOpen: boolean
isCollapsed: boolean
onToggleCollapse: () => void
onClose: () => void
}
export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: SidebarProps) {
export function Sidebar({ isOpen, onClose }: SidebarProps) {
const [unreadCount, setUnreadCount] = useState(0)
useEffect(() => {
@ -80,31 +76,12 @@ export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: Side
{/* Sidebar panel */}
<aside
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",
"w-[264px] px-4 lg:translate-x-0",
isCollapsed && "lg:w-[88px] lg:px-2",
"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",
isOpen ? "translate-x-0" : "-translate-x-full",
)}
>
<div className={cn("flex items-center justify-between px-2", isCollapsed && "justify-center")}>
{isCollapsed ? (
<span className="h-10 w-10 overflow-hidden">
<BrandLogo className="h-10 w-auto max-w-none" />
</span>
) : (
<div className="flex items-center justify-between px-2">
<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
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"
@ -126,37 +103,32 @@ export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: Side
className={({ isActive }) =>
cn(
"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",
isActive &&
"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 }) => (
<>
<span
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",
isActive && "bg-brand-500/90 text-white",
"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 text-white",
)}
>
<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>
{!isCollapsed && <span className="truncate">{item.label}</span>}
{!isCollapsed && item.to === "/notifications" && unreadCount > 0 && (
<span className="truncate">{item.label}</span>
{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">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
{!isCollapsed && item.to !== "/notifications" && isActive ? (
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
) : !isCollapsed && item.to === "/notifications" && unreadCount === 0 && isActive ? (
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
{item.to !== "/notifications" && isActive ? (
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500" />
) : item.to === "/notifications" && unreadCount === 0 && isActive ? (
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500" />
) : null}
</>
)}
@ -172,14 +144,10 @@ export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: Side
localStorage.clear()
window.location.href = "/login"
}}
className={cn(
"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}
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"
>
<LogOut className="h-4 w-4" />
{!isCollapsed && "Logout"}
Logout
</button>
</div>
</aside>

View File

@ -20,7 +20,7 @@ import {
import { Badge } from "../ui/badge"
import { cn } from "../../lib/utils"
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 }> = {
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
@ -105,10 +105,10 @@ function NotificationItem({
!notification.is_read && "font-semibold"
)}
>
{getNotificationTitle(notification)}
{notification.payload.headline}
</p>
<p className="mt-0.5 line-clamp-2 text-xs text-grayScale-500">
{getNotificationMessage(notification)}
{notification.payload.message}
</p>
<p className="mt-1 text-[11px] text-grayScale-400">
{formatTimestamp(notification.timestamp)}
@ -170,14 +170,11 @@ export function NotificationDropdown() {
{/* Bell button */}
<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"
onClick={() => setOpen((prev) => !prev)}
>
<Bell className="h-5 w-5" />
<span className="hidden text-xs font-medium text-grayScale-600 sm:inline">
Notifications
</span>
{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">
{unreadCount > 99 ? "99+" : unreadCount}

View File

@ -9,10 +9,10 @@ import { cn } from "../../lib/utils"
import { NotificationDropdown } from "./NotificationDropdown"
type TopbarProps = {
onSidebarToggle: () => void
onMenuClick: () => void
}
export function Topbar({ onSidebarToggle }: TopbarProps) {
export function Topbar({ onMenuClick }: TopbarProps) {
const navigate = useNavigate()
const [shortName, setShortName] = useState("AA")
@ -46,11 +46,11 @@ export function Topbar({ onSidebarToggle }: TopbarProps) {
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">
{/* Sidebar toggle */}
{/* Mobile hamburger */}
<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"
onClick={onSidebarToggle}
onClick={onMenuClick}
aria-label="Open menu"
>
<Menu className="h-5 w-5" />

View File

@ -5,15 +5,14 @@ import { Topbar } from "../components/topbar/Topbar"
export function AppLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const token = localStorage.getItem("access_token")
if (!token) {
return <Navigate to="/login" replace />
}
const handleSidebarToggle = useCallback(() => {
setSidebarOpen((prev) => !prev)
const handleMenuClick = useCallback(() => {
setSidebarOpen(true)
}, [])
const handleSidebarClose = useCallback(() => {
@ -22,18 +21,9 @@ export function AppLayout() {
return (
<div className="flex min-h-screen bg-grayScale-100">
<Sidebar
isOpen={sidebarOpen}
isCollapsed={sidebarCollapsed}
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} />
<Sidebar isOpen={sidebarOpen} onClose={handleSidebarClose} />
<div className="flex min-w-0 flex-1 flex-col lg:ml-[264px]">
<Topbar onMenuClick={handleMenuClick} />
<main className="min-w-0 flex-1 overflow-y-auto px-4 pb-8 pt-4 lg:px-6">
<Outlet />
</main>
@ -41,12 +31,12 @@ export function AppLayout() {
<div className="flex items-center justify-center gap-1.5 text-xs text-grayScale-400">
<span>Powered by</span>
<a
href="https://tech.yaltopia.com"
href="https://yaltopia.com"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-brand-500 transition-colors hover:text-brand-600"
>
Yaltopia Tech
Yaltopia
</a>
<span>·</span>
<span>© {new Date().getFullYear()}</span>

View File

@ -5,15 +5,12 @@ import {
// Coins,
DollarSign,
HelpCircle,
MessageSquare,
Star,
TicketCheck,
// TrendingUp,
Users,
Bell,
UsersRound,
} from "lucide-react"
import spinnerSrc from "../assets/Circular-indeterminate progress indicator.svg"
import {
Area,
AreaChart,
@ -29,15 +26,12 @@ import {
YAxis,
} from "recharts"
import { StatCard } from "../components/dashboard/StatCard"
import alertSrc from "../assets/Alert.svg"
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
import { cn } from "../lib/utils"
import { getTeamMemberById } from "../api/team.api"
import { getDashboard } from "../api/analytics.api"
import { getRatings } from "../api/courses.api"
import { useEffect, useState } from "react"
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"]
@ -51,8 +45,6 @@ export function DashboardPage() {
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true)
const [activeStatTab, setActiveStatTab] = useState<"primary" | "secondary">("primary")
const [appRatings, setAppRatings] = useState<Rating[]>([])
const [appRatingsLoading, setAppRatingsLoading] = useState(true)
useEffect(() => {
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()
fetchDashboard()
fetchAppRatings()
}, [])
const registrationData =
@ -131,15 +111,9 @@ export function DashboardPage() {
</div>
{loading ? (
<div className="flex flex-col items-center justify-center gap-3 py-20">
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
<span className="text-sm font-medium text-grayScale-400">Loading dashboard</span>
</div>
<div className="flex items-center justify-center py-20 text-grayScale-500">Loading dashboard</div>
) : !dashboard ? (
<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 font-medium text-destructive">Failed to load dashboard data.</span>
</div>
<div className="flex items-center justify-center py-20 text-destructive">Failed to load dashboard data.</div>
) : (
<>
{/* Stat tabs */}
@ -427,90 +401,6 @@ export function DashboardPage() {
</Card>
))}
</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>
</>
)}

View File

@ -4,33 +4,23 @@ import {
CheckCircle2,
Clock,
Globe,
Loader2,
// GraduationCap,
Languages,
Mail,
MapPin,
Pencil,
Phone,
Save,
Shield,
User,
X,
XCircle,
Briefcase,
BookOpen,
Target,
Languages,
Heart,
MessageCircle,
// RefreshCw,
} from "lucide-react";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card, CardContent } from "../components/ui/card";
import { Input } from "../components/ui/input";
import { Select } from "../components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
import { cn } from "../lib/utils";
import { getMyProfile, updateProfile } from "../api/users.api";
import type { UserProfileData, UpdateProfileRequest } from "../types/user.types";
import { toast } from "sonner";
import { getMyProfile } from "../api/users.api";
import type { UserProfileData } from "../types/user.types";
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return "—";
@ -54,10 +44,11 @@ function formatDateTime(dateStr: string | null | undefined): string {
function LoadingSkeleton() {
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">
{/* Hero skeleton */}
<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="-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" />
@ -69,6 +60,7 @@ function LoadingSkeleton() {
</div>
</div>
</div>
{/* Info cards skeleton */}
<div className="grid gap-6 md:grid-cols-3">
{[1, 2, 3].map((i) => (
<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 }) {
return verified ? (
<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 }) {
const radius = 18;
const radius = 14;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (percent / 100) * circumference;
return (
<div className="relative inline-flex items-center justify-center">
<svg className="h-12 w-12 -rotate-90" viewBox="0 0 44 44">
<svg className="h-8 w-8 -rotate-90" viewBox="0 0 44 44">
<circle
cx="22"
cy="22"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeWidth="3"
className="text-grayScale-200"
/>
<circle
@ -124,51 +143,14 @@ function ProgressRing({ percent }: { percent: number }) {
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeWidth="3"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
className="text-brand-500 transition-all duration-700"
/>
</svg>
<span className="absolute text-[10px] 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>
<span className="absolute text-[9px] font-bold text-brand-600">{percent}%</span>
</div>
);
}
@ -177,9 +159,6 @@ export function ProfilePage() {
const [profile, setProfile] = useState<UserProfileData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [editForm, setEditForm] = useState<UpdateProfileRequest>({});
useEffect(() => {
const fetchProfile = async () => {
@ -196,59 +175,11 @@ export function ProfilePage() {
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 (error || !profile) {
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">
<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">
@ -269,396 +200,220 @@ export function ProfilePage() {
}
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;
return (
<div className="mx-auto w-full max-w-7xl space-y-6 pb-8">
{/* ─── Hero Card ─── */}
<div className="relative overflow-hidden rounded-3xl border border-grayScale-100 bg-white shadow-sm ring-1 ring-black/5">
{/* Tall dark gradient banner with content inside */}
<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">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(255,255,255,0.08),transparent_60%)]" />
<div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6">
{/* Page header (no tabs) */}
<div className="mb-5">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">My Info</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-grayScale-800">Profile</h1>
</div>
<div className="relative z-10 space-y-2">
<h2 className="text-2xl font-bold tracking-tight text-white sm:text-3xl">
Hello {profile.first_name}
</h2>
<p className="max-w-2xl text-sm leading-relaxed text-white/70">
Track your account status, keep profile details up to date, and manage your learning preferences from one place.
{/* Main profile layout card */}
<div className="rounded-2xl border border-grayScale-100 bg-white shadow-sm">
{/* Header strip */}
<div className="border-b border-grayScale-100 px-6 py-4 sm:px-8">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">Overview</p>
<p className="mt-1 text-sm text-grayScale-500">
Personal, job and account details for this team member.
</p>
<div className="mt-4 flex flex-wrap items-center gap-2">
<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 className="relative z-10 mt-6">
{!editing ? (
<Button
variant="outline"
size="sm"
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"
onClick={startEditing}
>
<Pencil className="h-3.5 w-3.5" />
Edit Profile
</Button>
) : (
<div className="flex items-center gap-2">
<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="px-6 py-6 sm:px-8 sm:py-7">
<div className="grid gap-8 md:grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)]">
{/* Left column: About & details */}
<div className="space-y-6">
{/* Identity */}
<div className="flex flex-col gap-4 sm:flex-row">
<Avatar className="h-16 w-16 sm:h-18 sm:w-18">
<AvatarImage src={profile.profile_picture_url || undefined} alt={fullName} />
<AvatarFallback className="bg-grayScale-100 text-base font-semibold text-grayScale-600">
{initials}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Input
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"
/>
<h2 className="text-lg font-semibold tracking-tight text-grayScale-800">{fullName}</h2>
<span className="rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500">
#{profile.id}
</span>
</div>
) : (
<div className="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">
<div className="mt-1 flex flex-wrap items-center gap-2">
<Badge
className={cn(
"px-2.5 py-0.5 text-xs font-semibold",
profile.role === "ADMIN"
? "bg-brand-500/10 text-brand-600 border border-brand-500/20"
: "bg-grayScale-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" />
{profile.role}
</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">
<Calendar className="h-3 w-3" />
Joined {formatDate(profile.created_at)}
</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<span
className={cn(
"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>
{/* ─── Detail Cards Grid ─── */}
<div className="grid gap-6 lg:grid-cols-3">
{/* ── Contact & Personal ── */}
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md lg:col-span-2">
<div className="h-1 bg-gradient-to-r from-brand-500 to-brand-400" />
<CardContent className="p-0">
<div className="grid divide-y divide-grayScale-100 sm:grid-cols-2 sm:divide-x sm:divide-y-0">
{/* Contact */}
<div className="p-5">
<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
{/* About / Contact */}
<div>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
About
</h3>
<div className="space-y-1.5 rounded-xl border border-grayScale-100 bg-grayScale-50/60 px-3 py-3">
<InfoRow icon={Phone} label="Phone" value={profile.phone_number} extra={<VerifiedIcon verified={profile.phone_verified} />} />
<InfoRow icon={Mail} label="Email" value={profile.email} extra={<VerifiedIcon verified={profile.email_verified} />} />
<InfoRow
icon={MapPin}
label="Location"
value={[profile.region, profile.country].filter(Boolean).join(", ") || "—"}
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>
{/* Personal */}
<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">1824</option>
<option value="25_34">2534</option>
<option value="35_44">3544</option>
<option value="45_54">4554</option>
<option value="55_64">5564</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} />
{/* Employee details */}
<div>
<p className="text-sm font-semibold text-grayScale-700">Profile Completion</p>
<p className="mt-0.5 text-xs text-grayScale-400">
{completionPct === 100 ? "All set!" : "Complete your profile for the best experience."}
</p>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
Employee details
</h3>
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-xs sm:text-sm text-grayScale-500">
<div>
<dt className="text-grayScale-400">Date of birth</dt>
<dd className="mt-0.5 font-medium text-grayScale-700">
{formatDate(profile.birth_day)}
</dd>
</div>
<div>
<dt className="text-grayScale-400">Age</dt>
<dd className="mt-0.5 font-medium text-grayScale-700">
{profile.age ? `${profile.age} years` : "—"}
</dd>
</div>
<div>
<dt className="text-grayScale-400">Gender</dt>
<dd className="mt-0.5 font-medium text-grayScale-700">
{profile.gender || "Not specified"}
</dd>
</div>
<div>
<dt className="text-grayScale-400">Age group</dt>
<dd className="mt-0.5 font-medium text-grayScale-700">
{profile.age_group || "—"}
</dd>
</div>
<div>
<dt className="text-grayScale-400">Occupation</dt>
<dd className="mt-0.5 font-medium text-grayScale-700">
{profile.occupation || "—"}
</dd>
</div>
<div>
<dt className="text-grayScale-400">Preferred language</dt>
<dd className="mt-0.5 font-medium text-grayScale-700">
{profile.preferred_language || "—"}
</dd>
</div>
</dl>
</div>
</div>
</CardContent>
</Card>
{/* Right column: Activity & account summary */}
<div className="space-y-6">
{/* Activity */}
<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-grayScale-300 to-grayScale-200" />
<CardContent className="space-y-4 p-5">
<p className="text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
<div>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
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 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" />
</div>
<div>
<p className="text-xs font-medium text-grayScale-600">Last Login</p>
<p className="text-[11px] text-grayScale-400">{formatDateTime(profile.last_login)}</p>
<p className="text-sm font-medium text-grayScale-700">
Last login
</p>
<p className="text-xs text-grayScale-400">
{formatDateTime(profile.last_login)}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-grayScale-50 text-grayScale-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" />
</div>
<div>
<p className="text-xs font-medium text-grayScale-600">Account Created</p>
<p className="text-[11px] text-grayScale-400">{formatDateTime(profile.created_at)}</p>
<p className="text-sm font-medium text-grayScale-700">
Account created
</p>
<p className="text-xs text-grayScale-400">
{formatDateTime(profile.created_at)}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Quick Account Info */}
<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-500 to-brand-600" />
<CardContent className="space-y-3 p-5">
<p className="text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
{/* Account summary */}
<div>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
Account
</p>
<div className="space-y-2.5">
</h3>
<Card className="shadow-none border-grayScale-100">
<CardContent className="space-y-3 p-4">
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Role</span>
<Badge
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>
<span className="font-medium text-grayScale-700">{profile.role}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Status</span>
<span
className={cn(
"inline-flex items-center gap-1.5 text-xs font-semibold",
profile.status === "ACTIVE" ? "text-mint-600" : "text-destructive",
profile.status === "ACTIVE"
? "text-mint-600"
: "text-destructive"
)}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive",
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive"
)}
/>
{profile.status}
@ -666,8 +421,8 @@ export function ProfilePage() {
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Email</span>
<span className="flex items-center gap-1">
<span className="max-w-[130px] truncate text-xs text-grayScale-600">
<span className="flex items-center gap-1 text-grayScale-700">
<span className="truncate max-w-[140px] text-right text-xs sm:text-sm">
{profile.email}
</span>
<VerifiedIcon verified={profile.email_verified} />
@ -675,96 +430,20 @@ export function ProfilePage() {
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Phone</span>
<span className="flex items-center gap-1">
<span className="max-w-[110px] truncate text-xs text-grayScale-600">
<span className="flex items-center gap-1 text-grayScale-700">
<span className="truncate max-w-[120px] text-right text-xs sm:text-sm">
{profile.phone_number || "—"}
</span>
<VerifiedIcon verified={profile.phone_verified} />
</span>
</div>
</div>
</CardContent>
</Card>
</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>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -21,7 +21,7 @@ import { Button } from "../components/ui/button";
import { Select } from "../components/ui/select";
import { Separator } from "../components/ui/separator";
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 { toast } from "sonner";
@ -127,15 +127,9 @@ function ProfileTab({ profile }: { profile: UserProfileData }) {
const handleSave = async () => {
setSaving(true);
try {
await updateProfile({
first_name: firstName,
last_name: lastName,
nick_name: nickName,
preferred_language: language,
});
// placeholder — wire up to API when endpoint is ready
await new Promise((r) => setTimeout(r, 600));
toast.success("Profile settings saved");
} catch {
toast.error("Failed to save profile settings.");
} finally {
setSaving(false);
}

View File

@ -1,5 +1,4 @@
import { useEffect, useState } from "react"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import {
Area,
AreaChart,
@ -15,7 +14,6 @@ import {
XAxis,
YAxis,
} from "recharts"
import alertSrc from "../../assets/Alert.svg"
import {
Users,
BadgeCheck,
@ -307,10 +305,7 @@ export function AnalyticsPage() {
return (
<div className="mx-auto w-full max-w-6xl">
<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">
<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 className="flex items-center justify-center py-20 text-grayScale-500">Loading analytics</div>
</div>
)
}
@ -320,7 +315,6 @@ export function AnalyticsPage() {
<div className="mx-auto w-full max-w-6xl">
<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">
<img src={alertSrc} alt="" className="h-12 w-12" />
<span className="text-sm text-destructive">Failed to load analytics data.</span>
<Button variant="outline" size="sm" onClick={fetchData}>
<RefreshCw className="mr-2 h-4 w-4" />

View File

@ -1,7 +1,6 @@
import { useState } from "react"
import { Link } from "react-router-dom"
import { ArrowLeft } from "lucide-react"
import successSrc from "../../assets/success.svg"
import { ArrowLeft, Mail } from "lucide-react"
import { BrandLogo } from "../../components/brand/BrandLogo"
import { Button } from "../../components/ui/button"
@ -31,8 +30,13 @@ export function ForgotPasswordPage() {
</div>
<div className="relative z-10 max-w-md px-12 text-center">
{/* Brand logo */}
<BrandLogo variant="light" className="mx-auto mb-8 h-16" />
{/* Large brand icon */}
<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">
Manage your academy, track student progress, and streamline
operations all from one powerful dashboard.
@ -60,7 +64,9 @@ export function ForgotPasswordPage() {
{submitted ? (
/* Success state */
<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">
Check your email
</h1>

View File

@ -1,12 +1,11 @@
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 { BrandLogo } from "../../components/brand/BrandLogo";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import { login, loginWithGoogle } from "../../api/auth.api";
import type { LoginRequest } from "../../types/auth.types";
import type { LoginResult } from "../../api/auth.api";
@ -65,11 +64,6 @@ function GoogleIcon({ className }: { className?: string }) {
export function LoginPage() {
const navigate = useNavigate();
const token = localStorage.getItem("access_token");
if (token) {
return <Navigate to="/dashboard" replace />;
}
const [showPassword, setShowPassword] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@ -217,8 +211,13 @@ export function LoginPage() {
</div>
<div className="relative z-10 max-w-md px-12 text-center">
{/* Brand logo */}
<BrandLogo variant="light" className="mx-auto mb-8 h-16" />
{/* Large brand icon */}
<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">
Manage your academy, track student progress, and streamline
operations all from one powerful dashboard.
@ -404,15 +403,13 @@ export function LoginPage() {
{/* Footer */}
<div className="mt-10 text-center text-xs text-grayScale-400">
<p>© {new Date().getFullYear()} Yimaru Academy · All rights reserved</p>
</div>
</div>
</div>
{/* Version badge */}
<p className="fixed bottom-3 right-4 font-mono text-[10px] text-grayScale-300">
<p className="mt-1 font-mono text-[10px] text-grayScale-300">
v{__BUILD_HASH__} · {new Date(__BUILD_TIME__).toLocaleDateString()}
</p>
</div>
</div>
</div>
</div>
);
}

View File

@ -889,7 +889,7 @@ export function AddNewPracticePage() {
className="w-full bg-brand-500 hover:bg-brand-600"
onClick={() => navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`)}
>
Go back to Course
Go back to Sub-course
</Button>
<Button
variant="outline"

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"
import { useState } from "react"
import { useNavigate, useParams } from "react-router-dom"
import { ArrowLeft, Plus, X } from "lucide-react"
import { toast } from "sonner"
@ -7,41 +7,30 @@ import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/ca
import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea"
import { Select } from "../../components/ui/select"
import { createQuestion, getQuestionById, updateQuestion } from "../../api/courses.api"
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
type Difficulty = "EASY" | "MEDIUM" | "HARD"
type QuestionStatus = "DRAFT" | "PUBLISHED" | "INACTIVE"
type QuestionType = "multiple-choice" | "short-answer" | "true-false"
interface Question {
id?: number
id: string
question: string
type: QuestionType
options: string[]
correctAnswer: string
points: number
difficulty: Difficulty
status: QuestionStatus
tips: string
explanation: string
voicePrompt: string
sampleAnswerVoicePrompt: string
audioCorrectAnswerText: string
category?: string
difficulty?: string
}
const initialForm: Question = {
// Mock data for editing
const mockQuestion: Question = {
id: "1",
question: "",
type: "MCQ",
type: "multiple-choice",
options: ["", "", "", ""],
correctAnswer: "",
points: 1,
difficulty: "EASY",
status: "PUBLISHED",
tips: "",
explanation: "",
voicePrompt: "",
sampleAnswerVoicePrompt: "",
audioCorrectAnswerText: "",
points: 10,
category: "",
difficulty: "",
}
export function AddQuestionPage() {
@ -49,83 +38,36 @@ export function AddQuestionPage() {
const { id } = useParams<{ id?: string }>()
const isEditing = !!id
const [formData, setFormData] = useState<Question>(initialForm)
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
const loadQuestion = async () => {
if (!isEditing || !id) return
setLoading(true)
try {
const res = await getQuestionById(Number(id))
const q = res.data.data
const mappedType: QuestionType =
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 [formData, setFormData] = useState<Question>(
isEditing
? mockQuestion // In a real app, fetch the question by id
: {
id: Date.now().toString(),
question: "",
type: "multiple-choice",
options: ["", "", "", ""],
correctAnswer: "",
points: 10,
category: "",
difficulty: "",
},
)
const handleTypeChange = (type: QuestionType) => {
setFormData((prev) => {
if (type === "TRUE_FALSE") {
if (type === "true-false") {
return {
...prev,
type,
options: ["True", "False"],
correctAnswer: prev.correctAnswer === "True" || prev.correctAnswer === "False" ? prev.correctAnswer : "",
}
} else if (type === "SHORT_ANSWER" || type === "AUDIO") {
} else if (type === "short-answer") {
return {
...prev,
type,
options: [],
correctAnswer: type === "AUDIO" ? prev.audioCorrectAnswerText : prev.correctAnswer,
correctAnswer: "",
}
} else {
return {
@ -159,7 +101,7 @@ export function AddQuestionPage() {
}))
}
const handleSubmit = async (e: React.FormEvent) => {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// Validation
@ -170,14 +112,14 @@ export function AddQuestionPage() {
return
}
if (formData.type === "MCQ" || formData.type === "TRUE_FALSE") {
if (formData.type === "multiple-choice" || formData.type === "true-false") {
if (!formData.correctAnswer) {
toast.error("Missing correct answer", {
description: "Select the correct answer for this question.",
})
return
}
if (formData.type === "MCQ") {
if (formData.type === "multiple-choice") {
const hasEmptyOptions = formData.options.some((opt) => !opt.trim())
if (hasEmptyOptions) {
toast.error("Incomplete options", {
@ -186,74 +128,23 @@ export function AddQuestionPage() {
return
}
}
} else if (formData.type === "SHORT_ANSWER") {
} else if (formData.type === "short-answer") {
if (!formData.correctAnswer.trim()) {
toast.error("Missing correct answer", {
description: "Enter the expected correct answer.",
})
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)
try {
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)
}
// In a real app, save the question here
console.log("Saving question:", formData)
toast.success(isEditing ? "Question updated" : "Question created", {
description: isEditing
? "The question has been updated successfully."
: "Your new question has been created.",
})
navigate("/content/questions")
} catch (error) {
console.error("Failed to save question:", error)
toast.error("Failed to save question")
} finally {
setSubmitting(false)
}
}
return (
@ -279,11 +170,6 @@ export function AddQuestionPage() {
</div>
<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}>
<Card className="shadow-sm border border-grayScale-100 rounded-xl">
<CardHeader className="pb-2">
@ -299,10 +185,9 @@ export function AddQuestionPage() {
value={formData.type}
onChange={(e) => handleTypeChange(e.target.value as QuestionType)}
>
<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>
<option value="multiple-choice">Multiple Choice</option>
<option value="short-answer">Short Answer</option>
<option value="true-false">True/False</option>
</Select>
</div>
@ -324,7 +209,7 @@ export function AddQuestionPage() {
</div>
{/* Options for Multiple Choice */}
{(formData.type === "MCQ" || formData.type === "TRUE_FALSE") && (
{(formData.type === "multiple-choice" || formData.type === "true-false") && (
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Options
@ -339,10 +224,10 @@ export function AddQuestionPage() {
value={option}
onChange={(e) => handleOptionChange(index, e.target.value)}
placeholder={`Option ${index + 1}`}
disabled={formData.type === "TRUE_FALSE"}
disabled={formData.type === "true-false"}
required
/>
{formData.type === "MCQ" && formData.options.length > 2 && (
{formData.type === "multiple-choice" && formData.options.length > 2 && (
<Button
type="button"
variant="ghost"
@ -355,7 +240,7 @@ export function AddQuestionPage() {
)}
</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">
<Plus className="h-4 w-4" />
Add Option
@ -370,9 +255,9 @@ export function AddQuestionPage() {
{/* Correct Answer */}
<div>
<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>
{formData.type === "MCQ" || formData.type === "TRUE_FALSE" ? (
{formData.type === "multiple-choice" || formData.type === "true-false" ? (
<Select
value={formData.correctAnswer}
onChange={(e) =>
@ -389,14 +274,10 @@ export function AddQuestionPage() {
</Select>
) : (
<Textarea
placeholder={formData.type === "AUDIO" ? "Enter audio correct answer text..." : "Enter the correct answer..."}
value={formData.type === "AUDIO" ? formData.audioCorrectAnswerText : formData.correctAnswer}
placeholder="Enter the correct answer..."
value={formData.correctAnswer}
onChange={(e) =>
setFormData((prev) =>
formData.type === "AUDIO"
? { ...prev, audioCorrectAnswerText: e.target.value }
: { ...prev, correctAnswer: e.target.value },
)
setFormData((prev) => ({ ...prev, correctAnswer: e.target.value }))
}
rows={2}
required
@ -419,7 +300,7 @@ export function AddQuestionPage() {
min="1"
value={formData.points}
onChange={(e) =>
setFormData((prev) => ({ ...prev, points: parseInt(e.target.value) || 1 }))
setFormData((prev) => ({ ...prev, points: parseInt(e.target.value) || 0 }))
}
required
/>
@ -431,74 +312,27 @@ export function AddQuestionPage() {
Difficulty (Optional)
</label>
<Select
value={formData.difficulty}
onChange={(e) => setFormData((prev) => ({ ...prev, difficulty: e.target.value as Difficulty }))}
value={formData.difficulty || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, difficulty: e.target.value }))}
>
<option value="EASY">Easy</option>
<option value="MEDIUM">Medium</option>
<option value="HARD">Hard</option>
<option value="">Select difficulty</option>
<option value="Easy">Easy</option>
<option value="Medium">Medium</option>
<option value="Hard">Hard</option>
</Select>
</div>
</div>
{/* Status */}
{/* Category */}
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Status
<label htmlFor="category" className="mb-1.5 block text-sm font-medium text-grayScale-500">
Category (Optional)
</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
value={formData.tips}
onChange={(e) => setFormData((prev) => ({ ...prev, tips: e.target.value }))}
placeholder="Helpful tip for learners"
/>
</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"
id="category"
placeholder="e.g., Programming, Geography"
value={formData.category || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, category: e.target.value }))}
/>
</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">
Cancel
</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"}
</Button>
</div>

View File

@ -76,7 +76,7 @@ export function AllCoursesPage() {
setCourses(allCourses)
} catch (err) {
console.error("Failed to load courses:", err)
setError("Failed to load sub-categories")
setError("Failed to load courses")
} finally {
setLoading(false)
}
@ -116,7 +116,7 @@ export function AllCoursesPage() {
description: createDescription.trim(),
})
toast.success("Sub-category created", {
toast.success("Course created", {
description: `"${createTitle.trim()}" has been created.`,
})
@ -130,7 +130,7 @@ export function AllCoursesPage() {
await fetchAllCourses()
} catch (err: any) {
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.",
})
} finally {
@ -145,7 +145,7 @@ export function AllCoursesPage() {
await fetchAllCourses()
} catch (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 {
setTogglingId(null)
}
@ -173,13 +173,13 @@ export function AllCoursesPage() {
title: editTitle.trim(),
description: editDescription.trim(),
})
toast.success("Sub-category updated")
toast.success("Course updated")
setEditOpen(false)
setCourseToEdit(null)
await fetchAllCourses()
} catch (err: any) {
console.error("Failed to update course:", err)
toast.error("Failed to update sub-category", {
toast.error("Failed to update course", {
description: err?.response?.data?.message || "Please try again.",
})
} finally {
@ -193,7 +193,7 @@ export function AllCoursesPage() {
<div className="rounded-2xl bg-white shadow-sm p-6">
<RefreshCw className="h-10 w-10 animate-spin text-brand-600" />
</div>
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading all sub-categories</p>
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading all courses</p>
</div>
)
}
@ -216,9 +216,9 @@ export function AllCoursesPage() {
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">All 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">
View and manage sub-categories across all categories.
View and manage courses across all categories.
</p>
</div>
<Button
@ -226,14 +226,14 @@ export function AllCoursesPage() {
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Create Sub-category
Create Course
</Button>
</div>
<Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-4">
<CardTitle className="text-base font-semibold text-grayScale-600">
Sub-category Management
Course Management
</CardTitle>
</CardHeader>
<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">
<BookOpen className="h-8 w-8 text-grayScale-400" />
</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">
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>
</div>
)}
@ -388,7 +388,7 @@ export function AllCoursesPage() {
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create sub-category</DialogTitle>
<DialogTitle>Create course</DialogTitle>
<DialogDescription>
Choose a category, add basic details, and optionally attach a thumbnail and intro
video.
@ -439,7 +439,7 @@ export function AllCoursesPage() {
</div>
<div className="sm:col-span-1">
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Sub-category title
Course title
</label>
<Input
placeholder="e.g. Beginner English A1"
@ -455,7 +455,7 @@ export function AllCoursesPage() {
</label>
<Textarea
rows={3}
placeholder="Short summary of what this sub-category covers."
placeholder="Short summary of what this course covers."
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
/>
@ -514,7 +514,7 @@ export function AllCoursesPage() {
disabled={creating}
onClick={handleCreateCourse}
>
{creating ? "Creating…" : "Create sub-category"}
{creating ? "Creating…" : "Create course"}
</Button>
</div>
</DialogContent>
@ -524,9 +524,9 @@ export function AllCoursesPage() {
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Edit sub-category</DialogTitle>
<DialogTitle>Edit course</DialogTitle>
<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.
</DialogDescription>
</DialogHeader>
@ -534,12 +534,12 @@ export function AllCoursesPage() {
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Sub-category title
Course title
</label>
<Input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
placeholder="Enter sub-category title"
placeholder="Enter course title"
/>
</div>
<div>
@ -550,7 +550,7 @@ export function AllCoursesPage() {
rows={3}
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Short summary of this sub-category."
placeholder="Short summary of this course."
/>
</div>
</div>

View File

@ -12,7 +12,7 @@ const contentSections = [
pathFn: (categoryId: string | undefined) => `/content/category/${categoryId}/courses`,
icon: BookOpen,
title: "Courses",
description: "Manage sub-categories, course videos and educational content",
description: "Manage course videos and educational content",
action: "Manage Courses",
count: 12,
countLabel: "courses",

View File

@ -1,8 +1,6 @@
import { useEffect, useState } from "react"
import { Link } from "react-router-dom"
import { FolderOpen, RefreshCw, BookOpen, Plus } from "lucide-react"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import alertSrc from "../../assets/Alert.svg"
import { FolderOpen, RefreshCw, AlertCircle, BookOpen, Plus } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
@ -50,7 +48,10 @@ export function CourseCategoryPage() {
if (loading) {
return (
<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>
</div>
)
@ -60,7 +61,9 @@ export function CourseCategoryPage() {
return (
<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">
<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>
<p className="text-sm font-semibold text-red-700">{error}</p>
<p className="mt-1 text-xs text-red-400">
@ -139,7 +142,7 @@ export function CourseCategoryPage() {
<CardContent>
<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>
@ -291,12 +294,15 @@ export function CourseCategoryPage() {
setCreating(true)
try {
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
try {
const data: any = parentRes?.data
createdCategoryId =
data?.data?.id ??
data?.data?.category?.id ??
data?.data?.id ??
data?.category?.id ??
@ -309,7 +315,10 @@ export function CourseCategoryPage() {
if (createdCategoryId && pendingSubCategories.length > 0) {
await Promise.all(
pendingSubCategories.map((subName) =>
createCourseCategory({ name: subName }),
createCourseCategory({
name: subName,
parent_id: createdCategoryId,
}),
),
)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,23 +1,30 @@
import { useEffect, useState } from "react"
import { useEffect, useState, useRef } from "react"
import { Link, useParams, useNavigate } from "react-router-dom"
import { Plus, ArrowLeft, ToggleLeft, ToggleRight, X, Trash2, Edit, AlertCircle, Star, MessageSquare } from "lucide-react"
import practiceSrc from "../../assets/Practice.svg"
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 { Plus, ArrowLeft, BookOpen, ToggleLeft, ToggleRight, X, Trash2, MoreVertical, Edit, RefreshCw, AlertCircle } from "lucide-react"
import { Card, CardContent } from "../../components/ui/card"
import { Button } from "../../components/ui/button"
import { Badge } from "../../components/ui/badge"
import { Input } from "../../components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table"
import { getCoursesByCategory, getCourseCategories, createCourse, deleteCourse, updateCourseStatus, updateCourse, getRatings } from "../../api/courses.api"
import type { Course, CourseCategory, Rating } from "../../types/course.types"
import { FileUpload } from "../../components/ui/file-upload"
import { getCoursesByCategory, getCourseCategories, createCourse, deleteCourse, updateCourseStatus, updateCourse } from "../../api/courses.api"
import type { Course, CourseCategory } from "../../types/course.types"
function CourseThumbnail({ src, alt, gradient }: { src?: string; alt: string; gradient: string }) {
const [imgError, setImgError] = useState(false)
if (!src || imgError) {
return <div className={`h-full w-full rounded-t-lg ${gradient}`} />
}
return (
<img
src={src}
alt={alt}
className="h-full w-full object-cover rounded-t-lg"
onError={() => setImgError(true)}
/>
)
}
export function CoursesPage() {
const { categoryId } = useParams<{ categoryId: string }>()
@ -32,10 +39,16 @@ export function CoursesPage() {
const [description, setDescription] = useState("")
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [newThumbnailFile, setNewThumbnailFile] = useState<File | null>(null)
const [newVideoFile, setNewVideoFile] = useState<File | null>(null)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [courseToDelete, setCourseToDelete] = useState<Course | null>(null)
const [deleting, setDeleting] = useState(false)
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 [courseToEdit, setCourseToEdit] = useState<Course | null>(null)
const [editTitle, setEditTitle] = useState("")
@ -43,10 +56,19 @@ export function CoursesPage() {
const [editThumbnail, setEditThumbnail] = useState("")
const [updating, setUpdating] = useState(false)
const [updateError, setUpdateError] = useState<string | null>(null)
const [showRatingsModal, setShowRatingsModal] = useState(false)
const [ratingsCourseId, setRatingsCourseId] = useState<number | null>(null)
const [courseRatings, setCourseRatings] = useState<Rating[]>([])
const [courseRatingsLoading, setCourseRatingsLoading] = useState(false)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
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 () => {
if (!categoryId) return
@ -70,14 +92,14 @@ export function CoursesPage() {
getCourseCategories(),
])
setCourses(coursesRes.data.data.courses ?? [])
setCourses(coursesRes.data.data.courses)
const foundCategory = categoriesRes.data.data.categories.find(
(c) => c.id === Number(categoryId)
)
setCategory(foundCategory ?? null)
} catch (err) {
console.error("Failed to fetch courses:", err)
setError("Failed to load sub-categories")
setError("Failed to load courses")
} finally {
setLoading(false)
}
@ -90,6 +112,8 @@ export function CoursesPage() {
setTitle("")
setDescription("")
setSaveError(null)
setNewThumbnailFile(null)
setNewVideoFile(null)
setShowModal(true)
}
@ -98,6 +122,8 @@ export function CoursesPage() {
setTitle("")
setDescription("")
setSaveError(null)
setNewThumbnailFile(null)
setNewVideoFile(null)
}
const handleSave = async () => {
@ -123,7 +149,7 @@ export function CoursesPage() {
await fetchCourses()
} catch (err: any) {
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 {
setSaving(false)
}
@ -206,7 +232,7 @@ export function CoursesPage() {
await fetchCourses()
} catch (err: any) {
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 {
setUpdating(false)
}
@ -216,28 +242,13 @@ export function CoursesPage() {
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) {
return (
<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" />
</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>
)
}
@ -246,7 +257,9 @@ export function CoursesPage() {
return (
<div className="flex items-center justify-center py-32">
<div className="mx-4 flex w-full max-w-md items-center gap-3 rounded-2xl border border-red-100 bg-red-50 px-6 py-5 shadow-sm">
<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>
</div>
</div>
@ -267,155 +280,150 @@ export function CoursesPage() {
</Link>
<div>
<h1 className="text-xl font-bold text-grayScale-700 sm:text-2xl">
{category?.name} Sub-categories
{category?.name} Courses
</h1>
<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>
</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}>
<Plus className="mr-2 h-4 w-4" />
Add New Sub-category
Add New Course
</Button>
</div>
</div>
{/* Course table 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">
{/* Course grid or empty state */}
{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">
<img src={practiceSrc} alt="" className="h-16 w-16" />
<h3 className="mt-4 text-base font-semibold text-grayScale-600">No sub-categories yet</h3>
<p className="mt-1.5 text-sm text-grayScale-400">
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>
<Card className="border-dashed border-grayScale-200 shadow-none">
<CardContent className="flex flex-col items-center justify-center py-20">
<div className="rounded-2xl bg-grayScale-50 p-5">
<BookOpen className="h-14 w-14 text-grayScale-300" />
</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">
<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">
Sub-category
</TableHead>
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
Status
</TableHead>
<TableHead className="py-3 text-right text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{courses.map((course, index) => (
<TableRow
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{courses.map((course, index) => {
const gradients = [
"bg-gradient-to-br from-blue-100 to-blue-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-yellow-100 to-yellow-200",
]
return (
<Card
key={course.id}
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${
index % 2 === 0 ? "bg-white" : "bg-grayScale-100/40"
}`}
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"
onClick={() => handleCourseClick(course.id)}
>
<TableCell className="max-w-md py-3.5">
<div className="truncate text-sm font-semibold text-grayScale-700">
{course.title}
{/* Thumbnail */}
<div className="relative aspect-video w-full overflow-hidden">
<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>
{course.description && (
<div className="mt-1 truncate text-xs text-grayScale-400">
{course.description}
{/* Content */}
<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>
)}
</TableCell>
<TableCell className="hidden py-3.5 md:table-cell">
<Badge
variant={course.is_active ? "success" : "secondary"}
className="text-[11px] font-semibold"
>
{course.is_active ? "Active" : "Inactive"}
</Badge>
</TableCell>
<TableCell className="py-3.5 text-right">
<div className="flex items-center justify-end gap-1">
</div>
</div>
{/* Title */}
<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.description || "No description available"}
</p>
{/* Edit button */}
<Button
variant="ghost"
size="icon"
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"
variant="outline"
className="w-full border-grayScale-200 text-grayScale-600 transition-all hover:border-brand-200 hover:bg-brand-50 hover:text-brand-600"
onClick={(e) => {
e.stopPropagation()
handleEditClick(course)
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700"
disabled={togglingId === course.id}
onClick={(e) => {
e.stopPropagation()
handleToggleStatus(course)
}}
>
{course.is_active ? (
<ToggleLeft className="h-4 w-4" />
) : (
<ToggleRight className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="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" />
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
)
})}
</div>
)}
{/* Add Course Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-2xl animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
<h2 className="text-lg font-bold text-grayScale-700">Add New Sub-category</h2>
<h2 className="text-lg font-bold text-grayScale-700">Add New Course</h2>
<button
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"
@ -441,7 +449,7 @@ export function CoursesPage() {
</label>
<Input
id="course-title"
placeholder="Enter sub-category title"
placeholder="Enter course title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
@ -456,7 +464,7 @@ export function CoursesPage() {
</label>
<textarea
id="course-description"
placeholder="Enter sub-category description"
placeholder="Enter course description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
@ -464,6 +472,29 @@ export function CoursesPage() {
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p className="mb-2 text-sm font-medium text-grayScale-600">Thumbnail image</p>
<FileUpload
accept="image/*"
onFileSelect={setNewThumbnailFile}
label="Upload thumbnail"
description="Optional course cover image"
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
</div>
<div>
<p className="mb-2 text-sm font-medium text-grayScale-600">Intro video</p>
<FileUpload
accept="video/*"
onFileSelect={setNewVideoFile}
label="Upload intro video"
description="Optional overview for this course"
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
</div>
</div>
<div className="rounded-lg bg-grayScale-50 px-3 py-2 text-xs text-grayScale-400">
Category: <span className="font-semibold text-grayScale-600">{category?.name}</span>
</div>
@ -478,7 +509,7 @@ export function CoursesPage() {
onClick={handleSave}
disabled={saving}
>
{saving ? "Saving..." : "Save Sub-category"}
{saving ? "Saving..." : "Save Course"}
</Button>
</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="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">
<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
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"
@ -516,7 +547,7 @@ export function CoursesPage() {
</label>
<Input
id="edit-course-title"
placeholder="Enter sub-category title"
placeholder="Enter course title"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
/>
@ -531,7 +562,7 @@ export function CoursesPage() {
</label>
<textarea
id="edit-course-description"
placeholder="Enter sub-category description"
placeholder="Enter course description"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
@ -564,133 +595,19 @@ export function CoursesPage() {
onClick={handleUpdate}
disabled={updating}
>
{updating ? "Updating..." : "Update Sub-category"}
{updating ? "Updating..." : "Update Course"}
</Button>
</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 */}
{showDeleteModal && courseToDelete && (
<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="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
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"

View File

@ -1,267 +1,299 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { RefreshCw } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { useState } from "react"
import { Plus, Edit, Trash2 } from "lucide-react"
import { Button } from "../../components/ui/button"
import { Card } from "../../components/ui/card"
import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea"
import { Select } from "../../components/ui/select"
import {
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"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "../../components/ui/dialog"
const statusColor: Record<string, string> = {
PUBLISHED: "bg-green-100 text-green-700",
DRAFT: "bg-amber-100 text-amber-700",
ARCHIVED: "bg-grayScale-200 text-grayScale-600",
}
const mockLeaders = [
{ id: "1", name: "John Doe", role: "CEO" },
{ id: "2", name: "Jane Smith", role: "COO" },
]
const mockMembers = [
{ id: "1", name: "John Doe", role: "Member" },
{ id: "2", name: "Jane Smith", role: "Member" },
]
export function PracticeDetailsPage() {
const [practices, setPractices] = useState<QuestionSet[]>([])
const [selectedPracticeId, setSelectedPracticeId] = useState<number | null>(null)
const [selectedPracticeDetail, setSelectedPracticeDetail] = useState<QuestionSetDetail | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [loadingList, setLoadingList] = useState(false)
const [loadingDetail, setLoadingDetail] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [ownerTypeFilter, setOwnerTypeFilter] = useState("all")
const [isMemberModalOpen, setIsMemberModalOpen] = useState(false)
const [isLeaderModalOpen, setIsLeaderModalOpen] = useState(false)
const [memberName, setMemberName] = useState("")
const [memberRole, setMemberRole] = useState("")
const [leaderName, setLeaderName] = useState("")
const [leaderRole, setLeaderRole] = useState("")
const fetchPractices = useCallback(async () => {
setLoadingList(true)
try {
const res = await getQuestionSets({ set_type: "PRACTICE" })
const payload = res.data?.data as unknown
let sets: QuestionSet[] = []
if (Array.isArray(payload)) {
sets = payload as QuestionSet[]
} 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
const [formData, setFormData] = useState({
name: "",
description: "",
type: "",
street: "",
city: "",
state: "",
zipCode: "",
})
}, [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 (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-8">
{/* Page Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Practice Management</h1>
<p className="mt-1 text-sm text-grayScale-400">
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>
<p className="mt-1 text-sm text-grayScale-400">Manage your practice details, leadership, and members</p>
</div>
<Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-4">
<CardTitle className="text-base font-semibold text-grayScale-600">
Practices ({totalCount})
</CardTitle>
</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>
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2">
{/* Practice Leadership */}
<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 Leadership</h2>
<Button
variant="outline"
onClick={() => {
setSearchQuery("")
setStatusFilter("all")
setOwnerTypeFilter("all")
}}
size="sm"
onClick={() => setIsLeaderModalOpen(true)}
className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors w-full sm:w-auto"
>
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>
</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>
)}
</CardContent>
</Card>
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="sm:max-w-2xl">
{/* Practice Details */}
<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>
<DialogTitle>Practice Detail</DialogTitle>
<DialogTitle>Add New Member</DialogTitle>
</DialogHeader>
{!selectedPracticeId ? (
<p className="text-sm text-grayScale-500">Select a practice from the list to view details.</p>
) : loadingDetail ? (
<p className="text-sm text-grayScale-500">Loading detail...</p>
) : !selectedPracticeDetail ? (
<p className="text-sm text-grayScale-500">Failed to load practice detail.</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<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">Title</p>
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.title}</p>
<div className="space-y-5 py-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Member Name
</label>
<Input
value={memberName}
onChange={(e) => setMemberName(e.target.value)}
placeholder="Enter member name"
/>
</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">Set Type</p>
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.set_type}</p>
</div>
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 p-3 sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Description</p>
<p className="mt-1 text-sm text-grayScale-700">{selectedPracticeDetail.description || "—"}</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">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>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Member Role
</label>
<Input
value={memberRole}
onChange={(e) => setMemberRole(e.target.value)}
placeholder="Enter member role"
/>
</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>
</Dialog>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { useState } from "react"
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 { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Input } from "../../components/ui/input"
import { Select } from "../../components/ui/select"
import { Textarea } from "../../components/ui/textarea"
import {
Table,
TableBody,
@ -15,294 +14,117 @@ import {
TableRow,
} from "../../components/ui/table"
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 DifficultyFilter = "all" | "EASY" | "MEDIUM" | "HARD"
type StatusFilter = "all" | "DRAFT" | "PUBLISHED" | "INACTIVE"
type QuestionTypeEdit = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
type QuestionType = "multiple-choice" | "short-answer" | "true-false"
interface EditOption {
option_text: string
option_order: number
is_correct: boolean
interface Question {
id: string
question: string
type: QuestionType
options: string[]
correctAnswer: string
points: number
category?: string
difficulty?: string
createdAt?: string
}
const typeLabels: Record<string, string> = {
MCQ: "Multiple Choice",
TRUE_FALSE: "True/False",
SHORT_ANSWER: "Short Answer",
SHORT: "Short Answer",
AUDIO: "Audio",
// Mock data
const mockQuestions: Question[] = [
{
id: "1",
question: "What is the capital of France?",
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> = {
MCQ: "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: "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",
const typeColors: Record<QuestionType, string> = {
"multiple-choice": "bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-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",
}
export function QuestionsPage() {
const [questions, setQuestions] = useState<QuestionDetail[]>([])
const [loading, setLoading] = useState(false)
const [deleting, setDeleting] = useState(false)
const [questions, setQuestions] = useState<Question[]>(mockQuestions)
const [searchQuery, setSearchQuery] = useState("")
const [typeFilter, setTypeFilter] = useState<QuestionTypeFilter>("all")
const [difficultyFilter, setDifficultyFilter] = useState<DifficultyFilter>("all")
const [statusFilter, setStatusFilter] = useState<StatusFilter>("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 [typeFilter, setTypeFilter] = useState<string>("all")
const [categoryFilter, setCategoryFilter] = useState<string>("all")
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
const fetchQuestions = useCallback(async () => {
setLoading(true)
try {
const batchSize = 100
let nextOffset = 0
let allRows: QuestionDetail[] = []
let expectedTotal = Number.POSITIVE_INFINITY
const filteredQuestions = questions.filter((q) => {
const matchesSearch = q.question.toLowerCase().includes(searchQuery.toLowerCase())
const matchesType = typeFilter === "all" || q.type === typeFilter
const matchesCategory = categoryFilter === "all" || q.category === categoryFilter
const matchesDifficulty = difficultyFilter === "all" || q.difficulty === difficultyFilter
while (allRows.length < expectedTotal) {
const res = await getQuestions({
question_type: typeFilter === "all" ? undefined : typeFilter,
difficulty: difficultyFilter === "all" ? undefined : difficultyFilter,
status: statusFilter === "all" ? undefined : statusFilter,
limit: batchSize,
offset: nextOffset,
return matchesSearch && matchesType && matchesCategory && matchesDifficulty
})
const payload = res.data?.data as unknown
const meta = res.data?.metadata as { total_count?: number } | null | undefined
const categories = Array.from(new Set(questions.map((q) => q.category).filter(Boolean)))
const difficulties = Array.from(new Set(questions.map((q) => q.difficulty).filter(Boolean)))
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
}
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 handleDelete = (id: string) => {
if (window.confirm("Are you sure you want to delete this question?")) {
setQuestions(questions.filter((q) => q.id !== id))
}
}
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 (
<div className="space-y-8">
{/* Page Header */}
@ -315,16 +137,6 @@ export function QuestionsPage() {
Create and manage your question bank
</p>
</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">
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
<Plus className="h-4 w-4" />
@ -332,7 +144,6 @@ export function QuestionsPage() {
</Button>
</Link>
</div>
</div>
<Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-4">
@ -354,78 +165,51 @@ export function QuestionsPage() {
</div>
<div className="flex flex-wrap items-center gap-2">
<Select
value={typeFilter}
onChange={(e) => {
setTypeFilter(e.target.value as QuestionTypeFilter)
}}
>
<Select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}>
<option value="all">All Types</option>
<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>
<option value="multiple-choice">Multiple Choice</option>
<option value="short-answer">Short Answer</option>
<option value="true-false">True/False</option>
</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
value={difficultyFilter}
onChange={(e) => {
setDifficultyFilter(e.target.value as DifficultyFilter)
}}
onChange={(e) => setDifficultyFilter(e.target.value)}
>
<option value="all">All Difficulties</option>
<option value="EASY">Easy</option>
<option value="MEDIUM">Medium</option>
<option value="HARD">Hard</option>
</Select>
<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>
{difficulties.map((diff) => (
<option key={diff} value={diff}>
{diff}
</option>
))}
</Select>
)}
</div>
</div>
{/* Results count */}
<div className="text-xs font-medium text-grayScale-400">
Showing {paginatedQuestions.length} of {totalCount} questions
Showing {filteredQuestions.length} of {questions.length} questions
</div>
{/* Questions Table */}
{loading ? (
<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 ? (
{filteredQuestions.length > 0 ? (
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
<Table>
<TableHeader>
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
<TableHead className="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">
Question
</TableHead>
@ -433,10 +217,10 @@ export function QuestionsPage() {
Type
</TableHead>
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
Difficulty
Category
</TableHead>
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
Status
Difficulty
</TableHead>
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Points
@ -447,80 +231,65 @@ export function QuestionsPage() {
</TableRow>
</TableHeader>
<TableBody>
{paginatedQuestions.map((question, index) => (
{filteredQuestions.map((question, index) => (
<TableRow
key={question.id}
onClick={() => openDetails(question.id)}
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${
className={`transition-colors hover:bg-brand-100/30 ${
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">
<div className="truncate text-sm font-medium text-grayScale-600">
{question.question_text}
{question.question}
</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">
Options: {question.options?.map((opt) => opt.option_text).join(", ")}
Options: {question.options.join(", ")}
</div>
)}
</TableCell>
<TableCell className="py-3.5">
<Badge className={`text-xs font-medium ${typeColors[question.question_type] || "bg-grayScale-100 text-grayScale-600"}`}>
{typeLabels[question.question_type] || question.question_type}
<Badge className={`text-xs font-medium ${typeColors[question.type]}`}>
{typeLabels[question.type]}
</Badge>
</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">
{question.difficulty_level && (
{question.difficulty && (
<Badge
variant={
question.difficulty_level === "EASY"
question.difficulty === "Easy"
? "default"
: question.difficulty_level === "MEDIUM"
: question.difficulty === "Medium"
? "secondary"
: "destructive"
}
>
{question.difficulty_level}
{question.difficulty}
</Badge>
)}
</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">
{question.points ?? 0}
{question.points}
</TableCell>
<TableCell className="py-3.5 text-right">
<div className="flex items-center justify-end gap-1">
<Link to={`/content/questions/edit/${question.id}`}>
<Button
variant="ghost"
size="icon"
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" />
</Button>
</Link>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-grayScale-400 hover:bg-red-50 hover:text-destructive"
onClick={(e) => {
e.stopPropagation()
handleDeleteRequest([question.id])
}}
onClick={() => handleDelete(question.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
@ -545,225 +314,8 @@ export function QuestionsPage() {
</p>
</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>
</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>
)
}

View File

@ -1,164 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Plus, Mic, X } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Link } from "react-router-dom"
import { Plus, Mic } from "lucide-react"
import { Card, CardContent } from "../../components/ui/card"
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() {
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 (
<div className="space-y-8">
<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.
</p>
</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" />
Add New Speaking Practice
Add New Practice
</Button>
</Link>
</div>
<Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-4">
<CardTitle className="text-base font-semibold text-grayScale-600">
AUDIO Questions
</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" />
<Card className="border-2 border-dashed border-grayScale-200 shadow-none">
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
<div className="mb-6 grid h-20 w-20 place-items-center rounded-full bg-gradient-to-br from-brand-100 to-brand-200">
<Mic className="h-10 w-10 text-brand-500" />
</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">
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>
</div>
) : (
<div className="space-y-3">
{audioQuestions.map((question, idx) => (
<div
key={question.id}
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>
)}
<Link to="/content/speaking/add-practice" className="mt-8">
<Button className="bg-brand-500 px-6 hover:bg-brand-600">
<Plus className="h-4 w-4" />
Create Your First Practice
</Button>
</Link>
</CardContent>
</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>
)
}

View File

@ -1,9 +1,7 @@
import { useEffect, useState } from "react"
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 spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import { ArrowLeft, Plus, FileText, Layers, Edit, Trash2, X, Video, MoreVertical } from "lucide-react"
import { Card } from "../../components/ui/card"
import alertSrc from "../../assets/Alert.svg"
import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
@ -15,14 +13,11 @@ import {
deleteQuestionSet,
createVimeoVideo,
updateSubCourseVideo,
deleteSubCourseVideo,
getRatings,
getVimeoSample,
deleteSubCourseVideo
} from "../../api/courses.api"
import type { SubCourse, QuestionSet, SubCourseVideo, Rating, VimeoSampleVideo } from "../../types/course.types"
import { Select } from "../../components/ui/select"
import type { SubCourse, QuestionSet, SubCourseVideo } from "../../types/course.types"
type TabType = "video" | "practice" | "ratings"
type TabType = "video" | "practice"
type StatusFilter = "all" | "published" | "draft" | "archived"
export function SubCourseContentPage() {
@ -65,27 +60,12 @@ export function SubCourseContentPage() {
const [deletingVideo, setDeletingVideo] = useState(false)
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 [videoDescription, setVideoDescription] = useState("")
const [videoUrl, setVideoUrl] = useState("")
const [videoFileSize, setVideoFileSize] = 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(() => {
const fetchData = async () => {
if (!subCourseId || !courseId) return
@ -97,8 +77,8 @@ export function SubCourseContentPage() {
)
setSubCourse(foundSubCourse ?? null)
} catch (err) {
console.error("Failed to fetch course data:", err)
setError("Failed to load course")
console.error("Failed to fetch sub-course data:", err)
setError("Failed to load sub-course")
} finally {
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(() => {
if (activeTab === "practice") {
fetchPractices()
} else if (activeTab === "video") {
} else {
fetchVideos()
} else if (activeTab === "ratings") {
fetchRatings(ratingsPage * ratingsPageSize)
}
}, [activeTab, subCourseId])
useEffect(() => {
if (activeTab === "ratings") {
fetchRatings(ratingsPage * ratingsPageSize)
}
}, [ratingsPage])
const handleAddPractice = () => {
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) => {
if (statusFilter === "all") return true
if (statusFilter === "published") return practice.status === "PUBLISHED"
@ -373,8 +286,8 @@ export function SubCourseContentPage() {
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-20">
<img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading course</p>
<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 sub-course</p>
</div>
)
}
@ -382,7 +295,9 @@ export function SubCourseContentPage() {
if (error) {
return (
<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>
</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"
>
<ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
Back to Courses
Back to Sub-courses
</Link>
{/* 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" />
)}
</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>
@ -667,25 +569,15 @@ export function SubCourseContentPage() {
{/* Title */}
<h3 className="font-semibold leading-snug text-grayScale-900 line-clamp-2">{video.title}</h3>
{/* Edit / Preview buttons */}
<div className="flex gap-2">
{/* Edit button */}
<Button
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)}
>
<Edit className="mr-1.5 h-4 w-4" />
<Edit className="mr-2 h-4 w-4" />
Edit
</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 */}
<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 */}
{showDeleteModal && practiceToDelete && (
<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 */}
{showAddVideoModal && (
<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">
<h2 className="text-lg font-semibold text-grayScale-900">Add Video</h2>
<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"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="max-h-[70vh] space-y-5 overflow-y-auto 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-5 px-6 py-6">
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Title</label>
<Input
@ -1123,48 +855,6 @@ export function SubCourseContentPage() {
</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>
)
}

View File

@ -1,425 +1,223 @@
import { useEffect, useState, useRef } from "react";
import { Link, useParams, useNavigate } from "react-router-dom";
import {
ArrowLeft,
ToggleLeft,
ToggleRight,
MoreVertical,
X,
Trash2,
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";
import { useEffect, useState, useRef } from "react"
import { Link, useParams, useNavigate } from "react-router-dom"
import { ArrowLeft, Layers, ToggleLeft, ToggleRight, MoreVertical, X, Trash2, RefreshCw, AlertCircle, Edit } from "lucide-react"
import { Card, CardContent } from "../../components/ui/card"
import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button"
import { getSubCoursesByCourse, getCoursesByCategory, getCourseCategories, createSubCourse, updateSubCourse, updateSubCourseStatus, deleteSubCourse } from "../../api/courses.api"
import { Input } from "../../components/ui/input"
import type { SubCourse, Course, CourseCategory } from "../../types/course.types"
export function SubCoursesPage() {
const { categoryId, courseId } = useParams<{
categoryId: string;
courseId: string;
}>();
const navigate = useNavigate();
const [subCourses, setSubCourses] = useState<SubCourse[]>([]);
const [course, setCourse] = useState<Course | null>(null);
const [category, setCategory] = useState<CourseCategory | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { categoryId, courseId } = useParams<{ categoryId: string; courseId: string }>()
const navigate = useNavigate()
const [subCourses, setSubCourses] = useState<SubCourse[]>([])
const [course, setCourse] = useState<Course | 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 [togglingId, setTogglingId] = useState<number | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [subCourseToDelete, setSubCourseToDelete] = useState<SubCourse | null>(
null,
);
const [deleting, setDeleting] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const [openMenuId, setOpenMenuId] = useState<number | null>(null)
const [togglingId, setTogglingId] = useState<number | null>(null)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [subCourseToDelete, setSubCourseToDelete] = useState<SubCourse | null>(null)
const [deleting, setDeleting] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const [showAddModal, setShowAddModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [subCourseToEdit, setSubCourseToEdit] = useState<SubCourse | null>(
null,
);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [level, setLevel] = useState("");
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);
const [showAddModal, setShowAddModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [subCourseToEdit, setSubCourseToEdit] = useState<SubCourse | null>(null)
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [level, setLevel] = useState("")
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setOpenMenuId(null);
setOpenMenuId(null)
}
}
};
if (openMenuId !== null) {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("mousedown", handleClickOutside)
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [openMenuId]);
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [openMenuId])
const fetchSubCourses = async () => {
if (!courseId) return;
if (!courseId) return
try {
const subCoursesRes = await getSubCoursesByCourse(Number(courseId));
setSubCourses(subCoursesRes.data.data.sub_courses ?? []);
const subCoursesRes = await getSubCoursesByCourse(Number(courseId))
setSubCourses(subCoursesRes.data.data.sub_courses ?? [])
} 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(() => {
const fetchData = async () => {
if (!courseId || !categoryId) return;
if (!courseId || !categoryId) return
try {
const [subCoursesRes, coursesRes, categoriesRes] = await Promise.all([
getSubCoursesByCourse(Number(courseId)),
getCoursesByCategory(Number(categoryId)),
getCourseCategories(),
]);
])
setSubCourses(subCoursesRes.data.data.sub_courses ?? []);
setSubCourses(subCoursesRes.data.data.sub_courses ?? [])
const foundCourse = coursesRes.data.data.courses?.find(
(c) => c.id === Number(courseId),
);
setCourse(foundCourse ?? null);
(c) => c.id === Number(courseId)
)
setCourse(foundCourse ?? null)
const foundCategory = categoriesRes.data.data.categories?.find(
(c) => c.id === Number(categoryId),
);
setCategory(foundCategory ?? null);
(c) => c.id === Number(categoryId)
)
setCategory(foundCategory ?? null)
} catch (err) {
console.error("Failed to fetch sub-courses:", err);
setError("Failed to load courses");
console.error("Failed to fetch sub-courses:", err)
setError("Failed to load sub-courses")
} finally {
setLoading(false);
setLoading(false)
}
};
fetchData();
}, [courseId, categoryId]);
useEffect(() => {
if (subCourses.length > 0) {
fetchAllPrerequisites(subCourses);
}
}, [subCourses]);
fetchData()
}, [courseId, categoryId])
const handleToggleStatus = async (subCourse: SubCourse) => {
setTogglingId(subCourse.id);
setTogglingId(subCourse.id)
try {
await updateSubCourseStatus(subCourse.id, {
is_active: !subCourse.is_active,
level: subCourse.level,
title: subCourse.title,
});
await fetchSubCourses();
})
await fetchSubCourses()
} catch (err) {
console.error("Failed to update sub-course status:", err);
console.error("Failed to update sub-course status:", err)
} finally {
setTogglingId(null);
setTogglingId(null)
}
}
};
const handleDeleteClick = (subCourse: SubCourse) => {
setSubCourseToDelete(subCourse);
setShowDeleteModal(true);
};
setSubCourseToDelete(subCourse)
setShowDeleteModal(true)
}
const handleConfirmDelete = async () => {
if (!subCourseToDelete) return;
if (!subCourseToDelete) return
setDeleting(true);
setDeleting(true)
try {
await deleteSubCourse(subCourseToDelete.id);
setShowDeleteModal(false);
setSubCourseToDelete(null);
await fetchSubCourses();
await deleteSubCourse(subCourseToDelete.id)
setShowDeleteModal(false)
setSubCourseToDelete(null)
await fetchSubCourses()
} catch (err) {
console.error("Failed to delete sub-course:", err);
console.error("Failed to delete sub-course:", err)
} finally {
setDeleting(false);
setDeleting(false)
}
}
};
const handleAddSubCourse = () => {
setTitle("");
setDescription("");
setLevel("");
setSaveError(null);
setShowAddModal(true);
};
setTitle("")
setDescription("")
setLevel("")
setSaveError(null)
setShowAddModal(true)
}
const handleSaveNewSubCourse = async () => {
if (!courseId) return;
setSaving(true);
setSaveError(null);
if (!courseId) return
setSaving(true)
setSaveError(null)
try {
await createSubCourse({
course_id: Number(courseId),
title,
description,
level,
});
setShowAddModal(false);
setTitle("");
setDescription("");
setLevel("");
await fetchSubCourses();
})
setShowAddModal(false)
setTitle("")
setDescription("")
setLevel("")
await fetchSubCourses()
} catch (err) {
console.error("Failed to create sub-course:", err);
setSaveError("Failed to create course");
console.error("Failed to create sub-course:", err)
setSaveError("Failed to create sub-course")
} finally {
setSaving(false);
setSaving(false)
}
}
};
const handleEditClick = (subCourse: SubCourse) => {
setSubCourseToEdit(subCourse);
setTitle(subCourse.title);
setDescription(subCourse.description);
setLevel(subCourse.level);
setSaveError(null);
setShowEditModal(true);
};
setSubCourseToEdit(subCourse)
setTitle(subCourse.title)
setDescription(subCourse.description)
setLevel(subCourse.level)
setSaveError(null)
setShowEditModal(true)
}
const handleSaveEditSubCourse = async () => {
if (!subCourseToEdit) return;
setSaving(true);
setSaveError(null);
if (!subCourseToEdit) return
setSaving(true)
setSaveError(null)
try {
await updateSubCourse(subCourseToEdit.id, {
title,
description,
level,
});
setShowEditModal(false);
setSubCourseToEdit(null);
setTitle("");
setDescription("");
setLevel("");
await fetchSubCourses();
})
setShowEditModal(false)
setSubCourseToEdit(null)
setTitle("")
setDescription("")
setLevel("")
await fetchSubCourses()
} catch (err) {
console.error("Failed to update sub-course:", err);
setSaveError("Failed to update course");
console.error("Failed to update sub-course:", err)
setSaveError("Failed to update sub-course")
} finally {
setSaving(false);
setSaving(false)
}
}
};
const handleSubCourseClick = (subCourseId: number) => {
navigate(
`/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);
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`)
}
};
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) {
return (
<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" />
</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>
);
)
}
if (error) {
return (
<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">
<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>
</div>
</div>
);
)
}
return (
@ -435,75 +233,30 @@ export function SubCoursesPage() {
</Link>
<div className="min-w-0">
<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">
{category?.name}
</span>
<span className="truncate max-w-[100px] rounded bg-grayScale-50 px-1.5 py-0.5 sm:max-w-none">{category?.name}</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">
{course?.title}
</span>
<span className="truncate max-w-[100px] rounded bg-grayScale-50 px-1.5 py-0.5 sm:max-w-none">{course?.title}</span>
</div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
Courses
</h1>
<p className="mt-0.5 text-sm text-grayScale-400">
{subCourses.length} course{subCourses.length !== 1 ? "s" : ""}{" "}
available
</p>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">Sub-courses</h1>
<p className="mt-0.5 text-sm text-grayScale-400">{subCourses.length} sub-course{subCourses.length !== 1 ? "s" : ""} available</p>
</div>
</div>
<div className="flex items-center gap-2">
{subCourses.length > 0 && (
<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 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 Sub-course
</Button>
</div>
</div>
{/* Sub-course grid or empty state */}
{subCourses.length === 0 ? (
<Card className="border border-dashed border-grayScale-200 shadow-none">
<CardContent className="flex flex-col items-center justify-center py-16">
<img src={practiceSrc} alt="" className="h-20 w-20" />
<h3 className="mt-5 text-base font-semibold text-grayScale-600">
No courses yet
</h3>
<p className="mt-1.5 max-w-xs text-center text-sm text-grayScale-400">
Get started by adding your first course to this sub-category
</p>
<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
<div className="rounded-2xl bg-brand-50 p-5">
<Layers className="h-10 w-10 text-brand-400" />
</div>
<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">Get started by adding your first sub-course to this course</p>
<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 sub-course
</Button>
</CardContent>
</Card>
@ -515,7 +268,7 @@ export function SubCoursesPage() {
"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-yellow-100 to-yellow-200",
];
]
return (
<Card
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"
/>
) : (
<div
className={`h-full w-full rounded-t-lg transition-transform duration-300 group-hover:scale-105 ${gradients[index % gradients.length]}`}
/>
<div className={`h-full w-full rounded-t-lg transition-transform duration-300 group-hover:scale-105 ${gradients[index % gradients.length]}`} />
)}
{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">
@ -553,9 +304,7 @@ export function SubCoursesPage() {
: "border border-grayScale-200 bg-grayScale-50 text-grayScale-500"
}`}
>
<span
className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${subCourse.is_active ? "bg-green-500" : "bg-grayScale-400"}`}
/>
<span 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"}
</Badge>
<div
@ -564,11 +313,7 @@ export function SubCoursesPage() {
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() =>
setOpenMenuId(
openMenuId === subCourse.id ? null : subCourse.id,
)
}
onClick={() => 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"
>
<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">
<button
onClick={() => {
handlePrereqClick(subCourse);
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);
handleToggleStatus(subCourse)
setOpenMenuId(null)
}}
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"
@ -609,8 +343,8 @@ export function SubCoursesPage() {
<div className="mx-3 border-t border-grayScale-100" />
<button
onClick={() => {
handleDeleteClick(subCourse);
setOpenMenuId(null);
handleDeleteClick(subCourse)
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"
>
@ -624,9 +358,7 @@ export function SubCoursesPage() {
{/* Title */}
<div>
<h3 className="font-semibold text-grayScale-700 group-hover:text-brand-600 transition-colors">
{subCourse.title}
</h3>
<h3 className="font-semibold text-grayScale-700 group-hover:text-brand-600 transition-colors">{subCourse.title}</h3>
<p className="mt-1 text-sm leading-relaxed text-grayScale-400 line-clamp-2">
{subCourse.description || "No description available"}
</p>
@ -637,8 +369,8 @@ export function SubCoursesPage() {
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"
onClick={(e) => {
e.stopPropagation();
handleEditClick(subCourse);
e.stopPropagation()
handleEditClick(subCourse)
}}
>
<Edit className="mr-2 h-3.5 w-3.5" />
@ -646,7 +378,7 @@ export function SubCoursesPage() {
</Button>
</div>
</Card>
);
)
})}
</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="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">
<h2 className="text-lg font-semibold text-grayScale-700">
Delete Course
</h2>
<h2 className="text-lg font-semibold text-grayScale-700">Delete Sub-course</h2>
<button
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"
@ -673,10 +403,8 @@ export function SubCoursesPage() {
</div>
<p className="text-center text-sm leading-relaxed text-grayScale-600">
Are you sure you want to delete{" "}
<span className="font-semibold text-grayScale-700">
{subCourseToDelete.title}
</span>
? This action cannot be undone.
<span className="font-semibold text-grayScale-700">{subCourseToDelete.title}</span>? This action cannot
be undone.
</p>
</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="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">
<h2 className="text-lg font-semibold text-grayScale-700">
Add New Course
</h2>
<h2 className="text-lg font-semibold text-grayScale-700">Add New Sub-course</h2>
<button
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"
@ -719,31 +445,25 @@ export function SubCoursesPage() {
<div className="space-y-5 px-6 py-6">
<div className="space-y-1.5">
<label className="text-sm font-semibold text-grayScale-600">
Title
</label>
<label className="text-sm font-semibold text-grayScale-600">Title</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter course title"
placeholder="Enter sub-course title"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-semibold text-grayScale-600">
Description
</label>
<label className="text-sm font-semibold text-grayScale-600">Description</label>
<textarea
value={description}
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"
rows={3}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-semibold text-grayScale-600">
Level
</label>
<label className="text-sm font-semibold text-grayScale-600">Level</label>
<Input
value={level}
onChange={(e) => setLevel(e.target.value)}
@ -779,149 +499,12 @@ export function SubCoursesPage() {
</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 */}
{showEditModal && subCourseToEdit && (
<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="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-700">
Edit Course
</h2>
<h2 className="text-lg font-semibold text-grayScale-700">Edit Sub-course</h2>
<button
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"
@ -932,31 +515,25 @@ export function SubCoursesPage() {
<div className="space-y-5 px-6 py-6">
<div className="space-y-1.5">
<label className="text-sm font-semibold text-grayScale-600">
Title
</label>
<label className="text-sm font-semibold text-grayScale-600">Title</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter course title"
placeholder="Enter sub-course title"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-semibold text-grayScale-600">
Description
</label>
<label className="text-sm font-semibold text-grayScale-600">Description</label>
<textarea
value={description}
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"
rows={3}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-semibold text-grayScale-600">
Level
</label>
<label className="text-sm font-semibold text-grayScale-600">Level</label>
<Input
value={level}
onChange={(e) => setLevel(e.target.value)}
@ -992,5 +569,5 @@ export function SubCoursesPage() {
</div>
)}
</div>
);
)
}

View File

@ -46,7 +46,6 @@ import {
getIssueById,
updateIssueStatus,
deleteIssue,
createIssue,
} from "../../api/issues.api";
import type { Issue, IssueFilters } from "../../types/issue.types";
@ -208,9 +207,6 @@ export function IssuesPage() {
const [createSubject, setCreateSubject] = useState("");
const [createType, setCreateType] = useState<string>("bug");
const [createDescription, setCreateDescription] = useState("");
const [createDevice, setCreateDevice] = useState("");
const [createBrowser, setCreateBrowser] = useState("");
const [createSubmitting, setCreateSubmitting] = useState(false);
const fetchIssues = useCallback(async () => {
setLoading(true);
@ -526,6 +522,7 @@ export function IssuesPage() {
const typeConfig = getIssueTypeConfig(issue.issue_type);
const statusConfig = getStatusConfig(issue.status);
const TypeIcon = typeConfig.icon;
const StatusIcon = statusConfig.icon;
return (
<TableRow key={issue.id} className="group">
@ -910,29 +907,6 @@ export function IssuesPage() {
onChange={(e) => setCreateDescription(e.target.value)}
/>
</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 className="mt-5 flex items-center justify-end gap-2">
@ -943,49 +917,24 @@ export function IssuesPage() {
setCreateSubject("");
setCreateDescription("");
setCreateType("bug");
setCreateDevice("");
setCreateBrowser("");
}}
disabled={createSubmitting}
>
Cancel
</Button>
<Button
className="bg-brand-500 text-white hover:bg-brand-600"
disabled={createSubmitting || !createSubject.trim() || !createDescription.trim()}
onClick={async () => {
if (!createSubject.trim() || !createDescription.trim()) return;
setCreateSubmitting(true);
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;
onClick={() => {
// Hook to create-issue API here; currently UI-only.
if (!createSubject.trim() || !createDescription.trim()) {
return;
}
await createIssue(payload);
setCreateOpen(false);
setCreateSubject("");
setCreateDescription("");
setCreateType("bug");
setCreateDevice("");
setCreateBrowser("");
fetchIssues();
} catch (error) {
console.error("Failed to create issue:", error);
} finally {
setCreateSubmitting(false);
}
}}
>
{createSubmitting ? "Creating..." : "Create Issue"}
Create Issue
</Button>
</div>
</DialogContent>

View File

@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useMemo } from "react"
import { useEffect, useState, useCallback } from "react"
import {
Bell,
BellOff,
@ -20,9 +20,6 @@ import {
CheckCheck,
MailX,
Search,
ChevronDown,
Calendar,
Clock3,
} from "lucide-react"
import { Card, CardContent } from "../../components/ui/card"
import { Badge } from "../../components/ui/badge"
@ -38,17 +35,6 @@ import {
DialogHeader,
DialogTitle,
} 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 { cn } from "../../lib/utils"
import {
@ -58,18 +44,10 @@ import {
markAsUnread,
markAllRead,
markAllUnread,
sendBulkSms,
sendBulkEmail,
sendBulkPush,
} from "../../api/notifications.api"
import { getRoles } from "../../api/rbac.api"
import { getTeamMembers } from "../../api/team.api"
import { getUsers } from "../../api/users.api"
import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types"
import type { Role } from "../../types/rbac.types"
import type { Notification } from "../../types/notification.types"
import type { TeamMember } from "../../types/team.types"
import type { UserApiDTO } from "../../types/user.types"
import { toast } from "sonner"
const PAGE_SIZE = 10
@ -135,10 +113,6 @@ function formatTypeLabel(type: string) {
.join(" ")
}
function digitsOnly(value: string, maxLength: number) {
return value.replace(/\D/g, "").slice(0, maxLength)
}
function NotificationItem({
notification,
onToggleRead,
@ -187,7 +161,7 @@ function NotificationItem({
notification.is_read ? "text-grayScale-600" : "text-grayScale-800",
)}
>
{getNotificationTitle(notification)}
{notification.payload.headline}
</span>
<Badge variant={getLevelBadge(notification.level)} className="text-[10px] px-1.5 py-0">
{notification.level}
@ -199,7 +173,7 @@ function NotificationItem({
notification.is_read ? "text-grayScale-400" : "text-grayScale-600",
)}
>
{getNotificationMessage(notification)}
{notification.payload.message}
</p>
</div>
@ -287,152 +261,6 @@ export function NotificationsPage() {
const [composeOpen, setComposeOpen] = useState(false)
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) => {
setLoading(true)
setError(false)
@ -516,8 +344,8 @@ export function NotificationsPage() {
if (searchTerm.trim()) {
const q = searchTerm.toLowerCase()
const haystack = [
getNotificationTitle(n),
getNotificationMessage(n),
n.payload.headline,
n.payload.message,
formatTypeLabel(n.type),
n.delivery_channel,
n.level,
@ -590,10 +418,10 @@ export function NotificationsPage() {
<Button
size="sm"
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" />
Send notification
<Megaphone className="mr-2 h-3.5 w-3.5" />
New notification
</Button>
{notifications.length > 0 && (
<>
@ -644,7 +472,7 @@ export function NotificationsPage() {
{totalCount.toLocaleString()}
</p>
</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" />
</div>
</CardContent>
@ -857,12 +685,12 @@ export function NotificationsPage() {
n.is_read ? "text-grayScale-600" : "text-grayScale-800",
)}
>
{getNotificationTitle(n)}
{n.payload.headline}
</p>
</TableCell>
<TableCell className="hidden lg:table-cell">
<p className="max-w-sm truncate text-xs text-grayScale-500">
{getNotificationMessage(n)}
{n.payload.message}
</p>
</TableCell>
<TableCell>
@ -969,7 +797,7 @@ export function NotificationsPage() {
})()}
</span>
<span className="truncate text-base">
{getNotificationTitle(selectedNotification)}
{selectedNotification.payload.headline}
</span>
</DialogTitle>
<DialogDescription>
@ -981,7 +809,7 @@ export function NotificationsPage() {
<div className="space-y-4">
<div className="rounded-lg bg-grayScale-50 p-3">
<p className="text-sm text-grayScale-600">
{getNotificationMessage(selectedNotification)}
{selectedNotification.payload.message}
</p>
</div>
@ -1245,487 +1073,6 @@ export function NotificationsPage() {
</form>
</DialogContent>
</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>
)
}

View File

@ -1,337 +1,104 @@
import { useEffect, useMemo, useState } from "react"
import { ArrowLeft, Loader2, Search, X, Check } from "lucide-react"
import { useState } from "react"
import { ArrowLeft } from "lucide-react"
import { useNavigate } from "react-router-dom"
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 { Textarea } from "../../components/ui/textarea"
import { Badge } from "../../components/ui/badge"
import { createRole, setRolePermissions, getAllPermissions } from "../../api/rbac.api"
import type { RolePermission } from "../../types/rbac.types"
import { cn } from "../../lib/utils"
import { toast } from "sonner"
const permissions = [
"View Dashboard",
"Manage Users",
"Manage Roles",
"Manage Practices",
"View Reports",
"Manage Content",
"Manage Settings",
]
export function AddRolePage() {
const navigate = useNavigate()
const [roleName, setRoleName] = 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 [permissionsMap, setPermissionsMap] = useState<Record<string, RolePermission[]>>({})
const [permLoading, setPermLoading] = useState(true)
const [permSearch, setPermSearch] = useState("")
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],
const togglePermission = (permission: string) => {
setSelectedPermissions((prev) =>
prev.includes(permission)
? prev.filter((p) => p !== permission)
: [...prev, permission],
)
// 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))
}, [permissionsMap, permSearch])
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.`)
const handleSubmit = () => {
console.log("Add role:", { roleName, roleDescription, selectedPermissions })
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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate("/roles")} className="h-8 w-8">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<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>
<h1 className="text-xl font-semibold text-grayScale-900">Add New Role</h1>
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)]">
{/* Left Role info */}
<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">
<Card className="p-6">
<div className="space-y-6">
<div>
<label className="mb-1.5 block text-xs font-medium text-grayScale-500">
Role Name
</label>
<label className="mb-2 block text-sm font-medium text-grayScale-700">Role Name</label>
<Input
value={roleName}
onChange={(e) => setRoleName(e.target.value)}
placeholder="e.g. CONTENT_MANAGER"
placeholder="Enter role name"
required
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-grayScale-500">
Description
<label className="mb-2 block text-sm font-medium text-grayScale-700">
Role Description
</label>
<Textarea
value={roleDescription}
onChange={(e) => setRoleDescription(e.target.value)}
placeholder="Describe what this role can do…"
placeholder="Enter role description"
rows={3}
required
/>
</div>
<div className="border-t border-grayScale-100 pt-4">
<div className="flex items-center justify-between text-xs text-grayScale-400">
<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">
<div>
<label className="mb-4 block text-sm font-medium text-grayScale-700">
Permissions
</CardTitle>
<div className="flex items-center gap-2">
<Button
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>
<div className="space-y-2">
{permissions.map((permission) => (
<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",
)}
key={permission}
className="flex items-center gap-2 rounded-lg border p-3 hover:bg-grayScale-50 cursor-pointer"
>
<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"
checked={selectedPermissions.includes(permission)}
onChange={() => togglePermission(permission)}
className="h-4 w-4 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>
<span className="text-sm text-grayScale-700">{permission}</span>
</label>
)
})}
))}
</div>
</div>
)
})
)}
<div className="flex justify-end">
<Button onClick={handleSubmit} className="bg-brand-500 hover:bg-brand-600">
Add Role
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@ -1,242 +1,19 @@
import { useEffect, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom"
import {
Plus, Search, Shield, ShieldCheck, ChevronLeft, ChevronRight,
Loader2, AlertCircle, Eye, X, Pencil, Check,
} from "lucide-react"
import { Plus, Edit } from "lucide-react"
import { Button } from "../../components/ui/button"
import { Card, CardContent } from "../../components/ui/card"
import { Badge } from "../../components/ui/badge"
import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea"
import {
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"
const mockRoles = [
{ id: "1", name: "Admin", userCount: 10 },
{ id: "2", name: "User", userCount: 5 },
]
export function RolesListPage() {
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 (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">Role Management</h1>
<p className="mt-1 text-sm text-grayScale-400">
Manage roles and their permissions.
</p>
</div>
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-grayScale-900">Role Management</h1>
<Button
onClick={() => navigate("/roles/add")}
className="bg-brand-500 hover:bg-brand-600"
@ -246,448 +23,22 @@ export function RolesListPage() {
</Button>
</div>
{/* Search */}
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search roles…"
className="pl-9"
/>
{query && (
<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
<div className="grid gap-4 md:grid-cols-2">
{mockRoles.map((role) => (
<Card key={role.id} className="overflow-hidden shadow-sm">
<div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" />
<CardContent className="p-6">
<h3 className="mb-2 text-lg font-semibold text-grayScale-900">{role.name}</h3>
<p className="mb-4 text-sm text-grayScale-600">{role.userCount} Users</p>
<Button variant="outline" className="w-full">
<Edit className="mr-2 h-4 w-4" />
Edit Role
</Button>
</div>
</CardContent>
</Card>
))}
</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>
)
}

View File

@ -7,7 +7,6 @@ import {
SlidersHorizontal,
ChevronLeft,
ChevronRight,
X,
} from "lucide-react";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
@ -21,9 +20,8 @@ import {
} from "../../components/ui/table";
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
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 { toast } from "sonner";
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString("en-US", {
@ -90,8 +88,6 @@ export function TeamManagementPage() {
const [roleFilter, setRoleFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
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(() => {
const fetchMembers = async () => {
@ -137,36 +133,7 @@ export function TeamManagementPage() {
};
const handleToggle = (id: number) => {
const member = members.find((m) => m.id === 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);
setToggledStatuses((prev) => ({ ...prev, [id]: !prev[id] }));
};
return (
@ -242,8 +209,6 @@ export function TeamManagementPage() {
<TableRow>
<TableHead>USER</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>STATUS</TableHead>
</TableRow>
@ -252,7 +217,7 @@ export function TeamManagementPage() {
<TableBody>
{members.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-grayScale-400">
<TableCell colSpan={4} className="text-center text-grayScale-400">
No team members found
</TableCell>
</TableRow>
@ -296,12 +261,6 @@ export function TeamManagementPage() {
{formatRoleLabel(member.team_role)}
</span>
</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">
{member.last_login ? (
<div>
@ -324,16 +283,13 @@ export function TeamManagementPage() {
type="button"
onClick={() => handleToggle(member.id)}
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",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-300 focus-visible:ring-offset-1",
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"
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
isActive ? "bg-brand-500" : "bg-grayScale-200"
)}
>
<span
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"
)}
/>
@ -416,42 +372,6 @@ export function TeamManagementPage() {
</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>
);
}

View File

@ -2,19 +2,47 @@ import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import {
ArrowLeft,
Briefcase,
Building2,
Calendar,
CheckCircle2,
Clock,
Globe,
KeyRound,
MessageCircle,
Mail,
Phone,
Shield,
User,
XCircle,
} from "lucide-react";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card";
import { Separator } from "../../components/ui/separator";
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
import { cn } from "../../lib/utils";
import { getTeamMemberById } from "../../api/team.api";
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 {
return role
.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() {
return (
<div className="space-y-6">
<div className="h-5 w-32 animate-pulse rounded bg-grayScale-100" />
<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="lg:col-span-2 rounded-2xl bg-grayScale-100 h-96" />
<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 className="rounded-2xl bg-grayScale-100 h-52" />
</div>
</div>
</div>
@ -141,153 +157,33 @@ export function TeamMemberDetailPage() {
Back to Team
</Link>
{/* Hero Banner */}
<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="relative z-10 max-w-2xl">
<h1 className="text-3xl font-bold text-white sm:text-4xl">
Hello {member.first_name}
</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">
<Card className="overflow-hidden">
<div className="h-28 bg-gradient-to-r from-brand-600 via-brand-400 to-mint-500" />
<CardContent className="-mt-12 px-4 sm:px-8 pb-4 sm:pb-8 pt-0">
<div className="flex flex-col items-start gap-5 sm:flex-row sm:items-end">
<Avatar className="h-24 w-24 ring-4 ring-white shadow-soft">
<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}
</AvatarFallback>
</Avatar>
</div>
</div>
<h3 className="mt-4 text-lg font-bold text-grayScale-600">
{fullName}
</h3>
<p className="text-sm text-grayScale-400">{member.job_title}</p>
{/* 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">
<div className="flex-1 pb-1">
<h1 className="text-2xl font-bold text-grayScale-600">{fullName}</h1>
<p className="mt-0.5 text-sm text-grayScale-400">{member.job_title} · {member.department}</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span
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)
)}
>
<Shield className="h-3 w-3" />
{formatRoleLabel(member.team_role)}
</span>
</p>
</div>
<div className="px-2">
<p className="text-xs font-medium text-grayScale-400">Status</p>
<p className="mt-1">
<span
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"
? "bg-mint-100 text-mint-500"
: "bg-destructive/10 text-destructive"
@ -301,17 +197,136 @@ export function TeamMemberDetailPage() {
/>
{member.status === "active" ? "Active" : "Inactive"}
</span>
</p>
</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">
<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">
{formatEmploymentType(member.employment_type)}
</p>
</span>
</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>
</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>
);

View File

@ -1,5 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
import {
Search,
ChevronDown,
@ -377,7 +376,7 @@ export function UserLogPage() {
<TableRow>
<TableCell colSpan={6} className="text-center py-12">
<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>
</div>
</TableCell>

View File

@ -1,13 +1,11 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect } from "react";
import {
ArrowLeft,
BarChart3,
BookOpen,
Calendar,
CheckCircle2,
Globe,
GraduationCap,
Lock,
Mail,
MapPin,
Phone,
@ -25,22 +23,6 @@ import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"
import { cn } from "../../lib/utils";
import { useUsersStore } from "../../zustand/userStore";
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> = {
completed: CheckCircle2,
@ -48,19 +30,10 @@ const activityIcons: Record<string, typeof CheckCircle2> = {
joined: UserPlus,
};
type CourseOption = Course & { category_name: string };
export function UserDetailPage() {
const { id } = useParams();
const userProfile = useUsersStore((s) => s.userProfile);
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(() => {
if (!id) return;
@ -76,103 +49,6 @@ export function UserDetailPage() {
fetchUser();
}, [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) {
return (
<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 },
];
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<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"
>
<ArrowLeft className="h-4 w-4" />
@ -428,132 +285,6 @@ export function UserDetailPage() {
</CardContent>
</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 */}
<Card>
<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 }) {
return (
<div>

View File

@ -1,4 +1,3 @@
import { useEffect, useState } from "react"
import { Link } from "react-router-dom"
import {
Users,
@ -8,35 +7,10 @@ import {
ArrowRight,
List,
UsersRound,
Loader2,
} from "lucide-react"
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() {
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 (
<div className="space-y-8">
{/* Page Header */}
@ -56,9 +30,7 @@ export function UserManagementDashboard() {
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white/80">Total Users</p>
<p className="text-2xl font-bold text-white">
{statsLoading ? <Loader2 className="h-5 w-5 animate-spin text-white" /> : stats ? formatNum(stats.total_users) : "—"}
</p>
<p className="text-2xl font-bold text-white">1,248</p>
</div>
</CardContent>
</Card>
@ -70,15 +42,7 @@ export function UserManagementDashboard() {
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white/80">Active Users</p>
<p className="text-2xl font-bold text-white">
{statsLoading ? (
<Loader2 className="h-5 w-5 animate-spin text-white" />
) : activeUsers !== null ? (
formatNum(activeUsers)
) : (
"—"
)}
</p>
<p className="text-2xl font-bold text-white">1,180</p>
</div>
</CardContent>
</Card>
@ -90,15 +54,7 @@ export function UserManagementDashboard() {
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white/80">New This Month</p>
<p className="text-2xl font-bold text-white">
{statsLoading ? (
<Loader2 className="h-5 w-5 animate-spin text-white" />
) : stats ? (
formatNum(stats.new_month)
) : (
"—"
)}
</p>
<p className="text-2xl font-bold text-white">64</p>
</div>
</CardContent>
</Card>

View File

@ -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 { useNavigate } from "react-router-dom"
import { Input } from "../../components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"
import { Button } from "../../components/ui/button"
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 { useUsersStore } from "../../zustand/userStore"
import { toast } from "sonner"
export function UsersListPage() {
const navigate = useNavigate()
@ -28,25 +26,14 @@ export function UsersListPage() {
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [toggledStatuses, setToggledStatuses] = useState<Record<number, boolean>>({})
const [updatingStatusIds, setUpdatingStatusIds] = useState<Set<number>>(new Set())
const [confirmDialog, setConfirmDialog] = useState<{
id: number
name: string
nextStatus: UserStatus
} | null>(null)
const [roleFilter, setRoleFilter] = useState("")
const [statusFilter, setStatusFilter] = useState("")
const [countryFilter, setCountryFilter] = useState("")
const [regionFilter, setRegionFilter] = useState("")
const [subscriptionFilter, setSubscriptionFilter] = useState("")
useEffect(() => {
const fetchUsers = async () => {
try {
const res = await getUsers(
page,
pageSize,
roleFilter || undefined,
statusFilter || undefined,
search || undefined,
)
const res = await getUsers(page, pageSize)
const apiUsers = res.data.data.users
const mapped = apiUsers.map(mapUserApiToUser)
@ -55,7 +42,7 @@ export function UsersListPage() {
const initialStatuses: Record<number, boolean> = {}
mapped.forEach((u) => {
initialStatuses[u.id] = u.status === "ACTIVE"
initialStatuses[u.id] = true
})
setToggledStatuses((prev) => ({ ...prev, ...initialStatuses }))
} catch (error) {
@ -66,7 +53,7 @@ export function UsersListPage() {
}
fetchUsers()
}, [page, pageSize, roleFilter, statusFilter, search, setUsers, setTotal])
}, [page, pageSize, setUsers, setTotal])
const pageCount = Math.max(1, Math.ceil(total / pageSize))
const safePage = Math.min(page, pageCount)
@ -115,46 +102,7 @@ export function UsersListPage() {
}
const handleToggle = (id: number) => {
if (updatingStatusIds.has(id)) return
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)
}
setToggledStatuses((prev) => ({ ...prev, [id]: !prev[id] }))
}
const handleRowClick = (userId: number) => {
@ -186,29 +134,45 @@ export function UsersListPage() {
<div className="flex flex-wrap items-center gap-3">
<div className="relative w-full sm:w-auto">
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
value={countryFilter}
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"
>
<option value="">All roles</option>
<option value="STUDENT">Student</option>
<option value="TEACHER">Teacher</option>
<option value="ADMIN">Admin</option>
<option value="">Country</option>
<option value="USA">USA</option>
<option value="UK">UK</option>
<option value="Canada">Canada</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={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
value={regionFilter}
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"
>
<option value="">All statuses</option>
<option value="ACTIVE">Active</option>
<option value="DEACTIVATED">Deactivated</option>
<option value="SUSPENDED">Suspended</option>
<option value="PENDING">Pending</option>
<option value="">Region</option>
<option value="North">North</option>
<option value="South">South</option>
<option value="East">East</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>
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
</div>
@ -229,7 +193,6 @@ export function UsersListPage() {
/>
</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">Country</TableHead>
<TableHead className="hidden md:table-cell">Region</TableHead>
@ -240,7 +203,7 @@ export function UsersListPage() {
<TableBody>
{users.length === 0 ? (
<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 h-14 w-14 items-center justify-center rounded-full bg-grayScale-100">
<Users className="h-7 w-7 text-grayScale-400" />
@ -255,7 +218,6 @@ export function UsersListPage() {
) : (
users.map((u) => {
const isActive = toggledStatuses[u.id] ?? false
const isUpdatingStatus = updatingStatusIds.has(u.id)
return (
<TableRow
key={u.id}
@ -284,7 +246,6 @@ export function UsersListPage() {
</div>
</div>
</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.country || "-"}</TableCell>
<TableCell className="hidden md:table-cell text-grayScale-500">{u.region || "-"}</TableCell>
@ -292,19 +253,14 @@ export function UsersListPage() {
<button
type="button"
onClick={() => handleToggle(u.id)}
disabled={isUpdatingStatus}
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",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-300 focus-visible:ring-offset-1",
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",
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
isActive ? "bg-brand-500" : "bg-grayScale-200"
)}
>
<span
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"
)}
/>
@ -388,41 +344,6 @@ export function UsersListPage() {
</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>
)
}

View File

@ -318,8 +318,6 @@ export interface PracticeQuestion {
id: number
practice_id: number
question: string
points?: number
difficulty_level?: string
question_voice_prompt: string
sample_answer_voice_prompt: string
sample_answer: string
@ -345,11 +343,6 @@ export interface CreatePracticeQuestionRequest {
sample_answer_voice_prompt?: string
sample_answer: string
tips?: string
explanation?: string
difficulty_level?: string
points?: number
options?: QuestionOption[]
short_answers?: string[]
type: "MCQ" | "TRUE_FALSE" | "SHORT"
}
@ -359,11 +352,6 @@ export interface UpdatePracticeQuestionRequest {
sample_answer_voice_prompt?: string
sample_answer: string
tips?: string
explanation?: string
difficulty_level?: string
points?: number
options?: QuestionOption[]
short_answers?: string[]
type: "MCQ" | "TRUE_FALSE" | "SHORT"
}
@ -387,65 +375,7 @@ export interface QuestionSet {
export interface GetQuestionSetsResponse {
message: string
data: QuestionSet[] | { question_sets: QuestionSet[]; total_count?: number }
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[]
data: QuestionSet[]
success: boolean
status_code: number
metadata: unknown
@ -466,7 +396,7 @@ export interface CreateQuestionSetRequest {
}
export interface AddQuestionToSetRequest {
display_order?: number
display_order: number
question_id: number
}
@ -479,16 +409,15 @@ export interface QuestionOption {
export interface CreateQuestionRequest {
question_text: string
question_type: string
difficulty_level?: string
points?: number
difficulty_level: string
points: number
tips?: string
explanation?: string
status?: string
options?: QuestionOption[]
voice_prompt?: string
sample_answer_voice_prompt?: string
audio_correct_answer_text?: string
short_answers?: string[] | { acceptable_answer: string; match_type: "EXACT" | "CASE_INSENSITIVE" }[]
short_answers?: string[]
}
export interface CreateQuestionResponse {
@ -501,52 +430,6 @@ export interface CreateQuestionResponse {
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 {
message: string
data: {
@ -556,145 +439,3 @@ export interface CreateQuestionSetResponse {
status_code: number
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
}

View File

@ -32,21 +32,6 @@ export interface GetIssueResponse {
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 {
message: string;
success: boolean;

View File

@ -1,8 +1,6 @@
export interface NotificationPayload {
headline?: string
title?: string
message?: string
body?: string
headline: string
message: string
tags: string[] | null
}
@ -22,28 +20,6 @@ export interface Notification {
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 {
notifications: Notification[]
total_count: number

View File

@ -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
}

View File

@ -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
}

View File

@ -50,11 +50,9 @@ export interface User {
nickName: string
email: string
phoneNumber: string
role: string
region: string
country: string
lastLogin: string | null
status: string
}
export const mapUserApiToUser = (u: UserApiDTO): User => ({
@ -64,11 +62,9 @@ export const mapUserApiToUser = (u: UserApiDTO): User => ({
nickName: u.nick_name,
email: u.email,
phoneNumber: u.phone_number ?? "",
role: u.role,
region: u.region,
country: u.country,
lastLogin: null,
status: u.status,
})
export interface UserProfileData {
@ -114,35 +110,3 @@ export interface UserProfileResponse {
data: UserProfileData
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
}