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 type { LoginRequest, LoginResponse, LoginResponseData } from "../types/auth.types";
|
||||
import http from "./http"
|
||||
import type { LoginRequest, LoginResponse, LoginResponseData } from "../types/auth.types"
|
||||
|
||||
export interface LoginResult {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
role: string;
|
||||
user_id: number;
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
role: string
|
||||
memberId: number
|
||||
}
|
||||
|
||||
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 {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
role: data.role,
|
||||
user_id: data.user_id,
|
||||
};
|
||||
};
|
||||
role: data.team_role,
|
||||
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({
|
||||
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
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
|
|
@ -16,20 +74,41 @@ http.interceptors.request.use((config) => {
|
|||
return config;
|
||||
});
|
||||
|
||||
// Handle 401 globally
|
||||
// Handle 401 globally with token refresh
|
||||
http.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Clear stored tokens and user info
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
localStorage.removeItem("user_id");
|
||||
localStorage.removeItem("role");
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
// Redirect to login page
|
||||
window.location.href = "/login";
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
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);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
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) =>
|
||||
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 { AnalyticsPage } from "../pages/analytics/AnalyticsPage"
|
||||
import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout"
|
||||
import { CourseCategoryPage } from "../pages/content-management/CourseCategoryPage"
|
||||
import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage"
|
||||
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 { AddVideoPage } from "../pages/content-management/AddVideoPage"
|
||||
import { AddPracticePage } from "../pages/content-management/AddPracticePage"
|
||||
import { NotFoundPage } from "../pages/NotFoundPage"
|
||||
import { NotificationsPage } from "../pages/notifications/NotificationsPage"
|
||||
import { PlaceholderPage } from "../pages/PlaceholderPage"
|
||||
import { UserDetailPage } from "../pages/user-management/UserDetailPage"
|
||||
import { UserManagementLayout } from "../pages/user-management/UserManagementLayout"
|
||||
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 { AddQuestionPage } from "../pages/content-management/AddQuestionPage"
|
||||
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 { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage"
|
||||
import { VerificationPage } from "../pages/auth/VerificationPage"
|
||||
|
|
@ -52,9 +59,15 @@ export function AppRoutes() {
|
|||
</Route>
|
||||
|
||||
<Route path="/content" element={<ContentManagementLayout />}>
|
||||
<Route index element={<ContentOverviewPage />} />
|
||||
<Route path="courses" element={<CoursesPage />} />
|
||||
<Route path="courses/add-video" element={<AddVideoPage />} />
|
||||
<Route index element={<CourseCategoryPage />} />
|
||||
<Route path="category/:categoryId" element={<ContentOverviewPage />} />
|
||||
<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/add-practice" element={<AddPracticePage />} />
|
||||
<Route path="practices" element={<PracticeDetailsPage />} />
|
||||
|
|
@ -68,8 +81,9 @@ export function AppRoutes() {
|
|||
<Route path="/user-log" element={<UserLogPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
|
||||
<Route path="/team" element={<PlaceholderPage title="Team Management" />} />
|
||||
<Route path="/profile" element={<PlaceholderPage title="Profile" />} />
|
||||
<Route path="/team" element={<TeamManagementPage />} />
|
||||
<Route path="/team/:id" element={<TeamMemberDetailPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client" // make sure this is a client component
|
||||
|
||||
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 * as DropdownMenu from "@radix-ui/react-dropdown-menu"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
|
@ -11,9 +11,16 @@ export function Topbar() {
|
|||
const [shortName, setShortName] = useState("AA")
|
||||
|
||||
useEffect(() => {
|
||||
const updateShortName = () => {
|
||||
const first = localStorage.getItem("user_first_name") ?? "A"
|
||||
const last = localStorage.getItem("user_last_name") ?? "A"
|
||||
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) => {
|
||||
|
|
@ -66,30 +73,39 @@ export function Topbar() {
|
|||
<DropdownMenu.Content
|
||||
side="bottom"
|
||||
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
|
||||
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")}
|
||||
>
|
||||
<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
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
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")}
|
||||
>
|
||||
<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
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
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")}
|
||||
>
|
||||
<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
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
|
|
|
|||
|
|
@ -25,8 +25,7 @@ import { StatCard } from "../components/dashboard/StatCard"
|
|||
import { Button } from "../components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
|
||||
import { cn } from "../lib/utils"
|
||||
import { getUserById } from "../api/users.api"
|
||||
import type { UserProfileResponse } from "../types/user.types"
|
||||
import { getTeamMemberById } from "../api/team.api"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
const userGrowth = [
|
||||
|
|
@ -69,13 +68,14 @@ export function DashboardPage() {
|
|||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const userId = Number(localStorage.getItem("user_id"))
|
||||
const res = await getUserById(userId)
|
||||
const userProfile: UserProfileResponse = res.data
|
||||
const memberId = Number(localStorage.getItem("member_id"))
|
||||
const res = await getTeamMemberById(memberId)
|
||||
const member = res.data.data
|
||||
|
||||
setUserFirstName(userProfile.data.first_name)
|
||||
localStorage.setItem("user_first_name", userProfile.data.first_name)
|
||||
localStorage.setItem("user_last_name", userProfile.data.last_name)
|
||||
setUserFirstName(member.first_name)
|
||||
localStorage.setItem("user_first_name", member.first_name)
|
||||
localStorage.setItem("user_last_name", member.last_name)
|
||||
window.dispatchEvent(new Event("user-profile-updated"))
|
||||
} catch (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("refresh_token", res.refreshToken);
|
||||
localStorage.setItem("role", res.role);
|
||||
localStorage.setItem("user_id", res.user_id.toString());
|
||||
localStorage.setItem("member_id", res.memberId.toString());
|
||||
|
||||
navigate("/dashboard");
|
||||
} catch (err: any) {
|
||||
|
|
@ -69,7 +69,7 @@ export function LoginPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6" autoComplete="on">
|
||||
<form onSubmit={handleSubmit} className="space-y-6" autoComplete="on" method="post">
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label
|
||||
|
|
@ -82,7 +82,7 @@ export function LoginPage() {
|
|||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
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 { BookOpen, Mic, Briefcase, HelpCircle } from "lucide-react"
|
||||
import { useEffect, useState } from "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 { Button } from "../../components/ui/button"
|
||||
import { getCourseCategories } from "../../api/courses.api"
|
||||
import type { CourseCategory } from "../../types/course.types"
|
||||
|
||||
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 (
|
||||
<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">
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader>
|
||||
|
|
@ -17,7 +49,7 @@ export function ContentOverviewPage() {
|
|||
<CardDescription>Manage course videos and educational content</CardDescription>
|
||||
</CardHeader>
|
||||
<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>
|
||||
</Link>
|
||||
</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 { Plus } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
import { useEffect, useState, useRef } from "react"
|
||||
import { Link, useParams, useNavigate } from "react-router-dom"
|
||||
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 { 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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-grayScale-900">Courses</h1>
|
||||
<Link to="/content/courses/add-video">
|
||||
<Button className="bg-brand-500 hover:bg-brand-600">
|
||||
<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>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="h-full w-full object-cover rounded-t-lg"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function CoursesPage() {
|
||||
const { categoryId } = useParams<{ categoryId: string }>()
|
||||
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 { 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 { Badge } from "../../components/ui/badge";
|
||||
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 { useUsersStore } from "../../zustand/userStore";
|
||||
import { getUserById } from "../../api/users.api";
|
||||
|
||||
const activityIcons: Record<string, typeof CheckCircle2> = {
|
||||
completed: CheckCircle2,
|
||||
started: PlayCircle,
|
||||
joined: UserPlus,
|
||||
};
|
||||
|
||||
export function UserDetailPage() {
|
||||
const { id } = useParams();
|
||||
const userProfile = useUsersStore((s) => s.userProfile);
|
||||
|
|
@ -29,13 +51,16 @@ export function UserDetailPage() {
|
|||
|
||||
if (!userProfile) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl space-y-4">
|
||||
<div className="text-sm font-semibold text-grayScale-500">User Detail</div>
|
||||
<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="text-lg font-semibold text-grayScale-900">User not found</div>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<div className="mx-auto w-full max-w-3xl space-y-4 py-12">
|
||||
<Card className="shadow-soft">
|
||||
<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">
|
||||
<Target className="h-8 w-8 text-grayScale-300" />
|
||||
</div>
|
||||
<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>
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
|
@ -46,166 +71,261 @@ export function UserDetailPage() {
|
|||
|
||||
const user = userProfile;
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Back Link */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
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" />
|
||||
Back to Users
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="text-xl font-semibold text-grayScale-900">User Detail</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{/* Left Column */}
|
||||
<div className="space-y-4 lg:col-span-1">
|
||||
{/* Basic Information */}
|
||||
<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 items-center gap-3">
|
||||
<div className="h-12 w-12 rounded-full bg-grayScale-200 flex items-center justify-center overflow-hidden">
|
||||
{user.profile_picture_url ? (
|
||||
<img
|
||||
src={user.profile_picture_url}
|
||||
alt={fullName}
|
||||
className="h-12 w-12 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
<div className="grid gap-6 lg:grid-cols-[340px_1fr]">
|
||||
{/* ── Left column ── */}
|
||||
<div className="space-y-6">
|
||||
{/* Profile card */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="h-24 bg-gradient-to-br from-brand-600 via-brand-500 to-brand-400" />
|
||||
<CardContent className="-mt-12 space-y-5 px-6 pb-6 pt-0">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<Avatar className="h-20 w-20 ring-4 ring-white shadow-soft">
|
||||
<AvatarImage src={user.profile_picture_url ?? undefined} alt={fullName} />
|
||||
<AvatarFallback className="bg-brand-100 text-brand-600 text-xl">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<h2 className="mt-3 text-lg font-semibold text-grayScale-600">
|
||||
{fullName}
|
||||
</h2>
|
||||
<Badge
|
||||
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"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-grayScale-600">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Profile Completed</div>
|
||||
<div className="font-semibold">{user.profile_completed ? "Yes" : "No"}</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<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 className="text-xs font-semibold text-grayScale-400">Preferred Language</div>
|
||||
<div className="font-semibold">{user.preferred_language || "-"}</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wider text-grayScale-400">
|
||||
{label}
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="space-y-4 lg:col-span-2">
|
||||
{/* Learning Profile */}
|
||||
<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="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<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>
|
||||
{/* ── Right column ── */}
|
||||
<div className="space-y-6">
|
||||
{/* Learning profile */}
|
||||
<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">
|
||||
<GraduationCap className="h-4 w-4 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Age</div>
|
||||
<div className="text-sm font-semibold text-grayScale-600">{user.age || "-"}</div>
|
||||
<CardTitle>Learning Profile</CardTitle>
|
||||
</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 className="grid gap-4 md:grid-cols-2">
|
||||
<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>
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Learning Goal</div>
|
||||
<div className="text-sm font-semibold text-grayScale-600">{user.learning_goal || "-"}</div>
|
||||
<div className="text-[11px] font-medium uppercase tracking-wider text-grayScale-400 mb-2">
|
||||
Primary Goal
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Language Challenge</div>
|
||||
<div className="text-sm font-semibold text-grayScale-600">{user.language_challange || "-"}</div>
|
||||
<div className="rounded-xl bg-grayScale-100 p-4 text-sm leading-relaxed text-grayScale-600">
|
||||
{user.learning_goal ||
|
||||
"Improve business communication skills for professional advancement"}
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Status / Dates */}
|
||||
<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-3 text-sm text-grayScale-600">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Last Login</div>
|
||||
<div className="font-semibold">{user.last_login ? new Date(user.last_login).toLocaleString() : "-"}</div>
|
||||
{/* Recent activity */}
|
||||
<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">
|
||||
<BookOpen className="h-4 w-4 text-gold-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-grayScale-400">Updated At</div>
|
||||
<div className="font-semibold">{user.updated_at ? new Date(user.updated_at).toLocaleString() : "-"}</div>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -214,3 +334,27 @@ export function UserDetailPage() {
|
|||
</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 { useEffect } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { Badge } from "../../components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, Search } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { getUsers } from "../../api/users.api"
|
||||
import { mapUserApiToUser } from "../../types/user.types"
|
||||
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() {
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
users,
|
||||
total,
|
||||
|
|
@ -43,14 +24,27 @@ export function UsersListPage() {
|
|||
setSearch,
|
||||
} = 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(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const res = await getUsers(page, pageSize)
|
||||
const apiUsers = res.data.data.users
|
||||
|
||||
setUsers(apiUsers.map(mapUserApiToUser))
|
||||
const mapped = apiUsers.map(mapUserApiToUser)
|
||||
setUsers(mapped)
|
||||
setTotal(res.data.data.total)
|
||||
|
||||
const initialStatuses: Record<number, boolean> = {}
|
||||
mapped.forEach((u) => {
|
||||
initialStatuses[u.id] = true
|
||||
})
|
||||
setToggledStatuses((prev) => ({ ...prev, ...initialStatuses }))
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch users:", error)
|
||||
setUsers([])
|
||||
|
|
@ -63,104 +57,226 @@ export function UsersListPage() {
|
|||
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||
const safePage = Math.min(page, pageCount)
|
||||
const pageNumbers = Array.from({ length: pageCount }, (_, i) => i + 1)
|
||||
|
||||
const handlePrev = () => safePage > 1 && setPage(safePage - 1)
|
||||
const handleNext = () => safePage < pageCount && setPage(safePage + 1)
|
||||
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>User Management</CardTitle>
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds(new Set(users.map((u) => u.id)))
|
||||
} 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">
|
||||
<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 phone number"
|
||||
placeholder="Search by name, phone number"
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</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>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8">#</TableHead>
|
||||
<TableHead>First Name</TableHead>
|
||||
<TableHead>Last Name</TableHead>
|
||||
<TableHead>Nick Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Phone Number</TableHead>
|
||||
<TableHead>Region</TableHead>
|
||||
<TableHead className="w-12">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-grayScale-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>USER</TableHead>
|
||||
<TableHead>Phone</TableHead>
|
||||
<TableHead>Country</TableHead>
|
||||
<TableHead>Last Active</TableHead>
|
||||
<TableHead>Subscription</TableHead>
|
||||
<TableHead className="w-[56px]" />
|
||||
<TableHead>Region</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-center text-grayScale-400">
|
||||
<TableCell colSpan={6} className="text-center text-grayScale-400">
|
||||
No users found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((u, index) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell className="text-grayScale-500">{(page - 1) * pageSize + index + 1}</TableCell>
|
||||
<TableCell className="text-grayScale-600">{u.firstName}</TableCell>
|
||||
<TableCell className="text-grayScale-600">{u.lastName}</TableCell>
|
||||
<TableCell className="text-grayScale-600">{u.nickName}</TableCell>
|
||||
<TableCell className="text-grayScale-500">{u.email}</TableCell>
|
||||
<TableCell className="text-grayScale-500">{u.phoneNumber || "-"}</TableCell>
|
||||
<TableCell className="text-grayScale-500">{u.region}</TableCell>
|
||||
<TableCell className="text-grayScale-500">{u.country}</TableCell>
|
||||
<TableCell className="text-grayScale-500">
|
||||
{u.lastLogin ? new Date(u.lastLogin).toLocaleString() : "-"}
|
||||
users.map((u) => {
|
||||
const isActive = toggledStatuses[u.id] ?? false
|
||||
return (
|
||||
<TableRow
|
||||
key={u.id}
|
||||
className="cursor-pointer hover:bg-grayScale-50"
|
||||
onClick={() => handleRowClick(u.id)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
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>
|
||||
<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 className="text-right">
|
||||
<Link
|
||||
to={`/users/${u.id}`}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border bg-white text-grayScale-500 hover:text-brand-600"
|
||||
<TableCell className="text-grayScale-500">{u.phoneNumber || "-"}</TableCell>
|
||||
<TableCell className="text-grayScale-500">{u.country || "-"}</TableCell>
|
||||
<TableCell className="text-grayScale-500">{u.region || "-"}</TableCell>
|
||||
<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" />
|
||||
</Link>
|
||||
<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>
|
||||
|
||||
{/* Pagination */}
|
||||
<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 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">
|
||||
Rows per page
|
||||
<span>Row Per Page</span>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value))
|
||||
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}>
|
||||
{size}
|
||||
</option>
|
||||
))}
|
||||
</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 className="flex items-center gap-1">
|
||||
|
|
@ -168,40 +284,47 @@ export function UsersListPage() {
|
|||
onClick={handlePrev}
|
||||
disabled={safePage === 1}
|
||||
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"
|
||||
)}
|
||||
>
|
||||
Prev
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{pageNumbers.map((n) => (
|
||||
{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 bg-white text-xs font-semibold text-grayScale-500",
|
||||
n === safePage && "border-brand-200 bg-brand-100/40 text-brand-600"
|
||||
"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-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"
|
||||
)}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,19 @@
|
|||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponseData {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
role: string;
|
||||
user_id: number;
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
member_id: number
|
||||
team_role: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
message: string;
|
||||
data: LoginResponseData;
|
||||
success: boolean;
|
||||
status_code: number;
|
||||
metadata: any | null;
|
||||
message: string
|
||||
data: LoginResponseData
|
||||
success: boolean
|
||||
status_code: number
|
||||
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
|
||||
export interface UserApiDTO {
|
||||
ID: number
|
||||
FirstName: string
|
||||
LastName: string
|
||||
Gender: string
|
||||
id: number
|
||||
first_name: string
|
||||
last_name: string
|
||||
gender: string
|
||||
birth_day: string | null
|
||||
|
||||
Email: string
|
||||
PhoneNumber: string
|
||||
Role: string
|
||||
email: string
|
||||
phone_number?: string
|
||||
role: string
|
||||
|
||||
Age: number
|
||||
EducationLevel: string
|
||||
Country: string
|
||||
Region: string
|
||||
age_group: string
|
||||
education_level: string
|
||||
country: string
|
||||
region: string
|
||||
|
||||
KnowledgeLevel: string
|
||||
InitialAssessmentCompleted: boolean
|
||||
NickName: string
|
||||
Occupation: string
|
||||
LearningGoal: string
|
||||
LanguageGoal: string
|
||||
LanguageChallange: string
|
||||
FavouriteTopic: string
|
||||
nick_name: string
|
||||
occupation: string
|
||||
learning_goal: string
|
||||
language_goal: string
|
||||
language_challange: string
|
||||
favoutite_topic: string
|
||||
|
||||
EmailVerified: boolean
|
||||
PhoneVerified: boolean
|
||||
Status: string
|
||||
email_verified: boolean
|
||||
phone_verified: boolean
|
||||
status: string
|
||||
|
||||
LastLogin: string | null
|
||||
ProfileCompleted: boolean
|
||||
ProfilePictureURL: string
|
||||
PreferredLanguage: string
|
||||
profile_completed: boolean
|
||||
profile_picture_url: string
|
||||
preferred_language: string
|
||||
|
||||
CreatedAt: string
|
||||
UpdatedAt: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface GetUsersResponse {
|
||||
|
|
@ -60,15 +56,15 @@ export interface User {
|
|||
}
|
||||
|
||||
export const mapUserApiToUser = (u: UserApiDTO): User => ({
|
||||
id: u.ID,
|
||||
firstName: u.FirstName,
|
||||
lastName: u.LastName,
|
||||
nickName: u.NickName,
|
||||
email: u.Email,
|
||||
phoneNumber: u.PhoneNumber,
|
||||
region: u.Region,
|
||||
country: u.Country,
|
||||
lastLogin: u.LastLogin,
|
||||
id: u.id,
|
||||
firstName: u.first_name,
|
||||
lastName: u.last_name,
|
||||
nickName: u.nick_name,
|
||||
email: u.email,
|
||||
phoneNumber: u.phone_number ?? "",
|
||||
region: u.region,
|
||||
country: u.country,
|
||||
lastLogin: null,
|
||||
})
|
||||
|
||||
export interface UserProfileData {
|
||||
|
|
@ -104,6 +100,8 @@ export interface UserProfileData {
|
|||
|
||||
created_at: string
|
||||
updated_at?: string | null // optional
|
||||
age_group?: string
|
||||
profile_completion_percentage?: number
|
||||
}
|
||||
|
||||
export interface UserProfileResponse {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user