team management + profile + content management integrations + minor fixes
This commit is contained in:
parent
cda7d9d551
commit
a29d82bfee
2
.env
2
.env
|
|
@ -1 +1 @@
|
||||||
VITE_API_BASE_URL=http://195.35.29.82:8080/api/v1
|
VITE_API_BASE_URL=http://localhost:8080/api/v1
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,22 @@
|
||||||
import http from "./http";
|
import http from "./http"
|
||||||
import type { LoginRequest, LoginResponse, LoginResponseData } from "../types/auth.types";
|
import type { LoginRequest, LoginResponse, LoginResponseData } from "../types/auth.types"
|
||||||
|
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
accessToken: string;
|
accessToken: string
|
||||||
refreshToken: string;
|
refreshToken: string
|
||||||
role: string;
|
role: string
|
||||||
user_id: number;
|
memberId: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const login = async (payload: LoginRequest): Promise<LoginResult> => {
|
export const login = async (payload: LoginRequest): Promise<LoginResult> => {
|
||||||
const res = await http.post<LoginResponse>("/auth/customer-login", payload);
|
const res = await http.post<LoginResponse>("/team/login", payload)
|
||||||
|
|
||||||
const data: LoginResponseData = res.data.data;
|
const data: LoginResponseData = res.data.data
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken: data.access_token,
|
accessToken: data.access_token,
|
||||||
refreshToken: data.refresh_token,
|
refreshToken: data.refresh_token,
|
||||||
role: data.role,
|
role: data.team_role,
|
||||||
user_id: data.user_id,
|
memberId: data.member_id,
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
|
||||||
193
src/api/courses.api.ts
Normal file
193
src/api/courses.api.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
import http from "./http"
|
||||||
|
import type {
|
||||||
|
GetCourseCategoriesResponse,
|
||||||
|
GetCoursesResponse,
|
||||||
|
CreateCourseRequest,
|
||||||
|
UpdateCourseRequest,
|
||||||
|
GetSubCoursesResponse,
|
||||||
|
CreateSubCourseRequest,
|
||||||
|
UpdateSubCourseRequest,
|
||||||
|
UpdateSubCourseStatusRequest,
|
||||||
|
GetSubCourseVideosResponse,
|
||||||
|
CreateSubCourseVideoRequest,
|
||||||
|
UpdateSubCourseVideoRequest,
|
||||||
|
GetPracticesResponse,
|
||||||
|
CreatePracticeRequest,
|
||||||
|
UpdatePracticeRequest,
|
||||||
|
UpdatePracticeStatusRequest,
|
||||||
|
GetPracticeQuestionsResponse,
|
||||||
|
CreatePracticeQuestionRequest,
|
||||||
|
UpdatePracticeQuestionRequest,
|
||||||
|
GetProgramsResponse,
|
||||||
|
GetLevelsResponse,
|
||||||
|
GetModulesResponse,
|
||||||
|
UpdateProgramStatusRequest,
|
||||||
|
CreateProgramRequest,
|
||||||
|
UpdateProgramRequest,
|
||||||
|
CreateLevelRequest,
|
||||||
|
UpdateLevelRequest,
|
||||||
|
UpdateLevelStatusRequest,
|
||||||
|
CreateModuleRequest,
|
||||||
|
UpdateModuleRequest,
|
||||||
|
UpdateModuleStatusRequest,
|
||||||
|
GetQuestionSetsResponse,
|
||||||
|
CreateQuestionSetRequest,
|
||||||
|
CreateQuestionSetResponse,
|
||||||
|
AddQuestionToSetRequest,
|
||||||
|
CreateQuestionRequest,
|
||||||
|
CreateQuestionResponse,
|
||||||
|
CreateVimeoVideoRequest,
|
||||||
|
} from "../types/course.types"
|
||||||
|
|
||||||
|
export const getCourseCategories = () =>
|
||||||
|
http.get<GetCourseCategoriesResponse>("/course-management/categories")
|
||||||
|
|
||||||
|
export const getCoursesByCategory = (categoryId: number) =>
|
||||||
|
http.get<GetCoursesResponse>(`/course-management/categories/${categoryId}/courses`)
|
||||||
|
|
||||||
|
export const createCourse = (data: CreateCourseRequest) =>
|
||||||
|
http.post("/course-management/courses", data)
|
||||||
|
|
||||||
|
export const deleteCourse = (courseId: number) =>
|
||||||
|
http.delete(`/course-management/courses/${courseId}`)
|
||||||
|
|
||||||
|
export const updateCourseStatus = (courseId: number, isActive: boolean) =>
|
||||||
|
http.put(`/course-management/courses/${courseId}`, { is_active: isActive })
|
||||||
|
|
||||||
|
export const updateCourse = (courseId: number, data: UpdateCourseRequest) =>
|
||||||
|
http.put(`/course-management/courses/${courseId}`, data)
|
||||||
|
|
||||||
|
// SubCourse APIs (New Hierarchy)
|
||||||
|
export const getSubCoursesByCourse = (courseId: number) =>
|
||||||
|
http.get<GetSubCoursesResponse>(`/course-management/courses/${courseId}/sub-courses`)
|
||||||
|
|
||||||
|
export const createSubCourse = (data: CreateSubCourseRequest) =>
|
||||||
|
http.post("/course-management/sub-courses", data)
|
||||||
|
|
||||||
|
export const updateSubCourse = (subCourseId: number, data: UpdateSubCourseRequest) =>
|
||||||
|
http.patch(`/course-management/sub-courses/${subCourseId}`, data)
|
||||||
|
|
||||||
|
export const updateSubCourseStatus = (subCourseId: number, data: UpdateSubCourseStatusRequest) =>
|
||||||
|
http.patch(`/course-management/sub-courses/${subCourseId}`, data)
|
||||||
|
|
||||||
|
export const deleteSubCourse = (subCourseId: number) =>
|
||||||
|
http.delete(`/course-management/sub-courses/${subCourseId}`)
|
||||||
|
|
||||||
|
// SubCourse Video APIs
|
||||||
|
export const getVideosBySubCourse = (subCourseId: number) =>
|
||||||
|
http.get<GetSubCourseVideosResponse>(`/course-management/sub-courses/${subCourseId}/videos`)
|
||||||
|
|
||||||
|
export const createSubCourseVideo = (data: CreateSubCourseVideoRequest) =>
|
||||||
|
http.post("/course-management/sub-course-videos", data)
|
||||||
|
|
||||||
|
export const updateSubCourseVideo = (videoId: number, data: UpdateSubCourseVideoRequest) =>
|
||||||
|
http.put(`/course-management/sub-course-videos/${videoId}`, data)
|
||||||
|
|
||||||
|
export const deleteSubCourseVideo = (videoId: number) =>
|
||||||
|
http.delete(`/course-management/sub-course-videos/${videoId}`)
|
||||||
|
|
||||||
|
// Practice APIs - for SubCourse practices (New Hierarchy)
|
||||||
|
export const getPracticesBySubCourse = (subCourseId: number) =>
|
||||||
|
http.get<GetPracticesResponse>(`/course-management/sub-courses/${subCourseId}/practices`)
|
||||||
|
|
||||||
|
export const createPractice = (data: CreatePracticeRequest) =>
|
||||||
|
http.post("/course-management/practices", data)
|
||||||
|
|
||||||
|
export const updatePractice = (practiceId: number, data: UpdatePracticeRequest) =>
|
||||||
|
http.put(`/course-management/practices/${practiceId}`, data)
|
||||||
|
|
||||||
|
export const updatePracticeStatus = (practiceId: number, data: UpdatePracticeStatusRequest) =>
|
||||||
|
http.put(`/course-management/practices/${practiceId}`, data)
|
||||||
|
|
||||||
|
export const deletePractice = (practiceId: number) =>
|
||||||
|
http.delete(`/course-management/practices/${practiceId}`)
|
||||||
|
|
||||||
|
// Practice Questions APIs
|
||||||
|
export const getPracticeQuestions = (practiceId: number) =>
|
||||||
|
http.get<GetPracticeQuestionsResponse>(`/course-management/practices/${practiceId}/questions`)
|
||||||
|
|
||||||
|
export const createPracticeQuestion = (data: CreatePracticeQuestionRequest) =>
|
||||||
|
http.post("/course-management/practice-questions", data)
|
||||||
|
|
||||||
|
export const updatePracticeQuestion = (questionId: number, data: UpdatePracticeQuestionRequest) =>
|
||||||
|
http.put(`/course-management/practice-questions/${questionId}`, data)
|
||||||
|
|
||||||
|
export const deletePracticeQuestion = (questionId: number) =>
|
||||||
|
http.delete(`/course-management/practice-questions/${questionId}`)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Legacy APIs (deprecated - using SubCourse hierarchy now)
|
||||||
|
// Keeping for backward compatibility
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const getProgramsByCourse = (courseId: number) =>
|
||||||
|
http.get<GetProgramsResponse>(`/course-management/courses/${courseId}/programs`)
|
||||||
|
|
||||||
|
export const updateProgramStatus = (programId: number, data: UpdateProgramStatusRequest) =>
|
||||||
|
http.patch(`/course-management/programs/${programId}`, data)
|
||||||
|
|
||||||
|
export const deleteProgram = (programId: number) =>
|
||||||
|
http.delete(`/course-management/programs/${programId}`)
|
||||||
|
|
||||||
|
export const createProgram = (data: CreateProgramRequest) =>
|
||||||
|
http.post("/course-management/programs", data)
|
||||||
|
|
||||||
|
export const updateProgram = (programId: number, data: UpdateProgramRequest) =>
|
||||||
|
http.patch(`/course-management/programs/${programId}`, data)
|
||||||
|
|
||||||
|
export const getLevelsByProgram = (programId: number) =>
|
||||||
|
http.get<GetLevelsResponse>(`/course-management/programs/${programId}/levels`)
|
||||||
|
|
||||||
|
export const createLevel = (data: CreateLevelRequest) =>
|
||||||
|
http.post("/course-management/levels", data)
|
||||||
|
|
||||||
|
export const updateLevel = (levelId: number, data: UpdateLevelRequest) =>
|
||||||
|
http.put(`/course-management/levels/${levelId}`, data)
|
||||||
|
|
||||||
|
export const updateLevelStatus = (levelId: number, data: UpdateLevelStatusRequest) =>
|
||||||
|
http.put(`/course-management/levels/${levelId}`, data)
|
||||||
|
|
||||||
|
export const deleteLevel = (levelId: number) =>
|
||||||
|
http.delete(`/course-management/levels/${levelId}`)
|
||||||
|
|
||||||
|
export const getModulesByLevel = (levelId: number) =>
|
||||||
|
http.get<GetModulesResponse>(`/course-management/levels/${levelId}/modules`)
|
||||||
|
|
||||||
|
export const createModule = (data: CreateModuleRequest) =>
|
||||||
|
http.post("/course-management/modules", data)
|
||||||
|
|
||||||
|
export const updateModule = (moduleId: number, data: UpdateModuleRequest) =>
|
||||||
|
http.put(`/course-management/modules/${moduleId}`, data)
|
||||||
|
|
||||||
|
export const updateModuleStatus = (moduleId: number, data: UpdateModuleStatusRequest) =>
|
||||||
|
http.put(`/course-management/modules/${moduleId}`, data)
|
||||||
|
|
||||||
|
export const deleteModule = (moduleId: number) =>
|
||||||
|
http.delete(`/course-management/modules/${moduleId}`)
|
||||||
|
|
||||||
|
export const getPracticesByLevel = (levelId: number) =>
|
||||||
|
http.get<GetPracticesResponse>(`/course-management/levels/${levelId}/practices`)
|
||||||
|
|
||||||
|
export const getPracticesByModule = (moduleId: number) =>
|
||||||
|
http.get<GetPracticesResponse>(`/course-management/modules/${moduleId}/practices`)
|
||||||
|
|
||||||
|
// Question Sets API
|
||||||
|
export const getQuestionSetsByOwner = (ownerType: string, ownerId: number) =>
|
||||||
|
http.get<GetQuestionSetsResponse>("/question-sets/by-owner", {
|
||||||
|
params: { owner_type: ownerType, owner_id: ownerId },
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createQuestionSet = (data: CreateQuestionSetRequest) =>
|
||||||
|
http.post<CreateQuestionSetResponse>("/question-sets", data)
|
||||||
|
|
||||||
|
export const addQuestionToSet = (questionSetId: number, data: AddQuestionToSetRequest) =>
|
||||||
|
http.post(`/question-sets/${questionSetId}/questions`, data)
|
||||||
|
|
||||||
|
export const createQuestion = (data: CreateQuestionRequest) =>
|
||||||
|
http.post<CreateQuestionResponse>("/questions", data)
|
||||||
|
|
||||||
|
export const deleteQuestionSet = (questionSetId: number) =>
|
||||||
|
http.delete(`/question-sets/${questionSetId}`)
|
||||||
|
|
||||||
|
export const createVimeoVideo = (data: CreateVimeoVideoRequest) =>
|
||||||
|
http.post("/course-management/videos/vimeo", data)
|
||||||
101
src/api/http.ts
101
src/api/http.ts
|
|
@ -1,4 +1,4 @@
|
||||||
import axios, { type AxiosInstance } from "axios";
|
import axios, { type AxiosInstance, type AxiosError, type InternalAxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
const http: AxiosInstance = axios.create({
|
const http: AxiosInstance = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||||
|
|
@ -7,6 +7,64 @@ const http: AxiosInstance = axios.create({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let isRefreshing = false;
|
||||||
|
let failedQueue: Array<{
|
||||||
|
resolve: (token: string) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const processQueue = (error: Error | null, token: string | null = null) => {
|
||||||
|
failedQueue.forEach((prom) => {
|
||||||
|
if (error) {
|
||||||
|
prom.reject(error);
|
||||||
|
} else if (token) {
|
||||||
|
prom.resolve(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
failedQueue = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAuthAndRedirect = () => {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
localStorage.removeItem("member_id");
|
||||||
|
localStorage.removeItem("role");
|
||||||
|
window.location.href = "/login";
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshAccessToken = async (): Promise<string> => {
|
||||||
|
const accessToken = localStorage.getItem("access_token");
|
||||||
|
const refreshToken = localStorage.getItem("refresh_token");
|
||||||
|
const role = localStorage.getItem("role");
|
||||||
|
const memberId = localStorage.getItem("member_id");
|
||||||
|
|
||||||
|
if (!refreshToken || !memberId) {
|
||||||
|
throw new Error("No refresh token available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
`${import.meta.env.VITE_API_BASE_URL}/auth/refresh`,
|
||||||
|
{
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
role: role || "admin",
|
||||||
|
member_id: Number(memberId),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const newAccessToken = response.data?.data?.access_token;
|
||||||
|
const newRefreshToken = response.data?.data?.refresh_token;
|
||||||
|
|
||||||
|
if (newAccessToken) {
|
||||||
|
localStorage.setItem("access_token", newAccessToken);
|
||||||
|
}
|
||||||
|
if (newRefreshToken) {
|
||||||
|
localStorage.setItem("refresh_token", newRefreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newAccessToken;
|
||||||
|
};
|
||||||
|
|
||||||
// Attach access token to every request
|
// Attach access token to every request
|
||||||
http.interceptors.request.use((config) => {
|
http.interceptors.request.use((config) => {
|
||||||
const token = localStorage.getItem("access_token");
|
const token = localStorage.getItem("access_token");
|
||||||
|
|
@ -16,20 +74,41 @@ http.interceptors.request.use((config) => {
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle 401 globally
|
// Handle 401 globally with token refresh
|
||||||
http.interceptors.response.use(
|
http.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
async (error: AxiosError) => {
|
||||||
if (error.response?.status === 401) {
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||||
// Clear stored tokens and user info
|
|
||||||
localStorage.removeItem("access_token");
|
|
||||||
localStorage.removeItem("refresh_token");
|
|
||||||
localStorage.removeItem("user_id");
|
|
||||||
localStorage.removeItem("role");
|
|
||||||
|
|
||||||
// Redirect to login page
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
window.location.href = "/login";
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
failedQueue.push({ resolve, reject });
|
||||||
|
})
|
||||||
|
.then((token) => {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||||
|
return http(originalRequest);
|
||||||
|
})
|
||||||
|
.catch((err) => Promise.reject(err));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
originalRequest._retry = true;
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newToken = await refreshAccessToken();
|
||||||
|
processQueue(null, newToken);
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||||
|
return http(originalRequest);
|
||||||
|
} catch (refreshError) {
|
||||||
|
processQueue(refreshError as Error, null);
|
||||||
|
clearAuthAndRedirect();
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
13
src/api/team.api.ts
Normal file
13
src/api/team.api.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import http from "./http"
|
||||||
|
import type { GetTeamMembersResponse, GetTeamMemberResponse } from "../types/team.types"
|
||||||
|
|
||||||
|
export const getTeamMembers = (page?: number, pageSize?: number) =>
|
||||||
|
http.get<GetTeamMembersResponse>("/team/members", {
|
||||||
|
params: {
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getTeamMemberById = (id: number) =>
|
||||||
|
http.get<GetTeamMemberResponse>(`/team/members/${id}`)
|
||||||
|
|
@ -11,3 +11,6 @@ export const getUsers = (page?: number, pageSize?: number) =>
|
||||||
|
|
||||||
export const getUserById = (id: number) =>
|
export const getUserById = (id: number) =>
|
||||||
http.get<UserProfileResponse>(`/user/single/${id}`);
|
http.get<UserProfileResponse>(`/user/single/${id}`);
|
||||||
|
|
||||||
|
export const getMyProfile = () =>
|
||||||
|
http.get<UserProfileResponse>("/team/me");
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,18 @@ import { AppLayout } from "../layouts/AppLayout"
|
||||||
import { DashboardPage } from "../pages/DashboardPage"
|
import { DashboardPage } from "../pages/DashboardPage"
|
||||||
import { AnalyticsPage } from "../pages/analytics/AnalyticsPage"
|
import { AnalyticsPage } from "../pages/analytics/AnalyticsPage"
|
||||||
import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout"
|
import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout"
|
||||||
|
import { CourseCategoryPage } from "../pages/content-management/CourseCategoryPage"
|
||||||
import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage"
|
import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage"
|
||||||
import { CoursesPage } from "../pages/content-management/CoursesPage"
|
import { CoursesPage } from "../pages/content-management/CoursesPage"
|
||||||
|
import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuestionsPage"
|
||||||
|
import { AddNewPracticePage } from "../pages/content-management/AddNewPracticePage"
|
||||||
|
import { SubCoursesPage } from "../pages/content-management/SubCoursesPage"
|
||||||
|
import { SubCourseContentPage } from "../pages/content-management/SubCourseContentPage"
|
||||||
import { SpeakingPage } from "../pages/content-management/SpeakingPage"
|
import { SpeakingPage } from "../pages/content-management/SpeakingPage"
|
||||||
import { AddVideoPage } from "../pages/content-management/AddVideoPage"
|
import { AddVideoPage } from "../pages/content-management/AddVideoPage"
|
||||||
import { AddPracticePage } from "../pages/content-management/AddPracticePage"
|
import { AddPracticePage } from "../pages/content-management/AddPracticePage"
|
||||||
import { NotFoundPage } from "../pages/NotFoundPage"
|
import { NotFoundPage } from "../pages/NotFoundPage"
|
||||||
import { NotificationsPage } from "../pages/notifications/NotificationsPage"
|
import { NotificationsPage } from "../pages/notifications/NotificationsPage"
|
||||||
import { PlaceholderPage } from "../pages/PlaceholderPage"
|
|
||||||
import { UserDetailPage } from "../pages/user-management/UserDetailPage"
|
import { UserDetailPage } from "../pages/user-management/UserDetailPage"
|
||||||
import { UserManagementLayout } from "../pages/user-management/UserManagementLayout"
|
import { UserManagementLayout } from "../pages/user-management/UserManagementLayout"
|
||||||
import { UsersListPage } from "../pages/user-management/UsersListPage"
|
import { UsersListPage } from "../pages/user-management/UsersListPage"
|
||||||
|
|
@ -25,6 +29,9 @@ import { PracticeMembersPage } from "../pages/content-management/PracticeMembers
|
||||||
import { QuestionsPage } from "../pages/content-management/QuestionsPage"
|
import { QuestionsPage } from "../pages/content-management/QuestionsPage"
|
||||||
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage"
|
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage"
|
||||||
import { UserLogPage } from "../pages/user-log/UserLogPage"
|
import { UserLogPage } from "../pages/user-log/UserLogPage"
|
||||||
|
import { ProfilePage } from "../pages/ProfilePage"
|
||||||
|
import { TeamManagementPage } from "../pages/team/TeamManagementPage"
|
||||||
|
import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage"
|
||||||
import { LoginPage } from "../pages/auth/LoginPage"
|
import { LoginPage } from "../pages/auth/LoginPage"
|
||||||
import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage"
|
import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage"
|
||||||
import { VerificationPage } from "../pages/auth/VerificationPage"
|
import { VerificationPage } from "../pages/auth/VerificationPage"
|
||||||
|
|
@ -52,9 +59,15 @@ export function AppRoutes() {
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/content" element={<ContentManagementLayout />}>
|
<Route path="/content" element={<ContentManagementLayout />}>
|
||||||
<Route index element={<ContentOverviewPage />} />
|
<Route index element={<CourseCategoryPage />} />
|
||||||
<Route path="courses" element={<CoursesPage />} />
|
<Route path="category/:categoryId" element={<ContentOverviewPage />} />
|
||||||
<Route path="courses/add-video" element={<AddVideoPage />} />
|
<Route path="category/:categoryId/courses" element={<CoursesPage />} />
|
||||||
|
{/* Course → Sub-course → Video/Practice */}
|
||||||
|
<Route path="category/:categoryId/courses/:courseId/sub-courses" element={<SubCoursesPage />} />
|
||||||
|
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subCourseId" element={<SubCourseContentPage />} />
|
||||||
|
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subCourseId/add-practice" element={<AddNewPracticePage />} />
|
||||||
|
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subCourseId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} />
|
||||||
|
<Route path="category/:categoryId/courses/add-video" element={<AddVideoPage />} />
|
||||||
<Route path="speaking" element={<SpeakingPage />} />
|
<Route path="speaking" element={<SpeakingPage />} />
|
||||||
<Route path="speaking/add-practice" element={<AddPracticePage />} />
|
<Route path="speaking/add-practice" element={<AddPracticePage />} />
|
||||||
<Route path="practices" element={<PracticeDetailsPage />} />
|
<Route path="practices" element={<PracticeDetailsPage />} />
|
||||||
|
|
@ -68,8 +81,9 @@ export function AppRoutes() {
|
||||||
<Route path="/user-log" element={<UserLogPage />} />
|
<Route path="/user-log" element={<UserLogPage />} />
|
||||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||||
|
|
||||||
<Route path="/team" element={<PlaceholderPage title="Team Management" />} />
|
<Route path="/team" element={<TeamManagementPage />} />
|
||||||
<Route path="/profile" element={<PlaceholderPage title="Profile" />} />
|
<Route path="/team/:id" element={<TeamMemberDetailPage />} />
|
||||||
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client" // make sure this is a client component
|
"use client" // make sure this is a client component
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Bell } from "lucide-react"
|
import { Bell, LogOut, Settings, UserCircle2 } from "lucide-react"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"
|
||||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
|
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
|
|
@ -11,9 +11,16 @@ export function Topbar() {
|
||||||
const [shortName, setShortName] = useState("AA")
|
const [shortName, setShortName] = useState("AA")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const updateShortName = () => {
|
||||||
const first = localStorage.getItem("user_first_name") ?? "A"
|
const first = localStorage.getItem("user_first_name") ?? "A"
|
||||||
const last = localStorage.getItem("user_last_name") ?? "A"
|
const last = localStorage.getItem("user_last_name") ?? "A"
|
||||||
setShortName(first.charAt(0).toUpperCase() + last.charAt(0).toUpperCase())
|
setShortName(first.charAt(0).toUpperCase() + last.charAt(0).toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
updateShortName()
|
||||||
|
|
||||||
|
window.addEventListener("user-profile-updated", updateShortName)
|
||||||
|
return () => window.removeEventListener("user-profile-updated", updateShortName)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleOptionClick = (option: string) => {
|
const handleOptionClick = (option: string) => {
|
||||||
|
|
@ -66,30 +73,39 @@ export function Topbar() {
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
side="bottom"
|
side="bottom"
|
||||||
align="end"
|
align="end"
|
||||||
className="z-50 w-40 rounded-lg bg-white p-2 shadow-lg ring-1 ring-black ring-opacity-5"
|
className="z-50 w-48 rounded-lg bg-white p-2 shadow-lg ring-1 ring-black ring-opacity-5"
|
||||||
>
|
>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer rounded px-3 py-2 text-grayScale-700 text-sm hover:bg-grayScale-100"
|
"group flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-grayScale-600 hover:bg-grayScale-100 hover:text-brand-600"
|
||||||
)}
|
)}
|
||||||
onClick={() => handleOptionClick("profile")}
|
onClick={() => handleOptionClick("profile")}
|
||||||
>
|
>
|
||||||
|
<span className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 group-hover:bg-brand-100 group-hover:text-brand-600">
|
||||||
|
<UserCircle2 className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
Profile
|
Profile
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer rounded px-3 py-2 text-grayScale-700 text-sm hover:bg-grayScale-100"
|
"group flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-grayScale-600 hover:bg-grayScale-100 hover:text-brand-600"
|
||||||
)}
|
)}
|
||||||
onClick={() => handleOptionClick("settings")}
|
onClick={() => handleOptionClick("settings")}
|
||||||
>
|
>
|
||||||
|
<span className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 group-hover:bg-brand-100 group-hover:text-brand-600">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
Settings
|
Settings
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer rounded px-3 py-2 text-grayScale-700 text-sm hover:bg-grayScale-100"
|
"group flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-grayScale-600 hover:bg-grayScale-100 hover:text-brand-600"
|
||||||
)}
|
)}
|
||||||
onClick={() => handleOptionClick("logout")}
|
onClick={() => handleOptionClick("logout")}
|
||||||
>
|
>
|
||||||
|
<span className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 group-hover:bg-brand-100 group-hover:text-brand-600">
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
Logout
|
Logout
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,7 @@ import { StatCard } from "../components/dashboard/StatCard"
|
||||||
import { Button } from "../components/ui/button"
|
import { Button } from "../components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
|
||||||
import { cn } from "../lib/utils"
|
import { cn } from "../lib/utils"
|
||||||
import { getUserById } from "../api/users.api"
|
import { getTeamMemberById } from "../api/team.api"
|
||||||
import type { UserProfileResponse } from "../types/user.types"
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
const userGrowth = [
|
const userGrowth = [
|
||||||
|
|
@ -69,13 +68,14 @@ export function DashboardPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUser = async () => {
|
const fetchUser = async () => {
|
||||||
try {
|
try {
|
||||||
const userId = Number(localStorage.getItem("user_id"))
|
const memberId = Number(localStorage.getItem("member_id"))
|
||||||
const res = await getUserById(userId)
|
const res = await getTeamMemberById(memberId)
|
||||||
const userProfile: UserProfileResponse = res.data
|
const member = res.data.data
|
||||||
|
|
||||||
setUserFirstName(userProfile.data.first_name)
|
setUserFirstName(member.first_name)
|
||||||
localStorage.setItem("user_first_name", userProfile.data.first_name)
|
localStorage.setItem("user_first_name", member.first_name)
|
||||||
localStorage.setItem("user_last_name", userProfile.data.last_name)
|
localStorage.setItem("user_last_name", member.last_name)
|
||||||
|
window.dispatchEvent(new Event("user-profile-updated"))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
308
src/pages/ProfilePage.tsx
Normal file
308
src/pages/ProfilePage.tsx
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Globe,
|
||||||
|
GraduationCap,
|
||||||
|
Languages,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Phone,
|
||||||
|
Shield,
|
||||||
|
User,
|
||||||
|
XCircle,
|
||||||
|
Briefcase,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Badge } from "../components/ui/badge";
|
||||||
|
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 { getMyProfile } from "../api/users.api";
|
||||||
|
import type { UserProfileData } from "../types/user.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 LoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-5xl space-y-6 py-8">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="rounded-2xl bg-grayScale-100 h-72" />
|
||||||
|
<div className="mt-6 grid gap-6 md:grid-cols-3">
|
||||||
|
<div className="rounded-2xl bg-grayScale-100 h-56" />
|
||||||
|
<div className="rounded-2xl bg-grayScale-100 h-56" />
|
||||||
|
<div className="rounded-2xl bg-grayScale-100 h-56" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VerifiedIcon({ verified }: { verified: boolean }) {
|
||||||
|
return verified ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-mint-500" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-4 w-4 text-grayScale-300" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfilePage() {
|
||||||
|
const [profile, setProfile] = useState<UserProfileData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProfile = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getMyProfile();
|
||||||
|
setProfile(res.data.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch profile", err);
|
||||||
|
setError("Failed to load profile. Please try again later.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchProfile();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <LoadingSkeleton />;
|
||||||
|
|
||||||
|
if (error || !profile) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-5xl py-12">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center gap-4 p-10">
|
||||||
|
<div className="h-16 w-16 rounded-full bg-grayScale-100 flex items-center justify-center">
|
||||||
|
<User className="h-8 w-8 text-grayScale-300" />
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold text-grayScale-600">
|
||||||
|
{error || "Profile not available"}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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-5xl space-y-6 py-8">
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="h-32 bg-gradient-to-r from-brand-600 via-brand-500 to-brand-400" />
|
||||||
|
<CardContent className="-mt-14 px-8 pb-8 pt-0">
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
<Avatar className="h-24 w-24 ring-4 ring-white shadow-soft">
|
||||||
|
<AvatarImage src={profile.profile_picture_url || undefined} alt={fullName} />
|
||||||
|
<AvatarFallback className="bg-brand-100 text-brand-600 text-2xl font-bold">
|
||||||
|
{initials}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<h1 className="mt-4 text-2xl font-bold text-grayScale-600">{fullName}</h1>
|
||||||
|
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
"mt-2",
|
||||||
|
profile.role === "ADMIN"
|
||||||
|
? "bg-brand-500/15 text-brand-600 border border-brand-500/25"
|
||||||
|
: "bg-grayScale-200 text-grayScale-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Shield className="h-3 w-3 mr-1" />
|
||||||
|
{profile.role}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium",
|
||||||
|
profile.status === "ACTIVE"
|
||||||
|
? "bg-mint-100 text-mint-500"
|
||||||
|
: "bg-destructive/10 text-destructive"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"h-2 w-2 rounded-full",
|
||||||
|
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{profile.status}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium",
|
||||||
|
profile.email_verified
|
||||||
|
? "bg-mint-100 text-mint-500"
|
||||||
|
: "bg-grayScale-100 text-grayScale-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{profile.email_verified ? (
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Email {profile.email_verified ? "Verified" : "Unverified"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium",
|
||||||
|
profile.phone_verified
|
||||||
|
? "bg-mint-100 text-mint-500"
|
||||||
|
: "bg-grayScale-100 text-grayScale-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{profile.phone_verified ? (
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Phone {profile.phone_verified ? "Verified" : "Unverified"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 rounded-full bg-brand-100/60 px-3 py-1.5 text-xs font-medium text-brand-600">
|
||||||
|
<GraduationCap className="h-3 w-3" />
|
||||||
|
Profile {completionPct}% Complete
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
|
<Card className="border-l-4 border-l-brand-500">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">Personal Information</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-0">
|
||||||
|
<InfoRow icon={User} label="Full Name" value={fullName} />
|
||||||
|
<Separator />
|
||||||
|
<InfoRow icon={User} label="Gender" value={profile.gender || "Not specified"} />
|
||||||
|
<Separator />
|
||||||
|
<InfoRow icon={Calendar} label="Birthday" value={formatDate(profile.birth_day)} />
|
||||||
|
<Separator />
|
||||||
|
<InfoRow icon={User} label="Age Group" value={profile.age_group || "—"} />
|
||||||
|
<Separator />
|
||||||
|
<InfoRow icon={Briefcase} label="Occupation" value={profile.occupation || "—"} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-l-4 border-l-brand-500">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">Contact & Location</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-0">
|
||||||
|
<InfoRow
|
||||||
|
icon={Mail}
|
||||||
|
label="Email"
|
||||||
|
value={profile.email}
|
||||||
|
extra={<VerifiedIcon verified={profile.email_verified} />}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<InfoRow
|
||||||
|
icon={Phone}
|
||||||
|
label="Phone"
|
||||||
|
value={profile.phone_number}
|
||||||
|
extra={<VerifiedIcon verified={profile.phone_verified} />}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<InfoRow icon={Globe} label="Country" value={profile.country || "—"} />
|
||||||
|
<Separator />
|
||||||
|
<InfoRow icon={MapPin} label="Region" value={profile.region || "—"} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-l-4 border-l-brand-500">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">Account Details</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-0">
|
||||||
|
<InfoRow icon={Shield} label="Role" value={profile.role} />
|
||||||
|
<Separator />
|
||||||
|
<InfoRow
|
||||||
|
icon={Languages}
|
||||||
|
label="Language"
|
||||||
|
value={profile.preferred_language || "—"}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<InfoRow
|
||||||
|
icon={Clock}
|
||||||
|
label="Last Login"
|
||||||
|
value={formatDateTime(profile.last_login)}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<InfoRow
|
||||||
|
icon={Calendar}
|
||||||
|
label="Member Since"
|
||||||
|
value={formatDate(profile.created_at)}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<InfoRow
|
||||||
|
icon={CheckCircle2}
|
||||||
|
label="Status"
|
||||||
|
value={profile.status}
|
||||||
|
extra={
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"h-2 w-2 rounded-full",
|
||||||
|
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -36,7 +36,7 @@ export function LoginPage() {
|
||||||
localStorage.setItem("access_token", res.accessToken);
|
localStorage.setItem("access_token", res.accessToken);
|
||||||
localStorage.setItem("refresh_token", res.refreshToken);
|
localStorage.setItem("refresh_token", res.refreshToken);
|
||||||
localStorage.setItem("role", res.role);
|
localStorage.setItem("role", res.role);
|
||||||
localStorage.setItem("user_id", res.user_id.toString());
|
localStorage.setItem("member_id", res.memberId.toString());
|
||||||
|
|
||||||
navigate("/dashboard");
|
navigate("/dashboard");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
@ -69,7 +69,7 @@ export function LoginPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6" autoComplete="on">
|
<form onSubmit={handleSubmit} className="space-y-6" autoComplete="on" method="post">
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
|
|
@ -82,7 +82,7 @@ export function LoginPage() {
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
autoComplete="username"
|
autoComplete="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
|
|
|
||||||
933
src/pages/content-management/AddNewPracticePage.tsx
Normal file
933
src/pages/content-management/AddNewPracticePage.tsx
Normal file
|
|
@ -0,0 +1,933 @@
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Link, useParams, useNavigate } from "react-router-dom"
|
||||||
|
import { ArrowLeft, ArrowRight, ChevronDown, Grid3X3, Check, Plus, Trash2, GripVertical, X, Edit, Rocket } from "lucide-react"
|
||||||
|
import { Card } from "../../components/ui/card"
|
||||||
|
import { Button } from "../../components/ui/button"
|
||||||
|
import { Input } from "../../components/ui/input"
|
||||||
|
import { createQuestionSet, createQuestion, addQuestionToSet } from "../../api/courses.api"
|
||||||
|
import { Select } from "../../components/ui/select"
|
||||||
|
import type { QuestionOption } from "../../types/course.types"
|
||||||
|
|
||||||
|
type Step = 1 | 2 | 3 | 4 | 5
|
||||||
|
type ResultStatus = "success" | "error"
|
||||||
|
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT"
|
||||||
|
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
|
||||||
|
|
||||||
|
interface Persona {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
avatar: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MCQOption {
|
||||||
|
text: string
|
||||||
|
isCorrect: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Question {
|
||||||
|
id: string
|
||||||
|
questionText: string
|
||||||
|
questionType: QuestionType
|
||||||
|
difficultyLevel: DifficultyLevel
|
||||||
|
points: number
|
||||||
|
tips: string
|
||||||
|
explanation: string
|
||||||
|
options: MCQOption[]
|
||||||
|
voicePrompt: string
|
||||||
|
sampleAnswerVoicePrompt: string
|
||||||
|
shortAnswers: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERSONAS: Persona[] = [
|
||||||
|
{ id: "1", name: "Dawit", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Dawit" },
|
||||||
|
{ id: "2", name: "Mahlet", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Mahlet" },
|
||||||
|
{ id: "3", name: "Amanuel", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Amanuel" },
|
||||||
|
{ id: "4", name: "Bethel", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Bethel" },
|
||||||
|
{ id: "5", name: "Liya", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Liya" },
|
||||||
|
{ id: "6", name: "Aseffa", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Aseffa" },
|
||||||
|
{ id: "7", name: "Hana", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Hana" },
|
||||||
|
{ id: "8", name: "Nahom", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Nahom" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ number: 1, label: "Context" },
|
||||||
|
{ number: 2, label: "Persona" },
|
||||||
|
{ number: 3, label: "Questions" },
|
||||||
|
{ number: 4, label: "Review" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function createEmptyQuestion(id: string): Question {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
questionText: "",
|
||||||
|
questionType: "MCQ",
|
||||||
|
difficultyLevel: "EASY",
|
||||||
|
points: 1,
|
||||||
|
tips: "",
|
||||||
|
explanation: "",
|
||||||
|
options: [
|
||||||
|
{ text: "", isCorrect: true },
|
||||||
|
{ text: "", isCorrect: false },
|
||||||
|
{ text: "", isCorrect: false },
|
||||||
|
{ text: "", isCorrect: false },
|
||||||
|
],
|
||||||
|
voicePrompt: "",
|
||||||
|
sampleAnswerVoicePrompt: "",
|
||||||
|
shortAnswers: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddNewPracticePage() {
|
||||||
|
const { categoryId, courseId, subCourseId } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [currentStep, setCurrentStep] = useState<Step>(1)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
// Step 1: Context
|
||||||
|
const [selectedProgram] = useState("Intermediate")
|
||||||
|
const [selectedCourse] = useState("B2")
|
||||||
|
const [practiceTitle, setPracticeTitle] = useState("")
|
||||||
|
const [practiceDescription, setPracticeDescription] = useState("")
|
||||||
|
const [shuffleQuestions, setShuffleQuestions] = useState(false)
|
||||||
|
const [passingScore, setPassingScore] = useState(50)
|
||||||
|
const [timeLimitMinutes, setTimeLimitMinutes] = useState(60)
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
|
const [resultStatus, setResultStatus] = useState<ResultStatus | null>(null)
|
||||||
|
const [resultMessage, setResultMessage] = useState("")
|
||||||
|
|
||||||
|
// Step 2: Persona
|
||||||
|
const [selectedPersona, setSelectedPersona] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Step 3: Questions
|
||||||
|
const [questions, setQuestions] = useState<Question[]>([
|
||||||
|
createEmptyQuestion("1"),
|
||||||
|
])
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (currentStep < 4) {
|
||||||
|
setCurrentStep((currentStep + 1) as Step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (currentStep > 1) {
|
||||||
|
setCurrentStep((currentStep - 1) as Step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addQuestion = () => {
|
||||||
|
setQuestions([...questions, createEmptyQuestion(String(Date.now()))])
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeQuestion = (id: string) => {
|
||||||
|
if (questions.length > 1) {
|
||||||
|
setQuestions(questions.filter(q => q.id !== id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateQuestion = (id: string, updates: Partial<Question>) => {
|
||||||
|
setQuestions(questions.map(q => q.id === id ? { ...q, ...updates } : q))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateOption = (questionId: string, optionIndex: number, updates: Partial<MCQOption>) => {
|
||||||
|
setQuestions(questions.map(q => {
|
||||||
|
if (q.id !== questionId) return q
|
||||||
|
const newOptions = q.options.map((opt, i) => i === optionIndex ? { ...opt, ...updates } : opt)
|
||||||
|
return { ...q, options: newOptions }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const addOption = (questionId: string) => {
|
||||||
|
setQuestions(questions.map(q => {
|
||||||
|
if (q.id !== questionId) return q
|
||||||
|
return { ...q, options: [...q.options, { text: "", isCorrect: false }] }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeOption = (questionId: string, optionIndex: number) => {
|
||||||
|
setQuestions(questions.map(q => {
|
||||||
|
if (q.id !== questionId) return q
|
||||||
|
return { ...q, options: q.options.filter((_, i) => i !== optionIndex) }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const setCorrectOption = (questionId: string, optionIndex: number) => {
|
||||||
|
setQuestions(questions.map(q => {
|
||||||
|
if (q.id !== questionId) return q
|
||||||
|
return { ...q, options: q.options.map((opt, i) => ({ ...opt, isCorrect: i === optionIndex })) }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveQuestionSet = async (status: "DRAFT" | "PUBLISHED") => {
|
||||||
|
setSaving(true)
|
||||||
|
setSaveError(null)
|
||||||
|
try {
|
||||||
|
const persona = PERSONAS.find(p => p.id === selectedPersona)
|
||||||
|
const setRes = await createQuestionSet({
|
||||||
|
title: practiceTitle || "Untitled Practice",
|
||||||
|
description: practiceDescription,
|
||||||
|
set_type: "PRACTICE",
|
||||||
|
owner_type: "SUB_COURSE",
|
||||||
|
owner_id: Number(subCourseId),
|
||||||
|
persona: persona?.name,
|
||||||
|
shuffle_questions: shuffleQuestions,
|
||||||
|
status,
|
||||||
|
passing_score: passingScore,
|
||||||
|
time_limit_minutes: timeLimitMinutes,
|
||||||
|
})
|
||||||
|
|
||||||
|
const questionSetId = setRes.data?.data?.id
|
||||||
|
if (questionSetId) {
|
||||||
|
for (let i = 0; i < questions.length; i++) {
|
||||||
|
const q = questions[i]
|
||||||
|
if (!q.questionText.trim()) continue
|
||||||
|
|
||||||
|
const options: QuestionOption[] = q.questionType === "MCQ"
|
||||||
|
? q.options.map((opt, idx) => ({
|
||||||
|
option_order: idx + 1,
|
||||||
|
option_text: opt.text,
|
||||||
|
is_correct: opt.isCorrect,
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
|
||||||
|
const qRes = await createQuestion({
|
||||||
|
question_text: q.questionText,
|
||||||
|
question_type: q.questionType,
|
||||||
|
difficulty_level: q.difficultyLevel,
|
||||||
|
points: q.points,
|
||||||
|
tips: q.tips || undefined,
|
||||||
|
explanation: q.explanation || undefined,
|
||||||
|
status: "PUBLISHED",
|
||||||
|
options: options.length > 0 ? options : undefined,
|
||||||
|
voice_prompt: q.voicePrompt || undefined,
|
||||||
|
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
|
||||||
|
short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const questionId = qRes.data?.data?.id
|
||||||
|
if (questionId) {
|
||||||
|
await addQuestionToSet(questionSetId, {
|
||||||
|
display_order: i + 1,
|
||||||
|
question_id: questionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setResultStatus("success")
|
||||||
|
setResultMessage(
|
||||||
|
status === "PUBLISHED"
|
||||||
|
? "Your speaking practice is now active."
|
||||||
|
: "Your practice has been saved as a draft."
|
||||||
|
)
|
||||||
|
setCurrentStep(5)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("Failed to save practice:", err)
|
||||||
|
const errorMsg = err instanceof Error ? err.message : "An unexpected error occurred."
|
||||||
|
setResultStatus("error")
|
||||||
|
setResultMessage(errorMsg)
|
||||||
|
setCurrentStep(5)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveAsDraft = () => saveQuestionSet("DRAFT")
|
||||||
|
const handlePublish = () => saveQuestionSet("PUBLISHED")
|
||||||
|
|
||||||
|
const getNextButtonLabel = () => {
|
||||||
|
switch (currentStep) {
|
||||||
|
case 1: return "Next: Persona"
|
||||||
|
case 2: return "Next: Questions"
|
||||||
|
case 3: return "Next: Review"
|
||||||
|
default: return "Next"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{currentStep !== 5 && (
|
||||||
|
<>
|
||||||
|
{/* Back Link */}
|
||||||
|
<Link
|
||||||
|
to={`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`}
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-grayScale-600 hover:text-grayScale-900"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to Sub-course
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-grayScale-900">Add New Practice</h1>
|
||||||
|
<p className="mt-1 text-sm text-grayScale-500">
|
||||||
|
Create a new immersive practice session for students.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step Tracker */}
|
||||||
|
{currentStep !== 5 && (
|
||||||
|
<div className="flex items-center justify-center py-6">
|
||||||
|
{STEPS.map((step, index) => (
|
||||||
|
<div key={step.number} className="flex items-center">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium transition-colors ${
|
||||||
|
currentStep === step.number
|
||||||
|
? "bg-brand-500 text-white"
|
||||||
|
: currentStep > step.number
|
||||||
|
? "bg-brand-500 text-white"
|
||||||
|
: "border-2 border-grayScale-300 text-grayScale-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{currentStep > step.number ? <Check className="h-4 w-4" /> : step.number}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`mt-2 text-xs font-medium ${
|
||||||
|
currentStep === step.number ? "text-brand-500" : "text-grayScale-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{index < STEPS.length - 1 && (
|
||||||
|
<div
|
||||||
|
className={`mx-4 h-0.5 w-32 ${
|
||||||
|
currentStep > step.number ? "bg-brand-500" : "bg-grayScale-200"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step Content */}
|
||||||
|
{currentStep === 1 && (
|
||||||
|
<Card className="mx-auto max-w-2xl p-8">
|
||||||
|
<h2 className="text-lg font-semibold text-grayScale-900">Step 1: Context Definition</h2>
|
||||||
|
<p className="mt-1 text-sm text-grayScale-500">
|
||||||
|
Define the educational level and curriculum module for this practice.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 space-y-6">
|
||||||
|
{/* Practice Title */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Practice Title</label>
|
||||||
|
<Input
|
||||||
|
value={practiceTitle}
|
||||||
|
onChange={(e) => setPracticeTitle(e.target.value)}
|
||||||
|
placeholder="Enter practice title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Practice Description */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={practiceDescription}
|
||||||
|
onChange={(e) => setPracticeDescription(e.target.value)}
|
||||||
|
placeholder="Enter practice description"
|
||||||
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Passing Score */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Passing Score</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={passingScore}
|
||||||
|
onChange={(e) => setPassingScore(Number(e.target.value))}
|
||||||
|
placeholder="50"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Limit */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Time Limit (minutes)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={timeLimitMinutes}
|
||||||
|
onChange={(e) => setTimeLimitMinutes(Number(e.target.value))}
|
||||||
|
placeholder="60"
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shuffle Questions */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShuffleQuestions(!shuffleQuestions)}
|
||||||
|
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
|
||||||
|
shuffleQuestions ? "bg-brand-500" : "bg-grayScale-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transition-transform ${
|
||||||
|
shuffleQuestions ? "translate-x-5" : "translate-x-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Shuffle Questions</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Program */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
|
Program <span className="text-brand-500">(Auto-selected)</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-grayScale-200 px-4 py-3">
|
||||||
|
<Grid3X3 className="h-5 w-5 text-grayScale-400" />
|
||||||
|
<span className="flex-1 text-sm">{selectedProgram}</span>
|
||||||
|
<ChevronDown className="h-5 w-5 text-grayScale-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Course */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
|
Course <span className="text-brand-500">(Auto-selected)</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-grayScale-200 px-4 py-3">
|
||||||
|
<Grid3X3 className="h-5 w-5 text-grayScale-400" />
|
||||||
|
<span className="flex-1 text-sm">{selectedCourse}</span>
|
||||||
|
<ChevronDown className="h-5 w-5 text-grayScale-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="mt-8 flex items-center justify-between border-t pt-6">
|
||||||
|
<Button variant="ghost" onClick={handleCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleNext}>
|
||||||
|
{getNextButtonLabel()}
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<div className="mx-auto max-w-4xl">
|
||||||
|
<h2 className="text-xl font-semibold text-grayScale-900">Select Personas</h2>
|
||||||
|
<p className="mt-1 text-sm text-grayScale-500">
|
||||||
|
Choose the characters that will participate in this practice scenario. Students will interact with these personas.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 grid grid-cols-4 gap-4">
|
||||||
|
{PERSONAS.map((persona) => (
|
||||||
|
<button
|
||||||
|
key={persona.id}
|
||||||
|
onClick={() => setSelectedPersona(persona.id)}
|
||||||
|
className={`relative flex flex-col items-center rounded-xl border-2 p-6 transition-all ${
|
||||||
|
selectedPersona === persona.id
|
||||||
|
? "border-brand-500 bg-brand-50"
|
||||||
|
: "border-grayScale-200 hover:border-grayScale-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selectedPersona === persona.id && (
|
||||||
|
<div className="absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-brand-500 text-white">
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-3 h-20 w-20 overflow-hidden rounded-full bg-grayScale-100">
|
||||||
|
<img
|
||||||
|
src={persona.avatar}
|
||||||
|
alt={persona.name}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-grayScale-900">{persona.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="mt-8 flex items-center justify-between">
|
||||||
|
<Button variant="outline" onClick={handleBack}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleNext}>
|
||||||
|
{getNextButtonLabel()}
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 3 && (
|
||||||
|
<div className="mx-auto max-w-4xl">
|
||||||
|
<h2 className="text-xl font-semibold text-grayScale-900">Create Practice Questions</h2>
|
||||||
|
<p className="mt-1 text-sm text-grayScale-500">
|
||||||
|
Add questions to your practice. Support for MCQ, True/False, and Short Answer types.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
{questions.map((question, index) => (
|
||||||
|
<Card key={question.id} className="border-l-4 border-l-brand-500 p-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<GripVertical className="h-5 w-5 cursor-grab text-grayScale-400" />
|
||||||
|
<span className="font-medium text-grayScale-900">Question {index + 1}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => removeQuestion(question.id)}
|
||||||
|
className="rounded p-1 text-grayScale-400 hover:bg-red-50 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
{/* Question Text */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
||||||
|
Question Text
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={question.questionText}
|
||||||
|
onChange={(e) => updateQuestion(question.id, { questionText: e.target.value })}
|
||||||
|
placeholder="Enter your question..."
|
||||||
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{/* Question Type */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={question.questionType}
|
||||||
|
onChange={(e) => updateQuestion(question.id, { questionType: e.target.value as QuestionType })}
|
||||||
|
>
|
||||||
|
<option value="MCQ">Multiple Choice</option>
|
||||||
|
<option value="TRUE_FALSE">True/False</option>
|
||||||
|
<option value="SHORT">Short Answer</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Difficulty */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
||||||
|
Difficulty
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={question.difficultyLevel}
|
||||||
|
onChange={(e) => updateQuestion(question.id, { difficultyLevel: e.target.value as DifficultyLevel })}
|
||||||
|
>
|
||||||
|
<option value="EASY">Easy</option>
|
||||||
|
<option value="MEDIUM">Medium</option>
|
||||||
|
<option value="HARD">Hard</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Points */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
||||||
|
Points
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={question.points}
|
||||||
|
onChange={(e) => updateQuestion(question.id, { points: Number(e.target.value) || 1 })}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MCQ Options */}
|
||||||
|
{question.questionType === "MCQ" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
||||||
|
Options
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{question.options.map((option, optIdx) => (
|
||||||
|
<div key={optIdx} className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCorrectOption(question.id, optIdx)}
|
||||||
|
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors ${
|
||||||
|
option.isCorrect
|
||||||
|
? "border-green-500 bg-green-500 text-white"
|
||||||
|
: "border-grayScale-300 hover:border-brand-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.isCorrect && <Check className="h-3 w-3" />}
|
||||||
|
</button>
|
||||||
|
<Input
|
||||||
|
value={option.text}
|
||||||
|
onChange={(e) => updateOption(question.id, optIdx, { text: e.target.value })}
|
||||||
|
placeholder={`Option ${optIdx + 1}`}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
{question.options.length > 2 && (
|
||||||
|
<button
|
||||||
|
onClick={() => removeOption(question.id, optIdx)}
|
||||||
|
className="rounded p-1 text-grayScale-400 hover:bg-red-50 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addOption(question.id)}
|
||||||
|
className="flex items-center gap-1 text-sm text-brand-500 hover:text-brand-600"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Option
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-grayScale-400">Click the circle to mark the correct answer.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TRUE_FALSE Options */}
|
||||||
|
{question.questionType === "TRUE_FALSE" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
||||||
|
Correct Answer
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{["True", "False"].map((val, i) => (
|
||||||
|
<button
|
||||||
|
key={val}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateQuestion(question.id, {
|
||||||
|
options: [
|
||||||
|
{ text: "True", isCorrect: i === 0 },
|
||||||
|
{ text: "False", isCorrect: i === 1 },
|
||||||
|
],
|
||||||
|
})}
|
||||||
|
className={`flex-1 rounded-lg border-2 px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||||
|
question.options[i]?.isCorrect
|
||||||
|
? "border-green-500 bg-green-50 text-green-700"
|
||||||
|
: "border-grayScale-200 text-grayScale-600 hover:border-grayScale-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{val}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Tips */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
||||||
|
Tips (Optional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={question.tips}
|
||||||
|
onChange={(e) => updateQuestion(question.id, { tips: e.target.value })}
|
||||||
|
placeholder="Helpful tip for the student"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Explanation */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
||||||
|
Explanation (Optional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={question.explanation}
|
||||||
|
onChange={(e) => updateQuestion(question.id, { explanation: e.target.value })}
|
||||||
|
placeholder="Why this is the correct answer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Voice Prompt */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
||||||
|
Voice Prompt (Optional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={question.voicePrompt}
|
||||||
|
onChange={(e) => updateQuestion(question.id, { voicePrompt: e.target.value })}
|
||||||
|
placeholder="Voice prompt text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sample Answer Voice Prompt */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
||||||
|
Sample Answer Voice Prompt (Optional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={question.sampleAnswerVoicePrompt}
|
||||||
|
onChange={(e) => updateQuestion(question.id, { sampleAnswerVoicePrompt: e.target.value })}
|
||||||
|
placeholder="Sample answer voice prompt"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Button */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<button
|
||||||
|
onClick={addQuestion}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium text-brand-500 hover:text-brand-600"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add New Question
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="mt-8 flex items-center justify-between">
|
||||||
|
<Button variant="outline" onClick={handleBack}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleNext}>
|
||||||
|
{getNextButtonLabel()}
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 4 && (
|
||||||
|
<div className="mx-auto max-w-4xl">
|
||||||
|
<h2 className="text-xl font-semibold text-grayScale-900">Review & Publish</h2>
|
||||||
|
<p className="mt-1 text-sm text-grayScale-500">
|
||||||
|
Review your practice details before saving.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Basic Information Card */}
|
||||||
|
<Card className="mt-6 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-grayScale-900">Basic Information</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentStep(1)}
|
||||||
|
className="flex items-center gap-1 text-sm text-brand-500 hover:text-brand-600"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-grayScale-500">Title</span>
|
||||||
|
<span className="text-sm font-medium text-grayScale-900">{practiceTitle || "Untitled Practice"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-grayScale-500">Description</span>
|
||||||
|
<span className="max-w-sm text-right text-sm text-grayScale-700">{practiceDescription || "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-grayScale-500">Passing Score</span>
|
||||||
|
<span className="text-sm font-medium text-grayScale-900">{passingScore}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-grayScale-500">Time Limit</span>
|
||||||
|
<span className="text-sm font-medium text-grayScale-900">{timeLimitMinutes} minutes</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-grayScale-500">Shuffle Questions</span>
|
||||||
|
<span className="text-sm font-medium text-grayScale-900">{shuffleQuestions ? "Yes" : "No"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-sm text-grayScale-500">Persona</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{selectedPersona && (
|
||||||
|
<div className="h-6 w-6 overflow-hidden rounded-full bg-grayScale-100">
|
||||||
|
<img
|
||||||
|
src={PERSONAS.find(p => p.id === selectedPersona)?.avatar}
|
||||||
|
alt="Persona"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium text-brand-500">
|
||||||
|
{PERSONAS.find(p => p.id === selectedPersona)?.name || "None selected"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Questions Review */}
|
||||||
|
<Card className="mt-6 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-grayScale-900">Questions</h3>
|
||||||
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-grayScale-100 text-xs">
|
||||||
|
{questions.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentStep(3)}
|
||||||
|
className="flex items-center gap-1 text-sm text-brand-500 hover:text-brand-600"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
{questions.map((question, index) => (
|
||||||
|
<div key={question.id} className="rounded-lg border border-grayScale-200 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-brand-100 text-xs font-semibold text-brand-600">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<p className="text-sm font-medium text-grayScale-900">{question.questionText}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="rounded bg-blue-50 px-2 py-0.5 text-xs text-blue-600">
|
||||||
|
{question.questionType === "MCQ" ? "Multiple Choice" : question.questionType === "TRUE_FALSE" ? "True/False" : "Short Answer"}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-purple-50 px-2 py-0.5 text-xs text-purple-600">
|
||||||
|
{question.difficultyLevel}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-grayScale-500">{question.points} pt{question.points !== 1 ? "s" : ""}</span>
|
||||||
|
</div>
|
||||||
|
{question.questionType === "MCQ" && question.options.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{question.options.map((opt, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`flex items-center gap-2 rounded px-2 py-1 text-sm ${
|
||||||
|
opt.isCorrect ? "bg-green-50 text-green-700 font-medium" : "text-grayScale-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.isCorrect && <Check className="h-3 w-3" />}
|
||||||
|
{opt.text || `Option ${i + 1}`}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{question.tips && (
|
||||||
|
<p className="text-xs text-amber-600">Tip: {question.tips}</p>
|
||||||
|
)}
|
||||||
|
{question.explanation && (
|
||||||
|
<p className="text-xs text-grayScale-500">Explanation: {question.explanation}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{saveError && (
|
||||||
|
<p className="mt-4 text-sm text-red-500">{saveError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="mt-8 flex items-center justify-between">
|
||||||
|
<Button variant="outline" onClick={handleBack}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="outline" onClick={handleSaveAsDraft} disabled={saving}>
|
||||||
|
{saving ? "Saving..." : "Save as Draft"}
|
||||||
|
</Button>
|
||||||
|
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handlePublish} disabled={saving}>
|
||||||
|
<Rocket className="mr-2 h-4 w-4" />
|
||||||
|
{saving ? "Publishing..." : "Publish Now"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 5: Result */}
|
||||||
|
{currentStep === 5 && resultStatus && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
|
{resultStatus === "success" ? (
|
||||||
|
<>
|
||||||
|
<div className="flex h-28 w-28 items-center justify-center rounded-full bg-brand-100">
|
||||||
|
<svg viewBox="0 0 24 24" className="h-14 w-14 text-brand-500" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-8 text-2xl font-bold text-grayScale-900">
|
||||||
|
Practice Published Successfully!
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-grayScale-500">{resultMessage}</p>
|
||||||
|
<div className="mt-10 flex w-full max-w-sm flex-col gap-3">
|
||||||
|
<Button
|
||||||
|
className="w-full bg-brand-500 hover:bg-brand-600"
|
||||||
|
onClick={() => navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`)}
|
||||||
|
>
|
||||||
|
Go back to Sub-course
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-brand-500 text-brand-500 hover:bg-brand-50"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentStep(1)
|
||||||
|
setPracticeTitle("")
|
||||||
|
setPracticeDescription("")
|
||||||
|
setShuffleQuestions(false)
|
||||||
|
setPassingScore(50)
|
||||||
|
setTimeLimitMinutes(60)
|
||||||
|
setSelectedPersona(null)
|
||||||
|
setQuestions([createEmptyQuestion("1")])
|
||||||
|
setSaveError(null)
|
||||||
|
setResultStatus(null)
|
||||||
|
setResultMessage("")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Another Practice
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex h-28 w-28 items-center justify-center rounded-full bg-amber-100">
|
||||||
|
<svg viewBox="0 0 24 24" className="h-14 w-14 text-amber-500" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13" />
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-8 text-2xl font-bold text-grayScale-900">
|
||||||
|
Publish Error!
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-grayScale-500">{resultMessage}</p>
|
||||||
|
<div className="mt-10 flex w-full max-w-sm flex-col gap-3">
|
||||||
|
<Button
|
||||||
|
className="w-full bg-brand-500 hover:bg-brand-600"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentStep(4)
|
||||||
|
setResultStatus(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,44 @@
|
||||||
import { Link } from "react-router-dom"
|
import { useEffect, useState } from "react"
|
||||||
import { BookOpen, Mic, Briefcase, HelpCircle } from "lucide-react"
|
import { Link, useParams } from "react-router-dom"
|
||||||
|
import { BookOpen, Mic, Briefcase, HelpCircle, ArrowLeft } from "lucide-react"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
|
import { getCourseCategories } from "../../api/courses.api"
|
||||||
|
import type { CourseCategory } from "../../types/course.types"
|
||||||
|
|
||||||
export function ContentOverviewPage() {
|
export function ContentOverviewPage() {
|
||||||
|
const { categoryId } = useParams<{ categoryId: string }>()
|
||||||
|
const [category, setCategory] = useState<CourseCategory | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCategory = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getCourseCategories()
|
||||||
|
const found = res.data.data.categories.find((c) => c.id === Number(categoryId))
|
||||||
|
setCategory(found ?? null)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch category:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryId) {
|
||||||
|
fetchCategory()
|
||||||
|
}
|
||||||
|
}, [categoryId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-xl font-semibold text-grayScale-900">Content Management</h1>
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
to="/content"
|
||||||
|
className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 hover:bg-brand-100 hover:text-brand-600"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-xl font-semibold text-grayScale-900">
|
||||||
|
{category?.name ?? "Content Management"}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<Card className="shadow-sm">
|
<Card className="shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -17,7 +49,7 @@ export function ContentOverviewPage() {
|
||||||
<CardDescription>Manage course videos and educational content</CardDescription>
|
<CardDescription>Manage course videos and educational content</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Link to="/content/courses">
|
<Link to={`/content/category/${categoryId}/courses`}>
|
||||||
<Button className="w-full bg-brand-500 hover:bg-brand-600">Manage Courses</Button>
|
<Button className="w-full bg-brand-500 hover:bg-brand-600">Manage Courses</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
73
src/pages/content-management/CourseCategoryPage.tsx
Normal file
73
src/pages/content-management/CourseCategoryPage.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Link } from "react-router-dom"
|
||||||
|
import { FolderOpen } from "lucide-react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
|
import { getCourseCategories } from "../../api/courses.api"
|
||||||
|
import type { CourseCategory } from "../../types/course.types"
|
||||||
|
|
||||||
|
export function CourseCategoryPage() {
|
||||||
|
const [categories, setCategories] = useState<CourseCategory[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getCourseCategories()
|
||||||
|
setCategories(res.data.data.categories)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch categories:", err)
|
||||||
|
setError("Failed to load categories")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCategories()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-sm text-grayScale-500">Loading categories...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-sm text-red-500">{error}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-xl font-semibold text-grayScale-900">Course Categories</h1>
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{categories.map((category) => (
|
||||||
|
<Link key={category.id} to={`/content/category/${category.id}/courses`} className="group">
|
||||||
|
<Card className="h-full shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-brand-200">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="mb-4 grid h-12 w-12 place-items-center rounded-lg bg-brand-100 text-brand-600 transition group-hover:bg-brand-500 group-hover:text-white">
|
||||||
|
<FolderOpen className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-lg">{category.name}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<span className="text-sm font-medium text-brand-500 group-hover:text-brand-600">
|
||||||
|
View Courses →
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{categories.length === 0 && (
|
||||||
|
<div className="text-center text-sm text-grayScale-500">No categories found</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,30 +1,593 @@
|
||||||
import { Link } from "react-router-dom"
|
import { useEffect, useState, useRef } from "react"
|
||||||
import { Plus } from "lucide-react"
|
import { Link, useParams, useNavigate } from "react-router-dom"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
import { Plus, ArrowLeft, BookOpen, ToggleLeft, ToggleRight, X, Trash2, MoreVertical, Edit } from "lucide-react"
|
||||||
|
import { Card, CardContent } from "../../components/ui/card"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
|
import { Badge } from "../../components/ui/badge"
|
||||||
|
import { Input } from "../../components/ui/input"
|
||||||
|
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}`} />
|
||||||
|
}
|
||||||
|
|
||||||
export function CoursesPage() {
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<img
|
||||||
<div className="flex items-center justify-between">
|
src={src}
|
||||||
<h1 className="text-xl font-semibold text-grayScale-900">Courses</h1>
|
alt={alt}
|
||||||
<Link to="/content/courses/add-video">
|
className="h-full w-full object-cover rounded-t-lg"
|
||||||
<Button className="bg-brand-500 hover:bg-brand-600">
|
onError={() => setImgError(true)}
|
||||||
<Plus className="h-4 w-4" />
|
/>
|
||||||
Add New Video
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<Card className="shadow-none">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Course Management</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="text-sm text-muted-foreground">
|
|
||||||
Manage your course videos and content here.
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CoursesPage() {
|
||||||
|
const { categoryId } = useParams<{ categoryId: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [courses, setCourses] = useState<Course[]>([])
|
||||||
|
const [category, setCategory] = useState<CourseCategory | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const [title, setTitle] = useState("")
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saveError, setSaveError] = useState<string | 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("")
|
||||||
|
const [editDescription, setEditDescription] = useState("")
|
||||||
|
const [editThumbnail, setEditThumbnail] = useState("")
|
||||||
|
const [updating, setUpdating] = useState(false)
|
||||||
|
const [updateError, setUpdateError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
try {
|
||||||
|
const coursesRes = await getCoursesByCategory(Number(categoryId))
|
||||||
|
console.log("Courses response:", coursesRes.data.data.courses)
|
||||||
|
setCourses(coursesRes.data.data.courses ?? [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch courses:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
if (!categoryId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [coursesRes, categoriesRes] = await Promise.all([
|
||||||
|
getCoursesByCategory(Number(categoryId)),
|
||||||
|
getCourseCategories(),
|
||||||
|
])
|
||||||
|
|
||||||
|
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 courses")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData()
|
||||||
|
}, [categoryId])
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
setTitle("")
|
||||||
|
setDescription("")
|
||||||
|
setSaveError(null)
|
||||||
|
setShowModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setShowModal(false)
|
||||||
|
setTitle("")
|
||||||
|
setDescription("")
|
||||||
|
setSaveError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
setSaveError("Title is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!description.trim()) {
|
||||||
|
setSaveError("Description is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setSaveError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createCourse({
|
||||||
|
category_id: Number(categoryId),
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
})
|
||||||
|
handleCloseModal()
|
||||||
|
await fetchCourses()
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Failed to create course:", err)
|
||||||
|
setSaveError(err.response?.data?.message || "Failed to create course")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteClick = (course: Course) => {
|
||||||
|
setCourseToDelete(course)
|
||||||
|
setShowDeleteModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (!courseToDelete) return
|
||||||
|
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await deleteCourse(courseToDelete.id)
|
||||||
|
setShowDeleteModal(false)
|
||||||
|
setCourseToDelete(null)
|
||||||
|
await fetchCourses()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete course:", err)
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleStatus = async (course: Course) => {
|
||||||
|
setTogglingId(course.id)
|
||||||
|
try {
|
||||||
|
await updateCourseStatus(course.id, !course.is_active)
|
||||||
|
await fetchCourses()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update course status:", err)
|
||||||
|
} finally {
|
||||||
|
setTogglingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditClick = (course: Course) => {
|
||||||
|
setCourseToEdit(course)
|
||||||
|
setEditTitle(course.title || "")
|
||||||
|
setEditDescription(course.description || "")
|
||||||
|
setEditThumbnail(course.thumbnail || "")
|
||||||
|
setUpdateError(null)
|
||||||
|
setShowEditModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseEditModal = () => {
|
||||||
|
setShowEditModal(false)
|
||||||
|
setCourseToEdit(null)
|
||||||
|
setEditTitle("")
|
||||||
|
setEditDescription("")
|
||||||
|
setEditThumbnail("")
|
||||||
|
setUpdateError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
if (!courseToEdit) return
|
||||||
|
|
||||||
|
if (!editTitle.trim()) {
|
||||||
|
setUpdateError("Title is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!editDescription.trim()) {
|
||||||
|
setUpdateError("Description is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUpdating(true)
|
||||||
|
setUpdateError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateCourse(courseToEdit.id, {
|
||||||
|
title: editTitle.trim(),
|
||||||
|
description: editDescription.trim(),
|
||||||
|
thumbnail: editThumbnail.trim() || undefined,
|
||||||
|
is_active: courseToEdit.is_active,
|
||||||
|
})
|
||||||
|
handleCloseEditModal()
|
||||||
|
await fetchCourses()
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Failed to update course:", err)
|
||||||
|
setUpdateError(err.response?.data?.message || "Failed to update course")
|
||||||
|
} finally {
|
||||||
|
setUpdating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCourseClick = (courseId: number) => {
|
||||||
|
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-sm text-grayScale-500">Loading courses...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-sm text-red-500">{error}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
to="/content"
|
||||||
|
className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 hover:bg-brand-100 hover:text-brand-600"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-grayScale-900">
|
||||||
|
{category?.name} Courses
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-grayScale-500">{courses.length} courses available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleOpenModal}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add New Course
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{courses.length === 0 ? (
|
||||||
|
<Card className="shadow-none">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<BookOpen className="mb-4 h-12 w-12 text-grayScale-300" />
|
||||||
|
<p className="text-sm text-grayScale-500">No courses found in this category</p>
|
||||||
|
<Button variant="outline" className="mt-4" onClick={handleOpenModal}>
|
||||||
|
Add your first course
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6 md: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 overflow-hidden border-0 bg-white shadow-sm transition hover:shadow-md"
|
||||||
|
onClick={() => handleCourseClick(course.id)}
|
||||||
|
>
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div className="relative aspect-video w-full">
|
||||||
|
<CourseThumbnail
|
||||||
|
src={course.thumbnail}
|
||||||
|
alt={course.title}
|
||||||
|
gradient={gradients[index % gradients.length]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{/* Status and menu */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge
|
||||||
|
className={`text-xs font-medium ${
|
||||||
|
course.is_active
|
||||||
|
? "bg-transparent text-green-600 border border-green-200"
|
||||||
|
: "bg-grayScale-100 text-grayScale-600 border border-grayScale-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${course.is_active ? "bg-green-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="text-grayScale-400 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 w-40 rounded-lg bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleToggleStatus(course)
|
||||||
|
setOpenMenuId(null)
|
||||||
|
}}
|
||||||
|
disabled={togglingId === course.id}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-grayScale-700 hover:bg-grayScale-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{course.is_active ? (
|
||||||
|
<>
|
||||||
|
<ToggleLeft className="h-4 w-4" />
|
||||||
|
Deactivate
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ToggleRight className="h-4 w-4" />
|
||||||
|
Activate
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleDeleteClick(course)
|
||||||
|
setOpenMenuId(null)
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-500 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className="font-medium text-grayScale-900">{course.title}</h3>
|
||||||
|
<p className="text-sm text-grayScale-500 line-clamp-2">
|
||||||
|
{course.description || "No description available"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Edit button */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-grayScale-200 text-grayScale-700"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleEditClick(course)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||||
|
<h2 className="text-lg font-semibold text-grayScale-900">Add New Course</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 px-6 py-5">
|
||||||
|
{saveError && (
|
||||||
|
<div className="rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600">
|
||||||
|
{saveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="course-title"
|
||||||
|
className="mb-2 block text-sm font-medium text-grayScale-600"
|
||||||
|
>
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="course-title"
|
||||||
|
placeholder="Enter course title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="course-description"
|
||||||
|
className="mb-2 block text-sm font-medium text-grayScale-600"
|
||||||
|
>
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="course-description"
|
||||||
|
placeholder="Enter course description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className="flex w-full rounded-lg border bg-white px-3 py-2 text-sm ring-offset-background placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-grayScale-500">
|
||||||
|
Category: <span className="font-medium">{category?.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
||||||
|
<Button variant="outline" onClick={handleCloseModal} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-brand-500 hover:bg-brand-600"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save Course"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showEditModal && courseToEdit && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||||
|
<h2 className="text-lg font-semibold text-grayScale-900">Edit Course</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseEditModal}
|
||||||
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 px-6 py-5">
|
||||||
|
{updateError && (
|
||||||
|
<div className="rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600">
|
||||||
|
{updateError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="edit-course-title"
|
||||||
|
className="mb-2 block text-sm font-medium text-grayScale-600"
|
||||||
|
>
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="edit-course-title"
|
||||||
|
placeholder="Enter course title"
|
||||||
|
value={editTitle}
|
||||||
|
onChange={(e) => setEditTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="edit-course-description"
|
||||||
|
className="mb-2 block text-sm font-medium text-grayScale-600"
|
||||||
|
>
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="edit-course-description"
|
||||||
|
placeholder="Enter course description"
|
||||||
|
value={editDescription}
|
||||||
|
onChange={(e) => setEditDescription(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className="flex w-full rounded-lg border bg-white px-3 py-2 text-sm ring-offset-background placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="edit-course-thumbnail"
|
||||||
|
className="mb-2 block text-sm font-medium text-grayScale-600"
|
||||||
|
>
|
||||||
|
Thumbnail URL
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="edit-course-thumbnail"
|
||||||
|
placeholder="Enter thumbnail URL (e.g., https://example.com/image.jpg)"
|
||||||
|
value={editThumbnail}
|
||||||
|
onChange={(e) => setEditThumbnail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
||||||
|
<Button variant="outline" onClick={handleCloseEditModal} disabled={updating}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-brand-500 hover:bg-brand-600"
|
||||||
|
onClick={handleUpdate}
|
||||||
|
disabled={updating}
|
||||||
|
>
|
||||||
|
{updating ? "Updating..." : "Update Course"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDeleteModal && courseToDelete && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||||
|
<h2 className="text-lg font-semibold text-grayScale-900">Delete Course</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteModal(false)}
|
||||||
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-5">
|
||||||
|
<p className="text-sm text-grayScale-600">
|
||||||
|
Are you sure you want to delete{" "}
|
||||||
|
<span className="font-semibold">{courseToDelete.title}</span>? This action cannot
|
||||||
|
be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDeleteModal(false)}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-red-500 hover:bg-red-600"
|
||||||
|
onClick={handleConfirmDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting ? "Deleting..." : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
477
src/pages/content-management/PracticeQuestionsPage.tsx
Normal file
477
src/pages/content-management/PracticeQuestionsPage.tsx
Normal file
|
|
@ -0,0 +1,477 @@
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Link, useParams } from "react-router-dom"
|
||||||
|
import { ArrowLeft, HelpCircle, Plus, Edit, Trash2, X } from "lucide-react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
|
import { Badge } from "../../components/ui/badge"
|
||||||
|
import { Button } from "../../components/ui/button"
|
||||||
|
import { getPracticeQuestions, createPracticeQuestion, updatePracticeQuestion, deletePracticeQuestion } from "../../api/courses.api"
|
||||||
|
import { Input } from "../../components/ui/input"
|
||||||
|
import { Select } from "../../components/ui/select"
|
||||||
|
import type { PracticeQuestion } from "../../types/course.types"
|
||||||
|
|
||||||
|
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT"
|
||||||
|
|
||||||
|
const typeLabels: Record<QuestionType, string> = {
|
||||||
|
MCQ: "Multiple Choice",
|
||||||
|
TRUE_FALSE: "True/False",
|
||||||
|
SHORT: "Short Answer",
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeColors: Record<QuestionType, string> = {
|
||||||
|
MCQ: "bg-blue-100 text-blue-700",
|
||||||
|
TRUE_FALSE: "bg-purple-100 text-purple-700",
|
||||||
|
SHORT: "bg-green-100 text-green-700",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PracticeQuestionsPage() {
|
||||||
|
const { categoryId, courseId, subCourseId, practiceId } = useParams()
|
||||||
|
|
||||||
|
const [questions, setQuestions] = useState<PracticeQuestion[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false)
|
||||||
|
const [questionToEdit, setQuestionToEdit] = useState<PracticeQuestion | null>(null)
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||||
|
const [questionToDelete, setQuestionToDelete] = useState<PracticeQuestion | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
const [questionText, setQuestionText] = useState("")
|
||||||
|
const [questionType, setQuestionType] = useState<QuestionType>("MCQ")
|
||||||
|
const [sampleAnswer, setSampleAnswer] = useState("")
|
||||||
|
const [tips, setTips] = useState("")
|
||||||
|
const [questionVoicePrompt, setQuestionVoicePrompt] = useState("")
|
||||||
|
const [sampleAnswerVoicePrompt, setSampleAnswerVoicePrompt] = useState("")
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const backLink = `/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`
|
||||||
|
|
||||||
|
const fetchQuestions = async () => {
|
||||||
|
if (!practiceId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getPracticeQuestions(Number(practiceId))
|
||||||
|
setQuestions(res.data.data.questions ?? [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch questions:", err)
|
||||||
|
setError("Failed to load questions")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchQuestions()
|
||||||
|
}, [practiceId])
|
||||||
|
|
||||||
|
const handleAddQuestion = () => {
|
||||||
|
setQuestionText("")
|
||||||
|
setQuestionType("MCQ")
|
||||||
|
setSampleAnswer("")
|
||||||
|
setTips("")
|
||||||
|
setQuestionVoicePrompt("")
|
||||||
|
setSampleAnswerVoicePrompt("")
|
||||||
|
setSaveError(null)
|
||||||
|
setShowAddModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveNewQuestion = async () => {
|
||||||
|
if (!practiceId) return
|
||||||
|
setSaving(true)
|
||||||
|
setSaveError(null)
|
||||||
|
try {
|
||||||
|
await createPracticeQuestion({
|
||||||
|
practice_id: Number(practiceId),
|
||||||
|
question: questionText,
|
||||||
|
type: questionType,
|
||||||
|
sample_answer: sampleAnswer,
|
||||||
|
tips,
|
||||||
|
question_voice_prompt: questionVoicePrompt,
|
||||||
|
sample_answer_voice_prompt: sampleAnswerVoicePrompt,
|
||||||
|
})
|
||||||
|
setShowAddModal(false)
|
||||||
|
resetForm()
|
||||||
|
await fetchQuestions()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create question:", err)
|
||||||
|
setSaveError("Failed to create question")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditClick = (question: PracticeQuestion) => {
|
||||||
|
setQuestionToEdit(question)
|
||||||
|
setQuestionText(question.question)
|
||||||
|
setQuestionType(question.type)
|
||||||
|
setSampleAnswer(question.sample_answer)
|
||||||
|
setTips(question.tips || "")
|
||||||
|
setQuestionVoicePrompt(question.question_voice_prompt || "")
|
||||||
|
setSampleAnswerVoicePrompt(question.sample_answer_voice_prompt || "")
|
||||||
|
setSaveError(null)
|
||||||
|
setShowEditModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveEditQuestion = async () => {
|
||||||
|
if (!questionToEdit) return
|
||||||
|
setSaving(true)
|
||||||
|
setSaveError(null)
|
||||||
|
try {
|
||||||
|
await updatePracticeQuestion(questionToEdit.id, {
|
||||||
|
question: questionText,
|
||||||
|
type: questionType,
|
||||||
|
sample_answer: sampleAnswer,
|
||||||
|
tips,
|
||||||
|
question_voice_prompt: questionVoicePrompt,
|
||||||
|
sample_answer_voice_prompt: sampleAnswerVoicePrompt,
|
||||||
|
})
|
||||||
|
setShowEditModal(false)
|
||||||
|
setQuestionToEdit(null)
|
||||||
|
resetForm()
|
||||||
|
await fetchQuestions()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update question:", err)
|
||||||
|
setSaveError("Failed to update question")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteClick = (question: PracticeQuestion) => {
|
||||||
|
setQuestionToDelete(question)
|
||||||
|
setShowDeleteModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (!questionToDelete) return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await deletePracticeQuestion(questionToDelete.id)
|
||||||
|
setShowDeleteModal(false)
|
||||||
|
setQuestionToDelete(null)
|
||||||
|
await fetchQuestions()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete question:", err)
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setQuestionText("")
|
||||||
|
setQuestionType("MCQ")
|
||||||
|
setSampleAnswer("")
|
||||||
|
setTips("")
|
||||||
|
setQuestionVoicePrompt("")
|
||||||
|
setSampleAnswerVoicePrompt("")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-sm text-grayScale-500">Loading questions...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-sm text-red-500">{error}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
to={backLink}
|
||||||
|
className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 hover:bg-brand-100 hover:text-brand-600"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-grayScale-900">Practice Questions</h1>
|
||||||
|
<p className="text-sm text-grayScale-500">{questions.length} questions available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleAddQuestion}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add New Question
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{questions.length === 0 ? (
|
||||||
|
<Card className="shadow-none">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<HelpCircle className="mb-4 h-12 w-12 text-grayScale-300" />
|
||||||
|
<p className="text-sm text-grayScale-500">No questions found for this practice</p>
|
||||||
|
<Button variant="outline" className="mt-4" onClick={handleAddQuestion}>
|
||||||
|
Add your first question
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{questions.map((question, index) => (
|
||||||
|
<Card key={question.id} className="shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-brand-100 text-sm font-semibold text-brand-600">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<Badge className={typeColors[question.type]}>
|
||||||
|
{typeLabels[question.type]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => handleEditClick(question)}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-red-500 hover:text-red-600"
|
||||||
|
onClick={() => handleDeleteClick(question)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="mt-3 text-base font-medium">{question.question}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 pt-0">
|
||||||
|
<div className="rounded-lg bg-grayScale-50 p-3">
|
||||||
|
<p className="text-xs font-medium text-grayScale-500">Sample Answer</p>
|
||||||
|
<p className="mt-1 text-sm text-grayScale-700">{question.sample_answer}</p>
|
||||||
|
</div>
|
||||||
|
{question.tips && (
|
||||||
|
<div className="rounded-lg bg-amber-50 p-3">
|
||||||
|
<p className="text-xs font-medium text-amber-600">Tips</p>
|
||||||
|
<p className="mt-1 text-sm text-amber-700">{question.tips}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Modal */}
|
||||||
|
{showDeleteModal && questionToDelete && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||||
|
<h2 className="text-lg font-semibold text-grayScale-900">Delete Question</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteModal(false)}
|
||||||
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-5">
|
||||||
|
<p className="text-sm text-grayScale-600">
|
||||||
|
Are you sure you want to delete this question? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
||||||
|
<Button variant="outline" onClick={() => setShowDeleteModal(false)} disabled={deleting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button className="bg-red-500 hover:bg-red-600" onClick={handleConfirmDelete} disabled={deleting}>
|
||||||
|
{deleting ? "Deleting..." : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Modal */}
|
||||||
|
{showAddModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-xl bg-white shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||||
|
<h2 className="text-lg font-semibold text-grayScale-900">Add New Question</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddModal(false)}
|
||||||
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 px-6 py-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Question Type</label>
|
||||||
|
<Select value={questionType} onChange={(e) => setQuestionType(e.target.value as QuestionType)}>
|
||||||
|
<option value="MCQ">Multiple Choice</option>
|
||||||
|
<option value="TRUE_FALSE">True/False</option>
|
||||||
|
<option value="SHORT">Short Answer</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Question</label>
|
||||||
|
<textarea
|
||||||
|
value={questionText}
|
||||||
|
onChange={(e) => setQuestionText(e.target.value)}
|
||||||
|
placeholder="Enter your question"
|
||||||
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Sample Answer</label>
|
||||||
|
<textarea
|
||||||
|
value={sampleAnswer}
|
||||||
|
onChange={(e) => setSampleAnswer(e.target.value)}
|
||||||
|
placeholder="Enter the sample answer"
|
||||||
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Tips (Optional)</label>
|
||||||
|
<textarea
|
||||||
|
value={tips}
|
||||||
|
onChange={(e) => setTips(e.target.value)}
|
||||||
|
placeholder="Enter helpful tips"
|
||||||
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Question Voice Prompt (Optional)</label>
|
||||||
|
<Input
|
||||||
|
value={questionVoicePrompt}
|
||||||
|
onChange={(e) => setQuestionVoicePrompt(e.target.value)}
|
||||||
|
placeholder="Voice prompt for question"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Sample Answer Voice Prompt (Optional)</label>
|
||||||
|
<Input
|
||||||
|
value={sampleAnswerVoicePrompt}
|
||||||
|
onChange={(e) => setSampleAnswerVoicePrompt(e.target.value)}
|
||||||
|
placeholder="Voice prompt for sample answer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{saveError && <p className="text-sm text-red-500">{saveError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
||||||
|
<Button variant="outline" onClick={() => setShowAddModal(false)} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-brand-500 hover:bg-brand-600"
|
||||||
|
onClick={handleSaveNewQuestion}
|
||||||
|
disabled={saving || !questionText.trim() || !sampleAnswer.trim()}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
{showEditModal && questionToEdit && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-xl bg-white shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||||
|
<h2 className="text-lg font-semibold text-grayScale-900">Edit Question</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEditModal(false)}
|
||||||
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 px-6 py-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Question Type</label>
|
||||||
|
<Select value={questionType} onChange={(e) => setQuestionType(e.target.value as QuestionType)}>
|
||||||
|
<option value="MCQ">Multiple Choice</option>
|
||||||
|
<option value="TRUE_FALSE">True/False</option>
|
||||||
|
<option value="SHORT">Short Answer</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Question</label>
|
||||||
|
<textarea
|
||||||
|
value={questionText}
|
||||||
|
onChange={(e) => setQuestionText(e.target.value)}
|
||||||
|
placeholder="Enter your question"
|
||||||
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Sample Answer</label>
|
||||||
|
<textarea
|
||||||
|
value={sampleAnswer}
|
||||||
|
onChange={(e) => setSampleAnswer(e.target.value)}
|
||||||
|
placeholder="Enter the sample answer"
|
||||||
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Tips (Optional)</label>
|
||||||
|
<textarea
|
||||||
|
value={tips}
|
||||||
|
onChange={(e) => setTips(e.target.value)}
|
||||||
|
placeholder="Enter helpful tips"
|
||||||
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Question Voice Prompt (Optional)</label>
|
||||||
|
<Input
|
||||||
|
value={questionVoicePrompt}
|
||||||
|
onChange={(e) => setQuestionVoicePrompt(e.target.value)}
|
||||||
|
placeholder="Voice prompt for question"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Sample Answer Voice Prompt (Optional)</label>
|
||||||
|
<Input
|
||||||
|
value={sampleAnswerVoicePrompt}
|
||||||
|
onChange={(e) => setSampleAnswerVoicePrompt(e.target.value)}
|
||||||
|
placeholder="Voice prompt for sample answer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{saveError && <p className="text-sm text-red-500">{saveError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
||||||
|
<Button variant="outline" onClick={() => setShowEditModal(false)} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-brand-500 hover:bg-brand-600"
|
||||||
|
onClick={handleSaveEditQuestion}
|
||||||
|
disabled={saving || !questionText.trim() || !sampleAnswer.trim()}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
838
src/pages/content-management/SubCourseContentPage.tsx
Normal file
838
src/pages/content-management/SubCourseContentPage.tsx
Normal file
|
|
@ -0,0 +1,838 @@
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Link, useParams, useNavigate } from "react-router-dom"
|
||||||
|
import { ArrowLeft, Plus, FileText, Layers, Edit, Trash2, X, Video, MoreVertical } from "lucide-react"
|
||||||
|
import { Card } from "../../components/ui/card"
|
||||||
|
import { Badge } from "../../components/ui/badge"
|
||||||
|
import { Button } from "../../components/ui/button"
|
||||||
|
import { Input } from "../../components/ui/input"
|
||||||
|
import {
|
||||||
|
getSubCoursesByCourse,
|
||||||
|
getQuestionSetsByOwner,
|
||||||
|
getVideosBySubCourse,
|
||||||
|
updatePractice,
|
||||||
|
deleteQuestionSet,
|
||||||
|
createVimeoVideo,
|
||||||
|
updateSubCourseVideo,
|
||||||
|
deleteSubCourseVideo
|
||||||
|
} from "../../api/courses.api"
|
||||||
|
import type { SubCourse, QuestionSet, SubCourseVideo } from "../../types/course.types"
|
||||||
|
|
||||||
|
type TabType = "video" | "practice"
|
||||||
|
type StatusFilter = "all" | "published" | "draft" | "archived"
|
||||||
|
|
||||||
|
export function SubCourseContentPage() {
|
||||||
|
const { categoryId, courseId, subCourseId } = useParams<{
|
||||||
|
categoryId: string
|
||||||
|
courseId: string
|
||||||
|
subCourseId: string
|
||||||
|
}>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [subCourse, setSubCourse] = useState<SubCourse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>("practice")
|
||||||
|
const [statusFilter] = useState<StatusFilter>("all")
|
||||||
|
|
||||||
|
const [practices, setPractices] = useState<QuestionSet[]>([])
|
||||||
|
const [videos, setVideos] = useState<SubCourseVideo[]>([])
|
||||||
|
const [practicesLoading, setPracticesLoading] = useState(false)
|
||||||
|
const [videosLoading, setVideosLoading] = useState(false)
|
||||||
|
|
||||||
|
const [showEditPracticeModal, setShowEditPracticeModal] = useState(false)
|
||||||
|
const [practiceToEdit, setPracticeToEdit] = useState<QuestionSet | null>(null)
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||||
|
const [practiceToDelete, setPracticeToDelete] = useState<QuestionSet | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
const [title, setTitle] = useState("")
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [persona, setPersona] = useState("")
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [showAddVideoModal, setShowAddVideoModal] = useState(false)
|
||||||
|
const [showEditVideoModal, setShowEditVideoModal] = useState(false)
|
||||||
|
const [videoToEdit, setVideoToEdit] = useState<SubCourseVideo | null>(null)
|
||||||
|
const [showDeleteVideoModal, setShowDeleteVideoModal] = useState(false)
|
||||||
|
const [videoToDelete, setVideoToDelete] = useState<SubCourseVideo | null>(null)
|
||||||
|
const [deletingVideo, setDeletingVideo] = useState(false)
|
||||||
|
const [openVideoMenuId, setOpenVideoMenuId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const [videoTitle, setVideoTitle] = useState("")
|
||||||
|
const [videoDescription, setVideoDescription] = useState("")
|
||||||
|
const [videoUrl, setVideoUrl] = useState("")
|
||||||
|
const [videoFileSize, setVideoFileSize] = useState<number>(0)
|
||||||
|
const [videoDuration, setVideoDuration] = useState<number>(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
if (!subCourseId || !courseId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const subCoursesRes = await getSubCoursesByCourse(Number(courseId))
|
||||||
|
const foundSubCourse = subCoursesRes.data.data.sub_courses?.find(
|
||||||
|
(sc) => sc.id === Number(subCourseId)
|
||||||
|
)
|
||||||
|
setSubCourse(foundSubCourse ?? null)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch sub-course data:", err)
|
||||||
|
setError("Failed to load sub-course")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData()
|
||||||
|
}, [subCourseId, courseId])
|
||||||
|
|
||||||
|
const fetchPractices = async () => {
|
||||||
|
if (!subCourseId) return
|
||||||
|
setPracticesLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await getQuestionSetsByOwner("SUB_COURSE", Number(subCourseId))
|
||||||
|
setPractices(res.data.data ?? [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch practices:", err)
|
||||||
|
} finally {
|
||||||
|
setPracticesLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchVideos = async () => {
|
||||||
|
if (!subCourseId) return
|
||||||
|
setVideosLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await getVideosBySubCourse(Number(subCourseId))
|
||||||
|
setVideos(res.data.data.videos ?? [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch videos:", err)
|
||||||
|
} finally {
|
||||||
|
setVideosLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === "practice") {
|
||||||
|
fetchPractices()
|
||||||
|
} else {
|
||||||
|
fetchVideos()
|
||||||
|
}
|
||||||
|
}, [activeTab, subCourseId])
|
||||||
|
|
||||||
|
const handleAddPractice = () => {
|
||||||
|
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}/add-practice`)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const handleEditClick = (practice: QuestionSet) => {
|
||||||
|
setPracticeToEdit(practice)
|
||||||
|
setTitle(practice.title)
|
||||||
|
setDescription(practice.description)
|
||||||
|
setPersona(practice.persona || "")
|
||||||
|
setSaveError(null)
|
||||||
|
setShowEditPracticeModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveEditPractice = async () => {
|
||||||
|
if (!practiceToEdit) return
|
||||||
|
setSaving(true)
|
||||||
|
setSaveError(null)
|
||||||
|
try {
|
||||||
|
await updatePractice(practiceToEdit.id, {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
persona,
|
||||||
|
})
|
||||||
|
setShowEditPracticeModal(false)
|
||||||
|
setPracticeToEdit(null)
|
||||||
|
setTitle("")
|
||||||
|
setDescription("")
|
||||||
|
setPersona("")
|
||||||
|
await fetchPractices()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update practice:", err)
|
||||||
|
setSaveError("Failed to update practice")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteClick = (practice: QuestionSet) => {
|
||||||
|
setPracticeToDelete(practice)
|
||||||
|
setShowDeleteModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (!practiceToDelete) return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await deleteQuestionSet(practiceToDelete.id)
|
||||||
|
setShowDeleteModal(false)
|
||||||
|
setPracticeToDelete(null)
|
||||||
|
await fetchPractices()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete practice:", err)
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePracticeClick = (practiceId: number) => {
|
||||||
|
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}/practices/${practiceId}/questions`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddVideo = () => {
|
||||||
|
setVideoTitle("")
|
||||||
|
setVideoDescription("")
|
||||||
|
setVideoUrl("")
|
||||||
|
setVideoFileSize(0)
|
||||||
|
setVideoDuration(0)
|
||||||
|
setSaveError(null)
|
||||||
|
setShowAddVideoModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveNewVideo = async () => {
|
||||||
|
if (!subCourseId) return
|
||||||
|
setSaving(true)
|
||||||
|
setSaveError(null)
|
||||||
|
try {
|
||||||
|
await createVimeoVideo({
|
||||||
|
sub_course_id: Number(subCourseId),
|
||||||
|
title: videoTitle,
|
||||||
|
description: videoDescription,
|
||||||
|
source_url: videoUrl,
|
||||||
|
file_size: videoFileSize,
|
||||||
|
duration: videoDuration,
|
||||||
|
})
|
||||||
|
setShowAddVideoModal(false)
|
||||||
|
setVideoTitle("")
|
||||||
|
setVideoDescription("")
|
||||||
|
setVideoUrl("")
|
||||||
|
setVideoFileSize(0)
|
||||||
|
setVideoDuration(0)
|
||||||
|
await fetchVideos()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create video:", err)
|
||||||
|
setSaveError("Failed to create video")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditVideoClick = (video: SubCourseVideo) => {
|
||||||
|
setVideoToEdit(video)
|
||||||
|
setVideoTitle(video.title)
|
||||||
|
setVideoDescription(video.description || "")
|
||||||
|
setVideoUrl(video.video_url || "")
|
||||||
|
setSaveError(null)
|
||||||
|
setShowEditVideoModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveEditVideo = async () => {
|
||||||
|
if (!videoToEdit) return
|
||||||
|
setSaving(true)
|
||||||
|
setSaveError(null)
|
||||||
|
try {
|
||||||
|
await updateSubCourseVideo(videoToEdit.id, {
|
||||||
|
title: videoTitle,
|
||||||
|
description: videoDescription,
|
||||||
|
video_url: videoUrl,
|
||||||
|
})
|
||||||
|
setShowEditVideoModal(false)
|
||||||
|
setVideoToEdit(null)
|
||||||
|
setVideoTitle("")
|
||||||
|
setVideoDescription("")
|
||||||
|
setVideoUrl("")
|
||||||
|
await fetchVideos()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update video:", err)
|
||||||
|
setSaveError("Failed to update video")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteVideoClick = (video: SubCourseVideo) => {
|
||||||
|
setVideoToDelete(video)
|
||||||
|
setShowDeleteVideoModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmDeleteVideo = async () => {
|
||||||
|
if (!videoToDelete) return
|
||||||
|
setDeletingVideo(true)
|
||||||
|
try {
|
||||||
|
await deleteSubCourseVideo(videoToDelete.id)
|
||||||
|
setShowDeleteVideoModal(false)
|
||||||
|
setVideoToDelete(null)
|
||||||
|
await fetchVideos()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete video:", err)
|
||||||
|
} finally {
|
||||||
|
setDeletingVideo(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredPractices = practices.filter((practice) => {
|
||||||
|
if (statusFilter === "all") return true
|
||||||
|
if (statusFilter === "published") return practice.status === "PUBLISHED"
|
||||||
|
if (statusFilter === "draft") return practice.status === "DRAFT"
|
||||||
|
if (statusFilter === "archived") return practice.status === "ARCHIVED"
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-sm text-grayScale-500">Loading sub-course...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-sm text-red-500">{error}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Back Button */}
|
||||||
|
<Link
|
||||||
|
to={`/content/category/${categoryId}/courses/${courseId}/sub-courses`}
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-grayScale-600 hover:text-grayScale-900"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to Sub-courses
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* SubCourse Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-semibold text-grayScale-900">
|
||||||
|
{subCourse?.title}
|
||||||
|
</h1>
|
||||||
|
{subCourse?.level && (
|
||||||
|
<Badge className="bg-purple-100 text-purple-700">{subCourse.level}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-grayScale-500">
|
||||||
|
{subCourse?.description || "No description available"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-brand-500 text-brand-500 hover:bg-brand-50"
|
||||||
|
onClick={handleAddPractice}
|
||||||
|
>
|
||||||
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
|
Add Practice
|
||||||
|
</Button>
|
||||||
|
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleAddVideo}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Video
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="border-b border-grayScale-200">
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("video")}
|
||||||
|
className={`pb-3 text-sm font-medium transition-colors ${
|
||||||
|
activeTab === "video"
|
||||||
|
? "border-b-2 border-brand-500 text-brand-500"
|
||||||
|
: "text-grayScale-500 hover:text-grayScale-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Video
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("practice")}
|
||||||
|
className={`pb-3 text-sm font-medium transition-colors ${
|
||||||
|
activeTab === "practice"
|
||||||
|
? "border-b-2 border-brand-500 text-brand-500"
|
||||||
|
: "text-grayScale-500 hover:text-grayScale-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Practice
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{activeTab === "practice" && (
|
||||||
|
<>
|
||||||
|
{practicesLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-sm text-grayScale-500">Loading practices...</div>
|
||||||
|
</div>
|
||||||
|
) : filteredPractices.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<FileText className="mb-4 h-12 w-12 text-grayScale-300" />
|
||||||
|
<p className="text-sm text-grayScale-500">No practices found</p>
|
||||||
|
<Button variant="outline" className="mt-4" onClick={handleAddPractice}>
|
||||||
|
Add your first practice
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{filteredPractices.map((practice) => {
|
||||||
|
const statusConfig: Record<string, { bg: string; dot: string; text: string }> = {
|
||||||
|
PUBLISHED: { bg: "bg-transparent border border-green-200 text-green-600", dot: "bg-green-500", text: "Published" },
|
||||||
|
DRAFT: { bg: "bg-grayScale-100 border border-grayScale-200 text-grayScale-600", dot: "bg-grayScale-400", text: "Draft" },
|
||||||
|
ARCHIVED: { bg: "bg-transparent border border-amber-200 text-amber-600", dot: "bg-amber-500", text: "Archived" },
|
||||||
|
}
|
||||||
|
const status = statusConfig[practice.status] ?? statusConfig.DRAFT
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={practice.id}
|
||||||
|
className="cursor-pointer overflow-hidden border border-grayScale-200 shadow-sm transition hover:shadow-md hover:border-brand-200"
|
||||||
|
onClick={() => handlePracticeClick(practice.id)}
|
||||||
|
>
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="font-semibold text-grayScale-900 line-clamp-2">{practice.title}</h3>
|
||||||
|
<Badge className={`shrink-0 text-xs font-medium ${status.bg}`}>
|
||||||
|
<span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${status.dot}`} />
|
||||||
|
{status.text}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-grayScale-500 line-clamp-2">{practice.description}</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Badge className="bg-brand-50 text-brand-600 text-xs px-2 py-0.5 border border-brand-200">
|
||||||
|
{practice.set_type}
|
||||||
|
</Badge>
|
||||||
|
{practice.persona && (
|
||||||
|
<Badge className="bg-purple-50 text-purple-600 text-xs px-2 py-0.5 border border-purple-200">
|
||||||
|
{practice.persona}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-xs text-grayScale-400">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Layers className="h-3.5 w-3.5" />
|
||||||
|
<span>{practice.owner_type.replace("_", " ")}</span>
|
||||||
|
</div>
|
||||||
|
{practice.shuffle_questions && (
|
||||||
|
<span className="text-amber-500">Shuffle ON</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-grayScale-100 pt-3">
|
||||||
|
<span className="text-xs text-grayScale-400">
|
||||||
|
{new Date(practice.created_at).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditClick(practice)}
|
||||||
|
className="rounded p-1.5 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteClick(practice)}
|
||||||
|
className="rounded p-1.5 text-grayScale-400 hover:bg-red-50 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "video" && (
|
||||||
|
<>
|
||||||
|
{videosLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-sm text-grayScale-500">Loading videos...</div>
|
||||||
|
</div>
|
||||||
|
) : videos.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Video className="mb-4 h-12 w-12 text-grayScale-300" />
|
||||||
|
<p className="text-sm text-grayScale-500">No videos found</p>
|
||||||
|
<Button variant="outline" className="mt-4" onClick={handleAddVideo}>
|
||||||
|
Add your first video
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{videos.map((video, index) => {
|
||||||
|
const gradients = [
|
||||||
|
"bg-gradient-to-br from-blue-100 to-blue-200",
|
||||||
|
"bg-gradient-to-br from-yellow-100 to-yellow-200",
|
||||||
|
"bg-gradient-to-br from-purple-100 to-purple-200",
|
||||||
|
"bg-gradient-to-br from-green-100 to-green-200",
|
||||||
|
]
|
||||||
|
const formatDuration = (seconds: number) => {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Card key={video.id} className="overflow-hidden border-0 bg-white shadow-sm">
|
||||||
|
{/* Thumbnail with duration */}
|
||||||
|
<div className="relative aspect-video w-full">
|
||||||
|
{video.thumbnail ? (
|
||||||
|
<img src={video.thumbnail} alt={video.title} className="h-full w-full object-cover rounded-t-lg" />
|
||||||
|
) : (
|
||||||
|
<div className={`h-full w-full rounded-t-lg ${gradients[index % gradients.length]}`} />
|
||||||
|
)}
|
||||||
|
<div className="absolute bottom-2 right-2 rounded bg-grayScale-900/80 px-2 py-0.5 text-xs font-medium text-white">
|
||||||
|
{formatDuration(video.duration || 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{/* Status and menu */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge
|
||||||
|
className={`text-xs font-medium ${
|
||||||
|
video.is_published
|
||||||
|
? "bg-transparent text-green-600 border border-green-200"
|
||||||
|
: "bg-grayScale-100 text-grayScale-600 border border-grayScale-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${video.is_published ? "bg-green-500" : "bg-grayScale-400"}`} />
|
||||||
|
{video.is_published ? "PUBLISHED" : "DRAFT"}
|
||||||
|
</Badge>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpenVideoMenuId(openVideoMenuId === video.id ? null : video.id)}
|
||||||
|
className="text-grayScale-400 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{openVideoMenuId === video.id && (
|
||||||
|
<div className="absolute right-0 top-full z-10 mt-1 w-32 rounded-lg bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleDeleteVideoClick(video)
|
||||||
|
setOpenVideoMenuId(null)
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-500 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className="font-medium text-grayScale-900">{video.title}</h3>
|
||||||
|
|
||||||
|
{/* Edit button */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-grayScale-200 text-grayScale-700"
|
||||||
|
onClick={() => handleEditVideoClick(video)}
|
||||||
|
>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Publish button */}
|
||||||
|
<Button
|
||||||
|
className={`w-full ${
|
||||||
|
video.is_published
|
||||||
|
? "bg-green-500 hover:bg-green-600"
|
||||||
|
: "bg-brand-500 hover:bg-brand-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{video.is_published ? "Published" : "Publish"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Modal */}
|
||||||
|
{showDeleteModal && practiceToDelete && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||||
|
<h2 className="text-lg font-semibold text-grayScale-900">Delete Practice</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteModal(false)}
|
||||||
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-5">
|
||||||
|
<p className="text-sm text-grayScale-600">
|
||||||
|
Are you sure you want to delete{" "}
|
||||||
|
<span className="font-semibold">{practiceToDelete.title}</span>? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
||||||
|
<Button variant="outline" onClick={() => setShowDeleteModal(false)} disabled={deleting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button className="bg-red-500 hover:bg-red-600" onClick={handleConfirmDelete} disabled={deleting}>
|
||||||
|
{deleting ? "Deleting..." : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Practice Modal */}
|
||||||
|
{showEditPracticeModal && practiceToEdit && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||||
|
<h2 className="text-lg font-semibold text-grayScale-900">Edit Practice</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEditPracticeModal(false)}
|
||||||
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 px-6 py-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Title</label>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Enter practice title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Enter practice description"
|
||||||
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Persona (Optional)</label>
|
||||||
|
<Input
|
||||||
|
value={persona}
|
||||||
|
onChange={(e) => setPersona(e.target.value)}
|
||||||
|
placeholder="Enter persona"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{saveError && <p className="text-sm text-red-500">{saveError}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
||||||
|
<Button variant="outline" onClick={() => setShowEditPracticeModal(false)} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-brand-500 hover:bg-brand-600"
|
||||||
|
onClick={handleSaveEditPractice}
|
||||||
|
disabled={saving || !title.trim()}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Video Modal */}
|
||||||
|
{showAddVideoModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||||
|
<h2 className="text-lg font-semibold text-grayScale-900">Add Video</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddVideoModal(false)}
|
||||||
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 px-6 py-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Title</label>
|
||||||
|
<Input
|
||||||
|
value={videoTitle}
|
||||||
|
onChange={(e) => setVideoTitle(e.target.value)}
|
||||||
|
placeholder="Enter video title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={videoDescription}
|
||||||
|
onChange={(e) => setVideoDescription(e.target.value)}
|
||||||
|
placeholder="Enter video description"
|
||||||
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Source URL</label>
|
||||||
|
<Input
|
||||||
|
value={videoUrl}
|
||||||
|
onChange={(e) => setVideoUrl(e.target.value)}
|
||||||
|
placeholder="https://example-storage.com/video.mp4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">File Size (bytes)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={videoFileSize || ""}
|
||||||
|
onChange={(e) => setVideoFileSize(Number(e.target.value))}
|
||||||
|
placeholder="52428800"
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Duration (seconds)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={videoDuration || ""}
|
||||||
|
onChange={(e) => setVideoDuration(Number(e.target.value))}
|
||||||
|
placeholder="300"
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{saveError && <p className="text-sm text-red-500">{saveError}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
||||||
|
<Button variant="outline" onClick={() => setShowAddVideoModal(false)} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-brand-500 hover:bg-brand-600"
|
||||||
|
onClick={handleSaveNewVideo}
|
||||||
|
disabled={saving || !videoTitle.trim() || !videoUrl.trim()}
|
||||||
|
>
|
||||||
|
{saving ? "Uploading..." : "Upload to Vimeo"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Video Modal */}
|
||||||
|
{showEditVideoModal && videoToEdit && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||||
|
<h2 className="text-lg font-semibold text-grayScale-900">Edit Video</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEditVideoModal(false)}
|
||||||
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 px-6 py-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Title</label>
|
||||||
|
<Input
|
||||||
|
value={videoTitle}
|
||||||
|
onChange={(e) => setVideoTitle(e.target.value)}
|
||||||
|
placeholder="Enter video title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={videoDescription}
|
||||||
|
onChange={(e) => setVideoDescription(e.target.value)}
|
||||||
|
placeholder="Enter video description"
|
||||||
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Video URL</label>
|
||||||
|
<Input
|
||||||
|
value={videoUrl}
|
||||||
|
onChange={(e) => setVideoUrl(e.target.value)}
|
||||||
|
placeholder="Enter video URL"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{saveError && <p className="text-sm text-red-500">{saveError}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
||||||
|
<Button variant="outline" onClick={() => setShowEditVideoModal(false)} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-brand-500 hover:bg-brand-600"
|
||||||
|
onClick={handleSaveEditVideo}
|
||||||
|
disabled={saving || !videoTitle.trim()}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Video Modal */}
|
||||||
|
{showDeleteVideoModal && videoToDelete && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||||
|
<h2 className="text-lg font-semibold text-grayScale-900">Delete Video</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteVideoModal(false)}
|
||||||
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-5">
|
||||||
|
<p className="text-sm text-grayScale-600">
|
||||||
|
Are you sure you want to delete{" "}
|
||||||
|
<span className="font-semibold">{videoToDelete.title}</span>? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
||||||
|
<Button variant="outline" onClick={() => setShowDeleteVideoModal(false)} disabled={deletingVideo}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button className="bg-red-500 hover:bg-red-600" onClick={handleConfirmDeleteVideo} disabled={deletingVideo}>
|
||||||
|
{deletingVideo ? "Deleting..." : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
537
src/pages/content-management/SubCoursesPage.tsx
Normal file
537
src/pages/content-management/SubCoursesPage.tsx
Normal file
|
|
@ -0,0 +1,537 @@
|
||||||
|
import { useEffect, useState, useRef } from "react"
|
||||||
|
import { Link, useParams, useNavigate } from "react-router-dom"
|
||||||
|
import { ArrowLeft, Layers, ToggleLeft, ToggleRight, MoreVertical, X, Trash2 } 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 [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)
|
||||||
|
|
||||||
|
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 fetchSubCourses = async () => {
|
||||||
|
if (!courseId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const subCoursesRes = await getSubCoursesByCourse(Number(courseId))
|
||||||
|
setSubCourses(subCoursesRes.data.data.sub_courses ?? [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch sub-courses:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
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 ?? [])
|
||||||
|
|
||||||
|
const foundCourse = coursesRes.data.data.courses?.find(
|
||||||
|
(c) => c.id === Number(courseId)
|
||||||
|
)
|
||||||
|
setCourse(foundCourse ?? null)
|
||||||
|
|
||||||
|
const foundCategory = categoriesRes.data.data.categories?.find(
|
||||||
|
(c) => c.id === Number(categoryId)
|
||||||
|
)
|
||||||
|
setCategory(foundCategory ?? null)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch sub-courses:", err)
|
||||||
|
setError("Failed to load sub-courses")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData()
|
||||||
|
}, [courseId, categoryId])
|
||||||
|
|
||||||
|
const handleToggleStatus = async (subCourse: SubCourse) => {
|
||||||
|
setTogglingId(subCourse.id)
|
||||||
|
try {
|
||||||
|
await updateSubCourseStatus(subCourse.id, {
|
||||||
|
is_active: !subCourse.is_active,
|
||||||
|
level: subCourse.level,
|
||||||
|
title: subCourse.title,
|
||||||
|
})
|
||||||
|
await fetchSubCourses()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update sub-course status:", err)
|
||||||
|
} finally {
|
||||||
|
setTogglingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteClick = (subCourse: SubCourse) => {
|
||||||
|
setSubCourseToDelete(subCourse)
|
||||||
|
setShowDeleteModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (!subCourseToDelete) return
|
||||||
|
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await deleteSubCourse(subCourseToDelete.id)
|
||||||
|
setShowDeleteModal(false)
|
||||||
|
setSubCourseToDelete(null)
|
||||||
|
await fetchSubCourses()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete sub-course:", err)
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddSubCourse = () => {
|
||||||
|
setTitle("")
|
||||||
|
setDescription("")
|
||||||
|
setLevel("")
|
||||||
|
setSaveError(null)
|
||||||
|
setShowAddModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveNewSubCourse = async () => {
|
||||||
|
if (!courseId) return
|
||||||
|
setSaving(true)
|
||||||
|
setSaveError(null)
|
||||||
|
try {
|
||||||
|
await createSubCourse({
|
||||||
|
course_id: Number(courseId),
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
level,
|
||||||
|
})
|
||||||
|
setShowAddModal(false)
|
||||||
|
setTitle("")
|
||||||
|
setDescription("")
|
||||||
|
setLevel("")
|
||||||
|
await fetchSubCourses()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create sub-course:", err)
|
||||||
|
setSaveError("Failed to create sub-course")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditClick = (subCourse: SubCourse) => {
|
||||||
|
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)
|
||||||
|
try {
|
||||||
|
await updateSubCourse(subCourseToEdit.id, {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
level,
|
||||||
|
})
|
||||||
|
setShowEditModal(false)
|
||||||
|
setSubCourseToEdit(null)
|
||||||
|
setTitle("")
|
||||||
|
setDescription("")
|
||||||
|
setLevel("")
|
||||||
|
await fetchSubCourses()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update sub-course:", err)
|
||||||
|
setSaveError("Failed to update sub-course")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubCourseClick = (subCourseId: number) => {
|
||||||
|
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-sm text-grayScale-500">Loading sub-courses...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-sm text-red-500">{error}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
to={`/content/category/${categoryId}/courses`}
|
||||||
|
className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 hover:bg-brand-100 hover:text-brand-600"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-grayScale-500">
|
||||||
|
<span>{category?.name}</span>
|
||||||
|
<span>→</span>
|
||||||
|
<span>{course?.title}</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-semibold text-grayScale-900">Sub-courses</h1>
|
||||||
|
<p className="text-sm text-grayScale-500">{subCourses.length} sub-courses available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleAddSubCourse}>
|
||||||
|
Add New Sub-course
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subCourses.length === 0 ? (
|
||||||
|
<Card className="shadow-none">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Layers className="mb-4 h-12 w-12 text-grayScale-300" />
|
||||||
|
<p className="text-sm text-grayScale-500">No sub-courses found for this course</p>
|
||||||
|
<Button variant="outline" className="mt-4" onClick={handleAddSubCourse}>
|
||||||
|
Add your first sub-course
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{subCourses.map((subCourse, 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={subCourse.id}
|
||||||
|
className="cursor-pointer overflow-hidden border-0 bg-white shadow-sm transition hover:shadow-md"
|
||||||
|
onClick={() => handleSubCourseClick(subCourse.id)}
|
||||||
|
>
|
||||||
|
{/* Thumbnail with level badge */}
|
||||||
|
<div className="relative aspect-video w-full">
|
||||||
|
{subCourse.thumbnail ? (
|
||||||
|
<img
|
||||||
|
src={subCourse.thumbnail}
|
||||||
|
alt={subCourse.title}
|
||||||
|
className="h-full w-full object-cover rounded-t-lg"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={`h-full w-full rounded-t-lg ${gradients[index % gradients.length]}`} />
|
||||||
|
)}
|
||||||
|
{subCourse.level && (
|
||||||
|
<div className="absolute bottom-2 right-2 rounded bg-purple-600 px-3 py-1 text-sm font-semibold text-white">
|
||||||
|
{subCourse.level}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{/* Status and menu */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge
|
||||||
|
className={`text-xs font-medium ${
|
||||||
|
subCourse.is_active
|
||||||
|
? "bg-transparent text-green-600 border border-green-200"
|
||||||
|
: "bg-grayScale-100 text-grayScale-600 border border-grayScale-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
className="relative"
|
||||||
|
ref={openMenuId === subCourse.id ? menuRef : undefined}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpenMenuId(openMenuId === subCourse.id ? null : subCourse.id)}
|
||||||
|
className="text-grayScale-400 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{openMenuId === subCourse.id && (
|
||||||
|
<div className="absolute right-0 top-full z-10 mt-1 w-40 rounded-lg bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleToggleStatus(subCourse)
|
||||||
|
setOpenMenuId(null)
|
||||||
|
}}
|
||||||
|
disabled={togglingId === subCourse.id}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-grayScale-700 hover:bg-grayScale-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{subCourse.is_active ? (
|
||||||
|
<>
|
||||||
|
<ToggleLeft className="h-4 w-4" />
|
||||||
|
Deactivate
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ToggleRight className="h-4 w-4" />
|
||||||
|
Activate
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleDeleteClick(subCourse)
|
||||||
|
setOpenMenuId(null)
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-500 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className="font-medium text-grayScale-900">{subCourse.title}</h3>
|
||||||
|
<p className="text-sm text-grayScale-500 line-clamp-2">
|
||||||
|
{subCourse.description || "No description available"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Edit button */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-grayScale-200 text-grayScale-700"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleEditClick(subCourse)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDeleteModal && subCourseToDelete && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||||
|
<h2 className="text-lg font-semibold text-grayScale-900">Delete Sub-course</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteModal(false)}
|
||||||
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-5">
|
||||||
|
<p className="text-sm text-grayScale-600">
|
||||||
|
Are you sure you want to delete{" "}
|
||||||
|
<span className="font-semibold">{subCourseToDelete.title}</span>? This action cannot
|
||||||
|
be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDeleteModal(false)}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-red-500 hover:bg-red-600"
|
||||||
|
onClick={handleConfirmDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting ? "Deleting..." : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAddModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||||
|
<h2 className="text-lg font-semibold text-grayScale-900">Add New Sub-course</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddModal(false)}
|
||||||
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 px-6 py-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Title</label>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Enter sub-course title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Enter sub-course description"
|
||||||
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Level</label>
|
||||||
|
<Input
|
||||||
|
value={level}
|
||||||
|
onChange={(e) => setLevel(e.target.value)}
|
||||||
|
placeholder="e.g., Beginner, Intermediate, Advanced"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{saveError && <p className="text-sm text-red-500">{saveError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowAddModal(false)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-brand-500 hover:bg-brand-600"
|
||||||
|
onClick={handleSaveNewSubCourse}
|
||||||
|
disabled={saving || !title.trim()}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showEditModal && subCourseToEdit && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||||
|
<h2 className="text-lg font-semibold text-grayScale-900">Edit Sub-course</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEditModal(false)}
|
||||||
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 px-6 py-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Title</label>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Enter sub-course title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Enter sub-course description"
|
||||||
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Level</label>
|
||||||
|
<Input
|
||||||
|
value={level}
|
||||||
|
onChange={(e) => setLevel(e.target.value)}
|
||||||
|
placeholder="e.g., Beginner, Intermediate, Advanced"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{saveError && <p className="text-sm text-red-500">{saveError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowEditModal(false)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-brand-500 hover:bg-brand-600"
|
||||||
|
onClick={handleSaveEditSubCourse}
|
||||||
|
disabled={saving || !title.trim()}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
374
src/pages/team/TeamManagementPage.tsx
Normal file
374
src/pages/team/TeamManagementPage.tsx
Normal file
|
|
@ -0,0 +1,374 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
ChevronDown,
|
||||||
|
SlidersHorizontal,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
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 { cn } from "../../lib/utils";
|
||||||
|
import { getTeamMembers } from "../../api/team.api";
|
||||||
|
import type { TeamMember } from "../../types/team.types";
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRelativeTime(dateStr: string): string {
|
||||||
|
const now = new Date();
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
const diffWeeks = Math.floor(diffDays / 7);
|
||||||
|
|
||||||
|
if (diffMins < 1) return "Just now";
|
||||||
|
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`;
|
||||||
|
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
|
||||||
|
if (diffWeeks < 4) return `${diffWeeks} week${diffWeeks > 1 ? "s" : ""} ago`;
|
||||||
|
return formatDate(dateStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoleBadgeClasses(role: string): string {
|
||||||
|
switch (role) {
|
||||||
|
case "super_admin":
|
||||||
|
return "bg-brand-500/15 text-brand-600 border-brand-500/25";
|
||||||
|
case "admin":
|
||||||
|
return "bg-brand-100 text-brand-600";
|
||||||
|
case "content_manager":
|
||||||
|
return "bg-mint-100 text-mint-500";
|
||||||
|
case "instructor":
|
||||||
|
return "bg-gold-100 text-gold-600";
|
||||||
|
case "support_agent":
|
||||||
|
return "bg-orange-100 text-orange-600";
|
||||||
|
case "finance":
|
||||||
|
return "bg-sky-100 text-sky-600";
|
||||||
|
case "hr":
|
||||||
|
return "bg-pink-100 text-pink-600";
|
||||||
|
case "analyst":
|
||||||
|
return "bg-violet-100 text-violet-600";
|
||||||
|
default:
|
||||||
|
return "bg-grayScale-100 text-grayScale-600";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRoleLabel(role: string): string {
|
||||||
|
return role
|
||||||
|
.split("_")
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamManagementPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [members, setMembers] = useState<TeamMember[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(5);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [roleFilter, setRoleFilter] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
|
const [toggledStatuses, setToggledStatuses] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchMembers = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getTeamMembers(page, pageSize);
|
||||||
|
const data = res.data.data;
|
||||||
|
setMembers(data);
|
||||||
|
setTotal(res.data.metadata.total);
|
||||||
|
|
||||||
|
const initialStatuses: Record<number, boolean> = {};
|
||||||
|
data.forEach((m) => {
|
||||||
|
initialStatuses[m.id] = m.status === "active";
|
||||||
|
});
|
||||||
|
setToggledStatuses((prev) => ({ ...prev, ...initialStatuses }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch team members:", error);
|
||||||
|
setMembers([]);
|
||||||
|
setTotal(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMembers();
|
||||||
|
}, [page, pageSize]);
|
||||||
|
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const safePage = Math.min(page, pageCount);
|
||||||
|
|
||||||
|
const handlePrev = () => safePage > 1 && setPage(safePage - 1);
|
||||||
|
const handleNext = () => safePage < pageCount && setPage(safePage + 1);
|
||||||
|
|
||||||
|
const getPageNumbers = () => {
|
||||||
|
const pages: (number | string)[] = [];
|
||||||
|
if (pageCount <= 7) {
|
||||||
|
for (let i = 1; i <= pageCount; i++) pages.push(i);
|
||||||
|
} else {
|
||||||
|
pages.push(1, 2, 3, 4);
|
||||||
|
if (safePage > 5) pages.push("...");
|
||||||
|
if (safePage > 4 && safePage < pageCount - 3) pages.push(safePage);
|
||||||
|
if (safePage < pageCount - 4) pages.push("...");
|
||||||
|
pages.push(pageCount);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (id: number) => {
|
||||||
|
setToggledStatuses((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-grayScale-600">Team Management</h1>
|
||||||
|
<p className="text-sm text-grayScale-400">
|
||||||
|
Manage user access, roles, and platform permissions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button className="bg-brand-600 hover:bg-brand-500 text-white">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Team Member
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border bg-white p-3">
|
||||||
|
<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
|
||||||
|
placeholder="Search by name or email address..."
|
||||||
|
className="pl-9"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={roleFilter}
|
||||||
|
onChange={(e) => setRoleFilter(e.target.value)}
|
||||||
|
className="h-10 appearance-none rounded-lg border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">Role: All</option>
|
||||||
|
<option value="super_admin">Super Admin</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="content_manager">Content Manager</option>
|
||||||
|
<option value="instructor">Instructor</option>
|
||||||
|
<option value="support_agent">Support Agent</option>
|
||||||
|
<option value="finance">Finance</option>
|
||||||
|
<option value="hr">HR</option>
|
||||||
|
<option value="analyst">Analyst</option>
|
||||||
|
</select>
|
||||||
|
<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">
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="h-10 appearance-none rounded-lg border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">Status: All</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="inactive">Inactive</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>
|
||||||
|
|
||||||
|
<Button variant="outline" className="shrink-0">
|
||||||
|
<SlidersHorizontal className="h-4 w-4" />
|
||||||
|
More Filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-white">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>USER</TableHead>
|
||||||
|
<TableHead>ROLE</TableHead>
|
||||||
|
<TableHead>LAST LOGIN</TableHead>
|
||||||
|
<TableHead>STATUS</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center text-grayScale-400">
|
||||||
|
No team members found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
members.map((member) => {
|
||||||
|
const initials = `${member.first_name?.[0] ?? ""}${member.last_name?.[0] ?? ""}`.toUpperCase();
|
||||||
|
const isActive = toggledStatuses[member.id] ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={member.id}
|
||||||
|
className="cursor-pointer hover:bg-grayScale-50"
|
||||||
|
onClick={() => navigate(`/team/${member.id}`)}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="h-10 w-10">
|
||||||
|
<AvatarImage
|
||||||
|
src={undefined}
|
||||||
|
alt={`${member.first_name} ${member.last_name}`}
|
||||||
|
/>
|
||||||
|
<AvatarFallback className="bg-grayScale-200 text-grayScale-500">
|
||||||
|
{initials}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-grayScale-600">
|
||||||
|
{member.first_name} {member.last_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-grayScale-400">{member.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium",
|
||||||
|
getRoleBadgeClasses(member.team_role)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatRoleLabel(member.team_role)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{member.last_login ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-grayScale-600">
|
||||||
|
{formatDate(member.last_login)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-grayScale-400">
|
||||||
|
{getRelativeTime(member.last_login)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-grayScale-600">Never</div>
|
||||||
|
<div className="text-xs text-grayScale-400">—</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggle(member.id)}
|
||||||
|
className={cn(
|
||||||
|
"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-sm ring-0 transition-transform",
|
||||||
|
isActive ? "translate-x-5" : "translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>Row Per Page</span>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPageSize(Number(e.target.value));
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
|
||||||
|
>
|
||||||
|
{[5, 10, 20, 30, 50].map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
<span>Entries</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={handlePrev}
|
||||||
|
disabled={safePage === 1}
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 flex items-center justify-center rounded-md border bg-white text-grayScale-500",
|
||||||
|
safePage === 1 && "opacity-50 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{getPageNumbers().map((n, idx) =>
|
||||||
|
typeof n === "string" ? (
|
||||||
|
<span key={`ellipsis-${idx}`} className="px-2 text-grayScale-400">
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage(n)}
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 rounded-md border text-sm font-medium",
|
||||||
|
n === safePage
|
||||||
|
? "border-brand-500 bg-brand-500 text-white"
|
||||||
|
: "bg-white text-grayScale-600 hover:bg-grayScale-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={safePage === pageCount}
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 flex items-center justify-center rounded-md border bg-white text-grayScale-500",
|
||||||
|
safePage === pageCount && "opacity-50 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
333
src/pages/team/TeamMemberDetailPage.tsx
Normal file
333
src/pages/team/TeamMemberDetailPage.tsx
Normal file
|
|
@ -0,0 +1,333 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Briefcase,
|
||||||
|
Building2,
|
||||||
|
Calendar,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Globe,
|
||||||
|
KeyRound,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
Shield,
|
||||||
|
User,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Badge } from "../../components/ui/badge";
|
||||||
|
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("_")
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEmploymentType(type: string): string {
|
||||||
|
return type
|
||||||
|
.split("_")
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoleBadgeClasses(role: string): string {
|
||||||
|
switch (role) {
|
||||||
|
case "super_admin":
|
||||||
|
return "bg-brand-500/15 text-brand-600 border border-brand-500/25";
|
||||||
|
case "admin":
|
||||||
|
return "bg-brand-100 text-brand-600 border border-brand-200";
|
||||||
|
case "content_manager":
|
||||||
|
return "bg-mint-100 text-mint-500 border border-mint-300/40";
|
||||||
|
case "instructor":
|
||||||
|
return "bg-gold-100 text-gold-600 border border-gold-300/40";
|
||||||
|
case "support_agent":
|
||||||
|
return "bg-orange-100 text-orange-600 border border-orange-200";
|
||||||
|
case "finance":
|
||||||
|
return "bg-sky-100 text-sky-600 border border-sky-200";
|
||||||
|
case "hr":
|
||||||
|
return "bg-pink-100 text-pink-600 border border-pink-200";
|
||||||
|
case "analyst":
|
||||||
|
return "bg-violet-100 text-violet-600 border border-violet-200";
|
||||||
|
default:
|
||||||
|
return "bg-grayScale-100 text-grayScale-600 border border-grayScale-200";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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-64" />
|
||||||
|
<div className="mt-6 grid gap-6 lg:grid-cols-3">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamMemberDetailPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const [member, setMember] = useState<TeamMember | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
const fetchMember = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getTeamMemberById(Number(id));
|
||||||
|
setMember(res.data.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch team member", err);
|
||||||
|
setError("Team member not found.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchMember();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) return <LoadingSkeleton />;
|
||||||
|
|
||||||
|
if (error || !member) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link
|
||||||
|
to="/team"
|
||||||
|
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" />
|
||||||
|
Back to Team
|
||||||
|
</Link>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center gap-4 p-10">
|
||||||
|
<div className="h-16 w-16 rounded-full bg-grayScale-100 flex items-center justify-center">
|
||||||
|
<User className="h-8 w-8 text-grayScale-300" />
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold text-grayScale-600">
|
||||||
|
{error || "Member not found"}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullName = `${member.first_name} ${member.last_name}`;
|
||||||
|
const initials = `${member.first_name?.[0] ?? ""}${member.last_name?.[0] ?? ""}`.toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link
|
||||||
|
to="/team"
|
||||||
|
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" />
|
||||||
|
Back to Team
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<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-8 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-2xl font-bold">
|
||||||
|
{initials}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<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.5 py-0.5 text-xs font-semibold",
|
||||||
|
getRoleBadgeClasses(member.team_role)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Shield className="h-3 w-3" />
|
||||||
|
{formatRoleLabel(member.team_role)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"h-1.5 w-1.5 rounded-full",
|
||||||
|
member.status === "active" ? "bg-mint-500" : "bg-destructive"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{member.status === "active" ? "Active" : "Inactive"}
|
||||||
|
</span>
|
||||||
|
<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)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,35 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { ArrowLeft, UserCircle2 } from "lucide-react";
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
BookOpen,
|
||||||
|
Calendar,
|
||||||
|
CheckCircle2,
|
||||||
|
Globe,
|
||||||
|
GraduationCap,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Phone,
|
||||||
|
PlayCircle,
|
||||||
|
RefreshCw,
|
||||||
|
Target,
|
||||||
|
UserPlus,
|
||||||
|
} from "lucide-react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Card, CardContent } from "../../components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card";
|
||||||
|
import { Separator } from "../../components/ui/separator";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { useUsersStore } from "../../zustand/userStore";
|
import { useUsersStore } from "../../zustand/userStore";
|
||||||
import { getUserById } from "../../api/users.api";
|
import { getUserById } from "../../api/users.api";
|
||||||
|
|
||||||
|
const activityIcons: Record<string, typeof CheckCircle2> = {
|
||||||
|
completed: CheckCircle2,
|
||||||
|
started: PlayCircle,
|
||||||
|
joined: UserPlus,
|
||||||
|
};
|
||||||
|
|
||||||
export function UserDetailPage() {
|
export function UserDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const userProfile = useUsersStore((s) => s.userProfile);
|
const userProfile = useUsersStore((s) => s.userProfile);
|
||||||
|
|
@ -29,13 +51,16 @@ export function UserDetailPage() {
|
||||||
|
|
||||||
if (!userProfile) {
|
if (!userProfile) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-3xl space-y-4">
|
<div className="mx-auto w-full max-w-3xl space-y-4 py-12">
|
||||||
<div className="text-sm font-semibold text-grayScale-500">User Detail</div>
|
<Card className="shadow-soft">
|
||||||
<Card className="overflow-hidden shadow-sm">
|
<CardContent className="flex flex-col items-center gap-4 p-10">
|
||||||
<div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" />
|
<div className="h-16 w-16 rounded-full bg-grayScale-100 flex items-center justify-center">
|
||||||
<CardContent className="p-6 space-y-4">
|
<Target className="h-8 w-8 text-grayScale-300" />
|
||||||
<div className="text-lg font-semibold text-grayScale-900">User not found</div>
|
</div>
|
||||||
<Button asChild variant="outline" className="w-full">
|
<div className="text-lg font-semibold text-grayScale-600">
|
||||||
|
User not found
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline" className="mt-2">
|
||||||
<Link to="/users/list">Back to Users</Link>
|
<Link to="/users/list">Back to Users</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -46,166 +71,261 @@ export function UserDetailPage() {
|
||||||
|
|
||||||
const user = userProfile;
|
const user = userProfile;
|
||||||
const fullName = `${user.first_name} ${user.last_name}`;
|
const fullName = `${user.first_name} ${user.last_name}`;
|
||||||
|
const initials = `${user.first_name?.[0] ?? ""}${user.last_name?.[0] ?? ""}`.toUpperCase();
|
||||||
|
|
||||||
|
const recentActivities = [
|
||||||
|
{ type: "completed", text: "Completed Unit 4: Business Emails", time: "Today, 10:27 AM" },
|
||||||
|
{ type: "completed", text: "Completed Unit 3: Formal Writing", time: "Yesterday, 3:45 PM" },
|
||||||
|
{ type: "started", text: "Started Learning Path: Business English", time: "Jan 15, 2025" },
|
||||||
|
{ type: "joined", text: "Joined Yimaru", time: "Jan 10, 2025" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const infoFields = [
|
||||||
|
{ icon: Phone, label: "Phone", value: user.phone_number },
|
||||||
|
{ icon: Mail, label: "Email", value: user.email },
|
||||||
|
{ icon: Globe, label: "Country", value: user.country || "Ethiopia" },
|
||||||
|
{ icon: MapPin, label: "Region", value: user.region },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Back Link */}
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link
|
<Link
|
||||||
to="/users"
|
to="/users"
|
||||||
className="inline-flex items-center gap-2 text-sm font-semibold text-grayScale-500 hover:text-brand-600"
|
className="inline-flex items-center gap-2 text-sm font-medium text-grayScale-500 transition-colors hover:text-brand-600"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Back to Users
|
Back to Users
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xl font-semibold text-grayScale-900">User Detail</div>
|
<div className="grid gap-6 lg:grid-cols-[340px_1fr]">
|
||||||
|
{/* ── Left column ── */}
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
<div className="space-y-6">
|
||||||
{/* Left Column */}
|
{/* Profile card */}
|
||||||
<div className="space-y-4 lg:col-span-1">
|
<Card className="overflow-hidden">
|
||||||
{/* Basic Information */}
|
<div className="h-24 bg-gradient-to-br from-brand-600 via-brand-500 to-brand-400" />
|
||||||
<Card className="overflow-hidden shadow-sm">
|
<CardContent className="-mt-12 space-y-5 px-6 pb-6 pt-0">
|
||||||
<div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" />
|
<div className="flex flex-col items-center text-center">
|
||||||
<CardContent className="p-6 space-y-4">
|
<Avatar className="h-20 w-20 ring-4 ring-white shadow-soft">
|
||||||
<div className="flex items-center gap-3">
|
<AvatarImage src={user.profile_picture_url ?? undefined} alt={fullName} />
|
||||||
<div className="h-12 w-12 rounded-full bg-grayScale-200 flex items-center justify-center overflow-hidden">
|
<AvatarFallback className="bg-brand-100 text-brand-600 text-xl">
|
||||||
{user.profile_picture_url ? (
|
{initials}
|
||||||
<img
|
</AvatarFallback>
|
||||||
src={user.profile_picture_url}
|
</Avatar>
|
||||||
alt={fullName}
|
<h2 className="mt-3 text-lg font-semibold text-grayScale-600">
|
||||||
className="h-12 w-12 object-cover"
|
{fullName}
|
||||||
/>
|
</h2>
|
||||||
) : (
|
|
||||||
<UserCircle2 className="h-12 w-12 text-grayScale-400" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-lg font-semibold text-grayScale-900">{fullName}</div>
|
|
||||||
<div className="mt-1 flex items-center gap-2 text-xs text-grayScale-400">
|
|
||||||
<span>ID: {user.id}</span>
|
|
||||||
<span className="h-1 w-1 rounded-full bg-grayScale-300" />
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"h-2 w-2 rounded-full",
|
|
||||||
user.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{user.status === "ACTIVE" ? "Active" : "Inactive"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 text-sm text-grayScale-600">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-semibold text-grayScale-400">Phone</div>
|
|
||||||
<div className="font-semibold">{user.phone_number || "-"}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-semibold text-grayScale-400">Email</div>
|
|
||||||
<div className="font-semibold">{user.email || "-"}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-semibold text-grayScale-400">Region</div>
|
|
||||||
<div className="font-semibold">{user.region || "-"}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-semibold text-grayScale-400">Joined Date</div>
|
|
||||||
<div className="font-semibold">
|
|
||||||
{user.created_at ? new Date(user.created_at).toLocaleDateString() : "-"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Subscription */}
|
|
||||||
<Card className="overflow-hidden shadow-sm">
|
|
||||||
<div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" />
|
|
||||||
<CardContent className="p-6 space-y-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div className="text-lg font-semibold text-grayScale-900">Subscription</div>
|
|
||||||
<Badge
|
<Badge
|
||||||
className={cn(
|
className={cn(
|
||||||
user.status === "ACTIVE" ? "bg-mint-500 text-white" : "bg-destructive text-white"
|
"mt-1.5",
|
||||||
|
user.status === "ACTIVE"
|
||||||
|
? "bg-mint-500/15 text-mint-500 border border-mint-500/25"
|
||||||
|
: "bg-destructive/15 text-destructive border border-destructive/25"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{user.status === "ACTIVE" ? "Active" : "Inactive"}
|
{user.status === "ACTIVE" ? "Active" : "Inactive"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-sm text-grayScale-600">
|
|
||||||
<div>
|
<Separator />
|
||||||
<div className="text-xs font-semibold text-grayScale-400">Profile Completed</div>
|
|
||||||
<div className="font-semibold">{user.profile_completed ? "Yes" : "No"}</div>
|
<div className="space-y-3">
|
||||||
|
{infoFields.map(({ icon: Icon, label, value }) => (
|
||||||
|
<div key={label} className="flex items-center gap-3">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-grayScale-100">
|
||||||
|
<Icon className="h-4 w-4 text-grayScale-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="text-xs font-semibold text-grayScale-400">Preferred Language</div>
|
<div className="text-[11px] font-medium uppercase tracking-wider text-grayScale-400">
|
||||||
<div className="font-semibold">{user.preferred_language || "-"}</div>
|
{label}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="truncate text-sm text-grayScale-600">
|
||||||
|
{value || "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-grayScale-100">
|
||||||
|
<Calendar className="h-4 w-4 text-grayScale-400" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[11px] font-medium uppercase tracking-wider text-grayScale-400">
|
||||||
|
Joined
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-grayScale-600">
|
||||||
|
{user.created_at
|
||||||
|
? new Date(user.created_at).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
: "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Subscription card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>Subscription</CardTitle>
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
user.status === "ACTIVE"
|
||||||
|
? "bg-mint-500/15 text-mint-500 border border-mint-500/25"
|
||||||
|
: "bg-destructive/15 text-destructive border border-destructive/25"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{user.status === "ACTIVE" ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="rounded-xl bg-grayScale-100 p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-grayScale-400">
|
||||||
|
Plan
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-grayScale-600">6-Month</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-grayScale-400">
|
||||||
|
Expires
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-grayScale-600">Nov 13, 2025</span>
|
||||||
|
<Badge className="bg-gold-100 text-gold-600 text-[10px] border border-gold-300">
|
||||||
|
3 days left
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button className="w-full bg-brand-600 hover:bg-brand-500 text-white transition-colors">
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Extend Subscription
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Button variant="outline" className="w-full text-sm">
|
||||||
|
Mark as Paid
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full text-sm text-destructive border-destructive/40 hover:bg-destructive/5"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column */}
|
{/* ── Right column ── */}
|
||||||
<div className="space-y-4 lg:col-span-2">
|
<div className="space-y-6">
|
||||||
{/* Learning Profile */}
|
{/* Learning profile */}
|
||||||
<Card className="overflow-hidden shadow-sm">
|
<Card>
|
||||||
<div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" />
|
<CardHeader className="pb-3">
|
||||||
<CardContent className="p-6 space-y-4">
|
<div className="flex items-center gap-2">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-100/50">
|
||||||
<div>
|
<GraduationCap className="h-4 w-4 text-brand-600" />
|
||||||
<div className="text-xs font-semibold text-grayScale-400">Education Level</div>
|
|
||||||
<div className="text-sm font-semibold text-grayScale-600">{user.education_level || "-"}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<CardTitle>Learning Profile</CardTitle>
|
||||||
<div className="text-xs font-semibold text-grayScale-400">Age</div>
|
|
||||||
<div className="text-sm font-semibold text-grayScale-600">{user.age || "-"}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3">
|
||||||
|
<InfoItem
|
||||||
|
label="Education Level"
|
||||||
|
value={user.education_level || "Undergraduate"}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
label="Age Group"
|
||||||
|
value={user.age ? `${user.age} years` : "25-34"}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-medium uppercase tracking-wider text-grayScale-400 mb-1">
|
||||||
|
Proficiency
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-grayScale-600">Intermediate</span>
|
||||||
|
<span className="inline-flex h-5 items-center rounded-md bg-brand-100/60 px-1.5 text-[11px] font-semibold text-brand-600">
|
||||||
|
B1
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<InfoItem
|
||||||
|
label="Preferred Topic"
|
||||||
|
value={user.favoutite_topic || "Business"}
|
||||||
|
/>
|
||||||
|
<TagItem label="Learning Path" value={user.learning_goal || "Business English"} />
|
||||||
|
<TagItem label="Challenge" value={user.language_challange || "Speaking"} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<Separator />
|
||||||
<div>
|
|
||||||
<div className="text-xs font-semibold text-grayScale-400">Nick Name</div>
|
|
||||||
<div className="text-sm font-semibold text-grayScale-600">{user.nick_name || "-"}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-semibold text-grayScale-400">Occupation</div>
|
|
||||||
<div className="text-sm font-semibold text-grayScale-600">{user.occupation || "-"}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold text-grayScale-400">Learning Goal</div>
|
<div className="text-[11px] font-medium uppercase tracking-wider text-grayScale-400 mb-2">
|
||||||
<div className="text-sm font-semibold text-grayScale-600">{user.learning_goal || "-"}</div>
|
Primary Goal
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-xl bg-grayScale-100 p-4 text-sm leading-relaxed text-grayScale-600">
|
||||||
<div>
|
{user.learning_goal ||
|
||||||
<div className="text-xs font-semibold text-grayScale-400">Language Challenge</div>
|
"Improve business communication skills for professional advancement"}
|
||||||
<div className="text-sm font-semibold text-grayScale-600">{user.language_challange || "-"}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-semibold text-grayScale-400">Favourite Topic</div>
|
|
||||||
<div className="text-sm font-semibold text-grayScale-600">{user.favoutite_topic || "-"}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Status / Dates */}
|
{/* Recent activity */}
|
||||||
<Card className="overflow-hidden shadow-sm">
|
<Card>
|
||||||
<div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" />
|
<CardHeader className="pb-3">
|
||||||
<CardContent className="p-6 space-y-3 text-sm text-grayScale-600">
|
<div className="flex items-center gap-2">
|
||||||
<div>
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gold-100/60">
|
||||||
<div className="text-xs font-semibold text-grayScale-400">Last Login</div>
|
<BookOpen className="h-4 w-4 text-gold-600" />
|
||||||
<div className="font-semibold">{user.last_login ? new Date(user.last_login).toLocaleString() : "-"}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<CardTitle>Recent Activity</CardTitle>
|
||||||
<div className="text-xs font-semibold text-grayScale-400">Updated At</div>
|
</div>
|
||||||
<div className="font-semibold">{user.updated_at ? new Date(user.updated_at).toLocaleString() : "-"}</div>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="relative space-y-0">
|
||||||
|
{recentActivities.map((activity, index) => {
|
||||||
|
const Icon = activityIcons[activity.type] ?? CheckCircle2;
|
||||||
|
const isLast = index === recentActivities.length - 1;
|
||||||
|
return (
|
||||||
|
<div key={index} className="relative flex gap-4 pb-5 last:pb-0">
|
||||||
|
{!isLast && (
|
||||||
|
<div className="absolute left-[15px] top-8 bottom-0 w-px bg-grayScale-200" />
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||||
|
activity.type === "completed"
|
||||||
|
? "bg-mint-100 text-mint-500"
|
||||||
|
: activity.type === "started"
|
||||||
|
? "bg-brand-100/50 text-brand-500"
|
||||||
|
: "bg-grayScale-100 text-grayScale-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 pt-1">
|
||||||
|
<div className="text-sm font-medium text-grayScale-600">
|
||||||
|
{activity.text}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xs text-grayScale-400">{activity.time}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -214,3 +334,27 @@ export function UserDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-medium uppercase tracking-wider text-grayScale-400 mb-1">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-grayScale-600">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagItem({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-medium uppercase tracking-wider text-grayScale-400 mb-1">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<span className="inline-block rounded-lg border border-grayScale-200 bg-grayScale-100 px-2.5 py-1 text-xs font-medium text-grayScale-600">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,16 @@
|
||||||
import { Eye, Search } from "lucide-react"
|
import { ChevronDown, ChevronLeft, ChevronRight, Search } from "lucide-react"
|
||||||
import { useEffect } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Link } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import { Badge } from "../../components/ui/badge"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { getUsers } from "../../api/users.api"
|
import { getUsers } from "../../api/users.api"
|
||||||
import { mapUserApiToUser } from "../../types/user.types"
|
import { mapUserApiToUser } from "../../types/user.types"
|
||||||
import { useUsersStore } from "../../zustand/userStore"
|
import { useUsersStore } from "../../zustand/userStore"
|
||||||
|
|
||||||
type SubscriptionType = "Monthly" | "Free" | "Expired" | "3-Month" | "6-Month" | "N/A"
|
|
||||||
|
|
||||||
function subscriptionVariant(sub: SubscriptionType) {
|
|
||||||
switch (sub) {
|
|
||||||
case "Monthly":
|
|
||||||
return "warning"
|
|
||||||
case "Free":
|
|
||||||
return "secondary"
|
|
||||||
case "Expired":
|
|
||||||
return "destructive"
|
|
||||||
case "3-Month":
|
|
||||||
return "success"
|
|
||||||
case "6-Month":
|
|
||||||
return "info"
|
|
||||||
default:
|
|
||||||
return "default"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UsersListPage() {
|
export function UsersListPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const {
|
const {
|
||||||
users,
|
users,
|
||||||
total,
|
total,
|
||||||
|
|
@ -43,14 +24,27 @@ export function UsersListPage() {
|
||||||
setSearch,
|
setSearch,
|
||||||
} = useUsersStore()
|
} = useUsersStore()
|
||||||
|
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||||
|
const [toggledStatuses, setToggledStatuses] = useState<Record<number, boolean>>({})
|
||||||
|
const [countryFilter, setCountryFilter] = useState("")
|
||||||
|
const [regionFilter, setRegionFilter] = useState("")
|
||||||
|
const [subscriptionFilter, setSubscriptionFilter] = useState("")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getUsers(page, pageSize)
|
const res = await getUsers(page, pageSize)
|
||||||
const apiUsers = res.data.data.users
|
const apiUsers = res.data.data.users
|
||||||
|
|
||||||
setUsers(apiUsers.map(mapUserApiToUser))
|
const mapped = apiUsers.map(mapUserApiToUser)
|
||||||
|
setUsers(mapped)
|
||||||
setTotal(res.data.data.total)
|
setTotal(res.data.data.total)
|
||||||
|
|
||||||
|
const initialStatuses: Record<number, boolean> = {}
|
||||||
|
mapped.forEach((u) => {
|
||||||
|
initialStatuses[u.id] = true
|
||||||
|
})
|
||||||
|
setToggledStatuses((prev) => ({ ...prev, ...initialStatuses }))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch users:", error)
|
console.error("Failed to fetch users:", error)
|
||||||
setUsers([])
|
setUsers([])
|
||||||
|
|
@ -63,104 +57,226 @@ export function UsersListPage() {
|
||||||
|
|
||||||
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||||
const safePage = Math.min(page, pageCount)
|
const safePage = Math.min(page, pageCount)
|
||||||
const pageNumbers = Array.from({ length: pageCount }, (_, i) => i + 1)
|
|
||||||
|
|
||||||
const handlePrev = () => safePage > 1 && setPage(safePage - 1)
|
const handlePrev = () => safePage > 1 && setPage(safePage - 1)
|
||||||
const handleNext = () => safePage < pageCount && setPage(safePage + 1)
|
const handleNext = () => safePage < pageCount && setPage(safePage + 1)
|
||||||
|
|
||||||
return (
|
const handleSelectAll = (checked: boolean) => {
|
||||||
<Card className="shadow-none">
|
if (checked) {
|
||||||
<CardHeader className="pb-3">
|
setSelectedIds(new Set(users.map((u) => u.id)))
|
||||||
<CardTitle>User Management</CardTitle>
|
} else {
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
const handleSelectOne = (id: number, checked: boolean) => {
|
||||||
|
const newSet = new Set(selectedIds)
|
||||||
|
if (checked) {
|
||||||
|
newSet.add(id)
|
||||||
|
} else {
|
||||||
|
newSet.delete(id)
|
||||||
|
}
|
||||||
|
setSelectedIds(newSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSelected = users.length > 0 && selectedIds.size === users.length
|
||||||
|
|
||||||
|
const getPageNumbers = () => {
|
||||||
|
const pages: (number | string)[] = []
|
||||||
|
if (pageCount <= 7) {
|
||||||
|
for (let i = 1; i <= pageCount; i++) pages.push(i)
|
||||||
|
} else {
|
||||||
|
pages.push(1, 2, 3, 4)
|
||||||
|
if (safePage > 5) {
|
||||||
|
pages.push("...")
|
||||||
|
}
|
||||||
|
if (safePage > 4 && safePage < pageCount - 3) {
|
||||||
|
pages.push(safePage)
|
||||||
|
}
|
||||||
|
if (safePage < pageCount - 4) {
|
||||||
|
pages.push("...")
|
||||||
|
}
|
||||||
|
pages.push(pageCount)
|
||||||
|
}
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggle = (id: number) => {
|
||||||
|
setToggledStatuses((prev) => ({ ...prev, [id]: !prev[id] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRowClick = (userId: number) => {
|
||||||
|
navigate(`/users/${userId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg border">
|
||||||
|
<div className="p-4 border-b">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="relative w-full md:max-w-sm">
|
<div className="relative w-full md:max-w-sm">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search by name or phone number"
|
placeholder="Search by name, phone number"
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="p-0">
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={countryFilter}
|
||||||
|
onChange={(e) => setCountryFilter(e.target.value)}
|
||||||
|
className="h-9 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="">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">
|
||||||
|
<select
|
||||||
|
value={regionFilter}
|
||||||
|
onChange={(e) => setRegionFilter(e.target.value)}
|
||||||
|
className="h-9 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="">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">
|
||||||
|
<select
|
||||||
|
value={subscriptionFilter}
|
||||||
|
onChange={(e) => setSubscriptionFilter(e.target.value)}
|
||||||
|
className="h-9 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-8">#</TableHead>
|
<TableHead className="w-12">
|
||||||
<TableHead>First Name</TableHead>
|
<input
|
||||||
<TableHead>Last Name</TableHead>
|
type="checkbox"
|
||||||
<TableHead>Nick Name</TableHead>
|
checked={allSelected}
|
||||||
<TableHead>Email</TableHead>
|
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||||
<TableHead>Phone Number</TableHead>
|
className="h-4 w-4 rounded border-grayScale-300 text-brand-600 focus:ring-brand-500"
|
||||||
<TableHead>Region</TableHead>
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>USER</TableHead>
|
||||||
|
<TableHead>Phone</TableHead>
|
||||||
<TableHead>Country</TableHead>
|
<TableHead>Country</TableHead>
|
||||||
<TableHead>Last Active</TableHead>
|
<TableHead>Region</TableHead>
|
||||||
<TableHead>Subscription</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead className="w-[56px]" />
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{users.length === 0 ? (
|
{users.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={11} className="text-center text-grayScale-400">
|
<TableCell colSpan={6} className="text-center text-grayScale-400">
|
||||||
No users found
|
No users found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
users.map((u, index) => (
|
users.map((u) => {
|
||||||
<TableRow key={u.id}>
|
const isActive = toggledStatuses[u.id] ?? false
|
||||||
<TableCell className="text-grayScale-500">{(page - 1) * pageSize + index + 1}</TableCell>
|
return (
|
||||||
<TableCell className="text-grayScale-600">{u.firstName}</TableCell>
|
<TableRow
|
||||||
<TableCell className="text-grayScale-600">{u.lastName}</TableCell>
|
key={u.id}
|
||||||
<TableCell className="text-grayScale-600">{u.nickName}</TableCell>
|
className="cursor-pointer hover:bg-grayScale-50"
|
||||||
<TableCell className="text-grayScale-500">{u.email}</TableCell>
|
onClick={() => handleRowClick(u.id)}
|
||||||
<TableCell className="text-grayScale-500">{u.phoneNumber || "-"}</TableCell>
|
>
|
||||||
<TableCell className="text-grayScale-500">{u.region}</TableCell>
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
<TableCell className="text-grayScale-500">{u.country}</TableCell>
|
<input
|
||||||
<TableCell className="text-grayScale-500">
|
type="checkbox"
|
||||||
{u.lastLogin ? new Date(u.lastLogin).toLocaleString() : "-"}
|
checked={selectedIds.has(u.id)}
|
||||||
|
onChange={(e) => handleSelectOne(u.id, e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-grayScale-300 text-brand-600 focus:ring-brand-500"
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={subscriptionVariant("N/A")}>N/A</Badge>
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="h-10 w-10">
|
||||||
|
<AvatarImage src={undefined} alt={`${u.firstName} ${u.lastName}`} />
|
||||||
|
<AvatarFallback className="bg-grayScale-200 text-grayScale-500">
|
||||||
|
{`${u.firstName?.[0] ?? ""}${u.lastName?.[0] ?? ""}`.toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-grayScale-600">{u.firstName} {u.lastName}</div>
|
||||||
|
<div className="text-xs text-grayScale-400">{u.email || u.phoneNumber || "-"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-grayScale-500">{u.phoneNumber || "-"}</TableCell>
|
||||||
<Link
|
<TableCell className="text-grayScale-500">{u.country || "-"}</TableCell>
|
||||||
to={`/users/${u.id}`}
|
<TableCell className="text-grayScale-500">{u.region || "-"}</TableCell>
|
||||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border bg-white text-grayScale-500 hover:text-brand-600"
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggle(u.id)}
|
||||||
|
className={cn(
|
||||||
|
"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"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<span
|
||||||
</Link>
|
className={cn(
|
||||||
|
"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"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
)
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{/* Pagination */}
|
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-xs text-grayScale-500">
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
Rows per page
|
<span>Row Per Page</span>
|
||||||
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setPageSize(Number(e.target.value))
|
setPageSize(Number(e.target.value))
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}}
|
}}
|
||||||
className="h-8 rounded-md border bg-white px-2 text-xs font-semibold text-grayScale-600 focus:outline-none"
|
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
|
||||||
>
|
>
|
||||||
{[1, 2, 3, 4, 5, 10, 20, 30].map((size) => (
|
{[5, 10, 20, 30, 50].map((size) => (
|
||||||
<option key={size} value={size}>
|
<option key={size} value={size}>
|
||||||
{size}
|
{size}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
of {total}
|
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
<span>Entries</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|
@ -168,40 +284,47 @@ export function UsersListPage() {
|
||||||
onClick={handlePrev}
|
onClick={handlePrev}
|
||||||
disabled={safePage === 1}
|
disabled={safePage === 1}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-12 rounded-md border bg-white text-xs font-semibold text-grayScale-500",
|
"h-8 w-8 flex items-center justify-center rounded-md border bg-white text-grayScale-500",
|
||||||
safePage === 1 && "opacity-50 cursor-not-allowed"
|
safePage === 1 && "opacity-50 cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Prev
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{pageNumbers.map((n) => (
|
{getPageNumbers().map((n, idx) =>
|
||||||
|
typeof n === "string" ? (
|
||||||
|
<span key={`ellipsis-${idx}`} className="px-2 text-grayScale-400">
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
key={n}
|
key={n}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPage(n)}
|
onClick={() => setPage(n)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-8 rounded-md border bg-white text-xs font-semibold text-grayScale-500",
|
"h-8 w-8 rounded-md border text-sm font-medium",
|
||||||
n === safePage && "border-brand-200 bg-brand-100/40 text-brand-600"
|
n === safePage
|
||||||
|
? "border-brand-500 bg-brand-500 text-white"
|
||||||
|
: "bg-white text-grayScale-600 hover:bg-grayScale-50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{n}
|
{n}
|
||||||
</button>
|
</button>
|
||||||
))}
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
disabled={safePage === pageCount}
|
disabled={safePage === pageCount}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-12 rounded-md border bg-white text-xs font-semibold text-grayScale-500",
|
"h-8 w-8 flex items-center justify-center rounded-md border bg-white text-grayScale-500",
|
||||||
safePage === pageCount && "opacity-50 cursor-not-allowed"
|
safePage === pageCount && "opacity-50 cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Next
|
<ChevronRight className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,19 @@
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
email: string;
|
email: string
|
||||||
password: string;
|
password: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponseData {
|
export interface LoginResponseData {
|
||||||
access_token: string;
|
access_token: string
|
||||||
refresh_token: string;
|
refresh_token: string
|
||||||
role: string;
|
member_id: number
|
||||||
user_id: number;
|
team_role: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
message: string;
|
message: string
|
||||||
data: LoginResponseData;
|
data: LoginResponseData
|
||||||
success: boolean;
|
success: boolean
|
||||||
status_code: number;
|
status_code: number
|
||||||
metadata: any | null;
|
metadata: any | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
435
src/types/course.types.ts
Normal file
435
src/types/course.types.ts
Normal file
|
|
@ -0,0 +1,435 @@
|
||||||
|
export interface CourseCategory {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetCourseCategoriesResponse {
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
categories: CourseCategory[]
|
||||||
|
total_count: number
|
||||||
|
}
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Course {
|
||||||
|
id: number
|
||||||
|
category_id: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
thumbnail: string
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetCoursesResponse {
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
courses: Course[]
|
||||||
|
total_count: number
|
||||||
|
}
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCourseRequest {
|
||||||
|
category_id: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCourseRequest {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
thumbnail?: string
|
||||||
|
is_active?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Legacy Types (deprecated - using SubCourse hierarchy now)
|
||||||
|
// Keeping for backward compatibility with existing API endpoints
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** @deprecated Use SubCourse instead */
|
||||||
|
export interface Program {
|
||||||
|
id: number
|
||||||
|
course_id: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
thumbnail: string
|
||||||
|
display_order: number
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use GetSubCoursesResponse instead */
|
||||||
|
export interface GetProgramsResponse {
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
programs: Program[]
|
||||||
|
total_count: number
|
||||||
|
}
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use UpdateSubCourseStatusRequest instead */
|
||||||
|
export interface UpdateProgramStatusRequest {
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use CreateSubCourseRequest instead */
|
||||||
|
export interface CreateProgramRequest {
|
||||||
|
course_id: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use UpdateSubCourseRequest instead */
|
||||||
|
export interface UpdateProgramRequest {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use SubCourse instead */
|
||||||
|
export interface Level {
|
||||||
|
id: number
|
||||||
|
program_id: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
level_index: number
|
||||||
|
number_of_modules: number
|
||||||
|
number_of_practices: number
|
||||||
|
number_of_videos: number
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use GetSubCoursesResponse instead */
|
||||||
|
export interface GetLevelsResponse {
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
levels: Level[]
|
||||||
|
total_count: number
|
||||||
|
}
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use CreateSubCourseRequest instead */
|
||||||
|
export interface CreateLevelRequest {
|
||||||
|
program_id: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use UpdateSubCourseRequest instead */
|
||||||
|
export interface UpdateLevelRequest {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use UpdateSubCourseStatusRequest instead */
|
||||||
|
export interface UpdateLevelStatusRequest {
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use SubCourse hierarchy instead */
|
||||||
|
export interface Module {
|
||||||
|
id: number
|
||||||
|
level_id: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
display_order: number
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use GetSubCoursesResponse instead */
|
||||||
|
export interface GetModulesResponse {
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
modules: Module[]
|
||||||
|
total_count: number
|
||||||
|
}
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use CreateSubCourseRequest instead */
|
||||||
|
export interface CreateModuleRequest {
|
||||||
|
level_id: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use UpdateSubCourseRequest instead */
|
||||||
|
export interface UpdateModuleRequest {
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use UpdateSubCourseStatusRequest instead */
|
||||||
|
export interface UpdateModuleStatusRequest {
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// New Hierarchy: SubCourse (replaces Program/Level/Module)
|
||||||
|
// ============================================
|
||||||
|
export interface SubCourse {
|
||||||
|
id: number
|
||||||
|
course_id: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
level: string
|
||||||
|
thumbnail: string
|
||||||
|
display_order: number
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetSubCoursesResponse {
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
sub_courses: SubCourse[]
|
||||||
|
total_count: number
|
||||||
|
}
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSubCourseRequest {
|
||||||
|
course_id: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
level: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSubCourseRequest {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
level: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSubCourseStatusRequest {
|
||||||
|
is_active: boolean
|
||||||
|
level: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubCourse Video
|
||||||
|
export interface SubCourseVideo {
|
||||||
|
id: number
|
||||||
|
sub_course_id: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
video_url: string
|
||||||
|
duration: number
|
||||||
|
thumbnail: string
|
||||||
|
is_published: boolean
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetSubCourseVideosResponse {
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
videos: SubCourseVideo[]
|
||||||
|
total_count: number
|
||||||
|
}
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSubCourseVideoRequest {
|
||||||
|
sub_course_id: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
video_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateVimeoVideoRequest {
|
||||||
|
sub_course_id: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
source_url: string
|
||||||
|
file_size: number
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSubCourseVideoRequest {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
video_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Practice now belongs to SubCourse
|
||||||
|
export interface Practice {
|
||||||
|
id: number
|
||||||
|
sub_course_id: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
banner_image: string
|
||||||
|
persona: string
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetPracticesResponse {
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
practices: Practice[]
|
||||||
|
total_count: number
|
||||||
|
}
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePracticeRequest {
|
||||||
|
sub_course_id: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
persona?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePracticeRequest {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
persona?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePracticeStatusRequest {
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PracticeQuestion {
|
||||||
|
id: number
|
||||||
|
practice_id: number
|
||||||
|
question: string
|
||||||
|
question_voice_prompt: string
|
||||||
|
sample_answer_voice_prompt: string
|
||||||
|
sample_answer: string
|
||||||
|
tips: string
|
||||||
|
type: "MCQ" | "TRUE_FALSE" | "SHORT"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetPracticeQuestionsResponse {
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
questions: PracticeQuestion[]
|
||||||
|
total_count: number
|
||||||
|
}
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePracticeQuestionRequest {
|
||||||
|
practice_id: number
|
||||||
|
question: string
|
||||||
|
question_voice_prompt?: string
|
||||||
|
sample_answer_voice_prompt?: string
|
||||||
|
sample_answer: string
|
||||||
|
tips?: string
|
||||||
|
type: "MCQ" | "TRUE_FALSE" | "SHORT"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePracticeQuestionRequest {
|
||||||
|
question: string
|
||||||
|
question_voice_prompt?: string
|
||||||
|
sample_answer_voice_prompt?: string
|
||||||
|
sample_answer: string
|
||||||
|
tips?: string
|
||||||
|
type: "MCQ" | "TRUE_FALSE" | "SHORT"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Question Sets (Practice sets fetched via /question-sets)
|
||||||
|
export type QuestionSetType = "PRACTICE" | "EXAM"
|
||||||
|
export type QuestionSetStatus = "PUBLISHED" | "DRAFT" | "ARCHIVED"
|
||||||
|
export type QuestionSetOwnerType = "SUB_COURSE" | "COURSE"
|
||||||
|
|
||||||
|
export interface QuestionSet {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
set_type: QuestionSetType
|
||||||
|
owner_type: QuestionSetOwnerType
|
||||||
|
owner_id: number
|
||||||
|
persona: string
|
||||||
|
shuffle_questions: boolean
|
||||||
|
status: QuestionSetStatus
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetQuestionSetsResponse {
|
||||||
|
message: string
|
||||||
|
data: QuestionSet[]
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateQuestionSetRequest {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
set_type: string
|
||||||
|
owner_type: string
|
||||||
|
owner_id: number
|
||||||
|
persona?: string
|
||||||
|
shuffle_questions?: boolean
|
||||||
|
status?: string
|
||||||
|
banner_image?: string
|
||||||
|
passing_score?: number
|
||||||
|
time_limit_minutes?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddQuestionToSetRequest {
|
||||||
|
display_order: number
|
||||||
|
question_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestionOption {
|
||||||
|
option_order: number
|
||||||
|
option_text: string
|
||||||
|
is_correct: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateQuestionRequest {
|
||||||
|
question_text: string
|
||||||
|
question_type: string
|
||||||
|
difficulty_level: string
|
||||||
|
points: number
|
||||||
|
tips?: string
|
||||||
|
explanation?: string
|
||||||
|
status?: string
|
||||||
|
options?: QuestionOption[]
|
||||||
|
voice_prompt?: string
|
||||||
|
sample_answer_voice_prompt?: string
|
||||||
|
short_answers?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateQuestionResponse {
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateQuestionSetResponse {
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown
|
||||||
|
}
|
||||||
41
src/types/team.types.ts
Normal file
41
src/types/team.types.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
export interface TeamMember {
|
||||||
|
id: number
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
email: string
|
||||||
|
phone_number: string
|
||||||
|
team_role: string
|
||||||
|
department: string
|
||||||
|
job_title: string
|
||||||
|
employment_type: string
|
||||||
|
hire_date: string
|
||||||
|
bio: string
|
||||||
|
status: string
|
||||||
|
email_verified: boolean
|
||||||
|
permissions: string[]
|
||||||
|
last_login?: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamMembersMetadata {
|
||||||
|
total: number
|
||||||
|
total_pages: number
|
||||||
|
current_page: number
|
||||||
|
limit: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetTeamMembersResponse {
|
||||||
|
message: string
|
||||||
|
data: TeamMember[]
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: TeamMembersMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetTeamMemberResponse {
|
||||||
|
message: string
|
||||||
|
data: TeamMember
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: null
|
||||||
|
}
|
||||||
|
|
@ -1,40 +1,36 @@
|
||||||
// This matches the API response 1:1
|
// This matches the API response 1:1
|
||||||
export interface UserApiDTO {
|
export interface UserApiDTO {
|
||||||
ID: number
|
id: number
|
||||||
FirstName: string
|
first_name: string
|
||||||
LastName: string
|
last_name: string
|
||||||
Gender: string
|
gender: string
|
||||||
birth_day: string | null
|
birth_day: string | null
|
||||||
|
|
||||||
Email: string
|
email: string
|
||||||
PhoneNumber: string
|
phone_number?: string
|
||||||
Role: string
|
role: string
|
||||||
|
|
||||||
Age: number
|
age_group: string
|
||||||
EducationLevel: string
|
education_level: string
|
||||||
Country: string
|
country: string
|
||||||
Region: string
|
region: string
|
||||||
|
|
||||||
KnowledgeLevel: string
|
nick_name: string
|
||||||
InitialAssessmentCompleted: boolean
|
occupation: string
|
||||||
NickName: string
|
learning_goal: string
|
||||||
Occupation: string
|
language_goal: string
|
||||||
LearningGoal: string
|
language_challange: string
|
||||||
LanguageGoal: string
|
favoutite_topic: string
|
||||||
LanguageChallange: string
|
|
||||||
FavouriteTopic: string
|
|
||||||
|
|
||||||
EmailVerified: boolean
|
email_verified: boolean
|
||||||
PhoneVerified: boolean
|
phone_verified: boolean
|
||||||
Status: string
|
status: string
|
||||||
|
|
||||||
LastLogin: string | null
|
profile_completed: boolean
|
||||||
ProfileCompleted: boolean
|
profile_picture_url: string
|
||||||
ProfilePictureURL: string
|
preferred_language: string
|
||||||
PreferredLanguage: string
|
|
||||||
|
|
||||||
CreatedAt: string
|
created_at: string
|
||||||
UpdatedAt: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetUsersResponse {
|
export interface GetUsersResponse {
|
||||||
|
|
@ -60,15 +56,15 @@ export interface User {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mapUserApiToUser = (u: UserApiDTO): User => ({
|
export const mapUserApiToUser = (u: UserApiDTO): User => ({
|
||||||
id: u.ID,
|
id: u.id,
|
||||||
firstName: u.FirstName,
|
firstName: u.first_name,
|
||||||
lastName: u.LastName,
|
lastName: u.last_name,
|
||||||
nickName: u.NickName,
|
nickName: u.nick_name,
|
||||||
email: u.Email,
|
email: u.email,
|
||||||
phoneNumber: u.PhoneNumber,
|
phoneNumber: u.phone_number ?? "",
|
||||||
region: u.Region,
|
region: u.region,
|
||||||
country: u.Country,
|
country: u.country,
|
||||||
lastLogin: u.LastLogin,
|
lastLogin: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
export interface UserProfileData {
|
export interface UserProfileData {
|
||||||
|
|
@ -104,6 +100,8 @@ export interface UserProfileData {
|
||||||
|
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at?: string | null // optional
|
updated_at?: string | null // optional
|
||||||
|
age_group?: string
|
||||||
|
profile_completion_percentage?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserProfileResponse {
|
export interface UserProfileResponse {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user