team management + profile + content management integrations + minor fixes

This commit is contained in:
Yared Yemane 2026-02-06 10:50:30 -08:00
parent cda7d9d551
commit a29d82bfee
26 changed files with 5898 additions and 372 deletions

2
.env
View File

@ -1 +1 @@
VITE_API_BASE_URL=http://195.35.29.82:8080/api/v1 VITE_API_BASE_URL=http://localhost:8080/api/v1

View File

@ -1,22 +1,22 @@
import http from "./http"; import http from "./http"
import type { LoginRequest, LoginResponse, LoginResponseData } from "../types/auth.types"; import type { LoginRequest, LoginResponse, LoginResponseData } from "../types/auth.types"
export interface LoginResult { export interface LoginResult {
accessToken: string; accessToken: string
refreshToken: string; refreshToken: string
role: string; role: string
user_id: number; memberId: number
} }
export const login = async (payload: LoginRequest): Promise<LoginResult> => { export const login = async (payload: LoginRequest): Promise<LoginResult> => {
const res = await http.post<LoginResponse>("/auth/customer-login", payload); const res = await http.post<LoginResponse>("/team/login", payload)
const data: LoginResponseData = res.data.data; const data: LoginResponseData = res.data.data
return { return {
accessToken: data.access_token, accessToken: data.access_token,
refreshToken: data.refresh_token, refreshToken: data.refresh_token,
role: data.role, role: data.team_role,
user_id: data.user_id, memberId: data.member_id,
}; }
}; }

193
src/api/courses.api.ts Normal file
View 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)

View File

@ -1,4 +1,4 @@
import axios, { type AxiosInstance } from "axios"; import axios, { type AxiosInstance, type AxiosError, type InternalAxiosRequestConfig } from "axios";
const http: AxiosInstance = axios.create({ const http: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL, baseURL: import.meta.env.VITE_API_BASE_URL,
@ -7,6 +7,64 @@ const http: AxiosInstance = axios.create({
}, },
}); });
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: Error) => void;
}> = [];
const processQueue = (error: Error | null, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else if (token) {
prom.resolve(token);
}
});
failedQueue = [];
};
const clearAuthAndRedirect = () => {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("member_id");
localStorage.removeItem("role");
window.location.href = "/login";
};
const refreshAccessToken = async (): Promise<string> => {
const accessToken = localStorage.getItem("access_token");
const refreshToken = localStorage.getItem("refresh_token");
const role = localStorage.getItem("role");
const memberId = localStorage.getItem("member_id");
if (!refreshToken || !memberId) {
throw new Error("No refresh token available");
}
const response = await axios.post(
`${import.meta.env.VITE_API_BASE_URL}/auth/refresh`,
{
access_token: accessToken,
refresh_token: refreshToken,
role: role || "admin",
member_id: Number(memberId),
}
);
const newAccessToken = response.data?.data?.access_token;
const newRefreshToken = response.data?.data?.refresh_token;
if (newAccessToken) {
localStorage.setItem("access_token", newAccessToken);
}
if (newRefreshToken) {
localStorage.setItem("refresh_token", newRefreshToken);
}
return newAccessToken;
};
// Attach access token to every request // Attach access token to every request
http.interceptors.request.use((config) => { http.interceptors.request.use((config) => {
const token = localStorage.getItem("access_token"); const token = localStorage.getItem("access_token");
@ -16,20 +74,41 @@ http.interceptors.request.use((config) => {
return config; return config;
}); });
// Handle 401 globally // Handle 401 globally with token refresh
http.interceptors.response.use( http.interceptors.response.use(
(response) => response, (response) => response,
(error) => { async (error: AxiosError) => {
if (error.response?.status === 401) { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Clear stored tokens and user info
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user_id");
localStorage.removeItem("role");
// Redirect to login page if (error.response?.status === 401 && !originalRequest._retry) {
window.location.href = "/login"; if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return http(originalRequest);
})
.catch((err) => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
try {
const newToken = await refreshAccessToken();
processQueue(null, newToken);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return http(originalRequest);
} catch (refreshError) {
processQueue(refreshError as Error, null);
clearAuthAndRedirect();
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
} }
return Promise.reject(error); return Promise.reject(error);
} }
); );

13
src/api/team.api.ts Normal file
View 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}`)

View File

@ -11,3 +11,6 @@ export const getUsers = (page?: number, pageSize?: number) =>
export const getUserById = (id: number) => export const getUserById = (id: number) =>
http.get<UserProfileResponse>(`/user/single/${id}`); http.get<UserProfileResponse>(`/user/single/${id}`);
export const getMyProfile = () =>
http.get<UserProfileResponse>("/team/me");

View File

@ -3,14 +3,18 @@ import { AppLayout } from "../layouts/AppLayout"
import { DashboardPage } from "../pages/DashboardPage" import { DashboardPage } from "../pages/DashboardPage"
import { AnalyticsPage } from "../pages/analytics/AnalyticsPage" import { AnalyticsPage } from "../pages/analytics/AnalyticsPage"
import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout" import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout"
import { CourseCategoryPage } from "../pages/content-management/CourseCategoryPage"
import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage" import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage"
import { CoursesPage } from "../pages/content-management/CoursesPage" import { CoursesPage } from "../pages/content-management/CoursesPage"
import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuestionsPage"
import { AddNewPracticePage } from "../pages/content-management/AddNewPracticePage"
import { SubCoursesPage } from "../pages/content-management/SubCoursesPage"
import { SubCourseContentPage } from "../pages/content-management/SubCourseContentPage"
import { SpeakingPage } from "../pages/content-management/SpeakingPage" import { SpeakingPage } from "../pages/content-management/SpeakingPage"
import { AddVideoPage } from "../pages/content-management/AddVideoPage" import { AddVideoPage } from "../pages/content-management/AddVideoPage"
import { AddPracticePage } from "../pages/content-management/AddPracticePage" import { AddPracticePage } from "../pages/content-management/AddPracticePage"
import { NotFoundPage } from "../pages/NotFoundPage" import { NotFoundPage } from "../pages/NotFoundPage"
import { NotificationsPage } from "../pages/notifications/NotificationsPage" import { NotificationsPage } from "../pages/notifications/NotificationsPage"
import { PlaceholderPage } from "../pages/PlaceholderPage"
import { UserDetailPage } from "../pages/user-management/UserDetailPage" import { UserDetailPage } from "../pages/user-management/UserDetailPage"
import { UserManagementLayout } from "../pages/user-management/UserManagementLayout" import { UserManagementLayout } from "../pages/user-management/UserManagementLayout"
import { UsersListPage } from "../pages/user-management/UsersListPage" import { UsersListPage } from "../pages/user-management/UsersListPage"
@ -25,6 +29,9 @@ import { PracticeMembersPage } from "../pages/content-management/PracticeMembers
import { QuestionsPage } from "../pages/content-management/QuestionsPage" import { QuestionsPage } from "../pages/content-management/QuestionsPage"
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage" import { AddQuestionPage } from "../pages/content-management/AddQuestionPage"
import { UserLogPage } from "../pages/user-log/UserLogPage" import { UserLogPage } from "../pages/user-log/UserLogPage"
import { ProfilePage } from "../pages/ProfilePage"
import { TeamManagementPage } from "../pages/team/TeamManagementPage"
import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage"
import { LoginPage } from "../pages/auth/LoginPage" import { LoginPage } from "../pages/auth/LoginPage"
import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage" import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage"
import { VerificationPage } from "../pages/auth/VerificationPage" import { VerificationPage } from "../pages/auth/VerificationPage"
@ -52,9 +59,15 @@ export function AppRoutes() {
</Route> </Route>
<Route path="/content" element={<ContentManagementLayout />}> <Route path="/content" element={<ContentManagementLayout />}>
<Route index element={<ContentOverviewPage />} /> <Route index element={<CourseCategoryPage />} />
<Route path="courses" element={<CoursesPage />} /> <Route path="category/:categoryId" element={<ContentOverviewPage />} />
<Route path="courses/add-video" element={<AddVideoPage />} /> <Route path="category/:categoryId/courses" element={<CoursesPage />} />
{/* Course → Sub-course → Video/Practice */}
<Route path="category/:categoryId/courses/:courseId/sub-courses" element={<SubCoursesPage />} />
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subCourseId" element={<SubCourseContentPage />} />
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subCourseId/add-practice" element={<AddNewPracticePage />} />
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subCourseId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} />
<Route path="category/:categoryId/courses/add-video" element={<AddVideoPage />} />
<Route path="speaking" element={<SpeakingPage />} /> <Route path="speaking" element={<SpeakingPage />} />
<Route path="speaking/add-practice" element={<AddPracticePage />} /> <Route path="speaking/add-practice" element={<AddPracticePage />} />
<Route path="practices" element={<PracticeDetailsPage />} /> <Route path="practices" element={<PracticeDetailsPage />} />
@ -68,8 +81,9 @@ export function AppRoutes() {
<Route path="/user-log" element={<UserLogPage />} /> <Route path="/user-log" element={<UserLogPage />} />
<Route path="/analytics" element={<AnalyticsPage />} /> <Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/team" element={<PlaceholderPage title="Team Management" />} /> <Route path="/team" element={<TeamManagementPage />} />
<Route path="/profile" element={<PlaceholderPage title="Profile" />} /> <Route path="/team/:id" element={<TeamMemberDetailPage />} />
<Route path="/profile" element={<ProfilePage />} />
</Route> </Route>
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />

View File

@ -1,7 +1,7 @@
"use client" // make sure this is a client component "use client" // make sure this is a client component
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Bell } from "lucide-react" import { Bell, LogOut, Settings, UserCircle2 } from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"
import * as DropdownMenu from "@radix-ui/react-dropdown-menu" import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
@ -11,9 +11,16 @@ export function Topbar() {
const [shortName, setShortName] = useState("AA") const [shortName, setShortName] = useState("AA")
useEffect(() => { useEffect(() => {
const first = localStorage.getItem("user_first_name") ?? "A" const updateShortName = () => {
const last = localStorage.getItem("user_last_name") ?? "A" const first = localStorage.getItem("user_first_name") ?? "A"
setShortName(first.charAt(0).toUpperCase() + last.charAt(0).toUpperCase()) 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) => { const handleOptionClick = (option: string) => {
@ -66,30 +73,39 @@ export function Topbar() {
<DropdownMenu.Content <DropdownMenu.Content
side="bottom" side="bottom"
align="end" align="end"
className="z-50 w-40 rounded-lg bg-white p-2 shadow-lg ring-1 ring-black ring-opacity-5" className="z-50 w-48 rounded-lg bg-white p-2 shadow-lg ring-1 ring-black ring-opacity-5"
> >
<DropdownMenu.Item <DropdownMenu.Item
className={cn( className={cn(
"cursor-pointer rounded px-3 py-2 text-grayScale-700 text-sm hover:bg-grayScale-100" "group flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-grayScale-600 hover:bg-grayScale-100 hover:text-brand-600"
)} )}
onClick={() => handleOptionClick("profile")} onClick={() => handleOptionClick("profile")}
> >
<span className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 group-hover:bg-brand-100 group-hover:text-brand-600">
<UserCircle2 className="h-4 w-4" />
</span>
Profile Profile
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
className={cn( className={cn(
"cursor-pointer rounded px-3 py-2 text-grayScale-700 text-sm hover:bg-grayScale-100" "group flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-grayScale-600 hover:bg-grayScale-100 hover:text-brand-600"
)} )}
onClick={() => handleOptionClick("settings")} onClick={() => handleOptionClick("settings")}
> >
<span className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 group-hover:bg-brand-100 group-hover:text-brand-600">
<Settings className="h-4 w-4" />
</span>
Settings Settings
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
className={cn( className={cn(
"cursor-pointer rounded px-3 py-2 text-grayScale-700 text-sm hover:bg-grayScale-100" "group flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-grayScale-600 hover:bg-grayScale-100 hover:text-brand-600"
)} )}
onClick={() => handleOptionClick("logout")} onClick={() => handleOptionClick("logout")}
> >
<span className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 group-hover:bg-brand-100 group-hover:text-brand-600">
<LogOut className="h-4 w-4" />
</span>
Logout Logout
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>

View File

@ -25,8 +25,7 @@ import { StatCard } from "../components/dashboard/StatCard"
import { Button } from "../components/ui/button" import { Button } from "../components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
import { cn } from "../lib/utils" import { cn } from "../lib/utils"
import { getUserById } from "../api/users.api" import { getTeamMemberById } from "../api/team.api"
import type { UserProfileResponse } from "../types/user.types"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
const userGrowth = [ const userGrowth = [
@ -69,13 +68,14 @@ export function DashboardPage() {
useEffect(() => { useEffect(() => {
const fetchUser = async () => { const fetchUser = async () => {
try { try {
const userId = Number(localStorage.getItem("user_id")) const memberId = Number(localStorage.getItem("member_id"))
const res = await getUserById(userId) const res = await getTeamMemberById(memberId)
const userProfile: UserProfileResponse = res.data const member = res.data.data
setUserFirstName(userProfile.data.first_name) setUserFirstName(member.first_name)
localStorage.setItem("user_first_name", userProfile.data.first_name) localStorage.setItem("user_first_name", member.first_name)
localStorage.setItem("user_last_name", userProfile.data.last_name) localStorage.setItem("user_last_name", member.last_name)
window.dispatchEvent(new Event("user-profile-updated"))
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} }

308
src/pages/ProfilePage.tsx Normal file
View 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>
);
}

View File

@ -36,7 +36,7 @@ export function LoginPage() {
localStorage.setItem("access_token", res.accessToken); localStorage.setItem("access_token", res.accessToken);
localStorage.setItem("refresh_token", res.refreshToken); localStorage.setItem("refresh_token", res.refreshToken);
localStorage.setItem("role", res.role); localStorage.setItem("role", res.role);
localStorage.setItem("user_id", res.user_id.toString()); localStorage.setItem("member_id", res.memberId.toString());
navigate("/dashboard"); navigate("/dashboard");
} catch (err: any) { } catch (err: any) {
@ -69,7 +69,7 @@ export function LoginPage() {
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-6" autoComplete="on"> <form onSubmit={handleSubmit} className="space-y-6" autoComplete="on" method="post">
{/* Email */} {/* Email */}
<div> <div>
<label <label
@ -82,7 +82,7 @@ export function LoginPage() {
id="email" id="email"
name="email" name="email"
type="email" type="email"
autoComplete="username" autoComplete="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required

View 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>
)
}

View File

@ -1,12 +1,44 @@
import { Link } from "react-router-dom" import { useEffect, useState } from "react"
import { BookOpen, Mic, Briefcase, HelpCircle } from "lucide-react" import { Link, useParams } from "react-router-dom"
import { BookOpen, Mic, Briefcase, HelpCircle, ArrowLeft } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { getCourseCategories } from "../../api/courses.api"
import type { CourseCategory } from "../../types/course.types"
export function ContentOverviewPage() { export function ContentOverviewPage() {
const { categoryId } = useParams<{ categoryId: string }>()
const [category, setCategory] = useState<CourseCategory | null>(null)
useEffect(() => {
const fetchCategory = async () => {
try {
const res = await getCourseCategories()
const found = res.data.data.categories.find((c) => c.id === Number(categoryId))
setCategory(found ?? null)
} catch (err) {
console.error("Failed to fetch category:", err)
}
}
if (categoryId) {
fetchCategory()
}
}, [categoryId])
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h1 className="text-xl font-semibold text-grayScale-900">Content Management</h1> <div className="flex items-center gap-3">
<Link
to="/content"
className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 hover:bg-brand-100 hover:text-brand-600"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<h1 className="text-xl font-semibold text-grayScale-900">
{category?.name ?? "Content Management"}
</h1>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<Card className="shadow-sm"> <Card className="shadow-sm">
<CardHeader> <CardHeader>
@ -17,7 +49,7 @@ export function ContentOverviewPage() {
<CardDescription>Manage course videos and educational content</CardDescription> <CardDescription>Manage course videos and educational content</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Link to="/content/courses"> <Link to={`/content/category/${categoryId}/courses`}>
<Button className="w-full bg-brand-500 hover:bg-brand-600">Manage Courses</Button> <Button className="w-full bg-brand-500 hover:bg-brand-600">Manage Courses</Button>
</Link> </Link>
</CardContent> </CardContent>

View 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>
)
}

View File

@ -1,30 +1,593 @@
import { Link } from "react-router-dom" import { useEffect, useState, useRef } from "react"
import { Plus } from "lucide-react" import { Link, useParams, useNavigate } from "react-router-dom"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Plus, ArrowLeft, BookOpen, ToggleLeft, ToggleRight, X, Trash2, MoreVertical, Edit } from "lucide-react"
import { Card, CardContent } from "../../components/ui/card"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Badge } from "../../components/ui/badge"
import { Input } from "../../components/ui/input"
import { getCoursesByCategory, getCourseCategories, createCourse, deleteCourse, updateCourseStatus, updateCourse } from "../../api/courses.api"
import type { Course, CourseCategory } from "../../types/course.types"
function CourseThumbnail({ src, alt, gradient }: { src?: string; alt: string; gradient: string }) {
const [imgError, setImgError] = useState(false)
if (!src || imgError) {
return <div className={`h-full w-full rounded-t-lg ${gradient}`} />
}
export function CoursesPage() {
return ( return (
<div className="space-y-6"> <img
<div className="flex items-center justify-between"> src={src}
<h1 className="text-xl font-semibold text-grayScale-900">Courses</h1> alt={alt}
<Link to="/content/courses/add-video"> className="h-full w-full object-cover rounded-t-lg"
<Button className="bg-brand-500 hover:bg-brand-600"> onError={() => setImgError(true)}
<Plus className="h-4 w-4" /> />
Add New Video
</Button>
</Link>
</div>
<Card className="shadow-none">
<CardHeader>
<CardTitle>Course Management</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Manage your course videos and content here.
</CardContent>
</Card>
</div>
) )
} }
export function CoursesPage() {
const { categoryId } = useParams<{ categoryId: string }>()
const navigate = useNavigate()
const [courses, setCourses] = useState<Course[]>([])
const [category, setCategory] = useState<CourseCategory | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showModal, setShowModal] = useState(false)
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [courseToDelete, setCourseToDelete] = useState<Course | null>(null)
const [deleting, setDeleting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
const [openMenuId, setOpenMenuId] = useState<number | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [showEditModal, setShowEditModal] = useState(false)
const [courseToEdit, setCourseToEdit] = useState<Course | null>(null)
const [editTitle, setEditTitle] = useState("")
const [editDescription, setEditDescription] = useState("")
const [editThumbnail, setEditThumbnail] = useState("")
const [updating, setUpdating] = useState(false)
const [updateError, setUpdateError] = useState<string | null>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setOpenMenuId(null)
}
}
if (openMenuId !== null) {
document.addEventListener("mousedown", handleClickOutside)
}
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [openMenuId])
const fetchCourses = async () => {
if (!categoryId) return
try {
const coursesRes = await getCoursesByCategory(Number(categoryId))
console.log("Courses response:", coursesRes.data.data.courses)
setCourses(coursesRes.data.data.courses ?? [])
} catch (err) {
console.error("Failed to fetch courses:", err)
}
}
useEffect(() => {
const fetchData = async () => {
if (!categoryId) return
try {
const [coursesRes, categoriesRes] = await Promise.all([
getCoursesByCategory(Number(categoryId)),
getCourseCategories(),
])
setCourses(coursesRes.data.data.courses)
const foundCategory = categoriesRes.data.data.categories.find(
(c) => c.id === Number(categoryId)
)
setCategory(foundCategory ?? null)
} catch (err) {
console.error("Failed to fetch courses:", err)
setError("Failed to load courses")
} finally {
setLoading(false)
}
}
fetchData()
}, [categoryId])
const handleOpenModal = () => {
setTitle("")
setDescription("")
setSaveError(null)
setShowModal(true)
}
const handleCloseModal = () => {
setShowModal(false)
setTitle("")
setDescription("")
setSaveError(null)
}
const handleSave = async () => {
if (!title.trim()) {
setSaveError("Title is required")
return
}
if (!description.trim()) {
setSaveError("Description is required")
return
}
setSaving(true)
setSaveError(null)
try {
await createCourse({
category_id: Number(categoryId),
title: title.trim(),
description: description.trim(),
})
handleCloseModal()
await fetchCourses()
} catch (err: any) {
console.error("Failed to create course:", err)
setSaveError(err.response?.data?.message || "Failed to create course")
} finally {
setSaving(false)
}
}
const handleDeleteClick = (course: Course) => {
setCourseToDelete(course)
setShowDeleteModal(true)
}
const handleConfirmDelete = async () => {
if (!courseToDelete) return
setDeleting(true)
try {
await deleteCourse(courseToDelete.id)
setShowDeleteModal(false)
setCourseToDelete(null)
await fetchCourses()
} catch (err) {
console.error("Failed to delete course:", err)
} finally {
setDeleting(false)
}
}
const handleToggleStatus = async (course: Course) => {
setTogglingId(course.id)
try {
await updateCourseStatus(course.id, !course.is_active)
await fetchCourses()
} catch (err) {
console.error("Failed to update course status:", err)
} finally {
setTogglingId(null)
}
}
const handleEditClick = (course: Course) => {
setCourseToEdit(course)
setEditTitle(course.title || "")
setEditDescription(course.description || "")
setEditThumbnail(course.thumbnail || "")
setUpdateError(null)
setShowEditModal(true)
}
const handleCloseEditModal = () => {
setShowEditModal(false)
setCourseToEdit(null)
setEditTitle("")
setEditDescription("")
setEditThumbnail("")
setUpdateError(null)
}
const handleUpdate = async () => {
if (!courseToEdit) return
if (!editTitle.trim()) {
setUpdateError("Title is required")
return
}
if (!editDescription.trim()) {
setUpdateError("Description is required")
return
}
setUpdating(true)
setUpdateError(null)
try {
await updateCourse(courseToEdit.id, {
title: editTitle.trim(),
description: editDescription.trim(),
thumbnail: editThumbnail.trim() || undefined,
is_active: courseToEdit.is_active,
})
handleCloseEditModal()
await fetchCourses()
} catch (err: any) {
console.error("Failed to update course:", err)
setUpdateError(err.response?.data?.message || "Failed to update course")
} finally {
setUpdating(false)
}
}
const handleCourseClick = (courseId: number) => {
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses`)
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-sm text-grayScale-500">Loading courses...</div>
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-sm text-red-500">{error}</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link
to="/content"
className="grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 hover:bg-brand-100 hover:text-brand-600"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<div>
<h1 className="text-xl font-semibold text-grayScale-900">
{category?.name} Courses
</h1>
<p className="text-sm text-grayScale-500">{courses.length} courses available</p>
</div>
</div>
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleOpenModal}>
<Plus className="mr-2 h-4 w-4" />
Add New Course
</Button>
</div>
{courses.length === 0 ? (
<Card className="shadow-none">
<CardContent className="flex flex-col items-center justify-center py-12">
<BookOpen className="mb-4 h-12 w-12 text-grayScale-300" />
<p className="text-sm text-grayScale-500">No courses found in this category</p>
<Button variant="outline" className="mt-4" onClick={handleOpenModal}>
Add your first course
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{courses.map((course, index) => {
const gradients = [
"bg-gradient-to-br from-blue-100 to-blue-200",
"bg-gradient-to-br from-purple-100 to-purple-200",
"bg-gradient-to-br from-green-100 to-green-200",
"bg-gradient-to-br from-yellow-100 to-yellow-200",
]
return (
<Card
key={course.id}
className="cursor-pointer overflow-hidden border-0 bg-white shadow-sm transition hover:shadow-md"
onClick={() => handleCourseClick(course.id)}
>
{/* Thumbnail */}
<div className="relative aspect-video w-full">
<CourseThumbnail
src={course.thumbnail}
alt={course.title}
gradient={gradients[index % gradients.length]}
/>
</div>
{/* Content */}
<div className="p-4 space-y-3">
{/* Status and menu */}
<div className="flex items-center justify-between">
<Badge
className={`text-xs font-medium ${
course.is_active
? "bg-transparent text-green-600 border border-green-200"
: "bg-grayScale-100 text-grayScale-600 border border-grayScale-200"
}`}
>
<span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${course.is_active ? "bg-green-500" : "bg-grayScale-400"}`} />
{course.is_active ? "ACTIVE" : "INACTIVE"}
</Badge>
<div className="relative" ref={openMenuId === course.id ? menuRef : undefined} onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setOpenMenuId(openMenuId === course.id ? null : course.id)}
className="text-grayScale-400 hover:text-grayScale-600"
>
<MoreVertical className="h-4 w-4" />
</button>
{openMenuId === course.id && (
<div className="absolute right-0 top-full z-10 mt-1 w-40 rounded-lg bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5">
<button
onClick={() => {
handleToggleStatus(course)
setOpenMenuId(null)
}}
disabled={togglingId === course.id}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-grayScale-700 hover:bg-grayScale-100 disabled:opacity-50"
>
{course.is_active ? (
<>
<ToggleLeft className="h-4 w-4" />
Deactivate
</>
) : (
<>
<ToggleRight className="h-4 w-4" />
Activate
</>
)}
</button>
<button
onClick={() => {
handleDeleteClick(course)
setOpenMenuId(null)
}}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-500 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
</div>
)}
</div>
</div>
{/* Title */}
<h3 className="font-medium text-grayScale-900">{course.title}</h3>
<p className="text-sm text-grayScale-500 line-clamp-2">
{course.description || "No description available"}
</p>
{/* Edit button */}
<Button
variant="outline"
className="w-full border-grayScale-200 text-grayScale-700"
onClick={(e) => {
e.stopPropagation()
handleEditClick(course)
}}
>
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
</div>
</Card>
)
})}
</div>
)}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
<div className="flex items-center justify-between border-b px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Add New Course</h2>
<button
onClick={handleCloseModal}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-4 px-6 py-5">
{saveError && (
<div className="rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600">
{saveError}
</div>
)}
<div>
<label
htmlFor="course-title"
className="mb-2 block text-sm font-medium text-grayScale-600"
>
Title
</label>
<Input
id="course-title"
placeholder="Enter course title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div>
<label
htmlFor="course-description"
className="mb-2 block text-sm font-medium text-grayScale-600"
>
Description
</label>
<textarea
id="course-description"
placeholder="Enter course description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
className="flex w-full rounded-lg border bg-white px-3 py-2 text-sm ring-offset-background placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="text-xs text-grayScale-500">
Category: <span className="font-medium">{category?.name}</span>
</div>
</div>
<div className="flex justify-end gap-3 border-t px-6 py-4">
<Button variant="outline" onClick={handleCloseModal} disabled={saving}>
Cancel
</Button>
<Button
className="bg-brand-500 hover:bg-brand-600"
onClick={handleSave}
disabled={saving}
>
{saving ? "Saving..." : "Save Course"}
</Button>
</div>
</div>
</div>
)}
{showEditModal && courseToEdit && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
<div className="flex items-center justify-between border-b px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Edit Course</h2>
<button
onClick={handleCloseEditModal}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-4 px-6 py-5">
{updateError && (
<div className="rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600">
{updateError}
</div>
)}
<div>
<label
htmlFor="edit-course-title"
className="mb-2 block text-sm font-medium text-grayScale-600"
>
Title
</label>
<Input
id="edit-course-title"
placeholder="Enter course title"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
/>
</div>
<div>
<label
htmlFor="edit-course-description"
className="mb-2 block text-sm font-medium text-grayScale-600"
>
Description
</label>
<textarea
id="edit-course-description"
placeholder="Enter course description"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
className="flex w-full rounded-lg border bg-white px-3 py-2 text-sm ring-offset-background placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div>
<label
htmlFor="edit-course-thumbnail"
className="mb-2 block text-sm font-medium text-grayScale-600"
>
Thumbnail URL
</label>
<Input
id="edit-course-thumbnail"
placeholder="Enter thumbnail URL (e.g., https://example.com/image.jpg)"
value={editThumbnail}
onChange={(e) => setEditThumbnail(e.target.value)}
/>
</div>
</div>
<div className="flex justify-end gap-3 border-t px-6 py-4">
<Button variant="outline" onClick={handleCloseEditModal} disabled={updating}>
Cancel
</Button>
<Button
className="bg-brand-500 hover:bg-brand-600"
onClick={handleUpdate}
disabled={updating}
>
{updating ? "Updating..." : "Update Course"}
</Button>
</div>
</div>
</div>
)}
{showDeleteModal && courseToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl">
<div className="flex items-center justify-between border-b px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Delete Course</h2>
<button
onClick={() => setShowDeleteModal(false)}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="px-6 py-5">
<p className="text-sm text-grayScale-600">
Are you sure you want to delete{" "}
<span className="font-semibold">{courseToDelete.title}</span>? This action cannot
be undone.
</p>
</div>
<div className="flex justify-end gap-3 border-t px-6 py-4">
<Button
variant="outline"
onClick={() => setShowDeleteModal(false)}
disabled={deleting}
>
Cancel
</Button>
<Button
className="bg-red-500 hover:bg-red-600"
onClick={handleConfirmDelete}
disabled={deleting}
>
{deleting ? "Deleting..." : "Delete"}
</Button>
</div>
</div>
</div>
)}
</div>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
);
}

View 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>
);
}

View File

@ -1,13 +1,35 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { ArrowLeft, UserCircle2 } from "lucide-react"; import {
ArrowLeft,
BookOpen,
Calendar,
CheckCircle2,
Globe,
GraduationCap,
Mail,
MapPin,
Phone,
PlayCircle,
RefreshCw,
Target,
UserPlus,
} from "lucide-react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { Badge } from "../../components/ui/badge"; import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { Card, CardContent } from "../../components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card";
import { Separator } from "../../components/ui/separator";
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { useUsersStore } from "../../zustand/userStore"; import { useUsersStore } from "../../zustand/userStore";
import { getUserById } from "../../api/users.api"; import { getUserById } from "../../api/users.api";
const activityIcons: Record<string, typeof CheckCircle2> = {
completed: CheckCircle2,
started: PlayCircle,
joined: UserPlus,
};
export function UserDetailPage() { export function UserDetailPage() {
const { id } = useParams(); const { id } = useParams();
const userProfile = useUsersStore((s) => s.userProfile); const userProfile = useUsersStore((s) => s.userProfile);
@ -29,13 +51,16 @@ export function UserDetailPage() {
if (!userProfile) { if (!userProfile) {
return ( return (
<div className="mx-auto w-full max-w-3xl space-y-4"> <div className="mx-auto w-full max-w-3xl space-y-4 py-12">
<div className="text-sm font-semibold text-grayScale-500">User Detail</div> <Card className="shadow-soft">
<Card className="overflow-hidden shadow-sm"> <CardContent className="flex flex-col items-center gap-4 p-10">
<div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" /> <div className="h-16 w-16 rounded-full bg-grayScale-100 flex items-center justify-center">
<CardContent className="p-6 space-y-4"> <Target className="h-8 w-8 text-grayScale-300" />
<div className="text-lg font-semibold text-grayScale-900">User not found</div> </div>
<Button asChild variant="outline" className="w-full"> <div className="text-lg font-semibold text-grayScale-600">
User not found
</div>
<Button asChild variant="outline" className="mt-2">
<Link to="/users/list">Back to Users</Link> <Link to="/users/list">Back to Users</Link>
</Button> </Button>
</CardContent> </CardContent>
@ -46,166 +71,261 @@ export function UserDetailPage() {
const user = userProfile; const user = userProfile;
const fullName = `${user.first_name} ${user.last_name}`; const fullName = `${user.first_name} ${user.last_name}`;
const initials = `${user.first_name?.[0] ?? ""}${user.last_name?.[0] ?? ""}`.toUpperCase();
const recentActivities = [
{ type: "completed", text: "Completed Unit 4: Business Emails", time: "Today, 10:27 AM" },
{ type: "completed", text: "Completed Unit 3: Formal Writing", time: "Yesterday, 3:45 PM" },
{ type: "started", text: "Started Learning Path: Business English", time: "Jan 15, 2025" },
{ type: "joined", text: "Joined Yimaru", time: "Jan 10, 2025" },
];
const infoFields = [
{ icon: Phone, label: "Phone", value: user.phone_number },
{ icon: Mail, label: "Email", value: user.email },
{ icon: Globe, label: "Country", value: user.country || "Ethiopia" },
{ icon: MapPin, label: "Region", value: user.region },
];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Back Link */} <div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link <Link
to="/users" to="/users"
className="inline-flex items-center gap-2 text-sm font-semibold text-grayScale-500 hover:text-brand-600" className="inline-flex items-center gap-2 text-sm font-medium text-grayScale-500 transition-colors hover:text-brand-600"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Back to Users Back to Users
</Link> </Link>
</div> </div>
<div className="text-xl font-semibold text-grayScale-900">User Detail</div> <div className="grid gap-6 lg:grid-cols-[340px_1fr]">
{/* ── Left column ── */}
<div className="grid gap-4 lg:grid-cols-3"> <div className="space-y-6">
{/* Left Column */} {/* Profile card */}
<div className="space-y-4 lg:col-span-1"> <Card className="overflow-hidden">
{/* Basic Information */} <div className="h-24 bg-gradient-to-br from-brand-600 via-brand-500 to-brand-400" />
<Card className="overflow-hidden shadow-sm"> <CardContent className="-mt-12 space-y-5 px-6 pb-6 pt-0">
<div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" /> <div className="flex flex-col items-center text-center">
<CardContent className="p-6 space-y-4"> <Avatar className="h-20 w-20 ring-4 ring-white shadow-soft">
<div className="flex items-center gap-3"> <AvatarImage src={user.profile_picture_url ?? undefined} alt={fullName} />
<div className="h-12 w-12 rounded-full bg-grayScale-200 flex items-center justify-center overflow-hidden"> <AvatarFallback className="bg-brand-100 text-brand-600 text-xl">
{user.profile_picture_url ? ( {initials}
<img </AvatarFallback>
src={user.profile_picture_url} </Avatar>
alt={fullName} <h2 className="mt-3 text-lg font-semibold text-grayScale-600">
className="h-12 w-12 object-cover" {fullName}
/> </h2>
) : (
<UserCircle2 className="h-12 w-12 text-grayScale-400" />
)}
</div>
<div className="min-w-0">
<div className="truncate text-lg font-semibold text-grayScale-900">{fullName}</div>
<div className="mt-1 flex items-center gap-2 text-xs text-grayScale-400">
<span>ID: {user.id}</span>
<span className="h-1 w-1 rounded-full bg-grayScale-300" />
<span className="inline-flex items-center gap-1">
<span
className={cn(
"h-2 w-2 rounded-full",
user.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive"
)}
/>
{user.status === "ACTIVE" ? "Active" : "Inactive"}
</span>
</div>
</div>
</div>
<div className="space-y-3 text-sm text-grayScale-600">
<div>
<div className="text-xs font-semibold text-grayScale-400">Phone</div>
<div className="font-semibold">{user.phone_number || "-"}</div>
</div>
<div>
<div className="text-xs font-semibold text-grayScale-400">Email</div>
<div className="font-semibold">{user.email || "-"}</div>
</div>
<div>
<div className="text-xs font-semibold text-grayScale-400">Region</div>
<div className="font-semibold">{user.region || "-"}</div>
</div>
<div>
<div className="text-xs font-semibold text-grayScale-400">Joined Date</div>
<div className="font-semibold">
{user.created_at ? new Date(user.created_at).toLocaleDateString() : "-"}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Subscription */}
<Card className="overflow-hidden shadow-sm">
<div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" />
<CardContent className="p-6 space-y-4">
<div className="flex justify-between items-center">
<div className="text-lg font-semibold text-grayScale-900">Subscription</div>
<Badge <Badge
className={cn( className={cn(
user.status === "ACTIVE" ? "bg-mint-500 text-white" : "bg-destructive text-white" "mt-1.5",
user.status === "ACTIVE"
? "bg-mint-500/15 text-mint-500 border border-mint-500/25"
: "bg-destructive/15 text-destructive border border-destructive/25"
)} )}
> >
{user.status === "ACTIVE" ? "Active" : "Inactive"} {user.status === "ACTIVE" ? "Active" : "Inactive"}
</Badge> </Badge>
</div> </div>
<div className="space-y-1 text-sm text-grayScale-600">
<div> <Separator />
<div className="text-xs font-semibold text-grayScale-400">Profile Completed</div>
<div className="font-semibold">{user.profile_completed ? "Yes" : "No"}</div> <div className="space-y-3">
{infoFields.map(({ icon: Icon, label, value }) => (
<div key={label} className="flex items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-grayScale-100">
<Icon className="h-4 w-4 text-grayScale-400" />
</div>
<div 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>
<div> <div className="min-w-0">
<div className="text-xs font-semibold text-grayScale-400">Preferred Language</div> <div className="text-[11px] font-medium uppercase tracking-wider text-grayScale-400">
<div className="font-semibold">{user.preferred_language || "-"}</div> 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>
</div> </div>
</CardContent> </CardContent>
</Card> </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> </div>
{/* Right Column */} {/* ── Right column ── */}
<div className="space-y-4 lg:col-span-2"> <div className="space-y-6">
{/* Learning Profile */} {/* Learning profile */}
<Card className="overflow-hidden shadow-sm"> <Card>
<div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" /> <CardHeader className="pb-3">
<CardContent className="p-6 space-y-4"> <div className="flex items-center gap-2">
<div className="grid gap-4 md:grid-cols-2"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-100/50">
<div> <GraduationCap className="h-4 w-4 text-brand-600" />
<div className="text-xs font-semibold text-grayScale-400">Education Level</div>
<div className="text-sm font-semibold text-grayScale-600">{user.education_level || "-"}</div>
</div> </div>
<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>
<div className="text-xs font-semibold text-grayScale-400">Age</div> <div className="text-[11px] font-medium uppercase tracking-wider text-grayScale-400 mb-1">
<div className="text-sm font-semibold text-grayScale-600">{user.age || "-"}</div> 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> </div>
<InfoItem
label="Preferred Topic"
value={user.favoutite_topic || "Business"}
/>
<TagItem label="Learning Path" value={user.learning_goal || "Business English"} />
<TagItem label="Challenge" value={user.language_challange || "Speaking"} />
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <Separator />
<div>
<div className="text-xs font-semibold text-grayScale-400">Nick Name</div>
<div className="text-sm font-semibold text-grayScale-600">{user.nick_name || "-"}</div>
</div>
<div>
<div className="text-xs font-semibold text-grayScale-400">Occupation</div>
<div className="text-sm font-semibold text-grayScale-600">{user.occupation || "-"}</div>
</div>
</div>
<div> <div>
<div className="text-xs font-semibold text-grayScale-400">Learning Goal</div> <div className="text-[11px] font-medium uppercase tracking-wider text-grayScale-400 mb-2">
<div className="text-sm font-semibold text-grayScale-600">{user.learning_goal || "-"}</div> Primary Goal
</div> </div>
<div className="rounded-xl bg-grayScale-100 p-4 text-sm leading-relaxed text-grayScale-600">
<div> {user.learning_goal ||
<div className="text-xs font-semibold text-grayScale-400">Language Challenge</div> "Improve business communication skills for professional advancement"}
<div className="text-sm font-semibold text-grayScale-600">{user.language_challange || "-"}</div> </div>
</div>
<div>
<div className="text-xs font-semibold text-grayScale-400">Favourite Topic</div>
<div className="text-sm font-semibold text-grayScale-600">{user.favoutite_topic || "-"}</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Status / Dates */} {/* Recent activity */}
<Card className="overflow-hidden shadow-sm"> <Card>
<div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" /> <CardHeader className="pb-3">
<CardContent className="p-6 space-y-3 text-sm text-grayScale-600"> <div className="flex items-center gap-2">
<div> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gold-100/60">
<div className="text-xs font-semibold text-grayScale-400">Last Login</div> <BookOpen className="h-4 w-4 text-gold-600" />
<div className="font-semibold">{user.last_login ? new Date(user.last_login).toLocaleString() : "-"}</div> </div>
<CardTitle>Recent Activity</CardTitle>
</div> </div>
<div> </CardHeader>
<div className="text-xs font-semibold text-grayScale-400">Updated At</div> <CardContent>
<div className="font-semibold">{user.updated_at ? new Date(user.updated_at).toLocaleString() : "-"}</div> <div className="relative space-y-0">
{recentActivities.map((activity, index) => {
const Icon = activityIcons[activity.type] ?? CheckCircle2;
const isLast = index === recentActivities.length - 1;
return (
<div key={index} className="relative flex gap-4 pb-5 last:pb-0">
{!isLast && (
<div className="absolute left-[15px] top-8 bottom-0 w-px bg-grayScale-200" />
)}
<div
className={cn(
"relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
activity.type === "completed"
? "bg-mint-100 text-mint-500"
: activity.type === "started"
? "bg-brand-100/50 text-brand-500"
: "bg-grayScale-100 text-grayScale-400"
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 pt-1">
<div className="text-sm font-medium text-grayScale-600">
{activity.text}
</div>
<div className="mt-0.5 text-xs text-grayScale-400">{activity.time}</div>
</div>
</div>
);
})}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -214,3 +334,27 @@ export function UserDetailPage() {
</div> </div>
); );
} }
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-[11px] font-medium uppercase tracking-wider text-grayScale-400 mb-1">
{label}
</div>
<div className="text-sm text-grayScale-600">{value}</div>
</div>
);
}
function TagItem({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-[11px] font-medium uppercase tracking-wider text-grayScale-400 mb-1">
{label}
</div>
<span className="inline-block rounded-lg border border-grayScale-200 bg-grayScale-100 px-2.5 py-1 text-xs font-medium text-grayScale-600">
{value}
</span>
</div>
);
}

View File

@ -1,35 +1,16 @@
import { Eye, Search } from "lucide-react" import { ChevronDown, ChevronLeft, ChevronRight, Search } from "lucide-react"
import { useEffect } from "react" import { useEffect, useState } from "react"
import { Link } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { Badge } from "../../components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { getUsers } from "../../api/users.api" import { getUsers } from "../../api/users.api"
import { mapUserApiToUser } from "../../types/user.types" import { mapUserApiToUser } from "../../types/user.types"
import { useUsersStore } from "../../zustand/userStore" import { useUsersStore } from "../../zustand/userStore"
type SubscriptionType = "Monthly" | "Free" | "Expired" | "3-Month" | "6-Month" | "N/A"
function subscriptionVariant(sub: SubscriptionType) {
switch (sub) {
case "Monthly":
return "warning"
case "Free":
return "secondary"
case "Expired":
return "destructive"
case "3-Month":
return "success"
case "6-Month":
return "info"
default:
return "default"
}
}
export function UsersListPage() { export function UsersListPage() {
const navigate = useNavigate()
const { const {
users, users,
total, total,
@ -43,14 +24,27 @@ export function UsersListPage() {
setSearch, setSearch,
} = useUsersStore() } = useUsersStore()
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [toggledStatuses, setToggledStatuses] = useState<Record<number, boolean>>({})
const [countryFilter, setCountryFilter] = useState("")
const [regionFilter, setRegionFilter] = useState("")
const [subscriptionFilter, setSubscriptionFilter] = useState("")
useEffect(() => { useEffect(() => {
const fetchUsers = async () => { const fetchUsers = async () => {
try { try {
const res = await getUsers(page, pageSize) const res = await getUsers(page, pageSize)
const apiUsers = res.data.data.users const apiUsers = res.data.data.users
setUsers(apiUsers.map(mapUserApiToUser)) const mapped = apiUsers.map(mapUserApiToUser)
setUsers(mapped)
setTotal(res.data.data.total) setTotal(res.data.data.total)
const initialStatuses: Record<number, boolean> = {}
mapped.forEach((u) => {
initialStatuses[u.id] = true
})
setToggledStatuses((prev) => ({ ...prev, ...initialStatuses }))
} catch (error) { } catch (error) {
console.error("Failed to fetch users:", error) console.error("Failed to fetch users:", error)
setUsers([]) setUsers([])
@ -63,145 +57,274 @@ export function UsersListPage() {
const pageCount = Math.max(1, Math.ceil(total / pageSize)) const pageCount = Math.max(1, Math.ceil(total / pageSize))
const safePage = Math.min(page, pageCount) const safePage = Math.min(page, pageCount)
const pageNumbers = Array.from({ length: pageCount }, (_, i) => i + 1)
const handlePrev = () => safePage > 1 && setPage(safePage - 1) const handlePrev = () => safePage > 1 && setPage(safePage - 1)
const handleNext = () => safePage < pageCount && setPage(safePage + 1) const handleNext = () => safePage < pageCount && setPage(safePage + 1)
return ( const handleSelectAll = (checked: boolean) => {
<Card className="shadow-none"> if (checked) {
<CardHeader className="pb-3"> setSelectedIds(new Set(users.map((u) => u.id)))
<CardTitle>User Management</CardTitle> } else {
setSelectedIds(new Set())
}
}
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> const handleSelectOne = (id: number, checked: boolean) => {
const newSet = new Set(selectedIds)
if (checked) {
newSet.add(id)
} else {
newSet.delete(id)
}
setSelectedIds(newSet)
}
const allSelected = users.length > 0 && selectedIds.size === users.length
const getPageNumbers = () => {
const pages: (number | string)[] = []
if (pageCount <= 7) {
for (let i = 1; i <= pageCount; i++) pages.push(i)
} else {
pages.push(1, 2, 3, 4)
if (safePage > 5) {
pages.push("...")
}
if (safePage > 4 && safePage < pageCount - 3) {
pages.push(safePage)
}
if (safePage < pageCount - 4) {
pages.push("...")
}
pages.push(pageCount)
}
return pages
}
const handleToggle = (id: number) => {
setToggledStatuses((prev) => ({ ...prev, [id]: !prev[id] }))
}
const handleRowClick = (userId: number) => {
navigate(`/users/${userId}`)
}
return (
<div className="bg-white rounded-lg border">
<div className="p-4 border-b">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="relative w-full md:max-w-sm"> <div className="relative w-full md:max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
<Input <Input
placeholder="Search by name or phone number" placeholder="Search by name, phone number"
className="pl-9" className="pl-9"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
</div> </div>
<div 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>
</CardHeader> </div>
<CardContent className="p-0"> <Table>
<Table> <TableHeader>
<TableHeader> <TableRow>
<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>Region</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 ? (
<TableRow> <TableRow>
<TableHead className="w-8">#</TableHead> <TableCell colSpan={6} className="text-center text-grayScale-400">
<TableHead>First Name</TableHead> No users found
<TableHead>Last Name</TableHead> </TableCell>
<TableHead>Nick Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Phone Number</TableHead>
<TableHead>Region</TableHead>
<TableHead>Country</TableHead>
<TableHead>Last Active</TableHead>
<TableHead>Subscription</TableHead>
<TableHead className="w-[56px]" />
</TableRow> </TableRow>
</TableHeader> ) : (
users.map((u) => {
<TableBody> const isActive = toggledStatuses[u.id] ?? false
{users.length === 0 ? ( return (
<TableRow> <TableRow
<TableCell colSpan={11} className="text-center text-grayScale-400"> key={u.id}
No users found className="cursor-pointer hover:bg-grayScale-50"
</TableCell> onClick={() => handleRowClick(u.id)}
</TableRow> >
) : ( <TableCell onClick={(e) => e.stopPropagation()}>
users.map((u, index) => ( <input
<TableRow key={u.id}> type="checkbox"
<TableCell className="text-grayScale-500">{(page - 1) * pageSize + index + 1}</TableCell> checked={selectedIds.has(u.id)}
<TableCell className="text-grayScale-600">{u.firstName}</TableCell> onChange={(e) => handleSelectOne(u.id, e.target.checked)}
<TableCell className="text-grayScale-600">{u.lastName}</TableCell> className="h-4 w-4 rounded border-grayScale-300 text-brand-600 focus:ring-brand-500"
<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() : "-"}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={subscriptionVariant("N/A")}>N/A</Badge> <div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarImage src={undefined} alt={`${u.firstName} ${u.lastName}`} />
<AvatarFallback className="bg-grayScale-200 text-grayScale-500">
{`${u.firstName?.[0] ?? ""}${u.lastName?.[0] ?? ""}`.toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<div className="font-medium text-grayScale-600">{u.firstName} {u.lastName}</div>
<div className="text-xs text-grayScale-400">{u.email || u.phoneNumber || "-"}</div>
</div>
</div>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-grayScale-500">{u.phoneNumber || "-"}</TableCell>
<Link <TableCell className="text-grayScale-500">{u.country || "-"}</TableCell>
to={`/users/${u.id}`} <TableCell className="text-grayScale-500">{u.region || "-"}</TableCell>
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border bg-white text-grayScale-500 hover:text-brand-600" <TableCell onClick={(e) => e.stopPropagation()}>
<button
type="button"
onClick={() => handleToggle(u.id)}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
isActive ? "bg-brand-500" : "bg-grayScale-200"
)}
> >
<Eye className="h-4 w-4" /> <span
</Link> className={cn(
"pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-sm ring-0 transition-transform",
isActive ? "translate-x-5" : "translate-x-0"
)}
/>
</button>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) )
)} })
</TableBody> )}
</Table> </TableBody>
</Table>
{/* Pagination */} <div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-xs text-grayScale-500"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <span>Row Per Page</span>
Rows per page <div className="relative">
<select <select
value={pageSize} value={pageSize}
onChange={(e) => { onChange={(e) => {
setPageSize(Number(e.target.value)) setPageSize(Number(e.target.value))
setPage(1) setPage(1)
}} }}
className="h-8 rounded-md border bg-white px-2 text-xs font-semibold text-grayScale-600 focus:outline-none" className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
> >
{[1, 2, 3, 4, 5, 10, 20, 30].map((size) => ( {[5, 10, 20, 30, 50].map((size) => (
<option key={size} value={size}> <option key={size} value={size}>
{size} {size}
</option> </option>
))} ))}
</select> </select>
of {total} <ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
</div> </div>
<span>Entries</span>
</div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
onClick={handlePrev} onClick={handlePrev}
disabled={safePage === 1} disabled={safePage === 1}
className={cn( className={cn(
"h-8 w-12 rounded-md border bg-white text-xs font-semibold text-grayScale-500", "h-8 w-8 flex items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === 1 && "opacity-50 cursor-not-allowed" safePage === 1 && "opacity-50 cursor-not-allowed"
)} )}
> >
Prev <ChevronLeft className="h-4 w-4" />
</button> </button>
{pageNumbers.map((n) => ( {getPageNumbers().map((n, idx) =>
typeof n === "string" ? (
<span key={`ellipsis-${idx}`} className="px-2 text-grayScale-400">
...
</span>
) : (
<button <button
key={n} key={n}
type="button" type="button"
onClick={() => setPage(n)} onClick={() => setPage(n)}
className={cn( className={cn(
"h-8 w-8 rounded-md border bg-white text-xs font-semibold text-grayScale-500", "h-8 w-8 rounded-md border text-sm font-medium",
n === safePage && "border-brand-200 bg-brand-100/40 text-brand-600" n === safePage
? "border-brand-500 bg-brand-500 text-white"
: "bg-white text-grayScale-600 hover:bg-grayScale-50"
)} )}
> >
{n} {n}
</button> </button>
))} )
)}
<button <button
onClick={handleNext} onClick={handleNext}
disabled={safePage === pageCount} disabled={safePage === pageCount}
className={cn( className={cn(
"h-8 w-12 rounded-md border bg-white text-xs font-semibold text-grayScale-500", "h-8 w-8 flex items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === pageCount && "opacity-50 cursor-not-allowed" safePage === pageCount && "opacity-50 cursor-not-allowed"
)} )}
> >
Next <ChevronRight className="h-4 w-4" />
</button> </button>
</div>
</div> </div>
</CardContent> </div>
</Card> </div>
) )
} }

View File

@ -1,20 +1,19 @@
export interface LoginRequest { export interface LoginRequest {
email: string; email: string
password: string; password: string
} }
export interface LoginResponseData { export interface LoginResponseData {
access_token: string; access_token: string
refresh_token: string; refresh_token: string
role: string; member_id: number
user_id: number; team_role: string
} }
export interface LoginResponse { export interface LoginResponse {
message: string; message: string
data: LoginResponseData; data: LoginResponseData
success: boolean; success: boolean
status_code: number; status_code: number
metadata: any | null; metadata: any | null
} }

435
src/types/course.types.ts Normal file
View 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
View 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
}

View File

@ -1,40 +1,36 @@
// This matches the API response 1:1 // This matches the API response 1:1
export interface UserApiDTO { export interface UserApiDTO {
ID: number id: number
FirstName: string first_name: string
LastName: string last_name: string
Gender: string gender: string
birth_day: string | null birth_day: string | null
Email: string email: string
PhoneNumber: string phone_number?: string
Role: string role: string
Age: number age_group: string
EducationLevel: string education_level: string
Country: string country: string
Region: string region: string
KnowledgeLevel: string nick_name: string
InitialAssessmentCompleted: boolean occupation: string
NickName: string learning_goal: string
Occupation: string language_goal: string
LearningGoal: string language_challange: string
LanguageGoal: string favoutite_topic: string
LanguageChallange: string
FavouriteTopic: string
EmailVerified: boolean email_verified: boolean
PhoneVerified: boolean phone_verified: boolean
Status: string status: string
LastLogin: string | null profile_completed: boolean
ProfileCompleted: boolean profile_picture_url: string
ProfilePictureURL: string preferred_language: string
PreferredLanguage: string
CreatedAt: string created_at: string
UpdatedAt: string | null
} }
export interface GetUsersResponse { export interface GetUsersResponse {
@ -60,15 +56,15 @@ export interface User {
} }
export const mapUserApiToUser = (u: UserApiDTO): User => ({ export const mapUserApiToUser = (u: UserApiDTO): User => ({
id: u.ID, id: u.id,
firstName: u.FirstName, firstName: u.first_name,
lastName: u.LastName, lastName: u.last_name,
nickName: u.NickName, nickName: u.nick_name,
email: u.Email, email: u.email,
phoneNumber: u.PhoneNumber, phoneNumber: u.phone_number ?? "",
region: u.Region, region: u.region,
country: u.Country, country: u.country,
lastLogin: u.LastLogin, lastLogin: null,
}) })
export interface UserProfileData { export interface UserProfileData {
@ -104,6 +100,8 @@ export interface UserProfileData {
created_at: string created_at: string
updated_at?: string | null // optional updated_at?: string | null // optional
age_group?: string
profile_completion_percentage?: number
} }
export interface UserProfileResponse { export interface UserProfileResponse {